0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-01-31 19:39:07 -05:00

Copy/paste properties an CSS

This commit is contained in:
alonso.torres 2025-01-10 11:50:32 +01:00
parent 80d6968156
commit 714a274789
11 changed files with 1485 additions and 23 deletions

View file

@ -12,6 +12,8 @@
- New gradients UI with multi-stop support.
- Shareable link pointing to an specific board.
- Copy styles in CSS
- Copy/paste shape styles (fills, strokes, shadows, etc..)
### :bug: Bugs fixed

View file

@ -9,6 +9,7 @@
#?(:clj [app.common.fressian :as fres])
[app.common.colors :as clr]
[app.common.data :as d]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.proportions :as gpr]
@ -17,6 +18,7 @@
[app.common.record :as cr]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.text :as txt]
[app.common.transit :as t]
[app.common.types.color :as ctc]
[app.common.types.grid :as ctg]
@ -570,3 +572,136 @@
:class Shape
:wfn fres/write-map-like
:rfn (comp map->Shape fres/read-map-like)}))
;; --- SHAPE COPY/PASTE PROPS
;; Copy/paste properties:
;; - Fill
;; - Stroke
;; - Opacity
;; - Layout (Grid & Flex)
;; - Flex element
;; - Flex board
;; - Text properties
;; - Contraints
;; - Shadow
;; - Blur
;; - Border radius
(def ^:private basic-extract-props
[:fills
:strokes
:opacity
;; Layout Item
:layout-item-margin
:layout-item-margin-type
:layout-item-h-sizing
:layout-item-v-sizing
:layout-item-max-h
:layout-item-min-h
:layout-item-max-w
:layout-item-min-w
:layout-item-absolute
:layout-item-z-index
;; Constraints
:constraints-h
:constraints-v
:shadow
:blur
;; Radius
:r1
:r2
:r3
:r4])
(def ^:private layout-extract-props
[:layout
:layout-flex-dir
:layout-gap-type
:layout-gap
:layout-wrap-type
:layout-align-items
:layout-align-content
:layout-justify-items
:layout-justify-content
:layout-padding-type
:layout-padding
:layout-grid-dir
:layout-grid-rows
:layout-grid-columns
:layout-grid-cells])
(defn extract-props
"Retrieves an object with the 'pasteable' properties for a shape."
[shape]
(letfn [(assoc-props
[props node attrs]
(->> attrs
(reduce
(fn [props attr]
(cond-> props
(and (not (contains? props attr))
(some? (get node attr)))
(assoc attr (get node attr))))
props)))
(extract-text-props
[props shape]
(->> (txt/node-seq (:content shape))
(reduce
(fn [result node]
(cond-> result
(txt/is-root-node? node)
(assoc-props node txt/root-attrs)
(txt/is-paragraph-node? node)
(assoc-props node txt/paragraph-attrs)
(txt/is-text-node? node)
(assoc-props node txt/text-node-attrs)))
props)))
(extract-layout-props
[props shape]
(d/patch-object props (select-keys shape layout-extract-props)))]
(-> shape
(select-keys basic-extract-props)
(cond-> (cfh/text-shape? shape) (extract-text-props shape))
(cond-> (ctsl/any-layout? shape) (extract-layout-props shape)))))
(defn patch-props
"Given the object of `extract-props` applies it to a shape. Adapt the shape if necesary"
[shape props objects]
(letfn [(patch-text-props [shape props]
(-> shape
(update
:content
(fn [content]
(->> content
(txt/transform-nodes
(fn [node]
(cond-> node
(txt/is-root-node? node)
(d/patch-object (select-keys props txt/root-attrs))
(txt/is-paragraph-node? node)
(d/patch-object (select-keys props txt/paragraph-attrs))
(txt/is-text-node? node)
(d/patch-object (select-keys props txt/text-node-attrs))))))))))
(patch-layout-props [shape props]
(let [shape (d/patch-object shape (select-keys props layout-extract-props))]
(cond-> shape
(ctsl/grid-layout? shape)
(ctsl/assign-cells objects))))]
(-> shape
(d/patch-object (select-keys props basic-extract-props))
(cond-> (cfh/text-shape? shape) (patch-text-props props))
(cond-> (cfh/frame-shape? shape) (patch-layout-props props)))))

