Compare commits

..

8 Commits

Author SHA1 Message Date
Andrey Antukh
8c0d29bb79 Add minor compatibility adjustments for audit archive task 2026-02-27 11:50:52 +01:00
Dalai Felinto
0ff5574b12 Add the ability to import tokens from Linked Library
Add the option to import tokens from a linked library.

I know there are plans to link the tokens in together with the library.
Once this happens this patch can be reverted. Until then it helps a lot
to use a design system that relies on themes.

Before that someones would need to:
* Download the design system / add to their team.
* Open the file, download the tokens.

For every new file:
* Link the Design System library.
* Import the tokens file.

With this patch all you need to get started is to download the design
system and add to your team. From their importing the links is done on
the same pop-up that is used to import the tokens.

---

Technical considerations:

I try adding this as a dialog that is called once the library is
imported. I ran into a few issues though:

* To find whether the library has tokens (and thus show the dialog) I
  would need to extend library summary to include tokens.
* I couldn't find a reliable way to import the tokens after importing
  the library without resorting to a timer :/

I'm sure both of those hurdles are doable, I just wasted enough time
trying it to the point I decided on a different approach.

Signed-off-by: Dalai Felinto <dalai@blender.org>

📎 Fix minor issues and linter reports

📎 Reuse translations
2026-02-26 11:37:56 +01:00
Alexis Morin
05521a84d4 🌐 Add Canadian French 2026-02-26 10:26:10 +01:00
MkDev11
e30c01db26 🎉 Allow duplicating color and typography styles (#8449)
Add duplicate functionality for colors and typographies in the Assets
panel, matching the existing duplicate feature for components.

Changes:
- Add duplicate-color and duplicate-typography events in libraries
- Add Duplicate context menu option for colors
- Add Duplicate context menu option for typographies
- Update CHANGES.md

Closes #2912

Signed-off-by: mkdev11 <98430825+MkDev11@users.noreply.github.com>
2026-02-26 10:13:34 +01:00
Pablo Alba
c27f874e74 Show subscription type nitrate 2026-02-26 09:09:48 +01:00
Alejandro Alonso
901aa9bf09 Merge pull request #8403 from penpot/azazeln28-issue-13306-45-degree-rotated-board
🐛 Fix 45 rotated board doesn't show title properly
2026-02-26 07:57:12 +01:00
Aitor Moreno
0aea699482 🐛 Fix board title cropped using wrong side 2026-02-25 16:14:40 +01:00
Aitor Moreno
48d2135cf3 🐛 Fix 45 rotated board doesn't show title properly 2026-02-25 16:14:24 +01:00
46 changed files with 548 additions and 1179 deletions

2
.gitignore vendored
View File

@@ -69,7 +69,7 @@
/frontend/test-results/
/other/
/scripts/
/telemetry/
/nexus/
/tmp/
/vendor/**/target
/vendor/svgclean/bundle*.js

View File

@@ -10,10 +10,12 @@
### :sparkles: New features & Enhancements
- Allow duplicating color and typography styles (by @MkDev11) [Github #2912](https://github.com/penpot/penpot/issues/2912)
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112), [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248)
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
- Import Tokens from linked library [Github #8391](https://github.com/penpot/penpot/pull/8391)
### :bug: Bugs fixed
@@ -52,6 +54,7 @@
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
## 2.13.3

View File

@@ -2,6 +2,7 @@
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key
# DEPRECATED: only used for subscriptions

View File

@@ -103,6 +103,7 @@
[:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string]

View File

@@ -120,7 +120,7 @@
;; an external storage and data cleared.
(def ^:private schema:event
[:map {:title "event"}
[:map {:title "AuditEvent"}
[::type ::sm/text]
[::name ::sm/text]
[::profile-id ::sm/uuid]

View File

@@ -10,14 +10,11 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.transit :as t]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.client :as http]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[integrant.core :as ig]
[lambdaisland.uri :as u]
[promesa.exec :as px]))
;; This is a task responsible to send the accumulated events to
@@ -52,19 +49,18 @@
(defn- send!
[{:keys [::uri] :as cfg} events]
(let [token (tokens/generate cfg
{:iss "authentication"
:uid uuid/zero})
(let [skey (-> cfg ::setup/shared-keys :nexus)
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (str (cf/get :public-uri))
"cookie" (u/map->query-string {:auth-token token})}
"x-shared-key" (str "nexus " skey)}
params {:uri uri
:timeout 12000
:method :post
:headers headers
:body body}
resp (http/req! cfg params)]
(if (= (:status resp) 204)
true
(do
@@ -109,7 +105,7 @@
(def ^:private schema:handler-params
[:map
::db/pool
::setup/props
::setup/shared-keys
::http/client])
(defmethod ig/assert-key ::handler

View File

@@ -466,16 +466,17 @@
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
:nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
:nexus (cf/get :nexus-shared-key)
:nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock
{}
:app.loggers.audit.archive-task/handler
{::setup/props (ig/ref ::setup/props)
::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)}
{::setup/shared-keys (ig/ref ::setup/shared-keys)
::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)}
:app.loggers.audit.gc-task/handler
{::db/pool (ig/ref ::db/pool)}

View File

@@ -126,7 +126,8 @@
[cfg profile]
(try
(let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})]
(assoc profile :nitrate-licence (:valid nitrate-licence)))
(assoc-in profile [:props :nitrate-license]
(select-keys nitrate-licence [:valid :created-at])))
(catch Throwable cause
(l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)

View File

@@ -82,45 +82,37 @@
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(db/xact-lock! conn 0)
(when-not key
(l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate "
"all sessions on each restart, it is highly recommended setting up the "
"PENPOT_SECRET_KEY environment variable")))
(l/wrn :hint (str "using autogenerated secret-key, it will change "
"on each restart and will invalidate "
"all sessions on each restart, it is highly "
"recommended setting up the "
"PENPOT_SECRET_KEY environment variable")))
(let [secret (or key (generate-random-key))]
(-> (get-all-props conn)
(assoc :secret-key secret)
(assoc :tokens-key (keys/derive secret :salt "tokens"))
(update :instance-id handle-instance-id conn (db/read-only? pool)))))))
(sm/register! ::props [:map-of :keyword ::sm/any])
(defmethod ig/init-key ::shared-keys
[_ {:keys [::props] :as cfg}]
(let [secret (get props :secret-key)]
(d/without-nils
{:exporter
(let [key (or (get cfg :exporter)
(-> (keys/derive secret :salt "exporter")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "exporter key is disabled because empty string found")
nil)
(do
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key)))
(reduce (fn [keys id]
(let [key (or (get cfg id)
(-> (keys/derive secret :salt (name id))
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :id (name id) :hint "key is disabled because empty string found")
keys)
(do
(l/inf :id (name id) :hint "key initialized" :key (d/obfuscate-string key))
(assoc keys id key)))))
{}
[:exporter
:nitrate
:nexus])))
:nitrate
(let [key (or (get cfg :nitrate)
(-> (keys/derive secret :salt "nitrate")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "nitrate key is disabled because empty string found")
nil)
(do
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
key)))})))
(sm/register! ::props [:map-of :keyword ::sm/any])
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])

View File

