mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
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:
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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user