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 ac8101ee32d4..7f98b7b2fbad 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -97,6 +97,7 @@ import { insertListItemAt, insertTemplateAt, } from "~/builder/features/workspace/canvas-tools/outline/block-utils"; +import { richTextPlaceholders } from "~/shared/content-model"; const BindInstanceToNodePlugin = ({ refs, @@ -1024,6 +1025,18 @@ const RichTextContentPlugin = (props: RichTextContentPluginProps) => { return ; }; +const getTag = (instanceId: Instance["id"]) => { + const instances = $instances.get(); + const metas = $registeredComponentMetas.get(); + const instance = instances.get(instanceId); + if (instance === undefined) { + return; + } + const meta = metas.get(instance.component); + const tags = Object.keys(meta?.presetStyle ?? {}); + return instance.tag ?? tags[0]; +}; + const RichTextContentPluginInternal = ({ rootInstanceSelector, onOpen, @@ -1136,20 +1149,14 @@ const RichTextContentPluginInternal = ({ if (event.key === "Backspace" || event.key === "Delete") { if ($getRoot().getTextContentSize() === 0) { - const currentInstance = $instances - .get() - .get(rootInstanceSelector[0]); - - if (currentInstance?.component === "ListItem") { + const tag = getTag(rootInstanceSelector[0]); + if (tag === "li") { 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, @@ -1159,7 +1166,6 @@ const RichTextContentPluginInternal = ({ ) ); }); - event.preventDefault(); return true; } @@ -1185,16 +1191,10 @@ const RichTextContentPluginInternal = ({ if (menuState === "closed") { if (event.key === "Enter" && !event.shiftKey) { - // Custom logic if we are editing ListItem - const currentInstance = $instances - .get() - .get(rootInstanceSelector[0]); - - if ( - currentInstance?.component === "ListItem" && - $getRoot().getTextContentSize() > 0 - ) { - // Instead of creating block component we need to add a new ListItem + // Custom logic if we are editing list item + const tag = getTag(rootInstanceSelector[0]); + if (tag === "li" && $getRoot().getTextContentSize() > 0) { + // Instead of creating block component we need to add a new list item insertListItemAt(rootInstanceSelector); event.preventDefault(); return true; @@ -1202,11 +1202,11 @@ const RichTextContentPluginInternal = ({ // Check if it pressed on the last line, last symbol - const allowedComponents = ["Paragraph", "Text", "Heading"]; + const allowedTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6"]; - for (const component of allowedComponents) { + for (const tag of allowedTags) { const templateSelector = templates.find( - ([instance]) => instance.component === component + ([instance]) => getTag(instance.id) === tag )?.[1]; if (templateSelector === undefined) { @@ -1249,10 +1249,7 @@ const RichTextContentPluginInternal = ({ insertTemplateAt(templateSelector, rootInstanceSelector, false); - if ( - currentInstance?.component === "ListItem" && - $getRoot().getTextContentSize() === 0 - ) { + if (tag === "li" && $getRoot().getTextContentSize() === 0) { const parentInstanceSelector = rootInstanceSelector.slice(1); const parentInstance = $instances .get() @@ -1679,11 +1676,13 @@ export const TextEditor = ({ continue; } const meta = metas.get(instance.component); + const tags = Object.keys(meta?.presetStyle ?? {}); + const tag = instance.tag ?? tags[0]; // opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason). if ( // Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing - meta?.placeholder === undefined && + richTextPlaceholders.has(tag) === false && 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 060f711d402a..5405f9c3bb7a 100644 --- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx +++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx @@ -73,6 +73,7 @@ import { editablePlaceholderAttribute, editingPlaceholderVariable, } from "~/canvas/shared/styles"; +import { richTextPlaceholders } from "~/shared/content-model"; const ContentEditable = ({ placeholder, @@ -403,23 +404,19 @@ const getEditableComponentPlaceholder = ( mode: "editing" | "editable" ) => { const meta = metas.get(instance.component); - if (meta?.placeholder === undefined) { + const tags = Object.keys(meta?.presetStyle ?? {}); + const tag = instance.tag ?? tags[0]; + const placeholder = richTextPlaceholders.get(tag); + if (placeholder === undefined) { return; } - const isContentBlockChild = undefined !== findBlockSelector(instanceSelector, instances); - - 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; + // The paragraph contains only an "editing" placeholder within the content block. + if (tag === "p" && isContentBlockChild && mode === "editing") { + return "Write something or press '/' for commands..."; } - - return meta.placeholder; + return placeholder; }; export const WebstudioComponentCanvas = forwardRef< diff --git a/apps/builder/app/shared/content-model.ts b/apps/builder/app/shared/content-model.ts index cf6ed532db0e..ae544dfeb705 100644 --- a/apps/builder/app/shared/content-model.ts +++ b/apps/builder/app/shared/content-model.ts @@ -409,7 +409,23 @@ export const richTextContentTags = new Set([ "span", ]); -const richTextContainerTags = new Set(["a", "span"]); +/** + * textual placeholder is used when no content specified while in builder + * also signals to not insert components inside unless dropped explicitly + */ +export const richTextPlaceholders: Map = new Map([ + ["h1", "Heading 1"], + ["h2", "Heading 2"], + ["h3", "Heading 3"], + ["h4", "Heading 4"], + ["h5", "Heading 5"], + ["h6", "Heading 6"], + ["p", "Paragraph"], + ["blockquote", "Blockquote"], + ["li", "List item"], + ["a", "Link"], + ["span", "Span"], +]); const findContentTags = ({ instances, @@ -492,7 +508,7 @@ export const isRichTextTree = ({ setIsSubsetOf(contentTags, richTextContentTags) && // rich text cannot contain only span and only link // those links and spans are containers in such cases - !setIsSubsetOf(contentTags, richTextContainerTags) + !setIsSubsetOf(contentTags, new Set(richTextPlaceholders.keys())) ); }; @@ -633,7 +649,7 @@ export const findClosestNonTextualContainer = ({ } if ( instance.children.length === 0 && - !meta?.placeholder && + !richTextPlaceholders.has(tag) && !richTextContentTags.has(tag) ) { return instanceSelector.slice(index); diff --git a/packages/sdk-components-react/src/blockquote.ws.ts b/packages/sdk-components-react/src/blockquote.ws.ts index 17e16783543d..ef5f066b692d 100644 --- a/packages/sdk-components-react/src/blockquote.ws.ts +++ b/packages/sdk-components-react/src/blockquote.ws.ts @@ -59,7 +59,6 @@ const presetStyle = { } satisfies PresetStyle; export const meta: WsComponentMeta = { - 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 1e488429a028..100be098e508 100644 --- a/packages/sdk-components-react/src/heading.ws.ts +++ b/packages/sdk-components-react/src/heading.ws.ts @@ -4,7 +4,6 @@ import { h1, h2, h3, h4, h5, h6 } from "@webstudio-is/sdk/normalize.css"; import { props } from "./__generated__/heading.props"; export const meta: WsComponentMeta = { - placeholder: "Heading", icon: HeadingIcon, states: defaultStates, presetStyle: { diff --git a/packages/sdk-components-react/src/link.ws.ts b/packages/sdk-components-react/src/link.ws.ts index 77b439b3c09c..39ab444d6ab6 100644 --- a/packages/sdk-components-react/src/link.ws.ts +++ b/packages/sdk-components-react/src/link.ws.ts @@ -19,7 +19,6 @@ const presetStyle = { } satisfies PresetStyle; export const meta: WsComponentMeta = { - placeholder: "Link", icon: LinkIcon, presetStyle, states: [ diff --git a/packages/sdk-components-react/src/list-item.ws.ts b/packages/sdk-components-react/src/list-item.ws.ts index afbe23338e89..817892dfc45e 100644 --- a/packages/sdk-components-react/src/list-item.ws.ts +++ b/packages/sdk-components-react/src/list-item.ws.ts @@ -4,7 +4,6 @@ import { li } from "@webstudio-is/sdk/normalize.css"; import { props } from "./__generated__/list-item.props"; export const meta: WsComponentMeta = { - placeholder: "List item", icon: ListItemIcon, states: defaultStates, presetStyle: { li }, diff --git a/packages/sdk-components-react/src/paragraph.ws.ts b/packages/sdk-components-react/src/paragraph.ws.ts index 2b698824eda5..777c7250170d 100644 --- a/packages/sdk-components-react/src/paragraph.ws.ts +++ b/packages/sdk-components-react/src/paragraph.ws.ts @@ -1,22 +1,12 @@ import { TextAlignLeftIcon } from "@webstudio-is/icons/svg"; -import { - defaultStates, - type PresetStyle, - type WsComponentMeta, -} from "@webstudio-is/sdk"; +import { defaultStates, type WsComponentMeta } from "@webstudio-is/sdk"; import { p } from "@webstudio-is/sdk/normalize.css"; -import type { defaultTag } from "./paragraph"; import { props } from "./__generated__/paragraph.props"; -const presetStyle = { - p, -} satisfies PresetStyle; - export const meta: WsComponentMeta = { - placeholder: "Paragraph", icon: TextAlignLeftIcon, states: defaultStates, - presetStyle, + presetStyle: { p }, initialProps: ["id", "class"], props, }; diff --git a/packages/sdk-components-react/src/time.ws.ts b/packages/sdk-components-react/src/time.ws.ts index 803ff7a8f0b4..05bd5370907e 100644 --- a/packages/sdk-components-react/src/time.ws.ts +++ b/packages/sdk-components-react/src/time.ws.ts @@ -8,6 +8,10 @@ export const meta: WsComponentMeta = { description: "Converts machine-readable date and time to a human-readable format.", icon: CalendarIcon, + contentModel: { + category: "instance", + children: [], + }, states: defaultStates, presetStyle: { time, diff --git a/packages/sdk/src/schema/component-meta.ts b/packages/sdk/src/schema/component-meta.ts index 52cfa29c9ba5..2b2c53c902eb 100644 --- a/packages/sdk/src/schema/component-meta.ts +++ b/packages/sdk/src/schema/component-meta.ts @@ -82,11 +82,6 @@ export type ContentModel = z.infer; export const WsComponentMeta = z.object({ category: z.enum(componentCategories).optional(), - /** - * 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(), contentModel: ContentModel.optional(), // when this field is specified component receives // prop with index of same components withiin specified ancestor