diff --git a/CHANGES.md b/CHANGES.md index c6ff637ed..34b96661c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 28c196227..4033b570a 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -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))))) diff --git a/frontend/playwright/data/workspace/get-file-copy-paste-fragment.json b/frontend/playwright/data/workspace/get-file-copy-paste-fragment.json new file mode 100644 index 000000000..888362433 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-copy-paste-fragment.json @@ -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" + } +} diff --git a/frontend/playwright/data/workspace/get-file-copy-paste.json b/frontend/playwright/data/workspace/get-file-copy-paste.json new file mode 100644 index 000000000..14d05eb92 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-copy-paste.json @@ -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 + } + } +} diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index 2356d5fae..81b073455 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -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(); +}); diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index e84646e67..a9821ba3d 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -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}] diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 3a41175bd..f7a7b591c 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -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] diff --git a/frontend/src/app/main/ui/comments.scss b/frontend/src/app/main/ui/comments.scss index 433a84a41..f6e2a37be 100644 --- a/frontend/src/app/main/ui/comments.scss +++ b/frontend/src/app/main/ui/comments.scss @@ -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); diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index c36ba6acb..dbbcbd066 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -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* diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 14e090feb..0ce02d628 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" + diff --git a/frontend/translations/es.po b/frontend/translations/es.po index bac438273..0344475bc 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"