@@ -119,13 +119,13 @@
:strict-session-cookies
:telemetry
:terms-and-privacy-checkbox
;; Only for developtment.
:tiered-file-data-storage
:token-base-font-size
:token-combobox
:token-color
:token-shadow
:token-tokenscript
:token-import-from-library
;; Only for developtment.
:transit-readable-response
:user-feedback
;; TODO: remove this flag.
@@ -181,7 +181,8 @@
:enable-token-color
:enable-token-shadow
:enable-inspect-styles
:enable-feature-fdata-objects-map])
:enable-feature-fdata-objects-map
:enable-token-import-from-library])
(defn parse
[& flags]

View File

@@ -5,7 +5,7 @@
;; Copyright (c) KALEIDOS INC
(ns app.common.schema
(:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys])
(:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys select-keys])
#?(:cljs (:require-macros [app.common.schema :refer [ignoring]]))
(:require
#?(:clj [malli.dev.pretty :as mdp])
@@ -93,6 +93,11 @@
[& items]
(apply mu/merge (map schema items)))
(defn select-keys
[s keys & {:as opts}]
(let [s (schema s)]
(mu/select-keys s keys opts)))
(defn assoc-key
"Add a key & value to a schema of type [:map]. If the first level node of the schema
is not a map, will do a depth search to find the first map node and add the key there."
@@ -138,10 +143,10 @@
(mu/optional-keys schema keys default-options)))
(defn required-keys
([schema]
(mu/required-keys schema nil default-options))
([schema keys]
(mu/required-keys schema keys default-options)))
([s]
(mu/required-keys (schema s) nil default-options))
([s keys]
(mu/required-keys (schema s) keys default-options)))
(defn transformer
[& transformers]
@@ -646,7 +651,7 @@
{:title "set"
:description "Set of Strings"
:error/message "should be a set of strings"
:gen/gen (-> kind sg/generator sg/set)
:gen/gen (sg/mcat (fn [_] (sg/generator kind)) sg/int)
:decode/string decode
:decode/json decode
:encode/string encode-string

View File

@@ -28,6 +28,7 @@
["date-fns/locale/eu$default" :as dfn-eu]
["date-fns/locale/fa-IR$default" :as dfn-fa-ir]
["date-fns/locale/fr$default" :as dfn-fr]
["date-fns/locale/fr-CA$default" :as dfn-fr-ca]
["date-fns/locale/gl$default" :as dfn-gl]
["date-fns/locale/he$default" :as dfn-he]
["date-fns/locale/hr$default" :as dfn-hr]
@@ -252,6 +253,7 @@
:fa dfn-fa-ir
:fa_ir dfn-fa-ir
:fr dfn-fr
:fr_ca dfn-fr-ca
:he dfn-he
:pt dfn-pt
:pt_pt dfn-pt

View File

@@ -43,9 +43,7 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
await setupTokensFileRender(page);
await page.getByRole("tab", { name: "Layers" }).click();
@@ -84,9 +82,7 @@ test.describe("Tokens: Apply token", () => {
await brTokenPillSM.click();
// Change token from dropdown
const brTokenOptionXl = borderRadiusSection
.getByRole("option", { name: "borderRadius.xl" })
.getByLabel("borderRadius.xl");
const brTokenOptionXl = borderRadiusSection.getByLabel("borderRadius.xl");
await expect(brTokenOptionXl).toBeVisible();
await brTokenOptionXl.click();
@@ -521,9 +517,7 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.nth(1).click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -577,9 +571,7 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -633,9 +625,7 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -690,9 +680,8 @@ test.describe("Tokens: Apply token", () => {
await dimensionXSTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = borderRadiusSection.getByRole("option", {
name: "dimension.xl",
});
const dimensionTokenOptionXl =
borderRadiusSection.getByLabel("dimension.xl");
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -761,9 +750,7 @@ test.describe("Tokens: Apply token", () => {
});
await tokenDropdown.click();
const widthOptionSmall = firstStrokeRow.getByRole("option", {
name: "width-small",
});
const widthOptionSmall = firstStrokeRow.getByLabel("width-small");
await expect(widthOptionSmall).toBeVisible();
await widthOptionSmall.click();
const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", {

View File

@@ -30,89 +30,6 @@ test.describe("Tokens - creation", () => {
});
});
test("User creates border radius token with combobox", async ({ page }) => {
const invalidValueError = "Invalid token value";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFileRender(page , {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const addTokenButton = tokensTabPanel.getByRole("button", {
name: `Add Token: Border Radius`,
});
await addTokenButton.click();
await expect(tokensUpdateCreateModal).toBeVisible();
// Placeholder checks
await expect(
tokensUpdateCreateModal.getByPlaceholder(
"Enter border radius token name",
),
).toBeVisible();
await expect(
tokensUpdateCreateModal.getByPlaceholder(
"Enter a value or alias with {alias}",
),
).toBeVisible();
// Elements
const nameField = tokensUpdateCreateModal.getByLabel("Name");
const valueField = tokensUpdateCreateModal.getByRole("combobox", {
name: "Value",
});
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
// Create first token
await nameField.fill("my-token");
await valueField.fill("1 + 2");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 3"),
).toBeVisible();
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(
tokensTabPanel.getByRole("button", { name: "my-token" }),
).toBeEnabled();
// Create second token referencing the first one using the combobox options
await addTokenButton.click();
await nameField.fill("my-token-2");
const toggleDropdownButton = tokensUpdateCreateModal.getByRole("button", {
name: "Open token list",
});
await toggleDropdownButton.click();
const option = page.getByRole("option", { name: "my-token" });
await expect(option).toBeVisible();
await option.click();
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 3"),
).toBeVisible();
await valueField.pressSequentially(" + 2");
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 5"),
).toBeVisible();
await valueField.pressSequentially(" + {");
await option.click();
await expect(
tokensUpdateCreateModal.getByText("Resolved value: 8"),
).toBeVisible();
});
test("User creates dimensions token", async ({ page }) => {
await testTokenCreationFlow(page, {
tokenLabel: "Dimensions",

View File

@@ -42,34 +42,6 @@ const createToken = async (page, type, name, textFieldName, value) => {
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
const createTokenCombobox = async (page, type, name, textFieldName, value) => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
flags: ["enable-token-shadow"],
});
// Create base token
await tokensTabPanel
.getByRole("button", { name: `Add Token: ${type}` })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(name);
const valueFill = tokensUpdateCreateModal.getByRole("combobox", {
name: textFieldName,
});
await valueFill.fill(value);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
const renameToken = async (page, oldName, newName) => {
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFileRender(page, { flags: ["enable-token-shadow"] });
@@ -431,21 +403,13 @@ test.describe("Remapping Tokens", () => {
test("User renames border radius token with alias references", async ({
page,
}) => {
const { tokensSidebar } = await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
const { tokensSidebar } = await setupTokensFileRender(page);
// Create base border radius token
await createTokenCombobox(
page,
"Border Radius",
"base-radius",
"Value",
"4",
);
await createToken(page, "Border Radius", "base-radius", "Value", "4");
// Create derived border radius token
await createTokenCombobox(
await createToken(
page,
"Border Radius",
"card-radius",
@@ -481,21 +445,13 @@ test.describe("Remapping Tokens", () => {
tokensUpdateCreateModal,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFileRender(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
} = await setupTokensFileRender(page);
// Create base border radius token
await createTokenCombobox(
page,
"Border Radius",
"radius-sm",
"Value",
"4",
);
await createToken(page, "Border Radius", "radius-sm", "Value", "4");
// Create derived border radius token
await createTokenCombobox(
await createToken(
page,
"Border Radius",
"button-radius",

View File

@@ -288,6 +288,7 @@ export async function compileTranslations() {
"es",
"fa",
"fr",
"fr_CA",
"he",
"sr",
"nb_NO",

View File

@@ -2,6 +2,9 @@
(:require
[app.main.data.modal :as modal]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.util.dom :as dom]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -14,4 +17,12 @@
(rx/map (fn [connectivity]
(modal/show popup-type (or connectivity {}))))))))
(defn go-to-nitrate-cc
[]
(st/emit! (dom/open-new-window "/control-center/")))
(defn go-to-nitrate-billing
[]
(st/emit! (rt/nav-raw :href "/control-center/licenses/billing")))

View File

@@ -221,6 +221,24 @@
(pcb/delete-color id))]
(rx/of (dch/commit-changes changes))))))
(defn duplicate-color
[file-id color-id]
(assert (uuid? file-id) "expected valid uuid for `file-id`")
(assert (uuid? color-id) "expected valid uuid for `color-id`")
(ptk/reify ::duplicate-color
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
color (ctl/get-color data color-id)
new-color (-> color
(assoc :id (uuid/next))
(d/without-nils)
(ctc/check-library-color))
changes (-> (pcb/empty-changes it)
(pcb/add-color new-color))]
(rx/of (dch/commit-changes changes))))))
;; FIXME: this should be deleted
(defn add-media
[media]
@@ -350,6 +368,23 @@
(pcb/delete-typography id))]
(rx/of (dch/commit-changes changes))))))
(defn duplicate-typography
[file-id typography-id]
(assert (uuid? file-id) "expected valid uuid for `file-id`")
(assert (uuid? typography-id) "expected valid uuid for `typography-id`")
(ptk/reify ::duplicate-typography
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
typography (get-in data [:typographies typography-id])
new-typography (-> typography
(assoc :id (uuid/next))
(ctt/check-typography))
changes (-> (pcb/empty-changes it)
(pcb/add-typography new-typography))]
(rx/of (dch/commit-changes changes))))))
(defn- add-component2
"This is the second step of the component creation."
([selected]

View File

@@ -302,9 +302,8 @@
on-create-org-click
(mf/use-fn
(fn []
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(if (dm/get-in profile [:props :nitrate-license :valid])
(dnt/go-to-nitrate-cc)
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))]
[:> dropdown-menu* props
@@ -548,9 +547,8 @@
on-create-org-click
(mf/use-fn
(fn []
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(if (dm/get-in profile [:props :nitrate-license :valid])
(dnt/go-to-nitrate-cc)
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))]
(if empty?
[:div {:class (stl/css :nitrate-orgs-empty)}
@@ -1088,7 +1086,7 @@
[:*
(if (contains? cf/flags :nitrate)
(when-not (:nitrate-licence profile)
(when-not (dm/get-in profile [:props :nitrate-license :valid])
[:> nitrate-sidebar* {:profile profile}])
(when (contains? cf/flags :subscriptions)
(if (show-subscription-dashboard-banner? profile)

View File

@@ -17,7 +17,6 @@
[:map
[:class {:optional true} :string]
[:tooltip-class {:optional true} [:maybe :string]]
[:type {:optional true} [:maybe [:enum "button" "submit" "reset"]]]
[:icon-class {:optional true} :string]
[:icon
[:and :string [:fn #(contains? icon-list %)]]]
@@ -30,7 +29,7 @@
(mf/defc icon-button*
{::mf/schema schema:icon-button
::mf/memo true}
[{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class type] :rest props}]
[{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class] :rest props}]
(let [variant
(d/nilv variant "primary")
@@ -48,7 +47,6 @@
props
(mf/spread-props props
{:class [class button-class]
:type (d/nilv type "button")
:aria-labelledby tooltip-id})]
[:> tooltip* {:content aria-label

View File

@@ -11,7 +11,6 @@ $mint-250: #00d1b8;
$mint-700: #426158;
$mint-150-60: #7efff599;
$mint-250-10: #00d1b81a;
$mint-250-70: #00d1b8b3;
$green-200: #a7e8d9;
$green-500: #2d9f8f;
@@ -34,7 +33,6 @@ $purple-500: #a977d1;
$purple-600: #8c33eb;
$purple-700: #6911d4;
$purple-600-10: #8c33eb1a;
$purple-600-70: #8c33ebb3;
$purple-700-60: #6911d499;
$aqua-200: #ddf7ff;
@@ -79,7 +77,6 @@ $grayish-red: #bfbfbf;
--color-accent-quaternary: #{$pink-400};
--color-accent-overlay: #{$purple-700-60};
--color-accent-select: #{$purple-600-10};
--color-accent-background-select: #{$purple-600-70};
--color-accent-action: #{$purple-400};
--color-accent-action-hover: #{$purple-500};
--color-accent-off: #{$gray-50};
@@ -131,7 +128,6 @@ $grayish-red: #bfbfbf;
--color-accent-quaternary: #{$pink-400};
--color-accent-overlay: #{$mint-150-60};
--color-accent-select: #{$mint-250-10};
--color-accent-background-select: #{$mint-250-70};
--color-accent-action: #{$purple-400};
--color-accent-action-hover: #{$purple-500};
--color-accent-off: #{$gray-50};

View File

@@ -35,8 +35,6 @@
(def ^:private schema:options-dropdown
[:map
[:ref {:optional true} fn?]
[:class {:optional true} :string]
[:wrapper-ref {:optional true} :any]
[:on-click fn?]
[:options [:vector schema:option]]
[:selected {:optional true} :any]
@@ -62,7 +60,6 @@
(case type
:group
[:li {:class (stl/css :group-option)
:role "presentation"
:key (weak-key option)}
[:> icon*
{:icon-id i/arrow-down
@@ -75,7 +72,7 @@
[:hr {:key (weak-key option) :class (stl/css :option-separator)}]
:empty
[:li {:key (weak-key option) :class (stl/css :option-empty) :role "presentation"}
[:li {:key (weak-key option) :class (stl/css :option-empty)}
(get option :label)]
;; Token option
@@ -86,7 +83,6 @@
:name name
:resolved (get option :resolved-value)
:ref ref
:role "option"
:focused (= id focused)
:on-click on-click}]
@@ -98,7 +94,6 @@
:aria-label (get option :aria-label)
:icon (get option :icon)
:ref ref
:role "option"
:focused (= id focused)
:dimmed (true? (:dimmed option))
:on-click on-click}]))))
@@ -106,16 +101,15 @@
(mf/defc options-dropdown*
{::mf/schema schema:options-dropdown}
[{:keys [ref on-click options selected focused empty-to-end align wrapper-ref class] :rest props}]
[{:keys [ref on-click options selected focused empty-to-end align] :rest props}]
(let [align
(d/nilv align :left)
props
(mf/spread-props props
{:class [class (stl/css-case :option-list true
:left-align (= align :left)
:right-align (= align :right))]
:ref wrapper-ref
{:class (stl/css-case :option-list true
:left-align (= align :left)
:right-align (= align :right))
:tab-index "-1"
:role "listbox"})

View File

@@ -40,7 +40,6 @@
:id id
:on-click on-click
:data-id id
:aria-label name
:data-testid "dropdown-option"}
(if selected

View File

@@ -4,10 +4,6 @@
//
// Copyright (c) KALEIDOS INC
@use "ds/typography.scss" as t;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@mixin textEllipsis {
display: block;
max-width: 99%;
@@ -24,73 +20,3 @@
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/// Custom Scrollbar Mixin
/// @param {Color} $thumb-color - Base thumb color
/// @param {Color} $thumb-hover-color - Thumb color on hover
/// @param {Length} $size - Scrollbar size (width/height)
/// @param {Length} $radius - Thumb border radius
/// @param {Length} $border - Inner transparent border size
/// @param {Bool} $include-selection - Include ::selection styles
/// @param {Bool} $include-placeholder - Include placeholder styles
@mixin custom-scrollbar(
$thumb-color: #aab5ba4d,
$thumb-hover-color: #aab5bab3,
$size: $sz-12,
$radius: $br-8,
$border: $b-2,
$include-selection: true,
$include-placeholder: true
) {
// Firefox
scrollbar-width: thin;
scrollbar-color: #{$thumb-color} transparent;
&:hover {
scrollbar-color: #{$thumb-hover-color} transparent;
}
// Webkit (legacy support)
&::-webkit-scrollbar {
background: transparent;
cursor: pointer;
width: $size;
height: $size;
}
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $thumb-color;
background-clip: content-box;
border: $border solid transparent;
border-radius: $radius;
&:hover {
background-color: $thumb-hover-color;
}
}
@if $include-selection {
&::selection {
background: var(--color-accent-background-select);
color: var(--color-static-white);
}
}
@if $include-placeholder {
&::placeholder {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}
// Legacy webkit
&::-webkit-input-placeholder {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}
}
}

View File

@@ -360,6 +360,11 @@
(let [route (mf/deref refs/route)
authenticated? (da/is-authenticated? profile)
nitrate-license (dm/get-in profile [:props :nitrate-license])
nitrate? (and (contains? cf/flags :nitrate)
(:valid nitrate-license))
params-subscription
(-> route :params :query :subscription)
@@ -390,7 +395,9 @@
(ct/format-inst (:created-at profile) "d MMMM, yyyy")
subscribed-since
(ct/format-inst (:start-date subscription) "d MMMM, yyyy")
(if nitrate?
(ct/format-inst (:created-at nitrate-license) "d MMMM, yyyy")
(ct/format-inst (:start-date subscription) "d MMMM, yyyy"))
go-to-pricing-page
(mf/use-fn
@@ -468,60 +475,73 @@
[:div {:class (stl/css :your-subscription)}
[:h3 {:class (stl/css :plan-section-title)} (tr "subscription.settings.section-plan")]
(case subscription-type
"professional"
[:> plan-card* {:card-title (tr "subscription.settings.professional")
:benefits [(tr "subscription.settings.professional.storage-benefit"),
(tr "subscription.settings.professional.autosave-benefit"),
(tr "subscription.settings.professional.teams-editors-benefit")]}]
(if nitrate?
;; TODO add translations for this texts when we have the definitive ones
[:> plan-card* {:card-title "Business Nitrate"
:card-title-icon i/character-b
:benefits-title "Loren ipsum",
:benefits ["Loren ipsum",
"Loren ipsum",
"Loren ipsum"]
:cta-text-with-icon "Control Center"
:cta-link-with-icon dnt/go-to-nitrate-cc
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link dnt/go-to-nitrate-billing}]
(case subscription-type
"professional"
[:> plan-card* {:card-title (tr "subscription.settings.professional")
:benefits [(tr "subscription.settings.professional.storage-benefit"),
(tr "subscription.settings.professional.autosave-benefit"),
(tr "subscription.settings.professional.teams-editors-benefit")]}]
"unlimited"
(if subscription-is-trial?
[:> plan-card* {:card-title (tr "subscription.settings.unlimited-trial")
:card-title-icon i/character-u
:benefits-title (tr "subscription.settings.benefits.all-professional-benefits"),
:benefits [(tr "subscription.settings.unlimited.storage-benefit")
(tr "subscription.settings.unlimited.autosave-benefit"),
(tr "subscription.settings.unlimited.bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments
:cta-text-trial (tr "subscription.settings.add-payment-to-continue")
:cta-link-trial go-to-payments
:editors (-> profile :props :subscription :quantity)}]
"unlimited"
(if subscription-is-trial?
[:> plan-card* {:card-title (tr "subscription.settings.unlimited-trial")
:card-title-icon i/character-u
:benefits-title (tr "subscription.settings.benefits.all-professional-benefits"),
:benefits [(tr "subscription.settings.unlimited.storage-benefit")
(tr "subscription.settings.unlimited.autosave-benefit"),
(tr "subscription.settings.unlimited.bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments
:cta-text-trial (tr "subscription.settings.add-payment-to-continue")
:cta-link-trial go-to-payments
:editors (-> profile :props :subscription :quantity)}]
[:> plan-card* {:card-title (tr "subscription.settings.unlimited")
:card-title-icon i/character-u
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits")
:benefits [(tr "subscription.settings.unlimited.storage-benefit"),
(tr "subscription.settings.unlimited.autosave-benefit"),
(tr "subscription.settings.unlimited.bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments
:editors (-> profile :props :subscription :quantity)}])
[:> plan-card* {:card-title (tr "subscription.settings.unlimited")
:card-title-icon i/character-u
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits")
:benefits [(tr "subscription.settings.unlimited.storage-benefit"),
(tr "subscription.settings.unlimited.autosave-benefit"),
(tr "subscription.settings.unlimited.bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments
:editors (-> profile :props :subscription :quantity)}])
"enterprise"
(if subscription-is-trial?
[:> plan-card* {:card-title (tr "subscription.settings.enterprise-trial")
:card-title-icon i/character-e
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits"),
:benefits [(tr "subscription.settings.enterprise.unlimited-storage-benefit"),
(tr "subscription.settings.enterprise.autosave"),
(tr "subscription.settings.enterprise.capped-bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments
:cta-text-trial (tr "subscription.settings.add-payment-to-continue")
:cta-link-trial go-to-payments}]
[:> plan-card* {:card-title (tr "subscription.settings.enterprise")
:card-title-icon i/character-e
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits"),
:benefits [(tr "subscription.settings.enterprise.unlimited-storage-benefit"),
(tr "subscription.settings.enterprise.autosave"),
(tr "subscription.settings.enterprise.capped-bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments}]))
"enterprise"
(if subscription-is-trial?
[:> plan-card* {:card-title (tr "subscription.settings.enterprise-trial")
:card-title-icon i/character-e
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits"),
:benefits [(tr "subscription.settings.enterprise.unlimited-storage-benefit"),
(tr "subscription.settings.enterprise.autosave"),
(tr "subscription.settings.enterprise.capped-bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments
:cta-text-trial (tr "subscription.settings.add-payment-to-continue")
:cta-link-trial go-to-payments}]
[:> plan-card* {:card-title (tr "subscription.settings.enterprise")
:card-title-icon i/character-e
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits"),
:benefits [(tr "subscription.settings.enterprise.unlimited-storage-benefit"),
(tr "subscription.settings.enterprise.autosave"),
(tr "subscription.settings.enterprise.capped-bill")]
:cta-text (tr "subscription.settings.manage-your-subscription")
:cta-link go-to-payments}])))
[:div {:class (stl/css :membership-container)}
(when (and subscribed-since (not= subscription-type "professional"))
(when (or nitrate?
(and subscribed-since (not= subscription-type "professional")))
[:div {:class (stl/css :membership)}
[:> icon* {:class (stl/css :subscription-member)
:icon-id "crown"
@@ -582,7 +602,7 @@
:show-button-cta (= subscription-type "professional")}])
;; TODO add translations for this texts when we have the definitive ones
(when (and (contains? cf/flags :nitrate) (not (:nitrate-licence profile)))
(when (and (contains? cf/flags :nitrate) (not nitrate?))
[:> plan-card* {:card-title "Business Nitrate"
:card-title-icon i/character-n
:price-value "$25"

View File

@@ -13,8 +13,10 @@
[app.common.types.components-list :as ctkl]
[app.common.types.file :as ctf]
[app.common.types.library :as ctl]
[app.common.types.tokens-lib :as ctob]
[app.common.types.typographies-list :as ctyl]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.dashboard :as dd]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
@@ -36,6 +38,7 @@
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
[app.main.ui.hooks :as h]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.tokens.import-from-library]
[app.util.color :as uc]
[app.util.dom :as dom]
[app.util.i18n :refer [c tr]]
@@ -180,6 +183,12 @@
[summary]
(boolean (:is-empty summary)))
(defn- has-tokens?
"Check if library has tokens to be imported"
[{:keys [data]}]
(when-let [tokens-lib (get data :tokens-lib)]
(not (ctob/empty-lib? tokens-lib))))
(mf/defc libraries-tab*
{::mf/props :obj
::mf/private true}
@@ -230,14 +239,18 @@
(keep library-names))))
(sort-by (comp str/lower :name))))
linked-libraries-ids (mf/with-memo [linked-libraries]
(into #{} (map :id) linked-libraries))
linked-libraries-ids
(mf/with-memo [linked-libraries]
(into #{} d/xf:map-id linked-libraries))
importing*
(mf/use-state nil)
importing* (mf/use-state nil)
sample-libraries [{:id "penpot-design-system", :name "Design system example"}
{:id "wireframing-kit", :name "Wireframe library"}
{:id "whiteboarding-kit", :name "Whiteboarding Kit"}]
sample-libraries
(mf/with-memo []
[{:id "penpot-design-system", :name "Design system example"}
{:id "wireframing-kit", :name "Wireframe library"}
{:id "whiteboarding-kit", :name "Whiteboarding Kit"}])
change-search-term
@@ -267,6 +280,17 @@
(st/emit! (dwl/unlink-file-from-library file-id library-id)
(dwl/sync-file file-id library-id)))))
import-tokens
(mf/use-fn
(mf/deps file-id)
(fn [event]
(let [library-id (some-> (dom/get-current-target event)
(dom/get-data "library-id")
(uuid/parse))]
(st/emit! (modal/show
:tokens/import-from-library {:file-id file-id
:library-id library-id})))))
on-delete-accept
(mf/use-fn
(mf/deps file-id)
@@ -332,8 +356,12 @@
:on-click publish}])]
(for [{:keys [id name data connected-to connected-to-names] :as library} linked-libraries]
(let [disabled? (some #(contains? linked-libraries-ids %) connected-to)]
[:div {:class (stl/css :section-list-item)
(let [disabled? (some #(contains? linked-libraries-ids %) connected-to)
has-tokens? (and (has-tokens? library)
(contains? cf/flags :token-import-from-library))]
[:div {:class (if has-tokens?
(stl/css :section-list-item-double-icon)
(stl/css :section-list-item))
:key (dm/str id)
:data-testid "library-item"}
[:div {:class (stl/css :item-content)}
@@ -348,6 +376,15 @@
[:span {:class (stl/css :connected-to-values)} (str/join ", " connected-to-names)]
[:span ")"]])])]]
(when ^boolean has-tokens?
[:> icon-button*
{:type "button"
:aria-label (tr "workspace.tokens.import-tokens")
:icon i/import-export
:data-library-id (dm/str id)
:variant "secondary"
:on-click import-tokens}])
[:> icon-button* {:type "button"
:aria-label (tr "workspace.libraries.unlink-library-btn")
:icon i/detach

View File

@@ -116,6 +116,11 @@
border-radius: $br-8;
}
.section-list-item-double-icon {
@extend .section-list-item;
grid-template-columns: 1fr auto auto;
}
.item-content {
height: fit-content;
}

View File

@@ -93,6 +93,12 @@
(dwl/sync-file file-id file-id :colors color-id)
(dwu/commit-undo-transaction undo-id))))))
duplicate-color
(mf/use-fn
(mf/deps file-id color-id)
(fn []
(st/emit! (dwl/duplicate-color file-id color-id))))
rename-color-clicked
(mf/use-fn
(mf/deps read-only? local?)
@@ -247,7 +253,10 @@
{:name (tr "workspace.assets.edit")
:id "assets-edit-color"
:handler edit-color-clicked})
(when-not (or multi-colors? multi-assets?)
{:name (tr "workspace.assets.duplicate")
:id "assets-duplicate-color"
:handler duplicate-color})
{:name (tr "workspace.assets.delete")
:id "assets-delete-color"
:handler delete-color}

View File

@@ -377,6 +377,12 @@
(dwl/sync-file file-id file-id :typographies (:id @state))
(dwu/commit-undo-transaction undo-id))))))
handle-duplicate-typography
(mf/use-fn
(mf/deps file-id @state)
(fn []
(st/emit! (dwl/duplicate-typography file-id (:id @state)))))
editing-id (:edit-typography local-data)
renaming-id (:rename-typography local-data)
@@ -440,6 +446,11 @@
:id "assets-edit-typography"
:handler handle-edit-typography-clicked})
(when-not (or multi-typographies? multi-assets?)
{:name (tr "workspace.assets.duplicate")
:id "assets-duplicate-typography"
:handler handle-duplicate-typography})
{:name (tr "workspace.assets.delete")
:id "assets-delete-typography"
:handler handle-delete-typography}

View File

@@ -92,6 +92,19 @@
(def ^:private xf:map-type (map :type))
(def ^:private xf:mapcat-type-to-options (mapcat type->options))
(defn fixed-decimal-value
"Fixes the amount of decimals that are kept"
([value]
(fixed-decimal-value value 2))
([value decimals]
(cond
(string? value)
(fixed-decimal-value (parse-double value) decimals)
(number? value)
(parse-double (.toFixed value decimals)))))
(mf/defc measures-menu*
[{:keys [ids values applied-tokens type shapes]}]
(let [token-numeric-inputs
@@ -300,7 +313,7 @@
(mf/deps ids)
(fn [value]
(if (or (string? value) (number? value))
(do
(let [value (fixed-decimal-value value)]
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (udw/increase-rotation ids value)))
(st/emit! (udw/trigger-bounding-box-cloaking ids)

View File

@@ -0,0 +1,92 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.import-from-library
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
(mf/defc import-modal-library*
{::mf/register modal/components
::mf/register-as :tokens/import-from-library}
[all-props]
(let [{:keys [file-id library-id]}
(js->clj all-props :keywordize-keys true)
library-file-ref (mf/with-memo [library-id]
(l/derived (fn [state]
(dm/get-in state [:files library-id :data]))
st/state))
library-data (mf/deref library-file-ref)
show-libraries-dialog
(mf/use-fn
(mf/deps file-id)
(fn []
(modal/hide!)
(modal/show! :libraries-dialog {:file-id file-id})))
cancel
(mf/use-fn
(fn []
(show-libraries-dialog)))
import
(mf/use-fn
(mf/deps file-id library-id library-data)
(fn []
(let [tokens-lib (:tokens-lib library-data)]
(st/emit! (dwtl/import-tokens-lib tokens-lib)))
(show-libraries-dialog)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog)}
[:> icon-button* {:class (stl/css :close-btn)
:on-click cancel
:aria-label (tr "labels.close")
:variant "ghost"
:icon i/close}]
[:div {:class (stl/css :modal-header)}
[:> heading* {:level 2
:id "modal-title"
:typography "headline-large"
:class (stl/css :modal-title)}
(tr "modals.import-library-tokens.title")]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "p" :typography t/body-medium} (tr "modals.import-library-tokens.description")]]
[:> context-notification* {:type :context
:appearance "neutral"
:level "default"
:is-html true}
(tr "workspace.tokens.import-warning")]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:> button* {:on-click cancel
:type "button"
:variant "secondary"}
(tr "labels.cancel")]
[:> button* {:on-click import
:type "button"
:variant "primary"}
(tr "modals.import-library-tokens.import")]]]]]))

View File

@@ -0,0 +1,70 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/typography.scss" as t;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
.close-btn {
position: absolute;
inset-block-start: var(--sp-s);
inset-inline-end: var(--sp-s);
}
.modal-overlay {
--modal-title-foreground-color: var(--color-foreground-primary);
--modal-text-foreground-color: var(--color-foreground-secondary);
@extend .modal-overlay-base;
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset-inline-start: 0;
inset-block-start: 0;
block-size: 100%;
inline-size: 100%;
background-color: var(--overlay-color);
}
.modal-dialog {
@extend .modal-container-base;
inline-size: 100%;
max-inline-size: 32rem;
max-block-size: unset;
user-select: none;
position: relative;
}
.modal-header {
margin-block-end: var(--sp-xxl);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
@include t.use-typography("headline-medium");
color: var(--modal-title-foreground-color);
word-break: break-word;
}
.modal-content {
@include t.use-typography("body-large");
color: var(--modal-text-foreground-color);
}
.modal-footer {
margin-block-start: var(--sp-xxl);
gap: var(--sp-s);
}
.action-buttons {
@extend .modal-action-btns;
gap: var(--sp-s);
}

View File

@@ -2,7 +2,6 @@
(:require
[app.common.data.macros :as dm]
[app.main.ui.workspace.tokens.management.forms.controls.color-input :as color]
[app.main.ui.workspace.tokens.management.forms.controls.combobox :as combobox]
[app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox :as fonts]
[app.main.ui.workspace.tokens.management.forms.controls.input :as input]
[app.main.ui.workspace.tokens.management.forms.controls.select :as select]))
@@ -17,6 +16,4 @@
(dm/export fonts/fonts-combobox*)
(dm/export fonts/composite-fonts-combobox*)
(dm/export select/select-indexed*)
(dm/export combobox/value-combobox*)
(dm/export select/select-indexed*)

View File

@@ -1,308 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.forms.controls.combobox
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.data.style-dictionary :as sd]
[app.main.data.tokenscript :as ts]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :as ds]
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation :refer [use-navigation]]
[app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown :refer [use-floating-dropdown]]
[app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp]
[app.main.ui.workspace.tokens.management.forms.controls.utils :as csu]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- resolve-value
[tokens prev-token token-name value]
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value value
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
tokens
(-> tokens
;; Remove previous token when renaming a token
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens-interactive tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(mf/defc value-combobox*
[{:keys [name tokens token token-type empty-to-end ref] :rest props}]
(let [form (mf/use-ctx fc/context)
token-name (get-in @form [:data :name] nil)
touched?
(and (contains? (:data @form) name)
(get-in @form [:touched name]))
error
(get-in @form [:errors name])
value
(get-in @form [:data name] "")
is-open* (mf/use-state false)
is-open (deref is-open*)
listbox-id (mf/use-id)
filter-term* (mf/use-state "")
filter-term (deref filter-term*)
options-ref (mf/use-ref nil)
dropdown-ref (mf/use-ref nil)
internal-ref (mf/use-ref nil)
nodes-ref (mf/use-ref nil)
wrapper-ref (mf/use-ref nil)
icon-button-ref (mf/use-ref nil)
ref (or ref internal-ref)
raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type)
filtered-tokens-by-type
(mf/with-memo [raw-tokens-by-type token-type]
(csu/filter-tokens-for-input raw-tokens-by-type token-type))
visible-options
(mf/with-memo [filtered-tokens-by-type token]
(if token
(tp/remove-self-token filtered-tokens-by-type token)
filtered-tokens-by-type))
dropdown-options
(mf/with-memo [visible-options filter-term]
(csu/get-token-dropdown-options visible-options (str "{" filter-term)))
set-option-ref
(mf/use-fn
(fn [node]
(let [state (mf/ref-val nodes-ref)
state (d/nilv state #js {})
id (dom/get-data node "id")
state (obj/set! state id node)]
(mf/set-ref-val! nodes-ref state))))
toggle-dropdown
(mf/use-fn
(mf/deps is-open)
(fn [event]
(dom/prevent-default event)
(swap! is-open* not)
(let [input-node (mf/ref-val ref)]
(dom/focus! input-node))))
resolve-stream
(mf/with-memo [token]
(if (contains? token :value)
(rx/behavior-subject (:value token))
(rx/subject)))
on-option-enter
(mf/use-fn
(mf/deps value resolve-stream name)
(fn [id]
(let [input-node (mf/ref-val ref)
input-value (dom/get-input-value input-node)
{:keys [value cursor]} (tp/select-option-by-id id options-ref input-node input-value)]
(when value
(fm/on-input-change form name value true)
(rx/push! resolve-stream value)
(js/setTimeout
(fn []
(set! (.-selectionStart input-node) cursor)
(set! (.-selectionEnd input-node) cursor))
0))
(reset! filter-term* "")
(reset! is-open* false))))
{:keys [focused-id on-key-down]}
(use-navigation
{:is-open is-open
:nodes-ref nodes-ref
:options dropdown-options
:toggle-dropdown toggle-dropdown
:is-open* is-open*
:on-enter on-option-enter})
on-change
(mf/use-fn
(mf/deps resolve-stream name form)
(fn [event]
(let [node (dom/get-target event)
value (dom/get-input-value node)
token (tp/active-token value node)]
(fm/on-input-change form name value)
(rx/push! resolve-stream value)
(if token
(do
(reset! is-open* true)
(reset! filter-term* (:partial token)))
(do
(reset! is-open* false)
(reset! filter-term* ""))))))
on-option-click
(mf/use-fn
(mf/deps value resolve-stream ref name)
(fn [event]
(let [input-node (mf/ref-val ref)
node (dom/get-current-target event)
id (dom/get-data node "id")
input-value (dom/get-input-value input-node)
{:keys [value cursor]} (tp/select-option-by-id id options-ref input-node input-value)]
(reset! filter-term* "")
(dom/focus! input-node)
(when value
(reset! is-open* false)
(fm/on-input-change form name value true)
(rx/push! resolve-stream value)
(js/setTimeout
(fn []
(set! (.-selectionStart input-node) cursor)
(set! (.-selectionEnd input-node) cursor))
0)))))
hint*
(mf/use-state {})
hint
(deref hint*)
props
(mf/spread-props props {:on-change on-change
:value value
:variant "comfortable"
:hint-message (:message hint)
:on-key-down on-key-down
:hint-type (:type hint)
:ref ref
:role "combobox"
:aria-activedescendant focused-id
:aria-controls listbox-id
:aria-expanded is-open
:slot-end
(when (some? @filtered-tokens-by-type)
(mf/html
[:> icon-button*
{:variant "action"
:icon i/arrow-down
:ref icon-button-ref
:tooltip-class (stl/css :button-tooltip)
:class (stl/css :invisible-button)
:tab-index "-1"
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
:on-mouse-down dom/prevent-default
:on-click toggle-dropdown}]))})
props
(if (and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)
{:keys [style ready?]} (use-floating-dropdown is-open wrapper-ref dropdown-ref)]
(mf/with-effect [resolve-stream tokens token name token-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token token-name))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
((:error/fn error) (:error/value error))))))
(rx/subs! (fn [{:keys [error value]}]
(let [touched? (get-in @form [:touched name])]
(when touched?
(if error
(do
(swap! form assoc-in [:extra-errors name] {:message error})
(reset! hint* {:message error :type "error"}))
(let [message (tr "workspace.tokens.resolved-value" value)]
(swap! form update :extra-errors dissoc name)
(reset! hint* {:message message :type "hint"}))))))))]
(fn []
(rx/dispose! subs))))
(mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref dropdown-options))
(mf/with-effect [is-open* ref wrapper-ref]
(when is-open
(let [handler (fn [event]
(let [wrapper-node (mf/ref-val wrapper-ref)
dropdown-node (mf/ref-val dropdown-ref)
target (dom/get-target event)]
(when (and wrapper-node dropdown-node
(not (dom/child? target wrapper-node))
(not (dom/child? target dropdown-node)))
(reset! is-open* false))))]
(.addEventListener js/document "mousedown" handler)
(fn []
(.removeEventListener js/document "mousedown" handler)))))
[:div {:ref wrapper-ref}
[:> ds/input* props]
(when ^boolean is-open
(let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)]
(mf/portal
(mf/html
[:> options-dropdown* {:on-click on-option-click
:class (stl/css :dropdown)
:style {:visibility (if ready? "visible" "hidden")
:left (:left style)
:top (or (:top style) "unset")
:bottom (or (:bottom style) "unset")
:width (:width style)}
:id listbox-id
:options options
:focused focused-id
:selected nil
:align :right
:empty-to-end empty-to-end
:wrapper-ref dropdown-ref
:ref set-option-ref}])
(dom/get-body))))]))

View File

@@ -1,16 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/mixins.scss" as *;
.dropdown {
position: fixed;
max-block-size: $sz-400;
overflow-y: auto;
@include custom-scrollbar();
}

View File

@@ -1,119 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation
(:require
[app.main.ui.workspace.tokens.management.forms.controls.utils :refer [focusable-options]]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[rumext.v2 :as mf]))
(defn- focusable-option?
[option]
(and (:id option)
(not= :group (:type option))
(not= :separator (:type option))))
(defn- first-focusable-id
[options]
(some #(when (focusable-option? %) (:id %)) options))
(defn next-focus-id
[focusables focused-id direction]
(let [ids (vec (map :id focusables))
idx (.indexOf (clj->js ids) focused-id)
idx (if (= idx -1) -1 idx)
next-idx (case direction
:down (min (dec (count ids)) (inc idx))
:up (max 0 (dec (if (= idx -1) 0 idx))))]
(nth ids next-idx nil)))
(defn use-navigation
[{:keys [is-open options nodes-ref is-open* toggle-dropdown on-enter]}]
(let [focused-id* (mf/use-state nil)
focused-id (deref focused-id*)
on-key-down
(mf/use-fn
(mf/deps is-open focused-id)
(fn [event]
(let [up? (kbd/up-arrow? event)
down? (kbd/down-arrow? event)
enter? (kbd/enter? event)
esc? (kbd/esc? event)
open-dropdown (kbd/is-key? event "{")
close-dropdown (kbd/is-key? event "}")
options (if (delay? options) @options options)]
(cond
down?
(do
(dom/prevent-default event)
(let [focusables (focusable-options options)]
(cond
is-open
(when (seq focusables)
(let [next-id (next-focus-id focusables focused-id :down)]
(reset! focused-id* next-id)))
(seq focusables)
(do
(toggle-dropdown event)
(reset! focused-id* (first-focusable-id focusables)))
:else
nil)))
up?
(when is-open
(dom/prevent-default event)
(let [focusables (focusable-options options)
next-id (next-focus-id focusables focused-id :up)]
(reset! focused-id* next-id)))
open-dropdown
(reset! is-open* true)
close-dropdown
(reset! is-open* false)
enter?
(do
(when (and is-open focused-id)
(let [focusables (focusable-options options)]
(dom/prevent-default event)
(when (some #(= (:id %) focused-id) focusables)
(on-enter focused-id)))))
esc?
(do
(dom/prevent-default event)
(reset! is-open* false))
:else nil))))]
;; Initial focus on first option
(mf/with-effect [is-open options]
(when is-open
(let [opts (if (delay? options) @options options)
focusables (focusable-options opts)
ids (set (map :id focusables))]
(when (and (seq focusables)
(not (contains? ids focused-id)))
(reset! focused-id* (:id (first focusables)))))))
;; auto scroll when key down
(mf/with-effect [focused-id nodes-ref]
(when focused-id
(let [nodes (mf/ref-val nodes-ref)
node (obj/get nodes focused-id)]
(when node
(dom/scroll-into-view-if-needed!
node {:block "nearest"
:inline "nearest"})))))
{:focused-id focused-id
:on-key-down on-key-down}))

View File

@@ -1,71 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown
(:require
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(defn use-floating-dropdown [is-open wrapper-ref dropdown-ref]
(let [position* (mf/use-state nil)
position (deref position*)
ready* (mf/use-state false)
ready (deref ready*)
calculate-position
(fn [node]
(let [combobox-rect (dom/get-bounding-rect node)
dropdown-node (mf/ref-val dropdown-ref)
dropdown-height (if dropdown-node
(-> (dom/get-bounding-rect dropdown-node)
(:height))
0)
windows-height (-> (dom/get-window-size)
(:height))
space-below (- windows-height (:bottom combobox-rect))
open-up? (and dropdown-height
(> dropdown-height space-below))
position (if open-up?
{:bottom (str (- windows-height (:top combobox-rect) 12) "px")
:left (str (:left combobox-rect) "px")
:width (str (:width combobox-rect) "px")
:placement :top}
{:top (str (+ (:bottom combobox-rect) 4) "px")
:left (str (:left combobox-rect) "px")
:width (str (:width combobox-rect) "px")
:placement :bottom})]
(reset! ready* true)
(reset! position* position)))]
(mf/with-effect [is-open dropdown-ref wrapper-ref]
(when is-open
(let [handler (fn [event]
(let [dropdown-node (mf/ref-val dropdown-ref)
target (dom/get-target event)]
(when (or (nil? dropdown-node)
(not (instance? js/Node target))
(not (.contains dropdown-node target)))
(js/requestAnimationFrame
(fn []
(let [wrapper-node (mf/ref-val wrapper-ref)]
(reset! ready* true)
(calculate-position wrapper-node)))))))]
(handler nil)
(.addEventListener js/window "resize" handler)
(.addEventListener js/window "scroll" handler true)
(fn []
(.removeEventListener js/window "resize" handler)
(.removeEventListener js/window "scroll" handler true)))))
{:style position
:ready? ready
:recalculate calculate-position}))

View File

@@ -1,89 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.forms.controls.token-parsing
(:require
[app.main.ui.ds.controls.select :refer [get-option]]
[app.util.dom :as dom]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn extract-partial-token
[value cursor]
(let [text-before (subs value 0 cursor)
last-open (str/last-index-of text-before "{")
last-close (str/last-index-of text-before "}")]
(when (and last-open (or (nil? last-close) (> last-open last-close)))
{:start last-open
:end (or (str/index-of value "}" last-open) cursor)
:partial (subs text-before (inc last-open))})))
(defn find-active-token-range
"Returns {:start :end} for the token surrounding the cursor.
A token starts with '{', contains no spaces, and may be incomplete.
Returns nil if no valid token is active."
[value cursor]
(let [start (.lastIndexOf value "{" (dec cursor))]
(when (>= start 0)
(let [between (subs value (inc start) cursor)]
(when-not (re-find #"\s" between)
(let [after (subs value (inc start))
close-index (.indexOf after "}")
close-pos (when (>= close-index 0)
(+ (inc start) close-index))
space-index (.indexOf after " ")
space-pos (when (>= space-index 0)
(+ (inc start) space-index))
open-index (.indexOf after "{")
open-pos (when (>= open-index 0)
(+ (inc start) open-index))
candidates (->> [space-pos open-pos close-pos]
(remove nil?)
(sort))
end (or (first candidates) cursor)
inside-token? (and (>= cursor start) (< cursor end))]
{:start start
:end (if inside-token?
(inc end)
end)}))))))
(defn replace-active-token
"Replaces the token at the cursor with `{new-name}`.
Returns {:value :cursor} with the updated value and new cursor position."
[value cursor new-name]
(let [new-token (str "{" new-name "}")]
(if-let [{:keys [start end]} (find-active-token-range value cursor)]
{:value (str (subs value 0 start)
new-token
(subs value end))
:cursor (+ start (count new-token))}
{:value (str (subs value 0 cursor)
new-token
(subs value cursor))
:cursor (+ cursor (count new-token))})))
(defn active-token [value input-node]
(let [cursor (dom/selection-start input-node)]
(extract-partial-token value cursor)))
(defn remove-self-token [filtered-options current-token]
(let [group (:type current-token)
current-id (:id current-token)
filtered-options (deref filtered-options)]
(update filtered-options group
(fn [options]
(remove #(= (:id %) current-id) options)))))
(defn select-option-by-id
[id options-ref input-node value]
(let [cursor (dom/selection-start input-node)
options (mf/ref-val options-ref)
options (if (delay? options) @options options)
option (get-option options id)
name (:name option)]
(replace-active-token value cursor name)))

View File

@@ -1,102 +0,0 @@
(ns app.main.ui.workspace.tokens.management.forms.controls.utils
(:require
[app.common.data.macros :as dm]
[app.common.types.token :as cto]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]))
(defn- token->dropdown-option
[token]
{:id (str (get token :id))
:type :token
:resolved-value (get token :value)
:name (get token :name)})
(defn- generate-dropdown-options
[tokens no-sets]
(let [non-empty-groups
(->> tokens
(filter (fn [[_ items]] (seq items))))]
(if (empty? non-empty-groups)
[{:type :empty
:label (if no-sets
(tr "ds.inputs.numeric-input.no-applicable-tokens")
(tr "ds.inputs.numeric-input.no-matches"))}]
(->> non-empty-groups
(keep (fn [[type items]]
(when (seq? items)
(cons {:group true
:type :group
:id (dm/str "group-" (name type))
:name (name type)}
(map token->dropdown-option items)))))
(interpose [{:separator true
:id "separator"
:type :separator}])
(apply concat)
(vec)
(not-empty)))))
(defn- extract-partial-brace-text
[s]
(when-let [start (str/last-index-of s "{")]
(subs s (inc start))))
(defn- filter-token-groups-by-name
[tokens filter-text]
(let [lc-filter (str/lower filter-text)]
(into {}
(keep (fn [[group tokens]]
(let [filtered (filter #(str/includes? (str/lower (:name %)) lc-filter) tokens)]
(when (seq filtered)
[group filtered]))))
tokens)))
(defn- sort-groups-and-tokens
"Sorts both the groups and the tokens inside them alphabetically.
Input:
A map where:
- keys are groups (keywords or strings, e.g. :dimensions, :colors)
- values are vectors of token maps, each containing at least a :name key
Example input:
{:dimensions [{:name \"tres\"} {:name \"quini\"}]
:colors [{:name \"azul\"} {:name \"rojo\"}]}
Output:
A sorted map where:
- groups are ordered alphabetically by key
- tokens inside each group are sorted alphabetically by :name
Example output:
{:colors [{:name \"azul\"} {:name \"rojo\"}]
:dimensions [{:name \"quini\"} {:name \"tres\"}]}"
[groups->tokens]
(into (sorted-map) ;; ensure groups are ordered alphabetically by their key
(for [[group tokens] groups->tokens]
[group (sort-by :name tokens)])))
(defn get-token-dropdown-options
[tokens filter-term]
(delay
(let [tokens (if (delay? tokens) @tokens tokens)
sorted-tokens (sort-groups-and-tokens tokens)
partial (extract-partial-brace-text filter-term)
options (if (seq partial)
(filter-token-groups-by-name sorted-tokens partial)
sorted-tokens)
no-sets? (empty? sorted-tokens)]
(generate-dropdown-options options no-sets?))))
(defn filter-tokens-for-input
[raw-tokens input-type]
(delay
(-> (deref raw-tokens)
(select-keys (get cto/tokens-by-input input-type))
(not-empty))))
(defn focusable-options [options]
(filter #(= (:type %) :token) options))

View File

@@ -8,10 +8,8 @@
(:require
[app.common.data :as d]
[app.common.types.tokens-lib :as ctob]
[app.config :as cf]
[app.main.refs :as refs]
[app.main.ui.workspace.tokens.management.forms.color :as color]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.font-family :as font-family]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.main.ui.workspace.tokens.management.forms.shadow :as shadow]
@@ -41,10 +39,7 @@
:token token})
text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")})
text-decoration-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")})
font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})
border-radius-props (if (contains? cf/flags :token-combobox)
(mf/spread-props props {:input-component token.controls/value-combobox*})
props)]
font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})]
(case token-type
:color [:> color/form* props]
@@ -54,5 +49,4 @@
:text-case [:> generic/form* text-case-props]
:text-decoration [:> generic/form* text-decoration-props]
:font-weight [:> generic/form* font-weight-props]
:border-radius [:> generic/form* border-radius-props]
[:> generic/form* props])))

View File

@@ -21,7 +21,6 @@
[app.main.data.workspace.tokens.remapping :as remap]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
@@ -98,10 +97,6 @@
(and (:name token) (:value token))
(assoc (:name token) token)))
active-tokens-by-type
(mf/with-memo [tokens]
(delay (ctob/group-by-type tokens)))
schema
(mf/with-memo [tokens-tree-in-selected-set active-tab]
(make-schema tokens-tree-in-selected-set active-tab))
@@ -229,80 +224,78 @@
error-message (first error-messages)]
(swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))]
[(mf/provider muc/active-tokens-by-type) {:value active-tokens-by-type}
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(if (= action "edit")
(tr "workspace.tokens.edit-token" token-type)
(tr "workspace.tokens.create-token" token-type))]
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(if (= action "edit")
(tr "workspace.tokens.edit-token" token-type)
(tr "workspace.tokens.create-token" token-type))]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:trim true
:auto-focus true}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:trim true
:auto-focus true}]]
[:div {:class (stl/css :input-row)}
(case value-type
:indexed
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:value-subfield value-subfield
:handle-toggle on-toggle-tab}]
[:div {:class (stl/css :input-row)}
(case value-type
:indexed
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:value-subfield value-subfield
:handle-toggle on-toggle-tab}]
:composite
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:handle-toggle on-toggle-tab}]
:composite
[:> input-component
{:token token
:tokens tokens
:tab active-tab
:handle-toggle on-toggle-tab}]
[:> input-component
{:placeholder (or input-value-placeholder
(tr "workspace.tokens.token-value-enter"))
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:token-type token-type
:tokens tokens}])]
[:> input-component
{:placeholder (or input-value-placeholder
(tr "workspace.tokens.token-value-enter"))
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}])]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]]))
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -55,7 +55,7 @@
(defn top?
[cur cand]
(let [closey? (mth/close? (:y cand) (:y cur))]
(let [closey? (mth/close? (:y cand) (:y cur) 0.01)]
(cond
(and closey? (< (:x cand) (:x cur))) cand
closey? cur
@@ -64,13 +64,19 @@
(defn right?
[cur cand]
(let [closex? (mth/close? (:x cand) (:x cur))]
(let [closex? (mth/close? (:x cand) (:x cur) 0.01)]
(cond
(and closex? (< (:y cand) (:y cur))) cand
closex? cur
(> (:x cand) (:x cur)) cand
:else cur)))
(defn title-transform-use-width?
[{:keys [rotation] :as shape}]
(let [side (mth/ceil (/ (- rotation 45) 90))
use-width? (even? side)]
use-width?))
(defn title-transform
[{:keys [points] :as shape} zoom grid-edition?]
(let [leftmost (->> points (reduce left?))

View File

@@ -129,13 +129,15 @@
(fn [_]
(on-frame-leave (:id frame))))
main-instance? (ctk/main-instance? frame)
is-variant? (:is-variant-container frame)
main-instance? (ctk/main-instance? frame)
is-variant? (:is-variant-container frame)
text-width (* (:width frame) zoom)
show-icon? (and (or (:use-for-thumbnail frame) is-grid-edition main-instance? is-variant?)
(not (<= text-width 15)))
text-pos-x (if show-icon? 15 0)
use-width? (vwu/title-transform-use-width? frame)
text-width (* (if use-width? (:width frame) (:height frame)) zoom)
show-icon? (and (or (:use-for-thumbnail frame) is-grid-edition main-instance? is-variant?)
(not (<= text-width 15)))
text-pos-x (if show-icon? 15 0)
edition* (mf/use-state false)
edition? (deref edition*)
@@ -178,7 +180,6 @@
(when (kbd/enter? event) (accept-edit))
(when (kbd/esc? event) (cancel-edit))))]
(when (not (:hidden frame))
[:g.frame-title {:id (dm/str "frame-title-" (:id frame))
:data-edit-grid is-grid-edition

View File

@@ -277,16 +277,6 @@
(when (and (some? node) (some? (unchecked-get node "select")))
(.select ^js node)))
(defn selection-start
[^js node]
(when (some? node)
(.-selectionStart node)))
(defn set-selection-range!
[^js node start end]
(when (some? node)
(.setSelectionRange node start end)))
(defn ^boolean equals?
[^js node-a ^js node-b]

View File

@@ -31,6 +31,7 @@
{:label "Dutch (community)" :value "nl"}
{:label "Euskera (community)" :value "eu"}
{:label "Français (community)" :value "fr"}
{:label "Français - Canada (community)" :value "fr_CA"}
{:label "Gallego (Community)" :value "gl"}
{:label "Hausa (Community)" :value "ha"}
{:label "Hrvatski (Community)" :value "hr"}

View File

@@ -1153,6 +1153,20 @@ msgstr "Type to search results"
msgid "dashboard.unpublish-shared"
msgstr "Unpublish Library"
#:src/app/main/ui/workspace/tokens/import_from_library.cljs
msgid "modals.import-library-tokens.title"
msgstr "Import tokens from library?"
#:src/app/main/ui/workspace/tokens/import_from_library.cljs
msgid "modals.import-library-tokens.description"
msgstr ""
"The library has tokens and themes which "
"are likely used by its components."
#:src/app/main/ui/workspace/tokens/import_from_library.cljs
msgid "modals.import-library-tokens.import"
msgstr "Import tokens"
#: src/app/main/ui/settings/options.cljs:74
msgid "dashboard.update-settings"
msgstr "Update settings"