mirror of
https://github.com/penpot/penpot.git
synced 2026-11-06 01:50:39 +00:00
🐛 Create Plugin API shapes at the page root (#12499)
* 🐛 Create Plugin API shapes at the page root Create component instances and SVG shapes at the page root so a board at the page origin cannot capture them. Continue using the existing forced frame option for component instances and an explicit parent frame for SVG imports. Interactive placement retains its current behavior. Cover component instances, SVG imports and SVG imports with images on ordinary, flex and grid origin boards, including undo, redo, position changes and explicit reparenting. Closes #12086 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: choi junsoo <junsoo1172@gmail.com> * 🐛 Preserve interactive creation placement Cover component and SVG placement inside and outside an origin board. Exercise the sidebar move lifecycle and verify parent preservation. Correct the Plugin API changelog category. AI-assisted-by: gpt-6 Signed-off-by: choi junsoo <junsoo1172@gmail.com> * 🔧 Cover page-root creation in the Plugin API test suite Check that LibraryComponent.instance(), createShapeFromSvg() and createShapeFromSvgWithImages() create shapes at the page root when a board covers the page origin. Each test removes the shape it creates, since it lives outside the scratch board. AI-assisted-by: claude-opus-5-5 --------- Signed-off-by: choi junsoo <junsoo1172@gmail.com> Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
parent
4be8df5877
commit
d42217a32b
@ -614,7 +614,7 @@
|
||||
in the given file library. Then selects the newly created instance."
|
||||
([file-id component-id position]
|
||||
(instantiate-component file-id component-id position nil))
|
||||
([file-id component-id position {:keys [start-move? initial-point id-ref origin]}]
|
||||
([file-id component-id position {:keys [start-move? initial-point id-ref origin frame-id]}]
|
||||
(dm/assert! (uuid? file-id))
|
||||
(dm/assert! (uuid? component-id))
|
||||
(dm/assert! (gpt/point? position))
|
||||
@ -637,7 +637,11 @@
|
||||
component-id
|
||||
position
|
||||
page
|
||||
libraries)
|
||||
libraries
|
||||
nil
|
||||
frame-id
|
||||
frame-id
|
||||
{:force-frame? (some? frame-id)})
|
||||
component (ctn/get-component-from-shape new-shape libraries)
|
||||
|
||||
undo-id (js/Symbol)]
|
||||
|
||||
@ -487,7 +487,8 @@
|
||||
(watch [_ _ _]
|
||||
(->> (svg->clj [name svg-string])
|
||||
(rx/take 1)
|
||||
(rx/map #(svg/add-svg-shapes id % position {:ignore-selection? true
|
||||
(rx/map #(svg/add-svg-shapes id % position {:frame-id uuid/zero
|
||||
:ignore-selection? true
|
||||
:change-selection? false}))))))
|
||||
(defn create-svg-shape-with-images
|
||||
[file-id id name svg-string position on-success on-error]
|
||||
@ -506,7 +507,8 @@
|
||||
id
|
||||
svg-data
|
||||
position
|
||||
{:ignore-selection? true
|
||||
{:frame-id uuid/zero
|
||||
:ignore-selection? true
|
||||
:change-selection? false})))
|
||||
(rx/tap on-success)
|
||||
(rx/catch on-error)))))
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
([svg-data position]
|
||||
(add-svg-shapes nil svg-data position nil))
|
||||
|
||||
([id svg-data position {:keys [change-selection? ignore-selection?]
|
||||
([id svg-data position {:keys [change-selection? ignore-selection? frame-id]
|
||||
:or {ignore-selection? false change-selection? true}}]
|
||||
(ptk/reify ::add-svg-shapes
|
||||
ptk/WatchEvent
|
||||
@ -88,10 +88,11 @@
|
||||
|
||||
base-id (:parent-id base)
|
||||
|
||||
frame-id (if (or selected-frame? (empty? selected)
|
||||
(not= :frame (dm/get-in objects [base-id :type])))
|
||||
(ctst/top-nested-frame objects position)
|
||||
base-id)
|
||||
frame-id (or frame-id
|
||||
(if (or selected-frame? (empty? selected)
|
||||
(not= :frame (dm/get-in objects [base-id :type])))
|
||||
(ctst/top-nested-frame objects position)
|
||||
base-id))
|
||||
|
||||
parent-id (if (or selected-frame? (empty? selected) (nil? base))
|
||||
frame-id
|
||||
|
||||
@ -936,7 +936,7 @@
|
||||
|
||||
:else
|
||||
(let [id-ref (atom nil)]
|
||||
(st/emit! (-> (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin"})
|
||||
(st/emit! (-> (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin" :frame-id uuid/zero})
|
||||
(se/add-event plugin-id)))
|
||||
(shape/shape-proxy plugin-id @id-ref))))
|
||||
|
||||
|
||||
156
frontend/test/frontend_tests/plugins/creation_parent_test.cljs
Normal file
156
frontend/test/frontend_tests/plugins/creation_parent_test.cljs
Normal file
@ -0,0 +1,156 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.plugins.creation-parent-test
|
||||
(:require
|
||||
["sax$default" :as sax]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.test-helpers.components :as cthc]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.svg-upload :as dwsvg]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.main.streams :as ms]
|
||||
[app.main.worker :as mw]
|
||||
[app.plugins.api :as api]
|
||||
[app.plugins.library :as library]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.async :as async]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.tokens.helpers.state :as tohs]
|
||||
[potok.v2.core :as ptk]
|
||||
[tubax.core :as tubax]))
|
||||
|
||||
(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
(def ^:private svg
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\"><rect width=\"24\" height=\"24\"/></svg>")
|
||||
|
||||
(t/deftest ^:async plugin-creation-uses-the-page-root
|
||||
(doseq [layout [nil :flex :grid]
|
||||
creation [:component :svg :svg-with-images]]
|
||||
(let [file (-> (cthf/sample-file :file :page-label :page)
|
||||
(cths/add-sample-shape :main :type :frame :x 1000 :y 1000)
|
||||
(cths/add-sample-shape :child :parent-label :main)
|
||||
(cthc/make-component :component :main)
|
||||
(cths/add-sample-shape :board :type :frame :x 0 :y 0
|
||||
:width 400 :height 300 :layout layout
|
||||
:layout-grid-rows [] :layout-grid-columns []
|
||||
:layout-grid-cells {}))
|
||||
store (ths/setup-store file {:renderer :svg})
|
||||
uploads (atom [])
|
||||
path [:files (:id file) :data :pages-index (thi/id :page) :objects]]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store
|
||||
st/emit! (fn
|
||||
([event] (ptk/emit! store event))
|
||||
([event & events] (apply ptk/emit! store event events)))
|
||||
mw/ask! (mock/stub (fn [_] (rx/empty)))
|
||||
rp/cmd! (mock/stub (fn [cmd params]
|
||||
(swap! uploads conj [cmd params])
|
||||
(rx/observe-on
|
||||
:async
|
||||
(rx/of {:id (uuid/next) :width 10 :height 20
|
||||
:mtype "image/png" :name "image"}))))
|
||||
;; Node exposes sax's CommonJS parser on the default export.
|
||||
tubax/create-parser (mock/stub (fn [_] (sax/parser true #js {})))}
|
||||
(ptk/emit! store (tohs/watch-undo-stack))
|
||||
(let [^js context (api/create-context plugin-id)
|
||||
^js created (case creation
|
||||
:component (.instance (library/lib-component-proxy plugin-id (:id file) (thi/id :component)))
|
||||
:svg (.createShapeFromSvg context svg)
|
||||
:svg-with-images
|
||||
(await (.createShapeFromSvgWithImages
|
||||
context
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\"><image href=\"https://example.com/image.png\" width=\"10\" height=\"20\"/></svg>")))
|
||||
id (obj/get created "$id")]
|
||||
(await (async/wait-for #(some? (get-in @store (conj path id))) "created shape committed"))
|
||||
(let [created-shape (get-in @store (conj path id))]
|
||||
(t/is (= uuid/zero (:parent-id created-shape)) (str creation " / " layout))
|
||||
(t/is (= uuid/zero (:frame-id created-shape)) (str creation " / " layout))
|
||||
(t/is (not (some #{id} (get-in @store (into path [(thi/id :board) :shapes])))))
|
||||
(t/is (= 1 (count (get-in @store [:workspace-undo :items])))))
|
||||
(when (= creation :svg-with-images)
|
||||
(t/is (= [:create-file-media-object-from-url] (mapv first @uploads))))
|
||||
(ptk/emit! store dwu/undo)
|
||||
(await (async/wait-for #(nil? (get-in @store (conj path id))) "creation undone"))
|
||||
(t/is (nil? (get-in @store (conj path id))))
|
||||
(t/is (empty? (get-in @store (into path [(thi/id :board) :shapes]))))
|
||||
(ptk/emit! store dwu/redo)
|
||||
(await (async/wait-for #(some? (get-in @store (conj path id))) "creation redone"))
|
||||
(set! (.-x created) 1000)
|
||||
(set! (.-y created) 1200)
|
||||
(await (async/wait-for #(= [1000 1200] ((juxt :x :y) (get-in @store (conj path id)))) "position updated"))
|
||||
(t/is (= [1000 1200] ((juxt :x :y) (get-in @store (conj path id)))))
|
||||
(let [^js page (.-currentPage context)
|
||||
^js board (.getShapeById page (str (thi/id :board)))]
|
||||
(.appendChild board created))
|
||||
(await (async/wait-for #(= (thi/id :board) (get-in @store (into path [id :parent-id]))) "explicit parent applied"))
|
||||
(t/is (= (thi/id :board) (get-in @store (into path [id :parent-id]))))))))))
|
||||
|
||||
(t/deftest ^:async interactive-creation-keeps-coordinate-based-parents
|
||||
(doseq [[creation position parent-label] [[:component (gpt/point 50 60) :board]
|
||||
[:component (gpt/point 600 700) nil]
|
||||
[:svg (gpt/point 50 60) :board]
|
||||
[:svg (gpt/point 600 700) nil]]]
|
||||
(let [file (-> (cthf/sample-file :file :page-label :page)
|
||||
(cths/add-sample-shape :main :type :frame :x 1000 :y 1000)
|
||||
(cths/add-sample-shape :child :parent-label :main)
|
||||
(cthc/make-component :component :main)
|
||||
(cths/add-sample-shape :board :type :frame :x 0 :y 0
|
||||
:width 400 :height 300))
|
||||
store (ths/setup-store file {:renderer :svg})
|
||||
id-ref (atom nil)
|
||||
svg-id (uuid/next)
|
||||
parent (if parent-label (thi/id parent-label) uuid/zero)
|
||||
path [:files (:id file) :data :pages-index (thi/id :page) :objects]]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store
|
||||
st/emit! (fn
|
||||
([event] (ptk/emit! store event))
|
||||
([event & events] (apply ptk/emit! store event events)))
|
||||
mw/ask! (mock/stub (fn [_] (rx/empty)))
|
||||
dom/get-element-by-class (mock/stub (constantly nil))
|
||||
refs/vbox (atom (grc/make-rect 0 0 2000 2000))
|
||||
ms/mouse-position (rx/behavior-subject position)
|
||||
ms/mouse-position-alt (rx/behavior-subject false)
|
||||
ms/mouse-position-shift (rx/behavior-subject false)
|
||||
ms/mouse-position-mod (rx/behavior-subject false)}
|
||||
(let [events {:component (dwl/instantiate-component (:id file) (thi/id :component) position
|
||||
{:id-ref id-ref :origin "sidebar"
|
||||
:start-move? true :initial-point position})
|
||||
:svg (dwsvg/add-svg-shapes svg-id
|
||||
{:name "test.svg" :attrs {:width 24 :height 24}
|
||||
:content [{:tag :rect :attrs {:width "24" :height "24"}}]}
|
||||
position nil)}]
|
||||
(ptk/emit! store (get events creation)))
|
||||
(let [id (if (= creation :component) @id-ref svg-id)]
|
||||
(await (async/wait-for #(some? (get-in @store (conj path id))) "interactive shape committed"))
|
||||
(t/is (= parent (get-in @store (into path [id :parent-id]))))
|
||||
(t/is (= parent (get-in @store (into path [id :frame-id]))))
|
||||
(t/is (= #{id} (set (get-in @store [:workspace-local :selected]))))
|
||||
(when (= creation :component)
|
||||
(await (async/wait-for #(= :move (get-in @store [:workspace-local :transform])) "interactive move started"))
|
||||
(t/is (= :move (get-in @store [:workspace-local :transform])))
|
||||
(rx/push! ms/mouse-position position)
|
||||
(await (async/settle))
|
||||
(ptk/emit! store :interrupt)
|
||||
(await (async/wait-for #(nil? (get-in @store [:workspace-local :transform])) "interactive move finished"))
|
||||
(t/is (nil? (get-in @store [:workspace-local :transform])))
|
||||
(t/is (= parent (get-in @store (into path [id :parent-id])))))))))))
|
||||
@ -72,6 +72,7 @@
|
||||
[frontend-tests.main.refs-test]
|
||||
[frontend-tests.plugins.comments-test]
|
||||
[frontend-tests.plugins.context-shapes-test]
|
||||
[frontend-tests.plugins.creation-parent-test]
|
||||
[frontend-tests.plugins.events-test]
|
||||
[frontend-tests.plugins.file-test]
|
||||
[frontend-tests.plugins.flex-test]
|
||||
@ -253,6 +254,7 @@
|
||||
'frontend-tests.logic.wasm-pixel-snap-test
|
||||
'frontend-tests.plugins.comments-test
|
||||
'frontend-tests.plugins.context-shapes-test
|
||||
'frontend-tests.plugins.creation-parent-test
|
||||
'frontend-tests.plugins.events-test
|
||||
'frontend-tests.plugins.management-test
|
||||
'frontend-tests.plugins.file-test
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **plugin-runtime:** `LibraryComponent.instance()`, `createShapeFromSvg()` and `createShapeFromSvgWithImages()` now create shapes at the page root instead of inside a board at the page origin. Closes #12086.
|
||||
- **plugin-types, plugin-runtime:** Dashboard property setters now update values after backend success and log failures without interrupting the workspace. File moves reject projects in other teams, backend rejections include their type and code, and `project.fileCount` stays at its listing snapshot. Duplicated projects include their file count and pinned state.
|
||||
- **plugin-runtime:** Cancel plugin loads before they execute after unloading or logout, and allow reopening plugins that close during startup.
|
||||
- **plugins-runtime**: `library.connectLibrary()` now resolves once the library's assets are loaded; it used to resolve with an empty library the first time. `library.connected` and `library.availableLibraries()` ignore files opened earlier in the same tab, which used to show up as connected and be left out of the available libraries.
|
||||
|
||||
@ -64,6 +64,20 @@ describe('Component instances', () => {
|
||||
expect(inst.isVariantHead()).toBeFalsy();
|
||||
});
|
||||
|
||||
// instance() places the copy at the page origin; a board covering that point
|
||||
// must not capture it.
|
||||
test('instance creates the copy at the page root', (ctx) => {
|
||||
ctx.board.x = 0;
|
||||
ctx.board.y = 0;
|
||||
const inst = makeComponent(ctx).instance();
|
||||
try {
|
||||
expect(inst.parent?.id).toBe(ctx.penpot.root?.id);
|
||||
expect(ctx.board.children.map((c) => c.id)).not.toContain(inst.id);
|
||||
} finally {
|
||||
inst.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test('component navigation methods return shapes', (ctx) => {
|
||||
const inst = instanceOf(ctx);
|
||||
expect(inst.componentRoot()).toBeDefined();
|
||||
|
||||
@ -325,4 +325,41 @@ describe('Shapes', () => {
|
||||
expect(a.x).toBeCloseTo(10, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// The factories place new shapes at the page origin. A board covering that
|
||||
// point must not capture them: they belong to the page root until the plugin
|
||||
// reparents them.
|
||||
describe('Placement', () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">' +
|
||||
'<rect width="10" height="10" fill="#ff0000"/></svg>';
|
||||
|
||||
test('createShapeFromSvg creates the shape at the page root', (ctx) => {
|
||||
ctx.board.x = 0;
|
||||
ctx.board.y = 0;
|
||||
const group = ctx.penpot.createShapeFromSvg(svg);
|
||||
expect(group).not.toBeNull();
|
||||
if (!group) return;
|
||||
try {
|
||||
expect(group.parent?.id).toBe(ctx.penpot.root?.id);
|
||||
expect(ctx.board.children.map((c) => c.id)).not.toContain(group.id);
|
||||
} finally {
|
||||
group.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test('createShapeFromSvgWithImages creates the shape at the page root', async (ctx) => {
|
||||
ctx.board.x = 0;
|
||||
ctx.board.y = 0;
|
||||
const group = await ctx.penpot.createShapeFromSvgWithImages(svg);
|
||||
expect(group).not.toBeNull();
|
||||
if (!group) return;
|
||||
try {
|
||||
expect(group.parent?.id).toBe(ctx.penpot.root?.id);
|
||||
expect(ctx.board.children.map((c) => c.id)).not.toContain(group.id);
|
||||
} finally {
|
||||
group.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user