diff --git a/apps/builder/app/builder/features/ai/apply-operations.ts b/apps/builder/app/builder/features/ai/apply-operations.ts index 269b709d3217..7bcf7b8f5707 100644 --- a/apps/builder/app/builder/features/ai/apply-operations.ts +++ b/apps/builder/app/builder/features/ai/apply-operations.ts @@ -10,7 +10,6 @@ import { serverSyncStore } from "~/shared/sync"; import { isBaseBreakpoint } from "~/shared/breakpoints"; import { deleteInstanceMutable, - findClosestInsertable, insertWebstudioFragmentAt, updateWebstudioData, type Insertable, @@ -76,11 +75,10 @@ const insertTemplateByOp = ( const instanceSelector = computeSelectorForInstanceId(operation.addTo); if (instanceSelector) { - let insertable: Insertable = { + const insertable: Insertable = { parentSelector: instanceSelector, position: operation.addAtIndex + 1, }; - insertable = findClosestInsertable(fragment, insertable) ?? insertable; insertWebstudioFragmentAt(fragment, insertable); return rootInstanceIds; } diff --git a/apps/builder/app/builder/features/command-panel/command-panel.tsx b/apps/builder/app/builder/features/command-panel/command-panel.tsx index 394d7efb4401..9120acfb1dbb 100644 --- a/apps/builder/app/builder/features/command-panel/command-panel.tsx +++ b/apps/builder/app/builder/features/command-panel/command-panel.tsx @@ -38,7 +38,6 @@ import { $selectedBreakpointId, } from "~/shared/nano-states"; import { - findClosestInsertable, getComponentTemplateData, getInstanceLabel, insertWebstudioFragmentAt, @@ -207,10 +206,7 @@ const ComponentOptionsGroup = ({ options }: { options: ComponentOption[] }) => { closeCommandPanel(); const fragment = getComponentTemplateData(component); if (fragment) { - const insertable = findClosestInsertable(fragment); - if (insertable) { - insertWebstudioFragmentAt(fragment, insertable); - } + insertWebstudioFragmentAt(fragment); } }} > diff --git a/apps/builder/app/builder/features/components/components.tsx b/apps/builder/app/builder/features/components/components.tsx index 066d8e13c954..0dd04bd9c883 100644 --- a/apps/builder/app/builder/features/components/components.tsx +++ b/apps/builder/app/builder/features/components/components.tsx @@ -37,7 +37,6 @@ import { $registeredTemplates, } from "~/shared/nano-states"; import { - findClosestInsertable, getComponentTemplateData, getInstanceLabel, insertWebstudioFragmentAt, @@ -218,10 +217,7 @@ export const ComponentsPanel = ({ const handleInsert = (component: string) => { const fragment = getComponentTemplateData(component); if (fragment) { - const insertable = findClosestInsertable(fragment); - if (insertable) { - insertWebstudioFragmentAt(fragment, insertable); - } + insertWebstudioFragmentAt(fragment); } onClose(); }; diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts index 6c2a93e09618..12cdf3493f69 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts @@ -13,6 +13,7 @@ import { } from "~/shared/instance-utils"; import { $instances, + $project, $registeredComponentMetas, $textEditingInstanceSelector, findBlockChildSelector, @@ -69,7 +70,11 @@ const getInsertionIndex = ( }; export const insertListItemAt = (listItemSelector: InstanceSelector) => { + const project = $project.get(); const instances = $instances.get(); + if (project === undefined) { + return; + } const parentSelector = listItemSelector.slice(1); @@ -111,6 +116,7 @@ export const insertListItemAt = (listItemSelector: InstanceSelector) => { ...data, startingInstanceId: target.parentSelector[0], }), + projectId: project.id, }); const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); if (newRootInstanceId === undefined) { @@ -141,7 +147,11 @@ export const insertTemplateAt = ( anchor: InstanceSelector, insertBefore: boolean ) => { + const project = $project.get(); const instances = $instances.get(); + if (project === undefined) { + return; + } const fragment = extractWebstudioFragment( getWebstudioData(), @@ -173,6 +183,7 @@ export const insertTemplateAt = ( ...data, startingInstanceId: target.parentSelector[0], }), + projectId: project.id, }); const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); if (newRootInstanceId === undefined) { diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index ccf3b0489638..119f66dab503 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -16,6 +16,7 @@ import { $isContentMode, $registeredComponentMetas, findBlockSelector, + $project, } from "~/shared/nano-states"; import { $breakpointsMenuView, @@ -439,6 +440,10 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ name: "duplicateInstance", defaultHotkeys: ["meta+d", "ctrl+d"], handler: () => { + const project = $project.get(); + if (project === undefined) { + return; + } if ($isDesignMode.get() === false) { builderApi.toast.info("Duplicating is only allowed in design mode."); return; @@ -462,6 +467,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ ...data, startingInstanceId: parentItem.instanceSelector[0], }), + projectId: project.id, }); const newRootInstanceId = newInstanceIds.get( selectedItem.instance.id diff --git a/apps/builder/app/shared/content-model.ts b/apps/builder/app/shared/content-model.ts index b3a5cac2d3d5..e5dddcf1c643 100644 --- a/apps/builder/app/shared/content-model.ts +++ b/apps/builder/app/shared/content-model.ts @@ -398,7 +398,7 @@ export const isTreeSatisfyingContentModel = ({ return isSatisfying; }; -const richTextContentTags = new Set([ +export const richTextContentTags = new Set([ "sup", "sub", "b", diff --git a/apps/builder/app/shared/copy-paste.test.tsx b/apps/builder/app/shared/copy-paste.test.tsx index 4c816070851f..510e8ea8e000 100644 --- a/apps/builder/app/shared/copy-paste.test.tsx +++ b/apps/builder/app/shared/copy-paste.test.tsx @@ -129,12 +129,14 @@ test("insert instances with slots", () => { data, fragment, availableVariables: [], + projectId: "", }); expect(data.instances.size).toEqual(4); insertWebstudioFragmentCopy({ data, fragment, availableVariables: [], + projectId: "", }); expect(data.instances.size).toEqual(5); expect(Array.from(data.instances.values())).toEqual([ @@ -164,6 +166,7 @@ test("insert instances with multiple roots", () => { data, fragment, availableVariables: [], + projectId: "", }); expect(data.instances.size).toEqual(5); }); @@ -185,6 +188,7 @@ test("should add :root local styles", () => { data: newProject, fragment, availableVariables: [], + projectId: "", }); expect(toCss(newProject)).toEqual( stripIndent(` @@ -223,6 +227,7 @@ test("should merge :root local styles", () => { data: newProject, fragment, availableVariables: [], + projectId: "", }); expect(toCss(newProject)).toEqual( stripIndent(` @@ -252,6 +257,7 @@ test("should copy local styles of duplicated instance", () => { data: project, fragment, availableVariables: [], + projectId: "", }); const newInstanceId = Array.from(project.instances.keys()).at(-1); expect(toCss(project)).toEqual( @@ -317,6 +323,7 @@ describe("props", () => { data, fragment, availableVariables: [], + projectId: "", }); expect(Array.from(data.props.values())).toEqual([ expect.objectContaining({ @@ -345,6 +352,7 @@ describe("props", () => { data, fragment, availableVariables: [], + projectId: "", }); expect(Array.from(data.props.values())).toEqual([ expect.objectContaining({ @@ -437,6 +445,7 @@ describe("variables", () => { data, fragment, availableVariables: [], + projectId: "", }); const [newDataSourceId] = data.dataSources.keys(); expect(Array.from(data.dataSources.values())).toEqual([ @@ -488,6 +497,7 @@ describe("variables", () => { data, fragment, availableVariables: [], + projectId: "", }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ @@ -540,6 +550,7 @@ describe("variables", () => { ...data, startingInstanceId: "bodyId", }), + projectId: "", }); const newInstanceId = Array.from(data.instances.keys()).at(-1) ?? ""; expect(newInstanceId).not.toEqual("boxId"); @@ -636,6 +647,7 @@ describe("resources", () => { ...data, startingInstanceId: "bodyId", }), + projectId: "", }); const newInstanceId = Array.from(data.instances.keys()).at(-1); expect(newInstanceId).not.toEqual("boxId"); @@ -724,6 +736,7 @@ describe("resources", () => { ...data, startingInstanceId: "bodyId", }), + projectId: "", }); const newInstanceId = Array.from(data.instances.keys()).at(-1); expect(newInstanceId).not.toEqual("boxId"); @@ -767,6 +780,7 @@ describe("resources", () => { data, fragment, availableVariables: [], + projectId: "", }); const [newPropResourceId, newVariableResourceId] = data.resources.keys(); const [newBoxVariableId] = data.dataSources.keys(); @@ -833,6 +847,7 @@ describe("resources", () => { data, fragment, availableVariables: [], + projectId: "", }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ diff --git a/apps/builder/app/shared/copy-paste/init-copy-paste.ts b/apps/builder/app/shared/copy-paste/init-copy-paste.ts index 16eaabf1afb6..af4dcb0e9edc 100644 --- a/apps/builder/app/shared/copy-paste/init-copy-paste.ts +++ b/apps/builder/app/shared/copy-paste/init-copy-paste.ts @@ -4,6 +4,7 @@ import { $textEditingInstanceSelector, } from "../nano-states"; import { instanceText, instanceJson } from "./plugin-instance"; +import { html } from "./plugin-html"; import * as markdown from "./plugin-markdown"; import * as webflow from "./plugin-webflow/plugin-webflow"; import { builderApi } from "../builder-api"; @@ -132,7 +133,7 @@ const initPlugins = ({ export const initCopyPaste = ({ signal }: { signal: AbortSignal }) => { initPlugins({ - plugins: [instanceJson, instanceText, markdown, webflow], + plugins: [instanceJson, instanceText, html, markdown, webflow], signal, }); }; diff --git a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx new file mode 100644 index 000000000000..531fd92ce59f --- /dev/null +++ b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx @@ -0,0 +1,65 @@ +import { expect, test } from "vitest"; +import { setEnv } from "@webstudio-is/feature-flags"; +import { renderData, ws } from "@webstudio-is/template"; +import { createDefaultPages } from "@webstudio-is/project-build"; +import type { Project } from "@webstudio-is/project"; +import { registerContainers } from "../sync"; +import { $instances, $pages, $project } from "../nano-states"; +import { $awareness } from "../awareness"; +import { html } from "./plugin-html"; + +setEnv("*"); +registerContainers(); + +test("paste html fragment", () => { + const data = renderData( + + + + ); + $project.set({ id: "" } as Project); + $instances.set(data.instances); + $pages.set( + createDefaultPages({ rootInstanceId: "bodyId", homePageId: "pageId" }) + ); + $awareness.set({ pageId: "pageId", instanceSelector: ["divId", "bodyId"] }); + expect( + html.onPaste?.(` +
+

It works

+
+ `) + ).toEqual(true); + const [_bodyId, _divId, sectionId, headingId] = $instances.get().keys(); + expect(sectionId).toBeTruthy(); + expect(headingId).toBeTruthy(); + expect($instances.get()).toEqual( + renderData( + + + + + It works + + + + + ).instances + ); +}); + +test("ignore html without any tags", () => { + const data = renderData( + + + + ); + $project.set({ id: "" } as Project); + $instances.set(data.instances); + $pages.set( + createDefaultPages({ rootInstanceId: "bodyId", homePageId: "pageId" }) + ); + $awareness.set({ pageId: "pageId", instanceSelector: ["divId", "bodyId"] }); + expect(html.onPaste?.(`It works`)).toEqual(false); + expect($instances.get()).toEqual(data.instances); +}); diff --git a/apps/builder/app/shared/copy-paste/plugin-html.ts b/apps/builder/app/shared/copy-paste/plugin-html.ts new file mode 100644 index 000000000000..7e1d45b59b03 --- /dev/null +++ b/apps/builder/app/shared/copy-paste/plugin-html.ts @@ -0,0 +1,15 @@ +import { isFeatureEnabled } from "@webstudio-is/feature-flags"; +import { generateFragmentFromHtml } from "../html"; +import { insertWebstudioFragmentAt } from "../instance-utils"; +import type { Plugin } from "./init-copy-paste"; + +export const html: Plugin = { + mimeType: "text/plain", + onPaste: (html: string) => { + if (!isFeatureEnabled("element")) { + return false; + } + const fragment = generateFragmentFromHtml(html); + return insertWebstudioFragmentAt(fragment); + }, +}; diff --git a/apps/builder/app/shared/copy-paste/plugin-instance.ts b/apps/builder/app/shared/copy-paste/plugin-instance.ts index 493f1a6f9b21..9006b09df7e5 100644 --- a/apps/builder/app/shared/copy-paste/plugin-instance.ts +++ b/apps/builder/app/shared/copy-paste/plugin-instance.ts @@ -9,7 +9,11 @@ import { isComponentDetachable, portalComponent, } from "@webstudio-is/sdk"; -import { $selectedInstanceSelector, $instances } from "../nano-states"; +import { + $selectedInstanceSelector, + $instances, + $project, +} from "../nano-states"; import type { InstanceSelector } from "../tree-utils"; import { deleteInstanceMutable, @@ -152,9 +156,9 @@ const findPasteTarget = (data: InstanceData): undefined | Insertable => { }; const onPaste = (clipboardData: string) => { + const project = $project.get(); const fragment = parse(clipboardData); - - if (fragment === undefined) { + if (fragment === undefined || project === undefined) { return false; } @@ -171,6 +175,7 @@ const onPaste = (clipboardData: string) => { ...data, startingInstanceId: pasteTarget.parentSelector[0], }), + projectId: project.id, }); const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); if (newRootInstanceId === undefined) { diff --git a/apps/builder/app/shared/copy-paste/plugin-markdown.ts b/apps/builder/app/shared/copy-paste/plugin-markdown.ts index ed7918f675ec..b647d5fa284e 100644 --- a/apps/builder/app/shared/copy-paste/plugin-markdown.ts +++ b/apps/builder/app/shared/copy-paste/plugin-markdown.ts @@ -7,10 +7,7 @@ import type { Instance, WebstudioFragment, } from "@webstudio-is/sdk"; -import { - findClosestInsertable, - insertWebstudioFragmentAt, -} from "../instance-utils"; +import { insertWebstudioFragmentAt } from "../instance-utils"; import { $breakpoints } from "../nano-states"; import { isBaseBreakpoint } from "../breakpoints"; import { denormalizeSrcProps } from "./asset-upload"; @@ -213,12 +210,7 @@ export const onPaste = async (clipboardData: string) => { return false; } fragment = await denormalizeSrcProps(fragment); - const insertable = findClosestInsertable(fragment); - if (insertable === undefined) { - return false; - } - insertWebstudioFragmentAt(fragment, insertable); - return true; + return insertWebstudioFragmentAt(fragment); }; export const __testing__ = { diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts index 3380c3b7af0c..40292b5c1c7f 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts @@ -166,8 +166,9 @@ const parse = (clipboardData: string) => { }; export const onPaste = async (clipboardData: string) => { + const project = $project.get(); const wfData = parse(clipboardData); - if (wfData === undefined) { + if (wfData === undefined || project === undefined) { return false; } @@ -190,6 +191,7 @@ export const onPaste = async (clipboardData: string) => { ...data, startingInstanceId: insertable.parentSelector[0], }), + projectId: project.id, }); const children = fragment.children diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx new file mode 100644 index 000000000000..2831db3489ec --- /dev/null +++ b/apps/builder/app/shared/html.test.tsx @@ -0,0 +1,192 @@ +import { expect, test } from "vitest"; +import { renderTemplate, ws } from "@webstudio-is/template"; +import { generateFragmentFromHtml } from "./html"; + +test("generate instances from html", () => { + expect( + generateFragmentFromHtml(` +
+
+

It works!

+

Webstudio is great.

+
    +
  • one
  • +
  • two
  • +
+
+
+ `) + ).toEqual( + renderTemplate( + + + It works! + Webstudio is great. + + one + two + + + + ) + ); +}); + +test("generate multiple root instances from html", () => { + expect( + generateFragmentFromHtml(` +
+

One

+
+
+

Two

+
+ `) + ).toEqual( + renderTemplate( + <> + + One + + + Two + + + ) + ); +}); + +test("handle broken html", () => { + expect( + generateFragmentFromHtml(` +
+

One

+
+ `) + ).toEqual( + renderTemplate( + <> + + One + + + ) + ); +}); + +test("handle non-html", () => { + expect(generateFragmentFromHtml("")).toEqual(renderTemplate(<>)); + expect(generateFragmentFromHtml("It works!")).toEqual(renderTemplate(<>)); +}); + +test("ignore custom elements", () => { + expect( + generateFragmentFromHtml(` + +
+
+
+ `) + ).toEqual(renderTemplate()); +}); + +test("ignore not allowed tags", () => { + expect( + generateFragmentFromHtml(` + + + +
+ `) + ).toEqual(renderTemplate()); +}); + +test("generate props from html attributes", () => { + expect( + generateFragmentFromHtml(` +
+ +
+ `) + ).toEqual( + renderTemplate( + + + My Button + + + ) + ); +}); + +test("generate props from number and boolean html attributes", () => { + expect( + generateFragmentFromHtml(` + + `) + ).toEqual( + renderTemplate( + + My Button + + ) + ); +}); + +test("generate props from number and boolean aria attributes", () => { + expect( + generateFragmentFromHtml(` + + `) + ).toEqual( + renderTemplate( + + My Button + + ) + ); +}); + +test("wrap text with span when spotted outside of rich text", () => { + expect( + generateFragmentFromHtml(` +
div
article
+ `) + ).toEqual( + renderTemplate( + + div + article + + ) + ); + expect( + generateFragmentFromHtml(` +
div
+ `) + ).toEqual( + renderTemplate( + + div + + + + + ) + ); +}); + +test("do not wrap text with span when spotted near link", () => { + expect( + generateFragmentFromHtml(` +
divlink
+ `) + ).toEqual( + renderTemplate( + + div + link + + ) + ); +}); diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts new file mode 100644 index 000000000000..bc8f290d8948 --- /dev/null +++ b/apps/builder/app/shared/html.ts @@ -0,0 +1,178 @@ +import { + type DefaultTreeAdapterMap, + defaultTreeAdapter, + parseFragment, +} from "parse5"; +import { + type WebstudioFragment, + type Instance, + elementComponent, + Prop, + tags, +} from "@webstudio-is/sdk"; +import { ariaAttributes, attributesByTag } from "@webstudio-is/html-data"; +import { richTextContentTags } from "./content-model"; +import { setIsSubsetOf } from "./shim"; + +type ElementNode = DefaultTreeAdapterMap["element"]; + +const spaceRegex = /^\s*$/; + +const getAttributeType = ( + attribute: (typeof ariaAttributes)[number] +): "string" | "boolean" | "number" => { + if (attribute.type === "string" || attribute.type === "select") { + return "string"; + } + if (attribute.type === "number" || attribute.type === "boolean") { + return attribute.type; + } + attribute.type satisfies never; + throw Error("Unknown type"); +}; + +const getAttributeTypes = () => { + const attributeTypes = new Map(); + for (const attribute of ariaAttributes) { + attributeTypes.set(attribute.name, getAttributeType(attribute)); + } + for (const attribute of attributesByTag["*"] ?? []) { + attributeTypes.set(attribute.name, getAttributeType(attribute)); + } + for (const [tag, attributes] of Object.entries(attributesByTag)) { + if (attributes) { + for (const attribute of attributes) { + attributeTypes.set( + `${tag}:${attribute.name}`, + getAttributeType(attribute) + ); + } + } + } + return attributeTypes; +}; + +const findContentTags = (element: ElementNode, tags = new Set()) => { + for (const childNode of element.childNodes) { + if (defaultTreeAdapter.isElementNode(childNode)) { + tags.add(childNode.tagName); + findContentTags(childNode, tags); + } + } + return tags; +}; + +export const generateFragmentFromHtml = (html: string): WebstudioFragment => { + const attributeTypes = getAttributeTypes(); + const instances = new Map(); + const props: Prop[] = []; + let lastId = -1; + const getNewId = () => { + lastId += 1; + return lastId.toString(); + }; + const convertElementToInstance = (node: ElementNode) => { + if (!tags.includes(node.tagName)) { + return; + } + const instance: Instance = { + type: "instance", + id: getNewId(), + component: elementComponent, + tag: node.tagName, + children: [], + }; + instances.set(instance.id, instance); + for (const attr of node.attrs) { + const id = `${instance.id}:${attr.name}`; + const instanceId = instance.id; + const name = attr.name; + // cast props to types extracted from html and aria specs + const type = + attributeTypes.get(`${node.tagName}:${name}`) ?? + attributeTypes.get(name) ?? + "string"; + if (type === "string") { + props.push({ id, instanceId, name, type, value: attr.value }); + continue; + } + if (type === "number") { + props.push({ id, instanceId, name, type, value: Number(attr.value) }); + continue; + } + if (type === "boolean") { + props.push({ id, instanceId, name, type, value: true }); + continue; + } + (type) satisfies never; + } + const contentTags = findContentTags(node); + const hasNonRichTextContent = !setIsSubsetOf( + contentTags, + richTextContentTags + ); + for (const childNode of node.childNodes) { + if (defaultTreeAdapter.isElementNode(childNode)) { + const child = convertElementToInstance(childNode); + if (child) { + instance.children.push(child); + } + } + if (defaultTreeAdapter.isTextNode(childNode)) { + if (spaceRegex.test(childNode.value)) { + continue; + } + let child: Instance["children"][number] = { + type: "text", + value: childNode.value, + }; + // when element has content elements other than supported by rich text + // wrap its text children with span, for example + //
+ // text + //
+ //
+ // is converted into + //
+ // text + //
+ //
+ if (hasNonRichTextContent) { + const span: Instance = { + type: "instance", + id: getNewId(), + component: elementComponent, + tag: "span", + children: [child], + }; + instances.set(span.id, span); + child = { type: "id", value: span.id }; + } + instance.children.push(child); + } + } + return { type: "id" as const, value: instance.id }; + }; + const documentFragment = parseFragment(html, { scriptingEnabled: false }); + const children: Instance["children"] = []; + for (const childNode of documentFragment.childNodes) { + if (defaultTreeAdapter.isElementNode(childNode)) { + const child = convertElementToInstance(childNode); + if (child) { + children.push(child); + } + } + } + return { + children, + instances: Array.from(instances.values()), + props, + dataSources: [], + resources: [], + styleSourceSelections: [], + styleSources: [], + styles: [], + breakpoints: [], + assets: [], + }; +}; diff --git a/apps/builder/app/shared/instance-utils.test.tsx b/apps/builder/app/shared/instance-utils.test.tsx index 0e9759f41e96..e04b6e97f2a8 100644 --- a/apps/builder/app/shared/instance-utils.test.tsx +++ b/apps/builder/app/shared/instance-utils.test.tsx @@ -1050,6 +1050,7 @@ describe("insert webstudio fragment copy", () => { assets: [createImageAsset("asset1", "name", "another_project")], }, availableVariables: [], + projectId: "current_project", }); expect(Array.from(data.assets.values())).toEqual([ createImageAsset("asset1", "name", "current_project"), @@ -1068,6 +1069,7 @@ describe("insert webstudio fragment copy", () => { ], }, availableVariables: [], + projectId: "current_project", }); expect(Array.from(data.assets.values())).toEqual([ // preserve any user changes @@ -1102,6 +1104,7 @@ describe("insert webstudio fragment copy", () => { ], }, availableVariables: [], + projectId: "", }); expect(Array.from(data.breakpoints.values())).toEqual([ { id: "existing_base", label: "base" }, @@ -1141,6 +1144,7 @@ describe("insert webstudio fragment copy", () => { ], }, availableVariables: [], + projectId: "", }); expect(Array.from(data.styleSources.values())).toEqual([ { id: "token1", type: "token", name: "oldLabel" }, @@ -1193,6 +1197,7 @@ describe("insert webstudio fragment copy", () => { ], }, availableVariables: [], + projectId: "", }); expect(Array.from(data.styleSourceSelections.values())).toEqual([ { @@ -1258,6 +1263,7 @@ describe("insert webstudio fragment copy", () => { ], }, availableVariables: [], + projectId: "", }); expect(Array.from(data.styleSourceSelections.values())).toEqual([ { instanceId: "fragment", values: ["localId", "tokenId"] }, diff --git a/apps/builder/app/shared/instance-utils.ts b/apps/builder/app/shared/instance-utils.ts index 42cdd1b0b89d..bfc554072349 100644 --- a/apps/builder/app/shared/instance-utils.ts +++ b/apps/builder/app/shared/instance-utils.ts @@ -41,11 +41,11 @@ import { $registeredComponentMetas, $dataSources, $assets, - $project, $breakpoints, $pages, $resources, $registeredTemplates, + $project, } from "./nano-states"; import { type DroppableTarget, @@ -77,6 +77,7 @@ import { findClosestNonTextualContainer, isRichTextTree, } from "./content-model"; +import type { Project } from "@webstudio-is/project"; /** * structuredClone can be invoked on draft and throw error @@ -259,8 +260,17 @@ export const insertInstanceChildrenMutable = ( export const insertWebstudioFragmentAt = ( fragment: WebstudioFragment, - insertable: Insertable -) => { + insertable?: Insertable +): boolean => { + // cannot insert empty fragment + if (fragment.children.length === 0) { + return false; + } + const project = $project.get(); + insertable = findClosestInsertable(fragment, insertable) ?? insertable; + if (project === undefined || insertable === undefined) { + return false; + } let newInstanceSelector: undefined | InstanceSelector; updateWebstudioData((data) => { const instancePath = getInstancePath( @@ -277,6 +287,7 @@ export const insertWebstudioFragmentAt = ( ...data, startingInstanceId: instancePath[0].instance.id, }), + projectId: project.id, }); const children: Instance["children"] = fragment.children.map((child) => { if (child.type === "id") { @@ -314,6 +325,7 @@ export const insertWebstudioFragmentAt = ( if (newInstanceSelector) { selectInstance(newInstanceSelector); } + return true; }; export const getComponentTemplateData = ( @@ -349,6 +361,10 @@ export const reparentInstanceMutable = ( sourceInstanceSelector: InstanceSelector, dropTarget: DroppableTarget ) => { + const project = $project.get(); + if (project === undefined) { + return; + } const [rootInstanceId] = sourceInstanceSelector; // delect is target is one of own descendants // prevent reparenting to avoid infinite loop @@ -420,6 +436,7 @@ export const reparentInstanceMutable = ( ...data, startingInstanceId: dropTarget.parentSelector[0], }), + projectId: project.id, }); const [newParentId] = dropTarget.parentSelector; const newRootInstanceId = @@ -810,10 +827,12 @@ export const insertWebstudioFragmentCopy = ({ data, fragment, availableVariables, + projectId, }: { data: Omit; fragment: WebstudioFragment; availableVariables: DataSource[]; + projectId: Project["id"]; }) => { const newInstanceIds = new Map(); const newDataSourceIds = new Map(); @@ -821,10 +840,6 @@ export const insertWebstudioFragmentCopy = ({ newInstanceIds, newDataSourceIds, }; - const projectId = $project.get()?.id; - if (projectId === undefined) { - return newDataIds; - } const fragmentInstances: Instances = new Map(); const portalContentRootIds = new Set(); diff --git a/apps/builder/app/shared/page-utils.ts b/apps/builder/app/shared/page-utils.ts index 142f88be58ed..6493fa698923 100644 --- a/apps/builder/app/shared/page-utils.ts +++ b/apps/builder/app/shared/page-utils.ts @@ -22,6 +22,7 @@ import { restoreExpressionVariables, unsetExpressionVariables, } from "./data-variables"; +import { $project } from "./nano-states"; const deduplicateName = ( pages: Pages, @@ -97,8 +98,9 @@ export const insertPageCopyMutable = ({ source: { data: WebstudioData; pageId: Page["id"] }; target: { data: WebstudioData; folderId: Folder["id"] }; }) => { + const project = $project.get(); const page = findPageByIdOrPath(source.pageId, source.data.pages); - if (page === undefined) { + if (project === undefined || page === undefined) { return; } // copy paste project :root @@ -109,6 +111,7 @@ export const insertPageCopyMutable = ({ ...target.data, startingInstanceId: ROOT_INSTANCE_ID, }), + projectId: project.id, }); const unsetVariables = new Set(); const unsetNameById = new Map(); @@ -132,6 +135,7 @@ export const insertPageCopyMutable = ({ unsetVariables, }), availableVariables, + projectId: project.id, }); // unwrap page draft const newPage = structuredClone(unwrap(page)); diff --git a/apps/builder/package.json b/apps/builder/package.json index 44a94f9d644b..c4ab53d2c1dc 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -100,6 +100,7 @@ "nanoevents": "^9.1.0", "nanoid": "^5.1.5", "nanostores": "^0.11.3", + "parse5": "7.3.0", "picocolors": "^1.1.1", "pretty-bytes": "^6.1.1", "react": "18.3.0-canary-14898b6a9-20240318", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d637cccf08c..1a02b4ed056c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,7 +40,7 @@ "env-paths": "^3.0.0", "nanoid": "^5.1.5", "p-limit": "^6.2.0", - "parse5": "7.2.1", + "parse5": "7.3.0", "picocolors": "^1.1.1", "reserved-identifiers": "^1.0.0", "tinyexec": "^0.3.2", diff --git a/packages/html-data/bin/elements.ts b/packages/html-data/bin/elements.ts index 1784adca4c78..525380c70280 100644 --- a/packages/html-data/bin/elements.ts +++ b/packages/html-data/bin/elements.ts @@ -74,10 +74,6 @@ for (const [tag, element] of Object.entries(elementsByTag)) { if (element.categories.includes("metadata")) { continue; } - // @todo remove when element insert can adapt to parent - if (element.categories.includes("none")) { - continue; - } tags.push(tag); } const tagsContent = `export const tags: string[] = ${JSON.stringify(tags, null, 2)}; diff --git a/packages/html-data/package.json b/packages/html-data/package.json index 54bfef523459..aa2d6c957cab 100644 --- a/packages/html-data/package.json +++ b/packages/html-data/package.json @@ -17,7 +17,7 @@ "@webstudio-is/sdk": "workspace:*", "@webstudio-is/tsconfig": "workspace:*", "aria-query": "^5.3.2", - "parse5": "7.2.1" + "parse5": "7.3.0" }, "exports": { "webstudio": "./src/index.ts" diff --git a/packages/icons/icons/html-element.svg b/packages/icons/icons/html-element.svg deleted file mode 100644 index 9ffecde7451a..000000000000 --- a/packages/icons/icons/html-element.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/packages/icons/src/__generated__/components.tsx b/packages/icons/src/__generated__/components.tsx index 85139186056a..b66ae41f5210 100644 --- a/packages/icons/src/__generated__/components.tsx +++ b/packages/icons/src/__generated__/components.tsx @@ -2738,36 +2738,6 @@ export const HomeIcon: IconComponent = forwardRef( ); HomeIcon.displayName = "HomeIcon"; -export const HtmlElementIcon: IconComponent = forwardRef( - ({ fill = "none", size = 16, ...props }, forwardedRef) => { - return ( - - - - - ); - } -); -HtmlElementIcon.displayName = "HtmlElementIcon"; - export const ImageIcon: IconComponent = forwardRef( ({ fill = "none", size = 16, ...props }, forwardedRef) => { return ( diff --git a/packages/icons/src/__generated__/svg.ts b/packages/icons/src/__generated__/svg.ts index 506b399bb5f1..f2a93935289c 100644 --- a/packages/icons/src/__generated__/svg.ts +++ b/packages/icons/src/__generated__/svg.ts @@ -204,8 +204,6 @@ export const HelpIcon = ``; -export const HtmlElementIcon = ``; - export const ImageIcon = ``; export const InfoCircleIcon = ``; diff --git a/packages/sdk/src/__generated__/tags.ts b/packages/sdk/src/__generated__/tags.ts index 9b7e3e4e7863..eee4408144e5 100644 --- a/packages/sdk/src/__generated__/tags.ts +++ b/packages/sdk/src/__generated__/tags.ts @@ -10,22 +10,29 @@ export const tags: string[] = [ "bdi", "bdo", "blockquote", + "body", "br", "button", "canvas", + "caption", "cite", "code", + "col", + "colgroup", "data", "datalist", + "dd", "del", "details", "dfn", "dialog", "div", "dl", + "dt", "em", "embed", "fieldset", + "figcaption", "figure", "footer", "form", @@ -35,9 +42,11 @@ export const tags: string[] = [ "h4", "h5", "h6", + "head", "header", "hgroup", "hr", + "html", "i", "iframe", "img", @@ -45,6 +54,8 @@ export const tags: string[] = [ "ins", "kbd", "label", + "legend", + "li", "main", "map", "mark", @@ -53,12 +64,16 @@ export const tags: string[] = [ "nav", "object", "ol", + "optgroup", + "option", "output", "p", "picture", "pre", "progress", "q", + "rp", + "rt", "ruby", "s", "samp", @@ -67,14 +82,22 @@ export const tags: string[] = [ "select", "slot", "small", + "source", "span", "strong", "sub", + "summary", "sup", "table", + "tbody", + "td", "textarea", + "tfoot", "th", + "thead", "time", + "tr", + "track", "u", "ul", "var", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 418b60d84149..6ea6e89aabfa 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -22,6 +22,7 @@ export * from "./resources-generator"; export * from "./page-meta-generator"; export * from "./url-pattern"; export * from "./css"; +export * from "./__generated__/tags"; export type { AnimationAction, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f77675f8a73..69996902faa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,6 +364,9 @@ importers: nanostores: specifier: ^0.11.3 version: 0.11.3 + parse5: + specifier: 7.3.0 + version: 7.3.0 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -1136,8 +1139,8 @@ importers: specifier: ^6.2.0 version: 6.2.0 parse5: - specifier: 7.2.1 - version: 7.2.1 + specifier: 7.3.0 + version: 7.3.0 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -1623,8 +1626,8 @@ importers: specifier: ^5.3.2 version: 5.3.2 parse5: - specifier: 7.2.1 - version: 7.2.1 + specifier: 7.3.0 + version: 7.3.0 packages/http-client: dependencies: @@ -6476,6 +6479,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -8092,8 +8099,8 @@ packages: parse-package-name@1.0.0: resolution: {integrity: sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==} - parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} @@ -14131,6 +14138,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.0: {} + env-paths@3.0.0: {} err-code@2.0.3: {} @@ -15173,7 +15182,7 @@ snapshots: https-proxy-agent: 5.0.1 is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.7 - parse5: 7.2.1 + parse5: 7.3.0 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 4.1.4 @@ -16380,9 +16389,9 @@ snapshots: parse-package-name@1.0.0: {} - parse5@7.2.1: + parse5@7.3.0: dependencies: - entities: 4.5.0 + entities: 6.0.0 parseurl@1.3.3: {}