diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 15cd6741b3c3..b73d46d272de 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -36,7 +36,6 @@ import { import { $selectedInstancePath, selectInstance } from "~/shared/awareness"; import { openCommandPanel } from "../features/command-panel"; import { builderApi } from "~/shared/builder-api"; - import { findClosestNonTextualContainer, isInstanceDetachable, diff --git a/apps/builder/app/canvas/features/text-editor/text-editor.tsx b/apps/builder/app/canvas/features/text-editor/text-editor.tsx index 5bed9ea23988..a47467ecf748 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -97,7 +97,6 @@ import { insertListItemAt, insertTemplateAt, } from "~/builder/features/workspace/canvas-tools/outline/block-utils"; -import { editablePlaceholderComponents } from "~/canvas/shared/styles"; const BindInstanceToNodePlugin = ({ refs, @@ -1580,6 +1579,7 @@ export const TextEditor = ({ const handleNext = useEffectEvent( (state: EditorState, args: HandleNextParams) => { const rootInstanceId = $selectedPage.get()?.rootInstanceId; + const metas = $registeredComponentMetas.get(); if (rootInstanceId === undefined) { return; @@ -1589,7 +1589,7 @@ export const TextEditor = ({ findAllEditableInstanceSelector( [rootInstanceId], instances, - $registeredComponentMetas.get(), + metas, editableInstanceSelectors ); @@ -1638,14 +1638,12 @@ export const TextEditor = ({ if (instance === undefined) { continue; } - - // Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing - const componentsWithPseudoElementChildren = - editablePlaceholderComponents; + const meta = metas.get(instance.component); // opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason). if ( - !componentsWithPseudoElementChildren.includes(instance.component) && + // Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing + meta?.placeholder === undefined && instance?.children.length === 0 ) { const elt = getElementByInstanceSelector(nextSelector); diff --git a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx index b79681bfe7f6..5d1fb76362ad 100644 --- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx +++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx @@ -36,8 +36,6 @@ import { getIndexesWithinAncestors, type AnyComponent, textContentAttribute, - editingPlaceholderVariable, - editablePlaceholderVariable, } from "@webstudio-is/react-sdk"; import { rawTheme } from "@webstudio-is/design-system"; import { @@ -68,8 +66,10 @@ import { } from "~/canvas/elements"; import { Block } from "../build-mode/block"; import { BlockTemplate } from "../build-mode/block-template"; -import { getInstanceLabel } from "~/shared/instance-utils"; -import { editablePlaceholderComponents } from "~/canvas/shared/styles"; +import { + editablePlaceholderAttribute, + editingPlaceholderVariable, +} from "~/canvas/shared/styles"; const ContentEditable = ({ placeholder, @@ -376,19 +376,14 @@ const getEditableComponentPlaceholder = ( metas: Map, mode: "editing" | "editable" ) => { - if (!editablePlaceholderComponents.includes(instance.component)) { + const meta = metas.get(instance.component); + if (meta?.placeholder === undefined) { return; } const isContentBlockChild = undefined !== findBlockSelector(instanceSelector, instances); - const meta = metas.get(instance.component); - - const label = meta - ? getInstanceLabel(instance, meta) - : (instance.label ?? instance.component); - const isParagraph = instance.component === "Paragraph"; if (isParagraph && isContentBlockChild) { @@ -398,7 +393,7 @@ const getEditableComponentPlaceholder = ( undefined; } - return label; + return meta.placeholder; }; export const WebstudioComponentCanvas = forwardRef< @@ -498,14 +493,6 @@ export const WebstudioComponentCanvas = forwardRef< Component = BlockTemplate; } - const placeholder = getEditableComponentPlaceholder( - instance, - instanceSelector, - instances, - metas, - "editable" - ); - const mergedProps = mergeProps(restProps, instanceProps, "delete"); const props: { @@ -514,20 +501,19 @@ export const WebstudioComponentCanvas = forwardRef< [selectorIdAttribute]: string; } & Record = { ...mergedProps, - ...(placeholder !== undefined - ? { - style: { - ...mergedProps.style, - [editablePlaceholderVariable]: `'${placeholder.replaceAll("'", "\\'")}'`, - }, - } - : null), // current props should override bypassed from parent // important for data-ws-* props tabIndex: 0, [selectorIdAttribute]: instanceSelector.join(","), [componentAttribute]: instance.component, [idAttribute]: instance.id, + [editablePlaceholderAttribute]: getEditableComponentPlaceholder( + instance, + instanceSelector, + instances, + metas, + "editable" + ), }; // React ignores defaultValue changes after first render. diff --git a/apps/builder/app/canvas/shared/styles.ts b/apps/builder/app/canvas/shared/styles.ts index 6bfb5926bc59..21152ec7c733 100644 --- a/apps/builder/app/canvas/shared/styles.ts +++ b/apps/builder/app/canvas/shared/styles.ts @@ -12,13 +12,7 @@ import { createImageValueTransformer, addFontRules, } from "@webstudio-is/sdk"; -import { - collapsedAttribute, - idAttribute, - editingPlaceholderVariable, - editablePlaceholderVariable, - componentAttribute, -} from "@webstudio-is/react-sdk"; +import { collapsedAttribute, idAttribute } from "@webstudio-is/react-sdk"; import { StyleValue, type TransformValue, @@ -68,31 +62,22 @@ export const mountStyles = () => { helpersSheet.render(); }; -/** - * Opinionated list of non collapsible components in the builder - */ -export const editablePlaceholderComponents = [ - "Paragraph", - "Heading", - "ListItem", - "Blockquote", - "Link", -]; - -const editablePlaceholderSelector = editablePlaceholderComponents - .map((component) => `[${componentAttribute}= "${component}"]`) - .join(", "); +export const editablePlaceholderAttribute = "data-ws-editable-placeholder"; +// @todo replace with modern typed attr() when supported in all browsers +// see the second edge case +// https://developer.mozilla.org/en-US/docs/Web/CSS/attr#backwards_compatibility +export const editingPlaceholderVariable = "--ws-editing-placeholder"; const helperStylesShared = [ // Display a placeholder text for elements that are editable but currently empty - `:is(${editablePlaceholderSelector}):empty::before { - content: var(${editablePlaceholderVariable}, '\\200B'); + `:is([${editablePlaceholderAttribute}]):empty::before { + content: attr(${editablePlaceholderAttribute}); opacity: 0.3; } `, // Display a placeholder text for elements that are editing but empty (Lexical adds p>br children) - `:is(${editablePlaceholderSelector})[contenteditable] > p:only-child:has(br:only-child) { + `:is([${editablePlaceholderAttribute}])[contenteditable] > p:only-child:has(br:only-child) { position: relative; display: block; &:after { diff --git a/apps/builder/app/shared/instance-utils.test.tsx b/apps/builder/app/shared/instance-utils.test.tsx index 0070bb9a35ee..5fbaf0b52822 100644 --- a/apps/builder/app/shared/instance-utils.test.tsx +++ b/apps/builder/app/shared/instance-utils.test.tsx @@ -1388,6 +1388,36 @@ describe("find closest insertable", () => { }); }); + test("finds closest container without textual placeholder", () => { + const { instances } = renderData( + <$.Body ws:id="bodyId"> + <$.Paragraph ws:id="paragraphId"> + + ); + $instances.set(instances); + selectInstance(["paragraphId", "bodyId"]); + expect(findClosestInsertable(newBoxFragment)).toEqual({ + parentSelector: ["bodyId"], + position: 1, + }); + }); + + test("finds closest container even with when parent has placeholder", () => { + const { instances } = renderData( + <$.Body ws:id="bodyId"> + <$.Paragraph ws:id="paragraphId"> + <$.Box ws:id="spanId" tag="span"> + + + ); + $instances.set(instances); + selectInstance(["boxId", "paragraphId", "bodyId"]); + expect(findClosestInsertable(newBoxFragment)).toEqual({ + parentSelector: ["paragraphId", "bodyId"], + position: 0, + }); + }); + test("forbids inserting into :root", () => { const { instances } = renderData(<$.Body ws:id="bodyId">); $instances.set(instances); diff --git a/apps/builder/app/shared/matcher.ts b/apps/builder/app/shared/matcher.ts index 12037173f110..40a1baa179f4 100644 --- a/apps/builder/app/shared/matcher.ts +++ b/apps/builder/app/shared/matcher.ts @@ -376,7 +376,10 @@ export const findClosestNonTextualContainer = ({ if (instance === undefined) { continue; } - let hasText = false; + const meta = metas.get(instance.component); + // placeholder exists only inside of empty instances + let hasText = + meta?.placeholder !== undefined && instance.children.length === 0; for (const child of instance.children) { if (child.type === "text" || child.type === "expression") { hasText = true; @@ -392,7 +395,6 @@ export const findClosestNonTextualContainer = ({ if (hasText) { continue; } - const meta = metas.get(instance.component); if (meta?.type === "container") { return index; } diff --git a/packages/react-sdk/src/props.ts b/packages/react-sdk/src/props.ts index 18c731dd23a3..8fdf2d05d37e 100644 --- a/packages/react-sdk/src/props.ts +++ b/packages/react-sdk/src/props.ts @@ -129,10 +129,6 @@ export const showAttribute = "data-ws-show" as const; export const indexAttribute = "data-ws-index" as const; export const collapsedAttribute = "data-ws-collapsed" as const; export const textContentAttribute = "data-ws-text-content" as const; -export const editablePlaceholderVariable = - "--data-ws-editable-placeholder" as const; -export const editingPlaceholderVariable = - "--data-ws-editing-placeholder" as const; /** * Copyright (c) Meta Platforms, Inc. and affiliates. diff --git a/packages/sdk-components-react/src/blockquote.ws.ts b/packages/sdk-components-react/src/blockquote.ws.ts index 05dff94f2df8..a9fd64c84180 100644 --- a/packages/sdk-components-react/src/blockquote.ws.ts +++ b/packages/sdk-components-react/src/blockquote.ws.ts @@ -61,6 +61,7 @@ const presetStyle = { export const meta: WsComponentMeta = { type: "container", + placeholder: "Blockquote", icon: BlockquoteIcon, states: defaultStates, presetStyle, diff --git a/packages/sdk-components-react/src/heading.ws.ts b/packages/sdk-components-react/src/heading.ws.ts index 822d1f66b8ff..34965c96247f 100644 --- a/packages/sdk-components-react/src/heading.ws.ts +++ b/packages/sdk-components-react/src/heading.ws.ts @@ -23,6 +23,7 @@ const presetStyle = { export const meta: WsComponentMeta = { type: "container", + placeholder: "Heading", icon: HeadingIcon, constraints: { relation: "ancestor", diff --git a/packages/sdk-components-react/src/link.ws.ts b/packages/sdk-components-react/src/link.ws.ts index ebfc69b73e4f..a50f51a6e857 100644 --- a/packages/sdk-components-react/src/link.ws.ts +++ b/packages/sdk-components-react/src/link.ws.ts @@ -21,6 +21,7 @@ const presetStyle = { export const meta: WsComponentMeta = { type: "container", + placeholder: "Link", icon: LinkIcon, constraints: { relation: "ancestor", diff --git a/packages/sdk-components-react/src/list-item.ws.ts b/packages/sdk-components-react/src/list-item.ws.ts index aca2beaa6f3c..63fcbd90a790 100644 --- a/packages/sdk-components-react/src/list-item.ws.ts +++ b/packages/sdk-components-react/src/list-item.ws.ts @@ -15,6 +15,7 @@ const presetStyle = { export const meta: WsComponentMeta = { type: "container", + placeholder: "List item", constraints: { // cannot use parent relation here // because list item can be put inside of collection or slot diff --git a/packages/sdk-components-react/src/paragraph.ws.ts b/packages/sdk-components-react/src/paragraph.ws.ts index 484726abba27..d81e8c2abc24 100644 --- a/packages/sdk-components-react/src/paragraph.ws.ts +++ b/packages/sdk-components-react/src/paragraph.ws.ts @@ -15,6 +15,7 @@ const presetStyle = { export const meta: WsComponentMeta = { type: "container", + placeholder: "Paragraph", icon: TextAlignLeftIcon, constraints: { relation: "ancestor", diff --git a/packages/sdk/src/schema/component-meta.ts b/packages/sdk/src/schema/component-meta.ts index 94409899c9ec..c836fc9a8d36 100644 --- a/packages/sdk/src/schema/component-meta.ts +++ b/packages/sdk/src/schema/component-meta.ts @@ -56,6 +56,11 @@ export const WsComponentMeta = z.object({ // embed - images, videos or other embeddable components, without children // rich-text-child - formatted text fragment, not listed in components list type: z.enum(["container", "control", "embed", "rich-text-child"]), + /** + * a property used as textual placeholder when no content specified while in builder + * also signals to not insert components inside unless dropped explicitly + */ + placeholder: z.string().optional(), constraints: Matchers.optional(), // when this field is specified component receives // prop with index of same components withiin specified ancestor