experimental: Enter support (#4627)

## Description

ref #4595 

- Enter on block creates new paragraph, text or h1 block if any exists
in templates
- New text added through `/` command is fully selected
- If after command block is empty it's replaced with another block
- esc now closes the `/` menu
- backspace on empty now deletes block


Play here


https://p-0b40acbe-34df-4475-8f52-120d5b97da4e-dot-enter.development.webstudio.is/?mode=content

- [x] - Bug (Next PR)
<img width="325" alt="image"
src="https://github.com/user-attachments/assets/0dfa447b-dbfd-4c60-b553-ca041da23a4f"
/>
to reproduce
`Enter`, `/` select Heading, type `blabla`, then `/`, then `Enter`



## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Ivan Starkov authored and GitHub committed 2024-12-20 22:57:02 +03:00
1 parent ea88935731
commit a27c8b9c9e
7 files changed
+214 -118

No files matched your search

@@ -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]
);
@@ -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]);
});
};
@@ -230,7 +230,6 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
source: "builder",
externalCommands: [
"editInstanceText",
"newInstanceText",
"formatBold",
"formatItalic",
"formatSuperscript",
@@ -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 <ContextMenuPluginInternal {...props} />;
if (templates.length === 0) {
return;
}
return <RichTextContentPluginInternal {...props} templates={templates} />;
};
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 = ({
<HistoryPlugin />
<SwitchBlockPlugin onNext={handleNext} />
<ContextMenuPlugin
<RichTextContentPlugin
onOpen={handleContextMenuOpen}
rootInstanceSelector={rootInstanceSelector}
onNext={handleNext}
/>
<OnChangeOnBlurPlugin onChange={handleChange} />
<InitCursorPlugin />
+36 -29
View File
@@ -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();
});
};
+1 -52
View File
@@ -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: () => {
@@ -18,6 +18,10 @@ export const $textEditingInstanceSelector = atom<
selector: InstanceSelector;
reason: "right" | "left" | "enter";
}
| {
selector: InstanceSelector;
reason: "new";
}
| {
selector: InstanceSelector;
reason: "click";