diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/block-editor-context-menu.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/block-editor-context-menu.tsx index 2f6c2f44f8..e35ba809bc 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/block-editor-context-menu.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/block-editor-context-menu.tsx @@ -18,7 +18,6 @@ import { useEffectEvent } from "~/shared/hook-utils/effect-event"; import type { InstanceSelector } from "~/shared/tree-utils"; import type { Instance } from "@webstudio-is/sdk"; import { shallowEqual } from "shallow-equal"; -import { emitCommand } from "~/builder/shared/commands"; const TriggerButton = styled("button", { position: "absolute", @@ -84,7 +83,6 @@ const Menu = ({ (templateSelector: InstanceSelector) => { const insertBefore = modifierKeys.altKey; insertTemplateAt(templateSelector, anchor, insertBefore); - emitCommand("newInstanceText"); }, [anchor, modifierKeys.altKey] ); 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 31eca57611..c20096643c 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 @@ -4,6 +4,7 @@ import { shallowEqual } from "shallow-equal"; import { selectInstance } from "~/shared/awareness"; import { extractWebstudioFragment, + findAllEditableInstanceSelector, findAvailableDataSources, getWebstudioData, insertInstanceChildrenMutable, @@ -12,6 +13,8 @@ import { } from "~/shared/instance-utils"; import { $instances, + $registeredComponentMetas, + $textEditingInstanceSelector, findBlockChildSelector, findBlockSelector, } from "~/shared/nano-states"; @@ -111,8 +114,34 @@ export const insertTemplateAt = ( const children: Instance["children"] = [ { type: "id", value: newRootInstanceId }, ]; + insertInstanceChildrenMutable(data, children, target); + const selectedInstanceSelector = [ + newRootInstanceId, + ...target.parentSelector, + ]; + + const selectors: InstanceSelector[] = []; + + findAllEditableInstanceSelector( + selectedInstanceSelector, + data.instances, + $registeredComponentMetas.get(), + selectors + ); + + const editableInstanceSelector = selectors[0]; + + if (editableInstanceSelector) { + $textEditingInstanceSelector.set({ + selector: editableInstanceSelector, + reason: "new", + }); + } else { + $textEditingInstanceSelector.set(undefined); + } + selectInstance([newRootInstanceId, ...target.parentSelector]); }); }; diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 03769b0a3f..85521775bd 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -230,7 +230,6 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ source: "builder", externalCommands: [ "editInstanceText", - "newInstanceText", "formatBold", "formatItalic", "formatSuperscript", 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 dc4ad16dfa..3cfdcc4b91 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -39,6 +39,7 @@ import { type NodeKey, $getNodeByKey, SELECTION_CHANGE_COMMAND, + $selectAll, } from "lexical"; import { LinkNode } from "@lexical/link"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; @@ -61,7 +62,11 @@ import { ToolbarConnectorPlugin } from "./toolbar-connector"; import { type Refs, $convertToLexical, $convertToUpdates } from "./interop"; import { colord } from "colord"; import { useEffectEvent } from "~/shared/hook-utils/effect-event"; -import { findAllEditableInstanceSelector } from "~/shared/instance-utils"; +import { + deleteInstanceMutable, + findAllEditableInstanceSelector, + updateWebstudioData, +} from "~/shared/instance-utils"; import { $blockChildOutline, $hoveredInstanceOutline, @@ -72,6 +77,7 @@ import { $textEditingInstanceSelector, $textEditorContextMenu, execTextEditorContextMenuCommand, + findBlockChildSelector, findTemplates, } from "~/shared/nano-states"; import { @@ -86,6 +92,7 @@ import { selectInstance, } from "~/shared/awareness"; import { shallowEqual } from "shallow-equal"; +import { insertTemplateAt } from "~/builder/features/workspace/canvas-tools/outline/block-utils"; const BindInstanceToNodePlugin = ({ refs, @@ -709,6 +716,10 @@ const InitCursorPlugin = () => { return; } + if (reason === "new") { + $selectAll(); + return; + } reason satisfies never; }); @@ -929,38 +940,39 @@ type ContextMenuParams = { cursorRect: DOMRect; }; -type ContextMenuPluginProps = { +type RichTextContentPluginProps = { rootInstanceSelector: InstanceSelector; onOpen: ( editorState: EditorState, params: undefined | ContextMenuParams ) => void; + onNext: (editorState: EditorState, params: HandleNextParams) => void; }; -const ContextMenuPlugin = (props: ContextMenuPluginProps) => { - const [hasTemplates] = useState(() => { - const templates = findTemplates( - props.rootInstanceSelector, - $instances.get() - ); - if (templates === undefined) { - return false; - } +const RichTextContentPlugin = (props: RichTextContentPluginProps) => { + const [templates] = useState(() => + findTemplates(props.rootInstanceSelector, $instances.get()) + ); - return templates.length > 0; - }); - - if (!hasTemplates) { - return null; + if (templates === undefined) { + return; } - return ; + if (templates.length === 0) { + return; + } + + return ; }; -const ContextMenuPluginInternal = ({ +const RichTextContentPluginInternal = ({ rootInstanceSelector, onOpen, -}: ContextMenuPluginProps) => { + templates, + onNext, +}: RichTextContentPluginProps & { + templates: [instance: Instance, instanceSelector: InstanceSelector][]; +}) => { const [editor] = useLexicalComposerContext(); const [preservedSelection] = useState(rootInstanceSelector); @@ -1002,6 +1014,19 @@ const ContextMenuPluginInternal = ({ if (!isSelectionInSameComponent) { node?.remove(); + + const rootNodeContent = $getRoot().getTextContent().trim(); + // Delete current + if (rootNodeContent.length === 0) { + const blockChildSelector = + findBlockChildSelector(rootInstanceSelector); + + if (blockChildSelector) { + updateWebstudioData((data) => { + deleteInstanceMutable(data, rootInstanceSelector); + }); + } + } } // if selection changed, remove the slash node @@ -1022,11 +1047,6 @@ const ContextMenuPluginInternal = ({ return false; } - if (!isSingleCursorSelection()) { - closeMenu(); - return false; - } - const selection = $getSelection(); if (!$isRangeSelection(selection)) { @@ -1047,16 +1067,89 @@ const ContextMenuPluginInternal = ({ const unsubscibeKeyDown = editor.registerCommand( KEY_DOWN_COMMAND, (event) => { - if (!isSingleCursorSelection()) { - return false; - } - const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } + if (event.key === "Backspace" || event.key === "Delete") { + const rootNodeContent = $getRoot().getTextContent().trim(); + // Delete current + if (rootNodeContent.length === 0) { + const blockChildSelector = + findBlockChildSelector(rootInstanceSelector); + + if (blockChildSelector) { + onNext(editor.getEditorState(), { reason: "left" }); + + updateWebstudioData((data) => { + deleteInstanceMutable(data, rootInstanceSelector); + }); + + event.preventDefault(); + return true; + } + } + } + + if (menuState === "closed") { + if (event.key === "Enter" && !event.shiftKey) { + // Check if it pressed on the last line, last symbol + + const allowedComponents = ["Paragraph", "Text", "Heading"]; + + for (const component of allowedComponents) { + const templateSelector = templates.find( + ([instance]) => instance.component === component + )?.[1]; + + if (templateSelector === undefined) { + continue; + } + + /* + @todo Split logic idea + // clone root node then + + // getPreviousSibling + const removeNextSiblings = (node: LexicalNode) => { + let current: LexicalNode | null = node; + while (current) { + const next = current.getNextSibling(); + if (next) { + next.remove(); + continue; + } + // Move up to parent and continue removing siblings + + current = current.getParent(); + + if ($isRootNode(current)) { + break; + } + } + }; + + const anchorNode = selection.anchor.getNode(); + const anchorOffset = selection.anchor.offset; + + if (!$isTextNode(anchorNode)) { + continue; + } + anchorNode.splitText(anchorOffset); + removeNextSiblings(anchorNode); + + */ + + insertTemplateAt(templateSelector, rootInstanceSelector, false); + + event.preventDefault(); + return true; + } + } + } + if (menuState === "opened") { if (event.key === "Escape") { closeMenu(); @@ -1192,7 +1285,14 @@ const ContextMenuPluginInternal = ({ unsubscibeSelectionChange(); unsubscribeBlurListener(); }; - }, [editor, handleOpen, preservedSelection]); + }, [ + editor, + handleOpen, + onNext, + preservedSelection, + rootInstanceSelector, + templates, + ]); return null; }; @@ -1282,13 +1382,14 @@ const AnyKeyDownPlugin = ({ }; export const TextEditor = ({ - rootInstanceSelector, + rootInstanceSelector: rootInstanceSelectorUnstable, instances, contentEditable, editable, onChange, onSelectInstance, }: TextEditorProps) => { + const [rootInstanceSelector] = useState(() => rootInstanceSelectorUnstable); // class names must be started with letter so we add a prefix const [paragraphClassName] = useState(() => `a${nanoid()}`); const [italicClassName] = useState(() => `a${nanoid()}`); @@ -1314,6 +1415,15 @@ export const TextEditor = ({ setDataCollapsed(rootInstanceSelector[0], false); }); + + const textEditingSelector = $textEditingInstanceSelector.get()?.selector; + if (textEditingSelector === undefined) { + return; + } + + if (shallowEqual(textEditingSelector, rootInstanceSelector)) { + $textEditingInstanceSelector.set(undefined); + } }); useLayoutEffect(() => { @@ -1363,7 +1473,7 @@ export const TextEditor = ({ onError, }; - const handleNext = useCallback( + const handleNext = useEffectEvent( (state: EditorState, args: HandleNextParams) => { const rootInstanceId = $selectedPage.get()?.rootInstanceId; @@ -1444,8 +1554,7 @@ export const TextEditor = ({ break; } - }, - [handleChange, instances, rootInstanceSelector] + } ); const handleAnyKeydown = useCallback((event: KeyboardEvent) => { @@ -1506,9 +1615,10 @@ export const TextEditor = ({ - diff --git a/apps/builder/app/canvas/instance-hovering.ts b/apps/builder/app/canvas/instance-hovering.ts index 4679874476..0f6fa03a7c 100644 --- a/apps/builder/app/canvas/instance-hovering.ts +++ b/apps/builder/app/canvas/instance-hovering.ts @@ -46,41 +46,47 @@ export const subscribeInstanceHovering = ({ }, eventOptions ); - window.addEventListener( - "mousemove", - () => { - // We want the hover outline to appear only if a mouse or trackpad action caused it, not from keyboard navigation. - // Otherwise, when we leave the Lexical editor using the keyboard, - // the mouseover event triggers on elements created after Lexical loses focus. - // This causes an outline to appear on the element under the now-invisible mouse pointer - // (as the browser hides the pointer on blur), creating some visual distraction. - if (hoveredElement !== undefined) { - const instanceSelector = getInstanceSelectorFromElement(hoveredElement); - if (instanceSelector) { - if (updateOnMouseMove) { - $hoveredInstanceSelector.set(instanceSelector); - updateEditableChildOutline(instanceSelector); - } else { - const textSelector = $textEditingInstanceSelector.get()?.selector; - // We need to update the editable child's outline even if the mouseover event is not triggered. - // This can happen if the user enters text editing mode, presses any key, and then moves the mouse. - // In this case, the mouseover event does not occur, but we still need to show the editable child's outline. - if ( - textSelector && - isDescendantOrSelf(instanceSelector, textSelector) && // optimisation - $blockChildOutline.get() === undefined - ) { - updateEditableChildOutline(instanceSelector); - } + const updateEditableOutline = () => { + // We want the hover outline to appear only if a mouse or trackpad action caused it, not from keyboard navigation. + // Otherwise, when we leave the Lexical editor using the keyboard, + // the mouseover event triggers on elements created after Lexical loses focus. + // This causes an outline to appear on the element under the now-invisible mouse pointer + // (as the browser hides the pointer on blur), creating some visual distraction. + if (hoveredElement !== undefined) { + const instanceSelector = getInstanceSelectorFromElement(hoveredElement); + if (instanceSelector) { + if (updateOnMouseMove) { + $hoveredInstanceSelector.set(instanceSelector); + updateEditableChildOutline(instanceSelector); + } else { + const textSelector = $textEditingInstanceSelector.get()?.selector; + + // We need to update the editable child's outline even if the mouseover event is not triggered. + // This can happen if the user enters text editing mode, presses any key, and then moves the mouse. + // In this case, the mouseover event does not occur, but we still need to show the editable child's outline. + if ( + textSelector && + isDescendantOrSelf(instanceSelector, textSelector) && // optimisation + $blockChildOutline.get() === undefined + ) { + updateEditableChildOutline(instanceSelector); } } } - updateOnMouseMove = false; - }, - eventOptions + } + updateOnMouseMove = false; + }; + + const unsubscribeTextEditingInstance = $textEditingInstanceSelector.listen( + () => { + // Fixes the bug if initial editable instance is empty and has collapsed paddings + setTimeout(updateEditableOutline, 0); + } ); + window.addEventListener("mousemove", updateEditableOutline, eventOptions); + window.addEventListener( "mouseout", () => { @@ -210,5 +216,6 @@ export const subscribeInstanceHovering = ({ clearTimeout(mouseOutTimeoutId); unsubscribeHoveredInstanceId(); usubscribeSelectedInstanceSelector(); + unsubscribeTextEditingInstance(); }); }; diff --git a/apps/builder/app/canvas/shared/commands.ts b/apps/builder/app/canvas/shared/commands.ts index 9bb08bc8ed..ef6c4a1f27 100644 --- a/apps/builder/app/canvas/shared/commands.ts +++ b/apps/builder/app/canvas/shared/commands.ts @@ -26,58 +26,6 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ source: "canvas", externalCommands: ["clickCanvas"], commands: [ - { - name: "newInstanceText", - hidden: true, - disableHotkeyOutsideApp: true, - handler: () => { - const selectedInstanceSelector = $selectedInstanceSelector.get(); - if (selectedInstanceSelector === undefined) { - return; - } - - if ( - isDescendantOrSelf( - $textEditingInstanceSelector.get()?.selector ?? [], - selectedInstanceSelector - ) - ) { - // already in text editing mode - return; - } - - const selectors: InstanceSelector[] = []; - - findAllEditableInstanceSelector( - selectedInstanceSelector, - $instances.get(), - $registeredComponentMetas.get(), - selectors - ); - - if (selectors.length === 0) { - $textEditingInstanceSelector.set(undefined); - return; - } - - const editableInstanceSelector = selectors[0]; - - const element = getElementByInstanceSelector(editableInstanceSelector); - if (element === undefined) { - return; - } - // When an event is triggered from the Builder, - // the canvas element may be unfocused, so it's important to focus the element on the canvas. - element.focus(); - selectInstance(editableInstanceSelector); - - $textEditingInstanceSelector.set({ - selector: editableInstanceSelector, - reason: "enter", - }); - }, - }, - { name: "editInstanceText", hidden: true, @@ -146,6 +94,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ name: "escapeSelection", hidden: true, defaultHotkeys: ["escape"], + disableHotkeyOnContentEditable: true, // reset selection for canvas, but not for the builder disableHotkeyOutsideApp: true, handler: () => { diff --git a/apps/builder/app/shared/nano-states/instances.ts b/apps/builder/app/shared/nano-states/instances.ts index 86ce9e1454..d2a2a6415a 100644 --- a/apps/builder/app/shared/nano-states/instances.ts +++ b/apps/builder/app/shared/nano-states/instances.ts @@ -18,6 +18,10 @@ export const $textEditingInstanceSelector = atom< selector: InstanceSelector; reason: "right" | "left" | "enter"; } + | { + selector: InstanceSelector; + reason: "new"; + } | { selector: InstanceSelector; reason: "click";