From fcc577a19030d464187d218498b6415e56b4e5ea Mon Sep 17 00:00:00 2001 From: istarkov Date: Wed, 18 Dec 2024 19:21:39 +0000 Subject: [PATCH 01/12] Edit --- .../block-editor-context-menu.tsx | 61 ++++ .../workspace/canvas-tools/canvas-tools.tsx | 2 + .../outline/block-instance-outline.tsx | 75 +++-- .../features/text-editor/text-editor.tsx | 188 +++++++++++ apps/builder/app/canvas/shared/commands.ts | 14 +- .../app/shared/nano-states/instances.ts | 7 + apps/builder/app/shared/sync/sync-stores.ts | 2 + apps/builder/package.json | 12 +- pnpm-lock.yaml | 314 +++++++++--------- 9 files changed, 475 insertions(+), 200 deletions(-) create mode 100644 apps/builder/app/builder/features/workspace/canvas-tools/block-editor-context-menu.tsx 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 new file mode 100644 index 000000000000..305d7b371e8e --- /dev/null +++ b/apps/builder/app/builder/features/workspace/canvas-tools/block-editor-context-menu.tsx @@ -0,0 +1,61 @@ +import { useStore } from "@nanostores/react"; +import { styled } from "@webstudio-is/design-system"; + +import { + $textEditingInstanceSelector, + $textEditorContextMenu, +} from "~/shared/nano-states"; +import { applyScale } from "./outline"; +import { $scale } from "~/builder/shared/nano-states"; +import { TemplatesMenu } from "./outline/block-instance-outline"; + +const TriggerButton = styled("button", { + position: "absolute", + appearance: "none", + backgroundColor: "transparent", + outline: "none", + pointerEvents: "all", + border: "none", + overflow: "hidden", + padding: 0, +}); + +export const TextEditorContextMenu = () => { + const textEditingInstanceSelector = useStore($textEditingInstanceSelector); + const textEditorContextMenu = useStore($textEditorContextMenu); + const scale = useStore($scale); + // const clampingRect = useStore($clampingRect); + + if (textEditorContextMenu === undefined) { + return; + } + + if (textEditingInstanceSelector === undefined) { + return; + } + const rect = applyScale(textEditorContextMenu.cursorRect, scale); + + return ( + { + console.log("open", open); + if (open) { + return; + } + $textEditorContextMenu.set(undefined); + }} + anchor={textEditingInstanceSelector.selector} + triggerTooltipContent={<>"Templates"} + > + + + ); +}; diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/canvas-tools.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/canvas-tools.tsx index f855015c6141..8b006915813b 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/canvas-tools.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/canvas-tools.tsx @@ -19,6 +19,7 @@ import { useSubscribeDragAndDropState } from "./use-subscribe-drag-drop-state"; import { applyScale } from "./outline"; import { $clampingRect, $scale } from "~/builder/shared/nano-states"; import { BlockChildHoveredInstanceOutline } from "./outline/block-instance-outline"; +import { TextEditorContextMenu } from "./block-editor-context-menu"; const containerStyle = css({ position: "absolute", @@ -82,6 +83,7 @@ export const CanvasTools = () => { + )} diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx index 8d1509fb7eac..036ce2e953d0 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx @@ -32,7 +32,7 @@ import { applyScale } from "./apply-scale"; import { $clampingRect, $scale } from "~/builder/shared/nano-states"; import { PlusIcon, TrashIcon } from "@webstudio-is/icons"; import { BoxIcon } from "@webstudio-is/icons/svg"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { isFeatureEnabled } from "@webstudio-is/feature-flags"; import type { DroppableTarget, InstanceSelector } from "~/shared/tree-utils"; import type { Instance, Instances } from "@webstudio-is/sdk"; @@ -173,22 +173,25 @@ const getInsertionIndex = ( return insertBefore ? index : index + 1; }; -const TemplatesMenu = ({ +export const TemplatesMenu = ({ onOpenChange, open, children, anchor, + triggerTooltipContent, }: { children: React.ReactNode; open: boolean; onOpenChange: (open: boolean) => void; anchor: InstanceSelector; + triggerTooltipContent: JSX.Element; }) => { const instances = useStore($instances); const metas = useStore($registeredComponentMetas); const modifierKeys = useStore($modifierKeys); const blockInstanceSelector = findBlockSelector(anchor, instances); + useEffect(() => {}, []); if (blockInstanceSelector === undefined) { return; @@ -214,7 +217,13 @@ const TemplatesMenu = ({ return ( - {children} + + {children} + { @@ -284,7 +295,6 @@ const TemplatesMenu = ({ key={id} value={JSON.stringify(value)} {...{ [skipInertHandlersAttribute]: true }} - data-yyy > {icon} @@ -465,40 +475,33 @@ export const BlockChildHoveredInstanceOutline = () => { } }} anchor={outline.selector} + triggerTooltipContent={tooltipContent} > - { + if (isAddMode) { + return; + } + + updateWebstudioData((data) => { + deleteInstanceMutable(data, outline.selector); + }); + + setButtonOutline(undefined); + $blockChildOutline.set(undefined); + $hoveredInstanceSelector.set(undefined); + $hoveredInstanceOutline.set(undefined); + }} + css={{ + borderStyle: "solid", + borderColor: isAddMode + ? `oklch(from ${theme.colors.backgroundPrimary} l c h / 0.7)` + : undefined, + }} > - - { - if (isAddMode) { - return; - } - - updateWebstudioData((data) => { - deleteInstanceMutable(data, outline.selector); - }); - - setButtonOutline(undefined); - $blockChildOutline.set(undefined); - $hoveredInstanceSelector.set(undefined); - $hoveredInstanceOutline.set(undefined); - }} - css={{ - borderStyle: "solid", - borderColor: isAddMode - ? `oklch(from ${theme.colors.backgroundPrimary} l c h / 0.7)` - : undefined, - }} - > - {isAddMode ? : } - - - + {isAddMode ? : } + 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 1af2dbe37b24..13aeaf0ff9ef 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -37,6 +37,8 @@ import { KEY_DOWN_COMMAND, COMMAND_PRIORITY_NORMAL, type NodeKey, + $getNodeByKey, + SELECTION_CHANGE_COMMAND, } from "lexical"; import { LinkNode } from "@lexical/link"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; @@ -67,6 +69,7 @@ import { $registeredComponentMetas, $selectedInstanceSelector, $textEditingInstanceSelector, + $textEditorContextMenu, } from "~/shared/nano-states"; import { getElementByInstanceSelector, @@ -170,6 +173,7 @@ const OnChangeOnBlurPlugin = ({ useEffect(() => { const handleBlur = () => { + console.log("handleBlur"); handleChange(editor.getEditorState()); }; @@ -904,6 +908,176 @@ const SwitchBlockPlugin = ({ onNext }: SwitchBlockPluginProps) => { return null; }; +type ContextMenuParams = { + cursorRect: DOMRect; +}; + +type ContextMenuPluginProps = { + onOpen: ( + editorState: EditorState, + params: undefined | ContextMenuParams + ) => void; +}; + +const ContextMenuPlugin = ({ onOpen }: ContextMenuPluginProps) => { + const [editor] = useLexicalComposerContext(); + + const handleOpen = useEffectEvent(onOpen); + + useEffect(() => { + if (!editor.isEditable()) { + return; + } + + let menuState: "closed" | "opening" | "opened" = "closed"; + + let slashNodeKey: NodeKey | undefined = undefined; + + const closeMenu = () => { + if (menuState === "closed") { + return; + } + + menuState = "closed"; + + handleOpen(editor.getEditorState(), undefined); + + if (slashNodeKey === undefined) { + return; + } + + const node = $getNodeByKey(slashNodeKey); + if ($isTextNode(node)) { + node.setStyle(""); + } + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.setStyle(""); + }; + + const unsubscibeSelectionChange = editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + if (menuState !== "opened") { + return false; + } + + if (!isSingleCursorSelection()) { + closeMenu(); + return false; + } + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + closeMenu(); + return false; + } + + if (selection.anchor.key !== slashNodeKey) { + closeMenu(); + return false; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ); + + const unsubscibeKeyDown = editor.registerCommand( + KEY_DOWN_COMMAND, + (event) => { + if (!isSingleCursorSelection()) { + return false; + } + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return false; + } + + if (menuState === "opened") { + if (event.key === "Escape" || event.key === " ") { + closeMenu(); + event.preventDefault(); + return true; + } + + if (event.key === "ArrowUp") { + // @todo + event.preventDefault(); + return true; + } + + if (event.key === "ArrowDown") { + // @todo + event.preventDefault(); + return true; + } + } + + if (event.key !== "/") { + return false; + } + + const slashNode = $createTextNode("/"); + slashNodeKey = slashNode.getKey(); + menuState = "opening"; + + slashNode.setStyle("background-color: rgba(127, 127, 127, 0.2);"); + selection.setStyle("background-color: rgba(127, 127, 127, 0.2);"); + selection.insertNodes([slashNode]); + + event.preventDefault(); + + return true; + }, + COMMAND_PRIORITY_EDITOR + ); + + const unsubscribeUpdateListener = editor.registerUpdateListener( + ({ editorState }) => { + if (menuState !== "opening") { + return; + } + + editorState.read(() => { + if (slashNodeKey === undefined) { + // handleOpen(editor.getEditorState(), undefined); + return; + } + const slashNode = editor.getElementByKey(slashNodeKey); + + if (slashNode === null) { + return; + } + + const rect = slashNode.getBoundingClientRect(); + + menuState = "opened"; + handleOpen(editor.getEditorState(), { + cursorRect: rect, + }); + }); + } + ); + + return () => { + unsubscibeKeyDown(); + unsubscribeUpdateListener(); + unsubscibeSelectionChange(); + }; + }, [editor, handleOpen]); + + return null; +}; + const onError = (error: Error) => { throw error; }; @@ -1004,6 +1178,11 @@ export const TextEditor = ({ const handleChange = useEffectEvent((editorState: EditorState) => { editorState.read(() => { + // Otherwise editorState.read captures focus + if ($textEditorContextMenu.get() !== undefined) { + //return; + } + const treeRootInstance = instances.get(rootInstanceSelector[0]); if (treeRootInstance) { const jsonState = editorState.toJSON(); @@ -1180,6 +1359,14 @@ export const TextEditor = ({ [newLinkKeyToInstanceId] ); + const handleContextMenuOpen = useCallback( + (_editorState: EditorState, params: undefined | ContextMenuParams) => { + console.log("CLOSE"); + $textEditorContextMenu.set(params); + }, + [] + ); + return ( @@ -1207,6 +1394,7 @@ export const TextEditor = ({ + { const selectedInstanceSelector = $selectedInstanceSelector.get(); const textEditingInstanceSelector = $textEditingInstanceSelector.get(); + const textToolbar = $textToolbar.get(); + if (selectedInstanceSelector === undefined) { return; } + + // close text toolbar first without exiting text editing mode + if (textToolbar) { + $textToolbar.set(undefined); + return; + } + // exit text editing mode first without unselecting instance if (textEditingInstanceSelector) { - $textEditingInstanceSelector.set(undefined); + // $textEditingInstanceSelector.set(undefined); return; } // unselect both instance and style source - selectInstance(undefined); + // selectInstance(undefined); }, }, diff --git a/apps/builder/app/shared/nano-states/instances.ts b/apps/builder/app/shared/nano-states/instances.ts index 352757e04fa8..dd8af6b37da1 100644 --- a/apps/builder/app/shared/nano-states/instances.ts +++ b/apps/builder/app/shared/nano-states/instances.ts @@ -31,4 +31,11 @@ export const $textEditingInstanceSelector = atom< } >(); +export const $textEditorContextMenu = atom< + | { + cursorRect: DOMRect; + } + | undefined +>(undefined); + export const $instances = atom(new Map()); diff --git a/apps/builder/app/shared/sync/sync-stores.ts b/apps/builder/app/shared/sync/sync-stores.ts index 86fb9b8c64fc..0531ebb05395 100644 --- a/apps/builder/app/shared/sync/sync-stores.ts +++ b/apps/builder/app/shared/sync/sync-stores.ts @@ -36,6 +36,7 @@ import { $builderMode, $selectedBreakpointId, $textEditingInstanceSelector, + $textEditorContextMenu, $isResizingCanvas, $collaborativeInstanceRect, $collaborativeInstanceSelector, @@ -135,6 +136,7 @@ export const createObjectPool = () => { "textEditingInstanceSelector", $textEditingInstanceSelector ), + new NanostoresSyncObject("textEditorContextMenu", $textEditorContextMenu), new NanostoresSyncObject("isResizingCanvas", $isResizingCanvas), new NanostoresSyncObject("textToolbar", $textToolbar), new NanostoresSyncObject( diff --git a/apps/builder/package.json b/apps/builder/package.json index 3bc9c7c47570..d62635ec772f 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -32,11 +32,11 @@ "@fontsource-variable/inter": "^5.0.20", "@fontsource-variable/manrope": "^5.0.20", "@fontsource/roboto-mono": "^5.0.18", - "@lexical/headless": "^0.16.0", - "@lexical/link": "^0.16.0", - "@lexical/react": "^0.16.0", - "@lexical/selection": "^0.16.0", - "@lexical/utils": "^0.16.0", + "@lexical/headless": "^0.21.0", + "@lexical/link": "^0.21.0", + "@lexical/react": "^0.21.0", + "@lexical/selection": "^0.21.0", + "@lexical/utils": "^0.21.0", "@lezer/common": "^1.2.3", "@lezer/css": "^1.1.9", "@lezer/highlight": "^1.2.1", @@ -88,7 +88,7 @@ "immer": "^10.1.1", "immerhin": "^0.10.0", "isbot": "^5.1.17", - "lexical": "^0.16.0", + "lexical": "^0.21.0", "match-sorter": "^8.0.0", "mdast-util-from-markdown": "^2.0.1", "mdast-util-gfm": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f097a4e672c..11f844f741f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,20 +164,20 @@ importers: specifier: ^5.0.18 version: 5.0.18 '@lexical/headless': - specifier: ^0.16.0 - version: 0.16.0 + specifier: ^0.21.0 + version: 0.21.0 '@lexical/link': - specifier: ^0.16.0 - version: 0.16.0 + specifier: ^0.21.0 + version: 0.21.0 '@lexical/react': - specifier: ^0.16.0 - version: 0.16.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)(yjs@13.5.52) + specifier: ^0.21.0 + version: 0.21.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)(yjs@13.5.52) '@lexical/selection': - specifier: ^0.16.0 - version: 0.16.0 + specifier: ^0.21.0 + version: 0.21.0 '@lexical/utils': - specifier: ^0.16.0 - version: 0.16.0 + specifier: ^0.21.0 + version: 0.21.0 '@lezer/common': specifier: ^1.2.3 version: 1.2.3 @@ -332,8 +332,8 @@ importers: specifier: ^5.1.17 version: 5.1.17 lexical: - specifier: ^0.16.0 - version: 0.16.0 + specifier: ^0.21.0 + version: 0.21.0 match-sorter: specifier: ^8.0.0 version: 8.0.0 @@ -3412,77 +3412,77 @@ packages: '@jspm/core@2.0.1': resolution: {integrity: sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==} - '@lexical/clipboard@0.16.0': - resolution: {integrity: sha512-eYMJ6jCXpWBVC05Mu9HLMysrBbfi++xFfsm+Yo7A6kYGrqYUhpXqjJkYnw1xdZYL3bV73Oe4ByVJuq42GU+Mqw==} + '@lexical/clipboard@0.21.0': + resolution: {integrity: sha512-3lNMlMeUob9fcnRXGVieV/lmPbmet/SVWckNTOwzfKrZ/YW5HiiyJrWviLRVf50dGXTbmBGt7K/2pfPYvWCHFA==} - '@lexical/code@0.16.0': - resolution: {integrity: sha512-1EKCBSFV745UI2zn5v75sKcvVdmd+y2JtZhw8CItiQkRnBLv4l4d/RZYy+cKOuXJGsoBrKtxXn5sl7HebwQbPw==} + '@lexical/code@0.21.0': + resolution: {integrity: sha512-E0DNSFu4I+LMn3ft+UT0Dbntc8ZKjIA0BJj6BDewm0qh3bir40YUf5DkI2lpiFNRF2OpcmmcIxakREeU6avqTA==} - '@lexical/devtools-core@0.16.0': - resolution: {integrity: sha512-Jt8p0J0UoMHf3UMh3VdyrXbLLwpEZuMqihTmbPRpwo+YQ6NGQU35QgwY2K0DpPAThpxL/Cm7uaFqGOy8Kjrhqw==} + '@lexical/devtools-core@0.21.0': + resolution: {integrity: sha512-csK41CmRLZbKNV5pT4fUn5RzdPjU5PoWR8EqaS9kiyayhDg2zEnuPtvUYWanLfCLH9A2oOfbEsGxjMctAySlJw==} peerDependencies: react: 18.3.0-canary-14898b6a9-20240318 react-dom: 18.3.0-canary-14898b6a9-20240318 - '@lexical/dragon@0.16.0': - resolution: {integrity: sha512-Yr29SFZzOPs+S6UrEZaXnnso1fJGVfZOXVJQZbyzlspqJpSHXVH7InOXYHWN6JSWQ8Hs/vU3ksJXwqz+0TCp2g==} + '@lexical/dragon@0.21.0': + resolution: {integrity: sha512-ahTCaOtRFNauEzplN1qVuPjyGAlDd+XcVM5FQCdxVh/1DvqmBxEJRVuCBqatzUUVb89jRBekYUcEdnY9iNjvEQ==} - '@lexical/hashtag@0.16.0': - resolution: {integrity: sha512-2EdAvxYVYqb0nv6vgxCRgE8ip7yez5p0y0oeUyxmdbcfZdA+Jl90gYH3VdevmZ5Bk3wE0/fIqiLD+Bb5smqjCQ==} + '@lexical/hashtag@0.21.0': + resolution: {integrity: sha512-O4dxcZNq1Xm45HLoRifbGAYvQkg3qLoBc6ibmHnDqZL5mQDsufnH6QEKWfgDtrvp9++3iqsSC+TE7VzWIvA7ww==} - '@lexical/headless@0.16.0': - resolution: {integrity: sha512-B0efH1EYpjPD5kayCsHFRbvneyF64JMB/unC6uYRkUxp6a733pqsWGta8DDn7KQt0W1XPI0jqDoJFDmQKwoi4g==} + '@lexical/headless@0.21.0': + resolution: {integrity: sha512-7/eEz6ed39MAg34c+rU7xUn46UV4Wdt5dEZwsdBzuflWhpNeUscQmkw8wIoFhEhJdCc+ZbB17CnjJlUZ1RxHvg==} - '@lexical/history@0.16.0': - resolution: {integrity: sha512-xwFxgDZGviyGEqHmgt6A6gPhsyU/yzlKRk9TBUVByba3khuTknlJ1a80H5jb+OYcrpiElml7iVuGYt+oC7atCA==} + '@lexical/history@0.21.0': + resolution: {integrity: sha512-Sv2sici2NnAfHYHYRSjjS139MDT8fHP6PlYM2hVr+17dOg7/fJl22VBLRgQ7/+jLtAPxQjID69jvaMlOvt4Oog==} - '@lexical/html@0.16.0': - resolution: {integrity: sha512-okxn3q/1qkUpCZNEFRI39XeJj4YRjb6prm3WqZgP4d39DI1W24feeTZJjYRCW+dc3NInwFaolU3pNA2MGkjRtg==} + '@lexical/html@0.21.0': + resolution: {integrity: sha512-UGahVsGz8OD7Ya39qwquE+JPStTxCw/uaQrnUNorCM7owtPidO2H+tsilAB3A1GK3ksFGdHeEjBjG0Gf7gOg+Q==} - '@lexical/link@0.16.0': - resolution: {integrity: sha512-ppvJSh/XGqlzbeymOiwcXJcUcrqgQqTK2QXTBAZq7JThtb0WsJxYd2CSLSN+Ycu23prnwqOqILcU0+34+gAVFw==} + '@lexical/link@0.21.0': + resolution: {integrity: sha512-/coktIyRXg8rXz/7uxXsSEfSQYxPIx8CmignAXWYhcyYtCWA0fD2mhEhWwVvHH9ofNzvidclRPYKUnrmUm3z3Q==} - '@lexical/list@0.16.0': - resolution: {integrity: sha512-nBx/DMM7nCgnOzo1JyNnVaIrk/Xi5wIPNi8jixrEV6w9Om2K6dHutn/79Xzp2dQlNGSLHEDjky6N2RyFgmXh0g==} + '@lexical/list@0.21.0': + resolution: {integrity: sha512-WItGlwwNJCS8b6SO1QPKzArShmD+OXQkLbhBcAh+EfpnkvmCW5T5LqY+OfIRmEN1dhDOnwqCY7mXkivWO8o5tw==} - '@lexical/mark@0.16.0': - resolution: {integrity: sha512-WMR4nqygSgIQ6Vdr5WAzohxBGjH+m44dBNTbWTGZGVlRvPzvBT6tieCoxFqpceIq/ko67HGTCNoFj2cMKVwgIA==} + '@lexical/mark@0.21.0': + resolution: {integrity: sha512-2x/LoHDYPOkZbKHz4qLFWsPywjRv9KggTOtmRazmaNRUG0FpkImJwUbbaKjWQXeESVGpzfL3qNFSAmCWthsc4g==} - '@lexical/markdown@0.16.0': - resolution: {integrity: sha512-7HQLFrBbpY68mcq4A6C1qIGmjgA+fAByditi2WRe7tD2eoIKb/B5baQAnDKis0J+m5kTaCBmdlT6csSzyOPzeQ==} + '@lexical/markdown@0.21.0': + resolution: {integrity: sha512-XCQCyW5ujK0xR6evV8sF0hv/MRUA//kIrB2JiyF12tLQyjLRNEXO+0IKastWnMKSaDdJMKjzgd+4PiummYs7uA==} - '@lexical/offset@0.16.0': - resolution: {integrity: sha512-4TqPEC2qA7sgO8Tm65nOWnhJ8dkl22oeuGv9sUB+nhaiRZnw3R45mDelg23r56CWE8itZnvueE7TKvV+F3OXtQ==} + '@lexical/offset@0.21.0': + resolution: {integrity: sha512-UR0wHg+XXbq++6aeUPdU0K41xhUDBYzX+AeiqU9bZ7yoOq4grvKD8KBr5tARCSYTy0yvQnL1ddSO12TrP/98Lg==} - '@lexical/overflow@0.16.0': - resolution: {integrity: sha512-a7gtIRxleEuMN9dj2yO4CdezBBfIr9Mq+m7G5z62+xy7VL7cfMfF+xWjy3EmDYDXS4vOQgAXAUgO4oKz2AKGhQ==} + '@lexical/overflow@0.21.0': + resolution: {integrity: sha512-93P+d1mbvaJvZF8KK2pG22GuS2pHLtyC7N3GBfkbyAIb7TL/rYs47iR+eADJ4iNY680lylJ4Sl/AEnWvlY7hAg==} - '@lexical/plain-text@0.16.0': - resolution: {integrity: sha512-BK7/GSOZUHRJTbNPkpb9a/xN9z+FBCdunTsZhnOY8pQ7IKws3kuMO2Tk1zXfTd882ZNAxFdDKNdLYDSeufrKpw==} + '@lexical/plain-text@0.21.0': + resolution: {integrity: sha512-r4CsAknBD7qGYSE5fPdjpJ6EjfvzHbDtuCeKciL9muiswQhw4HeJrT1qb/QUIY+072uvXTgCgmjUmkbYnxKyPA==} - '@lexical/react@0.16.0': - resolution: {integrity: sha512-WKFQbI0/m1YkLjL5t90YLJwjGcl5QRe6mkfm3ljQuL7Ioj3F92ZN/J2gHFVJ9iC8/lJs6Zzw6oFjiP8hQxJf9Q==} + '@lexical/react@0.21.0': + resolution: {integrity: sha512-tKwx8EoNkBBKOZf8c10QfyDImH87+XUI1QDL8KXt+Lb8E4ho7g1jAjoEirNEn9gMBj33K4l2qVdbe3XmPAdpMQ==} peerDependencies: react: 18.3.0-canary-14898b6a9-20240318 react-dom: 18.3.0-canary-14898b6a9-20240318 - '@lexical/rich-text@0.16.0': - resolution: {integrity: sha512-AGTD6yJZ+kj2TNah1r7/6vyufs6fZANeSvv9x5eG+WjV4uyUJYkd1qR8C5gFZHdkyr+bhAcsAXvS039VzAxRrQ==} + '@lexical/rich-text@0.21.0': + resolution: {integrity: sha512-+pvEKUneEkGfWOSTl9jU58N9knePilMLxxOtppCAcgnaCdilOh3n5YyRppXhvmprUe0JaTseCMoik2LP51G/JA==} - '@lexical/selection@0.16.0': - resolution: {integrity: sha512-trT9gQVJ2j6AwAe7tHJ30SRuxCpV6yR9LFtggxphHsXSvJYnoHC0CXh1TF2jHl8Gd5OsdWseexGLBE4Y0V3gwQ==} + '@lexical/selection@0.21.0': + resolution: {integrity: sha512-4u53bc8zlPPF0rnHjsGQExQ1St8NafsDd70/t1FMw7yvoMtUsKdH7+ap00esLkJOMv45unJD7UOzKRqU1X0sEA==} - '@lexical/table@0.16.0': - resolution: {integrity: sha512-A66K779kxdr0yH2RwT2itsMnkzyFLFNPXyiWGLobCH8ON4QPuBouZvjbRHBe8Pe64yJ0c1bRDxSbTqUi9Wt3Gg==} + '@lexical/table@0.21.0': + resolution: {integrity: sha512-JhylAWcf4qKD4FmxMUt3YzH5zg2+baBr4+/haLZL7178hMvUzJwGIiWk+3hD3phzmW3WrP49uFXzM7DMSCkE8w==} - '@lexical/text@0.16.0': - resolution: {integrity: sha512-9ilaOhuNIIGHKC8g8j3K/mEvJ09af9B6RKbm3GNoRcf/WNHD4dEFWNTEvgo/3zCzAS8EUBI6UINmfQQWlMjdIQ==} + '@lexical/text@0.21.0': + resolution: {integrity: sha512-ceB4fhYejCoR8ID4uIs0sO/VyQoayRjrRWTIEMvOcQtwUkcyciKRhY0A7f2wVeq/MFStd+ajLLjy4WKYK5zUnA==} - '@lexical/utils@0.16.0': - resolution: {integrity: sha512-GWmFEmd7o3GHqJBaEwzuZQbfTNI3Gg8ReGuHMHABgrkhZ8j2NggoRBlxsQLG0f7BewfTMVwbye22yBPq78775w==} + '@lexical/utils@0.21.0': + resolution: {integrity: sha512-YzsNOAiLkCy6R3DuP18gtseDrzgx+30lFyqRvp5M7mckeYgQElwdfG5biNFDLv7BM9GjSzgU5Cunjycsx6Sjqg==} - '@lexical/yjs@0.16.0': - resolution: {integrity: sha512-YIJr87DfAXTwoVHDjR7cci//hr4r/a61Nn95eo2JNwbTqQo65Gp8rwJivqVxNfvKZmRdwHTKgvdEDoBmI/tGog==} + '@lexical/yjs@0.21.0': + resolution: {integrity: sha512-AtPhC3pJ92CHz3dWoniSky7+MSK2WSd0xijc76I2qbTeXyeuFfYyhR6gWMg4knuY9Wz3vo9/+dXGdbQIPD8efw==} peerDependencies: yjs: '>=13.5.22' @@ -6563,8 +6563,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lexical@0.16.0: - resolution: {integrity: sha512-Skn45Qhriazq4fpAtwnAB11U//GKc4vjzx54xsV3TkDLDvWpbL4Z9TNRwRoN3g7w8AkWnqjeOSODKkrjgfRSrg==} + lexical@0.21.0: + resolution: {integrity: sha512-Dxc5SCG4kB+wF+Rh55ism3SuecOKeOtCtGHFGKd6pj2QKVojtjkxGTQPMt7//2z5rMSue4R+hmRM0pCEZflupA==} lib0@0.2.73: resolution: {integrity: sha512-aJJIElCLWnHMcYZPtsM07QoSfHwpxCy4VUzBYGXFYEmh/h2QS5uZNbCCfL0CqnkOE30b7Tp9DVfjXag+3qzZjQ==} @@ -9919,153 +9919,155 @@ snapshots: '@jspm/core@2.0.1': {} - '@lexical/clipboard@0.16.0': + '@lexical/clipboard@0.21.0': dependencies: - '@lexical/html': 0.16.0 - '@lexical/list': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/html': 0.21.0 + '@lexical/list': 0.21.0 + '@lexical/selection': 0.21.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/code@0.16.0': + '@lexical/code@0.21.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 prismjs: 1.29.0 - '@lexical/devtools-core@0.16.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)': + '@lexical/devtools-core@0.21.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)': dependencies: - '@lexical/html': 0.16.0 - '@lexical/link': 0.16.0 - '@lexical/mark': 0.16.0 - '@lexical/table': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/html': 0.21.0 + '@lexical/link': 0.21.0 + '@lexical/mark': 0.21.0 + '@lexical/table': 0.21.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 react: 18.3.0-canary-14898b6a9-20240318 react-dom: 18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318) - '@lexical/dragon@0.16.0': + '@lexical/dragon@0.21.0': dependencies: - lexical: 0.16.0 + lexical: 0.21.0 - '@lexical/hashtag@0.16.0': + '@lexical/hashtag@0.21.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/headless@0.16.0': + '@lexical/headless@0.21.0': dependencies: - lexical: 0.16.0 + lexical: 0.21.0 - '@lexical/history@0.16.0': + '@lexical/history@0.21.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/html@0.16.0': + '@lexical/html@0.21.0': dependencies: - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/selection': 0.21.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/link@0.16.0': + '@lexical/link@0.21.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/list@0.16.0': + '@lexical/list@0.21.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/mark@0.16.0': + '@lexical/mark@0.21.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/markdown@0.16.0': + '@lexical/markdown@0.21.0': dependencies: - '@lexical/code': 0.16.0 - '@lexical/link': 0.16.0 - '@lexical/list': 0.16.0 - '@lexical/rich-text': 0.16.0 - '@lexical/text': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/code': 0.21.0 + '@lexical/link': 0.21.0 + '@lexical/list': 0.21.0 + '@lexical/rich-text': 0.21.0 + '@lexical/text': 0.21.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/offset@0.16.0': + '@lexical/offset@0.21.0': dependencies: - lexical: 0.16.0 + lexical: 0.21.0 - '@lexical/overflow@0.16.0': + '@lexical/overflow@0.21.0': dependencies: - lexical: 0.16.0 + lexical: 0.21.0 - '@lexical/plain-text@0.16.0': + '@lexical/plain-text@0.21.0': dependencies: - '@lexical/clipboard': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/clipboard': 0.21.0 + '@lexical/selection': 0.21.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/react@0.16.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)(yjs@13.5.52)': + '@lexical/react@0.21.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)(yjs@13.5.52)': dependencies: - '@lexical/clipboard': 0.16.0 - '@lexical/code': 0.16.0 - '@lexical/devtools-core': 0.16.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) - '@lexical/dragon': 0.16.0 - '@lexical/hashtag': 0.16.0 - '@lexical/history': 0.16.0 - '@lexical/link': 0.16.0 - '@lexical/list': 0.16.0 - '@lexical/mark': 0.16.0 - '@lexical/markdown': 0.16.0 - '@lexical/overflow': 0.16.0 - '@lexical/plain-text': 0.16.0 - '@lexical/rich-text': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/table': 0.16.0 - '@lexical/text': 0.16.0 - '@lexical/utils': 0.16.0 - '@lexical/yjs': 0.16.0(yjs@13.5.52) - lexical: 0.16.0 + '@lexical/clipboard': 0.21.0 + '@lexical/code': 0.21.0 + '@lexical/devtools-core': 0.21.0(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) + '@lexical/dragon': 0.21.0 + '@lexical/hashtag': 0.21.0 + '@lexical/history': 0.21.0 + '@lexical/link': 0.21.0 + '@lexical/list': 0.21.0 + '@lexical/mark': 0.21.0 + '@lexical/markdown': 0.21.0 + '@lexical/overflow': 0.21.0 + '@lexical/plain-text': 0.21.0 + '@lexical/rich-text': 0.21.0 + '@lexical/selection': 0.21.0 + '@lexical/table': 0.21.0 + '@lexical/text': 0.21.0 + '@lexical/utils': 0.21.0 + '@lexical/yjs': 0.21.0(yjs@13.5.52) + lexical: 0.21.0 react: 18.3.0-canary-14898b6a9-20240318 react-dom: 18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318) react-error-boundary: 3.1.4(react@18.3.0-canary-14898b6a9-20240318) transitivePeerDependencies: - yjs - '@lexical/rich-text@0.16.0': + '@lexical/rich-text@0.21.0': dependencies: - '@lexical/clipboard': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/clipboard': 0.21.0 + '@lexical/selection': 0.21.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/selection@0.16.0': + '@lexical/selection@0.21.0': dependencies: - lexical: 0.16.0 + lexical: 0.21.0 - '@lexical/table@0.16.0': + '@lexical/table@0.21.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/clipboard': 0.21.0 + '@lexical/utils': 0.21.0 + lexical: 0.21.0 - '@lexical/text@0.16.0': + '@lexical/text@0.21.0': dependencies: - lexical: 0.16.0 + lexical: 0.21.0 - '@lexical/utils@0.16.0': + '@lexical/utils@0.21.0': dependencies: - '@lexical/list': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/table': 0.16.0 - lexical: 0.16.0 + '@lexical/list': 0.21.0 + '@lexical/selection': 0.21.0 + '@lexical/table': 0.21.0 + lexical: 0.21.0 - '@lexical/yjs@0.16.0(yjs@13.5.52)': + '@lexical/yjs@0.21.0(yjs@13.5.52)': dependencies: - '@lexical/offset': 0.16.0 - lexical: 0.16.0 + '@lexical/offset': 0.21.0 + '@lexical/selection': 0.21.0 + lexical: 0.21.0 yjs: 13.5.52 '@lezer/common@1.2.3': {} @@ -13672,7 +13674,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lexical@0.16.0: {} + lexical@0.21.0: {} lib0@0.2.73: dependencies: @@ -15047,7 +15049,7 @@ snapshots: react-error-boundary@3.1.4(react@18.3.0-canary-14898b6a9-20240318): dependencies: - '@babel/runtime': 7.22.3 + '@babel/runtime': 7.25.0 react: 18.3.0-canary-14898b6a9-20240318 react-error-boundary@4.1.2(react@18.3.0-canary-14898b6a9-20240318): From dddd1a0ebc95fbda9bf3d5898e4364122d71507d Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 08:37:50 +0000 Subject: [PATCH 02/12] Add menu --- .../block-editor-context-menu.tsx | 197 ++++++++-- .../outline/block-instance-outline.tsx | 358 ++++++------------ .../canvas-tools/outline/block-utils.ts | 108 ++++++ apps/builder/app/builder/shared/commands.ts | 3 +- .../features/text-editor/text-editor.tsx | 123 ++++-- apps/builder/app/shared/nano-states/canvas.ts | 85 ++++- .../app/shared/nano-states/instances.ts | 18 + apps/builder/app/shared/sync/sync-stores.ts | 5 + .../design-system/src/components/menu.tsx | 7 +- 9 files changed, 613 insertions(+), 291 deletions(-) create mode 100644 apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts 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 305d7b371e8e..a1284d628461 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 @@ -2,12 +2,22 @@ import { useStore } from "@nanostores/react"; import { styled } from "@webstudio-is/design-system"; import { + $instances, + $modifierKeys, $textEditingInstanceSelector, $textEditorContextMenu, + $textEditorContextMenuCommand, + findTemplates, } from "~/shared/nano-states"; import { applyScale } from "./outline"; import { $scale } from "~/builder/shared/nano-states"; import { TemplatesMenu } from "./outline/block-instance-outline"; +import { insertTemplateAt } from "./outline/block-utils"; +import { useEffect, useState } from "react"; +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"; const TriggerButton = styled("button", { position: "absolute", @@ -20,11 +30,159 @@ const TriggerButton = styled("button", { padding: 0, }); +const InertController = ({ + // value, + onChange, +}: { + // value: boolean; + onChange: (inert: boolean) => void; +}) => { + const handleChange = useEffectEvent(onChange); + + useEffect(() => { + const timeout = setTimeout(() => { + handleChange(false); + }, 0); + + return () => { + clearTimeout(timeout); + }; + }, [handleChange]); + + return null; +}; + +const mod = (n: number, m: number): number => { + return ((n % m) + m) % m; +}; + +const triggerTooltipContent = <>"Templates"; + +const Menu = ({ + cursorRect, + anchor, + templates, +}: { + cursorRect: DOMRect; + anchor: InstanceSelector; + templates: [instance: Instance, instanceSelector: InstanceSelector][]; +}) => { + const [inert, setInert] = useState(true); + const modifierKeys = useStore($modifierKeys); + const scale = useStore($scale); + // const textEditorContextMenuCommand = useStore($textEditorContextMenuCommand); + const rect = applyScale(cursorRect, scale); + + const [filtered, setFiltered] = useState({ repeat: 0, templates }); + const [value, setValue] = useState( + templates[0]?.[1] ?? undefined + ); + + const [intermediateValue, setIntermediateValue] = useState< + InstanceSelector | undefined + >(); + + const currentValue = intermediateValue ?? value; + + useEffect(() => { + return $textEditorContextMenuCommand.listen((command) => { + if (command === undefined) { + return; + } + const type = command.type; + + switch (type) { + case "filter": + { + const filter = command.value.toLowerCase(); + const filteredTemplates = templates.filter(([template]) => { + const title = template.label ?? template.component; + return title.toLowerCase().includes(filter); + }); + + setFiltered((prev) => { + if (filteredTemplates.length === 0) { + return { repeat: prev.repeat + 1, templates: [] }; + } + + return { repeat: 0, templates: filteredTemplates }; + }); + + setValue(filteredTemplates[0]?.[1] ?? undefined); + } + + break; + case "selectNext": + { + const index = filtered.templates.findIndex(([_, selector]) => + shallowEqual(selector, currentValue) + ); + const nextIndex = mod(index + 1, filtered.templates.length); + setValue(filtered.templates[nextIndex]?.[1] ?? undefined); + setIntermediateValue(undefined); + } + break; + case "selectPrevious": + { + const index = filtered.templates.findIndex(([_, selector]) => + shallowEqual(selector, currentValue) + ); + const prevIndex = mod(index - 1, filtered.templates.length); + setValue(filtered.templates[prevIndex]?.[1] ?? undefined); + setIntermediateValue(undefined); + } + + break; + + default: + (type) satisfies never; + } + }); + }, [filtered.templates, templates, currentValue]); + + // + + return ( + <> + { + if (open) { + return; + } + $textEditorContextMenu.set(undefined); + }} + anchor={anchor} + triggerTooltipContent={triggerTooltipContent} + templates={filtered.templates} + value={currentValue} + onValueChangeComplete={(templateSelector) => { + const insertBefore = modifierKeys.altKey; + insertTemplateAt(templateSelector, anchor, insertBefore); + }} + onValueChange={setIntermediateValue} + modal={false} + inert={inert} + preventFocusOnHover={true} + > + + + + + ); +}; + export const TextEditorContextMenu = () => { const textEditingInstanceSelector = useStore($textEditingInstanceSelector); const textEditorContextMenu = useStore($textEditorContextMenu); - const scale = useStore($scale); - // const clampingRect = useStore($clampingRect); + const instances = useStore($instances); if (textEditorContextMenu === undefined) { return; @@ -33,29 +191,22 @@ export const TextEditorContextMenu = () => { if (textEditingInstanceSelector === undefined) { return; } - const rect = applyScale(textEditorContextMenu.cursorRect, scale); + + const templates = findTemplates( + textEditingInstanceSelector.selector, + instances + ); + + if (templates === undefined) { + return; + } return ( - { - console.log("open", open); - if (open) { - return; - } - $textEditorContextMenu.set(undefined); - }} + "Templates"} - > - - + templates={templates} + /> ); }; diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx index 036ce2e953d0..2bdd0edffe0e 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx @@ -7,6 +7,8 @@ import { $isContentMode, $modifierKeys, $registeredComponentMetas, + findBlockSelector, + findTemplates, type BlockChildOutline, } from "~/shared/nano-states"; import { @@ -32,146 +34,21 @@ import { applyScale } from "./apply-scale"; import { $clampingRect, $scale } from "~/builder/shared/nano-states"; import { PlusIcon, TrashIcon } from "@webstudio-is/icons"; import { BoxIcon } from "@webstudio-is/icons/svg"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { isFeatureEnabled } from "@webstudio-is/feature-flags"; -import type { DroppableTarget, InstanceSelector } from "~/shared/tree-utils"; -import type { Instance, Instances } from "@webstudio-is/sdk"; -import { - blockComponent, - blockTemplateComponent, -} from "@webstudio-is/react-sdk"; +import type { InstanceSelector } from "~/shared/tree-utils"; +import type { Instance } from "@webstudio-is/sdk"; + import { deleteInstanceMutable, - extractWebstudioFragment, - findAvailableDataSources, - getWebstudioData, - insertInstanceChildrenMutable, - insertWebstudioFragmentCopy, updateWebstudioData, } from "~/shared/instance-utils"; -import { shallowEqual } from "shallow-equal"; + import { MetaIcon } from "~/builder/shared/meta-icon"; import { skipInertHandlersAttribute } from "~/builder/shared/inert-handlers"; -import { selectInstance } from "~/shared/awareness"; - -export const findBlockSelector = ( - anchor: InstanceSelector, - instances: Instances -) => { - if (anchor === undefined) { - return; - } - - if (anchor.length === 0) { - return; - } - - let blockInstanceSelector: InstanceSelector | undefined = undefined; - - for (let i = 0; i < anchor.length; ++i) { - const instanceId = anchor[i]; - - const instance = instances.get(instanceId); - if (instance === undefined) { - return; - } - - if (instance.component === blockComponent) { - blockInstanceSelector = anchor.slice(i); - break; - } - } - - if (blockInstanceSelector === undefined) { - return; - } - - return blockInstanceSelector; -}; - -const findTemplates = (anchor: InstanceSelector, instances: Instances) => { - const blockInstanceSelector = findBlockSelector(anchor, instances); - if (blockInstanceSelector === undefined) { - return; - } - - const blockInstance = instances.get(blockInstanceSelector[0]); - - if (blockInstance === undefined) { - return; - } - - const templateInstanceId = blockInstance.children.find( - (child) => - child.type === "id" && - instances.get(child.value)?.component === blockTemplateComponent - )?.value; - - if (templateInstanceId === undefined) { - return; - } - - const templateInstance = instances.get(templateInstanceId); - - if (templateInstance === undefined) { - return; - } - - const result: [instance: Instance, instanceSelector: InstanceSelector][] = - templateInstance.children - .filter((child) => child.type === "id") - .map((child) => child.value) - .map((childId) => instances.get(childId)) - .filter((child) => child !== undefined) - .map((child) => [ - child, - [child.id, templateInstanceId, ...blockInstanceSelector], - ]); - - return result; -}; - -const getInsertionIndex = ( - anchor: InstanceSelector, - instances: Instances, - insertBefore: boolean = false -) => { - const blockSelector = findBlockSelector(anchor, instances); - if (blockSelector === undefined) { - return; - } - - const insertAtInitialPosition = shallowEqual(blockSelector, anchor); - - const blockInstance = instances.get(blockSelector[0]); - if (blockInstance === undefined) { - return; - } - - const index = blockInstance.children.findIndex((child) => { - if (child.type !== "id") { - return false; - } - - if (insertAtInitialPosition) { - return instances.get(child.value)?.component === blockTemplateComponent; - } - - return child.value === anchor[0]; - }); - - if (index === -1) { - return; - } - - // Independent of insertBefore, we always insert after the Templates instance - if (insertAtInitialPosition) { - return index + 1; - } - - return insertBefore ? index : index + 1; -}; +import { insertTemplateAt } from "./block-utils"; +import { useEffectEvent } from "~/shared/hook-utils/effect-event"; export const TemplatesMenu = ({ onOpenChange, @@ -179,19 +56,43 @@ export const TemplatesMenu = ({ children, anchor, triggerTooltipContent, + templates, + value, + onValueChangeComplete, + onValueChange, + modal, + inert, + preventFocusOnHover, }: { children: React.ReactNode; open: boolean; onOpenChange: (open: boolean) => void; anchor: InstanceSelector; triggerTooltipContent: JSX.Element; + templates: [instance: Instance, instanceSelector: InstanceSelector][]; + value: InstanceSelector | undefined; + onValueChangeComplete: (value: InstanceSelector) => void; + onValueChange?: undefined | ((value: InstanceSelector | undefined) => void); + modal: boolean; + inert: boolean; + preventFocusOnHover: boolean; }) => { const instances = useStore($instances); const metas = useStore($registeredComponentMetas); const modifierKeys = useStore($modifierKeys); const blockInstanceSelector = findBlockSelector(anchor, instances); - useEffect(() => {}, []); + + const handleValueChangeComplete = useEffectEvent((value: string) => { + const templateSelector = JSON.parse(value) as InstanceSelector; + onValueChangeComplete(templateSelector); + }); + + const handleValueChange = useEffectEvent( + (value: InstanceSelector | undefined) => { + onValueChange?.(value); + } + ); if (blockInstanceSelector === undefined) { return; @@ -206,8 +107,6 @@ export const TemplatesMenu = ({ // 1 child is Templates instance const hasChildren = blockInstance.children.length > 1; - const templates = findTemplates(anchor, instances); - const menuItems = templates?.map(([template, templateSelector]) => ({ id: template.id, icon: , @@ -216,7 +115,7 @@ export const TemplatesMenu = ({ })); return ( - + - { - const insertBefore = modifierKeys.altKey; - - const templateSelector = JSON.parse(value) as InstanceSelector; - const fragment = extractWebstudioFragment( - getWebstudioData(), - templateSelector[0] - ); - - const parentSelector = findBlockSelector(anchor, instances); - - if (parentSelector === undefined) { - return; - } - - const position = getInsertionIndex( - anchor, - instances, - insertBefore - ); - - if (position === undefined) { - return; - } - - const target: DroppableTarget = { - parentSelector, - position, - }; - - 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); - - selectInstance([newRootInstanceId, ...target.parentSelector]); - }); - }} - > - {menuItems?.map(({ icon, title, id, value }) => ( - 0 ? ( + <> + - - {icon} - {title} - - - ))} - - - -
- - - - to add before - - - - - to add after - - - to add before - - -
+ {menuItems?.map(({ icon, title, id, value }) => ( + { + handleValueChange(value); + }} + onPointerMove={ + preventFocusOnHover + ? (e) => { + e.preventDefault(); + } + : undefined + } + onPointerLeave={ + preventFocusOnHover + ? (e) => { + handleValueChange(undefined); + e.preventDefault(); + } + : undefined + } + key={id} + value={JSON.stringify(value)} + {...{ [skipInertHandlersAttribute]: true }} + > + + {icon} + {title} + + + ))} + + +
+ + + + to add before + + + + + to add after + + + {" "} + to add before + + +
+ + ) : ( +
+ + No Results + +
+ )}
@@ -476,6 +357,19 @@ export const BlockChildHoveredInstanceOutline = () => { }} anchor={outline.selector} triggerTooltipContent={tooltipContent} + templates={templates} + onValueChangeComplete={(templateSelector) => { + const insertBefore = modifierKeys.altKey; + insertTemplateAt( + templateSelector, + outline.selector, + insertBefore + ); + }} + value={undefined} + modal={true} + inert={false} + preventFocusOnHover={false} > { + const blockSelector = findBlockSelector(anchor, instances); + if (blockSelector === undefined) { + return; + } + + const insertAtInitialPosition = shallowEqual(blockSelector, anchor); + + const blockInstance = instances.get(blockSelector[0]); + + if (blockInstance === undefined) { + return; + } + + const index = blockInstance.children.findIndex((child) => { + if (child.type !== "id") { + return false; + } + + if (insertAtInitialPosition) { + return instances.get(child.value)?.component === blockTemplateComponent; + } + + return child.value === anchor[0]; + }); + + if (index === -1) { + return; + } + + // Independent of insertBefore, we always insert after the Templates instance + if (insertAtInitialPosition) { + return index + 1; + } + + return insertBefore ? index : index + 1; +}; + +export const insertTemplateAt = ( + templateSelector: InstanceSelector, + anchor: InstanceSelector, + insertBefore: boolean +) => { + const instances = $instances.get(); + + const fragment = extractWebstudioFragment( + getWebstudioData(), + templateSelector[0] + ); + + const parentSelector = findBlockSelector(anchor, instances); + + if (parentSelector === undefined) { + return; + } + + const position = getInsertionIndex(anchor, instances, insertBefore); + + if (position === undefined) { + return; + } + + const target: DroppableTarget = { + parentSelector, + position, + }; + + 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); + + selectInstance([newRootInstanceId, ...target.parentSelector]); + }); +}; diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 747bc30a6d7a..85521775bdb5 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -12,6 +12,7 @@ import { $isPreviewMode, $isContentMode, $registeredComponentMetas, + findBlockSelector, } from "~/shared/nano-states"; import { $breakpointsMenuView, @@ -37,7 +38,7 @@ import { import { $selectedInstancePath, selectInstance } from "~/shared/awareness"; import { openCommandPanel } from "../features/command-panel"; import { builderApi } from "~/shared/builder-api"; -import { findBlockSelector } from "../features/workspace/canvas-tools/outline/block-instance-outline"; + import { findClosestNonTextualContainer, isTreeMatching, 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 13aeaf0ff9ef..5c640c05b32d 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -70,6 +70,7 @@ import { $selectedInstanceSelector, $textEditingInstanceSelector, $textEditorContextMenu, + execTextEditorContextMenuCommand, } from "~/shared/nano-states"; import { getElementByInstanceSelector, @@ -1003,68 +1004,128 @@ const ContextMenuPlugin = ({ onOpen }: ContextMenuPluginProps) => { } if (menuState === "opened") { - if (event.key === "Escape" || event.key === " ") { + if (event.key === "Escape") { closeMenu(); event.preventDefault(); return true; } + if (event.key === " ") { + closeMenu(); + } + + if (event.key === "/") { + closeMenu(); + } + + if (event.key === "Enter") { + // @todo ArrowUp in menu + event.preventDefault(); + return true; + } + if (event.key === "ArrowUp") { - // @todo + execTextEditorContextMenuCommand({ + type: "selectPrevious", + }); + event.preventDefault(); return true; } if (event.key === "ArrowDown") { - // @todo + execTextEditorContextMenuCommand({ + type: "selectNext", + }); + event.preventDefault(); return true; } } - if (event.key !== "/") { - return false; - } + if (menuState === "closed") { + if (event.key !== "/") { + return false; + } - const slashNode = $createTextNode("/"); - slashNodeKey = slashNode.getKey(); - menuState = "opening"; + const slashNode = $createTextNode("/"); + slashNodeKey = slashNode.getKey(); + menuState = "opening"; - slashNode.setStyle("background-color: rgba(127, 127, 127, 0.2);"); - selection.setStyle("background-color: rgba(127, 127, 127, 0.2);"); - selection.insertNodes([slashNode]); + slashNode.setStyle("background-color: rgba(127, 127, 127, 0.2);"); + selection.setStyle("background-color: rgba(127, 127, 127, 0.2);"); + selection.insertNodes([slashNode]); - event.preventDefault(); + event.preventDefault(); + return true; + } - return true; + return false; }, COMMAND_PRIORITY_EDITOR ); + const closeMenuWithUpdate = () => { + editor.update(() => { + closeMenu(); + }); + }; + const unsubscribeUpdateListener = editor.registerUpdateListener( ({ editorState }) => { - if (menuState !== "opening") { - return; + if (menuState === "opened") { + editorState.read(() => { + if (slashNodeKey === undefined) { + closeMenu(); + return; + } + const node = $getNodeByKey(slashNodeKey); + + if (node === null) { + closeMenuWithUpdate(); + return; + } + const content = node.getTextContent(); + + const filter = content.slice(1); + + execTextEditorContextMenuCommand({ + type: "filter", + value: filter, + }); + }); } - editorState.read(() => { - if (slashNodeKey === undefined) { - // handleOpen(editor.getEditorState(), undefined); - return; - } - const slashNode = editor.getElementByKey(slashNodeKey); + if (menuState === "opening") { + editorState.read(() => { + if (slashNodeKey === undefined) { + closeMenu(); + return; + } - if (slashNode === null) { - return; - } + const slashNode = editor.getElementByKey(slashNodeKey); + + if (slashNode === null) { + closeMenu(); + return; + } + + const rect = slashNode.getBoundingClientRect(); - const rect = slashNode.getBoundingClientRect(); + menuState = "opened"; - menuState = "opened"; - handleOpen(editor.getEditorState(), { - cursorRect: rect, + handleOpen(editor.getEditorState(), { + cursorRect: rect, + }); }); - }); + } + } + ); + + const unsubscribeBlurListener = editor.registerRootListener( + (rootElement, prevRootElement) => { + rootElement?.addEventListener("blur", closeMenuWithUpdate); + prevRootElement?.removeEventListener("blur", closeMenuWithUpdate); } ); @@ -1072,6 +1133,7 @@ const ContextMenuPlugin = ({ onOpen }: ContextMenuPluginProps) => { unsubscibeKeyDown(); unsubscribeUpdateListener(); unsubscibeSelectionChange(); + unsubscribeBlurListener(); }; }, [editor, handleOpen]); @@ -1361,7 +1423,6 @@ export const TextEditor = ({ const handleContextMenuOpen = useCallback( (_editorState: EditorState, params: undefined | ContextMenuParams) => { - console.log("CLOSE"); $textEditorContextMenu.set(params); }, [] diff --git a/apps/builder/app/shared/nano-states/canvas.ts b/apps/builder/app/shared/nano-states/canvas.ts index 17ba296916f7..bdc94007eb33 100644 --- a/apps/builder/app/shared/nano-states/canvas.ts +++ b/apps/builder/app/shared/nano-states/canvas.ts @@ -3,7 +3,10 @@ import type { Instance, Instances } from "@webstudio-is/sdk"; import type { FontWeight } from "@webstudio-is/fonts"; import { $instances } from "./instances"; import type { InstanceSelector } from "../tree-utils"; -import { blockComponent } from "@webstudio-is/react-sdk"; +import { + blockComponent, + blockTemplateComponent, +} from "@webstudio-is/react-sdk"; export type TextToolbarState = { selectionRect: undefined | DOMRect; @@ -91,6 +94,86 @@ export const findBlockChildSelector = (instanceSelector: InstanceSelector) => { } }; +export const findBlockSelector = ( + anchor: InstanceSelector, + instances: Instances +) => { + if (anchor === undefined) { + return; + } + + if (anchor.length === 0) { + return; + } + + let blockInstanceSelector: InstanceSelector | undefined = undefined; + + for (let i = 0; i < anchor.length; ++i) { + const instanceId = anchor[i]; + + const instance = instances.get(instanceId); + if (instance === undefined) { + return; + } + + if (instance.component === blockComponent) { + blockInstanceSelector = anchor.slice(i); + break; + } + } + + if (blockInstanceSelector === undefined) { + return; + } + + return blockInstanceSelector; +}; + +export const findTemplates = ( + anchor: InstanceSelector, + instances: Instances +) => { + const blockInstanceSelector = findBlockSelector(anchor, instances); + if (blockInstanceSelector === undefined) { + return; + } + + const blockInstance = instances.get(blockInstanceSelector[0]); + + if (blockInstance === undefined) { + return; + } + + const templateInstanceId = blockInstance.children.find( + (child) => + child.type === "id" && + instances.get(child.value)?.component === blockTemplateComponent + )?.value; + + if (templateInstanceId === undefined) { + return; + } + + const templateInstance = instances.get(templateInstanceId); + + if (templateInstance === undefined) { + return; + } + + const result: [instance: Instance, instanceSelector: InstanceSelector][] = + templateInstance.children + .filter((child) => child.type === "id") + .map((child) => child.value) + .map((childId) => instances.get(childId)) + .filter((child) => child !== undefined) + .map((child) => [ + child, + [child.id, templateInstanceId, ...blockInstanceSelector], + ]); + + return result; +}; + export const $canvasIframeState = atom<"idle" | "ready">("idle"); export const $detectedFontsWeights = atom>>( diff --git a/apps/builder/app/shared/nano-states/instances.ts b/apps/builder/app/shared/nano-states/instances.ts index dd8af6b37da1..bf0b3f5ee9c0 100644 --- a/apps/builder/app/shared/nano-states/instances.ts +++ b/apps/builder/app/shared/nano-states/instances.ts @@ -38,4 +38,22 @@ export const $textEditorContextMenu = atom< | undefined >(undefined); +type ContextMenuCommand = + | { + type: "filter"; + value: string; + } + | { type: "selectNext" } + | { type: "selectPrevious" }; + +export const $textEditorContextMenuCommand = atom< + undefined | ContextMenuCommand +>(undefined); + +export const execTextEditorContextMenuCommand = ( + command: ContextMenuCommand +) => { + $textEditorContextMenuCommand.set(command); +}; + export const $instances = atom(new Map()); diff --git a/apps/builder/app/shared/sync/sync-stores.ts b/apps/builder/app/shared/sync/sync-stores.ts index 0531ebb05395..3dbf104e66a5 100644 --- a/apps/builder/app/shared/sync/sync-stores.ts +++ b/apps/builder/app/shared/sync/sync-stores.ts @@ -37,6 +37,7 @@ import { $selectedBreakpointId, $textEditingInstanceSelector, $textEditorContextMenu, + $textEditorContextMenuCommand, $isResizingCanvas, $collaborativeInstanceRect, $collaborativeInstanceSelector, @@ -137,6 +138,10 @@ export const createObjectPool = () => { $textEditingInstanceSelector ), new NanostoresSyncObject("textEditorContextMenu", $textEditorContextMenu), + new NanostoresSyncObject( + "textEditorContextMenuCommand", + $textEditorContextMenuCommand + ), new NanostoresSyncObject("isResizingCanvas", $isResizingCanvas), new NanostoresSyncObject("textToolbar", $textToolbar), new NanostoresSyncObject( diff --git a/packages/design-system/src/components/menu.tsx b/packages/design-system/src/components/menu.tsx index 9c15b6f56cb7..63fa5844b00e 100644 --- a/packages/design-system/src/components/menu.tsx +++ b/packages/design-system/src/components/menu.tsx @@ -59,9 +59,10 @@ export const menuItemCss = css({ borderRadius: theme.borderRadius[3], // override button default styles backgroundColor: "transparent", - "&:focus, &[data-found], &[aria-selected=true], &[data-state=open]": { - backgroundColor: theme.colors.backgroundItemMenuItemHover, - }, + "&:focus, &[data-found], &[aria-selected=true], &[data-state=open], &[data-state=checked]": + { + backgroundColor: theme.colors.backgroundItemMenuItemHover, + }, "&[data-disabled], &[aria-disabled], &[disabled]": { color: theme.colors.foregroundDisabled, }, From 6bb4e1d4cdfd38ead8c68206795cedf0eeb15073 Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 11:41:13 +0000 Subject: [PATCH 03/12] Add using RT menu --- .../features/navigator/navigator-tree.tsx | 1 + .../block-editor-context-menu.tsx | 30 ++++++++-- .../outline/block-instance-outline.tsx | 7 +++ .../canvas-tools/outline/block-utils.ts | 14 ++++- .../features/text-editor/text-editor.tsx | 56 ++++++++++++++++--- apps/builder/app/canvas/shared/commands.ts | 27 ++++++++- apps/builder/app/canvas/shared/styles.ts | 3 +- apps/builder/app/shared/instance-utils.ts | 12 ++-- .../app/shared/nano-states/instances.ts | 3 +- 9 files changed, 127 insertions(+), 26 deletions(-) diff --git a/apps/builder/app/builder/features/navigator/navigator-tree.tsx b/apps/builder/app/builder/features/navigator/navigator-tree.tsx index baee7d04f917..bb9b590ac39c 100644 --- a/apps/builder/app/builder/features/navigator/navigator-tree.tsx +++ b/apps/builder/app/builder/features/navigator/navigator-tree.tsx @@ -655,6 +655,7 @@ export const NavigatorTree = () => { onClick: () => selectInstance(item.selector), onFocus: () => selectInstance(item.selector), onKeyDown: (event) => { + console.info("onKeyDown", event.key); if (event.key === "Enter") { emitCommand("editInstanceText"); } 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 a1284d628461..c8d56cdad488 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 @@ -13,11 +13,12 @@ import { applyScale } from "./outline"; import { $scale } from "~/builder/shared/nano-states"; import { TemplatesMenu } from "./outline/block-instance-outline"; import { insertTemplateAt } from "./outline/block-utils"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; 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", @@ -82,6 +83,14 @@ const Menu = ({ InstanceSelector | undefined >(); + const handleValueChangeComplete = useCallback( + (templateSelector: InstanceSelector) => { + const insertBefore = modifierKeys.altKey; + insertTemplateAt(templateSelector, anchor, insertBefore); + }, + [anchor, modifierKeys.altKey] + ); + const currentValue = intermediateValue ?? value; useEffect(() => { @@ -134,13 +143,22 @@ const Menu = ({ break; + case "enter": + { + if (currentValue !== undefined) { + handleValueChangeComplete(currentValue); + } + } + + break; + default: (type) satisfies never; } }); - }, [filtered.templates, templates, currentValue]); + }, [filtered.templates, templates, currentValue, handleValueChangeComplete]); - // + // @todo repeat and close return ( <> @@ -156,9 +174,9 @@ const Menu = ({ triggerTooltipContent={triggerTooltipContent} templates={filtered.templates} value={currentValue} - onValueChangeComplete={(templateSelector) => { - const insertBefore = modifierKeys.altKey; - insertTemplateAt(templateSelector, anchor, insertBefore); + onValueChangeComplete={(value) => { + handleValueChangeComplete(value); + emitCommand("editInstanceText"); }} onValueChange={setIntermediateValue} modal={false} diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx index 2bdd0edffe0e..2138241de5b7 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx @@ -159,6 +159,13 @@ export const TemplatesMenu = ({ } : undefined } + onPointerDown={ + preventFocusOnHover + ? (event) => { + event.preventDefault(); + } + : undefined + } key={id} value={JSON.stringify(value)} {...{ [skipInertHandlersAttribute]: true }} 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 e1cb7ec92e7c..31eca5761136 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 @@ -10,7 +10,11 @@ import { insertWebstudioFragmentCopy, updateWebstudioData, } from "~/shared/instance-utils"; -import { $instances, findBlockSelector } from "~/shared/nano-states"; +import { + $instances, + findBlockChildSelector, + findBlockSelector, +} from "~/shared/nano-states"; import type { DroppableTarget, InstanceSelector } from "~/shared/tree-utils"; const getInsertionIndex = ( @@ -31,6 +35,12 @@ const getInsertionIndex = ( return; } + const childBlockSelector = findBlockChildSelector(anchor); + + if (childBlockSelector === undefined) { + return; + } + const index = blockInstance.children.findIndex((child) => { if (child.type !== "id") { return false; @@ -40,7 +50,7 @@ const getInsertionIndex = ( return instances.get(child.value)?.component === blockTemplateComponent; } - return child.value === anchor[0]; + return child.value === childBlockSelector[0]; }); if (index === -1) { 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 5c640c05b32d..57f8672d58ab 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -174,7 +174,6 @@ const OnChangeOnBlurPlugin = ({ useEffect(() => { const handleBlur = () => { - console.log("handleBlur"); handleChange(editor.getEditorState()); }; @@ -196,6 +195,17 @@ const getNodeKeyFromDOMNode = ( return (dom as Node & Record)[prop]; }; +const isDescendantOrSelf = ( + descendant: InstanceSelector | undefined, + self: InstanceSelector | undefined +) => { + if (descendant === undefined || self === undefined) { + return false; + } + + return descendant.join(",").endsWith(self.join(",")); +}; + const LinkSelectionPlugin = ({ rootInstanceSelector, registerNewLink, @@ -204,7 +214,7 @@ const LinkSelectionPlugin = ({ registerNewLink: (key: NodeKey, instanceId: string) => void; }) => { const [editor] = useLexicalComposerContext(); - const [preservedSelection] = useState($selectedInstanceSelector.get()); + const [preservedSelection] = useState(rootInstanceSelector); useEffect(() => { if (!editor.isEditable()) { @@ -214,6 +224,15 @@ const LinkSelectionPlugin = ({ const removeUpdateListener = editor.registerUpdateListener( ({ editorState }) => { editorState.read(() => { + if ( + !isDescendantOrSelf( + $selectedInstanceSelector.get(), + preservedSelection + ) + ) { + return; + } + const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; @@ -914,14 +933,19 @@ type ContextMenuParams = { }; type ContextMenuPluginProps = { + rootInstanceSelector: InstanceSelector; onOpen: ( editorState: EditorState, params: undefined | ContextMenuParams ) => void; }; -const ContextMenuPlugin = ({ onOpen }: ContextMenuPluginProps) => { +const ContextMenuPlugin = ({ + rootInstanceSelector, + onOpen, +}: ContextMenuPluginProps) => { const [editor] = useLexicalComposerContext(); + const [preservedSelection] = useState(rootInstanceSelector); const handleOpen = useEffectEvent(onOpen); @@ -948,10 +972,21 @@ const ContextMenuPlugin = ({ onOpen }: ContextMenuPluginProps) => { } const node = $getNodeByKey(slashNodeKey); + if ($isTextNode(node)) { node.setStyle(""); } + const isSelectionInSameComponent = isDescendantOrSelf( + $selectedInstanceSelector.get(), + preservedSelection + ); + if (!isSelectionInSameComponent) { + node?.remove(); + } + + // if selection changed, remove the slash node + const selection = $getSelection(); if (!$isRangeSelection(selection)) { @@ -1019,7 +1054,10 @@ const ContextMenuPlugin = ({ onOpen }: ContextMenuPluginProps) => { } if (event.key === "Enter") { - // @todo ArrowUp in menu + execTextEditorContextMenuCommand({ + type: "enter", + }); + event.preventDefault(); return true; } @@ -1135,7 +1173,7 @@ const ContextMenuPlugin = ({ onOpen }: ContextMenuPluginProps) => { unsubscibeSelectionChange(); unsubscribeBlurListener(); }; - }, [editor, handleOpen]); + }, [editor, handleOpen, preservedSelection]); return null; }; @@ -1321,8 +1359,7 @@ export const TextEditor = ({ const editableInstanceSelectors: InstanceSelector[] = []; findAllEditableInstanceSelector( - rootInstanceId, - [], + [rootInstanceId], instances, $registeredComponentMetas.get(), editableInstanceSelectors @@ -1455,7 +1492,10 @@ export const TextEditor = ({ - + { const instances = $instances.get(); findAllEditableInstanceSelector( - rootInstanceId, - [], + [rootInstanceId], instances, $registeredComponentMetas.get(), editableInstanceSelectors diff --git a/apps/builder/app/shared/instance-utils.ts b/apps/builder/app/shared/instance-utils.ts index 494189c29713..6b9e3e8b8df7 100644 --- a/apps/builder/app/shared/instance-utils.ts +++ b/apps/builder/app/shared/instance-utils.ts @@ -205,12 +205,17 @@ const isTextEditingInstance = ( }; export const findAllEditableInstanceSelector = ( - instanceId: string, currentPath: InstanceSelector, instances: Map, metas: Map, results: InstanceSelector[] ) => { + const instanceId = currentPath[0]; + + if (instanceId === undefined) { + return; + } + const instance = instances.get(instanceId); if (instance === undefined) { return; @@ -218,7 +223,7 @@ export const findAllEditableInstanceSelector = ( // Check if current instance is text editing instance if (isTextEditingInstance(instance, instances, metas)) { - results.push([instanceId, ...currentPath]); + results.push(currentPath); return; } @@ -226,8 +231,7 @@ export const findAllEditableInstanceSelector = ( for (const child of instance.children) { if (child.type === "id") { findAllEditableInstanceSelector( - child.value, - [instanceId, ...currentPath], + [child.value, ...currentPath], instances, metas, results diff --git a/apps/builder/app/shared/nano-states/instances.ts b/apps/builder/app/shared/nano-states/instances.ts index bf0b3f5ee9c0..86ce9e1454ce 100644 --- a/apps/builder/app/shared/nano-states/instances.ts +++ b/apps/builder/app/shared/nano-states/instances.ts @@ -44,7 +44,8 @@ type ContextMenuCommand = value: string; } | { type: "selectNext" } - | { type: "selectPrevious" }; + | { type: "selectPrevious" } + | { type: "enter" }; export const $textEditorContextMenuCommand = atom< undefined | ContextMenuCommand From f2cdf6f64d9e539c3f678e680cfff575d0c9f6c3 Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 13:11:59 +0000 Subject: [PATCH 04/12] working add --- .../builder/app/canvas/features/text-editor/text-editor.tsx | 6 +++++- apps/builder/app/canvas/shared/commands.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) 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 57f8672d58ab..8d2817006ce7 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -174,7 +174,10 @@ const OnChangeOnBlurPlugin = ({ useEffect(() => { const handleBlur = () => { - handleChange(editor.getEditorState()); + // force read to get the latest state + editor.read(() => { + handleChange(editor.getEditorState()); + }); }; // https://github.com/facebook/lexical/blob/867d449b2a6497ff9b1fbdbd70724c74a1044d8b/packages/lexical-react/src/LexicalNodeEventPlugin.ts#L59C12-L67C8 @@ -981,6 +984,7 @@ const ContextMenuPlugin = ({ $selectedInstanceSelector.get(), preservedSelection ); + if (!isSelectionInSameComponent) { node?.remove(); } diff --git a/apps/builder/app/canvas/shared/commands.ts b/apps/builder/app/canvas/shared/commands.ts index e694d0560563..44263eb872c5 100644 --- a/apps/builder/app/canvas/shared/commands.ts +++ b/apps/builder/app/canvas/shared/commands.ts @@ -55,6 +55,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ ); if (selectors.length === 0) { + $textEditingInstanceSelector.set(undefined); return; } From 0122cc5d9708ac2c90974a9c6f5963fb829db98f Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 13:20:18 +0000 Subject: [PATCH 05/12] Fix to work only inside block with templates --- .../features/text-editor/text-editor.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 8d2817006ce7..9c78510ca69d 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -66,11 +66,13 @@ import { $blockChildOutline, $hoveredInstanceOutline, $hoveredInstanceSelector, + $instances, $registeredComponentMetas, $selectedInstanceSelector, $textEditingInstanceSelector, $textEditorContextMenu, execTextEditorContextMenuCommand, + findTemplates, } from "~/shared/nano-states"; import { getElementByInstanceSelector, @@ -943,7 +945,27 @@ type ContextMenuPluginProps = { ) => void; }; -const ContextMenuPlugin = ({ +const ContextMenuPlugin = (props: ContextMenuPluginProps) => { + const [hasTemplates] = useState(() => { + const templates = findTemplates( + props.rootInstanceSelector, + $instances.get() + ); + if (templates === undefined) { + return false; + } + + return templates.length > 0; + }); + + if (!hasTemplates) { + return null; + } + + return ; +}; + +const ContextMenuPluginInternal = ({ rootInstanceSelector, onOpen, }: ContextMenuPluginProps) => { From c8f87e8f6190f504f44ef0195f5d70c359414a5b Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 13:26:10 +0000 Subject: [PATCH 06/12] Fix --- apps/builder/app/canvas/features/text-editor/interop.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/builder/app/canvas/features/text-editor/interop.test.ts b/apps/builder/app/canvas/features/text-editor/interop.test.ts index d1f87c377373..7ccc209b397f 100644 --- a/apps/builder/app/canvas/features/text-editor/interop.test.ts +++ b/apps/builder/app/canvas/features/text-editor/interop.test.ts @@ -149,6 +149,7 @@ test("convert instances to lexical", async () => { "format": "", "indent": 0, "textFormat": 0, + "textStyle": "", "type": "paragraph", "version": 1, }, From 601c35b84d44e6aaf81cb0f439bd528c53084705 Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 17:30:31 +0000 Subject: [PATCH 07/12] Commands --- apps/builder/app/canvas/shared/commands.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/builder/app/canvas/shared/commands.ts b/apps/builder/app/canvas/shared/commands.ts index 44263eb872c5..8b11a57a22d3 100644 --- a/apps/builder/app/canvas/shared/commands.ts +++ b/apps/builder/app/canvas/shared/commands.ts @@ -89,10 +89,6 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ const textEditingInstanceSelector = $textEditingInstanceSelector.get(); const textToolbar = $textToolbar.get(); - if (selectedInstanceSelector === undefined) { - return; - } - // close text toolbar first without exiting text editing mode if (textToolbar) { $textToolbar.set(undefined); @@ -101,11 +97,15 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ // exit text editing mode first without unselecting instance if (textEditingInstanceSelector) { - // $textEditingInstanceSelector.set(undefined); + $textEditingInstanceSelector.set(undefined); + return; + } + + if (selectedInstanceSelector) { + // unselect both instance and style source + selectInstance(undefined); return; } - // unselect both instance and style source - // selectInstance(undefined); }, }, From f416fb25c971086a7d6f6f67ad181ef43c92ebbc Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 18:48:45 +0000 Subject: [PATCH 08/12] Fix --- apps/builder/app/builder/features/navigator/navigator-tree.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/builder/app/builder/features/navigator/navigator-tree.tsx b/apps/builder/app/builder/features/navigator/navigator-tree.tsx index bb9b590ac39c..baee7d04f917 100644 --- a/apps/builder/app/builder/features/navigator/navigator-tree.tsx +++ b/apps/builder/app/builder/features/navigator/navigator-tree.tsx @@ -655,7 +655,6 @@ export const NavigatorTree = () => { onClick: () => selectInstance(item.selector), onFocus: () => selectInstance(item.selector), onKeyDown: (event) => { - console.info("onKeyDown", event.key); if (event.key === "Enter") { emitCommand("editInstanceText"); } From d2057a368efe696c26c90b8b4f4b40215a404c40 Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 19:02:51 +0000 Subject: [PATCH 09/12] Replace descendant --- .../outline/hovered-instance-outline.tsx | 11 ++----- .../outline/selected-instance-outline.tsx | 9 +---- .../features/text-editor/text-editor.tsx | 33 ++++++++----------- apps/builder/app/canvas/instance-hovering.ts | 9 +---- apps/builder/app/shared/tree-utils.test.ts | 7 ++++ apps/builder/app/shared/tree-utils.ts | 18 ++++++++++ 6 files changed, 42 insertions(+), 45 deletions(-) diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx index 34dece26756e..59f52e99fdc5 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx @@ -13,16 +13,9 @@ import { applyScale } from "./apply-scale"; import { $clampingRect, $scale } from "~/builder/shared/nano-states"; import { findClosestSlot } from "~/shared/instance-utils"; import { shallowEqual } from "shallow-equal"; -import type { InstanceSelector } from "~/shared/tree-utils"; +import { isDescendantOrSelf } from "~/shared/tree-utils"; import { isFeatureEnabled } from "@webstudio-is/feature-flags"; -const isDescendantOrSelf = ( - descendant: InstanceSelector, - self: InstanceSelector -) => { - return descendant.join(",").endsWith(self.join(",")); -}; - export const HoveredInstanceOutline = () => { const instances = useStore($instances); const hoveredInstanceSelector = useStore($hoveredInstanceSelector); @@ -51,7 +44,7 @@ export const HoveredInstanceOutline = () => { textEditingInstanceSelector?.selector && isDescendantOrSelf( hoveredInstanceSelector, - textEditingInstanceSelector?.selector + textEditingInstanceSelector.selector ) ) { return; diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/selected-instance-outline.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/outline/selected-instance-outline.tsx index 63a970041f74..5fd7424781fb 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/selected-instance-outline.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/selected-instance-outline.tsx @@ -5,20 +5,13 @@ import { $selectedInstanceSelector, } from "~/shared/nano-states"; import { $textEditingInstanceSelector } from "~/shared/nano-states"; -import { type InstanceSelector } from "~/shared/tree-utils"; +import { isDescendantOrSelf } from "~/shared/tree-utils"; import { Outline } from "./outline"; import { applyScale } from "./apply-scale"; import { $clampingRect, $scale } from "~/builder/shared/nano-states"; import { findClosestSlot } from "~/shared/instance-utils"; import { $ephemeralStyles } from "~/canvas/stores"; -const isDescendantOrSelf = ( - descendant: InstanceSelector, - self: InstanceSelector -) => { - return descendant.join(",").endsWith(self.join(",")); -}; - export const SelectedInstanceOutline = () => { const instances = useStore($instances); const selectedInstanceSelector = useStore($selectedInstanceSelector); 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 9c78510ca69d..20d5859dbaf0 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -56,7 +56,7 @@ import { idAttribute, selectorIdAttribute, } from "@webstudio-is/react-sdk"; -import type { InstanceSelector } from "~/shared/tree-utils"; +import { isDescendantOrSelf, type InstanceSelector } from "~/shared/tree-utils"; import { ToolbarConnectorPlugin } from "./toolbar-connector"; import { type Refs, $convertToLexical, $convertToUpdates } from "./interop"; import { colord } from "colord"; @@ -200,17 +200,6 @@ const getNodeKeyFromDOMNode = ( return (dom as Node & Record)[prop]; }; -const isDescendantOrSelf = ( - descendant: InstanceSelector | undefined, - self: InstanceSelector | undefined -) => { - if (descendant === undefined || self === undefined) { - return false; - } - - return descendant.join(",").endsWith(self.join(",")); -}; - const LinkSelectionPlugin = ({ rootInstanceSelector, registerNewLink, @@ -229,11 +218,14 @@ const LinkSelectionPlugin = ({ const removeUpdateListener = editor.registerUpdateListener( ({ editorState }) => { editorState.read(() => { + const selectedInstanceSelector = $selectedInstanceSelector.get(); + + if (selectedInstanceSelector === undefined) { + return; + } + if ( - !isDescendantOrSelf( - $selectedInstanceSelector.get(), - preservedSelection - ) + !isDescendantOrSelf(selectedInstanceSelector, preservedSelection) ) { return; } @@ -1002,10 +994,11 @@ const ContextMenuPluginInternal = ({ node.setStyle(""); } - const isSelectionInSameComponent = isDescendantOrSelf( - $selectedInstanceSelector.get(), - preservedSelection - ); + const selectedInstanceSelector = $selectedInstanceSelector.get(); + + const isSelectionInSameComponent = selectedInstanceSelector + ? isDescendantOrSelf(selectedInstanceSelector, preservedSelection) + : false; if (!isSelectionInSameComponent) { node?.remove(); diff --git a/apps/builder/app/canvas/instance-hovering.ts b/apps/builder/app/canvas/instance-hovering.ts index 9fac91507116..46798744766f 100644 --- a/apps/builder/app/canvas/instance-hovering.ts +++ b/apps/builder/app/canvas/instance-hovering.ts @@ -14,17 +14,10 @@ import { getInstanceSelectorFromElement, } from "~/shared/dom-utils"; import { subscribeScrollState } from "./shared/scroll-state"; -import type { InstanceSelector } from "~/shared/tree-utils"; +import { isDescendantOrSelf, type InstanceSelector } from "~/shared/tree-utils"; type TimeoutId = undefined | ReturnType; -const isDescendantOrSelf = ( - descendant: InstanceSelector, - self: InstanceSelector -) => { - return descendant.join(",").endsWith(self.join(",")); -}; - export const subscribeInstanceHovering = ({ signal, }: { diff --git a/apps/builder/app/shared/tree-utils.test.ts b/apps/builder/app/shared/tree-utils.test.ts index cccb372f1990..2eb71ac9f07b 100644 --- a/apps/builder/app/shared/tree-utils.test.ts +++ b/apps/builder/app/shared/tree-utils.test.ts @@ -14,6 +14,7 @@ import { findLocalStyleSourcesWithinInstances, getAncestorInstanceSelector, insertPropsCopyMutable, + isDescendantOrSelf, } from "./tree-utils"; const expectString = expect.any(String) as unknown as string; @@ -173,3 +174,9 @@ test("find local style sources within instances", () => { ) ).toEqual(new Set(["local2", "local4"])); }); + +test("is descendant or self", () => { + expect(isDescendantOrSelf(["1", "2", "3"], ["1", "2", "3"])).toBe(true); + expect(isDescendantOrSelf(["0", "1", "2", "3"], ["1", "2", "3"])).toBe(true); + expect(isDescendantOrSelf(["1", "2", "3"], ["0", "1", "2", "3"])).toBe(false); +}); diff --git a/apps/builder/app/shared/tree-utils.ts b/apps/builder/app/shared/tree-utils.ts index a7b220a2b6d3..6a1e617e04e3 100644 --- a/apps/builder/app/shared/tree-utils.ts +++ b/apps/builder/app/shared/tree-utils.ts @@ -14,6 +14,7 @@ import { collectionComponent, type WsComponentMeta, } from "@webstudio-is/react-sdk"; +import { shallowEqual } from "shallow-equal"; // slots can have multiple parents so instance should be addressed // with full rendered path to avoid double selections with slots @@ -44,6 +45,23 @@ export const areInstanceSelectorsEqual = ( return left.join(",") === right.join(","); }; +export const isDescendantOrSelf = ( + descendant: InstanceSelector, + self: InstanceSelector +) => { + if (self.length === 0) { + return true; + } + + if (descendant.length < self.length) { + return false; + } + + const endSlice = descendant.slice(-self.length); + + return shallowEqual(endSlice, self); +}; + export type DroppableTarget = { parentSelector: InstanceSelector; position: number | "end"; From dc23bbfa1d3d043934957a0f88055d27d04debcb Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 19:16:44 +0000 Subject: [PATCH 10/12] Support of alt-enter --- apps/builder/app/canvas/shared/commands.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/builder/app/canvas/shared/commands.ts b/apps/builder/app/canvas/shared/commands.ts index 8b11a57a22d3..d7c961cad30c 100644 --- a/apps/builder/app/canvas/shared/commands.ts +++ b/apps/builder/app/canvas/shared/commands.ts @@ -20,7 +20,7 @@ import { hasSelectionFormat, } from "../features/text-editor/toolbar-connector"; import { selectInstance } from "~/shared/awareness"; -import type { InstanceSelector } from "~/shared/tree-utils"; +import { isDescendantOrSelf, type InstanceSelector } from "~/shared/tree-utils"; export const { emitCommand, subscribeCommands } = createCommandsEmitter({ source: "canvas", @@ -29,7 +29,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ { name: "editInstanceText", hidden: true, - defaultHotkeys: ["enter"], + defaultHotkeys: ["enter", "alt+enter"], // builder invokes command with custom hotkey setup disableHotkeyOutsideApp: true, handler: () => { @@ -38,6 +38,16 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ return; } + if ( + isDescendantOrSelf( + $textEditingInstanceSelector.get()?.selector ?? [], + selectedInstanceSelector + ) + ) { + // already in text editing mode + return; + } + let editableInstanceSelector = findClosestEditableInstanceSelector( selectedInstanceSelector, $instances.get(), From 62160ef4731241d2193a3b058344095cd75b7ce5 Mon Sep 17 00:00:00 2001 From: istarkov Date: Thu, 19 Dec 2024 19:34:16 +0000 Subject: [PATCH 11/12] Make new instance selection explicit --- .../block-editor-context-menu.tsx | 6 +- apps/builder/app/builder/shared/commands.ts | 1 + apps/builder/app/canvas/shared/commands.ts | 56 ++++++++++++++++++- 3 files changed, 58 insertions(+), 5 deletions(-) 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 c8d56cdad488..51b84cf77c5f 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 @@ -87,6 +87,7 @@ const Menu = ({ (templateSelector: InstanceSelector) => { const insertBefore = modifierKeys.altKey; insertTemplateAt(templateSelector, anchor, insertBefore); + emitCommand("newInstanceText"); }, [anchor, modifierKeys.altKey] ); @@ -174,10 +175,7 @@ const Menu = ({ triggerTooltipContent={triggerTooltipContent} templates={filtered.templates} value={currentValue} - onValueChangeComplete={(value) => { - handleValueChangeComplete(value); - emitCommand("editInstanceText"); - }} + onValueChangeComplete={handleValueChangeComplete} onValueChange={setIntermediateValue} modal={false} inert={inert} diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 85521775bdb5..03769b0a3f85 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -230,6 +230,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ source: "builder", externalCommands: [ "editInstanceText", + "newInstanceText", "formatBold", "formatItalic", "formatSuperscript", diff --git a/apps/builder/app/canvas/shared/commands.ts b/apps/builder/app/canvas/shared/commands.ts index d7c961cad30c..9bb08bc8edbb 100644 --- a/apps/builder/app/canvas/shared/commands.ts +++ b/apps/builder/app/canvas/shared/commands.ts @@ -26,10 +26,63 @@ 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, - defaultHotkeys: ["enter", "alt+enter"], + defaultHotkeys: ["enter"], + disableHotkeyOnContentEditable: true, // builder invokes command with custom hotkey setup disableHotkeyOutsideApp: true, handler: () => { @@ -79,6 +132,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ // 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({ From 9c00cbbe4caa257126e1914e1206ed980161f3db Mon Sep 17 00:00:00 2001 From: istarkov Date: Fri, 20 Dec 2024 13:16:39 +0000 Subject: [PATCH 12/12] Fix --- .../block-editor-context-menu.tsx | 83 +++++++++---------- .../features/text-editor/text-editor.tsx | 5 -- 2 files changed, 37 insertions(+), 51 deletions(-) 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 51b84cf77c5f..2f6c2f44f825 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 @@ -32,10 +32,8 @@ const TriggerButton = styled("button", { }); const InertController = ({ - // value, onChange, }: { - // value: boolean; onChange: (inert: boolean) => void; }) => { const handleChange = useEffectEvent(onChange); @@ -71,7 +69,6 @@ const Menu = ({ const [inert, setInert] = useState(true); const modifierKeys = useStore($modifierKeys); const scale = useStore($scale); - // const textEditorContextMenuCommand = useStore($textEditorContextMenuCommand); const rect = applyScale(cursorRect, scale); const [filtered, setFiltered] = useState({ repeat: 0, templates }); @@ -102,56 +99,50 @@ const Menu = ({ const type = command.type; switch (type) { - case "filter": - { - const filter = command.value.toLowerCase(); - const filteredTemplates = templates.filter(([template]) => { - const title = template.label ?? template.component; - return title.toLowerCase().includes(filter); - }); - - setFiltered((prev) => { - if (filteredTemplates.length === 0) { - return { repeat: prev.repeat + 1, templates: [] }; - } - - return { repeat: 0, templates: filteredTemplates }; - }); - - setValue(filteredTemplates[0]?.[1] ?? undefined); - } + case "filter": { + const filter = command.value.toLowerCase(); + const filteredTemplates = templates.filter(([template]) => { + const title = template.label ?? template.component; + return title.toLowerCase().includes(filter); + }); + + setFiltered((prev) => { + if (filteredTemplates.length === 0) { + return { repeat: prev.repeat + 1, templates: [] }; + } + return { repeat: 0, templates: filteredTemplates }; + }); + + setValue(filteredTemplates[0]?.[1] ?? undefined); break; - case "selectNext": - { - const index = filtered.templates.findIndex(([_, selector]) => - shallowEqual(selector, currentValue) - ); - const nextIndex = mod(index + 1, filtered.templates.length); - setValue(filtered.templates[nextIndex]?.[1] ?? undefined); - setIntermediateValue(undefined); - } + } + + case "selectNext": { + const index = filtered.templates.findIndex(([_, selector]) => + shallowEqual(selector, currentValue) + ); + const nextIndex = mod(index + 1, filtered.templates.length); + setValue(filtered.templates[nextIndex]?.[1] ?? undefined); + setIntermediateValue(undefined); break; - case "selectPrevious": - { - const index = filtered.templates.findIndex(([_, selector]) => - shallowEqual(selector, currentValue) - ); - const prevIndex = mod(index - 1, filtered.templates.length); - setValue(filtered.templates[prevIndex]?.[1] ?? undefined); - setIntermediateValue(undefined); - } - + } + case "selectPrevious": { + const index = filtered.templates.findIndex(([_, selector]) => + shallowEqual(selector, currentValue) + ); + const prevIndex = mod(index - 1, filtered.templates.length); + setValue(filtered.templates[prevIndex]?.[1] ?? undefined); + setIntermediateValue(undefined); break; + } - case "enter": - { - if (currentValue !== undefined) { - handleValueChangeComplete(currentValue); - } + case "enter": { + if (currentValue !== undefined) { + handleValueChangeComplete(currentValue); } - break; + } default: (type) satisfies never; 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 20d5859dbaf0..dc4ad16dfaea 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -1297,11 +1297,6 @@ export const TextEditor = ({ const handleChange = useEffectEvent((editorState: EditorState) => { editorState.read(() => { - // Otherwise editorState.read captures focus - if ($textEditorContextMenu.get() !== undefined) { - //return; - } - const treeRootInstance = instances.get(rootInstanceSelector[0]); if (treeRootInstance) { const jsonState = editorState.toJSON();