mirror of
https://github.com/webstudio-is/webstudio.git
synced 2025-03-14 09:57:02 +00:00
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:
@ -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,
|
||||
|
@ -23,7 +23,7 @@ export const SelectedInstanceOutline = () => {
|
||||
const isEditingCurrentInstance =
|
||||
textEditingInstanceSelector !== undefined &&
|
||||
areInstanceSelectorsEqual(
|
||||
textEditingInstanceSelector,
|
||||
textEditingInstanceSelector.selector,
|
||||
selectedInstanceSelector
|
||||
);
|
||||
|
||||
|
@ -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"),
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -112,6 +112,11 @@ const getSelectionClienRect = () => {
|
||||
if (nativeSelection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nativeSelection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domRange = nativeSelection.getRangeAt(0);
|
||||
return domRange.getBoundingClientRect();
|
||||
};
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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());
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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;
|
||||
|
2
fixtures/ssg/app/__generated__/index.css
generated
2
fixtures/ssg/app/__generated__/index.css
generated
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -12,6 +12,10 @@ const presetStyle = {
|
||||
property: "display",
|
||||
value: { type: "keyword", value: "contents" },
|
||||
},
|
||||
{
|
||||
property: "whiteSpaceCollapse",
|
||||
value: { type: "keyword", value: "collapse" },
|
||||
},
|
||||
],
|
||||
} satisfies PresetStyle<"div">;
|
||||
|
||||
|
@ -67,6 +67,10 @@ export const meta: WsComponentMeta = {
|
||||
property: "display",
|
||||
value: { type: "keyword", value: "contents" },
|
||||
},
|
||||
{
|
||||
property: "whiteSpaceCollapse",
|
||||
value: { type: "keyword", value: "collapse" },
|
||||
},
|
||||
],
|
||||
},
|
||||
order: 4,
|
||||
|
4
packages/sdk/src/__generated__/normalize.css.ts
generated
4
packages/sdk/src/__generated__/normalize.css.ts
generated
@ -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[] = [
|
||||
|
9
packages/sdk/src/normalize.css
vendored
9
packages/sdk/src/normalize.css
vendored
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user