diff --git a/apps/builder/app/builder/shared/commands.test.tsx b/apps/builder/app/builder/shared/commands.test.tsx
index 4ba7965a5467..c1d132daa60a 100644
--- a/apps/builder/app/builder/shared/commands.test.tsx
+++ b/apps/builder/app/builder/shared/commands.test.tsx
@@ -1,20 +1,27 @@
import { describe, expect, test } from "vitest";
+import { coreMetas } from "@webstudio-is/sdk";
import * as baseMetas from "@webstudio-is/sdk-components-react/metas";
import { createDefaultPages } from "@webstudio-is/project-build";
import { $, renderData, ws } from "@webstudio-is/template";
import {
$instances,
$pages,
+ $props,
$registeredComponentMetas,
} from "~/shared/nano-states";
import { registerContainers } from "~/shared/sync";
import { $awareness, selectInstance } from "~/shared/awareness";
-import { deleteSelectedInstance, unwrap, wrapIn } from "./commands";
+import {
+ deleteSelectedInstance,
+ replaceWith,
+ unwrap,
+ wrapIn,
+} from "./commands";
import { elementComponent } from "@webstudio-is/sdk";
registerContainers();
-const metas = new Map(Object.entries(baseMetas));
+const metas = new Map(Object.entries({ ...coreMetas, ...baseMetas }));
$registeredComponentMetas.set(metas);
$pages.set(createDefaultPages({ rootInstanceId: "" }));
$awareness.set({ pageId: "" });
@@ -167,6 +174,117 @@ describe("wrap in", () => {
});
});
+describe("replace with", () => {
+ test("replace legacy tag with element", () => {
+ const { instances, props } = renderData(
+
+ <$.Box tag="article" ws:id="articleId">$.Box>
+
+ );
+ $instances.set(instances);
+ $props.set(props);
+ selectInstance(["articleId", "bodyId"]);
+ replaceWith(elementComponent);
+ const { instances: newInstances, props: newProps } = renderData(
+
+
+
+ );
+ expect({ instances: $instances.get(), props: $props.get() }).toEqual({
+ instances: newInstances,
+ props: newProps,
+ });
+ });
+
+ test("migrate legacy properties as well", () => {
+ const { instances, props } = renderData(
+
+ <$.Box
+ ws:tag="div"
+ ws:id="divId"
+ className="my-class"
+ htmlFor="my-id"
+ >$.Box>
+
+ );
+ $instances.set(instances);
+ $props.set(props);
+ selectInstance(["divId", "bodyId"]);
+ replaceWith(elementComponent);
+ const { instances: newInstances, props: newProps } = renderData(
+
+
+
+ );
+ expect({ instances: $instances.get(), props: $props.get() }).toEqual({
+ instances: newInstances,
+ props: newProps,
+ });
+ });
+
+ test("preserve currently specified tag", () => {
+ $instances.set(
+ renderData(
+
+ <$.Box ws:tag="article" ws:id="articleId">$.Box>
+
+ ).instances
+ );
+ selectInstance(["articleId", "bodyId"]);
+ replaceWith(elementComponent);
+ expect($instances.get()).toEqual(
+ renderData(
+
+
+
+ ).instances
+ );
+ });
+
+ test("replace with first tag from presets", () => {
+ $instances.set(
+ renderData(
+
+ <$.Heading ws:id="headingId">$.Heading>
+
+ ).instances
+ );
+ selectInstance(["headingId", "bodyId"]);
+ replaceWith(elementComponent);
+ expect($instances.get()).toEqual(
+ renderData(
+
+
+
+ ).instances
+ );
+ });
+
+ test("fallback to div", () => {
+ $instances.set(
+ renderData(
+
+ <$.Box ws:id="divId">$.Box>
+
+ ).instances
+ );
+ selectInstance(["divId", "bodyId"]);
+ replaceWith(elementComponent);
+ expect($instances.get()).toEqual(
+ renderData(
+
+
+
+ ).instances
+ );
+ });
+});
+
describe("unwrap", () => {
test("unwrap instance", () => {
$instances.set(
diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts
index 1a23a906ec80..ada4d6139988 100644
--- a/apps/builder/app/builder/shared/commands.ts
+++ b/apps/builder/app/builder/shared/commands.ts
@@ -55,6 +55,8 @@ import { generateFragmentFromHtml } from "~/shared/html";
import { generateFragmentFromTailwind } from "~/shared/tailwind/tailwind";
import { denormalizeSrcProps } from "~/shared/copy-paste/asset-upload";
import { getInstanceLabel } from "./instance-label";
+import { $instanceTags } from "../features/style-panel/shared/model";
+import { reactPropsToStandardAttributes } from "@webstudio-is/react-sdk";
export const $styleSourceInputElement = atom();
@@ -174,7 +176,7 @@ export const wrapIn = (component: string, tag?: string) => {
component,
children: [{ type: "id", value: selectedInstance.id }],
};
- if (tag || elementComponent) {
+ if (tag || component === elementComponent) {
newInstance.tag = tag ?? "div";
}
const parentInstance = data.instances.get(parentItem.instance.id);
@@ -204,6 +206,61 @@ export const wrapIn = (component: string, tag?: string) => {
}
};
+export const replaceWith = (component: string, tag?: string) => {
+ const instancePath = $selectedInstancePath.get();
+ // global root or body are selected
+ if (instancePath === undefined || instancePath.length === 1) {
+ return;
+ }
+ const [selectedItem] = instancePath;
+ const selectedInstance = selectedItem.instance;
+ const selectedInstanceSelector = selectedItem.instanceSelector;
+ const metas = $registeredComponentMetas.get();
+ const instanceTags = $instanceTags.get();
+ try {
+ updateWebstudioData((data) => {
+ const instance = data.instances.get(selectedInstance.id);
+ if (instance === undefined) {
+ return;
+ }
+ instance.component = component;
+ // replace with specified tag or with currently used
+ if (tag || component === elementComponent) {
+ instance.tag = tag ?? instanceTags.get(selectedInstance.id) ?? "div";
+ // delete legacy tag prop if specified
+ for (const prop of data.props.values()) {
+ if (prop.instanceId !== selectedInstance.id) {
+ continue;
+ }
+ if (prop.name === "tag") {
+ data.props.delete(prop.id);
+ continue;
+ }
+ const newName = reactPropsToStandardAttributes[prop.name];
+ if (newName) {
+ const newId = `${prop.instanceId}:${newName}`;
+ data.props.delete(prop.id);
+ data.props.set(newId, { ...prop, id: newId, name: newName });
+ }
+ }
+ }
+ const isSatisfying = isTreeSatisfyingContentModel({
+ instances: data.instances,
+ props: data.props,
+ metas,
+ instanceSelector: selectedInstanceSelector,
+ });
+ if (isSatisfying === false) {
+ const label = getInstanceLabel({ component, tag }, {});
+ toast.error(`Cannot replace with ${label}`);
+ throw Error("Abort transaction");
+ }
+ });
+ } catch {
+ // do nothing
+ }
+};
+
export const unwrap = () => {
const instancePath = $selectedInstancePath.get();
// global root or body are selected
@@ -529,6 +586,14 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
name: "unwrap",
handler: () => unwrap(),
},
+ {
+ name: "replaceWithElement",
+ handler: () => replaceWith(elementComponent),
+ },
+ {
+ name: "replaceWithLink",
+ handler: () => replaceWith(elementComponent, "a"),
+ },
...(isFeatureEnabled("tailwind")
? [