View file

@ -0,0 +1,920 @@
{
"~:id": "~u870f9f10-87b5-8137-8005-93487d148645",
"~:file-id": "~u870f9f10-87b5-8137-8005-934804124660",
"~:created-at": "~m1736778551374",
"~:data": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u0eef4dd0-b39b-807a-8005-934805578f93",
"~u0eef4dd0-b39b-807a-8005-93484afe7510",
"~u0eef4dd0-b39b-807a-8005-93484d68344f",
"~u0eef4dd0-b39b-807a-8005-9348531e39f7",
"~u0eef4dd0-b39b-807a-8005-934855b02e52",
"~ud4467b11-7129-80c3-8005-934871e790b5"
]
}
},
"~u0eef4dd0-b39b-807a-8005-934805578f93": {
"~#shape": {
"~:y": 283,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Board",
"~:width": 406,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 460,
"~:y": 283
}
},
{
"~#point": {
"~:x": 866,
"~:y": 283
}
},
{
"~#point": {
"~:x": 866,
"~:y": 766
}
},
{
"~#point": {
"~:x": 460,
"~:y": 766
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u0eef4dd0-b39b-807a-8005-934805578f93",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 460,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 460,
"~:y": 283,
"~:width": 406,
"~:height": 483,
"~:x1": 460,
"~:y1": 283,
"~:x2": 866,
"~:y2": 766
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 483,
"~:flip-y": null,
"~:shapes": [
"~u0eef4dd0-b39b-807a-8005-934808365fa2"
]
}
},
"~u0eef4dd0-b39b-807a-8005-934808365fa2": {
"~#shape": {
"~:y": 339,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 117,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 530,
"~:y": 339
}
},
{
"~#point": {
"~:x": 647,
"~:y": 339
}
},
{
"~#point": {
"~:x": 647,
"~:y": 453
}
},
{
"~#point": {
"~:x": 530,
"~:y": 453
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u0eef4dd0-b39b-807a-8005-934808365fa2",
"~:parent-id": "~u0eef4dd0-b39b-807a-8005-934805578f93",
"~:frame-id": "~u0eef4dd0-b39b-807a-8005-934805578f93",
"~:strokes": [
{
"~:stroke-alignment": "~:outer",
"~:stroke-style": "~:solid",
"~:stroke-color": "#000000",
"~:stroke-opacity": 1,
"~:stroke-width": 3
}
],
"~:x": 530,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 530,
"~:y": 339,
"~:width": 117,
"~:height": 114,
"~:x1": 530,
"~:y1": 339,
"~:x2": 647,
"~:y2": 453
}
},
"~:fills": [
{
"~:fill-color-gradient": {
"~:start-x": 0.5,
"~:start-y": 0,
"~:end-x": 0.5,
"~:end-y": 1,
"~:width": 1,
"~:type": "~:linear",
"~:stops": [
{
"~:color": "#ff0000",
"~:file-id": null,
"~:offset": 0,
"~:opacity": 1,
"~:id": null
},
{
"~:color": "#ff8600",
"~:file-id": null,
"~:offset": 0.17,
"~:opacity": 1,
"~:id": null
},
{
"~:color": "#f6ff00",
"~:file-id": null,
"~:offset": 0.33,
"~:opacity": 1,
"~:id": null
},
{
"~:color": "#24ff00",
"~:file-id": null,
"~:offset": 0.5,
"~:opacity": 1,
"~:id": null
},
{
"~:color": "#00ffe6",
"~:file-id": null,
"~:offset": 0.67,
"~:opacity": 1,
"~:id": null
},
{
"~:color": "#2500ff",
"~:file-id": null,
"~:offset": 0.83,
"~:opacity": 1,
"~:id": null
},
{
"~:color": "#bf00ff",
"~:file-id": null,
"~:offset": 1,
"~:opacity": 1,
"~:id": null
}
]
}
}
],
"~:flip-x": null,
"~:height": 114,
"~:flip-y": null
}
},
"~u0eef4dd0-b39b-807a-8005-93484afe7510": {
"~#shape": {
"~:y": 314,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Ellipse",
"~:width": 103,
"~:type": "~:circle",
"~:points": [
{
"~#point": {
"~:x": 1151,
"~:y": 314
}
},
{
"~#point": {
"~:x": 1254,
"~:y": 314
}
},
{
"~#point": {
"~:x": 1254,
"~:y": 417
}
},
{
"~#point": {
"~:x": 1151,
"~:y": 417
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u0eef4dd0-b39b-807a-8005-93484afe7510",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 1151,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 1151,
"~:y": 314,
"~:width": 103,
"~:height": 103,
"~:x1": 1151,
"~:y1": 314,
"~:x2": 1254,
"~:y2": 417
}
},
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 103,
"~:flip-y": null
}
},
"~u0eef4dd0-b39b-807a-8005-93484d68344f": {
"~#shape": {
"~:y": null,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:content": [
{
"~:command": "~:move-to",
"~:params": {
"~:x": 1121,
"~:y": 554
}
},
{
"~:command": "~:line-to",
"~:params": {
"~:x": 1229,
"~:y": 458
}
},
{
"~:command": "~:curve-to",
"~:params": {
"~:x": 1303,
"~:y": 518,
"~:c1x": 1229,
"~:c1y": 458,
"~:c2x": 1320,
"~:c2y": 492
}
},
{
"~:command": "~:curve-to",
"~:params": {
"~:x": 1219,
"~:y": 584,
"~:c1x": 1286,
"~:c1y": 544,
"~:c2x": 1258,
"~:c2y": 572
}
},
{
"~:command": "~:curve-to",
"~:params": {
"~:x": 1121,
"~:y": 554,
"~:c1x": 1180,
"~:c1y": 596,
"~:c2x": 1121,
"~:c2y": 554
}
}
],
"~:name": "Path",
"~:width": null,
"~:type": "~:path",
"~:points": [
{
"~#point": {
"~:x": 1121,
"~:y": 458
}
},
{
"~#point": {
"~:x": 1305.1163606979621,
"~:y": 458
}
},
{
"~#point": {
"~:x": 1305.1163606979621,
"~:y": 586.15625
}
},
{
"~#point": {
"~:x": 1121,
"~:y": 586.15625
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u0eef4dd0-b39b-807a-8005-93484d68344f",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:inner",
"~:stroke-width": 2,
"~:stroke-color": "#000000",
"~:stroke-opacity": 1
}
],
"~:x": null,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 1121,
"~:y": 458,
"~:width": 184.11636069796214,
"~:height": 128.15625,
"~:x1": 1121,
"~:y1": 458,
"~:x2": 1305.1163606979621,
"~:y2": 586.15625
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": null,
"~:flip-y": null
}
},
"~u0eef4dd0-b39b-807a-8005-9348531e39f7": {
"~#shape": {
"~:y": 645,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Board",
"~:width": 140,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 1150,
"~:y": 645
}
},
{
"~#point": {
"~:x": 1290,
"~:y": 645
}
},
{
"~#point": {
"~:x": 1290,
"~:y": 766
}
},
{
"~#point": {
"~:x": 1150,
"~:y": 766
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u0eef4dd0-b39b-807a-8005-9348531e39f7",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 1150,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 1150,
"~:y": 645,
"~:width": 140,
"~:height": 121,
"~:x1": 1150,
"~:y1": 645,
"~:x2": 1290,
"~:y2": 766
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 121,
"~:flip-y": null,
"~:shapes": []
}
},
"~u0eef4dd0-b39b-807a-8005-934855b02e52": {
"~#shape": {
"~:y": 188,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Rectangle",
"~:width": 100,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 1154,
"~:y": 188
}
},
{
"~#point": {
"~:x": 1254,
"~:y": 188
}
},
{
"~#point": {
"~:x": 1254,
"~:y": 283
}
},
{
"~#point": {
"~:x": 1154,
"~:y": 283
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u0eef4dd0-b39b-807a-8005-934855b02e52",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 1154,
"~:proportion": 1,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 1154,
"~:y": 188,
"~:width": 100,
"~:height": 95,
"~:x1": 1154,
"~:y1": 188,
"~:x2": 1254,
"~:y2": 283
}
},
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 95,
"~:flip-y": null
}
},
"~ud4467b11-7129-80c3-8005-934871e790b5": {
"~#shape": {
"~:y": 856,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:font-size": "44",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "Uno dos tres cuatro"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "dqq7j",
"~:font-size": "44",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
]
},
"~:hide-in-viewer": false,
"~:name": "Uno dos tres cuatro",
"~:width": 360,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 1094,
"~:y": 856
}
},
{
"~#point": {
"~:x": 1454,
"~:y": 856
}
},
{
"~#point": {
"~:x": 1454,
"~:y": 909
}
},
{
"~#point": {
"~:x": 1094,
"~:y": 909
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ud4467b11-7129-80c3-8005-934871e790b5",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:position-data": [
{
"~#rect": {
"~:y": 910,
"~:font-style": "normal",
"~:text-transform": "none",
"~:font-size": "44px",
"~:font-weight": "400",
"~:y1": -3,
"~:width": 359.546875,
"~:text-decoration": "none solid rgb(0, 0, 0)",
"~:letter-spacing": "normal",
"~:x": 1094,
"~:x1": 0,
"~:y2": 54,
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:x2": 359.546875,
"~:direction": "ltr",
"~:font-family": "sourcesanspro",
"~:height": 57,
"~:text": "Uno dos tres cuatro"
}
}
],
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 1094,
"~:selrect": {
"~#rect": {
"~:x": 1094,
"~:y": 856,
"~:width": 360,
"~:height": 53,
"~:x1": 1094,
"~:y1": 856,
"~:x2": 1454,
"~:y2": 909
}
},
"~:flip-x": null,
"~:height": 53,
"~:flip-y": null
}
}
},
"~:id": "~u870f9f10-87b5-8137-8005-934804124661",
"~:name": "Page 1"
}
}

