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 c20096643ceb..5648a0147eda 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 @@ -68,6 +68,75 @@ const getInsertionIndex = ( return insertBefore ? index : index + 1; }; +export const insertListItemAt = (listItemSelector: InstanceSelector) => { + const instances = $instances.get(); + + const parentSelector = listItemSelector.slice(1); + + const parentInstance = instances.get(parentSelector[0]); + + if (parentInstance === undefined) { + return; + } + + const position = + 1 + + parentInstance.children.findIndex( + (child) => child.type === "id" && child.value === listItemSelector[0] + ); + + if (position === 0) { + return; + } + + const target: DroppableTarget = { + parentSelector, + position, + }; + + const fragment = extractWebstudioFragment( + getWebstudioData(), + listItemSelector[0] + ); + + fragment.instances = structuredClone(fragment.instances); + fragment.instances.splice(1); + fragment.instances[0].children = []; + + updateWebstudioData((data) => { + const { newInstanceIds } = insertWebstudioFragmentCopy({ + data, + fragment, + availableDataSources: findAvailableDataSources( + data.dataSources, + data.instances, + target.parentSelector + ), + }); + const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); + if (newRootInstanceId === undefined) { + return; + } + const children: Instance["children"] = [ + { type: "id", value: newRootInstanceId }, + ]; + + insertInstanceChildrenMutable(data, children, target); + + const selectedInstanceSelector = [ + newRootInstanceId, + ...target.parentSelector, + ]; + + $textEditingInstanceSelector.set({ + selector: selectedInstanceSelector, + reason: "new", + }); + + selectInstance(selectedInstanceSelector); + }); +}; + export const insertTemplateAt = ( templateSelector: InstanceSelector, anchor: InstanceSelector, 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 3cfdcc4b916d..81a01dcdf6c9 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -92,7 +92,10 @@ import { selectInstance, } from "~/shared/awareness"; import { shallowEqual } from "shallow-equal"; -import { insertTemplateAt } from "~/builder/features/workspace/canvas-tools/outline/block-utils"; +import { + insertListItemAt, + insertTemplateAt, +} from "~/builder/features/workspace/canvas-tools/outline/block-utils"; const BindInstanceToNodePlugin = ({ refs, @@ -572,12 +575,21 @@ const InitCursorPlugin = () => { if ($isTextNode(node)) { selection.anchor.set(node.getKey(), domOffset, "text"); selection.focus.set(node.getKey(), domOffset, "text"); + const normalizedSelection = + $normalizeSelection__EXPERIMENTAL(selection); + + $setSelection(normalizedSelection); + return; } - const normalizedSelection = - $normalizeSelection__EXPERIMENTAL(selection); + } - $setSelection(normalizedSelection); - return; + if (domNode instanceof Element) { + const rect = domNode.getBoundingClientRect(); + if (mouseX > rect.right) { + const selection = $getRoot().selectEnd(); + $setSelection(selection); + return; + } } } } @@ -1075,8 +1087,23 @@ const RichTextContentPluginInternal = ({ if (event.key === "Backspace" || event.key === "Delete") { const rootNodeContent = $getRoot().getTextContent().trim(); - // Delete current + if (rootNodeContent.length === 0) { + const currentInstance = $instances + .get() + .get(rootInstanceSelector[0]); + + if (currentInstance?.component === "ListItem") { + onNext(editor.getEditorState(), { reason: "left" }); + + updateWebstudioData((data) => { + deleteInstanceMutable(data, rootInstanceSelector); + }); + + event.preventDefault(); + return true; + } + const blockChildSelector = findBlockChildSelector(rootInstanceSelector); @@ -1084,7 +1111,7 @@ const RichTextContentPluginInternal = ({ onNext(editor.getEditorState(), { reason: "left" }); updateWebstudioData((data) => { - deleteInstanceMutable(data, rootInstanceSelector); + deleteInstanceMutable(data, blockChildSelector); }); event.preventDefault(); @@ -1095,6 +1122,18 @@ 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") { + // Instead of creating block component we need to add a new ListItem + insertListItemAt(rootInstanceSelector); + event.preventDefault(); + return true; + } + // Check if it pressed on the last line, last symbol const allowedComponents = ["Paragraph", "Text", "Heading"]; @@ -1531,9 +1570,20 @@ export const TextEditor = ({ const instance = instances.get(nextSelector[0]); + if (instance === undefined) { + continue; + } + + // Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing + const componentsWithPseudoElementChildren = ["ListItem"]; + // opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason). - if (instance?.children.length === 0) { + if ( + !componentsWithPseudoElementChildren.includes(instance.component) && + instance?.children.length === 0 + ) { const elt = getElementByInstanceSelector(nextSelector); + if (elt === undefined) { continue; } diff --git a/apps/builder/app/canvas/instance-hovering.ts b/apps/builder/app/canvas/instance-hovering.ts index 0f6fa03a7cd6..d02e04521bd8 100644 --- a/apps/builder/app/canvas/instance-hovering.ts +++ b/apps/builder/app/canvas/instance-hovering.ts @@ -78,11 +78,13 @@ export const subscribeInstanceHovering = ({ updateOnMouseMove = false; }; - const unsubscribeTextEditingInstance = $textEditingInstanceSelector.listen( + window.addEventListener( + "click", () => { // Fixes the bug if initial editable instance is empty and has collapsed paddings setTimeout(updateEditableOutline, 0); - } + }, + eventOptions ); window.addEventListener("mousemove", updateEditableOutline, eventOptions); @@ -216,6 +218,5 @@ export const subscribeInstanceHovering = ({ clearTimeout(mouseOutTimeoutId); unsubscribeHoveredInstanceId(); usubscribeSelectedInstanceSelector(); - unsubscribeTextEditingInstance(); }); }; diff --git a/packages/react-sdk/src/core-components.ts b/packages/react-sdk/src/core-components.ts index 9053d56f4cbc..95faec5a03bf 100644 --- a/packages/react-sdk/src/core-components.ts +++ b/packages/react-sdk/src/core-components.ts @@ -173,59 +173,204 @@ const blockMeta: WsComponentMeta = { props: [], children: [ { + component: blockTemplateComponent, type: "instance", label: "Templates", - component: blockTemplateComponent, children: [ { - type: "instance", component: "Paragraph", - children: [ + type: "instance", + children: [], + }, + { + component: "Heading", + type: "instance", + label: "Heading 1", + props: [ { - type: "text", - value: "Paragraph text you can edit", - placeholder: true, + name: "tag", + type: "string", + value: "h1", + }, + ], + children: [], + }, + { + component: "Heading", + type: "instance", + label: "Heading 2", + props: [ + { + name: "tag", + type: "string", + value: "h2", + }, + ], + children: [], + }, + { + component: "Heading", + type: "instance", + label: "Heading 3", + props: [ + { + name: "tag", + type: "string", + value: "h3", }, ], + children: [], }, { + component: "Heading", type: "instance", + label: "Heading 4", + props: [ + { + name: "tag", + type: "string", + value: "h4", + }, + ], + children: [], + }, + { + component: "Heading", + type: "instance", + label: "Heading 5", + props: [ + { + name: "tag", + type: "string", + value: "h5", + }, + ], + children: [], + }, + { + component: "Heading", + type: "instance", + label: "Heading 6", + props: [ + { + name: "tag", + type: "string", + value: "h6", + }, + ], + children: [], + }, + { component: "List", + type: "instance", + label: "List (Unordered)", children: [ { + component: "ListItem", type: "instance", + children: [{ type: "text", value: "list item you can edit" }], + }, + { component: "ListItem", - children: [ - { - type: "text", - value: "List Item text you can edit", - placeholder: true, - }, - ], + 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: "List", + type: "instance", + label: "List (Ordered)", + props: [ + { + name: "ordered", + type: "boolean", + value: true, + }, + ], + children: [ + { component: "ListItem", - children: [ - { - type: "text", - value: "List Item text you can edit", - placeholder: true, - }, - ], + 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", - children: [ - { - type: "text", - value: "List Item text you can edit", - placeholder: true, - }, - ], + type: "instance", + children: [{ type: "text", value: "list item you can edit" }], + }, + ], + }, + { + component: "Link", + type: "instance", + children: [], + }, + { + component: "Image", + type: "instance", + styles: [ + { + property: "marginRight", + value: { + type: "keyword", + value: "auto", + }, + }, + { + property: "marginLeft", + value: { + type: "keyword", + value: "auto", + }, + }, + { + property: "width", + value: { + type: "unit", + unit: "%", + value: 100, + }, + }, + { + property: "height", + value: { + type: "keyword", + value: "auto", + }, }, ], + children: [], + }, + { + component: "Separator", + type: "instance", + children: [], + }, + { + component: "Blockquote", + type: "instance", + children: [], + }, + { + component: "HtmlEmbed", + type: "instance", + children: [], + }, + { + component: "CodeText", + type: "instance", + children: [], }, ], },