From d42217a32be817b22d28f7e5ba14e87983ad9e74 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Thu, 5 Nov 2026 01:40:14 +0900 Subject: [PATCH] :bug: Create Plugin API shapes at the page root (#12499) * :bug: 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 * :bug: 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 * :wrench: 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 Co-authored-by: alonso.torres --- .../app/main/data/workspace/libraries.cljs | 8 +- .../src/app/main/data/workspace/media.cljs | 6 +- .../app/main/data/workspace/svg_upload.cljs | 11 +- frontend/src/app/plugins/library.cljs | 2 +- .../plugins/creation_parent_test.cljs | 156 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + plugins/CHANGELOG.md | 1 + .../src/tests/components.test.ts | 14 ++ .../src/tests/shapes-factories.test.ts | 37 +++++ 9 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 frontend/test/frontend_tests/plugins/creation_parent_test.cljs diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 8ebcfacff7..56ad36cbc6 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -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)] diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index f3a395a504..979b84bc4d 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -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))))) diff --git a/frontend/src/app/main/data/workspace/svg_upload.cljs b/frontend/src/app/main/data/workspace/svg_upload.cljs index a972edb3d9..48a84f0b30 100644 --- a/frontend/src/app/main/data/workspace/svg_upload.cljs +++ b/frontend/src/app/main/data/workspace/svg_upload.cljs @@ -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 diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index be94b45249..e7a91f9236 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -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)))) diff --git a/frontend/test/frontend_tests/plugins/creation_parent_test.cljs b/frontend/test/frontend_tests/plugins/creation_parent_test.cljs new file mode 100644 index 0000000000..76065ee734 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/creation_parent_test.cljs @@ -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 + "") + +(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 + ""))) + 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]))))))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index c0fee67ec6..0ddc7429cb 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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 diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 08b8e2d58e..7b667790fc 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -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. diff --git a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts index 92d68fd6aa..b2c16486f4 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts @@ -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(); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts index 6dc8be8fa5..eb0782cae0 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts @@ -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 = + '' + + ''; + + 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(); + } + }); + }); });