View file

@ -0,0 +1,49 @@
{
"~:features": {
"~#set": [
"layout/grid",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 6",
"~:revn": 13,
"~:modified-at": "~m1736778551377",
"~:vern": 0,
"~:id": "~u870f9f10-87b5-8137-8005-934804124660",
"~:is-shared": false,
"~:version": 60,
"~:project-id": "~u3ffbd505-2f26-800f-8004-f34da98bdad8",
"~:created-at": "~m1736778427466",
"~:data": {
"~:pages": [
"~u870f9f10-87b5-8137-8005-934804124661"
],
"~:pages-index": {
"~u870f9f10-87b5-8137-8005-934804124661": {
"~#penpot/pointer": [
"~u870f9f10-87b5-8137-8005-93487d148645",
{
"~:created-at": "~m1736778551378"
}
]
}
},
"~:id": "~u870f9f10-87b5-8137-8005-934804124660",
"~:options": {
"~:components-v2": true
}
}
}

View file

@ -250,3 +250,60 @@ test("User have edition menu entries", async ({ page }) => {
await expect(page.getByText("Undo")).toBeVisible();
await expect(page.getByText("Redo")).toBeVisible();
});
test("Copy/paste properties", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
// Access to the read/write clipboard necesary for this functionality
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.getByTestId("layer-item").getByRole("button").first().click();
await page
.getByTestId("children-0eef4dd0-b39b-807a-8005-934805578f93")
.getByText("Rectangle")
.click({ button: "right" });
await page.getByText("Copy/Paste as").hover();
await page.getByText("Copy properties").click();
await page
.getByTestId("layer-item")
.getByText("Uno dos tres cuatro")
.click({ button: "right" });
await page.getByText("Copy/Paste as").hover();
await page.getByText("Paste properties").click();
await page.getByText("Rectangle").first().click({ button: "right" });
await page.getByText("Copy/Paste as").hover();
await page.getByText("Paste properties").click();
await page.getByText("Board").nth(2).click({ button: "right" });
await page.getByText("Copy/Paste as").hover();
await page.getByText("Paste properties").click();
await page
.getByTestId("layer-item")
.locator("div")
.filter({ hasText: "Path" })
.nth(1)
.click({ button: "right" });
await page.getByText("Copy/Paste as").hover();
await page.getByText("Paste properties").click();
await page.getByText("Ellipse").click({ button: "right" });
await page.getByText("Copy/Paste as").hover();
await page.getByText("Paste properties").click();
});

View file

@ -81,6 +81,7 @@
[app.main.streams :as ms]
[app.main.worker :as uw]
[app.render-wasm :as wasm]
[app.util.code-gen.style-css :as css]
[app.util.dom :as dom]
[app.util.globals :as ug]
[app.util.http :as http]
@ -1411,7 +1412,8 @@
(rx/catch on-copy-error)
(rx/ignore))))))))))
(declare ^:private paste-transit)
(declare ^:private paste-transit-shapes)
(declare ^:private paste-transit-props)
(declare ^:private paste-html-text)
(declare ^:private paste-text)
(declare ^:private paste-image)
@ -1441,7 +1443,7 @@
(rx/of (paste-text data)))
:transit
(rx/of (paste-transit data))))
(rx/of (paste-transit-shapes data))))
(on-error [cause]
(let [data (ex-data cause)]
@ -1462,7 +1464,6 @@
(rx/take 1)
(rx/catch on-error))))))
(defn paste-from-event
"Perform a `paste` operation from user emmited event."
[event in-viewport?]
@ -1491,7 +1492,7 @@
(rx/map paste-image))
(coll? transit-data)
(rx/of (paste-transit (assoc transit-data :in-viewport in-viewport?)))
(rx/of (paste-transit-shapes (assoc transit-data :in-viewport in-viewport?)))
(string? html-data)
(rx/of (paste-html-text html-data text-data))
@ -1502,6 +1503,122 @@
:else
(rx/empty))))))))
(defn copy-selected-css
[]
(ptk/reify ::copy-selected-css
ptk/EffectEvent
(effect [_ state _]
(let [objects (wsh/lookup-page-objects state)
selected (->> (wsh/lookup-selected state) (mapv (d/getf objects)))
css (css/generate-style objects selected selected {:with-prelude? false})]
(wapi/write-to-clipboard css)))))
(defn copy-selected-css-nested
[]
(ptk/reify ::copy-selected-css-nested
ptk/EffectEvent
(effect [_ state _]
(let [objects (wsh/lookup-page-objects state)
selected (->> (wsh/lookup-selected state)
(cfh/selected-with-children objects)
(mapv (d/getf objects)))
css (css/generate-style objects selected selected {:with-prelude? false})]
(wapi/write-to-clipboard css)))))
(defn copy-selected-props
[]
(ptk/reify ::copy-selected-props
ptk/WatchEvent
(watch [_ state _]
(letfn [(fetch-image [entry]
(let [url (cf/resolve-file-media entry)]
(->> (http/send! {:method :get
:uri url
:response-type :blob})
(rx/map :body)
(rx/mapcat wapi/read-file-as-data-url)
(rx/map #(assoc entry :data %)))))
(resolve-images [data]
(let [images
(concat
(->> data :props :fills (keep :fill-image))
(->> data :props :strokes (keep :stroke-image)))]
(if (seq images)
(->> (rx/from images)
(rx/mapcat fetch-image)
(rx/reduce conj #{})
(rx/map #(assoc data :images %)))
(rx/of data))))
(on-copy-error [error]
(js/console.error "clipboard blocked:" error)
(rx/empty))]
(let [selected (->> (wsh/lookup-selected state) first)
objects (wsh/lookup-page-objects state)]
(when-let [shape (get objects selected)]
(let [props (cts/extract-props shape)
features (-> (features/get-team-enabled-features state)
(set/difference cfeat/frontend-only-features))
version (dm/get-in state [:workspace-file :version])
copy-data {:type :copied-props
:features features
:version version
:props props
:images #{}}]
;; The clipboard API doesn't handle well asynchronous calls because it expects to use
;; the clipboard in an user interaction. If you do an async call the callback is outside
;; the thread of the UI and so Safari blocks the copying event.
;; We use the API `ClipboardItem` that allows promises to be passed and so the event
;; will wait for the promise to resolve and everything should work as expected.
;; This only works in the current versions of the browsers.
(if (some? (unchecked-get ug/global "ClipboardItem"))
(let [resolve-data-promise
(p/create
(fn [resolve reject]
(->> (rx/of copy-data)
(rx/mapcat resolve-images)
(rx/map #(t/encode-str % {:type :json-verbose}))
(rx/map #(wapi/create-blob % "text/plain"))
(rx/subs! resolve reject))))]
(->> (rx/from (wapi/write-to-clipboard-promise "text/plain" resolve-data-promise))
(rx/catch on-copy-error)
(rx/ignore)))
;; FIXME: this is to support Firefox versions below 116 that don't support
;; `ClipboardItem` after the version 116 is less common we could remove this.
;; https://caniuse.com/?search=ClipboardItem
(->> (rx/of copy-data)
(rx/mapcat resolve-images)
(rx/map #(wapi/write-to-clipboard (t/encode-str % {:type :json-verbose})))
(rx/catch on-copy-error)
(rx/ignore))))))))))
(defn paste-selected-props
[]
(ptk/reify ::paste-selected-props
ptk/WatchEvent
(watch [_ _ _]
(letfn [(decode-entry [entry]
(-> entry t/decode-str paste-transit-props))
(on-error [cause]
(let [data (ex-data cause)]
(if (:not-implemented data)
(rx/of (ntf/warn (tr "errors.clipboard-not-implemented")))
(js/console.error "Clipboard error:" cause))
(rx/empty)))]
(->> (wapi/read-from-clipboard)
(rx/map decode-entry)
(rx/take 1)
(rx/catch on-error))))))
(defn selected-frame? [state]
(let [selected (wsh/lookup-selected state)
objects (wsh/lookup-page-objects state)]
@ -1530,8 +1647,8 @@
(:width (:selrect frame-obj)))))
(def ^:private
schema:paste-data
[:map {:title "paste-data"}
schema:paste-data-shapes
[:map {:title "paste-data-shapes"}
[:type [:= :copied-shapes]]
[:features ::sm/set-of-strings]
[:version :int]
@ -1542,12 +1659,26 @@
[:images [:set :map]]
[:position {:optional true} ::gpt/point]])
(def ^:private
schema:paste-data-props
[:map {:title "paste-data-props"}
[:type [:= :copied-props]]
[:features ::sm/set-of-strings]
[:version :int]
[:props
;; todo type the properties
[:map-of :keyword :any]]])
(def schema:paste-data
[:multi {:title "paste-data" :dispatch :type}
[:copied-shapes schema:paste-data-shapes]
[:copied-props schema:paste-data-props]])
(def paste-data-valid?
(sm/lazy-validator schema:paste-data))
(defn- paste-transit
(defn- paste-transit-shapes
[{:keys [images] :as pdata}]
(letfn [(upload-media [file-id imgpart]
(->> (http/send! {:uri (:data imgpart)
:response-type :blob
@ -1562,7 +1693,7 @@
(rx/mapcat (partial rp/cmd! :upload-file-media-object))
(rx/map #(assoc % :prev-id (:id imgpart)))))]
(ptk/reify ::paste-transit
(ptk/reify ::paste-transit-shapes
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
@ -1574,14 +1705,88 @@
:hibt "invalid paste data found"))
(cfeat/check-paste-features! features (:features pdata))
(if (= file-id (:file-id pdata))
(let [pdata (assoc pdata :images [])]
(rx/of (paste-shapes pdata)))
(->> (rx/from images)
(case (:type pdata)
:copied-shapes
(if (= file-id (:file-id pdata))
(let [pdata (assoc pdata :images [])]
(rx/of (paste-shapes pdata)))
(->> (rx/from images)
(rx/merge-map (partial upload-media file-id))
(rx/reduce conj [])
(rx/map #(assoc pdata :images %))
(rx/map paste-shapes)))
nil))))))
(defn- paste-transit-props
[pdata]
(letfn [(upload-media [file-id imgpart]
(->> (http/send! {:uri (:data imgpart)
:response-type :blob
:method :get})
(rx/map :body)
(rx/map
(fn [blob]
{:name (:name imgpart)
:file-id file-id
:content blob
:is-local true}))
(rx/mapcat (partial rp/cmd! :upload-file-media-object))
(rx/map #(vector (:id imgpart) %))))
(update-image-data
[pdata media-map]
(update
pdata :props
(fn [props]
(-> props
(d/update-when
:fills
(fn [fills]
(mapv (fn [fill]
(cond-> fill
(some? (:fill-image fill))
(update-in [:fill-image :id] #(get media-map % %))))
fills)))
(d/update-when
:strokes
(fn [strokes]
(mapv (fn [stroke]
(cond-> stroke
(some? (:stroke-image stroke))
(update-in [:stroke-image :id] #(get media-map % %))))
strokes)))))))
(upload-images
[file-id pdata]
(->> (rx/from (:images pdata))
(rx/merge-map (partial upload-media file-id))
(rx/reduce conj [])
(rx/map #(assoc pdata :images %))
(rx/map paste-shapes))))))))
(rx/reduce conj {})
(rx/map (partial update-image-data pdata))))]
(ptk/reify ::paste-transit-props
ptk/WatchEvent
(watch [_ state _]
(let [features (features/get-team-enabled-features state)
selected (wsh/lookup-selected state)]
(when (paste-data-valid? pdata)
(cfeat/check-paste-features! features (:features pdata))
(case (:type pdata)
:copied-props
(rx/concat
(->> (rx/of pdata)
(rx/mapcat (partial upload-images (:current-file-id state)))
(rx/map
#(dwsh/update-shapes
selected
(fn [shape objects] (cts/patch-props shape (:props pdata) objects))
{:with-objects? true})))
(rx/of (ptk/data-event :layout/update {:ids selected})))
;;
(rx/empty))))))))
(defn paste-shapes
[{in-viewport? :in-viewport :as pdata}]

View file

@ -85,8 +85,8 @@
:subsections [:edit]
:fn #(st/emit! (dw/copy-selected))}
:copy-link {:tooltip (ds/meta (ds/alt "C"))
:command (ds/c-mod "alt+c")
:copy-link {:tooltip (ds/shift (ds/alt "C"))
:command "shift+alt+c"
:subsections [:edit]
:fn #(st/emit! (dw/copy-link-to-clipboard))}
@ -103,6 +103,16 @@
:subsections [:edit]
:fn (constantly nil)}
:copy-props {:tooltip (ds/meta (ds/alt "c"))
:command (ds/c-mod "alt+c")
:subsections [:edit]
:fn #(st/emit! (dw/copy-selected-props))}
:paste-props {:tooltip (ds/meta (ds/alt "v"))
:command (ds/c-mod "alt+v")
:subsections [:edit]
:fn #(st/emit! (dw/paste-selected-props))}
:delete {:tooltip (ds/supr)
:command ["del" "backspace"]
:subsections [:edit]

View file

@ -321,7 +321,7 @@
.comment-input {
@include bodySmallTypography;
white-space: pre;
white-space: pre-line;
background: var(--input-background-color);
border-radius: $br-8;
border: $s-1 solid var(--input-border-color);

View file

@ -11,10 +11,12 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.transit :as t]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.page :as ctp]
[app.common.types.shape.layout :as ctl]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.shortcuts :as scd]
@ -35,6 +37,8 @@
[app.util.dom :as dom]
[app.util.i18n :refer [tr] :as i18n]
[app.util.timers :as timers]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[okulary.core :as l]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
@ -141,12 +145,44 @@
do-cut #(st/emit! (dw/copy-selected)
(dw/delete-selected))
do-paste #(st/emit! (dw/paste-from-clipboard))
do-duplicate #(st/emit! (dw/duplicate-selected true))]
do-duplicate #(st/emit! (dw/duplicate-selected true))
enabled-paste-props* (mf/use-state false)
handle-copy-css
(mf/use-callback #(st/emit! (dw/copy-selected-css)))
handle-copy-css-nested
(mf/use-callback #(st/emit! (dw/copy-selected-css-nested)))
handle-copy-props
(mf/use-callback #(st/emit! (dw/copy-selected-props)))
handle-paste-props
(mf/use-callback #(st/emit! (dw/paste-selected-props)))
handle-hover-copy-paste
(mf/use-callback
(fn []
(->> (wapi/read-from-clipboard)
(rx/take 1)
(rx/subs!
(fn [data]
(try
(let [pdata (t/decode-str data)]
(reset! enabled-paste-props*
(and (dw/paste-data-valid? pdata)
(= :copied-props (:type pdata)))))
(catch :default _
(reset! enabled-paste-props* false))))
(fn []
(reset! enabled-paste-props* false))))))]
[:*
[:> menu-entry* {:title (tr "workspace.shape.menu.copy")
:shortcut (sc/get-tooltip :copy)
:on-click do-copy}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy_link")
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-link")
:shortcut (sc/get-tooltip :copy-link)
:on-click do-copy-link}]
[:> menu-entry* {:title (tr "workspace.shape.menu.cut")
@ -159,6 +195,23 @@
:shortcut (sc/get-tooltip :duplicate)
:on-click do-duplicate}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-paste-as")
:on-pointer-enter (when (cf/check-browser? :chrome) handle-hover-copy-paste)}
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-css")
:on-click handle-copy-css}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-css-nested")
:on-click handle-copy-css-nested}]
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-props")
:shortcut (sc/get-tooltip :copy-props)
:on-click handle-copy-props}]
[:> menu-entry* {:title (tr "workspace.shape.menu.paste-props")
:shortcut (sc/get-tooltip :paste-props)
:disabled (and (cf/check-browser? :chrome) (not @enabled-paste-props*))
:on-click handle-paste-props}]]
[:> menu-separator* {}]]))
(mf/defc context-menu-layer-position*

View file

@ -5991,9 +5991,24 @@ msgid "workspace.shape.menu.copy"
msgstr "Copy"
#: src/app/main/ui/workspace/context_menu.cljs:146
msgid "workspace.shape.menu.copy_link"
msgid "workspace.shape.menu.copy-link"
msgstr "Copy link to clipboard"
msgid "workspace.shape.menu.copy-paste-as"
msgstr "Copy/Paste as ..."
msgid "workspace.shape.menu.copy-css"
msgstr "Copy as CSS"
msgid "workspace.shape.menu.copy-css-nested"
msgstr "Copy as CSS (nested layers)"
msgid "workspace.shape.menu.copy-props"
msgstr "Copy properties"
msgid "workspace.shape.menu.paste-props"
msgstr "Paste properties"
#: src/app/main/ui/workspace/sidebar/assets/common.cljs:431
msgid "workspace.shape.menu.create-annotation"
msgstr "Create annotation"
@ -6956,3 +6971,4 @@ msgstr "Notifications"
msgid "comments.mentions.not-found"
msgstr "No people found for @%s"

View file

@ -5990,9 +5990,24 @@ msgstr "Enviar atrás"
msgid "workspace.shape.menu.copy"
msgstr "Copiar"
msgid "workspace.shape.menu.copy_link"
msgid "workspace.shape.menu.copy-link"
msgstr "Copiar enlace al portapapeles"
msgid "workspace.shape.menu.copy-paste-as"
msgstr "Copiar/Pegar como ..."
msgid "workspace.shape.menu.copy-css"
msgstr "Copiar como CSS"
msgid "workspace.shape.menu.copy-css-nested"
msgstr "Copiar como CSS (capas anidadas)"
msgid "workspace.shape.menu.copy-props"
msgstr "Copiar propiedades"
msgid "workspace.shape.menu.paste-props"
msgstr "Pegar propiedades"
#: src/app/main/ui/workspace/sidebar/assets/common.cljs:427
msgid "workspace.shape.menu.create-annotation"
msgstr "Crear una nota"