Skip to content

Commit

Permalink
⏸ [0.7] Add block emulated cursors (facebook#3434)
Browse files Browse the repository at this point in the history
  • Loading branch information
trueadm authored Dec 7, 2022
1 parent b8925d4 commit 6b7d828
Show file tree
Hide file tree
Showing 17 changed files with 330 additions and 51 deletions.
24 changes: 16 additions & 8 deletions packages/lexical-playground/__tests__/e2e/CopyAndPaste.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2851,6 +2851,7 @@ test.describe('CopyAndPaste', () => {
test('HTML Copy + paste a paragraph element between horizontal rules', async ({
page,
isPlainText,
isCollab,
}) => {
test.skip(isPlainText);

Expand All @@ -2859,14 +2860,21 @@ test.describe('CopyAndPaste', () => {
let clipboard = {'text/html': '<hr/><hr/>'};

await pasteFromClipboard(page, clipboard);
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph"><br /></p>
<hr class="" contenteditable="false" data-lexical-decorator="true" />
<hr class="" contenteditable="false" data-lexical-decorator="true" />
`,
);
// Collab doesn't process the cursor correctly
if (!isCollab) {
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph"><br /></p>
<hr class="" contenteditable="false" data-lexical-decorator="true" />
<hr class="" contenteditable="false" data-lexical-decorator="true" />
<div
class="PlaygroundEditorTheme__blockCursor"
contenteditable="false"
data-lexical-cursor="true"></div>
`,
);
}
await click(page, 'hr:first-of-type');

// sets focus between HRs
Expand Down
33 changes: 23 additions & 10 deletions packages/lexical-playground/__tests__/e2e/HorizontalRule.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test.describe('HorizontalRule', () => {
test.beforeEach(({isCollab, page}) => initialize({isCollab, page}));
test('Can create a horizontal rule and move selection around it', async ({
page,
isCollab,
isPlainText,
browserName,
}) => {
Expand Down Expand Up @@ -139,17 +140,29 @@ test.describe('HorizontalRule', () => {

await pressBackspace(page, 10);

await assertHTML(
page,
'<hr class="" data-lexical-decorator="true" contenteditable="false"><p class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true">Some more text</span></p>',
);
// Collab doesn't process the cursor correctly
if (!isCollab) {
await assertHTML(
page,
'<div class="PlaygroundEditorTheme__blockCursor" contenteditable="false" data-lexical-cursor="true"></div><hr class="" data-lexical-decorator="true" contenteditable="false"><p class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" dir="ltr"><span data-lexical-text="true">Some more text</span></p>',
);
}

await assertSelection(page, {
anchorOffset: 0,
anchorPath: [],
focusOffset: 0,
focusPath: [],
});
if (browserName === 'webkit') {
await assertSelection(page, {
anchorOffset: 1,
anchorPath: [],
focusOffset: 1,
focusPath: [],
});
} else {
await assertSelection(page, {
anchorOffset: 0,
anchorPath: [],
focusOffset: 0,
focusPath: [],
});
}
});

test('Will add a horizontal rule at the end of a current TextNode and move selection to the new ParagraphNode.', async ({
Expand Down
22 changes: 22 additions & 0 deletions packages/lexical-playground/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1595,3 +1595,25 @@ hr.selected {
word-break: break-word;
z-index: 3;
}

.PlaygroundEditorTheme__blockCursor {
display: block;
pointer-events: none;
position: absolute;
}

.PlaygroundEditorTheme__blockCursor:after {
content: '';
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: CursorBlink 1.1s steps(2, start) infinite;
}

@keyframes CursorBlink {
to {
visibility: hidden;
}
}
4 changes: 4 additions & 0 deletions packages/lexical-playground/src/nodes/TableNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@ export class TableNode extends DecoratorNode<JSX.Element> {
</Suspense>
);
}

isInline(): false {
return false;
}
}

export function $isTableNode(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {EditorThemeClasses} from 'lexical';
import './PlaygroundEditorTheme.css';

const theme: EditorThemeClasses = {
blockCursor: 'PlaygroundEditorTheme__blockCursor',
characterLimit: 'PlaygroundEditorTheme__characterLimit',
code: 'PlaygroundEditorTheme__code',
codeHighlight: {
Expand Down
44 changes: 39 additions & 5 deletions packages/lexical-rich-text/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ import {
$applyNodeReplacement,
$createParagraphNode,
$createRangeSelection,
$getAdjacentNode,
$getNearestNodeFromDOMNode,
$getRoot,
$getSelection,
$isDecoratorNode,
$isElementNode,
$isNodeSelection,
$isRangeSelection,
$isRootNode,
Expand Down Expand Up @@ -459,11 +462,16 @@ function handleIndentAndOutdent(
}
}

function isTargetWithinDecorator(target: HTMLElement): boolean {
function $isTargetWithinDecorator(target: HTMLElement): boolean {
const node = $getNearestNodeFromDOMNode(target);
return $isDecoratorNode(node);
}

function $isSelectionAtEndOfRoot(selection: RangeSelection) {
const focus = selection.focus;
return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
}

export function registerRichText(editor: LexicalEditor): () => void {
const removeListener = mergeRegister(
editor.registerCommand(
Expand Down Expand Up @@ -660,7 +668,7 @@ export function registerRichText(editor: LexicalEditor): () => void {
const selection = $getSelection();
if (
$isNodeSelection(selection) &&
!isTargetWithinDecorator(event.target as HTMLElement)
!$isTargetWithinDecorator(event.target as HTMLElement)
) {
// If selection is on a node, let's try and move selection
// back to being a range selection.
Expand All @@ -669,6 +677,21 @@ export function registerRichText(editor: LexicalEditor): () => void {
nodes[0].selectPrevious();
return true;
}
} else if ($isRangeSelection(selection)) {
const possibleNode = $getAdjacentNode(selection.focus, true);
if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
possibleNode.selectPrevious();
event.preventDefault();
return true;
} else if (
$isElementNode(possibleNode) &&
!possibleNode.isInline() &&
!possibleNode.canBeEmpty()
) {
possibleNode.select();
event.preventDefault();
return true;
}
}
return false;
},
Expand All @@ -686,6 +709,17 @@ export function registerRichText(editor: LexicalEditor): () => void {
nodes[0].selectNext(0, 0);
return true;
}
} else if ($isRangeSelection(selection)) {
if ($isSelectionAtEndOfRoot(selection)) {
event.preventDefault();
return true;
}
const possibleNode = $getAdjacentNode(selection.focus, false);
if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
possibleNode.selectNext();
event.preventDefault();
return true;
}
}
return false;
},
Expand Down Expand Up @@ -724,7 +758,7 @@ export function registerRichText(editor: LexicalEditor): () => void {
const selection = $getSelection();
if (
$isNodeSelection(selection) &&
!isTargetWithinDecorator(event.target as HTMLElement)
!$isTargetWithinDecorator(event.target as HTMLElement)
) {
// If selection is on a node, let's try and move selection
// back to being a range selection.
Expand All @@ -751,7 +785,7 @@ export function registerRichText(editor: LexicalEditor): () => void {
editor.registerCommand<KeyboardEvent>(
KEY_BACKSPACE_COMMAND,
(event) => {
if (isTargetWithinDecorator(event.target as HTMLElement)) {
if ($isTargetWithinDecorator(event.target as HTMLElement)) {
return false;
}
const selection = $getSelection();
Expand Down Expand Up @@ -779,7 +813,7 @@ export function registerRichText(editor: LexicalEditor): () => void {
editor.registerCommand<KeyboardEvent>(
KEY_DELETE_COMMAND,
(event) => {
if (isTargetWithinDecorator(event.target as HTMLElement)) {
if ($isTargetWithinDecorator(event.target as HTMLElement)) {
return false;
}
const selection = $getSelection();
Expand Down
11 changes: 8 additions & 3 deletions packages/lexical-selection/src/range-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
} from 'lexical';

import {
$getDecoratorNode,
$getAdjacentNode,
$getPreviousSelection,
$hasAncestor,
$isDecoratorNode,
Expand Down Expand Up @@ -319,9 +319,14 @@ export function $shouldOverrideDefaultCharacterSelection(
selection: RangeSelection,
isBackward: boolean,
): boolean {
const possibleNode = $getDecoratorNode(selection.focus, isBackward);
const possibleNode = $getAdjacentNode(selection.focus, isBackward);

return $isDecoratorNode(possibleNode) && !possibleNode.isIsolated();
return (
($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) ||
($isElementNode(possibleNode) &&
!possibleNode.isInline() &&
!possibleNode.canBeEmpty())
);
}

export function $moveCaretSelection(
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical/flow/Lexical.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ declare export function $setSelection(
selection: null | RangeSelection | NodeSelection | GridSelection,
): void;
declare export function $nodesOfType<T: LexicalNode>(klass: Class<T>): Array<T>;
declare export function $getDecoratorNode(
declare export function $getAdjacentNode(
focus: Point,
isBackward: boolean,
): null | LexicalNode;
Expand Down
4 changes: 4 additions & 0 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type EditorFocusOptions = {
};

export type EditorThemeClasses = {
blockCursor?: EditorThemeClassName;
characterLimit?: EditorThemeClassName;
code?: EditorThemeClassName;
codeHighlight?: Record<string, EditorThemeClassName>;
Expand Down Expand Up @@ -281,6 +282,7 @@ export function resetEditor(
editor._normalizedNodes = new Set();
editor._updateTags = new Set();
editor._updates = [];
editor._blockCursorElement = null;

const observer = editor._observer;

Expand Down Expand Up @@ -492,6 +494,7 @@ export class LexicalEditor {
_htmlConversions: DOMConversionCache;
_window: null | Window;
_editable: boolean;
_blockCursorElement: null | HTMLDivElement;

constructor(
editorState: EditorState,
Expand Down Expand Up @@ -553,6 +556,7 @@ export class LexicalEditor {
this._editable = true;
this._headless = parentEditor !== null && parentEditor._headless;
this._window = null;
this._blockCursorElement = null;
}

isComposing(): boolean {
Expand Down
3 changes: 2 additions & 1 deletion packages/lexical/src/LexicalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1114,12 +1114,13 @@ export function addRootElementEvents(
event as FocusEvent,
);

case 'blur':
case 'blur': {
return dispatchCommand(
editor,
BLUR_COMMAND,
event as FocusEvent,
);
}

case 'drop':
return dispatchCommand(
Expand Down
7 changes: 5 additions & 2 deletions packages/lexical/src/LexicalMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function $flushMutations(
// We use the current editor state, as that reflects what is
// actually "on screen".
const currentEditorState = editor._editorState;
const blockCursorElement = editor._blockCursorElement;
let shouldRevertSelection = false;
let possibleTextForFirefoxPaste = '';

Expand Down Expand Up @@ -179,6 +180,7 @@ export function $flushMutations(

if (
parentDOM != null &&
addedDOM !== blockCursorElement &&
node === null &&
(addedDOM.nodeName !== 'BR' ||
!isManagedLineBreak(addedDOM, parentDOM, editor))
Expand Down Expand Up @@ -206,8 +208,9 @@ export function $flushMutations(
const removedDOM = removedDOMs[s];

if (
removedDOM.nodeName === 'BR' &&
isManagedLineBreak(removedDOM, targetDOM, editor)
(removedDOM.nodeName === 'BR' &&
isManagedLineBreak(removedDOM, targetDOM, editor)) ||
blockCursorElement === removedDOM
) {
targetDOM.appendChild(removedDOM);
unremovedBRs++;
Expand Down
9 changes: 8 additions & 1 deletion packages/lexical/src/LexicalReconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,14 @@ function getFirstChild(element: HTMLElement): Node | null {
}

function getNextSibling(element: HTMLElement): Node | null {
return element.nextSibling;
let nextSibling = element.nextSibling;
if (
nextSibling !== null &&
nextSibling === activeEditor._blockCursorElement
) {
nextSibling = nextSibling.nextSibling;
}
return nextSibling;
}

function reconcileNodeChildren(
Expand Down
Loading

0 comments on commit 6b7d828

Please sign in to comment.