From 9cd832bfa915a9e445fe1255f6f8a9d0013a57f7 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 30 Aug 2026 23:19:02 +0200 Subject: [PATCH] fix(app): let backspace cross a composer soft-wrap boundary (#2191) --- .../composer/ComposerSoftWrapDeletion.ts | 113 ++++++++++++++++++ .../lexical/composer/LexicalComposerInput.tsx | 2 + 2 files changed, 115 insertions(+) create mode 100644 fluxer_app/src/features/lexical/composer/ComposerSoftWrapDeletion.ts diff --git a/fluxer_app/src/features/lexical/composer/ComposerSoftWrapDeletion.ts b/fluxer_app/src/features/lexical/composer/ComposerSoftWrapDeletion.ts new file mode 100644 index 000000000..bce708209 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerSoftWrapDeletion.ts @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + $getNearestNodeFromDOMNode, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_HIGH, + DELETE_CHARACTER_COMMAND, + getDOMSelection, + getDOMTextNode, + isDOMTextNode, + type LexicalEditor, + type LexicalNode, + type TextNode, +} from 'lexical'; + +interface DOMCaret { + node: Node; + offset: number; +} + +function readDOMCaret(domSelection: Selection): DOMCaret | null { + const node = domSelection.anchorNode; + return node == null ? null : {node, offset: domSelection.anchorOffset}; +} + +function isSameDOMCaret(left: DOMCaret, right: DOMCaret): boolean { + return left.node === right.node && left.offset === right.offset; +} + +function $isPlainTextNode(node: LexicalNode | null | undefined): node is TextNode { + return $isTextNode(node) && !node.isSegmented() && !node.isToken(); +} + +function $textNodeAtDOMCaret(caret: DOMCaret): TextNode | null { + if (!isDOMTextNode(caret.node)) { + return null; + } + const node = $getNearestNodeFromDOMNode(caret.node); + return $isPlainTextNode(node) ? node : null; +} + +function $deleteCharacterAcrossSoftWrap(editor: LexicalEditor, isBackward: boolean): boolean { + if (!isBackward || editor.isComposing()) { + return false; + } + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false; + } + const anchor = selection.anchor; + if (anchor.type !== 'text') { + return false; + } + const anchorNode = anchor.getNode(); + if (!$isPlainTextNode(anchorNode)) { + return false; + } + const rootElement = editor.getRootElement(); + const domSelection = getDOMSelection(rootElement == null ? null : rootElement.ownerDocument.defaultView); + if (domSelection == null || typeof domSelection.modify !== 'function') { + return false; + } + const anchorElement = editor.getElementByKey(anchor.key); + const anchorDOM = anchorElement == null ? null : getDOMTextNode(anchorElement); + if (anchorDOM == null) { + return false; + } + const origin: DOMCaret = {node: anchorDOM, offset: anchor.offset}; + const collapseAtOrigin = () => { + domSelection.setBaseAndExtent(origin.node, origin.offset, origin.node, origin.offset); + }; + collapseAtOrigin(); + domSelection.modify('move', 'backward', 'character'); + const afterFirstMove = readDOMCaret(domSelection); + if (afterFirstMove == null || !isSameDOMCaret(afterFirstMove, origin)) { + collapseAtOrigin(); + return false; + } + domSelection.modify('move', 'backward', 'character'); + const landed = readDOMCaret(domSelection); + collapseAtOrigin(); + if (landed == null || isSameDOMCaret(landed, origin)) { + return false; + } + const landedNode = $textNodeAtDOMCaret(landed); + if (landedNode == null || landed.offset > landedNode.getTextContentSize()) { + return false; + } + const anchorParent = anchorNode.getParent(); + const landedParent = landedNode.getParent(); + if (anchorParent == null || landedParent == null || !anchorParent.is(landedParent)) { + return false; + } + if (landedNode.is(anchorNode) && landed.offset >= anchor.offset) { + return false; + } + selection.focus.set(landedNode.getKey(), landed.offset, 'text'); + if (selection.isCollapsed()) { + return false; + } + selection.removeText(); + return true; +} + +export function registerComposerSoftWrapDeletion(editor: LexicalEditor): () => void { + return editor.registerCommand( + DELETE_CHARACTER_COMMAND, + (isBackward) => $deleteCharacterAcrossSoftWrap(editor, isBackward), + COMMAND_PRIORITY_HIGH, + ); +} diff --git a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx index 6f22bc6fb..b47a4aff5 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx @@ -15,6 +15,7 @@ import {registerComposerMarkdownShortcuts} from '@app/features/lexical/composer/ import {ComposerMentionContext} from '@app/features/lexical/composer/ComposerMentionContext'; import {registerComposerPlainText} from '@app/features/lexical/composer/ComposerPlainText'; import {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization'; +import {registerComposerSoftWrapDeletion} from '@app/features/lexical/composer/ComposerSoftWrapDeletion'; import { type ComposerTypeaheadActiveState, registerComposerTypeaheadModifierGuard, @@ -548,6 +549,7 @@ const ComposerInner = ({ return mergeRegister( registerContextMenuUndoRedo(editor), registerComposerIMECommandGuard(editor), + registerComposerSoftWrapDeletion(editor), registerComposerTypeaheadModifierGuard(editor, typeaheadActiveState), registerComposerMarkdownShortcuts(editor), registerComposerCodeIndent(editor, typeaheadActiveState),