feat: Block editing with arrow keys (#4334)

## Description

- Double click now sets cursor to the click position
- Up/Down arrows on a first/last line trying to preserve cursor x
position (visible in case of block above block)
- Right/Left arrows switch blocks if cursor is at block begin/end.



## 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:
5de6)
- [ ] 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
2024-10-28 19:44:08 +03:00
committed by GitHub
parent d61945402c
commit afc77fa72a
23 changed files with 1229 additions and 128 deletions

View File

@ -55,6 +55,7 @@ export default {
// storybook use "util" package internally which is bundled with stories
// and gives and error that process is undefined
"process.env.NODE_DEBUG": "undefined",
"process.env.IS_STROYBOOK": "true",
},
resolve: {
...config.resolve,

View File

@ -23,7 +23,7 @@ export const SelectedInstanceOutline = () => {
const isEditingCurrentInstance =
textEditingInstanceSelector !== undefined &&
areInstanceSelectorsEqual(
textEditingInstanceSelector,
textEditingInstanceSelector.selector,
selectedInstanceSelector
);

View File

@ -1,14 +1,22 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useStore } from "@nanostores/react";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import type { StoryFn, Meta } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { Box } from "@webstudio-is/design-system";
import { Box, Button, Flex } from "@webstudio-is/design-system";
import { theme } from "@webstudio-is/design-system";
import type { Instance, Instances } from "@webstudio-is/sdk";
import { $textToolbar } from "~/shared/nano-states";
import {
$instances,
$pages,
$registeredComponentMetas,
$selectedPageId,
$textEditingInstanceSelector,
$textToolbar,
} from "~/shared/nano-states";
import { TextEditor } from "./text-editor";
import { emitCommand, subscribeCommands } from "~/canvas/shared/commands";
import { $, renderJsx } from "@webstudio-is/sdk/testing";
export default {
component: TextEditor,
@ -33,17 +41,21 @@ const createInstancePair = (
const instances: Instances = new Map([
createInstancePair("1", "Text", [
{ type: "text", value: "Paragraph you can edit " },
{ type: "text", value: "Paragraph you can edit Blabla " },
{ type: "id", value: "2" },
{ type: "id", value: "3" },
{ type: "id", value: "5" },
]),
createInstancePair("2", "Bold", [{ type: "text", value: "very bold text " }]),
createInstancePair("2", "Bold", [
{ type: "text", value: "Very Very very bold text " },
]),
createInstancePair("3", "Bold", [{ type: "id", value: "4" }]),
createInstancePair("4", "Italic", [
{ type: "text", value: "with small italic" },
{ type: "text", value: "And Bold Small with small italic" },
]),
createInstancePair("5", "Bold", [
{ type: "text", value: " la la la subtext" },
]),
createInstancePair("5", "Bold", [{ type: "text", value: " subtext" }]),
]);
export const Basic: StoryFn<typeof TextEditor> = ({ onChange }) => {
@ -128,6 +140,229 @@ export const Basic: StoryFn<typeof TextEditor> = ({ onChange }) => {
);
};
export const CursorPositioning: StoryFn<typeof TextEditor> = ({ onChange }) => {
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
return (
<>
<Box
css={{
width: 300,
"& > div": {
padding: 40,
backgroundColor: textEditingInstanceSelector
? "unset"
: "rgba(0,0,0,0.1)",
},
border: "1px solid #999",
color: "black",
" *": {
outline: "none",
},
}}
onClick={(event) => {
if (textEditingInstanceSelector !== undefined) {
return;
}
$textEditingInstanceSelector.set({
selector: ["1"],
reason: "click",
mouseX: event.clientX,
mouseY: event.clientY,
});
}}
>
{textEditingInstanceSelector && (
<TextEditor
rootInstanceSelector={["1"]}
instances={instances}
contentEditable={<ContentEditable />}
onChange={onChange}
onSelectInstance={(instanceId) =>
console.info("select instance", instanceId)
}
/>
)}
{!textEditingInstanceSelector && (
<div>
<span>Paragraph you can edit Blabla </span>
<strong>Very Very very bold text </strong>
<strong>
<i>And Bold Small with small italic</i>
</strong>
<strong> la la la subtext</strong>
</div>
)}
</Box>
<br />
<div>
<i>Click on text above, see cursor position and start editing text</i>
</div>
{textEditingInstanceSelector && (
<Button
onClick={() => {
$textEditingInstanceSelector.set(undefined);
}}
>
Reset
</Button>
)}
</>
);
};
export const CursorPositioningUpDown: StoryFn<typeof TextEditor> = () => {
const [{ instances }, setState] = useState(() => {
$pages.set({
folders: [],
homePage: {
id: "homePageId",
rootInstanceId: "bodyId",
meta: {},
path: "",
title: "",
name: "",
systemDataSourceId: "",
},
pages: [
{
id: "pageId",
rootInstanceId: "bodyId",
path: "",
title: "",
name: "",
systemDataSourceId: "",
meta: {},
},
],
});
$selectedPageId.set("pageId");
$registeredComponentMetas.set(
new Map([
[
"Box",
{
type: "container",
icon: "icon",
},
],
[
"Bold",
{
type: "rich-text-child",
icon: "icon",
},
],
])
);
return renderJsx(
<$.Body ws:id="bodyId">
<$.Box ws:id="boxAId">
Hello world <$.Bold ws:id="boldA">Hello world</$.Bold> Hello world
world Hello worldsdsdj skdk ls dk jslkdjklsjdkl sdk jskdj ksjd lksdj
dsj
</$.Box>
<$.Box ws:id="boxBId">
Let it be Let it be <$.Bold ws:id="boldB">Let it be Let</$.Bold> Let
it be Let it be Let it be Let it be Let it be Let it be
</$.Box>
</$.Body>
);
});
useEffect(() => {
$instances.set(instances);
}, [instances]);
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
return (
<>
<Flex
gap={2}
direction={"column"}
css={{
width: 500,
"& > div > div": {
padding: 5,
border: "1px solid #999",
},
"& *[aria-readonly]": {
backgroundColor: "rgba(0,0,0,0.02)",
},
"& strong": {
fontSize: "1.5em",
},
color: "black",
" *": {
outline: "none",
},
}}
>
<div style={{ display: "contents" }} data-ws-selector="boxAId,bodyId">
<TextEditor
key={textEditingInstanceSelector?.selector[0] ?? ""}
editable={
textEditingInstanceSelector === undefined ||
textEditingInstanceSelector?.selector[0] === "boxAId"
}
rootInstanceSelector={["boxAId", "bodyId"]}
instances={instances}
contentEditable={<ContentEditable />}
onChange={(data) => {
setState((prev) => {
for (const instance of data) {
prev.instances.set(instance.id, instance);
}
return prev;
});
}}
onSelectInstance={(instanceId) =>
console.info("select instance", instanceId)
}
/>
</div>
<div
style={{ display: "contents" }}
data-ws-selector="boxBId,bodyId"
data-ws-collapsed="true"
>
<TextEditor
key={textEditingInstanceSelector?.selector[0] ?? ""}
editable={textEditingInstanceSelector?.selector[0] === "boxBId"}
rootInstanceSelector={["boxBId", "bodyId"]}
instances={instances}
contentEditable={<ContentEditable />}
onChange={(data) => {
setState((prev) => {
for (const instance of data) {
prev.instances.set(instance.id, instance);
}
return prev;
});
}}
onSelectInstance={(instanceId) =>
console.info("select instance", instanceId)
}
/>
</div>
</Flex>
<br />
<i>Use arrows to move between editors, clicks are not working</i>
</>
);
};
Basic.args = {
onChange: action("onChange"),
};
CursorPositioning.args = {
onChange: action("onChange"),
};

View File

@ -1,4 +1,10 @@
import { useState, useEffect, useLayoutEffect } from "react";
import {
useState,
useEffect,
useLayoutEffect,
useCallback,
useRef,
} from "react";
import {
KEY_ENTER_COMMAND,
INSERT_LINE_BREAK_COMMAND,
@ -9,6 +15,23 @@ import {
$getSelection,
$isRangeSelection,
type EditorState,
$isLineBreakNode,
COMMAND_PRIORITY_LOW,
$setSelection,
$getRoot,
$isTextNode,
$isElementNode,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_ARROW_LEFT_COMMAND,
$createRangeSelection,
COMMAND_PRIORITY_CRITICAL,
$getNearestNodeFromDOMNode,
// eslint-disable-next-line camelcase
$normalizeSelection__EXPERIMENTAL,
type LexicalEditor,
type SerializedEditorState,
} from "lexical";
import { LinkNode } from "@lexical/link";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
@ -20,12 +43,26 @@ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
import { nanoid } from "nanoid";
import { createRegularStyleSheet } from "@webstudio-is/css-engine";
import type { Instance, Instances } from "@webstudio-is/sdk";
import { idAttribute } from "@webstudio-is/react-sdk";
import { collapsedAttribute, idAttribute } from "@webstudio-is/react-sdk";
import type { InstanceSelector } from "~/shared/tree-utils";
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 {
$registeredComponentMetas,
$selectedInstanceSelector,
$selectedPage,
$textEditingInstanceSelector,
} from "~/shared/nano-states";
import {
getElementByInstanceSelector,
getVisibleElementsByInstanceSelector,
} from "~/shared/dom-utils";
import deepEqual from "fast-deep-equal";
import { setDataCollapsed } from "~/canvas/collapsed";
// import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
const BindInstanceToNodePlugin = ({ refs }: { refs: Refs }) => {
const [editor] = useLexicalComposerContext();
@ -42,23 +79,6 @@ const BindInstanceToNodePlugin = ({ refs }: { refs: Refs }) => {
return null;
};
const AutofocusPlugin = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const rootElement = editor.getRootElement();
if (rootElement === null) {
return;
}
editor.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
};
/**
* In case of text color is near transparent, make caret visible with color animation between #666 and #999
*/
@ -191,6 +211,565 @@ const RemoveParagaphsPlugin = () => {
return null;
};
const isSelectionLastNode = () => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const rootNode = $getRoot();
const lastNode = rootNode.getLastDescendant();
const anchor = selection.anchor;
if ($isLineBreakNode(lastNode)) {
const anchorNode = anchor.getNode();
return (
$isElementNode(anchorNode) &&
anchorNode.getLastDescendant() === lastNode &&
anchor.offset === anchorNode.getChildrenSize()
);
} else if ($isTextNode(lastNode)) {
return (
anchor.offset === lastNode.getTextContentSize() &&
anchor.getNode() === lastNode
);
} else if ($isElementNode(lastNode)) {
return (
anchor.offset === lastNode.getChildrenSize() &&
anchor.getNode() === lastNode
);
}
return false;
};
const isSelectionFirstNode = () => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const rootNode = $getRoot();
const firstNode = rootNode.getFirstDescendant();
const anchor = selection.anchor;
if ($isLineBreakNode(firstNode)) {
const anchorNode = anchor.getNode();
return (
$isElementNode(anchorNode) &&
anchorNode.getFirstDescendant() === firstNode &&
anchor.offset === 0
);
} else if ($isTextNode(firstNode)) {
return anchor.offset === 0 && anchor.getNode() === firstNode;
} else if ($isElementNode(firstNode)) {
return anchor.offset === 0 && anchor.getNode() === firstNode;
}
return false;
};
const getDomSelectionRect = () => {
const domSelection = window.getSelection();
if (!domSelection || !domSelection.focusNode) {
return undefined;
}
// Get current line position
const range = domSelection.getRangeAt(0);
// The cursor position at the beginning of a line is technically associated with both:
// The end of the previous line
// The beginning of the current line
// Select the rectangle for the current line. It typically appears as the last rect in the list.
const rects = range.getClientRects();
const currentRect = rects[rects.length - 1] ?? undefined;
return currentRect;
};
const getVerticalIntersectionRatio = (rectA: DOMRect, rectB: DOMRect) => {
const topIntersection = Math.max(rectA.top, rectB.top);
const bottomIntersection = Math.min(rectA.bottom, rectB.bottom);
const intersectionHeight = Math.max(0, bottomIntersection - topIntersection);
const minHeight = Math.min(rectA.height, rectB.height);
return minHeight === 0 ? 0 : intersectionHeight / minHeight;
};
const caretFromPoint = (
x: number,
y: number
): null | {
offset: number;
node: Node;
} => {
if (typeof document.caretRangeFromPoint !== "undefined") {
const range = document.caretRangeFromPoint(x, y);
if (range === null) {
return null;
}
return {
node: range.startContainer,
offset: range.startOffset,
};
// @ts-expect-error no types
} else if (document.caretPositionFromPoint !== "undefined") {
// @ts-expect-error no types
const range = document.caretPositionFromPoint(x, y);
if (range === null) {
return null;
}
return {
node: range.offsetNode,
offset: range.offset,
};
} else {
// Gracefully handle IE
return null;
}
};
/**
* Select all TEXT nodes inside editor root, then find the top and bottom rects
*/
const getTopBottomRects = (
editor: LexicalEditor
): [topRects: DOMRect[], bottomRects: DOMRect[]] => {
const rootElement = editor.getElementByKey($getRoot().getKey());
if (rootElement == null) {
return [[], []];
}
const walker = document.createTreeWalker(
rootElement,
NodeFilter.SHOW_TEXT,
null
);
const allRects: DOMRect[] = [];
while (walker.nextNode()) {
const range = document.createRange();
range.selectNodeContents(walker.currentNode);
const rects = range.getClientRects();
allRects.push(...Array.from(rects));
}
if (allRects.length === 0) {
return [[], []];
}
const topRect = Array.from(allRects).sort((a, b) => a.top - b.top)[0];
const bottomRect = Array.from(allRects).sort(
(a, b) => b.bottom - a.bottom
)[0];
const topRects = allRects.filter(
(rect) => getVerticalIntersectionRatio(rect, topRect) > 0.5
);
const bottomRects = allRects.filter(
(rect) => getVerticalIntersectionRatio(rect, bottomRect) > 0.5
);
return [topRects, bottomRects];
};
const InitCursorPlugin = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.isEditable()) {
return;
}
editor.update(() => {
const textEditingInstanceSelector = $textEditingInstanceSelector.get();
if (textEditingInstanceSelector === undefined) {
return;
}
const { reason } = textEditingInstanceSelector;
if (reason === undefined) {
return;
}
if (reason === "click") {
const { mouseX, mouseY } = textEditingInstanceSelector;
const eventRange = caretFromPoint(mouseX, mouseY);
if (eventRange !== null) {
const { offset: domOffset, node: domNode } = eventRange;
const node = $getNearestNodeFromDOMNode(domNode);
if (node !== null) {
const selection = $createRangeSelection();
if ($isTextNode(node)) {
selection.anchor.set(node.getKey(), domOffset, "text");
selection.focus.set(node.getKey(), domOffset, "text");
}
const normalizedSelection =
$normalizeSelection__EXPERIMENTAL(selection);
$setSelection(normalizedSelection);
return;
}
}
}
while (reason === "down" || reason === "up") {
const { cursorX } = textEditingInstanceSelector;
const [topRects, bottomRects] = getTopBottomRects(editor);
// Smoodge the cursor a little to the left and right to find the nearest text node
const smoodgeOffsets = [1, 2, 4];
const maxOffset = Math.max(...smoodgeOffsets);
const rects = reason === "down" ? topRects : bottomRects;
rects.sort((a, b) => a.left - b.left);
const rectWithText = rects.find(
(rect, index) =>
rect.left - (index === 0 ? maxOffset : 0) <= cursorX &&
cursorX <= rect.right + (index === rects.length - 1 ? maxOffset : 0)
);
if (rectWithText === undefined) {
break;
}
const newCursorY = rectWithText.top + rectWithText.height / 2;
const eventRanges = [caretFromPoint(cursorX, newCursorY)];
for (const offset of smoodgeOffsets) {
eventRanges.push(caretFromPoint(cursorX - offset, newCursorY));
eventRanges.push(caretFromPoint(cursorX + offset, newCursorY));
}
for (const eventRange of eventRanges) {
if (eventRange === null) {
continue;
}
const { offset: domOffset, node: domNode } = eventRange;
const node = $getNearestNodeFromDOMNode(domNode);
if (node !== null && $isTextNode(node)) {
const selection = $createRangeSelection();
selection.anchor.set(node.getKey(), domOffset, "text");
selection.focus.set(node.getKey(), domOffset, "text");
const normalizedSelection =
$normalizeSelection__EXPERIMENTAL(selection);
$setSelection(normalizedSelection);
return;
}
}
break;
}
if (
reason === "down" ||
reason === "right" ||
reason === "enter" ||
reason === "click"
) {
const firstNode = $getRoot().getFirstDescendant();
if (firstNode === null) {
return;
}
if ($isTextNode(firstNode)) {
const selection = $createRangeSelection();
selection.anchor.set(firstNode.getKey(), 0, "text");
selection.focus.set(firstNode.getKey(), 0, "text");
$setSelection(selection);
}
if ($isElementNode(firstNode)) {
// e.g. Box is empty
const selection = $createRangeSelection();
selection.anchor.set(firstNode.getKey(), 0, "element");
selection.focus.set(firstNode.getKey(), 0, "element");
$setSelection(selection);
}
if ($isLineBreakNode(firstNode)) {
// e.g. Box contains 2+ empty lines
const selection = $createRangeSelection();
$setSelection(selection);
}
return;
}
if (reason === "up" || reason === "left") {
const selection = $createRangeSelection();
const lastNode = $getRoot().getLastDescendant();
if (lastNode === null) {
return;
}
if ($isTextNode(lastNode)) {
const contentSize = lastNode.getTextContentSize();
selection.anchor.set(lastNode.getKey(), contentSize, "text");
selection.focus.set(lastNode.getKey(), contentSize, "text");
$setSelection(selection);
}
if ($isElementNode(lastNode)) {
// e.g. Box is empty
const selection = $createRangeSelection();
selection.anchor.set(lastNode.getKey(), 0, "element");
selection.focus.set(lastNode.getKey(), 0, "element");
$setSelection(selection);
}
if ($isLineBreakNode(lastNode)) {
// e.g. Box contains 2+ empty lines
const parent = lastNode.getParent();
if ($isElementNode(parent)) {
const selection = $createRangeSelection();
selection.anchor.set(
parent.getKey(),
parent.getChildrenSize(),
"element"
);
selection.focus.set(
parent.getKey(),
parent.getChildrenSize(),
"element"
);
$setSelection(selection);
}
}
return;
}
reason satisfies never;
});
}, [editor]);
return null;
};
type HandleNextParams =
| {
reason: "up" | "down";
cursorX: number;
}
| {
reason: "right" | "left";
};
type SwitchBlockPluginProps = {
onNext: (editorState: EditorState, params: HandleNextParams) => void;
};
const isSingleCursorSelection = () => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const isCaret =
selection.anchor.offset === selection.focus.offset &&
selection.anchor.key === selection.focus.key;
if (!isCaret) {
return false;
}
return true;
};
const SwitchBlockPlugin = ({ onNext }: SwitchBlockPluginProps) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
// The right arrow key should move the cursor to the next block only if it is at the end of the current block.
return editor.registerCommand(
KEY_ARROW_RIGHT_COMMAND,
(event) => {
if (!isSingleCursorSelection()) {
return false;
}
const isLast = isSelectionLastNode();
if (isLast) {
const state = editor.getEditorState();
onNext(state, { reason: "right" });
event?.preventDefault();
return true;
}
return false;
},
COMMAND_PRIORITY_LOW
);
}, [editor, onNext]);
useEffect(() => {
// The left arrow key should move the cursor to the previous block only if it is at the start of the current block.
return editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
(event) => {
if (!isSingleCursorSelection()) {
return false;
}
const isFirst = isSelectionFirstNode();
if (isFirst) {
const state = editor.getEditorState();
onNext(state, { reason: "left" });
event?.preventDefault();
return true;
}
return false;
},
COMMAND_PRIORITY_LOW
);
}, [editor, onNext]);
useEffect(() => {
// The down arrow key should move the cursor to the next block if:
// - it is at the end of the current block
// - the cursor is at the last line of the current block
return editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => {
if (!isSingleCursorSelection()) {
return false;
}
const isLast = isSelectionLastNode();
const rect = getDomSelectionRect();
if (isLast) {
const state = editor.getEditorState();
onNext(state, { reason: "down", cursorX: rect?.x ?? 0 });
event?.preventDefault();
return true;
}
// Check if the cursor is inside a rectangle on the last line
if (rect === undefined) {
return false;
}
const rootNode = $getRoot();
const lastNode = rootNode.getLastDescendant();
if ($isLineBreakNode(lastNode)) {
return false;
}
const [, lineRects] = getTopBottomRects(editor);
const cursorY = rect.y + rect.height / 2;
if (
lineRects.some(
(lineRect) =>
lineRect.left <= rect.x &&
rect.x <= lineRect.right &&
lineRect.top <= cursorY &&
cursorY <= lineRect.bottom
)
) {
const state = editor.getEditorState();
onNext(state, { reason: "down", cursorX: rect?.x ?? 0 });
event?.preventDefault();
return true;
}
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor, onNext]);
useEffect(() => {
// The up arrow key should move the cursor to the previous block if:
// - it is at the start of the current block
// - the cursor is at the first line of the current block
return editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => {
if (!isSingleCursorSelection()) {
return false;
}
const isFirst = isSelectionFirstNode();
const rect = getDomSelectionRect();
if (isFirst) {
const state = editor.getEditorState();
onNext(state, { reason: "up", cursorX: rect?.x ?? 0 });
event?.preventDefault();
return true;
}
if (rect === undefined) {
return false;
}
const rootNode = $getRoot();
const lastNode = rootNode.getFirstDescendant();
if ($isLineBreakNode(lastNode)) {
return false;
}
const [lineRects] = getTopBottomRects(editor);
const cursorY = rect.y + rect.height / 2;
if (
lineRects.some(
(lineRect) =>
lineRect.left <= rect.x &&
rect.x <= lineRect.right &&
lineRect.top <= cursorY &&
cursorY <= lineRect.bottom
)
) {
const state = editor.getEditorState();
onNext(state, { reason: "up", cursorX: rect?.x ?? 0 });
event?.preventDefault();
return true;
}
// Lexical has a bug where the cursor sometimes stops moving up.
// Slight adjustments fix this issue.
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.modify("move", false, "character");
selection.modify("move", true, "character");
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor, onNext]);
return null;
};
const onError = (error: Error) => {
throw error;
};
@ -199,20 +778,60 @@ type TextEditorProps = {
rootInstanceSelector: InstanceSelector;
instances: Instances;
contentEditable: JSX.Element;
editable?: boolean;
onChange: (instancesList: Instance[]) => void;
onSelectInstance: (instanceId: Instance["id"]) => void;
};
const mod = (n: number, m: number) => {
return ((n % m) + m) % m;
};
const InitialJSONStatePlugin = ({
onInitialState,
}: {
onInitialState: (json: SerializedEditorState) => void;
}) => {
const [editor] = useLexicalComposerContext();
const handleInitialState = useEffectEvent(onInitialState);
useEffect(() => {
handleInitialState(editor.getEditorState().toJSON());
}, [editor, handleInitialState]);
return null;
};
export const TextEditor = ({
rootInstanceSelector,
instances,
contentEditable,
editable,
onChange,
onSelectInstance,
}: TextEditorProps) => {
// class names must be started with letter so we add a prefix
const [paragraphClassName] = useState(() => `a${nanoid()}`);
const [italicClassName] = useState(() => `a${nanoid()}`);
const lastSavedStateJsonRef = useRef<SerializedEditorState | null>(null);
const handleChange = useEffectEvent((editorState: EditorState) => {
editorState.read(() => {
const treeRootInstance = instances.get(rootInstanceSelector[0]);
if (treeRootInstance) {
const jsonState = editorState.toJSON();
if (deepEqual(jsonState, lastSavedStateJsonRef.current)) {
setDataCollapsed(rootInstanceSelector[0], false);
return;
}
onChange($convertToUpdates(treeRootInstance, refs));
lastSavedStateJsonRef.current = jsonState;
}
setDataCollapsed(rootInstanceSelector[0], false);
});
});
useLayoutEffect(() => {
const sheet = createRegularStyleSheet({ name: "text-editor" });
@ -221,6 +840,12 @@ export const TextEditor = ({
sheet.addPlaintextRule(`
.${paragraphClassName} { display: inline-block; margin: 0; }
`);
// fixes the bug on canvas that cursor is not shown on empty elements
sheet.addPlaintextRule(`
.${paragraphClassName}:has(br):not(:has(:not(br))) { min-width: 1px; }
`);
/// set italic style for bold italic combination on the same element
sheet.addPlaintextRule(`
.${italicClassName} { font-style: italic; }
@ -243,6 +868,7 @@ export const TextEditor = ({
italic: italicClassName,
},
},
editable,
editorState: () => {
const [rootInstanceId] = rootInstanceSelector;
// text editor is unmounted when change properties in side panel
@ -254,9 +880,94 @@ export const TextEditor = ({
onError,
};
const handleNext = useCallback(
(state: EditorState, args: HandleNextParams) => {
const rootInstanceId = $selectedPage.get()?.rootInstanceId;
if (rootInstanceId === undefined) {
return;
}
const editableInstanceSelectors: InstanceSelector[] = [];
findAllEditableInstanceSelector(
rootInstanceId,
[],
instances,
$registeredComponentMetas.get(),
editableInstanceSelectors
);
const currentIndex = editableInstanceSelectors.findIndex(
(instanceSelector) => {
return (
instanceSelector[0] === rootInstanceSelector[0] &&
instanceSelector.join(",") === rootInstanceSelector.join(",")
);
}
);
if (currentIndex === -1) {
return;
}
for (let i = 1; i < editableInstanceSelectors.length; i++) {
const nextIndex =
args.reason === "down" || args.reason === "right"
? mod(currentIndex + i, editableInstanceSelectors.length)
: mod(currentIndex - i, editableInstanceSelectors.length);
const nextSelector = editableInstanceSelectors[nextIndex];
const nextInstance = instances.get(nextSelector[0]);
if (nextInstance === undefined) {
continue;
}
const hasExpressionChildren = nextInstance.children.some(
(child) => child.type === "expression"
);
// opinionated: Skip if binded (double click is working)
if (hasExpressionChildren) {
continue;
}
// Skip invisible elements
if (getVisibleElementsByInstanceSelector(nextSelector).length === 0) {
continue;
}
const instance = instances.get(nextSelector[0]);
// opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason).
if (instance?.children.length === 0) {
const elt = getElementByInstanceSelector(nextSelector);
if (elt === undefined) {
continue;
}
if (!elt.hasAttribute(collapsedAttribute)) {
continue;
}
}
handleChange(state);
$textEditingInstanceSelector.set({
selector: editableInstanceSelectors[nextIndex],
...args,
});
$selectedInstanceSelector.set(editableInstanceSelectors[nextIndex]);
break;
}
},
[handleChange, instances, rootInstanceSelector]
);
return (
<LexicalComposer initialConfig={initialConfig}>
<AutofocusPlugin />
<RemoveParagaphsPlugin />
<CaretColorPlugin />
<ToolbarConnectorPlugin
@ -276,14 +987,12 @@ export const TextEditor = ({
<LinkPlugin />
<HistoryPlugin />
<OnChangeOnBlurPlugin
onChange={(editorState) => {
editorState.read(() => {
const treeRootInstance = instances.get(rootInstanceSelector[0]);
if (treeRootInstance) {
onChange($convertToUpdates(treeRootInstance, refs));
}
});
<SwitchBlockPlugin onNext={handleNext} />
<OnChangeOnBlurPlugin onChange={handleChange} />
<InitCursorPlugin />
<InitialJSONStatePlugin
onInitialState={(json) => {
lastSavedStateJsonRef.current = json;
}}
/>
</LexicalComposer>

View File

@ -112,6 +112,11 @@ const getSelectionClienRect = () => {
if (nativeSelection === null) {
return;
}
if (nativeSelection.rangeCount === 0) {
return;
}
const domRange = nativeSelection.getRangeAt(0);
return domRange.getBoundingClientRect();
};

View File

@ -8,7 +8,7 @@ import {
Fragment,
type ReactNode,
} from "react";
import { Suspense, lazy } from "react";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
@ -47,11 +47,8 @@ import {
import { setDataCollapsed } from "~/canvas/collapsed";
import { getIsVisuallyHidden } from "~/shared/visually-hidden";
import { serverSyncStore } from "~/shared/sync";
const TextEditor = lazy(async () => {
const { TextEditor } = await import("../text-editor");
return { default: TextEditor };
});
import { TextEditor } from "../text-editor";
import { $getSelection, $isRangeSelection } from "lexical";
const ContentEditable = ({
renderComponentWithRef,
@ -68,7 +65,7 @@ const ContentEditable = ({
* useLayoutEffect to be sure that editor plugins on useEffect would have access to rootElement
*/
useLayoutEffect(() => {
let rootElement = ref.current;
const rootElement = ref.current;
if (rootElement == null) {
return;
@ -78,24 +75,61 @@ const ContentEditable = ({
return;
}
if (rootElement?.tagName === "BUTTON" || rootElement.tagName === "A") {
// <button> with contentEditable does not let to press space
// <a> stops working with inline-flex when only 1 character left
// so add span inside and use it as editor element in lexical
const span = document.createElement("span");
for (const child of rootElement.childNodes) {
rootElement.removeChild(child);
span.appendChild(child);
if (rootElement.tagName === "A") {
if (window.getComputedStyle(rootElement).display === "inline-flex") {
// Issue: <a> tag doesn't work with inline-flex when the cursor is at the start or end of the text.
// Solution: Inline-flex is not supported by Lexical. Use "inline" during editing.
rootElement.style.display = "inline";
}
rootElement.appendChild(span);
}
rootElement = span;
}
if (rootElement) {
rootElement.contentEditable = "true";
// Issue: <button> with contentEditable does not allow pressing space.
// Solution: Add space on space keydown.
const abortController = new AbortController();
if (rootElement.tagName === "BUTTON") {
rootElement.addEventListener(
"keydown",
(event) => {
if (event.code === "Space") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(" ");
}
});
event.preventDefault();
}
},
{ signal: abortController.signal }
);
// Some controls like Tab and TabTrigger intercept arrow keys for navigation.
// Prevent propagation to avoid conflicts with Lexical's default behavior.
rootElement.addEventListener(
"keydown",
(event) => {
if (["ArrowLeft", "ArrowRight"].includes(event.code)) {
event.stopPropagation();
}
},
{ signal: abortController.signal }
);
}
rootElement.contentEditable = "true";
editor.setRootElement(rootElement);
// Must be done after 'setRootElement' to avoid Lexical's default behavior
// white-space affects "text-wrap", remove it and use "white-space-collapse" instead
rootElement.style.removeProperty("white-space");
rootElement.style.setProperty("white-space-collapse", "pre-wrap");
return () => {
abortController.abort();
};
}, [editor]);
return renderComponentWithRef(ref);
@ -421,52 +455,52 @@ export const WebstudioComponentCanvas = forwardRef<
);
if (
areInstanceSelectorsEqual(textEditingInstanceSelector, instanceSelector) ===
false
areInstanceSelectorsEqual(
textEditingInstanceSelector?.selector,
instanceSelector
) === false
) {
initialContentEditableContent.current = children;
return instanceElement;
}
return (
<Suspense fallback={instanceElement}>
<TextEditor
rootInstanceSelector={instanceSelector}
instances={instances}
contentEditable={
<ContentEditable
renderComponentWithRef={(elementRef) => (
<Component {...props} ref={mergeRefs(ref, elementRef)}>
{initialContentEditableContent.current}
</Component>
)}
/>
}
onChange={(instancesList) => {
serverSyncStore.createTransaction([$instances], (instances) => {
const deletedTreeIds = findTreeInstanceIds(instances, instance.id);
for (const updatedInstance of instancesList) {
instances.set(updatedInstance.id, updatedInstance);
// exclude reused instances
deletedTreeIds.delete(updatedInstance.id);
}
for (const instanceId of deletedTreeIds) {
instances.delete(instanceId);
}
});
}}
onSelectInstance={(instanceId) => {
const instances = $instances.get();
const newSelectedSelector = getInstanceSelector(
instances,
instanceSelector,
instanceId
);
$textEditingInstanceSelector.set(undefined);
$selectedInstanceSelector.set(newSelectedSelector);
}}
/>
</Suspense>
<TextEditor
rootInstanceSelector={instanceSelector}
instances={instances}
contentEditable={
<ContentEditable
renderComponentWithRef={(elementRef) => (
<Component {...props} ref={mergeRefs(ref, elementRef)}>
{initialContentEditableContent.current}
</Component>
)}
/>
}
onChange={(instancesList) => {
serverSyncStore.createTransaction([$instances], (instances) => {
const deletedTreeIds = findTreeInstanceIds(instances, instance.id);
for (const updatedInstance of instancesList) {
instances.set(updatedInstance.id, updatedInstance);
// exclude reused instances
deletedTreeIds.delete(updatedInstance.id);
}
for (const instanceId of deletedTreeIds) {
instances.delete(instanceId);
}
});
}}
onSelectInstance={(instanceId) => {
const instances = $instances.get();
const newSelectedSelector = getInstanceSelector(
instances,
instanceSelector,
instanceId
);
$textEditingInstanceSelector.set(undefined);
$selectedInstanceSelector.set(newSelectedSelector);
}}
/>
);
});

View File

@ -77,7 +77,12 @@ export const subscribeInstanceSelection = ({
// enable text editor when double click on its instance or one of its descendants
if (editableInstanceSelector) {
$selectedInstanceSelector.set(editableInstanceSelector);
$textEditingInstanceSelector.set(editableInstanceSelector);
$textEditingInstanceSelector.set({
selector: editableInstanceSelector,
reason: "click",
mouseX: event.clientX,
mouseY: event.clientY,
});
}
}
};

View File

@ -46,7 +46,10 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
// the canvas element may be unfocused, so it's important to focus the element on the canvas.
element.focus();
$selectedInstanceSelector.set(editableInstanceSelector);
$textEditingInstanceSelector.set(editableInstanceSelector);
$textEditingInstanceSelector.set({
selector: editableInstanceSelector,
reason: "enter",
});
},
},

View File

@ -2,11 +2,11 @@ import { nanoid } from "nanoid";
import { toast } from "@webstudio-is/design-system";
import { equalMedia, type StyleValue } from "@webstudio-is/css-engine";
import {
type Instance,
type Instances,
type StyleSource,
getStyleDeclKey,
findTreeInstanceIds,
Instance,
StyleSourceSelection,
StyleDecl,
Asset,
@ -162,6 +162,78 @@ export const getInstanceLabel = (
);
};
const isTextEditingInstance = (
instance: Instance,
instances: Instances,
metas: Map<string, WsComponentMeta>
) => {
// when start editing empty body all text content
// including style and scripts appear in editor
// assume body is root and stop checking further
if (instance.component === "Body") {
return false;
}
const meta = metas.get(instance.component);
if (meta === undefined) {
return false;
}
if (meta.type !== "container") {
return false;
}
// only container with rich-text-child children and text can be edited
for (const child of instance.children) {
if (child.type === "id") {
const childInstance = instances.get(child.value);
if (childInstance === undefined) {
return;
}
const childMeta = metas.get(childInstance.component);
if (childMeta?.type !== "rich-text-child") {
return;
}
}
}
return true;
};
export const findAllEditableInstanceSelector = (
instanceId: string,
currentPath: InstanceSelector,
instances: Map<string, Instance>,
metas: Map<string, WsComponentMeta>,
results: InstanceSelector[]
) => {
const instance = instances.get(instanceId);
if (instance === undefined) {
return;
}
// Check if current instance is text editing instance
if (isTextEditingInstance(instance, instances, metas)) {
results.push([instanceId, ...currentPath]);
return;
}
// If not, traverse its children
for (const child of instance.children) {
if (child.type === "id") {
findAllEditableInstanceSelector(
child.value,
[instanceId, ...currentPath],
instances,
metas,
results
);
}
}
return null;
};
export const findClosestEditableInstanceSelector = (
instanceSelector: InstanceSelector,
instances: Instances,
@ -172,33 +244,10 @@ export const findClosestEditableInstanceSelector = (
if (instance === undefined) {
return;
}
// when start editing empty body all text content
// including style and scripts appear in editor
// assume body is root and stop checking further
if (instance.component === "Body") {
return;
if (isTextEditingInstance(instance, instances, metas)) {
return getAncestorInstanceSelector(instanceSelector, instanceId);
}
const meta = metas.get(instance.component);
if (meta === undefined) {
return;
}
if (meta.type !== "container") {
continue;
}
// only container with rich-text-child children and text can be edited
for (const child of instance.children) {
if (child.type === "id") {
const childInstance = instances.get(child.value);
if (childInstance === undefined) {
return;
}
const childMeta = metas.get(childInstance.component);
if (childMeta?.type !== "rich-text-child") {
return;
}
}
}
return getAncestorInstanceSelector(instanceSelector, instanceId);
}
};

View File

@ -15,7 +15,22 @@ export const $editingItemSelector = atom<undefined | InstanceSelector>(
);
export const $textEditingInstanceSelector = atom<
undefined | InstanceSelector
| undefined
| {
selector: InstanceSelector;
reason: "right" | "left" | "enter";
}
| {
selector: InstanceSelector;
reason: "click";
mouseX: number;
mouseY: number;
}
| {
selector: InstanceSelector;
reason: "up" | "down";
cursorX: number;
}
>();
export const $instances = atom<Instances>(new Map());

View File

@ -72,6 +72,11 @@ export const createPubsub = <PublishMap>() => {
const unwrapAction = (payload: unknown) => {
if (typeof payload !== "object" || payload === null) {
if (process.env.IS_STROYBOOK) {
return { type: "storybook", payload: payload } as Action<
keyof PublishMap
>;
}
console.error("Invalid payload", payload);
throw new Error("Invalid payload");
}

View File

@ -5,6 +5,8 @@
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
white-space: pre-wrap;
white-space-collapse: preserve;
}
:where(body.w-body) {
box-sizing: border-box;

View File

@ -5,6 +5,8 @@
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
white-space: pre-wrap;
white-space-collapse: preserve;
}
:where(body.w-body) {
box-sizing: border-box;

View File

@ -5,6 +5,8 @@
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
white-space: pre-wrap;
white-space-collapse: preserve;
}
:where(body.w-body) {
box-sizing: border-box;

View File

@ -13,6 +13,8 @@
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
white-space: pre-wrap;
white-space-collapse: preserve;
}
:where(body.w-body) {
box-sizing: border-box;
@ -199,6 +201,8 @@
}
:where(div.w-html-embed) {
display: contents;
white-space: normal;
white-space-collapse: collapse;
}
:where(div.w-spinner) {
box-sizing: border-box;

View File

@ -5,6 +5,8 @@
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
white-space: pre-wrap;
white-space-collapse: preserve;
}
:where(body.w-body) {
box-sizing: border-box;

View File

@ -5,6 +5,8 @@
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
white-space: pre-wrap;
white-space-collapse: preserve;
}
:where(body.w-body) {
box-sizing: border-box;

View File

@ -5,6 +5,8 @@
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
white-space: pre-wrap;
white-space-collapse: preserve;
}
:where(body.w-body) {
box-sizing: border-box;
@ -222,6 +224,8 @@
}
:where(div.w-html-embed) {
display: contents;
white-space: normal;
white-space-collapse: collapse;
}
:where(div.w-item-content) {
box-sizing: border-box;

View File

@ -432,7 +432,6 @@ export const TreeNode = ({
// scroll the selected button into view when selected from canvas.
useEffect(() => {
if (isSelected) {
buttonRef.current?.focus();
buttonRef.current?.scrollIntoView({
// smooth behavior in both canvas and navigator confuses chrome
behavior: "auto",
@ -441,7 +440,9 @@ export const TreeNode = ({
}
}, [isSelected]);
const handleKeydown = (event: KeyboardEvent) => {
const handleKeydown = (event: KeyboardEvent<HTMLDivElement>) => {
nodeProps?.onKeyDown?.(event);
if (event.defaultPrevented) {
return;
}

View File

@ -12,6 +12,10 @@ const presetStyle = {
property: "display",
value: { type: "keyword", value: "contents" },
},
{
property: "whiteSpaceCollapse",
value: { type: "keyword", value: "collapse" },
},
],
} satisfies PresetStyle<"div">;

View File

@ -67,6 +67,10 @@ export const meta: WsComponentMeta = {
property: "display",
value: { type: "keyword", value: "contents" },
},
{
property: "whiteSpaceCollapse",
value: { type: "keyword", value: "collapse" },
},
],
},
order: 4,

View File

@ -88,6 +88,10 @@ export const html: StyleDecl[] = [
property: "lineHeight",
value: { type: "unit", unit: "number", value: 1.2 },
},
{
property: "whiteSpaceCollapse",
value: { type: "keyword", value: "preserve" },
},
];
export const body: StyleDecl[] = [

View File

@ -63,6 +63,7 @@ span {
/**
* 1. Layout source https://twitter.com/ChallengesCss/status/1471128244720181258
* 2. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
* 3. For visual editors
*/
html {
/* 1 */
@ -72,6 +73,14 @@ html {
font-family: Arial, Roboto, sans-serif;
font-size: 16px;
line-height: 1.2;
/* webstudio custom opinionated preset */
/* 3. We decided to use preserve in visual builders:
Preserves multiple spaces & trailing spaces,
Matches what users see while editing to final output (Provides more predictable WYSIWYG experience)
vs text editors' collapse (default): Normalizes multiple spaces into one, Removes trailing whitespace, Better for clean text content
*/
white-space-collapse: preserve;
}
/**