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 6746fef9dbb4..7746c4f8126f 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -1027,9 +1027,8 @@ const RichTextContentPluginInternal = ({ if (!isSelectionInSameComponent) { node?.remove(); - const rootNodeContent = $getRoot().getTextContent().trim(); // Delete current - if (rootNodeContent.length === 0) { + if ($getRoot().getTextContentSize() === 0) { const blockChildSelector = findBlockChildSelector(rootInstanceSelector); @@ -1086,9 +1085,7 @@ const RichTextContentPluginInternal = ({ } if (event.key === "Backspace" || event.key === "Delete") { - const rootNodeContent = $getRoot().getTextContent().trim(); - - if (rootNodeContent.length === 0) { + if ($getRoot().getTextContentSize() === 0) { const currentInstance = $instances .get() .get(rootInstanceSelector[0]); @@ -1096,8 +1093,18 @@ const RichTextContentPluginInternal = ({ if (currentInstance?.component === "ListItem") { onNext(editor.getEditorState(), { reason: "left" }); + const parentInstanceSelector = rootInstanceSelector.slice(1); + const parentInstance = $instances + .get() + .get(parentInstanceSelector[0]); + + const isLastChild = parentInstance?.children.length === 1; + updateWebstudioData((data) => { - deleteInstanceMutable(data, rootInstanceSelector); + deleteInstanceMutable( + data, + isLastChild ? parentInstanceSelector : rootInstanceSelector + ); }); event.preventDefault(); @@ -1127,10 +1134,9 @@ const RichTextContentPluginInternal = ({ .get() .get(rootInstanceSelector[0]); - const rootNodeContent = $getRoot().getTextContent().trim(); if ( currentInstance?.component === "ListItem" && - rootNodeContent.length > 0 + $getRoot().getTextContentSize() > 0 ) { // Instead of creating block component we need to add a new ListItem insertListItemAt(rootInstanceSelector); @@ -1189,11 +1195,21 @@ const RichTextContentPluginInternal = ({ if ( currentInstance?.component === "ListItem" && - rootNodeContent.length === 0 + $getRoot().getTextContentSize() === 0 ) { + const parentInstanceSelector = rootInstanceSelector.slice(1); + const parentInstance = $instances + .get() + .get(parentInstanceSelector[0]); + + const isLastChild = parentInstance?.children.length === 1; + // Pressing Enter within an empty list item deletes the empty item updateWebstudioData((data) => { - deleteInstanceMutable(data, rootInstanceSelector); + deleteInstanceMutable( + data, + isLastChild ? parentInstanceSelector : rootInstanceSelector + ); }); } @@ -1589,7 +1605,11 @@ export const TextEditor = ({ } // Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing - const componentsWithPseudoElementChildren = ["ListItem"]; + const componentsWithPseudoElementChildren = [ + "ListItem", + "Paragraph", + "Heading", + ]; // opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason). if ( @@ -1671,7 +1691,6 @@ export const TextEditor = ({ } /> 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 ef6f67a9261c..4ab83c09dcfc 100644 --- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx +++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx @@ -29,6 +29,9 @@ import { descendantComponent, blockComponent, blockTemplateComponent, + editingPlaceholderVariable, + WsComponentMeta, + editablePlaceholderVariable, } from "@webstudio-is/react-sdk"; import { rawTheme } from "@webstudio-is/design-system"; import { @@ -37,6 +40,7 @@ import { $instances, $registeredComponentMetas, $selectedInstanceRenderState, + findBlockSelector, } from "~/shared/nano-states"; import { $textEditingInstanceSelector } from "~/shared/nano-states"; import { @@ -58,10 +62,14 @@ 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"; const ContentEditable = ({ + placeholder, renderComponentWithRef, }: { + placeholder: string | undefined; renderComponentWithRef: ( elementRef: ForwardedRef ) => JSX.Element; @@ -136,10 +144,17 @@ const ContentEditable = ({ rootElement.style.removeProperty("white-space"); rootElement.style.setProperty("white-space-collapse", "pre-wrap"); + if (placeholder !== undefined) { + rootElement.style.setProperty( + editingPlaceholderVariable, + `'${placeholder.replaceAll("'", "\\'")}'` + ); + } + return () => { abortController.abort(); }; - }, [editor]); + }, [editor, placeholder]); return renderComponentWithRef(ref); }; @@ -348,12 +363,46 @@ const getTextContent = (instanceProps: Record) => { return value as ReactNode; }; +const getEditableComponentPlaceholder = ( + instanceSelector: InstanceSelector, + instances: Instances, + metas: Map, + mode: "editing" | "editable" +) => { + const instance = instances.get(instanceSelector[0])!; + + if (!editablePlaceholderComponents.includes(instance.component)) { + 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) { + return mode === "editing" + ? "Write something or press '/' for commands..." + : // The paragraph contains only an "editing" placeholder within the content block. + undefined; + } + + return label; +}; + export const WebstudioComponentCanvas = forwardRef< HTMLElement, WebstudioComponentProps >(({ instance, instanceSelector, components, ...restProps }, ref) => { const instanceId = instance.id; const instances = useStore($instances); + const metas = useStore($registeredComponentMetas); const textEditingInstanceSelector = useStore($textEditingInstanceSelector); @@ -444,12 +493,29 @@ export const WebstudioComponentCanvas = forwardRef< Component = BlockTemplate; } + const placeholder = getEditableComponentPlaceholder( + instanceSelector, + instances, + metas, + "editable" + ); + + const mergedProps = mergeProps(restProps, instanceProps, "delete"); + const props: { [componentAttribute]: string; [idAttribute]: string; [selectorIdAttribute]: string; } & Record = { - ...mergeProps(restProps, instanceProps, "delete"), + ...mergedProps, + ...(placeholder !== undefined + ? { + style: { + ...mergedProps.style, + [editablePlaceholderVariable]: `'${placeholder.replaceAll("'", "\\'")}'`, + }, + } + : null), // current props should override bypassed from parent // important for data-ws-* props tabIndex: 0, @@ -487,6 +553,12 @@ export const WebstudioComponentCanvas = forwardRef< instances={instances} contentEditable={ ( {initialContentEditableContent.current} diff --git a/apps/builder/app/canvas/shared/styles.ts b/apps/builder/app/canvas/shared/styles.ts index 23f29fa16673..56bc82316942 100644 --- a/apps/builder/app/canvas/shared/styles.ts +++ b/apps/builder/app/canvas/shared/styles.ts @@ -11,10 +11,13 @@ import { import { collapsedAttribute, idAttribute, + editingPlaceholderVariable, addGlobalRules, createImageValueTransformer, descendantComponent, rootComponent, + editablePlaceholderVariable, + componentAttribute, } from "@webstudio-is/react-sdk"; import { type TransformValue, @@ -61,7 +64,44 @@ 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(", "); + const helperStylesShared = [ + // Display a placeholder text for elements that are editable but currently empty + `:is(${editablePlaceholderSelector}):empty::before { + content: var(${editablePlaceholderVariable}, '\\200B'); + opacity: 0.3; + } + `, + + // Display a placeholder text for elements that are editing but empty (Lexical adds p>br children) + `:is(${editablePlaceholderSelector})[contenteditable]:has(p:only-child > br:only-child) { + position: relative; + & > p:after { + content: var(${editingPlaceholderVariable}); + position: absolute; + left: 0; + right: 0; + top: 0; + min-width: 100px; + opacity: 0.3; + } + } + `, + // Using :where allows to prevent increasing specificity, so that helper is overwritten by user styles. `[${idAttribute}]:where([${collapsedAttribute}]:not(body)) { outline: 1px dashed rgba(0,0,0,0.7); diff --git a/fixtures/ssg/app/__generated__/index.css b/fixtures/ssg/app/__generated__/index.css index 47aef12816f0..dd85fdf13b99 100644 --- a/fixtures/ssg/app/__generated__/index.css +++ b/fixtures/ssg/app/__generated__/index.css @@ -82,7 +82,6 @@ border-bottom-width: 1px; border-left-width: 1px; outline-width: 1px; - min-height: 1em; display: inline-block; } :where(img.w-image) { diff --git a/fixtures/webstudio-cloudflare-template/app/__generated__/index.css b/fixtures/webstudio-cloudflare-template/app/__generated__/index.css index 47aef12816f0..dd85fdf13b99 100644 --- a/fixtures/webstudio-cloudflare-template/app/__generated__/index.css +++ b/fixtures/webstudio-cloudflare-template/app/__generated__/index.css @@ -82,7 +82,6 @@ border-bottom-width: 1px; border-left-width: 1px; outline-width: 1px; - min-height: 1em; display: inline-block; } :where(img.w-image) { diff --git a/fixtures/webstudio-custom-template/app/__generated__/index.css b/fixtures/webstudio-custom-template/app/__generated__/index.css index 88b620285dca..e73fa2470bc6 100644 --- a/fixtures/webstudio-custom-template/app/__generated__/index.css +++ b/fixtures/webstudio-custom-template/app/__generated__/index.css @@ -81,7 +81,6 @@ border-bottom-width: 1px; border-left-width: 1px; outline-width: 1px; - min-height: 1em; display: inline-block; } :where(div.w-vimeo) { diff --git a/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/index.css b/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/index.css index 47aef12816f0..dd85fdf13b99 100644 --- a/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/index.css +++ b/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/index.css @@ -82,7 +82,6 @@ border-bottom-width: 1px; border-left-width: 1px; outline-width: 1px; - min-height: 1em; display: inline-block; } :where(img.w-image) { diff --git a/fixtures/webstudio-remix-netlify-functions/app/__generated__/index.css b/fixtures/webstudio-remix-netlify-functions/app/__generated__/index.css index 47aef12816f0..dd85fdf13b99 100644 --- a/fixtures/webstudio-remix-netlify-functions/app/__generated__/index.css +++ b/fixtures/webstudio-remix-netlify-functions/app/__generated__/index.css @@ -82,7 +82,6 @@ border-bottom-width: 1px; border-left-width: 1px; outline-width: 1px; - min-height: 1em; display: inline-block; } :where(img.w-image) { diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/index.css b/fixtures/webstudio-remix-vercel/app/__generated__/index.css index 51a36c2e556a..9a513e561e52 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/index.css +++ b/fixtures/webstudio-remix-vercel/app/__generated__/index.css @@ -172,7 +172,6 @@ border-bottom-width: 1px; border-left-width: 1px; outline-width: 1px; - min-height: 1em; display: inline-block; } :where(div.w-text) { diff --git a/packages/react-sdk/src/core-components.ts b/packages/react-sdk/src/core-components.ts index 7b0ee1a91f9b..106df1552a4e 100644 --- a/packages/react-sdk/src/core-components.ts +++ b/packages/react-sdk/src/core-components.ts @@ -267,17 +267,7 @@ const blockMeta: WsComponentMeta = { { component: "ListItem", type: "instance", - children: [{ type: "text", value: "list item you can edit" }], - }, - { - component: "ListItem", - type: "instance", - children: [{ type: "text", value: "list item you can edit" }], - }, - { - component: "ListItem", - type: "instance", - children: [{ type: "text", value: "list item you can edit" }], + children: [], }, ], }, @@ -296,17 +286,7 @@ const blockMeta: WsComponentMeta = { { component: "ListItem", type: "instance", - children: [{ type: "text", value: "list item you can edit" }], - }, - { - component: "ListItem", - type: "instance", - children: [{ type: "text", value: "list item you can edit" }], - }, - { - component: "ListItem", - type: "instance", - children: [{ type: "text", value: "list item you can edit" }], + children: [], }, ], }, diff --git a/packages/react-sdk/src/props.ts b/packages/react-sdk/src/props.ts index 8fdf2d05d37e..18c731dd23a3 100644 --- a/packages/react-sdk/src/props.ts +++ b/packages/react-sdk/src/props.ts @@ -129,6 +129,10 @@ 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 01f540b2666e..9658fe1c6569 100644 --- a/packages/sdk-components-react/src/blockquote.ws.ts +++ b/packages/sdk-components-react/src/blockquote.ws.ts @@ -72,13 +72,7 @@ export const meta: WsComponentMeta = { { type: "instance", component: "Blockquote", - children: [ - { - type: "text", - value: "Blockquote text you can edit", - placeholder: true, - }, - ], + children: [], }, ], }; diff --git a/packages/sdk-components-react/src/heading.ws.ts b/packages/sdk-components-react/src/heading.ws.ts index 3befd5ae9770..39b27497fa8a 100644 --- a/packages/sdk-components-react/src/heading.ws.ts +++ b/packages/sdk-components-react/src/heading.ws.ts @@ -38,13 +38,7 @@ export const meta: WsComponentMeta = { { type: "instance", component: "Heading", - children: [ - { - type: "text", - value: "Heading text you can edit", - placeholder: true, - }, - ], + children: [], }, ], }; diff --git a/packages/sdk-components-react/src/link.ws.ts b/packages/sdk-components-react/src/link.ws.ts index 2f2e6ab66d66..8a5ffd23486c 100644 --- a/packages/sdk-components-react/src/link.ws.ts +++ b/packages/sdk-components-react/src/link.ws.ts @@ -12,10 +12,6 @@ import { props } from "./__generated__/link.props"; const presetStyle = { a: [ ...a, - { - property: "minHeight", - value: { type: "unit", unit: "em", value: 1 }, - }, { property: "display", value: { type: "keyword", value: "inline-block" }, @@ -51,13 +47,7 @@ export const meta: WsComponentMeta = { { type: "instance", component: "Link", - children: [ - { - type: "text", - value: "Link text you can edit", - placeholder: true, - }, - ], + children: [], }, ], }; diff --git a/packages/sdk-components-react/src/list-item.ws.ts b/packages/sdk-components-react/src/list-item.ws.ts index 4c41974dfb18..455d1e201c40 100644 --- a/packages/sdk-components-react/src/list-item.ws.ts +++ b/packages/sdk-components-react/src/list-item.ws.ts @@ -32,13 +32,7 @@ export const meta: WsComponentMeta = { { type: "instance", component: "ListItem", - children: [ - { - type: "text", - value: "List Item text you can edit", - placeholder: true, - }, - ], + children: [], }, ], }; diff --git a/packages/sdk-components-react/src/paragraph.ws.ts b/packages/sdk-components-react/src/paragraph.ws.ts index a73838bd542f..cab2140e0af7 100644 --- a/packages/sdk-components-react/src/paragraph.ws.ts +++ b/packages/sdk-components-react/src/paragraph.ws.ts @@ -29,13 +29,7 @@ export const meta: WsComponentMeta = { { type: "instance", component: "Paragraph", - children: [ - { - type: "text", - value: "Paragraph text you can edit", - placeholder: true, - }, - ], + children: [], }, ], };