Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {
$caretRangeFromSelection,
$getCaretRange,
$getCaretRangeInDirection,
$getChildCaret,
$getEditor,
$getPreviousSelection,
$getSelection,
$getSiblingCaret,
$isChildCaret,
$isElementNode,
$isLineBreakNode,
$isRangeSelection,
$isSiblingCaret,
$isTextPointCaret,
$normalizeCaret,
$rewindSiblingCaret,
$setSelectionFromCaretRange,
$updateDOMSelection,
COMMAND_PRIORITY_BEFORE_CRITICAL,
defineExtension,
getDOMSelection,
mergeRegister,
safeCast,
SELECTION_CHANGE_COMMAND,
SKIP_SCROLL_INTO_VIEW_TAG,
SKIP_SELECTION_FOCUS_TAG,
} from 'lexical';

import {namedSignals} from './namedSignals';
import {effect, type Signal} from './signals';

export interface NormalizeTripleClickSelectionConfig {
/** `true` to disable this extension */
disabled: boolean;
/** The maximum number of msec from the triple click to expect a selection change, default `100` */
thresholdMsec: number;
/** The clock function used for delay-based merging, default `Date.now` */
dateNow: () => number;
/** The update function to call when triple click is detected */
$fixFocusOverselection: () => void;
}

export interface NormalizeTripleClickSelectionOutput {
/** `true` to disable this extension */
disabled: Signal<boolean>;
/** The maximum number of msec from the triple click to expect a selection change, default `100` */
thresholdMsec: Signal<number>;
/** The clock function used for delay-based merging, default `Date.now` */
dateNow: Signal<() => number>;
/** The update function to call when triple click is detected */
$fixFocusOverselection: Signal<() => void>;
}

const SKIP_TAGS = new Set([
SKIP_SELECTION_FOCUS_TAG,
SKIP_SCROLL_INTO_VIEW_TAG,
]);

function $fixFocusOverselection() {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
if (!selection.isCollapsed()) {
// Triple click causing selection to overflow into the nearest element. In that
// case visually it looks like a single element content is selected, focus node
// is actually at the beginning of the next element (if present) and any manipulations
// with selection (formatting) are affecting second element as well
const range = $getCaretRangeInDirection(
$caretRangeFromSelection(selection),
'next',
);
let focusCaret = range.focus;
// Move it out of the next TextNode if none of it is selected
if (
$isTextPointCaret(focusCaret) &&
range.anchor.origin !== focusCaret.origin &&
focusCaret.offset === 0
) {
focusCaret = $rewindSiblingCaret(focusCaret.getSiblingCaret());
}
// Move it behind a single LineBreakNode
if (
$isSiblingCaret(focusCaret) &&
range.anchor.origin !== focusCaret.origin &&
$isLineBreakNode(focusCaret.origin)
) {
focusCaret = $rewindSiblingCaret(focusCaret);
}
// Move the focus out of the start of any elements
while (
$isChildCaret(focusCaret) &&
range.anchor.origin !== focusCaret.origin
) {
focusCaret = $rewindSiblingCaret(
$getSiblingCaret(focusCaret.origin, 'next'),
);
}
// Move it inside the containing element
if ($isSiblingCaret(focusCaret) && $isElementNode(focusCaret.origin)) {
focusCaret = $normalizeCaret(
$getChildCaret(focusCaret.origin, 'previous'),
).getFlipped();
}
focusCaret = $normalizeCaret(focusCaret);
if (!focusCaret.isSamePointCaret(range.focus)) {
const sel = $setSelectionFromCaretRange(
$getCaretRange(range.anchor, focusCaret),
);
const editor = $getEditor();
const rootElement = editor.getRootElement();
const domSelection =
rootElement && getDOMSelection(rootElement.ownerDocument.defaultView);
if (domSelection) {
$updateDOMSelection(
$getPreviousSelection(),
sel,
$getEditor(),
domSelection,
SKIP_TAGS,
rootElement,
);
}
}
}
}

/**
* This extension handles triple-click events and will move the focus
* towards the anchor in certain conditions to meet expectations.
* Simply speaking, the focus should prefer to land at the end of a node
* rather than the beginning of its next sibling, and it should not skip
* over a LineBreakNode.
*
* In order to fix the result visually and avoid a flash of over-selection
* it will also eagerly manipulate the DOM selection directly.
*
* It is conservative in that it only fires this
* `$fixFocusOverselection` callback when it has detected a triple click,
* but it provides the function as an output signal so that it can both
* be called from other places and it can be replaced or wrapped with
* different functionality.
*/
export const NormalizeTripleClickSelectionExtension = defineExtension({
build: (editor, config, state): NormalizeTripleClickSelectionOutput =>
namedSignals(config),
config: safeCast<NormalizeTripleClickSelectionConfig>({
$fixFocusOverselection,
dateNow: Date.now,
disabled: false,
thresholdMsec: 100,
}),
name: '@lexical/NormalizeTripleClickSelection',
register: (editor, config, state) =>
effect(() => {
const stores = state.getOutput();
if (stores.disabled.value) {
return;
}
return editor.registerRootListener(rootElement => {
if (!rootElement) {
return;
}
let lastTripleClick = 0;
const refreshTripleClick = (event: null | MouseEvent) => {
if (event ? event.detail === 3 : lastTripleClick > 0) {
const now = stores.dateNow.peek()();
lastTripleClick =
(event && event.type === 'mousedown') ||
now - lastTripleClick <= stores.thresholdMsec.peek()
? now
: 0;
}
return lastTripleClick;
};
return mergeRegister(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
if (refreshTripleClick(null)) {
lastTripleClick = 0;
stores.$fixFocusOverselection.peek()();
}
return false;
},
COMMAND_PRIORITY_BEFORE_CRITICAL,
),
(() => {
const events = ['mouseup', 'mousedown'] as const;
events.forEach(v =>
rootElement.addEventListener(v, refreshTripleClick, true),
);
return () =>
events.forEach(v =>
rootElement.removeEventListener(v, refreshTripleClick, true),
);
})(),
);
});
}),
});
5 changes: 5 additions & 0 deletions packages/lexical-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export {
type NormalizeInlineElementsConfig,
NormalizeInlineElementsExtension,
} from './NormalizeInlineElementsExtension';
export {
type NormalizeTripleClickSelectionConfig,
NormalizeTripleClickSelectionExtension,
type NormalizeTripleClickSelectionOutput,
} from './NormalizeTripleClickSelectionExtension';
export {SelectionAlwaysOnDisplayExtension} from './SelectionAlwaysOnDisplayExtension';
export {
batch,
Expand Down
11 changes: 9 additions & 2 deletions packages/lexical-plain-text/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import {
$writeDragSourceToDataTransfer,
} from '@lexical/clipboard';
import {DragonExtension} from '@lexical/dragon';
import {NormalizeInlineElementsExtension} from '@lexical/extension';
import {
NormalizeInlineElementsExtension,
NormalizeTripleClickSelectionExtension,
} from '@lexical/extension';
import {
$moveCharacter,
$shouldOverrideDefaultCharacterSelection,
Expand Down Expand Up @@ -423,7 +426,11 @@ export function registerPlainText(editor: LexicalEditor): () => void {
*/
export const PlainTextExtension = defineExtension({
conflictsWith: ['@lexical/rich-text'],
dependencies: [DragonExtension, NormalizeInlineElementsExtension],
dependencies: [
DragonExtension,
NormalizeInlineElementsExtension,
NormalizeTripleClickSelectionExtension,
],
name: '@lexical/plain-text',
register: registerPlainText,
});
Loading
Loading