Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-08-28 11:22:52 +02:00
commit 0a14a1f3db
29 changed files with 258 additions and 158 deletions

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ opencode.json
!AGENTS.md !AGENTS.md
!CODE_OF_CONDUCT.md !CODE_OF_CONDUCT.md
!SECURITY.md !SECURITY.md
!HIGHLIGHTS.md
/*.png /*.png
/*.svg /*.svg
/*.sql /*.sql

26
HIGHLIGHTS.md Normal file
View File

@ -0,0 +1,26 @@
# HIGHLIGHTS
## 2.17.0
- Background blur is here
- WebGL rendering gets stronger
- MCP connection status and more
- Design tokens: more visible, more user-friendly
## 2.16.0
- Design tokens in the design panel
- Major community contributions
- WebGL rendering (beta)
## 2.15.0
- AI connected to real design context
- Multi-directional workflow
- Your stack, your model, your decision

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -10,7 +10,7 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro
<h2 id="projects-management">Projects</h2> <h2 id="projects-management">Projects</h2>
<p>Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.</p> <p>Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.</p>
<p>If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Your Penpot") remain private to you.</p> <p>If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Personal Projects") remain private to you.</p>
<figure> <figure>
<img src="/img/files-projects/01-projects.webp" alt="Projects view in dashboard" /> <img src="/img/files-projects/01-projects.webp" alt="Projects view in dashboard" />
</figure> </figure>

View File

@ -16,7 +16,7 @@ member is allowed to do depends on their permissions.</p>
<h3>Select team</h3> <h3>Select team</h3>
<p>At the top left of the dashboard you can find the team selector.</p> <p>At the top left of the dashboard you can find the team selector.</p>
<p>"Your Penpot" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.</p> <p>"Personal Projects" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.</p>
<figure><img src="/img/teams/team-selector.webp" alt="Teams selector" /></figure> <figure><img src="/img/teams/team-selector.webp" alt="Teams selector" /></figure>
<h3>Create teams</h3> <h3>Create teams</h3>

View File

@ -569,7 +569,7 @@ test.describe("Tokens - creation", () => {
}); });
await selectDropdown.click(); await selectDropdown.click();
const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); const fontOption = tokensUpdateCreateModal.getByRole('img', { name: 'ABeeZee' })
await expect(fontOption).toBeVisible(); await expect(fontOption).toBeVisible();
await fontOption.click(); await fontOption.click();
@ -583,7 +583,7 @@ test.describe("Tokens - creation", () => {
name: "Search font", name: "Search font",
}); });
await searchField.fill("alme"); await searchField.fill("alme");
const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); const fontOption2 = tokensUpdateCreateModal.getByRole('img', {name: "Almendra Display"});
await expect(fontOption2).toBeVisible(); await expect(fontOption2).toBeVisible();
await fontOption2.click(); await fontOption2.click();
@ -1521,7 +1521,7 @@ test.describe("Tokens - creation", () => {
}); });
await selectDropdown.click(); await selectDropdown.click();
const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); const fontOption = tokensUpdateCreateModal.getByRole("img", {name: "ABeeZee"});
await expect(fontOption).toBeVisible(); await expect(fontOption).toBeVisible();
await fontOption.click(); await fontOption.click();
@ -1536,7 +1536,7 @@ test.describe("Tokens - creation", () => {
name: "Search font", name: "Search font",
}); });
await searchField.fill("alme"); await searchField.fill("alme");
const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); const fontOption2 = tokensUpdateCreateModal.getByRole("img", {name: "Almendra Display"});
await expect(fontOption2).toBeVisible(); await expect(fontOption2).toBeVisible();
await fontOption2.click(); await fontOption2.click();
await expect( await expect(

View File

@ -41,6 +41,11 @@ body {
scrollbar-width: thin; scrollbar-width: thin;
} }
::selection {
background: var(--color-accent-background-select);
color: var(--color-static-white);
}
img { img {
height: auto; height: auto;
width: 100%; width: 100%;

View File

@ -415,6 +415,7 @@
is-own? (= profile-id (:owner-id organization))] is-own? (= profile-id (:owner-id organization))]
(or (= perm "any") is-own?))) all-organizations) (or (= perm "any") is-own?))) all-organizations)
team (first (filter #(= (:id %) team-id) teams)) team (first (filter #(= (:id %) team-id) teams))
current-organization (:organization team)
on-confirm (fn [organization-id] on-confirm (fn [organization-id]
(st/emit! (add-team-to-organization {:team-id team-id (st/emit! (add-team-to-organization {:team-id team-id
:organization-id organization-id}))) :organization-id organization-id})))
@ -422,11 +423,11 @@
(fn [organizations-allowed] (fn [organizations-allowed]
(let [has-filtered? (< (count organizations) (count all-organizations)) (let [has-filtered? (< (count organizations) (count all-organizations))
extra-props (when has-filtered? extra-props (when has-filtered?
{:info-message-key "dashboard.select-organization-modal.permission-info"})] {:info-message-key "dashboard.select-organization-modal.permission-info-add"})]
(modal/show :select-organization-modal (modal/show :select-organization-modal
(merge {:organizations organizations (merge {:organizations organizations
:organizations-allowed organizations-allowed :organizations-allowed organizations-allowed
:current-organization-id (dm/get-in team [:organization :id]) :current-organization current-organization
:on-confirm on-confirm :on-confirm on-confirm
:team-id team-id :team-id team-id
:title-key "dashboard.select-organization-modal.title" :title-key "dashboard.select-organization-modal.title"
@ -509,11 +510,12 @@
:title (tr "dashboard.change-organization-modal.title")}) :title (tr "dashboard.change-organization-modal.title")})
(modal/show :select-organization-modal (modal/show :select-organization-modal
(merge {:organizations selectable-organizations (merge {:organizations selectable-organizations
:organizations-allowed organizations-allowed :organizations-allowed organizations-allowed
:current-organization-id current-organization-id :current-organization source-organization
:on-confirm on-confirm :on-confirm on-confirm
:team-id team-id :team-id team-id
:title-key "dashboard.change-organization-modal.title" :title-key "dashboard.change-organization-modal.title"
:description-key "dashboard.change-organization-modal.description"
:choose-key "dashboard.change-organization-modal.choose" :choose-key "dashboard.change-organization-modal.choose"
:placeholder-key "dashboard.change-organization-modal.select" :placeholder-key "dashboard.change-organization-modal.select"
:accept-key "dashboard.change-organization-modal.accept" :accept-key "dashboard.change-organization-modal.accept"

View File

@ -1699,6 +1699,7 @@
(dm/export dwgu/set-hover-guide) (dm/export dwgu/set-hover-guide)
;; Zoom ;; Zoom
(dm/export dwz/center-on-shape)
(dm/export dwz/reset-zoom) (dm/export dwz/reset-zoom)
(dm/export dwz/zoom-to-selected-shape) (dm/export dwz/zoom-to-selected-shape)
(dm/export dwz/start-zooming) (dm/export dwz/start-zooming)

View File

@ -175,7 +175,12 @@
(rx/mapcat (fn [_] (rx/mapcat (fn [_]
(rx/from (fonts/ensure-loaded! font-id font-variant-id)))) (rx/from (fonts/ensure-loaded! font-id font-variant-id))))
(rx/take-until (text-work-stopper stream)) (rx/take-until (text-work-stopper stream))
(rx/ignore) (rx/mapcat (fn [_]
(st/emit! (dwsh/update-shapes
ids
#(dissoc % :position-data)
{:save-undo? false}))
(rx/empty)))
(wrf/with-pending :font ids)))) (wrf/with-pending :font ids))))
;; -- Content helpers ;; -- Content helpers
@ -857,16 +862,16 @@
::resize-text-debounce-event))))) ::resize-text-debounce-event)))))
(rx/empty)))))) (rx/empty))))))
(defn save-font (defn save-default-font
[data] [data]
(ptk/reify ::save-font (ptk/reify ::save-default-font
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
(let [multiple? (->> data vals (d/seek #(= % :multiple)))] (let [multiple? (->> data vals (d/seek #(= % :multiple)))
font (dissoc data :typography-ref-id :typography-ref-file)]
(cond-> state (cond-> state
(not multiple?) (not multiple?)
(assoc-in [:workspace-global :default-font] (update :workspace-global assoc :default-font font))))))
(dissoc data :typography-ref-id :typography-ref-file)))))))
(defn apply-text-modifier (defn apply-text-modifier
[shape text-modifier] [shape text-modifier]

View File

@ -135,6 +135,32 @@
(effect [_ state _] (effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state)))) (dwvw/maybe-sync-workspace-local-viewport! state))))
(defn center-on-shape
"Pan the viewport to center on the shape with the given id without changing zoom."
[id]
(ptk/reify ::center-on-shape
ptk/UpdateEvent
(update [_ state]
(if (dwvw/render-context-lost? state)
state
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shape (get objects id)
srect (:selrect shape)]
(if (nil? srect)
state
(update state :workspace-local
(fn [{:keys [vbox] :as local}]
(let [cx (+ (:x srect) (/ (:width srect) 2))
cy (+ (:y srect) (/ (:height srect) 2))
new-x (- cx (/ (:width vbox) 2))
new-y (- cy (/ (:height vbox) 2))]
(update local :vbox assoc :x new-x :y new-y))))))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(def zoom-to-selected-shape (def zoom-to-selected-shape
(ptk/reify ::zoom-to-selected-shape (ptk/reify ::zoom-to-selected-shape
ptk/UpdateEvent ptk/UpdateEvent

View File

@ -221,7 +221,7 @@
(when (zero? (:refs new-state)) (when (zero? (:refs new-state))
(dom/remove! node)))) (dom/remove! node))))
(defn- add-font-css! (defn- add-font-css
"Creates a style element and attaches it to the dom." "Creates a style element and attaches it to the dom."
[id css] [id css]
(let [node (dom/create-element "style")] (let [node (dom/create-element "style")]
@ -284,7 +284,7 @@
(->> (request-gfont-css url) (->> (request-gfont-css url)
(rx/map process-gfont-css) (rx/map process-gfont-css)
(rx/tap #(on-loaded id)) (rx/tap #(on-loaded id))
(rx/subs! (partial add-font-css! id) (rx/subs! (partial add-font-css id)
#(when (fn? on-failed) (on-failed %)))) #(when (fn? on-failed) (on-failed %))))
nil))) nil)))
@ -324,7 +324,7 @@
(when (globals/browser?) (when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "custom") (log/dbg :hint "load-font" :font-id id :backend "custom")
(let [css (generate-custom-font-css font)] (let [css (generate-custom-font-css font)]
(add-font-css! id css) (add-font-css id css)
(when (fn? on-loaded) (when (fn? on-loaded)
(on-loaded))))) (on-loaded)))))

View File

@ -371,8 +371,8 @@
[:span {:class (stl/css :my-teams-icon)} [:span {:class (stl/css :my-teams-icon)}
[:> raw-svg* {:id penpot-logo-icon-subtle}]] [:> raw-svg* {:id penpot-logo-icon-subtle}]]
[:span {:class (stl/css :team-text) [:span {:class (stl/css :team-text)
:title (tr "dashboard.my-teams")} :title (tr "dashboard.other-teams")}
(tr "dashboard.my-teams")] (tr "dashboard.other-teams")]
(when (= default-team-id (:default-team-id organization)) (when (= default-team-id (:default-team-id organization))
tick-icon)] tick-icon)]
(when (seq organizations) (when (seq organizations)
@ -438,7 +438,7 @@
(when-not (contains? cf/flags :admin-console) (when-not (contains? cf/flags :admin-console)
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon])
[:span {:class (stl/css :team-text)} (if (contains? cf/flags :admin-console) (tr "dashboard.my-files") (tr "dashboard.your-penpot"))] [:span {:class (stl/css :team-text)} (if (contains? cf/flags :admin-console) (tr "dashboard.personal-projects") (tr "dashboard.your-penpot"))]
(when (= default-team-id (:id team)) (when (= default-team-id (:id team))
tick-icon)] tick-icon)]
@ -835,7 +835,7 @@
[:span {:class (stl/css :my-teams-icon-xxxl)} [:span {:class (stl/css :my-teams-icon-xxxl)}
[:> raw-svg* {:id penpot-logo-icon-subtle}]] [:> raw-svg* {:id penpot-logo-icon-subtle}]]
[:span {:class (stl/css :team-text)} [:span {:class (stl/css :team-text)}
(tr "dashboard.my-teams")]] (tr "dashboard.other-teams")]]
[:* [:*
[:> organization-avatar* {:organization current-organization :size "xxxl"}] [:> organization-avatar* {:organization current-organization :size "xxxl"}]
[:span {:class (stl/css :team-text)} [:span {:class (stl/css :team-text)}
@ -972,7 +972,7 @@
:team-name-no-logo nitrate?)} :team-name-no-logo nitrate?)}
(when-not nitrate? (when-not nitrate?
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon])
[:span {:class (stl/css :team-text)} (if nitrate? (tr "dashboard.my-files") (tr "dashboard.default-team-name"))]] [:span {:class (stl/css :team-text)} (if nitrate? (tr "dashboard.personal-projects") (tr "dashboard.default-team-name"))]]
(and (contains? cf/flags :subscriptions) (and (contains? cf/flags :subscriptions)
(not is-default?) (not is-default?)

View File

@ -915,34 +915,57 @@
(mf/defc select-organization-modal (mf/defc select-organization-modal
{::mf/register modal/components {::mf/register modal/components
::mf/register-as :select-organization-modal} ::mf/register-as :select-organization-modal}
[{:keys [organizations organizations-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key team-id]}] [{:keys [organizations organizations-allowed current-organization on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key description-key team-id]}]
(let [valid-organizations (mf/with-memo [organizations] (let [current-organization-id (:id current-organization)
(remove #(= (:id %) current-organization-id) organizations)) has-current-org? (some? current-organization)
options (mf/with-memo [valid-organizations organizations-allowed] valid-organizations (mf/with-memo [organizations current-organization-id]
(mapv (fn [organization] (remove #(= (:id %) current-organization-id) organizations))
(let [organization-id (:id organization) all-organizations (mf/with-memo [organizations current-organization]
;; organizations-allowed is a map of organization-id and a boolean indicating if it is allowed (cond-> organizations
enabled? (or (nil? organizations-allowed) (and has-current-org?
(true? (get organizations-allowed organization-id)))] (not (some #(= (:id %) current-organization-id) organizations)))
(cond-> {:id (str organization-id) (conj current-organization)))
:label (:name organization) options (mf/with-memo [valid-organizations organizations-allowed current-organization]
:disabled (not enabled?) (let [other-options
:dimmed (not enabled?) (mapv (fn [organization]
:avatar {:render-fn render-organization-combobox-avatar* (let [organization-id (:id organization)
:organization organization enabled? (or (nil? organizations-allowed)
:size "xl"}} (true? (get organizations-allowed organization-id)))]
(not enabled?) (cond-> {:id (str organization-id)
(assoc :title (tr "dashboard.team-organization.disabled-organization-tooltip"))))) :label (:name organization)
valid-organizations)) :disabled (not enabled?)
:dimmed (not enabled?)
:avatar {:render-fn render-organization-combobox-avatar*
:organization organization
:size "xl"}}
(not enabled?)
(assoc :title (tr "dashboard.team-organization.disabled-organization-tooltip")))))
valid-organizations)]
(if has-current-org?
(into [{:id (str current-organization-id)
:label (:name current-organization)
:avatar {:render-fn render-organization-combobox-avatar*
:organization current-organization
:size "xl"}}]
other-options)
other-options)))
form (fm/use-form :schema schema:organization-form :initial {}) initial-form (mf/with-memo [has-current-org? current-organization-id]
(if has-current-org?
{:selected-id (str current-organization-id)}
{}))
form (fm/use-form :schema schema:organization-form :initial initial-form)
warning-info* (mf/use-state nil) warning-info* (mf/use-state nil)
warning-info (deref warning-info*) warning-info (deref warning-info*)
selected-organization (mf/with-memo [warning-info valid-organizations] selected-organization (mf/with-memo [warning-info all-organizations]
(when warning-info (when warning-info
(d/seek #(= (:id %) (:organization-id warning-info)) valid-organizations))) (d/seek #(= (:id %) (:organization-id warning-info)) all-organizations)))
selected-id (dm/get-in @form [:data :selected-id])
disabled? (or (not (:valid @form))
(and has-current-org?
(= (str selected-id) (str current-organization-id))))
on-change on-change
(mf/use-fn (mf/use-fn
(mf/deps form team-id) (mf/deps form team-id)
@ -978,18 +1001,25 @@
[:div {:class (stl/css :modal-content :modal-select-organization-text)} (tr text-key)]) [:div {:class (stl/css :modal-content :modal-select-organization-text)} (tr text-key)])
[:div {:class (stl/css :modal-select-organization-body)} [:div {:class (stl/css :modal-select-organization-body)}
(when info-message-key (when (or description-key info-message-key)
[:div {:class (stl/css :modal-select-organization-info)} [:div {:class (stl/css :modal-select-organization-info)}
(tr info-message-key)]) (when description-key
[:div
(tr description-key)])
(when info-message-key
[:div
(tr info-message-key)])])
[:div {:class (stl/css :modal-select-organization-content)} [:div {:class (stl/css :modal-select-organization-content)}
(tr choose-key)] (tr choose-key)]
[:> combobox* {:id "selected-id" [:> combobox* {:id "selected-id"
:class (stl/css :team-member) :class (stl/css :team-member)
:options options :options options
:select-only true :select-only true
:default-selected (or (some-> (get-in @form [:data :selected-id]) str) "")
:placeholder (tr placeholder-key) :placeholder (tr placeholder-key)
:on-change on-change}] :on-change on-change
:default-selected (if has-current-org?
(str current-organization-id)
"")}]
;; Warning for external invitations ;; Warning for external invitations
(when (and warning-info (when (and warning-info
@ -1017,7 +1047,7 @@
{:class (stl/css :accept-btn) {:class (stl/css :accept-btn)
:variant "primary" :variant "primary"
:type "button" :type "button"
:disabled (not (:valid @form)) :disabled disabled?
:on-click on-confirm'} :on-click on-confirm'}
(tr accept-key)]]]]])) (tr accept-key)]]]]]))

View File

@ -963,7 +963,11 @@
.modal-select-organization-info { .modal-select-organization-info {
@include t.use-typography("body-medium"); @include t.use-typography("body-medium");
display: flex;
flex-direction: column;
gap: var(--sp-m);
color: var(--color-foreground-secondary); color: var(--color-foreground-secondary);
margin-block-end: var(--sp-xxl);
} }
.modal-select-organization-title { .modal-select-organization-title {

View File

@ -97,6 +97,7 @@
&::selection { &::selection {
background: var(--color-accent-background-select); background: var(--color-accent-background-select);
color: var(--color-static-white);
} }
&::placeholder { &::placeholder {

View File

@ -8,7 +8,6 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.config :as cf]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.persistence :as dps] [app.main.data.persistence :as dps]
@ -229,12 +228,8 @@
(st/emit! (dps/initialize-persistence) (st/emit! (dps/initialize-persistence)
(dpl/update-plugins-permissions-peek))) (dpl/update-plugins-permissions-peek)))
;; FLAG :font-preview — prefetch the preview sprite markup on workspace mount
;; (kept in memory, not the DOM) so the typography selector renders previews on
;; open with no network wait. Remove the flag check to drop the feature.
(mf/with-effect [] (mf/with-effect []
(when (contains? cf/flags :font-preview) (fonts/prefetch-preview-sprite!))
(fonts/prefetch-preview-sprite!)))
;; Setting the layout preset by its name ;; Setting the layout preset by its name
(mf/with-effect [layout-name] (mf/with-effect [layout-name]
@ -302,4 +297,3 @@
(when (uuid? file-id) (when (uuid? file-id)
[:> workspace* props]))) [:> workspace* props])))

View File

@ -30,18 +30,24 @@
;; --- Text Editor Rendering ;; --- Text Editor Rendering
(mf/defc block-component* (mf/defc block-component
[{:keys [block-props] :as props}] {::mf/wrap-props false}
(let [data (.-data ^js block-props) [props]
style (sts/generate-paragraph-styles (.-shape ^js block-props) data) (let [bprops (obj/get props "blockProps")
dir (:text-direction data "auto")] data (obj/get bprops "data")
style (sts/generate-paragraph-styles (obj/get bprops "shape")
(obj/get bprops "data"))
dir (:text-direction data "auto")]
[:div {:style style :dir dir} [:div {:style style :dir dir}
[:> draft/EditorBlock props]])) [:> draft/EditorBlock props]]))
(mf/defc selection-component* (mf/defc selection-component
[{:keys [children]}] {::mf/wrap-props false}
[:span {:style {:background "#ccc" :display "inline-block"}} children]) [props]
(let [children (obj/get props "children")]
[:span {:style {:background "#ccc" :display "inline-block"}} children]))
(defn- render-block (defn- render-block
[block shape] [block shape]
@ -49,7 +55,7 @@
(case type (case type
"unstyled" "unstyled"
#js {:editable true #js {:editable true
:component block-component* :component block-component
:props #js {:data (ted/get-editor-block-data block) :props #js {:data (ted/get-editor-block-data block)
:shape shape}} :shape shape}}
nil))) nil)))
@ -63,7 +69,7 @@
(sts/generate-text-styles shape data {:show-text? false}))) (sts/generate-text-styles shape data {:show-text? false})))
(def default-decorator (def default-decorator
(ted/create-decorator "PENPOT_SELECTION" selection-component*)) (ted/create-decorator "PENPOT_SELECTION" selection-component))
(def empty-editor-state (def empty-editor-state
(ted/create-editor-state nil default-decorator)) (ted/create-editor-state nil default-decorator))
@ -89,11 +95,12 @@
"bottom" "flex-end" "bottom" "flex-end"
nil)) nil))
(mf/defc text-shape-edit-html* (mf/defc text-shape-edit-html
{::mf/wrap [mf/memo] {::mf/wrap [mf/memo]
::mf/wrap-props false
::mf/forward-ref true} ::mf/forward-ref true}
[{:keys [shape]} _] [props _]
(let [{:keys [id content]} shape (let [{:keys [id content] :as shape} (obj/get props "shape")
state-map (mf/deref refs/workspace-editor-state) state-map (mf/deref refs/workspace-editor-state)
state (get state-map id empty-editor-state) state (get state-map id empty-editor-state)
@ -268,7 +275,8 @@
(-> (gpt/subtract pt box) (-> (gpt/subtract pt box)
(gpt/multiply zoom))))) (gpt/multiply zoom)))))
(mf/defc text-editor-svg* (mf/defc text-editor-svg
{::mf/wrap-props false}
[{:keys [shape modifiers]}] [{:keys [shape modifiers]}]
(let [shape-id (dm/get-prop shape :id) (let [shape-id (dm/get-prop shape :id)
modifiers (dm/get-in modifiers [shape-id :modifiers]) modifiers (dm/get-in modifiers [shape-id :modifiers])
@ -341,6 +349,6 @@
[:foreignObject {:x x :y y :width width :height height} [:foreignObject {:x x :y y :width width :height height}
[:div {:style style} [:div {:style style}
[:> text-shape-edit-html* [:& text-shape-edit-html
{:shape shape {:shape shape
:key (dm/str shape-id)}]]]])) :key (dm/str shape-id)}]]]]))

View File

@ -438,7 +438,7 @@
navigate-next navigate-next
(mf/use-fn (mf/use-fn
(mf/deps text-match-count) (mf/deps text-match-ids text-match-count)
(fn [_] (fn [_]
(when (pos? text-match-count) (when (pos? text-match-count)
(let [ids (mf/ref-val text-match-ids-ref) (let [ids (mf/ref-val text-match-ids-ref)
@ -447,11 +447,11 @@
(mf/set-ref-val! match-idx-ref next-idx) (mf/set-ref-val! match-idx-ref next-idx)
(swap! state* assoc :current-match-idx next-idx) (swap! state* assoc :current-match-idx next-idx)
(st/emit! (dw/select-shape id) (st/emit! (dw/select-shape id)
dw/zoom-to-selected-shape))))) (dw/center-on-shape id))))))
navigate-prev navigate-prev
(mf/use-fn (mf/use-fn
(mf/deps text-match-count) (mf/deps text-match-ids text-match-count)
(fn [_] (fn [_]
(when (pos? text-match-count) (when (pos? text-match-count)
(let [ids (mf/ref-val text-match-ids-ref) (let [ids (mf/ref-val text-match-ids-ref)
@ -460,7 +460,7 @@
(mf/set-ref-val! match-idx-ref prev-idx) (mf/set-ref-val! match-idx-ref prev-idx)
(swap! state* assoc :current-match-idx prev-idx) (swap! state* assoc :current-match-idx prev-idx)
(st/emit! (dw/select-shape id) (st/emit! (dw/select-shape id)
dw/zoom-to-selected-shape))))) (dw/center-on-shape id))))))
handle-replace handle-replace
(mf/use-fn (mf/use-fn

View File

@ -412,7 +412,7 @@
(select-keys txt/text-node-attrs))] (select-keys txt/text-node-attrs))]
(when (features/active-feature? @st/state "text-editor-wasm/v1") (when (features/active-feature? @st/state "text-editor-wasm/v1")
(st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs))) (st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs)))
(st/emit! (dwt/save-font updated-attrs) (st/emit! (dwt/save-default-font updated-attrs)
(dwt/update-all-attrs ids attrs))))) (dwt/update-all-attrs ids attrs)))))
on-change on-change

View File

@ -12,7 +12,6 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.config :as cf]
[app.main.constants :refer [max-input-length]] [app.main.constants :refer [max-input-length]]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.fonts :as fts] [app.main.data.fonts :as fts]
@ -91,19 +90,13 @@
(constantly nil))))) (constantly nil)))))
@loaded?)) @loaded?))
;; --- FEATURE: font preview (flag :font-preview) ------------------------------
;; font-item-preview* and use-font-lazy-load are the whole feature. They are only
;; rendered/called behind the `:font-preview` flag check in font-item* below, so
;; their hooks never run when the flag is off. To remove the flag, inline
;; font-item-preview* into font-item* and drop the plain-name branch.
(mf/defc font-item-preview* (mf/defc font-item-preview*
"Row content with previews: a vector preview from the shared sprite for catalog "Row content with previews: a vector preview from the shared sprite for catalog
fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't
cover." cover."
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [font]}] [{:keys [font]}]
(let [font-id (:id font) (let [font-id (get font :id)
sprite (mf/deref fonts/preview-sprite) sprite (mf/deref fonts/preview-sprite)
;; The sprite is only referenceable once it's been attached to the DOM, ;; The sprite is only referenceable once it's been attached to the DOM,
@ -133,29 +126,20 @@
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [font is-current on-click style]}] [{:keys [font is-current on-click style]}]
(let [item-ref (mf/use-ref) (let [item-ref (mf/use-ref)
on-click (mf/use-fn (mf/deps font) #(on-click font)) on-click (mf/use-fn (mf/deps font) #(on-click font))]
;; FLAG :font-preview — gates the feature markup AND its row styling
;; (.font-item-preview-on in the scss). Remove this and its two uses below.
preview? (contains? cf/flags :font-preview)]
(mf/use-effect (mf/with-effect [is-current]
(mf/deps is-current) (when is-current
(fn [] (let [element (mf/ref-val item-ref)]
(when is-current (when-not (dom/is-in-viewport? element)
(let [element (mf/ref-val item-ref)] (dom/scroll-into-view! element)))))
(when-not (dom/is-in-viewport? element)
(dom/scroll-into-view! element))))))
[:div {:class (stl/css :font-wrapper) [:div {:class (stl/css :font-wrapper)
:style style :style style
:ref item-ref :ref item-ref
:on-click on-click} :on-click on-click}
[:div {:class (stl/css-case :font-item true [:div {:class (stl/css-case :font-item true :selected is-current)}
:font-item-preview-on preview? [:> font-item-preview* {:font font}]
:selected is-current)}
(if preview?
[:> font-item-preview* {:font font}]
[:span {:class (stl/css :font-item-label)} (:name font)])
(when is-current (when is-current
[:> icon* {:icon-id i/tick [:> icon* {:icon-id i/tick
:size "s"}])]])) :size "s"}])]]))
@ -260,14 +244,13 @@
(let [key (events/listen js/document "keydown" on-key-down)] (let [key (events/listen js/document "keydown" on-key-down)]
#(events/unlistenByKey key))) #(events/unlistenByKey key)))
;; FLAG :font-preview — materialize the preview sprite into the DOM only while ;; Materialize the preview sprite into the DOM only while the picker is open
;; the picker is open (markup is prefetched on workspace load), removing it on ;; (markup is prefetched on workspace load), removing it on close so its
;; close so its ~2000 nodes aren't kept around idle. The attachment is deferred ;; ~2000 nodes aren't kept around idle. The attachment is deferred so the
;; so the dropdown can paint first with plain names, then the sprite swaps in ;; dropdown can paint first with plain names, then the sprite swaps in on the
;; on the next tick. Remove the flag clause to drop the feature. ;; next tick.
(mf/with-effect [sprite-status] (mf/with-effect [sprite-status]
(when (and (contains? cf/flags :font-preview) (when (= :ready sprite-status)
(= :ready sprite-status))
(let [node* (volatile! nil) (let [node* (volatile! nil)
task (tm/schedule task (tm/schedule
(fn [] (fn []
@ -278,9 +261,11 @@
(fonts/detach-preview-sprite! n)))))) (fonts/detach-preview-sprite! n))))))
(mf/with-effect [@selected] (mf/with-effect [@selected]
(when-let [inst (mf/ref-val flist)] (let [node (mf/ref-val flist)
(when-let [index (:index @selected)] index (:index @selected)]
(.scrollToRow ^js inst index)))) ;; This is nil safe operation, do nothing if node or index are
;; invalid.
(dom/scroll-to-row node index)))
(mf/with-effect [@selected] (mf/with-effect [@selected]
(on-select @selected)) (on-select @selected))
@ -291,11 +276,12 @@
(st/emit! (dsc/pop-shortcuts :typography)))) (st/emit! (dsc/pop-shortcuts :typography))))
(mf/with-effect [] (mf/with-effect []
(let [index (d/index-of-pred fonts #(= (:id %) (:id current-font))) (let [index (d/index-of-pred fonts #(= (:id %) (:id current-font)))
inst (mf/ref-val flist)] node (mf/ref-val flist)]
(tm/schedule (tm/schedule
#(let [offset (.getOffsetForRow ^js inst #js {:alignment "center" :index index})] #(let [offset (.getOffsetForRow ^js node #js {:alignment "center" :index index})]
(.scrollToPosition ^js inst offset))))) ;; Safe operaton, do nothing if node or offset has invalid values
(dom/scroll-to-position node offset)))))
[:div {:class [(stl/css-case :font-selector true [:div {:class [(stl/css-case :font-selector true
:fonts-on-modal (not full-size?))]} :fonts-on-modal (not full-size?))]}

View File

@ -388,20 +388,7 @@ $font-preview-box-height: 28px;
&.selected { &.selected {
color: var(--color-foreground-primary); color: var(--color-foreground-primary);
} }
}
.font-item-label {
@include t.use-typography("body-small");
@include text-ellipsis;
flex-grow: 1;
min-inline-size: 0;
}
// --- FLAG :font-preview row styling. Only applied when font-item* adds the
// .font-item-preview-on modifier; remove this whole block with the flag so rows
// render exactly as before.
.font-item-preview-on {
// Center & clip so a previewed font's own metrics never grow/overflow the row. // Center & clip so a previewed font's own metrics never grow/overflow the row.
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
@ -414,6 +401,14 @@ $font-preview-box-height: 28px;
} }
} }
.font-item-label {
@include t.use-typography("body-small");
@include text-ellipsis;
flex-grow: 1;
min-inline-size: 0;
}
// `currentColor` makes the glyph fill follow the row text color (theme + selected). // `currentColor` makes the glyph fill follow the row text color (theme + selected).
.font-item-preview { .font-item-preview {
flex-grow: 1; flex-grow: 1;

View File

@ -460,8 +460,8 @@
[:& editor-v2/text-editor {:shape editing-shape [:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref :canvas-ref canvas-ref
:modifiers modifiers}] :modifiers modifiers}]
[:> editor-v1/text-editor-svg* {:shape editing-shape [:& editor-v1/text-editor-svg {:shape editing-shape
:modifiers modifiers}])) :modifiers modifiers}]))
(when show-frame-outline? (when show-frame-outline?
(let [outlined-frame-id (let [outlined-frame-id

View File

@ -759,8 +759,8 @@
:canvas-ref canvas-ref :canvas-ref canvas-ref
:ref text-editor-ref}] :ref text-editor-ref}]
:else [:> editor-v1/text-editor-svg* {:shape editing-shape :else [:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}])) :ref text-editor-ref}]))
(when show-frame-outline? (when show-frame-outline?
(let [outlined-frame-id (->> @hover-ids (let [outlined-frame-id (->> @hover-ids

View File

@ -244,6 +244,16 @@
height (.-clientHeight scroll-node)] height (.-clientHeight scroll-node)]
(/ distance height))) (/ distance height)))
(defn scroll-to-row
[node index]
(when (and (some? node) (number? index))
(.scrollToRow ^js node index)))
(defn scroll-to-position
[node offset]
(when (and (some? node) (number? offset))
(.scrollToPosition ^js node offset)))
(def get-target-val (comp get-value get-target)) (def get-target-val (comp get-value get-target))
(def get-target-scroll (comp get-scroll-position get-target)) (def get-target-scroll (comp get-scroll-position get-target))

View File

@ -379,14 +379,14 @@
"float letter-spacing is normalised to 2-decimal string"))))))) "float letter-spacing is normalised to 2-decimal string")))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Tests: save-font must not persist typography refs into the global default font ;; Tests: save-default-font must not persist typography refs into the global default font
;; ;;
;; Root cause of #10925: typography assets are file-specific references, but ;; Root cause of #10925: typography assets are file-specific references, but
;; save-font used to write :typography-ref-id / :typography-ref-file into the ;; save-default-font used to write :typography-ref-id / :typography-ref-file into the
;; session-global [:workspace-global :default-font]. That state survives a file ;; session-global [:workspace-global :default-font]. That state survives a file
;; switch, and v2-default-text-content bakes it into brand-new text shapes in ;; switch, and v2-default-text-content bakes it into brand-new text shapes in
;; the other file, so they got a non-existent typography asset instead of the ;; the other file, so they got a non-existent typography asset instead of the
;; default Penpot font. save-font now strips those two keys. ;; default Penpot font. save-default-font now strips those two keys.
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(t/deftest save-font-strips-typography-refs-from-default-font (t/deftest save-font-strips-typography-refs-from-default-font
@ -405,7 +405,7 @@
:typography-ref-id (uuid/next) :typography-ref-id (uuid/next)
:typography-ref-file (:id file)}] :typography-ref-file (:id file)}]
(ths/run-store (ths/run-store
store done [(dwt/save-font attrs)] store done [(dwt/save-default-font attrs)]
(fn [new-state] (fn [new-state]
(let [default-font (get-in new-state [:workspace-global :default-font])] (let [default-font (get-in new-state [:workspace-global :default-font])]
(t/is (some? default-font)) (t/is (some? default-font))
@ -425,7 +425,7 @@
:letter-spacing "0" :letter-spacing "0"
:typography-ref-id (uuid/next) :typography-ref-id (uuid/next)
:typography-ref-file (uuid/next)}] :typography-ref-file (uuid/next)}]
(ths/run-store store done [(dwt/save-font attrs)] (ths/run-store store done [(dwt/save-default-font attrs)]
(fn [new-state] (fn [new-state]
(let [default-font (get-in new-state [:workspace-global :default-font])] (let [default-font (get-in new-state [:workspace-global :default-font])]
(t/is (= "Open Sans" (:font-family default-font))) (t/is (= "Open Sans" (:font-family default-font)))

View File

@ -388,6 +388,9 @@ msgstr ""
msgid "dashboard.change-organization-modal.title" msgid "dashboard.change-organization-modal.title"
msgstr "Change team's organization" msgstr "Change team's organization"
msgid "dashboard.change-organization-modal.description"
msgstr "Projects and files will remain available to team members. The team will get the configuration from the new organization."
#: src/app/main/ui/dashboard/deleted.cljs:316 #: src/app/main/ui/dashboard/deleted.cljs:316
msgid "dashboard.clear-trash-button" msgid "dashboard.clear-trash-button"
msgstr "Clear trash" msgstr "Clear trash"
@ -901,8 +904,8 @@ msgid "dashboard.move-to-other-team"
msgstr "Move to other team" msgstr "Move to other team"
#: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761 #: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761
msgid "dashboard.my-teams" msgid "dashboard.other-teams"
msgstr "My Teams" msgstr "Other teams"
#: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254 #: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254
msgid "dashboard.new-file" msgid "dashboard.new-file"
@ -1140,11 +1143,11 @@ msgstr ""
msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled"
msgstr "Pending invitations to external users will be canceled." msgstr "Pending invitations to external users will be canceled."
#, unused
msgid "dashboard.select-organization-modal.permission-info" msgid "dashboard.select-organization-modal.permission-info"
msgstr "" msgstr "Here you'll find the organizations you are part of where you are allowed to move the team."
"Here you find all your organizations where you are allowed to create or add "
"teams." msgid "dashboard.select-organization-modal.permission-info-add"
msgstr "Here you'll find the organizations you are part of where you are allowed to create or add teams."
#, unused #, unused
msgid "dashboard.select-organization-modal.select" msgid "dashboard.select-organization-modal.select"
@ -1466,8 +1469,8 @@ msgstr "Your name"
msgid "dashboard.your-penpot" msgid "dashboard.your-penpot"
msgstr "Your Penpot" msgstr "Your Penpot"
msgid "dashboard.my-files" msgid "dashboard.personal-projects"
msgstr "My Files" msgstr "Personal Projects"
#: src/app/main/ui/alert.cljs:36 #: src/app/main/ui/alert.cljs:36
msgid "ds.alert-ok" msgid "ds.alert-ok"

View File

@ -395,6 +395,9 @@ msgstr ""
msgid "dashboard.change-organization-modal.title" msgid "dashboard.change-organization-modal.title"
msgstr "Cambiar el equipo de organización" msgstr "Cambiar el equipo de organización"
msgid "dashboard.change-organization-modal.description"
msgstr "Los proyectos y archivos permanecerán disponibles para los miembros del equipo. El equipo obtendrá la configuración de la nueva organización."
#: src/app/main/ui/dashboard/deleted.cljs:316 #: src/app/main/ui/dashboard/deleted.cljs:316
msgid "dashboard.clear-trash-button" msgid "dashboard.clear-trash-button"
msgstr "Vaciar papelera" msgstr "Vaciar papelera"
@ -905,8 +908,8 @@ msgid "dashboard.move-to-other-team"
msgstr "Mover a otro equipo" msgstr "Mover a otro equipo"
#: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761 #: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761
msgid "dashboard.my-teams" msgid "dashboard.other-teams"
msgstr "Mis Equipos" msgstr "Otros equipos"
#: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254 #: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254
msgid "dashboard.new-file" msgid "dashboard.new-file"
@ -1147,11 +1150,11 @@ msgstr ""
msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled"
msgstr "Las invitaciones pendientes a usuarios externos serán canceladas." msgstr "Las invitaciones pendientes a usuarios externos serán canceladas."
#, unused
msgid "dashboard.select-organization-modal.permission-info" msgid "dashboard.select-organization-modal.permission-info"
msgstr "" msgstr "Aquí encontrarás las organizaciones de las que eres parte donde tienes permiso para mover el equipo."
"Aquí encontrarás todas las organizaciones en las que tienes permiso para "
"crear o añadir equipos." msgid "dashboard.select-organization-modal.permission-info-add"
msgstr "Aquí encontrarás las organizaciones de las que eres parte donde tienes permiso para crear o añadir equipos."
#, unused #, unused
msgid "dashboard.select-organization-modal.select" msgid "dashboard.select-organization-modal.select"
@ -1473,8 +1476,8 @@ msgstr "Tu nombre"
msgid "dashboard.your-penpot" msgid "dashboard.your-penpot"
msgstr "Tu Penpot" msgstr "Tu Penpot"
msgid "dashboard.my-files" msgid "dashboard.personal-projects"
msgstr "Mis Archivos" msgstr "Proyectos Personales"
#: src/app/main/ui/alert.cljs:36 #: src/app/main/ui/alert.cljs:36
msgid "ds.alert-ok" msgid "ds.alert-ok"