Compare commits

..

1 Commits

Author SHA1 Message Date
alonso.torres
036df2d112 Improvements on variants plugins 2026-02-26 12:19:47 +01:00
50 changed files with 866 additions and 973 deletions

View File

@@ -20,7 +20,6 @@
### :bug: Bugs fixed
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527)
## 2.14.0 (Unreleased)

View File

@@ -58,3 +58,4 @@
(when (nil? (:data file))
(migrate-file conn file)))
(db/exec-one! conn ["drop table page cascade;"])))

View File

@@ -404,8 +404,6 @@ export class WorkspacePage extends BaseWebSocketPage {
return content !== "";
}, { timeout: 1000 });
await this.page.waitForTimeout(3000);
}
/**
@@ -419,8 +417,7 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.viewport.click({ button: "right" });
return this.page.getByText("Paste", { exact: true }).click();
}
await this.page.keyboard.press("ControlOrMeta+V");
await this.page.waitForTimeout(3000);
return this.page.keyboard.press("ControlOrMeta+V");
}
async panOnViewportAt(x, y, width, height) {

View File

@@ -910,7 +910,7 @@ test.describe("Tokens: Detach token", () => {
await expect(page.getByText("Don't remap")).toBeVisible();
await page.getByText("Don't remap").click();
const brokenPill = borderRadiusSection.getByRole("button", {
name: "is not in any active set",
name: "This token is not in any",
});
await expect(brokenPill).toBeVisible();

View File

@@ -383,26 +383,24 @@ test("User cut paste a component with path inside a variant", async ({
const variant = await findVariant(workspacePage, 0);
// Create a component
//Create a component
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(3000);
// Rename the component
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
await workspacePage.page
.getByTestId("layer-item")
.getByRole("textbox")
.pressSequentially("button / hover");
await workspacePage.page.keyboard.press("Enter");
await workspacePage.page.waitForTimeout(3000);
// Cut the component
//Cut the component
await workspacePage.cut("keyboard");
// Paste the component inside the variant
//Paste the component inside the variant
await variant.container.click();
await workspacePage.paste("keyboard");
@@ -429,7 +427,6 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(3000);
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();

View File

@@ -620,68 +620,61 @@
ptk/WatchEvent
(watch [_ state _]
;; We do not allow to apply tokens while text editor is open.
;; The classic text editor sets :workspace-editor-state; the WASM text editor
;; does not, so we also check :workspace-local :edition for text shapes.
(let [edition (get-in state [:workspace-local :edition])
objects (dsh/lookup-page-objects state)
text-editing? (and (some? edition)
(= :text (:type (get objects edition))))]
(when (and (empty? (get state :workspace-editor-state))
(not text-editing?))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
(when (empty? (get state :workspace-editor-state))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id))))))))))))))
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))))))
(defn apply-spacing-token-separated
"Handles edge-case for spacing token when applying token via toggle button.

View File

@@ -548,7 +548,7 @@
modif-tree
(dwm/build-modif-tree ids objects get-modifier)]
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
(rx/of (dwm/apply-wasm-modifiers modif-tree)))
(let [page-id (or (:page-id options)
(:current-page-id state))

View File

@@ -613,7 +613,7 @@
vec))
(defn combine-as-variants
[ids {:keys [page-id trigger]}]
[ids {:keys [page-id trigger variant-id] :or {variant-id nil}}]
(ptk/reify ::combine-as-variants
ptk/WatchEvent
(watch [_ state stream]
@@ -647,7 +647,7 @@
:shapes
count
inc)
variant-id (uuid/next)
variant-id (or variant-id (uuid/next))
undo-id (js/Symbol)]
(rx/concat

View File

@@ -183,6 +183,9 @@
[id]
(l/derived #(contains? % id) selected-shapes))
(def highlighted-shapes
(l/derived :highlighted workspace-local))
(def export-in-progress?
(l/derived :export-in-progress? export))

View File

@@ -658,7 +658,7 @@
(when (and token-applied (not= :multiple token-applied))
(let [token (get-option-by-name dropdown-options token-applied)
id (get token :id)
label (or (get token :name) applied-token)
label (get token :name)
token-value (or (get token :resolved-value)
(or (mf/ref-val last-value*)
(fmt/format-number value)))

View File

@@ -40,7 +40,7 @@
(let [set-active? (some? id)
content (if set-active?
label
(tr "ds.inputs.token-field.no-active-token-option" label))
(tr "ds.inputs.token-field.no-active-token-option"))
default-id (mf/use-id)
id (d/nilv id default-id)

View File

@@ -12,7 +12,7 @@
[app.common.types.component :as ctk]
[app.main.data.viewer :as dv]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner*]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[okulary.core :as l]
@@ -26,6 +26,7 @@
(mf/defc layer-item
[{:keys [item selected objects depth component-child? hide-toggle?] :as props}]
(let [id (:id item)
hidden? (:hidden item)
selected? (contains? selected id)
item-ref (mf/use-ref nil)
depth (+ depth 1)
@@ -67,17 +68,18 @@
(when (and (= (count selected) 1) selected?)
(dom/scroll-into-view-if-needed! (mf/ref-val item-ref) true))))
[:> layer-item-inner*
[:& layer-item-inner
{:ref item-ref
:item item
:depth depth
:is-read-only true
:is-highlighted false
:is-selected selected?
:is-component-tree component-tree?
:is-filtered false
:is-expanded expanded?
:hide-toggle hide-toggle?
:read-only? true
:highlighted? false
:selected? selected?
:component-tree? component-tree?
:hidden? hidden?
:filtered? false
:expanded? expanded?
:hide-toggle? hide-toggle?
:on-select-shape select-shape
:on-toggle-collapse toggle-collapse}

View File

@@ -10,7 +10,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.math :as mth]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
@@ -38,8 +37,6 @@
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
(defonce ^:private sidebar-hover-pending? (atom false))
(def ^:const default-chunk-size 50)
(defn- schedule-sidebar-hover-flush []
(when (compare-and-set! sidebar-hover-pending? false true)
(ts/raf
@@ -51,11 +48,12 @@
(when (seq enter)
(apply st/emit! (map dw/highlight-shape enter))))))))
(mf/defc layer-item-inner*
[{:keys [item depth parent-size name-ref children ref style rename-id
(mf/defc layer-item-inner
{::mf/wrap-props false}
[{:keys [item depth parent-size name-ref children ref style
;; Flags
is-read-only is-highlighted is-selected is-component-tree
is-filtered is-expanded dnd-over dnd-over-top dnd-over-bot hide-toggle
read-only? highlighted? selected? component-tree?
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
;; Callbacks
on-select-shape on-context-menu on-pointer-enter on-pointer-leave on-zoom-to-selected
on-toggle-collapse on-enable-drag on-disable-drag on-toggle-visibility on-toggle-blocking]}]
@@ -66,7 +64,7 @@
hidden? (:hidden item)
has-shapes? (-> item :shapes seq boolean)
touched? (-> item :touched seq boolean)
root-board? (and (cfh/frame-shape? item)
parent-board? (and (cfh/frame-shape? item)
(= uuid/zero (:parent-id item)))
absolute? (ctl/item-absolute? item)
is-variant? (ctk/is-variant? item)
@@ -75,11 +73,9 @@
variant-name (when is-variant? (:variant-name item))
variant-error (when is-variant? (:variant-error item))
component-id (get item :component-id)
data (mf/deref refs/workspace-data)
variant-properties (-> (ctkl/get-component data component-id)
(get :variant-properties))
data (deref refs/workspace-data)
component (ctkl/get-component data (:component-id item))
variant-properties (:variant-properties component)
icon-shape (usi/get-shape-icon item)]
[:*
@@ -89,30 +85,30 @@
:on-context-menu on-context-menu
:data-testid "layer-row"
:role "checkbox"
:aria-checked is-selected
:aria-checked selected?
:class (stl/css-case
:layer-row true
:highlight is-highlighted
:highlight highlighted?
:component (ctk/instance-head? item)
:masked (:masked-group item)
:selected is-selected
:selected selected?
:type-frame (cfh/frame-shape? item)
:type-bool (cfh/bool-shape? item)
:type-comp (or is-component-tree is-variant-container?)
:type-comp (or component-tree? is-variant-container?)
:hidden hidden?
:dnd-over dnd-over
:dnd-over-top dnd-over-top
:dnd-over-bot dnd-over-bot
:root-board root-board?)
:dnd-over dnd-over?
:dnd-over-top dnd-over-top?
:dnd-over-bot dnd-over-bot?
:root-board parent-board?)
:style style}
[:span {:class (stl/css-case
:tab-indentation true
:filtered is-filtered)
:filtered filtered?)
:style {"--depth" depth}}]
[:div {:class (stl/css-case
:element-list-body true
:filtered is-filtered
:selected is-selected
:filtered filtered?
:selected selected?
:icon-layer (= (:type item) :icon))
:style {"--depth" depth}
:on-pointer-enter on-pointer-enter
@@ -121,12 +117,12 @@
(if (< 0 (count (:shapes item)))
[:div {:class (stl/css :button-content)}
(when (and (not hide-toggle) (not is-filtered))
(when (and (not hide-toggle?) (not filtered?))
[:button {:class (stl/css-case
:toggle-content true
:inverse is-expanded)
:inverse expanded?)
:data-testid "toggle-content"
:aria-expanded is-expanded
:aria-expanded expanded?
:on-click on-toggle-collapse}
deprecated-icon/arrow])
@@ -137,7 +133,7 @@
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]]
[:div {:class (stl/css :button-content)}
(when (not ^boolean is-filtered)
(when (not ^boolean filtered?)
[:span {:class (stl/css :toggle-content)}])
[:div {:class (stl/css :icon-shape)
:on-double-click on-zoom-to-selected}
@@ -146,26 +142,25 @@
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]])
[:> layer-name* {:ref name-ref
:rename-id rename-id
:shape-id id
:shape-name name
:is-shape-touched touched?
:disabled-double-click is-read-only
:disabled-double-click read-only?
:on-start-edit on-disable-drag
:on-stop-edit on-enable-drag
:depth depth
:is-blocked blocked?
:parent-size parent-size
:is-selected is-selected
:type-comp (or is-component-tree is-variant-container?)
:is-selected selected?
:type-comp (or component-tree? is-variant-container?)
:type-frame (cfh/frame-shape? item)
:variant-id variant-id
:variant-name variant-name
:variant-properties variant-properties
:variant-error variant-error
:component-id component-id
:component-id (:id component)
:is-hidden hidden?}]]
(when (not ^boolean is-read-only)
(when (not read-only?)
[:div {:class (stl/css-case
:element-actions true
:is-parent has-shapes?
@@ -190,86 +185,41 @@
children]))
(mf/defc layer-item*
{::mf/wrap [mf/memo]}
[{:keys [index item selected objects rename-id
is-sortable is-filtered depth is-component-child
highlighted style render-children parent-size]
:or {render-children true}}]
(let [id (get item :id)
blocked? (get item :blocked)
hidden? (get item :hidden)
shapes (get item :shapes)
shapes (mf/with-memo [shapes objects]
(loop [counter 0
shapes (seq shapes)
result (list)]
(if-let [id (first shapes)]
(if-let [obj (get objects id)]
(do
;; NOTE: this is a bit hacky, but reduces substantially
;; the allocation; If we use enumeration, we allocate
;; new sequence and add one iteration on each render,
;; independently if objects are changed or not. If we
;; store counter on metadata, we still need to create a
;; new allocation for each shape; with this method we
;; bypass this by mutating a private property on the
;; object removing extra allocation and extra iteration
;; on every request.
(unchecked-set obj "__$__counter" counter)
(recur (inc counter)
(rest shapes)
(conj result obj)))
(recur (inc counter)
(rest shapes)
result))
(-> result vec not-empty))))
;; Memoized for performance
(mf/defc layer-item
{::mf/props :obj
::mf/wrap [mf/memo]}
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
:or {render-children? true}}]
(let [id (:id item)
blocked? (:blocked item)
hidden? (:hidden item)
drag-disabled* (mf/use-state false)
drag-disabled? (deref drag-disabled*)
scroll-middle-ref (mf/use-ref true)
scroll-to-middle? (mf/use-var true)
expanded-iref (mf/with-memo [id]
(l/derived #(dm/get-in % [:expanded id]) refs/workspace-local))
is-expanded (mf/deref expanded-iref)
(-> (l/in [:expanded id])
(l/derived refs/workspace-local)))
expanded? (mf/deref expanded-iref)
is-selected (contains? selected id)
is-highlighted (contains? highlighted id)
selected? (contains? selected id)
highlighted? (contains? highlighted id)
container? (or (cfh/frame-shape? item)
(cfh/group-shape? item))
is-read-only (mf/use-ctx ctx/workspace-read-only?)
root-board? (and (cfh/frame-shape? item)
read-only? (mf/use-ctx ctx/workspace-read-only?)
parent-board? (and (cfh/frame-shape? item)
(= uuid/zero (:parent-id item)))
name-node-ref (mf/use-ref)
depth (+ depth 1)
is-component-tree (or ^boolean is-component-child
^boolean (ctk/instance-root? item)
^boolean (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-ref (mf/use-ref nil)
toggle-collapse
(mf/use-fn
(mf/deps is-expanded)
(mf/deps expanded?)
(fn [event]
(dom/stop-propagation event)
(if (and is-expanded (kbd/shift? event))
(if (and expanded? (kbd/shift? event))
(st/emit! (dwc/collapse-all))
(st/emit! (dwc/toggle-collapse id)))))
@@ -294,13 +244,13 @@
select-shape
(mf/use-fn
(mf/deps id is-filtered objects)
(mf/deps id filtered? objects)
(fn [event]
(dom/prevent-default event)
(mf/set-ref-val! scroll-middle-ref false)
(reset! scroll-to-middle? false)
(cond
(kbd/shift? event)
(if is-filtered
(if filtered?
(st/emit! (dw/shift-select-shapes id objects))
(st/emit! (dw/shift-select-shapes id)))
@@ -335,11 +285,11 @@
on-context-menu
(mf/use-fn
(mf/deps item is-read-only)
(mf/deps item read-only?)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(when-not is-read-only
(when-not read-only?
(let [pos (dom/get-client-position event)]
(st/emit! (dw/show-shape-context-menu {:position pos :shape item}))))))
@@ -352,7 +302,7 @@
on-drop
(mf/use-fn
(mf/deps id objects is-expanded selected)
(mf/deps id objects expanded? selected)
(fn [side _data]
(let [single? (= (count selected) 1)
same? (and single? (= (first selected) id))]
@@ -365,34 +315,32 @@
(= side :center)
id
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape)))
(and expanded? (= side :bot) (d/not-empty? (:shapes shape)))
id
:else
(cfh/get-parent-id objects id))
[parent-id _]
(ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
[parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
parent (get objects parent-id)
current-index (d/index-of (:shapes parent) id)
to-index (cond
(= side :center) 0
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
(and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
;; target not found in parent (while lazy loading)
(neg? current-index) nil
(= side :top) (inc current-index)
:else current-index)]
(when (some? to-index)
(st/emit! (dw/relocate-selected-shapes parent-id to-index))))))))
on-hold
(mf/use-fn
(mf/deps id is-expanded)
(mf/deps id expanded?)
(fn []
(when-not is-expanded
(when-not expanded?
(st/emit! (dwc/toggle-collapse id)))))
zoom-to-selected
@@ -413,114 +361,112 @@
:data {:id (:id item)
:index index
:name (:name item)}
;; We don't want to change the structure of component copies
:draggable? (and ^boolean is-sortable
^boolean (not is-read-only)
^boolean (not (ctn/has-any-copy-parent? objects item))))]
:draggable? (and
sortable?
(not read-only?)
(not (ctn/has-any-copy-parent? objects item)))) ;; We don't want to change the structure of component copies
(mf/with-effect [is-selected selected]
ref (mf/use-ref)
depth (+ depth 1)
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-var (mf/use-var nil)
chunk-size 50]
(mf/with-effect [selected? selected]
(let [single? (= (count selected) 1)
node (mf/ref-val name-node-ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
parent-node (dom/get-parent-at node 2)
first-child-node (dom/get-first-child parent-node)
scroll-to-middle? (mf/ref-val scroll-middle-ref)
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
parent-node (dom/get-parent-at node 2)
first-child-node (dom/get-first-child parent-node)
subid
(when (and ^boolean single?
^boolean is-selected
^boolean scroll-to-middle?)
(when (and single? selected? @scroll-to-middle?)
(ts/schedule
100
#(when (and node scroll-node)
(let [scroll-distance-ratio (dom/get-scroll-distance-ratio node scroll-node)
scroll-behavior (if (> scroll-distance-ratio 1) "instant" "smooth")]
(dom/scroll-into-view-if-needed! first-child-node #js {:block "center" :behavior scroll-behavior :inline "start"})
(mf/set-ref-val! scroll-middle-ref true)))))]
(reset! scroll-to-middle? true)))))]
#(when (some? subid)
(rx/dispose! subid))))
;; Setup scroll-driven lazy loading when expanded
;; and ensures selected children are loaded immediately
(mf/with-effect [is-expanded shapes selected]
(let [total (count shapes)]
(if ^boolean is-expanded
(mf/with-effect [expanded? (:shapes item) selected]
(let [shapes-vec (:shapes item)
total (count shapes-vec)]
(if expanded?
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
;; Find if any selected id is a direct child and get its render index
selected-child-render-idx
(when (> total default-chunk-size)
(some (fn [sel-id]
(let [idx (.indexOf shapes sel-id)]
(when (>= idx 0) idx)))
selected))
(when (and (> total chunk-size) (seq selected))
(let [shapes-reversed (vec (reverse shapes-vec))]
(some (fn [sel-id]
(let [idx (.indexOf shapes-reversed sel-id)]
(when (>= idx 0) idx)))
selected)))
;; Load at least enough to include the selected child plus extra
;; for context (so it can be centered in the scroll view)
min-count
(if selected-child-render-idx
(+ selected-child-render-idx default-chunk-size)
default-chunk-size)
current-count
@children-count*
new-count
(mth/min total (mth/max current-count default-chunk-size min-count))]
min-count (if selected-child-render-idx
(+ selected-child-render-idx chunk-size)
chunk-size)
current @children-count*
new-count (min total (max current chunk-size min-count))]
(reset! children-count* new-count))
(reset! children-count* 0))
(fn []
(when-let [obs (mf/ref-val observer-ref)]
(.disconnect obs)
(mf/set-ref-val! obs nil)))))
(reset! children-count* 0))))
;; Re-observe sentinel whenever children-count changes (sentinel moves)
;; and (shapes item) to reconnect observer after shape changes
(mf/with-effect [children-count is-expanded shapes]
(let [total (count shapes)
name-node (mf/ref-val name-node-ref)
scroll-node (dom/get-parent-with-data name-node "scroll-container")
lazy-node (mf/ref-val lazy-ref)]
(mf/with-effect [children-count expanded? (:shapes item)]
(let [total (count (:shapes item))
node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data node "scroll-container")
lazy-node (mf/ref-val lazy-ref)]
;; Disconnect previous observer
(when-let [obs (mf/ref-val observer-ref)]
(when-let [obs ^js @observer-var]
(.disconnect obs)
(mf/set-ref-val! observer-ref nil))
(reset! observer-var nil))
;; Setup new observer if there are more children to load
(when (and ^boolean is-expanded
^boolean (< children-count total)
^boolean scroll-node
^boolean lazy-node)
(when (and expanded?
(< children-count total)
scroll-node
lazy-node)
(let [cb (fn [entries]
(when (and (pos? (alength entries))
(.-isIntersecting ^js (aget entries 0)))
(when (and (seq entries)
(.-isIntersecting (first entries)))
;; Load next chunk when sentinel intersects
(let [next-count (mth/min total (+ children-count default-chunk-size))]
(let [current @children-count*
next-count (min total (+ current chunk-size))]
(reset! children-count* next-count))))
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
(.observe observer lazy-node)
(mf/set-ref-val! observer-ref observer)))))
(reset! observer-var observer)))))
[:> layer-item-inner*
[:& layer-item-inner
{:ref dref
:item item
:depth depth
:parent-size parent-size
:name-ref name-node-ref
:rename-id rename-id
:is-read-only is-read-only
:is-highlighted is-highlighted
:is-selected is-selected
:is-component-tree is-component-tree
:is-filtered is-filtered
:is-expanded is-expanded
:dnd-over (= (:over dprops) :center)
:dnd-over-top (= (:over dprops) :top)
:dnd-over-bot (= (:over dprops) :bot)
:name-ref ref
:read-only? read-only?
:highlighted? highlighted?
:selected? selected?
:component-tree? component-tree?
:filtered? filtered?
:expanded? expanded?
:dnd-over? (= (:over dprops) :center)
:dnd-over-top? (= (:over dprops) :top)
:dnd-over-bot? (= (:over dprops) :bot)
:on-select-shape select-shape
:on-context-menu on-context-menu
:on-pointer-enter on-pointer-enter
@@ -533,28 +479,29 @@
:on-toggle-blocking toggle-blocking
:style style}
(when (and ^boolean render-children
^boolean shapes
^boolean is-expanded)
(when (and render-children?
(:shapes item)
expanded?)
[:div {:class (stl/css-case
:element-children true
:parent-selected is-selected
:sticky-children root-board?)
:parent-selected selected?
:sticky-children parent-board?)
:data-testid (dm/str "children-" id)}
(for [item (take children-count shapes)]
[:> layer-item*
{:item item
:rename-id rename-id
:highlighted highlighted
:selected selected
:index (unchecked-get item "__$__counter")
:objects objects
:key (dm/str (get item :id))
:is-sortable is-sortable
:depth depth
:parent-size parent-size
:is-component-child is-component-tree}])
(when (< children-count (count shapes))
(let [all-children (reverse (d/enumerate (:shapes item)))
visible (take children-count all-children)]
(for [[index id] visible]
(when-let [item (get objects id)]
[:& layer-item
{:item item
:highlighted highlighted
:selected selected
:index index
:objects objects
:key (dm/str id)
:sortable? sortable?
:depth depth
:parent-size parent-size
:component-child? component-tree?}])))
(when (< children-count (count (:shapes item)))
[:div {:ref lazy-ref
:class (stl/css :lazy-load-sentinel)}])])]))

View File

@@ -16,35 +16,39 @@
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private ^:const space-for-icons 110)
(def ^:private space-for-icons 110)
(def lens:shape-for-rename
(-> (l/in [:workspace-local :shape-for-rename])
(l/derived st/state)))
(mf/defc layer-name*
[{:keys [shape-id rename-id shape-name is-shape-touched disabled-double-click
{::mf/wrap-props false
::mf/forward-ref true}
[{:keys [shape-id shape-name is-shape-touched disabled-double-click
on-start-edit on-stop-edit depth parent-size is-selected
type-comp type-frame component-id is-hidden is-blocked
variant-id variant-name variant-properties variant-error ref]}]
variant-id variant-name variant-properties variant-error]} external-ref]
(let [edition* (mf/use-state false)
edition? (deref edition*)
local-ref (mf/use-ref)
ref (d/nilv ref local-ref)
ref (d/nilv external-ref local-ref)
shape-name
(if variant-id
(d/nilv variant-error variant-name)
shape-name)
shape-for-rename (mf/deref lens:shape-for-rename)
default-value
(mf/with-memo [variant-id variant-error variant-properties]
(if variant-id
(or variant-error (ctv/properties-map->formula variant-properties))
shape-name))
shape-name (if variant-id
(d/nilv variant-error variant-name)
shape-name)
has-path?
(str/includes? shape-name "/")
default-value (if variant-id
(or variant-error (ctv/properties-map->formula variant-properties))
shape-name)
has-path? (str/includes? shape-name "/")
start-edit
(mf/use-fn
@@ -81,11 +85,10 @@
(when (kbd/enter? event) (accept-edit))
(when (kbd/esc? event) (cancel-edit))))
parent-size
(dm/str (- parent-size space-for-icons) "px")]
parent-size (dm/str (- parent-size space-for-icons) "px")]
(mf/with-effect [rename-id edition? start-edit shape-id]
(when (and (= rename-id shape-id)
(mf/with-effect [shape-for-rename edition? start-edit shape-id]
(when (and (= shape-for-rename shape-id)
(not ^boolean edition?))
(start-edit)))
@@ -107,24 +110,21 @@
:auto-focus true
:id (dm/str "layer-name-" shape-id)
:default-value (d/nilv default-value "")}]
[:*
[:span {:class (stl/css-case
:element-name true
:left-ellipsis has-path?
:selected is-selected
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}
(if ^boolean (dbg/enabled? :show-ids)
(dm/str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
[:span
{:class (stl/css-case
:element-name true
:left-ellipsis has-path?
:selected is-selected
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}
(if (dbg/enabled? :show-ids)
(str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
(d/nilv shape-name ""))]
(when (and ^boolean (dbg/enabled? :show-touched)
^boolean is-shape-touched)
(when (and (dbg/enabled? :show-touched) ^boolean is-shape-touched)
[:span {:class (stl/css :element-name-touched)} "*"])])))

View File

@@ -21,7 +21,7 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.hooks :as hooks]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item*]]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item]]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.i18n :as i18n :refer [tr]]
@@ -31,160 +31,92 @@
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.events :as events]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private ref:highlighted-shapes
(l/derived (fn [local]
(-> local
(get :highlighted)
(not-empty)))
refs/workspace-local))
(def ^:private ref:shape-for-rename
(l/derived (l/key :shape-for-rename) refs/workspace-local))
(defn- use-selected-shapes
"A convencience hook wrapper for get selected shapes"
[]
(let [selected (mf/deref refs/selected-shapes)]
(hooks/use-equal-memo selected)))
[rumext.v2 :as mf])
(:import
goog.events.EventType))
;; This components is a piece for sharding equality check between top
;; level frames and try to avoid rerender frames that are does not
;; affected by the selected set.
(mf/defc frame-wrapper*
(mf/defc frame-wrapper
{::mf/props :obj}
[{:keys [selected] :as props}]
(let [pending-selected-ref
(mf/use-ref selected)
current-selected
(mf/use-state selected)
props
(mf/spread-object props {:selected @current-selected})
(let [pending-selected (mf/use-var selected)
current-selected (mf/use-state selected)
props (mf/spread-object props {:selected @current-selected})
set-selected
(mf/with-memo []
(throttle-fn 50 #(when-let [pending-selected (mf/ref-val pending-selected-ref)]
(reset! current-selected pending-selected))))]
(mf/use-memo
(fn []
(throttle-fn
50
#(when-let [pending-selected @pending-selected]
(reset! current-selected pending-selected)))))]
(mf/with-effect [selected set-selected]
(mf/set-ref-val! pending-selected-ref selected)
(^function set-selected)
(reset! pending-selected selected)
(set-selected)
(fn []
(mf/set-ref-val! pending-selected-ref nil)
(rx/dispose! set-selected)))
(reset! pending-selected nil)
#(rx/dispose! set-selected)))
[:> layer-item* props]))
(mf/defc layers-tree*
{::mf/wrap [mf/memo]}
[{:keys [objects is-filtered parent-size] :as props}]
(let [selected (use-selected-shapes)
highlighted (mf/deref ref:highlighted-shapes)
root (get objects uuid/zero)
rename-id (mf/deref ref:shape-for-rename)
shapes (get root :shapes)
shapes (mf/with-memo [shapes objects]
(loop [counter 0
shapes (seq shapes)
result (list)]
(if-let [id (first shapes)]
(if-let [obj (get objects id)]
(do
;; NOTE: this is a bit hacky, but reduces substantially
;; the allocation; If we use enumeration, we allocate
;; new sequence and add one iteration on each render,
;; independently if objects are changed or not. If we
;; store counter on metadata, we still need to create a
;; new allocation for each shape; with this method we
;; bypass this by mutating a private property on the
;; object removing extra allocation and extra iteration
;; on every request.
(unchecked-set obj "__$__counter" counter)
(recur (inc counter)
(rest shapes)
(conj result obj)))
(recur (inc counter)
(rest shapes)
result))
result)))]
[:> layer-item props]))
(mf/defc layers-tree
{::mf/wrap [mf/memo #(mf/throttle % 200)]
::mf/wrap-props false}
[{:keys [objects filtered? parent-size] :as props}]
(let [selected (mf/deref refs/selected-shapes)
selected (hooks/use-equal-memo selected)
highlighted (mf/deref refs/highlighted-shapes)
highlighted (hooks/use-equal-memo highlighted)
root (get objects uuid/zero)]
[:div {:class (stl/css :element-list) :data-testid "layer-item"}
[:> hooks/sortable-container* {}
(for [obj shapes]
(if (cfh/frame-shape? obj)
[:> frame-wrapper*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:parent-size parent-size
:depth -1}]
[:> layer-item*
{:item obj
:rename-id rename-id
:selected selected
:highlighted highlighted
:index (unchecked-get obj "__$__counter")
:objects objects
:key (dm/str (get obj :id))
:is-sortable true
:is-filtered is-filtered
:depth -1
:parent-size parent-size}]))]]))
(for [[index id] (reverse (d/enumerate (:shapes root)))]
(when-let [obj (get objects id)]
(if (cfh/frame-shape? obj)
[:& frame-wrapper
{:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
:sortable? true
:filtered? filtered?
:parent-size parent-size
:depth -1}]
[:& layer-item
{:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
:sortable? true
:filtered? filtered?
:depth -1
:parent-size parent-size}])))]]))
(mf/defc layers-tree-wrapper*
{::mf/private true}
[{:keys [objects] :as props}]
;; This is a performance sensitive componet, so we use lower-level primitives for
;; reduce residual allocation for this specific case
(let [state-tmp (mf/useState objects)
objects' (aget state-tmp 0)
set-objects (aget state-tmp 1)
subject-s (mf/with-memo []
(rx/subject))
changes-s (mf/with-memo [subject-s]
(->> subject-s
(rx/debounce 500)))
props (mf/spread-props props {:objects objects'})]
(mf/with-effect [objects subject-s]
(rx/push! subject-s objects))
(mf/with-effect [changes-s]
(let [sub (rx/subscribe changes-s set-objects)]
#(rx/dispose! sub)))
[:> layers-tree* props]))
(mf/defc filters-tree*
{::mf/wrap [mf/memo #(mf/throttle % 300)]
::mf/private true}
(mf/defc filters-tree
{::mf/wrap [mf/memo #(mf/throttle % 200)]
::mf/wrap-props false}
[{:keys [objects parent-size]}]
(let [selected (use-selected-shapes)
root (get objects uuid/zero)]
(let [selected (mf/deref refs/selected-shapes)
selected (hooks/use-equal-memo selected)
root (get objects uuid/zero)]
[:ul {:class (stl/css :element-list)}
(for [[index id] (d/enumerate (:shapes root))]
(when-let [obj (get objects id)]
[:> layer-item*
[:& layer-item
{:item obj
:selected selected
:index index
:objects objects
:key id
:is-sortable false
:is-filtered true
:sortable? false
:filtered? true
:depth -1
:parent-size parent-size}]))]))
@@ -200,7 +132,6 @@
keys
(filter #(not= uuid/zero %))
vec)]
(update reparented-objects uuid/zero assoc :shapes reparented-shapes)))
;; --- Layers Toolbox
@@ -346,11 +277,9 @@
(swap! state* update :num-items + 100))))]
(mf/with-effect []
(let [key1 (events/listen globals/document "keydown" on-key-down)
key2 (events/listen globals/document "click" hide-menu)]
(fn []
(events/unlistenByKey key1)
(events/unlistenByKey key2))))
(let [keys [(events/listen globals/document EventType.KEYDOWN on-key-down)
(events/listen globals/document EventType.CLICK hide-menu)]]
(fn [] (doseq [key keys] (events/unlistenByKey key)))))
[filtered-objects
handle-show-more
@@ -535,8 +464,6 @@
{::mf/wrap [mf/memo]}
[{:keys [size-parent]}]
(let [page (mf/deref refs/workspace-page)
page-id (get page :id)
focus (mf/deref refs/workspace-focus-selected)
objects (hooks/with-focus-objects (:objects page) focus)
@@ -546,8 +473,7 @@
observer-var (mf/use-var nil)
lazy-load-ref (mf/use-ref nil)
[filtered-objects show-more filter-component]
(use-search page objects)
[filtered-objects show-more filter-component] (use-search page objects)
intersection-callback
(fn [entries]
@@ -593,25 +519,25 @@
[:div {:class (stl/css :tool-window-content)
:data-scroll-container true
:ref on-render-container}
[:> filters-tree* {:objects filtered-objects
:key (dm/str page-id)
:parent-size size-parent}]
[:& filters-tree {:objects filtered-objects
:key (dm/str (:id page))
:parent-size size-parent}]
[:div {:ref lazy-load-ref}]]
[:div {:on-scroll on-scroll
:class (stl/css :tool-window-content)
:data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}}
[:> layers-tree-wrapper* {:objects filtered-objects
:key (dm/str page-id)
:is-filtered true
:parent-size size-parent}]]]
[:& layers-tree {:objects filtered-objects
:key (dm/str (:id page))
:filtered? true
:parent-size size-parent}]]]
[:div {:on-scroll on-scroll
:class (stl/css :tool-window-content)
:data-scroll-container true
:style {:display (when (some? filtered-objects) "none")}}
[:> layers-tree-wrapper* {:objects objects
:key (dm/str page-id)
:is-filtered false
:parent-size size-parent}]])]))
[:& layers-tree {:objects objects
:key (dm/str (:id page))
:filtered? false
:parent-size size-parent}]])]))

View File

@@ -96,14 +96,14 @@
id (dm/str (:id token) "-name")
swatch-tooltip-content (cond
not-active
(tr "ds.inputs.token-field.no-active-token-option" token-name)
(tr "ds.inputs.token-field.no-active-token-option")
has-errors
(tr "color-row.token-color-row.deleted-token")
:else
(tr "workspace.tokens.resolved-value" resolved))
name-tooltip-content (cond
not-active
(tr "ds.inputs.token-field.no-active-token-option" token-name)
(tr "ds.inputs.token-field.no-active-token-option")
has-errors
(tr "color-row.token-color-row.deleted-token")
:else

View File

@@ -75,11 +75,7 @@
is-type-unfolded (contains? (set unfolded-token-paths) (name type))
editing-ref (mf/deref refs/workspace-editor-state)
edition (mf/deref refs/selected-edition)
objects (mf/deref refs/workspace-page-objects)
not-editing? (and (empty? editing-ref)
(not (and (some? edition)
(= :text (:type (get objects edition))))))
not-editing? (empty? editing-ref)
can-edit?
(mf/use-ctx ctx/can-edit?)

View File

@@ -14,6 +14,7 @@
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.types.color :as ctc]
[app.common.types.component :as ctk]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
@@ -26,6 +27,7 @@
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.variants :as dwv]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :refer [fetch-font-css]]
@@ -608,4 +610,26 @@
:else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)]
(st/emit! (dw/convert-selected-to-path ids)))))))
(st/emit! (dw/convert-selected-to-path ids)))))
:createVariantFromComponents
(fn [shapes]
(cond
(or (not (seq shapes))
(not (every? u/is-main-component-proxy? shapes)))
(u/display-not-valid :shapes shapes)
:else
(let [file-id (obj/get (first shapes) "$file")
page-id (obj/get (first shapes) "$page")
ids (->> shapes
(map #(obj/get % "$id"))
(into #{}))
shape (u/locate-shape file-id page-id (first ids))
component (u/locate-library-component file-id (:component-id shape))]
(when (and component (not (ctk/is-variant? component)))
(let [variant-id (uuid/next)]
(st/emit! (dwv/combine-as-variants
ids
{:trigger "plugin:combine-as-variants" :variant-id variant-id}))
(library/variant-proxy plugin-id file-id variant-id))))))))

View File

@@ -1350,16 +1350,22 @@
:combineAsVariants
(fn [ids]
(if (or (not (seq ids)) (not (every? uuid/parse* ids)))
(cond
(or (not (seq ids)) (not (every? uuid/parse* ids)))
(u/display-not-valid :ids ids)
:else
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))
ids (->> ids
(map uuid/uuid)
(into #{id}))]
(when (and component (not (ctk/is-variant? component)))
(st/emit!
(dwv/combine-as-variants ids {:trigger "plugin:combine-as-variants"})))))))
(when (and component (not (ctk/is-variant? component)))
(let [variant-id (uuid/next)]
(st/emit! (dwv/combine-as-variants
ids
{:trigger "plugin:combine-as-variants" :variant-id variant-id}))
(variant-proxy plugin-id file-id variant-id)))))))
(cond-> (or (cfh/frame-shape? data) (cfh/group-shape? data) (cfh/svg-raw-shape? data) (cfh/bool-shape? data))
(crc/add-properties!

View File

@@ -11,6 +11,7 @@
[app.common.data.macros :as dm]
[app.common.json :as json]
[app.common.schema :as sm]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.tokens-lib :as ctob]
@@ -258,4 +259,9 @@
(if-let [explain (-> cause ex-data ::sm/explain)]
(println (sm/humanize-explain explain))
(js/console.log (ex-data cause)))
(js/console.log (.-stack cause)))
(js/console.log (.-stack cause)))
(defn is-main-component-proxy?
[p]
(when-let [shape (proxy->shape p)]
(ctk/main-instance? shape)))

View File

@@ -1362,7 +1362,7 @@ msgstr "Token trennen"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s ist nicht Teil eines aktiven Sets oder ungültig."
msgstr "Dieser Token ist nicht Teil eines aktiven Sets oder ungültig."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1284,7 +1284,7 @@ msgstr "Detach token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s is not in any active set or has an invalid value."
msgstr "This token is not in any active set or has an invalid value."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1274,7 +1274,7 @@ msgstr "Desvincular token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s no está disponible en ningún set o tiene un valor inválido."
msgstr "Este token no está disponible en ningún set o tiene un valor inválido."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1369,7 +1369,7 @@ msgstr "Détacher le token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s n'est pas disponible dans la collection ou le thème actif."
msgstr "Ce token n'est pas disponible dans la collection ou le thème actif."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1360,7 +1360,7 @@ msgstr "Détacher du token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s n'est disponible dans aucune collection ou est invalide."
msgstr "Ce token n'est disponible dans aucune collection ou est invalide."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1185,6 +1185,10 @@ msgstr "פתיחת רשימת אסימונים"
msgid "ds.inputs.token-field.detach-token"
msgstr "ניתוק אסימון"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "האסימון הזה לא זמין באף ערכה או שהערך שלו שגוי."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"
msgstr "ספק האימות לא מורשה לפרופיל הזה"

View File

@@ -1256,6 +1256,10 @@ msgstr "token सूची खोलें"
msgid "ds.inputs.token-field.detach-token"
msgstr "token अलग करें"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "यह token किसी भी सक्रिय सेट में नहीं है या इसका मान अमान्य है।"
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"
msgstr "इस प्रोफाइल के लिए ऑथ प्रोवाइडर अनुमति नहीं है"

View File

@@ -1355,7 +1355,7 @@ msgstr "Scollega token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s non è disponibile in nessun set o tema attivo."
msgstr "Questo token non è disponibile in nessun set o tema attivo."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1214,6 +1214,10 @@ msgstr "Atvērt tekstvienību sarakstu"
msgid "ds.inputs.token-field.detach-token"
msgstr "Atdalīt tekstvienību"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "Šī tekstvienība nav nevienā aktīvajā kopā vai tai ir nederīga vērtība."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"
msgstr "Autentificēšanās nodrošinātājs nav atļauts šim profilam"

View File

@@ -1358,7 +1358,7 @@ msgstr "Token loskoppelen"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s is niet beschikbaar in een actieve verzameling of thema."
msgstr "Dit token is niet beschikbaar in een actieve verzameling of thema."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1187,7 +1187,7 @@ msgstr "Desvincular token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s não está em nenhum conjunto ativo ou possui um valor inválido."
msgstr "Este token não está em nenhum conjunto ativo ou possui um valor inválido."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1204,7 +1204,7 @@ msgstr "Detașează tokenul"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s nu este în nici un set activ sau are o valoare invalidă."
msgstr "Acest token nu este în nici un set activ sau are o valoare invalidă."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1184,6 +1184,12 @@ msgstr "Открыть список токенов"
msgid "ds.inputs.token-field.detach-token"
msgstr "Отсоединить токен"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr ""
"Этот токен не входит ни в один активный набор или имеет недопустимое "
"значение."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"
msgstr "Поставщик аутентификации не разрешён для этого профиля"

View File

@@ -1188,7 +1188,7 @@ msgstr "Lösgör token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "%s är inte i någon aktiv uppsättning eller har ett ogiltigt värde."
msgstr "Denna token är inte i någon aktiv uppsättning eller har ett ogiltigt värde."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"

View File

@@ -1355,6 +1355,12 @@ msgstr "Token listesini aç"
msgid "ds.inputs.token-field.detach-token"
msgstr "Tokeni ayır"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr ""
"Bu token herhangi bir etkin kümede bulunmuyor veya geçersiz bir değere "
"sahip."
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"
msgstr "Kimlik doğrulama sağlayıcısına bu profil için izin verilmiyor"

View File

@@ -1116,6 +1116,10 @@ msgstr "打开token列表"
msgid "ds.inputs.token-field.detach-token"
msgstr "分离token"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
msgid "ds.inputs.token-field.no-active-token-option"
msgstr "该token于任意活动集合或主题皆不可用。"
#: src/app/main/data/auth.cljs:339
msgid "errors.auth-provider-not-allowed"
msgstr "认证提供者不允许访问此个人设定"

View File

@@ -1,10 +1,7 @@
# Penpot MCP Project Overview - Updated
## Purpose
This project is a Model Context Protocol (MCP) server for Penpot integration.
The MCP server communicates with a Penpot plugin via WebSockets, allowing
the MCP server to send tasks to the plugin and receive results,
enabling advanced AI-driven features in Penpot.
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
## Tech Stack
- **Language**: TypeScript
@@ -16,22 +13,21 @@ enabling advanced AI-driven features in Penpot.
## Project Structure
```
/ (project root)
├── packages/common/ # Shared type definitions
penpot-mcp/
├── common/ # Shared type definitions
│ ├── src/
│ │ ├── index.ts # Exports for shared types
│ │ └── types.ts # PluginTaskResult, request/response interfaces
│ └── package.json # @penpot-mcp/common package
├── packages/server/ # Main MCP server implementation
├── mcp-server/ # Main MCP server implementation
│ ├── src/
│ │ ├── index.ts # Main server entry point
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
│ │ ├── PluginTask.ts # Now supports result promises
│ │ ├── tasks/ # PluginTask implementations
│ │ └── tools/ # Tool implementations
| ├── data/ # Contains resources, such as API info and prompts
│ └── package.json # Includes @penpot-mcp/common dependency
├── packages/plugin/ # Penpot plugin with response capability
├── penpot-plugin/ # Penpot plugin with response capability
│ ├── src/
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
│ │ └── plugin.ts # Now sends task responses back to server
@@ -41,24 +37,55 @@ enabling advanced AI-driven features in Penpot.
## Key Tasks
### Adjusting the System Prompt
The system prompt file is located in `packages/server/data/initial_instructions.md`.
### Adding a new Tool
1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface.
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
2. Register the tool in `PenpotMcpServer`.
Tools can be associated with a `PluginTask` that is executed in the plugin.
Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution.
Look at `PrintTextTool` as an example.
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
### Adding a new PluginTask
1. Implement the input data interface for the task in `packages/common/src/types.ts`.
2. Implement the `PluginTask` class in `packages/server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`).
1. Implement the input data interface for the task in `common/src/types.ts`.
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
* In the success case, call `task.sendSuccess`.
* In the failure case, just throw an exception, which will be handled centrally!
4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list.
* Look at `PrintTextTaskHandler` as an example.
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
## Key Components
### Enhanced WebSocket Protocol
- **Request Format**: `{id: string, task: string, params: any}`
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
- **Request/Response Correlation**: Using unique UUIDs for task tracking
- **Timeout Handling**: 30-second timeout with automatic cleanup
- **Type Safety**: Shared definitions via @penpot-mcp/common package
### Core Classes
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
- **PluginTask**: Now creates result promises that resolve when plugin responds
- **Tool implementations**: Now properly await task completion and report results
- **Plugin handlers**: Send structured responses back to server
### New Features
1. **Bidirectional Communication**: Plugin now responds with success/failure status
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
3. **Error Reporting**: Failed tasks properly report error messages to tools
4. **Shared Type Safety**: Common package ensures consistency across projects
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
6. **Request Correlation**: Unique IDs match requests to responses
## Task Flow
```
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
↑ ↓
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
```

View File

@@ -1,3 +1,5 @@
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
@@ -17,7 +19,7 @@ read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
@@ -60,17 +62,15 @@ excluded_tools: []
# (contrary to the memories, which are loaded on demand).
initial_prompt: |
IMPORTANT: You use an idiomatic, object-oriented style.
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
Always read the "project_overview" memory.
Comments:
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
required for sentences).
# the name by which the project can be referenced within Serena
project_name: "penpot-mcp"
@@ -128,16 +128,3 @@ encoding: utf-8
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:

View File

@@ -1,4 +1,4 @@
import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types";
import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types";
export class PenpotUtils {
/**
@@ -189,24 +189,6 @@ export class PenpotUtils {
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
}
/**
* Gets the actual rendering bounds of a shape. For most shapes, this is simply the `bounds` property.
* However, for Text shapes, the `bounds` may not reflect the true size of the rendered text content,
* so we use the `textBounds` property instead.
*
* @param shape - The shape to get the bounds for
*/
public static getBounds(shape: Shape): Bounds {
if (shape.type === "text") {
const text = shape as Text;
// TODO: Remove ts-ignore once type definitions are updated
// @ts-ignore
return text.textBounds;
} else {
return shape.bounds;
}
}
/**
* Checks if a child shape is fully contained within its parent's bounds.
* Visual containment means all edges of the child are within the parent's bounding box.
@@ -216,13 +198,11 @@ export class PenpotUtils {
* @returns true if child is fully contained within parent bounds, false otherwise
*/
public static isContainedIn(child: Shape, parent: Shape): boolean {
const childBounds = this.getBounds(child);
const parentBounds = this.getBounds(parent);
return (
childBounds.x >= parentBounds.x &&
childBounds.y >= parentBounds.y &&
childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width &&
childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height
child.x >= parent.x &&
child.y >= parent.y &&
child.x + child.width <= parent.x + parent.width &&
child.y + child.height <= parent.y + parent.height
);
}
@@ -318,16 +298,39 @@ export class PenpotUtils {
/**
* Decodes a base64 string to a Uint8Array.
* This is required because the Penpot plugin environment does not provide the atob function.
*
* @param base64 - The base64-encoded string to decode
* @returns The decoded data as a Uint8Array
*/
public static base64ToByteArray(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
public static atob(base64: string): Uint8Array {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const lookup = new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === "=") {
bufferLength--;
if (base64[base64.length - 2] === "=") {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const encoded1 = lookup[base64.charCodeAt(i)];
const encoded2 = lookup[base64.charCodeAt(i + 1)];
const encoded3 = lookup[base64.charCodeAt(i + 2)];
const encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return bytes;
}
@@ -357,7 +360,7 @@ export class PenpotUtils {
height: number | undefined
): Promise<Rectangle> {
// convert base64 to Uint8Array
const bytes = PenpotUtils.base64ToByteArray(base64);
const bytes = PenpotUtils.atob(base64);
// upload the image data to Penpot
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);

View File

@@ -195,39 +195,10 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
const context = this.context;
const code = task.params.code;
// set the penpot.flags.naturalChildOrdering to true during code execution.
// NOTE: This significantly simplifies API usage (see )
// TODO: Remove ts-ignore once Penpot types have been updated
let originalNaturalChildOrdering: any;
// @ts-ignore
if (penpot.flags) {
// @ts-ignore
originalNaturalChildOrdering = penpot.flags.naturalChildOrdering;
// @ts-ignore
penpot.flags.naturalChildOrdering = true;
} else {
// TODO: This can be removed once `flags` has been merged to PROD
throw new Error(
"You are using a version of the Penpot MCP server which is incompatible " +
"with the connected Penpot version. " +
"Read the documentation for instructions on which version to use: " +
"https://github.com/penpot/penpot/tree/develop/mcp\n" +
"If you are an LLM, tell the user about this!"
);
}
let result: any;
try {
// execute the code in an async function with the context variables as parameters
result = await (async (ctx) => {
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
return fn(...Object.values(ctx));
})(context);
} finally {
// restore the original value of penpot.flags.naturalChildOrdering
// @ts-ignore
penpot.flags.naturalChildOrdering = originalNaturalChildOrdering;
}
let result: any = await (async (ctx) => {
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
return fn(...Object.values(ctx));
})(context);
console.log("Code execution result:", result);

View File

@@ -11,7 +11,7 @@ Penpot:
open: (
name: string,
url: string,
options?: { width: number; height: number; hidden: boolean },
options?: { width: number; height: number },
) => void;
size: { width: number; height: number } | null;
resize: (width: number, height: number) => void;
@@ -99,7 +99,7 @@ Penpot:
open: (
name: string,
url: string,
options?: { width: number; height: number; hidden: boolean },
options?: { width: number; height: number },
) => void;
size: { width: number; height: number } | null;
resize: (width: number, height: number) => void;
@@ -110,7 +110,7 @@ Penpot:
Type Declaration
* open: ( name: string, url: string, options?: { width: number; height: number; hidden: boolean },) => void
* open: (name: string, url: string, options?: { width: number; height: number }) => void
Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`.
There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter.
@@ -1062,7 +1062,7 @@ Board:
rotation: number;
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -1456,7 +1456,7 @@ Board:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -2171,7 +2171,7 @@ VariantContainer:
rotation: number;
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -2568,7 +2568,7 @@ VariantContainer:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -3270,7 +3270,7 @@ Boolean:
rotation: number;
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -3629,7 +3629,7 @@ Boolean:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -5850,7 +5850,7 @@ Ellipse:
rotation: number;
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -6179,7 +6179,7 @@ Ellipse:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -8279,7 +8279,7 @@ Group:
| "mixed";
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -8614,7 +8614,7 @@ Group:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -9523,7 +9523,7 @@ Image:
rotation: number;
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -9852,7 +9852,7 @@ Image:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -10444,8 +10444,6 @@ LayoutCellProperties:
position?: "area" | "auto" | "manual";
}
```
Referenced by: Board, Boolean, Ellipse, Group, Image, Path, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer
members:
Properties:
row: |-
@@ -12988,7 +12986,7 @@ Path:
rotation: number;
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -13341,7 +13339,7 @@ Path:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -14315,7 +14313,7 @@ Rectangle:
rotation: number;
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -14646,7 +14644,7 @@ Rectangle:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -15351,7 +15349,7 @@ ShapeBase:
| "mixed";
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -15681,7 +15679,7 @@ ShapeBase:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -16275,7 +16273,7 @@ Stroke:
strokeColorRefFile?: string;
strokeColorRefId?: string;
strokeOpacity?: number;
strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed";
strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed";
strokeWidth?: number;
strokeAlignment?: "center" | "inner" | "outer";
strokeCapStart?: StrokeCap;
@@ -16314,7 +16312,7 @@ Stroke:
Defaults to 1 if omitted.
strokeStyle: |-
```
strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed"
strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed"
```
The optional style of the stroke.
@@ -16417,7 +16415,7 @@ SvgRaw:
| "mixed";
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -16741,7 +16739,7 @@ SvgRaw:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -17336,7 +17334,7 @@ Text:
| "mixed";
strokes: Stroke[];
layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties;
layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void;
tokens: {
width: string;
@@ -17423,7 +17421,6 @@ Text:
direction: "mixed" | "ltr" | "rtl" | null;
align: "center" | "left" | "right" | "mixed" | "justify" | null;
verticalAlign: "center" | "top" | "bottom" | null;
textBounds: { x: number; y: number; width: number; height: number };
getRange(start: number, end: number): TextRange;
applyTypography(typography: LibraryTypography): void;
}
@@ -17678,7 +17675,7 @@ Text:
Layout properties for children of the shape.
layoutCell: |-
```
readonly layoutCell?: LayoutCellProperties
readonly layoutCell?: LayoutChildProperties
```
Layout properties for cells in a grid layout.
@@ -17838,13 +17835,6 @@ Text:
```
The vertical alignment of the text shape. It can be a specific alignment or 'mixed' if multiple alignments are used.
textBounds: |-
```
readonly textBounds: { x: number; y: number; width: number; height: number }
```
Return the bounding box for the text as a (x, y, width, height) rectangle
This is the box that covers the text even if it overflows its selection rectangle.
Methods:
getPluginData: |-
```

View File

@@ -39,28 +39,20 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
* `bounds` is READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`.
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
**Other Writable Properties**:
* `name` - Shape name
* `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - Styling properties
- Setting fills: `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]`; no fill (transparent): `shape.fills = []`;
- Colors: Use hex strings with caps only (e.g. '#FF5533')
- IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them!
* `borderRadius` - Uniform border radius for all corners
* `borderRadiusTopLeft`, `borderRadiusTopRight`, `borderRadiusBottomRight`, `borderRadiusBottomLeft` - Individual corner radii.
* `blur: Blur` - Blur properties
* `blendMode` - Blend mode (e.g. `"normal"`, `"multiply"`, `"overlay"`, etc.)
* `rotation` (deg), `opacity`, `blocked`, `hidden`, `visible`
* `proportionLock` - Whether width and height are locked to the same ratio
* `constraintsHorizontal` - Horizontal resize constraint (`"left"`, `"right"`, `"center"`, `"leftright"`, `"scale"`)
* `constraintsVertical` - Vertical resize constraint (`"top"`, `"bottom"`, `"center"`, `"topbottom"`, `"scale"`)
* `flipX`, `flipY` - Horizontal/vertical flip
* `fills`, `strokes` - Styling properties
IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them, e.g.
`shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]` to set a single red fill.
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
**Z-Order**:
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
(i.e. add background shapes first, then foreground shapes later).
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
and, for precise control, `setParentIndex(index)` (0-based).
@@ -73,7 +65,9 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
**Hierarchical Structure**:
* `parent` - The parent shape (null for root shapes)
Note: Hierarchical nesting does not necessarily imply visual containment
* To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)`
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
- Automatically removes the shape from its old parent
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
@@ -105,11 +99,17 @@ Boards can have layout systems that automatically control the positioning and sp
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
Optionally, adjust individual child margins via `child.layoutChild`.
- Sizing: `verticalSizing` and `horizontalSizing` are NOT functional. You need to size manually for the time being.
- When a board has flex layout, child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- When a board has flex layout,
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- CRITICAL: For dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`. So call it in the order of visual appearance.
To insert at a specific index, use `board.insertChild(index, shape)`.
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
or dir="row".
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
IMPORTANT: When adding a flex layout to a container that already has children,
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
@@ -131,12 +131,12 @@ Boards can have layout systems that automatically control the positioning and sp
# Text Elements
The rendered content of a `Text` element is given by the `characters` property.
The rendered content of `Text` element is given by the `characters` property.
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text.
it only changes the formal bounding box; if the text does not fit it, it will overflow.
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing!
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
# The `penpot` and `penpotUtils` Objects, Exploring Designs
@@ -228,76 +228,31 @@ Each `Library` object has:
* `colors: LibraryColor[]` - Array of colors
* `typographies: LibraryTypography[]` - Array of typographies
## Colors and Typographies
Adding a color:
```
const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
```
Adding a typography:
```
const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
```
## Components
Using library components:
* find a component in the library by name:
`const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));`
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
* create a new instance of the component on the current page:
`const instance: Shape = component.instance();`
const instance: Shape = component.instance();
This returns a `Shape` (often a `Board` containing child elements).
After instantiation, modify the instance's properties as desired.
* get the reference to the main component shape:
`const mainShape: Shape = component.mainInstance();`
const mainShape: Shape = component.mainInstance();
Adding a component to a library:
```
const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
```
Adding assets to a library:
* const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
Detaching:
* When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification.
* Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work.
### Variants
Variants are a system for grouping related component versions along named property axes (e.g. Type, Style), powering a structured swap UI for designers using component instances.
* `VariantContainer` (extends `Board`): The board that physically groups all variant components together.
- check with `isVariantContainer()`
- property `variants: Variants`.
* `Variants`: Defines the combinations of property values for which component variants can exist and manages the concrete component variants.
- `properties: string[]` (ordered list of property names); `addProperty()`, `renameProperty(pos, name)`, `currentValues(property)`
- `variantComponents(): LibraryVariantComponent[]`
* `LibraryVariantComponent` (extends `LibraryComponent`): full library component with metadata, for which `isVariant()` returns true.
- `variantProps: { [property: string]: string }` (this component's value for each property)
- `variantError` (non-null if e.g. two variants share the same combination of property values)
- `setVariantProperty(pos, value)`
Properties are often addressed positionally: `pos` parameter in various methods = index in `Variants.properties`.
**Creating a variant group**:
- `component.transformInVariant(): null`: Converts a standard component into a variant group, creating a `VariantContainer` and a second duplicate variant.
Both start with a default property `Property 1` with values `Value 1` / `Value 2`; there is no name-based auto-parsing.
- `board.combineAsVariants(ids: string[]): null`: Combines the board (a main component instance) with other main components (referenced via IDs) into a new variant group.
All components end up inside a single new `VariantContainer` on the canvas.
- In both cases, look for the created `VariantContainer` on the page, and then edit properties using `variants.renameProperty(pos, name)`, `variants.addProperty()`, and `comp.setVariantProperty(pos, value)`.
**Adding a variant to an existing group**:
Use `variantContainer.appendChild(mainInstance)` to move a component's main instance into the container, then set its position manually and assign property values via `setVariantProperty`.
**Using Variants**:
- `compInstance.switchVariant(pos, value)`: On a component instance, switches to the nearest variant that has the given value at property position `pos`, keeping all other property values the same.
- To instantiate a specific variant, find the right `LibraryVariantComponent` by checking `variantProps`, then call `.instance()`.
# Design Tokens
Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling.
@@ -321,7 +276,7 @@ The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
`Token`: union type encompassing various token types, with common properties:
* `name: string` - Token name (typically structured, e.g. "color.base.white")
* `value` - Raw value (direct value or reference to another token like "{color.primary}")
* `resolvedValue` - Computed final value (follows references)
* `resolvedValue` - Computed final value (follows references) - currently NOT working, do not use!
* `type: TokenType`
Discovering tokens:
@@ -337,19 +292,19 @@ Applying tokens:
- "all": applies the token to all properties it can control
- TokenBorderRadiusProps: "r1", "r2", "r3", "r4"
- TokenShadowProps: "shadow"
- TokenColorProps: "fill", "strokeColor"
- TokenDimensionProps: "x", "y", "strokeWidth"
- TokenFontFamiliesProps: "fontFamilies"
- TokenFontSizesProps: "fontSize"
- TokenFontWeightProps: "fontWeight"
- TokenLetterSpacingProps: "letterSpacing"
- TokenNumberProps: "rotation"
- TokenColorProps: "fill", "stroke-color"
- TokenDimensionProps: "x", "y", "stroke-width"
- TokenFontFamiliesProps: "font-families"
- TokenFontSizesProps: "font-size"
- TokenFontWeightProps: "font-weight"
- TokenLetterSpacingProps: "letter-spacing"
- TokenNumberProps: "rotation", "line-height"
- TokenOpacityProps: "opacity"
- TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH"
- TokenSpacingProps: "rowGap", "columnGap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
- TokenBorderWidthProps: "strokeWidth"
- TokenTextCaseProps: "textCase"
- TokenTextDecorationProps: "textDecoration"
- TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h"
- TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
- TokenBorderWidthProps: "stroke-width"
- TokenTextCaseProps: "text-case"
- TokenTextDecorationProps: "text-decoration"
- TokenTypographyProps: "typography"
* `token.applyToShapes(shapes, properties)` - Apply from token
* Application is **asynchronous** (wait for ~100ms to see the effects)
@@ -358,7 +313,7 @@ Applying tokens:
- The actual shape properties that the tokens control will reflect the token's resolved value.
Removing tokens:
Simply set the respective property directly - token binding is automatically removed, e.g.
Simply set the respective property directly - token binding is automatically removed, e.g.
shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token
# Visual Inspection of Designs

View File

@@ -23,6 +23,7 @@ export interface SessionContext {
export class PenpotMcpServer {
private readonly logger = createLogger("PenpotMcpServer");
private readonly server: McpServer;
private readonly tools: Map<string, Tool<any>>;
public readonly configLoader: ConfigurationLoader;
private app: any;
@@ -35,7 +36,10 @@ export class PenpotMcpServer {
*/
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
private readonly sseTransports: Record<string, { transport: SSEServerTransport; userToken?: string }> = {};
private readonly transports = {
streamable: {} as Record<string, StreamableHTTPServerTransport>,
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
};
public readonly host: string;
public readonly port: number;
@@ -52,11 +56,21 @@ export class PenpotMcpServer {
this.configLoader = new ConfigurationLoader(process.cwd());
this.apiDocs = new ApiDocs();
this.server = new McpServer(
{
name: "penpot-mcp-server",
version: "1.0.0",
},
{
instructions: this.getInitialInstructions(),
}
);
this.tools = new Map<string, Tool<any>>();
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
this.replServer = new ReplServer(this.pluginBridge, this.replPort);
this.initTools();
this.registerTools();
}
/**
@@ -105,44 +119,35 @@ export class PenpotMcpServer {
return this.sessionContext.getStore();
}
private initTools(): void {
private registerTools(): void {
// Create relevant tool instances (depending on file system access)
const toolInstances: Tool<any>[] = [
new ExecuteCodeTool(this),
new HighLevelOverviewTool(this),
new PenpotApiInfoTool(this, this.apiDocs),
new ExportShapeTool(this),
new ExportShapeTool(this), // tool adapts to file system access internally
];
if (this.isFileSystemAccessEnabled()) {
toolInstances.push(new ImportImageTool(this));
}
for (const tool of toolInstances) {
this.logger.info(`Registering tool: ${tool.getToolName()}`);
this.tools.set(tool.getToolName(), tool);
}
}
const toolName = tool.getToolName();
this.tools.set(toolName, tool);
/**
* Creates a fresh {@link McpServer} instance with all tools registered.
*/
private createMcpServer(): McpServer {
const server = new McpServer(
{ name: "penpot-mcp-server", version: "1.0.0" },
{ instructions: this.getInitialInstructions() }
);
for (const tool of this.tools.values()) {
server.registerTool(
tool.getToolName(),
// Register each tool with McpServer
this.logger.info(`Registering tool: ${toolName}`);
this.server.registerTool(
toolName,
{
description: tool.getToolDescription(),
inputSchema: tool.getInputSchema(),
},
async (args) => tool.execute(args)
async (args) => {
return tool.execute(args);
}
);
}
return server;
}
private setupHttpEndpoints(): void {
@@ -151,38 +156,51 @@ export class PenpotMcpServer {
*/
this.app.all("/mcp", async (req: any, res: any) => {
const userToken = req.query.userToken as string | undefined;
this.logger.info(`Received /mcp request with userToken: ${userToken}`);
await this.sessionContext.run({ userToken }, async () => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
const server = this.createMcpServer();
await server.connect(transport);
const { randomUUID } = await import("node:crypto");
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && this.transports.streamable[sessionId]) {
transport = this.transports.streamable[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
this.transports.streamable[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports.streamable[transport.sessionId];
}
};
await this.server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
res.on("close", () => {
transport.close();
server.close();
});
});
});
/**
* Legacy SSE connection endpoint.
* Legacy SSE connection endpoint
*/
this.app.get("/sse", async (req: any, res: any) => {
const userToken = req.query.userToken as string | undefined;
await this.sessionContext.run({ userToken }, async () => {
const transport = new SSEServerTransport("/messages", res);
this.sseTransports[transport.sessionId] = { transport, userToken };
this.transports.sse[transport.sessionId] = { transport, userToken };
const server = this.createMcpServer();
await server.connect(transport);
res.on("close", () => {
delete this.sseTransports[transport.sessionId];
server.close();
delete this.transports.sse[transport.sessionId];
});
await this.server.connect(transport);
});
});
@@ -191,7 +209,7 @@ export class PenpotMcpServer {
*/
this.app.post("/messages", async (req: any, res: any) => {
const sessionId = req.query.sessionId as string;
const session = this.sseTransports[sessionId];
const session = this.transports.sse[sessionId];
if (session) {
await this.sessionContext.run({ userToken: session.userToken }, async () => {

View File

@@ -1297,6 +1297,15 @@ export interface Context {
* @param shapes to flatten
*/
flatten(shapes: Shape[]): Path[];
/**
* Combine several standard Components into a VariantComponent. Similar to doing it
* with the contextual menu on the Penpot interface.
* All the shapes passed as arguments should be main instances.
* @param shapes A list of main instances of the components to combine.
* @return The variant container created
*/
createVariantFromComponents(shapes: Board[]): VariantContainer;
}
/**
@@ -3818,8 +3827,9 @@ export interface ShapeBase extends PluginData {
* on the Penpot interface.
* The current shape must be a component main instance.
* @param ids A list of ids of the main instances of the components to combine with this one.
* @return The variant container created
*/
combineAsVariants(ids: string[]): void;
combineAsVariants(ids: string[]): VariantContainer;
/**
* @return Returns true when the current shape is the head of a components tree nested structure,

View File

@@ -358,6 +358,11 @@ export function createApi(
checkPermission('content:write');
return plugin.context.flatten(shapes);
},
createVariantFromComponents(shapes: Board[]): VariantContainer {
checkPermission('content:write');
return plugin.context.createVariantFromComponents(shapes);
},
};
return {

View File

@@ -41,13 +41,8 @@ fn draw_stroke_on_rect(
}
};
// By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
canvas.save_layer(&layer_rec);
match corners {
Some(radii) => {
@@ -86,10 +81,7 @@ fn draw_stroke_on_circle(
// By default just draw the circle. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
canvas.save_layer(&layer_rec);
let clip_path = {
let mut pb = skia::PathBuilder::new();

View File

@@ -111,7 +111,7 @@ fn calculate_cursor_rect(
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.offset;
let char_pos = cursor.char_offset;
// For cursor, we get a zero-width range at the position
// We need to handle edge cases:
// - At start of paragraph: use position 0
@@ -209,13 +209,13 @@ fn calculate_selection_rects(
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
end.char_offset
} else {
para_char_count
};

View File

@@ -11,7 +11,6 @@ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{
self as skia,
paint::{self, Paint},
textlayout::Affinity,
textlayout::ParagraphBuilder,
textlayout::ParagraphStyle,
textlayout::PositionWithAffinity,
@@ -113,51 +112,31 @@ impl TextContentSize {
}
}
#[derive(Debug, Clone, Copy, Default)]
#[derive(Debug, Copy, Clone)]
pub struct TextPositionWithAffinity {
#[allow(dead_code)]
pub position_with_affinity: PositionWithAffinity,
pub paragraph: usize,
pub offset: usize,
}
impl PartialEq for TextPositionWithAffinity {
fn eq(&self, other: &Self) -> bool {
self.paragraph == other.paragraph && self.offset == other.offset
}
pub paragraph: i32,
#[allow(dead_code)]
pub span: i32,
#[allow(dead_code)]
pub span_relative_offset: i32,
pub offset: i32,
}
impl TextPositionWithAffinity {
pub fn new(
position_with_affinity: PositionWithAffinity,
paragraph: usize,
offset: usize,
paragraph: i32,
span: i32,
span_relative_offset: i32,
offset: i32,
) -> Self {
Self {
position_with_affinity,
paragraph,
offset,
}
}
pub fn empty() -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
paragraph: 0,
offset: 0,
}
}
pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Downstream,
},
paragraph,
span,
span_relative_offset,
offset,
}
}
@@ -454,11 +433,10 @@ impl TextContent {
let mut offset_y = 0.0;
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
// IMPORTANT! I'm keeping this because I think it should be better to have the span index
// cached the same way we keep the paragraph index.
#[allow(dead_code)]
let mut _span_index: usize = 0;
for (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() {
let mut paragraph_index: i32 = -1;
let mut span_index: i32 = -1;
for layout_paragraph in layout_paragraphs {
paragraph_index += 1;
let start_y = offset_y;
let end_y = offset_y + layout_paragraph.height();
@@ -475,22 +453,20 @@ impl TextContent {
if matches {
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
// Computed position keeps the current position in terms
// of number of characters of text. This is used to know
// in which span we are.
let mut computed_position: usize = 0;
// This could be useful in the future as part of the TextPositionWithAffinity.
#[allow(dead_code)]
let mut _span_offset: usize = 0;
let mut computed_position = 0;
let mut span_offset = 0;
// If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() {
_span_index = 0;
_span_offset = 0;
span_index = 0;
span_offset = 0;
} else {
for span in paragraph.children() {
span_index += 1;
let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
@@ -499,26 +475,27 @@ impl TextContent {
// Handle empty spans: if the span is empty and current position
// matches the start, this is the right span
if length == 0 && current_position == start_position {
_span_offset = 0;
span_offset = 0;
break;
}
if start_position <= current_position
&& end_position >= current_position
{
_span_offset =
position_with_affinity.position as usize - start_position;
span_offset =
position_with_affinity.position - start_position as i32;
break;
}
computed_position += length;
_span_index += 1;
}
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
position_with_affinity.position as usize,
span_index,
span_offset,
position_with_affinity.position,
));
}
}
@@ -539,7 +516,9 @@ impl TextContent {
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
0,
));
}

View File

@@ -7,10 +7,34 @@ use skia_safe::{
Color,
};
/// Cursor position within text content.
/// Uses character offsets for precise positioning.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct TextCursor {
pub paragraph: usize,
pub char_offset: usize,
}
impl TextCursor {
pub fn new(paragraph: usize, char_offset: usize) -> Self {
Self {
paragraph,
char_offset,
}
}
pub fn zero() -> Self {
Self {
paragraph: 0,
char_offset: 0,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
pub anchor: TextPositionWithAffinity,
pub focus: TextPositionWithAffinity,
pub anchor: TextCursor,
pub focus: TextCursor,
}
impl TextSelection {
@@ -18,10 +42,10 @@ impl TextSelection {
Self::default()
}
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
pub fn from_cursor(cursor: TextCursor) -> Self {
Self {
anchor: position,
focus: position,
anchor: cursor,
focus: cursor,
}
}
@@ -33,12 +57,12 @@ impl TextSelection {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
pub fn set_caret(&mut self, cursor: TextCursor) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
pub fn extend_to(&mut self, cursor: TextCursor) {
self.focus = cursor;
}
@@ -50,24 +74,24 @@ impl TextSelection {
self.focus = self.anchor;
}
pub fn start(&self) -> TextPositionWithAffinity {
pub fn start(&self) -> TextCursor {
if self.anchor.paragraph < self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph > self.focus.paragraph {
self.focus
} else if self.anchor.offset <= self.focus.offset {
} else if self.anchor.char_offset <= self.focus.char_offset {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextPositionWithAffinity {
pub fn end(&self) -> TextCursor {
if self.anchor.paragraph > self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph < self.focus.paragraph {
self.focus
} else if self.anchor.offset >= self.focus.offset {
} else if self.anchor.char_offset >= self.focus.char_offset {
self.anchor
} else {
self.focus
@@ -78,7 +102,7 @@ impl TextSelection {
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum TextEditorEvent {
pub enum EditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
@@ -107,7 +131,7 @@ pub struct TextEditorState {
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pending_events: Vec<TextEditorEvent>,
pending_events: Vec<EditorEvent>,
}
impl TextEditorState {
@@ -165,44 +189,56 @@ impl TextEditorState {
pub fn select_all(&mut self, content: &TextContent) -> bool {
self.is_pointer_selection_active = false;
self.set_caret_from_position(&TextPositionWithAffinity::empty());
let num_paragraphs = content.paragraphs().len() - 1;
self.set_caret_from_position(TextPositionWithAffinity::new(
PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
0,
0,
0,
0,
));
let num_paragraphs = (content.paragraphs().len() - 1) as i32;
let Some(last_paragraph) = content.paragraphs().last() else {
return false;
};
#[allow(dead_code)]
let _num_spans = last_paragraph.children().len() - 1;
let Some(_last_text_span) = last_paragraph.children().last() else {
let num_spans = (last_paragraph.children().len() - 1) as i32;
let Some(last_text_span) = last_paragraph.children().last() else {
return false;
};
let mut offset = 0;
for span in last_paragraph.children() {
offset += span.text.len();
}
self.extend_selection_from_position(&TextPositionWithAffinity::new(
self.extend_selection_from_position(TextPositionWithAffinity::new(
PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Upstream,
},
num_paragraphs,
offset,
num_spans,
last_text_span.text.len() as i32,
offset as i32,
));
self.reset_blink();
self.push_event(crate::state::TextEditorEvent::SelectionChanged);
self.push_event(crate::state::EditorEvent::SelectionChanged);
true
}
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.set_caret(*position);
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.set_caret(cursor);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
self.push_event(EditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position);
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.extend_to(cursor);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
self.push_event(EditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
@@ -228,17 +264,41 @@ impl TextEditorState {
self.last_blink_time = 0.0;
}
pub fn push_event(&mut self, event: TextEditorEvent) {
pub fn push_event(&mut self, event: EditorEvent) {
if self.pending_events.last() != Some(&event) {
self.pending_events.push(event);
}
}
pub fn poll_event(&mut self) -> TextEditorEvent {
self.pending_events.pop().unwrap_or(TextEditorEvent::None)
pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None)
}
pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.set_caret_from_position(text_position_with_affinity);
}
}
/// TODO: Remove legacy code
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
pub span: i32,
}
impl TextNodePosition {
pub fn new(paragraph: i32, span: i32) -> Self {
Self { paragraph, span }
}
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}

View File

@@ -1,7 +1,7 @@
use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::TextSelection;
use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextCursor, TextSelection};
use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet;
use crate::{with_state, with_state_mut, STATE};
@@ -132,7 +132,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(&position);
state.text_editor_state.set_caret_from_position(position);
}
});
}
@@ -168,7 +168,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
{
state
.text_editor_state
.extend_selection_from_position(&position);
.extend_selection_from_position(position);
}
});
}
@@ -203,7 +203,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
{
state
.text_editor_state
.extend_selection_from_position(&position);
.extend_selection_from_position(position);
}
state.text_editor_state.stop_pointer_selection();
});
@@ -231,7 +231,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(&position);
state.text_editor_state.set_caret_from_position(position);
}
});
}
@@ -276,8 +276,7 @@ pub extern "C" fn text_editor_insert_text() {
let cursor = state.text_editor_state.selection.focus;
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset);
let new_cursor = TextCursor::new(cursor.paragraph, new_offset);
state.text_editor_state.selection.set_caret(new_cursor);
}
@@ -287,10 +286,10 @@ pub extern "C" fn text_editor_insert_text() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
.push_event(crate::state::EditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
.push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -337,10 +336,10 @@ pub extern "C" fn text_editor_delete_backward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
.push_event(crate::state::EditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
.push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -385,10 +384,10 @@ pub extern "C" fn text_editor_delete_forward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
.push_event(crate::state::EditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
.push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -424,8 +423,7 @@ pub extern "C" fn text_editor_insert_paragraph() {
let cursor = state.text_editor_state.selection.focus;
if split_paragraph_at_cursor(text_content, &cursor) {
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
let new_cursor = TextCursor::new(cursor.paragraph + 1, 0);
state.text_editor_state.selection.set_caret(new_cursor);
}
@@ -435,10 +433,10 @@ pub extern "C" fn text_editor_insert_paragraph() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
.push_event(crate::state::EditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
.push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -496,7 +494,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::SelectionChanged);
.push_event(crate::state::EditorEvent::SelectionChanged);
});
}
@@ -713,12 +711,12 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
end.char_offset
} else {
para_char_count
};
@@ -766,9 +764,9 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
let sel = &state.text_editor_state.selection;
unsafe {
*buffer_ptr = sel.anchor.paragraph as u32;
*buffer_ptr.add(1) = sel.anchor.offset as u32;
*buffer_ptr.add(1) = sel.anchor.char_offset as u32;
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
*buffer_ptr.add(3) = sel.focus.offset as u32;
*buffer_ptr.add(3) = sel.focus.char_offset as u32;
}
1
})
@@ -778,11 +776,7 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
// HELPERS: Cursor & Selection
// ============================================================================
fn get_cursor_rect(
text_content: &TextContent,
cursor: &TextPositionWithAffinity,
shape: &Shape,
) -> Option<Rect> {
fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option<Rect> {
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
@@ -800,7 +794,7 @@ fn get_cursor_rect(
let mut y_offset = valign_offset;
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.offset;
let char_pos = cursor.char_offset;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let rects = laid_out_para.get_rects_for_range(
@@ -875,13 +869,13 @@ fn get_selection_rects(
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
end.char_offset
} else {
para_char_count
};
@@ -920,49 +914,40 @@ fn paragraph_char_count(para: &Paragraph) -> usize {
}
/// Clamp a cursor position to valid bounds within the text content.
fn clamp_cursor(
position: TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
if paragraphs.is_empty() {
return TextPositionWithAffinity::new_without_affinity(0, 0);
return TextCursor::new(0, 0);
}
let para_idx = position.paragraph.min(paragraphs.len() - 1);
let para_idx = cursor.paragraph.min(paragraphs.len() - 1);
let para_len = paragraph_char_count(&paragraphs[para_idx]);
let char_offset = position.offset.min(para_len);
let char_offset = cursor.char_offset.min(para_len);
TextPositionWithAffinity::new_without_affinity(para_idx, char_offset)
TextCursor::new(para_idx, char_offset)
}
/// Move cursor left by one character.
fn move_cursor_backward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if cursor.offset > 0 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
if cursor.char_offset > 0 {
TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
} else if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
TextCursor::new(prev_para, char_count)
} else {
*cursor
}
}
/// Move cursor right by one character.
fn move_cursor_forward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
let para = &paragraphs[cursor.paragraph];
let char_count = paragraph_char_count(para);
if cursor.offset < char_count {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
if cursor.char_offset < char_count {
TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
} else if cursor.paragraph < paragraphs.len() - 1 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
TextCursor::new(cursor.paragraph + 1, 0)
} else {
*cursor
}
@@ -970,58 +955,52 @@ fn move_cursor_forward(
/// Move cursor up by one line.
fn move_cursor_up(
cursor: &TextPositionWithAffinity,
cursor: &TextCursor,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextPositionWithAffinity {
) -> TextCursor {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(prev_para, new_offset)
} else {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
TextCursor::new(cursor.paragraph, 0)
}
}
/// Move cursor down by one line.
fn move_cursor_down(
cursor: &TextPositionWithAffinity,
cursor: &TextCursor,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextPositionWithAffinity {
) -> TextCursor {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph < paragraphs.len() - 1 {
let next_para = cursor.paragraph + 1;
let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(next_para, new_offset)
} else {
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
TextCursor::new(cursor.paragraph, char_count)
}
}
/// Move cursor to start of current line.
fn move_cursor_line_start(
cursor: &TextPositionWithAffinity,
_paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor {
// TODO: Implement proper line-start using line metrics
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
TextCursor::new(cursor.paragraph, 0)
}
/// Move cursor to end of current line.
fn move_cursor_line_end(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
// TODO: Implement proper line-end using line metrics
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
TextCursor::new(cursor.paragraph, char_count)
}
// ============================================================================
@@ -1049,7 +1028,7 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
/// Insert text at a cursor position. Returns the new character offset after insertion.
fn insert_text_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
cursor: &TextCursor,
text: &str,
) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut();
@@ -1069,7 +1048,7 @@ fn insert_text_at_cursor(
return Some(text.chars().count());
}
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?;
let children = para.children_mut();
let span = &mut children[span_idx];
@@ -1084,7 +1063,7 @@ fn insert_text_at_cursor(
new_text.insert_str(byte_offset, text);
span.set_text(new_text);
Some(cursor.offset + text.chars().count())
Some(cursor.char_offset + text.chars().count())
}
/// Delete a range of text specified by a selection.
@@ -1098,16 +1077,20 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
}
if start.paragraph == end.paragraph {
delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset);
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
end.char_offset,
);
} else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.offset,
start.char_offset,
start_para_len,
);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset);
if end.paragraph < paragraphs.len() {
let end_para_children: Vec<_> =
@@ -1206,19 +1189,13 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
}
/// Delete the character before the cursor. Returns the new cursor position.
fn delete_char_before(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> Option<TextPositionWithAffinity> {
if cursor.offset > 0 {
fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> {
if cursor.char_offset > 0 {
let paragraphs = text_content.paragraphs_mut();
let para = &mut paragraphs[cursor.paragraph];
let delete_pos = cursor.offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.offset);
Some(TextPositionWithAffinity::new_without_affinity(
cursor.paragraph,
delete_pos,
))
let delete_pos = cursor.char_offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
Some(TextCursor::new(cursor.paragraph, delete_pos))
} else if cursor.paragraph > 0 {
let prev_para_idx = cursor.paragraph - 1;
let paragraphs = text_content.paragraphs_mut();
@@ -1234,17 +1211,14 @@ fn delete_char_before(
paragraphs.remove(cursor.paragraph);
Some(TextPositionWithAffinity::new_without_affinity(
prev_para_idx,
prev_para_len,
))
Some(TextCursor::new(prev_para_idx, prev_para_len))
} else {
None
}
}
/// Delete the character after the cursor.
fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return;
@@ -1252,9 +1226,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAf
let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]);
if cursor.offset < para_len {
if cursor.char_offset < para_len {
let para = &mut paragraphs[cursor.paragraph];
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
delete_range_in_paragraph(para, cursor.char_offset, cursor.char_offset + 1);
} else if cursor.paragraph < paragraphs.len() - 1 {
let next_para_idx = cursor.paragraph + 1;
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
@@ -1267,10 +1241,7 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAf
}
/// Split a paragraph at the cursor position. Returns true if split was successful.
fn split_paragraph_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> bool {
fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return false;
@@ -1278,7 +1249,7 @@ fn split_paragraph_at_cursor(
let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else {
return false;
};