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