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..2f6c2f44f825 --- /dev/null +++ b/apps/builder/app/builder/features/workspace/canvas-tools/block-editor-context-menu.tsx @@ -0,0 +1,219 @@ +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 { 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", + appearance: "none", + backgroundColor: "transparent", + outline: "none", + pointerEvents: "all", + border: "none", + overflow: "hidden", + padding: 0, +}); + +const InertController = ({ + onChange, +}: { + 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 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 handleValueChangeComplete = useCallback( + (templateSelector: InstanceSelector) => { + const insertBefore = modifierKeys.altKey; + insertTemplateAt(templateSelector, anchor, insertBefore); + emitCommand("newInstanceText"); + }, + [anchor, modifierKeys.altKey] + ); + + 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; + } + + case "enter": { + if (currentValue !== undefined) { + handleValueChangeComplete(currentValue); + } + break; + } + + default: + (type) satisfies never; + } + }); + }, [filtered.templates, templates, currentValue, handleValueChangeComplete]); + + // @todo repeat and close + + return ( + <> + { + if (open) { + return; + } + $textEditorContextMenu.set(undefined); + }} + anchor={anchor} + triggerTooltipContent={triggerTooltipContent} + templates={filtered.templates} + value={currentValue} + onValueChangeComplete={handleValueChangeComplete} + onValueChange={setIntermediateValue} + modal={false} + inert={inert} + preventFocusOnHover={true} + > + + + + + ); +}; + +export const TextEditorContextMenu = () => { + const textEditingInstanceSelector = useStore($textEditingInstanceSelector); + const textEditorContextMenu = useStore($textEditorContextMenu); + const instances = useStore($instances); + + if (textEditorContextMenu === undefined) { + return; + } + + if (textEditingInstanceSelector === undefined) { + return; + } + + const templates = findTemplates( + textEditingInstanceSelector.selector, + instances + ); + + if (templates === undefined) { + return; + } + + return ( + + ); +}; 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..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 @@ -7,6 +7,8 @@ import { $isContentMode, $modifierKeys, $registeredComponentMetas, + findBlockSelector, + findTemplates, type BlockChildOutline, } from "~/shared/nano-states"; import { @@ -34,155 +36,46 @@ import { PlusIcon, TrashIcon } from "@webstudio-is/icons"; import { BoxIcon } from "@webstudio-is/icons/svg"; 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"; -const TemplatesMenu = ({ +export const TemplatesMenu = ({ onOpenChange, open, 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); @@ -190,6 +83,17 @@ const TemplatesMenu = ({ const blockInstanceSelector = findBlockSelector(anchor, instances); + 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; } @@ -203,8 +107,6 @@ 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: , @@ -213,8 +115,14 @@ const TemplatesMenu = ({ })); return ( - - {children} + + + {children} + - { - 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 }) => ( - - - {icon} - {title} - - - ))} - - - -
- - - - to add before - - - 0 ? ( + <> + - - 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 + } + onPointerDown={ + preventFocusOnHover + ? (event) => { + event.preventDefault(); + } + : undefined + } + key={id} + value={JSON.stringify(value)} + {...{ [skipInertHandlersAttribute]: true }} + > + + {icon} + {title} + + + ))} + + +
+ + + + to add before + + + + + to add after + + + {" "} + to add before + + +
+ + ) : ( +
+ + No Results + +
+ )}
@@ -465,40 +363,46 @@ 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} > - - - { - if (isAddMode) { - return; - } + { + if (isAddMode) { + return; + } + + updateWebstudioData((data) => { + deleteInstanceMutable(data, outline.selector); + }); - 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 ? : } - - - + 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 ? : } + 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 new file mode 100644 index 000000000000..31eca5761136 --- /dev/null +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts @@ -0,0 +1,118 @@ +import { blockTemplateComponent } from "@webstudio-is/react-sdk"; +import type { Instance, Instances } from "@webstudio-is/sdk"; +import { shallowEqual } from "shallow-equal"; +import { selectInstance } from "~/shared/awareness"; +import { + extractWebstudioFragment, + findAvailableDataSources, + getWebstudioData, + insertInstanceChildrenMutable, + insertWebstudioFragmentCopy, + updateWebstudioData, +} from "~/shared/instance-utils"; +import { + $instances, + findBlockChildSelector, + findBlockSelector, +} from "~/shared/nano-states"; +import type { DroppableTarget, InstanceSelector } from "~/shared/tree-utils"; + +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 childBlockSelector = findBlockChildSelector(anchor); + + if (childBlockSelector === 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 === childBlockSelector[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/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/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 747bc30a6d7a..03769b0a3f85 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, @@ -229,6 +230,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ source: "builder", externalCommands: [ "editInstanceText", + "newInstanceText", "formatBold", "formatItalic", "formatSuperscript", 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, }, 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..dc4ad16dfaea 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"; @@ -54,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"; @@ -64,9 +66,13 @@ import { $blockChildOutline, $hoveredInstanceOutline, $hoveredInstanceSelector, + $instances, $registeredComponentMetas, $selectedInstanceSelector, $textEditingInstanceSelector, + $textEditorContextMenu, + execTextEditorContextMenuCommand, + findTemplates, } from "~/shared/nano-states"; import { getElementByInstanceSelector, @@ -170,7 +176,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 @@ -199,7 +208,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()) { @@ -209,6 +218,18 @@ const LinkSelectionPlugin = ({ const removeUpdateListener = editor.registerUpdateListener( ({ editorState }) => { editorState.read(() => { + const selectedInstanceSelector = $selectedInstanceSelector.get(); + + if (selectedInstanceSelector === undefined) { + return; + } + + if ( + !isDescendantOrSelf(selectedInstanceSelector, preservedSelection) + ) { + return; + } + const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; @@ -904,6 +925,278 @@ const SwitchBlockPlugin = ({ onNext }: SwitchBlockPluginProps) => { return null; }; +type ContextMenuParams = { + cursorRect: DOMRect; +}; + +type ContextMenuPluginProps = { + rootInstanceSelector: InstanceSelector; + onOpen: ( + editorState: EditorState, + params: undefined | ContextMenuParams + ) => void; +}; + +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) => { + const [editor] = useLexicalComposerContext(); + const [preservedSelection] = useState(rootInstanceSelector); + + 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 selectedInstanceSelector = $selectedInstanceSelector.get(); + + const isSelectionInSameComponent = selectedInstanceSelector + ? isDescendantOrSelf(selectedInstanceSelector, preservedSelection) + : false; + + if (!isSelectionInSameComponent) { + node?.remove(); + } + + // if selection changed, remove the slash node + + 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") { + closeMenu(); + event.preventDefault(); + return true; + } + + if (event.key === " ") { + closeMenu(); + } + + if (event.key === "/") { + closeMenu(); + } + + if (event.key === "Enter") { + execTextEditorContextMenuCommand({ + type: "enter", + }); + + event.preventDefault(); + return true; + } + + if (event.key === "ArrowUp") { + execTextEditorContextMenuCommand({ + type: "selectPrevious", + }); + + event.preventDefault(); + return true; + } + + if (event.key === "ArrowDown") { + execTextEditorContextMenuCommand({ + type: "selectNext", + }); + + event.preventDefault(); + return true; + } + } + + if (menuState === "closed") { + 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; + } + + return false; + }, + COMMAND_PRIORITY_EDITOR + ); + + const closeMenuWithUpdate = () => { + editor.update(() => { + closeMenu(); + }); + }; + + const unsubscribeUpdateListener = editor.registerUpdateListener( + ({ editorState }) => { + 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, + }); + }); + } + + if (menuState === "opening") { + editorState.read(() => { + if (slashNodeKey === undefined) { + closeMenu(); + return; + } + + const slashNode = editor.getElementByKey(slashNodeKey); + + if (slashNode === null) { + closeMenu(); + return; + } + + const rect = slashNode.getBoundingClientRect(); + + menuState = "opened"; + + handleOpen(editor.getEditorState(), { + cursorRect: rect, + }); + }); + } + } + ); + + const unsubscribeBlurListener = editor.registerRootListener( + (rootElement, prevRootElement) => { + rootElement?.addEventListener("blur", closeMenuWithUpdate); + prevRootElement?.removeEventListener("blur", closeMenuWithUpdate); + } + ); + + return () => { + unsubscibeKeyDown(); + unsubscribeUpdateListener(); + unsubscibeSelectionChange(); + unsubscribeBlurListener(); + }; + }, [editor, handleOpen, preservedSelection]); + + return null; +}; + const onError = (error: Error) => { throw error; }; @@ -1080,8 +1373,7 @@ export const TextEditor = ({ const editableInstanceSelectors: InstanceSelector[] = []; findAllEditableInstanceSelector( - rootInstanceId, - [], + [rootInstanceId], instances, $registeredComponentMetas.get(), editableInstanceSelectors @@ -1180,6 +1472,13 @@ export const TextEditor = ({ [newLinkKeyToInstanceId] ); + const handleContextMenuOpen = useCallback( + (_editorState: EditorState, params: undefined | ContextMenuParams) => { + $textEditorContextMenu.set(params); + }, + [] + ); + return ( @@ -1207,6 +1506,10 @@ export const TextEditor = ({ + ; -const isDescendantOrSelf = ( - descendant: InstanceSelector, - self: InstanceSelector -) => { - return descendant.join(",").endsWith(self.join(",")); -}; - export const subscribeInstanceHovering = ({ signal, }: { diff --git a/apps/builder/app/canvas/shared/commands.ts b/apps/builder/app/canvas/shared/commands.ts index 3b7b12ef66da..9bb08bc8edbb 100644 --- a/apps/builder/app/canvas/shared/commands.ts +++ b/apps/builder/app/canvas/shared/commands.ts @@ -2,12 +2,16 @@ import { FORMAT_TEXT_COMMAND } from "lexical"; import { TOGGLE_LINK_COMMAND } from "@lexical/link"; import { createCommandsEmitter } from "~/shared/commands-emitter"; import { getElementByInstanceSelector } from "~/shared/dom-utils"; -import { findClosestEditableInstanceSelector } from "~/shared/instance-utils"; +import { + findClosestEditableInstanceSelector, + findAllEditableInstanceSelector, +} from "~/shared/instance-utils"; import { $instances, $registeredComponentMetas, $selectedInstanceSelector, $textEditingInstanceSelector, + $textToolbar, } from "~/shared/nano-states"; import { CLEAR_FORMAT_COMMAND, @@ -16,15 +20,69 @@ import { hasSelectionFormat, } from "../features/text-editor/toolbar-connector"; import { selectInstance } from "~/shared/awareness"; +import { isDescendantOrSelf, type InstanceSelector } from "~/shared/tree-utils"; 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"], + disableHotkeyOnContentEditable: true, // builder invokes command with custom hotkey setup disableHotkeyOutsideApp: true, handler: () => { @@ -32,14 +90,41 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ if (selectedInstanceSelector === undefined) { return; } - const editableInstanceSelector = findClosestEditableInstanceSelector( + + if ( + isDescendantOrSelf( + $textEditingInstanceSelector.get()?.selector ?? [], + selectedInstanceSelector + ) + ) { + // already in text editing mode + return; + } + + let editableInstanceSelector = findClosestEditableInstanceSelector( selectedInstanceSelector, $instances.get(), $registeredComponentMetas.get() ); + if (editableInstanceSelector === undefined) { - return; + const selectors: InstanceSelector[] = []; + + findAllEditableInstanceSelector( + selectedInstanceSelector, + $instances.get(), + $registeredComponentMetas.get(), + selectors + ); + + if (selectors.length === 0) { + $textEditingInstanceSelector.set(undefined); + return; + } + + editableInstanceSelector = selectors[0]; } + const element = getElementByInstanceSelector(editableInstanceSelector); if (element === undefined) { return; @@ -47,7 +132,9 @@ 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({ selector: editableInstanceSelector, reason: "enter", @@ -64,16 +151,25 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ handler: () => { const selectedInstanceSelector = $selectedInstanceSelector.get(); const textEditingInstanceSelector = $textEditingInstanceSelector.get(); - if (selectedInstanceSelector === undefined) { + const textToolbar = $textToolbar.get(); + + // 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); return; } - // unselect both instance and style source - selectInstance(undefined); + + if (selectedInstanceSelector) { + // unselect both instance and style source + selectInstance(undefined); + return; + } }, }, diff --git a/apps/builder/app/canvas/shared/styles.ts b/apps/builder/app/canvas/shared/styles.ts index 28aced390878..23f29fa16673 100644 --- a/apps/builder/app/canvas/shared/styles.ts +++ b/apps/builder/app/canvas/shared/styles.ts @@ -157,8 +157,7 @@ const subscribeContentEditModeHelperStyles = () => { 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/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 352757e04fa8..86ce9e1454ce 100644 --- a/apps/builder/app/shared/nano-states/instances.ts +++ b/apps/builder/app/shared/nano-states/instances.ts @@ -31,4 +31,30 @@ export const $textEditingInstanceSelector = atom< } >(); +export const $textEditorContextMenu = atom< + | { + cursorRect: DOMRect; + } + | undefined +>(undefined); + +type ContextMenuCommand = + | { + type: "filter"; + value: string; + } + | { type: "selectNext" } + | { type: "selectPrevious" } + | { type: "enter" }; + +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 86fb9b8c64fc..3dbf104e66a5 100644 --- a/apps/builder/app/shared/sync/sync-stores.ts +++ b/apps/builder/app/shared/sync/sync-stores.ts @@ -36,6 +36,8 @@ import { $builderMode, $selectedBreakpointId, $textEditingInstanceSelector, + $textEditorContextMenu, + $textEditorContextMenuCommand, $isResizingCanvas, $collaborativeInstanceRect, $collaborativeInstanceSelector, @@ -135,6 +137,11 @@ export const createObjectPool = () => { "textEditingInstanceSelector", $textEditingInstanceSelector ), + new NanostoresSyncObject("textEditorContextMenu", $textEditorContextMenu), + new NanostoresSyncObject( + "textEditorContextMenuCommand", + $textEditorContextMenuCommand + ), new NanostoresSyncObject("isResizingCanvas", $isResizingCanvas), new NanostoresSyncObject("textToolbar", $textToolbar), new NanostoresSyncObject( 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"; 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/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, }, 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):