diff --git a/fluxer_app/src/features/channel/components/UserMessage.tsx b/fluxer_app/src/features/channel/components/UserMessage.tsx index 57b454202..9ce5e9fe6 100644 --- a/fluxer_app/src/features/channel/components/UserMessage.tsx +++ b/fluxer_app/src/features/channel/components/UserMessage.tsx @@ -19,6 +19,7 @@ import Emoji from '@app/features/emoji/state/Emoji'; import {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils'; import Guilds from '@app/features/guild/state/Guilds'; import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; +import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines'; import GuildMembers from '@app/features/member/state/GuildMembers'; import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; import {SafeMarkdown} from '@app/features/messaging/components/markdown'; @@ -193,7 +194,7 @@ export const UserMessage = observer(() => { if (message.messageSnapshots) { return; } - const content = (actualContent ?? '').trim(); + const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim(); if (!content) { if (canSubmitEmptyMessageEdit(message)) { if (message.content.length === 0) { diff --git a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/ChannelOverviewTopicEditor.tsx b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/ChannelOverviewTopicEditor.tsx index 4dc3019fb..844fa718d 100644 --- a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/ChannelOverviewTopicEditor.tsx +++ b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/ChannelOverviewTopicEditor.tsx @@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet'; import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout'; +import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines'; import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput'; import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes'; import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils'; @@ -145,7 +146,7 @@ export const ChannelOverviewTopicEditor = observer( }); }, [actualTopic, form, isTopicInitialized]); const handleTopicChange = useCallback((_display: string, _segments: Array, wire: string) => { - setActualTopic(wire); + setActualTopic(dropTrailingEmptyBlockquoteLines(wire)); }, []); const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => { const composer = composerRef.current; diff --git a/fluxer_app/src/features/lexical/composer/ComposerBlockquote.test.ts b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.test.ts new file mode 100644 index 000000000..78e2d8803 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.test.ts @@ -0,0 +1,1012 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {BlockquoteLine} from '@app/features/lexical/composer/blockquoteLines'; +import {registerComposerBlockquote} from '@app/features/lexical/composer/ComposerBlockquote'; +import {$insertComposerClipboardSlice} from '@app/features/lexical/composer/ComposerClipboard'; +import {registerComposerEmojiShortcode} from '@app/features/lexical/composer/ComposerEmojiShortcode'; +import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight'; +import {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization'; +import {registerComposerSoftWrapDeletion} from '@app/features/lexical/composer/ComposerSoftWrapDeletion'; +import {$getComposerBlockquoteState, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets'; +import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans'; +import { + $isComposerBlockquoteLineNode, + ComposerBlockquoteLineNode, +} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; +import { + $isComposerBlockquoteMarkerNode, + ComposerBlockquoteMarkerNode, +} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode'; +import {ComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode'; +import { + $isComposerCustomEmojiNode, + ComposerCustomEmojiNode, +} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode'; +import {$isComposerMentionNode, ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode'; +import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode'; +import {ComposerStandardEmojiNode} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode'; +import {SlashOptionalHintNode} from '@app/features/lexical/composer/nodes/SlashOptionalHintNode'; +import {SlashSeparatorNode} from '@app/features/lexical/composer/nodes/SlashSeparatorNode'; +import {SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode'; +import {SlashSlotPlaceholderNode} from '@app/features/lexical/composer/nodes/SlashSlotPlaceholderNode'; +import {$createSyntaxMarkerNode, SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode'; +import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums'; +import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; +import {createEmptyHistoryState, registerHistory} from '@lexical/history'; +import { + $createParagraphNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isElementNode, + $isLineBreakNode, + $isRangeSelection, + $isTextNode, + $setCompositionKey, + COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_HIGH, + COMPOSITION_START_COMMAND, + CONTROLLED_TEXT_INSERTION_COMMAND, + createEditor, + DELETE_CHARACTER_COMMAND, + INSERT_LINE_BREAK_COMMAND, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_LEFT_COMMAND, + type LexicalCommand, + type LexicalEditor, + REDO_COMMAND, + SELECTION_CHANGE_COMMAND, + UNDO_COMMAND, +} from 'lexical'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null})); +vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null})); +vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null})); +vi.mock('@app/features/lexical/composer/nodes/SlashOptionalHintPill', () => ({SlashOptionalHintPill: () => null})); +vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({getEmojiURL: () => null})); +vi.mock('@lingui/core/macro', () => ({msg: (descriptor: unknown) => descriptor})); + +const NODES = [ + ComposerMentionNode, + ComposerCustomEmojiNode, + ComposerStandardEmojiNode, + ComposerPlainSegmentNode, + ComposerCommandNode, + SlashSlotNode, + SlashSlotPlaceholderNode, + SlashSeparatorNode, + SlashOptionalHintNode, + SyntaxMarkerNode, + ComposerBlockquoteLineNode, + ComposerBlockquoteMarkerNode, +]; + +const NO_BLOCKQUOTE_FLAGS = + DEFAULT_COMPOSER_MARKDOWN_FLAGS & ~(ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES); +const NO_MULTILINE_FLAGS = DEFAULT_COMPOSER_MARKDOWN_FLAGS & ~ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES; +const BIO_LIKE_FLAGS = + DEFAULT_COMPOSER_MARKDOWN_FLAGS & + ~( + ParserFlags.ALLOW_HEADINGS | + ParserFlags.ALLOW_CODE_BLOCKS | + ParserFlags.ALLOW_ROLE_MENTIONS | + ParserFlags.ALLOW_EVERYONE_MENTIONS | + ParserFlags.ALLOW_SUBTEXT | + ParserFlags.ALLOW_TABLES | + ParserFlags.ALLOW_ALERTS + ); + +const COMPOSITION_EVENT = {timeStamp: Number.MAX_SAFE_INTEGER, data: ''} as unknown as CompositionEvent; + +const disposers: Array<() => void> = []; + +afterEach(() => { + while (disposers.length > 0) { + disposers.pop()!(); + } +}); + +function createBareEditor(): LexicalEditor { + return createEditor({ + namespace: 'composer-blockquote-test', + nodes: NODES, + onError: (error) => { + throw error; + }, + }); +} + +function registerPlainTextFallbacks(editor: LexicalEditor): void { + disposers.push( + editor.registerCommand( + INSERT_LINE_BREAK_COMMAND, + (selectStart) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.insertLineBreak(selectStart); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + CONTROLLED_TEXT_INSERTION_COMMAND, + (payload) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || typeof payload !== 'string') { + return false; + } + selection.insertText(payload); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + ); +} + +function createHarness(options: {flags?: number; history?: boolean} = {}): LexicalEditor { + const editor = createBareEditor(); + disposers.push(registerComposerMarkdownHighlight(editor, options.flags), registerComposerBlockquote(editor)); + registerPlainTextFallbacks(editor); + if (options.history === true) { + disposers.push(registerHistory(editor, createEmptyHistoryState(), 0)); + } + return editor; +} + +function update(editor: LexicalEditor, fn: () => void): void { + editor.update(fn, {discrete: true}); +} + +function setComposer( + editor: LexicalEditor, + text: string, + anchor?: number, + focus?: number, + segments: ReadonlyArray = [], +): void { + update(editor, () => { + $hydrateComposerFromDraft(text, segments); + if (anchor != null) { + $selectComposerRange(anchor, focus == null ? anchor : focus); + } + }); +} + +function select(editor: LexicalEditor, anchor: number, focus = anchor): void { + update(editor, () => { + $selectComposerRange(anchor, focus); + }); +} + +function type(editor: LexicalEditor, text: string): void { + update(editor, () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(text); + } + }); +} + +function run(editor: LexicalEditor, command: LexicalCommand, payload: T): boolean { + let handled = false; + update(editor, () => { + handled = editor.dispatchCommand(command, payload); + }); + return handled; +} + +function historyStep(editor: LexicalEditor, command: typeof UNDO_COMMAND | typeof REDO_COMMAND): void { + editor.dispatchCommand(command, undefined); + update(editor, () => {}); +} + +interface Snapshot { + wire: string; + selection: {anchor: number; focus: number} | null; + lines: Array; + wrappers: number; + markers: Array; + breaksInsideWrappers: number; + hasSelection: boolean; +} + +function snapshot(editor: LexicalEditor): Snapshot { + return editor.getEditorState().read( + () => { + const state = $getComposerBlockquoteState(); + const paragraph = $getRoot().getFirstChild(); + const children = $isElementNode(paragraph) ? paragraph.getChildren() : []; + const wrappers = children.filter($isComposerBlockquoteLineNode); + return { + wire: $projectComposer().wire, + selection: state.selection, + lines: state.lines, + wrappers: wrappers.length, + markers: wrappers.map((wrapper) => { + const first = wrapper.getFirstChild(); + return $isComposerBlockquoteMarkerNode(first) ? first.getTextContent() : ''; + }), + breaksInsideWrappers: wrappers.reduce( + (count, wrapper) => count + wrapper.getChildren().filter($isLineBreakNode).length, + 0, + ), + hasSelection: $getSelection() !== null, + }; + }, + {editor}, + ); +} + +function expectedQuoteLines(text: string): number { + return text.split('\n').filter((line) => /^[ \t]*> /.test(line)).length; +} + +function caretInsideMarker(state: Snapshot): boolean { + const caret = state.selection == null ? null : state.selection.anchor; + return caret != null && state.lines.some((line) => line.start <= caret && caret < line.contentStart); +} + +function keyEvent(overrides: Record = {}): KeyboardEvent & {preventDefault: ReturnType} { + return { + key: 'ArrowLeft', + keyCode: 37, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + isComposing: false, + preventDefault: vi.fn(), + ...overrides, + } as unknown as KeyboardEvent & {preventDefault: ReturnType}; +} + +function compositionKey(editor: LexicalEditor): string | null { + return (editor as unknown as {_compositionKey: string | null})._compositionKey; +} + +function selectFirstMarkerText(editor: LexicalEditor, offset: number): string { + let key = ''; + update(editor, () => { + const paragraph = $getRoot().getFirstChild(); + const wrapper = $isElementNode(paragraph) ? paragraph.getFirstChild() : null; + const marker = $isComposerBlockquoteLineNode(wrapper) ? wrapper.getFirstChild() : null; + if (!$isComposerBlockquoteMarkerNode(marker)) { + throw new Error('expected a quote marker'); + } + marker.select(offset, offset); + key = marker.getKey(); + }); + return key; +} + +describe('blockquote line splits', () => { + it('moves the whole content down when the caret is at content start', () => { + const editor = createHarness(); + setComposer(editor, '> test', 2); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: '> \n> test', + selection: {anchor: 5, focus: 5}, + wrappers: 2, + breaksInsideWrappers: 0, + }); + }); + + it('moves the tail to the new quote line when the caret is mid line', () => { + const editor = createHarness(); + setComposer(editor, '> test', 4); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: '> te\n> st', + selection: {anchor: 7, focus: 7}, + wrappers: 2, + breaksInsideWrappers: 0, + }); + }); + + it('keeps text order when insertLineBreak runs over a range inside a quote line', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 3, 5); + update(editor, () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertLineBreak(); + } + }); + expect(snapshot(editor)).toMatchObject({wire: '> a\nd', wrappers: 1, breaksInsideWrappers: 0}); + }); + + it('declines a range selection and falls back to a plain break in order', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 3, 5); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor)).toMatchObject({wire: '> a\nd', wrappers: 1, breaksInsideWrappers: 0}); + }); + + it('keeps text order when raw text with a newline is inserted mid quote line', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + update(editor, () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertRawText('x\ny'); + } + }); + expect(snapshot(editor)).toMatchObject({ + wire: '> abx\nycd', + wrappers: 1, + breaksInsideWrappers: 0, + selection: {anchor: 7, focus: 7}, + }); + }); + + it('keeps text order when a clipboard slice with a newline lands mid quote line', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + update(editor, () => { + $insertComposerClipboardSlice({display: 'x\ny', segments: []}, false); + }); + expect(snapshot(editor)).toMatchObject({ + wire: '> abx\nycd', + wrappers: 1, + breaksInsideWrappers: 0, + selection: {anchor: 7, focus: 7}, + }); + }); + + it('leaves the tail on the first line when insertNewAfter returns null', () => { + const spy = vi + .spyOn(ComposerBlockquoteLineNode.prototype, 'insertNewAfter') + .mockImplementation(() => null as unknown as ComposerBlockquoteLineNode); + try { + const editor = createHarness(); + setComposer(editor, '> test', 4); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor).wire).toBe('> test\n> '); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('blockquote line breaks', () => { + const LINE_BREAK_ROWS: Array<[string, number, string, number]> = [ + ['> test', 6, '> test\n> ', 9], + ['> test', 4, '> te\n> st', 7], + ['> test', 2, '> \n> test', 5], + ['> ', 2, '> \n> ', 5], + ['x\n> ', 4, 'x\n> \n> ', 7], + ['> test\n> ', 9, '> test\n', 7], + ['> \n> ', 5, '', 0], + ['> \n> \n> ', 8, '', 0], + ['> a\n> \n> ', 9, '> a\n', 4], + ['> a\n> \n> b', 6, '> a\n\n> b', 4], + ['x\n> \n> ', 7, 'x\n', 2], + ['> ', 3, '> \n> ', 6], + [' > a', 5, ' > a\n> ', 8], + ]; + + it.each(LINE_BREAK_ROWS)('on %j at %i gives %j with the caret at %i', (text, before, expected, after) => { + const editor = createHarness(); + setComposer(editor, text, before); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + const state = snapshot(editor); + expect(state).toMatchObject({ + wire: expected, + selection: {anchor: after, focus: after}, + wrappers: expectedQuoteLines(expected), + breaksInsideWrappers: 0, + }); + expect(caretInsideMarker(state)).toBe(false); + }); + + it('treats INSERT_PARAGRAPH_COMMAND like a line break', () => { + const editor = createHarness(); + setComposer(editor, '> test', 6); + expect(run(editor, INSERT_PARAGRAPH_COMMAND, undefined)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> test\n> ', selection: {anchor: 9, focus: 9}}); + }); + + it('removes the whole quote when an empty quote is left twice', () => { + const editor = createHarness(); + setComposer(editor, '', 0); + type(editor, '>'); + type(editor, ' '); + expect(snapshot(editor)).toMatchObject({ + wire: '> ', + selection: {anchor: 2, focus: 2}, + wrappers: 1, + markers: ['> '], + }); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor)).toMatchObject({wire: '> \n> ', selection: {anchor: 5, focus: 5}, wrappers: 2}); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor)).toMatchObject({wire: '', selection: {anchor: 0, focus: 0}, wrappers: 0}); + }); + + it('leaves a plain line below the quote when the empty continuation is left', () => { + const editor = createHarness(); + setComposer(editor, '', 0); + for (const character of '> test') { + type(editor, character); + } + expect(snapshot(editor)).toMatchObject({wire: '> test', selection: {anchor: 6, focus: 6}, wrappers: 1}); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor)).toMatchObject({wire: '> test\n> ', selection: {anchor: 9, focus: 9}, wrappers: 2}); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor)).toMatchObject({wire: '> test\n', selection: {anchor: 7, focus: 7}, wrappers: 1}); + type(editor, 'x'); + expect(snapshot(editor)).toMatchObject({wire: '> test\nx', wrappers: 1}); + }); + + it('declines outside quote lines, with selectStart, and while composing', () => { + const editor = createHarness(); + setComposer(editor, 'abc', 3); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor).wire).toBe('abc\n'); + setComposer(editor, '> test', 6); + run(editor, INSERT_LINE_BREAK_COMMAND, true); + expect(snapshot(editor).wire).toBe('> test\n'); + setComposer(editor, '> test', 6); + update(editor, () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setCompositionKey(selection.anchor.key); + } + }); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor).wire).toBe('> test\n'); + update(editor, () => { + $setCompositionKey(null); + }); + }); +}); + +describe('blockquote backspace', () => { + const BACKSPACE_ROWS: Array<[string, number, string, number]> = [ + ['> test', 2, 'test', 0], + ['> ', 2, '', 0], + ['> \n> ', 5, '> \n', 3], + ['> \n', 3, '', 0], + ['> \nabc', 3, 'abc', 0], + ['a\n> \nb', 5, 'a\nb', 2], + [' > a', 4, 'a', 0], + ['x\n> test', 4, 'x\ntest', 2], + ]; + + it.each(BACKSPACE_ROWS)('on %j at %i gives %j with the caret at %i', (text, before, expected, after) => { + const editor = createHarness(); + setComposer(editor, text, before); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: expected, + selection: {anchor: after, focus: after}, + wrappers: expectedQuoteLines(expected), + }); + }); + + it('removes two empty quote lines with two presses', () => { + const editor = createHarness(); + setComposer(editor, '> \n> ', 5); + run(editor, DELETE_CHARACTER_COMMAND, true); + expect(snapshot(editor)).toMatchObject({wire: '> \n', selection: {anchor: 3, focus: 3}, wrappers: 1}); + run(editor, DELETE_CHARACTER_COMMAND, true); + expect(snapshot(editor)).toMatchObject({wire: '', selection: {anchor: 0, focus: 0}, wrappers: 0}); + }); + + const BACKSPACE_DECLINED: Array<[string, number, number?]> = [ + ['> a\nb', 4], + ['> test', 4], + ['> ', 3], + ['abc', 3], + ['```\n> a', 6], + ['> test', 2, 4], + ]; + + it.each(BACKSPACE_DECLINED)('declines on %j over %i..%s', (text, anchor, focus) => { + const editor = createHarness(); + setComposer(editor, text, anchor, focus); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(false); + }); + + it('declines a forward delete and a backspace while composing', () => { + const editor = createHarness(); + setComposer(editor, '> test', 2); + expect(run(editor, DELETE_CHARACTER_COMMAND, false)).toBe(false); + update(editor, () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setCompositionKey(selection.anchor.key); + } + }); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(false); + update(editor, () => { + $setCompositionKey(null); + }); + }); + + it.each(['before', 'after'])('wins over a high priority delete handler registered %s it', (order) => { + const editor = createBareEditor(); + const spy = vi.fn(() => true); + const registerCompetitors = () => { + disposers.push( + registerComposerSoftWrapDeletion(editor), + editor.registerCommand(DELETE_CHARACTER_COMMAND, spy, COMMAND_PRIORITY_HIGH), + ); + }; + if (order === 'before') { + registerCompetitors(); + } + disposers.push(registerComposerMarkdownHighlight(editor), registerComposerBlockquote(editor)); + if (order === 'after') { + registerCompetitors(); + } + setComposer(editor, '> test', 2); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(true); + expect(spy).not.toHaveBeenCalled(); + expect(snapshot(editor).wire).toBe('test'); + }); +}); + +describe('blockquote history', () => { + it('treats one line break exit and one backspace as single steps', () => { + const editor = createHarness({history: true}); + setComposer(editor, '> test\n> ', 9); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor)).toMatchObject({wire: '> test\n', selection: {anchor: 7, focus: 7}}); + historyStep(editor, UNDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: '> test\n> ', selection: {anchor: 9, focus: 9}, wrappers: 2}); + historyStep(editor, REDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: '> test\n', selection: {anchor: 7, focus: 7}, wrappers: 1}); + select(editor, 2); + run(editor, DELETE_CHARACTER_COMMAND, true); + expect(snapshot(editor).wire).toBe('test\n'); + historyStep(editor, UNDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: '> test\n', wrappers: 1}); + historyStep(editor, REDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: 'test\n', wrappers: 0}); + }); +}); + +describe('blockquote caret, typing and composition', () => { + it.each([ + ['> test', 0, 2], + ['> test', 1, 2], + ['ab\n> cd', 3, 5], + ['ab\n> cd', 4, 5], + ['> test', 3, 3], + ])('moves the caret on %j from %i to %i on a selection change', (text, before, after) => { + const editor = createHarness(); + setComposer(editor, text); + select(editor, before); + expect(run(editor, SELECTION_CHANGE_COMMAND, undefined)).toBe(false); + expect(snapshot(editor).selection).toEqual({anchor: after, focus: after}); + }); + + it('leaves range selections alone', () => { + const editor = createHarness(); + setComposer(editor, '> test'); + select(editor, 0, 4); + run(editor, SELECTION_CHANGE_COMMAND, undefined); + expect(snapshot(editor).selection).toEqual({anchor: 0, focus: 4}); + }); + + it('snaps a text point inside the marker to content start', () => { + const editor = createHarness(); + setComposer(editor, '> '); + selectFirstMarkerText(editor, 1); + run(editor, SELECTION_CHANGE_COMMAND, undefined); + expect(snapshot(editor).selection).toEqual({anchor: 2, focus: 2}); + }); + + it('moves the caret out of a marker the highlight pass has just created', () => { + const editor = createHarness(); + setComposer(editor, 'x> test', 1); + update(editor, () => { + $selectComposerRange(0, 1); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(''); + } + }); + expect(snapshot(editor)).toMatchObject({wire: '> test', selection: {anchor: 2, focus: 2}, wrappers: 1}); + }); + + it('creates a sibling content node when typing at the end of the marker text', () => { + const editor = createHarness(); + setComposer(editor, '> '); + selectFirstMarkerText(editor, 2); + type(editor, 'x'); + expect(snapshot(editor)).toMatchObject({ + wire: '> x', + wrappers: 1, + markers: ['> '], + selection: {anchor: 3, focus: 3}, + }); + }); + + it('creates a content node when typing at the element point after the marker', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + type(editor, 'x'); + expect(snapshot(editor)).toMatchObject({wire: '> x', wrappers: 1, markers: ['> ']}); + }); + + it('refuses text inserted before or after the marker', () => { + const editor = createHarness(); + setComposer(editor, '> '); + const guards = editor.getEditorState().read( + () => { + const paragraph = $getRoot().getFirstChild(); + const wrapper = $isElementNode(paragraph) ? paragraph.getFirstChild() : null; + const marker = $isComposerBlockquoteLineNode(wrapper) ? wrapper.getFirstChild() : null; + if (!$isComposerBlockquoteMarkerNode(marker)) { + throw new Error('expected a quote marker'); + } + return {before: marker.canInsertTextBefore(), after: marker.canInsertTextAfter()}; + }, + {editor}, + ); + expect(guards).toEqual({before: false, after: false}); + }); + + it('diverts a composition that starts at the end of the marker text', () => { + const editor = createHarness(); + setComposer(editor, '> '); + const markerKey = selectFirstMarkerText(editor, 2); + run(editor, COMPOSITION_START_COMMAND, COMPOSITION_EVENT); + const key = compositionKey(editor); + expect(key).not.toBeNull(); + expect(key).not.toBe(markerKey); + update(editor, () => { + const node = $getNodeByKey(key!); + if ($isTextNode(node)) { + node.setTextContent('あ'); + node.select(1, 1); + } + }); + expect(editor.isComposing()).toBe(true); + expect(snapshot(editor).markers).toEqual(['> ']); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(false); + update(editor, () => { + $setCompositionKey(null); + for (const node of $getRoot().getAllTextNodes()) { + node.markDirty(); + } + }); + expect(snapshot(editor)).toMatchObject({wire: '> あ', wrappers: 1, markers: ['> ']}); + }); + + it('diverts a composition that starts at the element point after the marker', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + run(editor, COMPOSITION_START_COMMAND, COMPOSITION_EVENT); + const key = compositionKey(editor); + const target = editor.getEditorState().read(() => $getNodeByKey(key!), {editor}); + expect(target).not.toBeNull(); + expect($isComposerBlockquoteMarkerNode(target)).toBe(false); + update(editor, () => { + $setCompositionKey(null); + }); + }); + + it('lets a plain syntax marker take both the typed text and the composition', () => { + const editor = createBareEditor(); + registerPlainTextFallbacks(editor); + let markerKey = ''; + update(editor, () => { + const paragraph = $createParagraphNode(); + const marker = $createSyntaxMarkerNode('> '); + paragraph.append(marker); + $getRoot().clear().append(paragraph); + marker.select(2, 2); + markerKey = marker.getKey(); + }); + run(editor, COMPOSITION_START_COMMAND, COMPOSITION_EVENT); + expect(compositionKey(editor)).toBe(markerKey); + update(editor, () => { + $setCompositionKey(null); + const marker = $getNodeByKey(markerKey); + if ($isTextNode(marker)) { + marker.select(2, 2); + } + }); + type(editor, 'x'); + expect(editor.getEditorState().read(() => $getNodeByKey(markerKey)?.getTextContent(), {editor})).toBe('> x'); + }); + + it('jumps over the hidden marker on ArrowLeft', () => { + const editor = createHarness(); + setComposer(editor, 'ab\n> cd', 5); + const event = keyEvent(); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(snapshot(editor).selection).toEqual({anchor: 2, focus: 2}); + setComposer(editor, '> test', 2); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent())).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 2, focus: 2}); + setComposer(editor, 'ab\n> cd', 5); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent({altKey: true}))).toBe(true); + }); + + it('declines ArrowLeft with shift, meta, composition and away from content start', () => { + const editor = createHarness(); + setComposer(editor, 'ab\n> cd', 5); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent({shiftKey: true}))).toBe(false); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent({metaKey: true}))).toBe(false); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent({isComposing: true}))).toBe(false); + setComposer(editor, '> test', 4); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent())).toBe(false); + }); +}); + +describe('multi-line blockquote markers', () => { + it('rewrites a typed ">>> " to "> " and restores it on undo', () => { + const editor = createHarness({history: true}); + setComposer(editor, '>>>', 3); + type(editor, ' '); + expect(snapshot(editor)).toMatchObject({wire: '> ', selection: {anchor: 2, focus: 2}, wrappers: 1}); + historyStep(editor, UNDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: '>>>', selection: {anchor: 3, focus: 3}, wrappers: 0}); + historyStep(editor, REDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: '> ', selection: {anchor: 2, focus: 2}, wrappers: 1}); + }); + + it('rewrites when the completing character is a ">"', () => { + const editor = createHarness(); + setComposer(editor, '>> a', 1); + type(editor, '>'); + expect(snapshot(editor)).toMatchObject({wire: '> a', selection: {anchor: 2, focus: 2}, wrappers: 1}); + }); + + it('rewrites only the marker that was typed on a later line', () => { + const editor = createHarness(); + setComposer(editor, 'a\n>>>\nb', 5); + type(editor, ' '); + expect(snapshot(editor)).toMatchObject({wire: 'a\n> \nb', selection: {anchor: 4, focus: 4}, wrappers: 1}); + }); + + it('keeps the rendered meaning of arrived text and creates no selection', () => { + const editor = createHarness(); + update(editor, () => { + $hydrateComposerFromDraft('>>> a\n\nb', []); + }); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> \n> b', wrappers: 3, hasSelection: false}); + }); + + it('keeps a caret placed at the end after hydration', () => { + const editor = createHarness(); + setComposer(editor, '>>> a\nb', 7); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> b', selection: {anchor: 7, focus: 7}, wrappers: 2}); + }); + + it('keeps the rendered meaning of pasted text', () => { + const editor = createHarness(); + setComposer(editor, '', 0); + update(editor, () => { + $insertComposerClipboardSlice({display: '>>> a\nb', segments: []}, false); + }); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> b', selection: {anchor: 7, focus: 7}, wrappers: 2}); + }); + + it('keeps the indent of an indented marker', () => { + const editor = createHarness(); + update(editor, () => { + $hydrateComposerFromDraft(' >>> a', []); + }); + expect(snapshot(editor)).toMatchObject({wire: ' > a', wrappers: 1, markers: [' > '], hasSelection: false}); + }); + + it('leaves an arrived alert marker alone so the stored text keeps its meaning', () => { + const editor = createHarness(); + update(editor, () => { + $hydrateComposerFromDraft('>>> [!NOTE]\nbody', []); + }); + expect(snapshot(editor)).toMatchObject({wire: '>>> [!NOTE]\nbody', wrappers: 0}); + }); + + it('leaves ">>> a" as text without ALLOW_MULTILINE_BLOCKQUOTES', () => { + const editor = createHarness({flags: NO_MULTILINE_FLAGS}); + update(editor, () => { + $hydrateComposerFromDraft('>>> a', []); + }); + expect(snapshot(editor)).toMatchObject({wire: '>>> a', wrappers: 0}); + }); +}); + +describe('blockquote transform convergence', () => { + it.each([ + '> > a', + '> [!NOTE]\n> body', + '> >>> a', + '> a\n> \n> b', + '```\n> a', + '> ', + '> a\n\nb', + ])('reaches a fixed point on %j', (text) => { + const editor = createHarness(); + setComposer(editor, text); + const before = JSON.stringify(editor.getEditorState().toJSON()); + const wire = snapshot(editor).wire; + const updates: Array<{intentionalElements: number; leaves: number}> = []; + disposers.push( + editor.registerUpdateListener(({dirtyElements, dirtyLeaves}) => { + updates.push({ + intentionalElements: [...dirtyElements].filter(([key, intentional]) => intentional && key !== 'root').length, + leaves: dirtyLeaves.size, + }); + }), + ); + update(editor, () => {}); + expect(updates.every((entry) => entry.intentionalElements === 0 && entry.leaves === 0)).toBe(true); + let marked = 0; + update(editor, () => { + for (const node of $getRoot().getAllTextNodes()) { + node.markDirty(); + marked += 1; + } + }); + expect(JSON.stringify(editor.getEditorState().toJSON())).toBe(before); + expect(updates[updates.length - 1]).toEqual({intentionalElements: 0, leaves: marked}); + expect(snapshot(editor).wire).toBe(wire); + }); + + it('keeps nested and alert quotes in a single wrapper per line', () => { + const editor = createHarness(); + setComposer(editor, '> > a'); + expect(snapshot(editor)).toMatchObject({wire: '> > a', wrappers: 1, markers: ['> ']}); + setComposer(editor, '> [!NOTE]\n> body'); + expect(snapshot(editor)).toMatchObject({wrappers: 2, markers: ['> ', '> ']}); + setComposer(editor, '> >>> a'); + expect(snapshot(editor)).toMatchObject({wire: '> >>> a', wrappers: 1, markers: ['> ']}); + }); + + it('keeps whitespace typed after the prefix as content of the same quote line', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + type(editor, ' '); + expect(snapshot(editor)).toMatchObject({ + wire: '> ', + markers: ['> '], + lines: [{start: 0, contentStart: 2, end: 3}], + selection: {anchor: 3, focus: 3}, + }); + }); +}); + +describe('blockquote code blocks', () => { + it('gives no wrapper to lines inside closed and unclosed fences', () => { + const editor = createHarness(); + setComposer(editor, '```\n> a', 7); + expect(snapshot(editor).wrappers).toBe(0); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor).wire).toBe('```\n> a\n'); + setComposer(editor, '```\n> a\n```'); + expect(snapshot(editor).wrappers).toBe(0); + }); + + it('keeps a quote line that follows a closed fence', () => { + const editor = createHarness(); + setComposer(editor, '```\nx\n```\n> a'); + expect(snapshot(editor)).toMatchObject({wrappers: 1, lines: [{start: 10, contentStart: 12, end: 13}]}); + }); + + it('does not continue the quote from a fence opened on a quote line', () => { + const editor = createHarness(); + setComposer(editor, '> ```', 5); + expect(snapshot(editor).wrappers).toBe(1); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor).wire).toBe('> ```\n'); + }); + + it('keeps the wrapper only on the opening line of a fence closed inside a quote', () => { + const editor = createHarness(); + setComposer(editor, '> ```\n> code\n> ```'); + expect(snapshot(editor)).toMatchObject({wrappers: 1, lines: [{start: 0, contentStart: 2, end: 5}]}); + }); +}); + +describe('blockquote parser flags', () => { + it('gives no wrappers and declines every handler without ALLOW_BLOCKQUOTES', () => { + const editor = createHarness({flags: NO_BLOCKQUOTE_FLAGS}); + setComposer(editor, '> a', 2); + expect(snapshot(editor).wrappers).toBe(0); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(false); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent())).toBe(false); + select(editor, 0); + run(editor, SELECTION_CHANGE_COMMAND, undefined); + expect(snapshot(editor).selection).toEqual({anchor: 0, focus: 0}); + select(editor, 3); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor).wire).toBe('> a\n'); + }); + + it('continues quotes under bio style flags', () => { + const editor = createHarness({flags: BIO_LIKE_FLAGS}); + setComposer(editor, '> a', 3); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> ', selection: {anchor: 6, focus: 6}}); + }); +}); + +describe('nodes inside quote lines', () => { + it('converts emoji shortcodes inside a quote line', () => { + const editor = createHarness(); + disposers.push( + registerComposerEmojiShortcode(editor, (name) => + name === 'blob' ? {kind: 'custom', emojiId: '1', animated: false, display: ':blob:', wire: '<:blob:1>'} : null, + ), + ); + setComposer(editor, '> hi :blob:', 11); + expect(snapshot(editor)).toMatchObject({wire: '> hi <:blob:1>', wrappers: 1}); + const emojiInWrapper = editor.getEditorState().read( + () => { + const paragraph = $getRoot().getFirstChild(); + const wrapper = $isElementNode(paragraph) ? paragraph.getFirstChild() : null; + return $isComposerBlockquoteLineNode(wrapper) && wrapper.getChildren().some($isComposerCustomEmojiNode); + }, + {editor}, + ); + expect(emojiInWrapper).toBe(true); + }); + + it('continues the quote when a mention display name holds a fence', () => { + const editor = createHarness(); + const segment: MentionSegment = { + type: 'user', + id: '1', + displayText: '@a```b', + actualText: '<@1>', + start: 2, + end: 8, + }; + setComposer(editor, '> @a```b', 8, 8, [segment]); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> <@1>\n> ', selection: {anchor: 11, focus: 11}, wrappers: 2}); + }); + + it('removes the marker below a mention display name holding a fence', () => { + const editor = createHarness(); + const segment: MentionSegment = { + type: 'user', + id: '1', + displayText: '@a```b', + actualText: '<@1>', + start: 2, + end: 8, + }; + setComposer(editor, '> @a```b\n> q', 11, 11, [segment]); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> <@1>\nq', selection: {anchor: 9, focus: 9}, wrappers: 1}); + }); + + it('projects a mention inside a quote line and survives a round trip', () => { + const editor = createHarness(); + const segment: MentionSegment = { + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start: 2, + end: 7, + }; + setComposer(editor, '> @name', 7, 7, [segment]); + const projection = editor.getEditorState().read(() => $projectComposer(), {editor}); + expect(projection).toEqual({display: '> @name', wire: '> <@1>', segments: [segment]}); + const mentionInWrapper = editor.getEditorState().read( + () => { + const paragraph = $getRoot().getFirstChild(); + const wrapper = $isElementNode(paragraph) ? paragraph.getFirstChild() : null; + return $isComposerBlockquoteLineNode(wrapper) && wrapper.getChildren().some($isComposerMentionNode); + }, + {editor}, + ); + expect(mentionInWrapper).toBe(true); + setComposer(editor, projection.display, undefined, undefined, projection.segments); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toEqual(projection); + expect(snapshot(editor).wrappers).toBe(1); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/ComposerBlockquote.ts b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.ts new file mode 100644 index 000000000..33e4fe3ae --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.ts @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type BlockquoteEdit, + type BlockquoteLine, + planBlockquoteArrowLeft, + planBlockquoteBackspace, + planBlockquoteLineBreak, + resolveBlockquoteCaret, +} from '@app/features/lexical/composer/blockquoteLines'; +import { + $captureSelectionOffsets, + $getComposerBlockquoteState, + $replaceComposerRange, + $selectComposerOffset, + $selectComposerRange, +} from '@app/features/lexical/composer/composerOffsets'; +import { + $createComposerBlockquoteLineNode, + $getComposerLineNodes, + $isComposerBlockquoteLineNode, +} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; +import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode'; +import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode'; +import {$isComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode'; +import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils'; +import {mergeRegister} from '@lexical/utils'; +import { + $createTextNode, + $isLineBreakNode, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_HIGH, + DELETE_CHARACTER_COMMAND, + type ElementNode, + INSERT_LINE_BREAK_COMMAND, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_LEFT_COMMAND, + type LexicalEditor, + type LexicalNode, + type LineBreakNode, + SELECTION_CHANGE_COMMAND, + TextNode, +} from 'lexical'; + +export interface ComposerLine { + nodes: Array; + lineBreak: LineBreakNode | null; +} + +type BlockquotePlanner = (text: string, lines: ReadonlyArray, caret: number) => BlockquoteEdit | null; + +export function $splitComposerLines(paragraph: ElementNode): Array { + const lines: Array = [{nodes: [], lineBreak: null}]; + for (const node of $getComposerLineNodes(paragraph)) { + if ($isLineBreakNode(node)) { + lines.push({nodes: [], lineBreak: node}); + } else { + lines[lines.length - 1]!.nodes.push(node); + } + } + return lines; +} + +function $applyBlockquotePlan(editor: LexicalEditor, planner: BlockquotePlanner): boolean { + if (editor.isComposing()) { + return false; + } + const {scanText, selection, lines} = $getComposerBlockquoteState(); + if (selection == null || selection.anchor !== selection.focus || lines.length === 0) { + return false; + } + const edit = planner(scanText, lines, selection.anchor); + if (edit == null) { + return false; + } + $replaceComposerRange(edit.start, edit.end, {kind: 'text', text: edit.text}, {leading: false, trailing: false}); + $selectComposerOffset(edit.caret); + return true; +} + +export function $snapCaretOutOfBlockquoteMarker(): void { + const {selection, lines} = $getComposerBlockquoteState(); + if (selection == null || selection.anchor !== selection.focus || lines.length === 0) { + return; + } + const caret = resolveBlockquoteCaret(lines, selection.anchor); + if (caret !== selection.anchor) { + $selectComposerOffset(caret); + } +} + +function shouldWrapLine(line: ComposerLine, quoted: boolean | undefined): boolean { + return quoted === true && $isComposerBlockquoteMarkerNode(line.nodes[0]); +} + +function $blockquoteStructureMatches( + paragraph: ElementNode, + lines: ReadonlyArray, + quoted: ReadonlyArray, +): boolean { + const children = paragraph.getChildren(); + let index = 0; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]!; + if (line.lineBreak != null) { + if (!line.lineBreak.is(children[index])) { + return false; + } + index += 1; + } + if (shouldWrapLine(line, quoted[lineIndex])) { + const wrapper = children[index]; + index += 1; + if (!$isComposerBlockquoteLineNode(wrapper)) { + return false; + } + const inner = wrapper.getChildren(); + if (inner.length !== line.nodes.length || inner.some((node, position) => !node.is(line.nodes[position]))) { + return false; + } + continue; + } + for (const node of line.nodes) { + if (!node.is(children[index])) { + return false; + } + index += 1; + } + } + return index === children.length; +} + +export function $syncComposerBlockquoteLines(paragraph: ElementNode, quoted: ReadonlyArray): void { + if ($blockquoteStructureMatches(paragraph, $splitComposerLines(paragraph), quoted)) { + return; + } + const selection = $captureSelectionOffsets(); + for (const child of paragraph.getChildren()) { + if ($isComposerBlockquoteLineNode(child)) { + for (const node of $getComposerLineNodes(child)) { + child.insertBefore(node); + } + child.remove(); + } + } + $splitComposerLines(paragraph).forEach((line, index) => { + if (shouldWrapLine(line, quoted[index])) { + const wrapper = $createComposerBlockquoteLineNode(); + line.nodes[0]!.insertBefore(wrapper); + wrapper.append(...line.nodes); + } + }); + if (selection != null) { + $selectComposerRange(selection.anchor, selection.focus); + } +} + +function isPlainTextLeaf(node: LexicalNode | undefined): node is TextNode { + return node instanceof TextNode && !$isComposerPlainSegmentNode(node) && !$isComposerCommandNode(node); +} + +function $spliceLeadingText( + nodes: ReadonlyArray, + offset: number, + deleteCount: number, + text: string, +): boolean { + const covered: Array = []; + let length = 0; + for (const node of nodes) { + if (!isPlainTextLeaf(node) || length >= offset + deleteCount) { + break; + } + covered.push(node); + length += node.getTextContentSize(); + } + const first = covered[0]; + if (first == null || length < offset + deleteCount) { + return false; + } + const combined = covered.map((node) => node.getTextContent()).join(''); + first.setTextContent(`${combined.slice(0, offset)}${text}${combined.slice(offset + deleteCount)}`); + for (const node of covered.slice(1)) { + node.remove(); + } + return true; +} + +function $prefixComposerLine(line: ComposerLine): void { + const first = line.nodes[0]; + if (isPlainTextLeaf(first)) { + first.setTextContent(`> ${first.getTextContent()}`); + } else if (first != null) { + first.insertBefore($createTextNode('> ')); + } else if (line.lineBreak != null) { + line.lineBreak.insertAfter($createTextNode('> ')); + } +} + +export function $rewriteMultilineBlockquoteMarker( + lines: ReadonlyArray, + lineSources: ReadonlyArray, + markerEnds: ReadonlyArray, +): boolean { + const index = markerEnds.findIndex( + (end, lineIndex) => end > 0 && lineSources[lineIndex]!.slice(0, end).trimStart().startsWith('>>> '), + ); + if (index < 0) { + return false; + } + const indent = markerEnds[index]! - 4; + const lineStarts: Array = []; + let offset = 0; + for (const line of lines) { + lineStarts.push(offset); + offset += line.nodes.reduce((sum, node) => sum + node.getTextContentSize(), 0) + 1; + } + const selection = $captureSelectionOffsets(); + const markerStart = lineStarts[index]! + indent; + const typed = + selection != null && + selection.anchor === selection.focus && + selection.anchor > markerStart && + selection.anchor <= markerStart + 4; + if (!$spliceLeadingText(lines[index]!.nodes, indent, 4, '> ')) { + return false; + } + const prefixedStarts = typed ? [] : lineStarts.slice(index + 1); + if (!typed) { + for (const line of lines.slice(index + 1)) { + $prefixComposerLine(line); + } + } + if (selection != null) { + const map = (value: number): number => { + if (value > markerStart && value < markerStart + 4) { + return markerStart + 2; + } + let next = value >= markerStart + 4 ? value - 2 : value; + for (const start of prefixedStarts) { + if (value >= start) { + next += 2; + } + } + return next; + }; + $selectComposerRange(map(selection.anchor), map(selection.focus)); + } + return true; +} + +export function registerComposerBlockquote(editor: LexicalEditor): () => void { + return mergeRegister( + editor.registerCommand( + INSERT_LINE_BREAK_COMMAND, + (selectStart) => !selectStart && $applyBlockquotePlan(editor, planBlockquoteLineBreak), + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => $applyBlockquotePlan(editor, planBlockquoteLineBreak), + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + DELETE_CHARACTER_COMMAND, + (isBackward) => isBackward && $applyBlockquotePlan(editor, planBlockquoteBackspace), + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + (event) => { + if (event.shiftKey || event.metaKey || isIMEComposing(event) || editor.isComposing()) { + return false; + } + const {selection, lines} = $getComposerBlockquoteState(); + if (selection == null || selection.anchor !== selection.focus) { + return false; + } + const caret = planBlockquoteArrowLeft(lines, selection.anchor); + if (caret == null) { + return false; + } + event.preventDefault(); + $selectComposerOffset(caret); + return true; + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + if (!editor.isComposing()) { + $snapCaretOutOfBlockquoteMarker(); + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ); +} diff --git a/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts b/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts index 5455ff758..fe96a9529 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts @@ -7,6 +7,7 @@ import { $selectComposerNodeBoundary, $selectComposerRange, } from '@app/features/lexical/composer/composerOffsets'; +import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; import {$createComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode'; import { $createComposerStandardEmojiNode, @@ -72,7 +73,7 @@ export function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiRes return; } const parent = node.getParent(); - if (parent == null || parent.getType() !== 'paragraph') { + if (parent == null || (parent.getType() !== 'paragraph' && !$isComposerBlockquoteLineNode(parent))) { return; } const text = node.getTextContent(); diff --git a/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts b/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts index 8be4aa724..edfad8501 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts @@ -1,5 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {findBlockquoteMarkerEnds} from '@app/features/lexical/composer/blockquoteLines'; +import { + $rewriteMultilineBlockquoteMarker, + $snapCaretOutOfBlockquoteMarker, + $splitComposerLines, + $syncComposerBlockquoteLines, +} from '@app/features/lexical/composer/ComposerBlockquote'; import {$captureSelectionOffsets, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets'; import { computeMarkdownHighlightSpans, @@ -8,6 +15,11 @@ import { type MarkdownSpan, markSilentMessagePrefix, } from '@app/features/lexical/composer/markdownSpans'; +import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; +import { + $createComposerBlockquoteMarkerNode, + $isComposerBlockquoteMarkerNode, +} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode'; import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode'; import {$isComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode'; import { @@ -24,7 +36,6 @@ import { $createTextNode, $getNodeByKey, $getSelection, - $isLineBreakNode, $isNodeSelection, $isRangeSelection, $setSelection, @@ -42,7 +53,6 @@ const STYLE_BY_BIT: ReadonlyArray<{bit: number; style: string}> = [ style: 'background-color:var(--background-modifier-active,rgba(0,0,0,0.15));border-radius:0.1875rem', }, {bit: MarkdownHl.heading, style: 'font-weight:700'}, - {bit: MarkdownHl.blockquote, style: 'color:var(--text-secondary)'}, {bit: MarkdownHl.subtext, style: 'font-size:0.85em;color:var(--text-muted,var(--text-secondary))'}, {bit: MarkdownHl.link, style: 'color:var(--text-link)'}, {bit: MarkdownHl.codeBlock, style: 'font-size:0.75em'}, @@ -98,6 +108,7 @@ export function registerComposerMarkdownHighlight( type Desired = | {role: 'marker'; text: string} + | {role: 'quote'; text: string} | {role: 'content'; text: string; format: MarkdownHlFormat} | {role: 'keep'; node: LexicalNode}; @@ -105,31 +116,26 @@ type BuildableDesired = Exclude; export function $reconcileLineOf(node: TextNode, parserFlags?: number): void { const parent = node.getParent(); - if (parent == null || parent.getType() !== 'paragraph') { + const block = $isComposerBlockquoteLineNode(parent) ? parent.getParent() : parent; + if (block == null || block.getType() !== 'paragraph') { return; } - $reconcileParagraph(parent as ParagraphNode, parserFlags); + $reconcileParagraph(block as ParagraphNode, parserFlags); } function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, silentMessagePrefix = false): void { - const lines: Array> = []; - let line: Array = []; - for (const child of paragraph.getChildren()) { - if ($isLineBreakNode(child)) { - lines.push(line); - line = []; - } else { - line.push(child); - } - } - lines.push(line); - const lineSources = lines.map((nodes) => nodes.map($nodeWireText).join('')); + const lines = $splitComposerLines(paragraph); + const lineSources = lines.map((line) => line.nodes.map($nodeWireText).join('')); const source = lineSources.join('\n'); const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags); + const quoteMarkerEnds = findBlockquoteMarkerEnds(source, markdownSpans); + if ($rewriteMultilineBlockquoteMarker(lines, lineSources, quoteMarkerEnds)) { + return; + } const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans; let lineStart = 0; for (let index = 0; index < lines.length; index += 1) { - const nodes = lines[index]!; + const nodes = lines[index]!.nodes; const lineSource = lineSources[index]!; const lineEnd = lineStart + lineSource.length; if (nodes.length > 0) { @@ -140,10 +146,17 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, sil end: Math.min(span.end, lineEnd) - lineStart, })) .filter((span) => span.end > span.start); - $reconcileLine(nodes, parserFlags, lineSource, lineSpans); + $reconcileLine(nodes, parserFlags, lineSource, lineSpans, quoteMarkerEnds[index]); } lineStart = lineEnd + 1; } + $syncComposerBlockquoteLines( + paragraph, + quoteMarkerEnds.map((end) => end > 0), + ); + if (quoteMarkerEnds.some((end) => end > 0)) { + $snapCaretOutOfBlockquoteMarker(); + } } function $isEscapedAtOffset(source: string, offset: number): boolean { @@ -161,6 +174,7 @@ function $reconcileLine( parserFlags?: number, precomputedSource?: string, precomputedSpans?: Array, + quoteMarkerEnd = 0, ): void { const desired: Array = []; const source = precomputedSource == null ? line.map($nodeWireText).join('') : precomputedSource; @@ -210,7 +224,16 @@ function $reconcileLine( continue; } const text = source.slice(start, end); - desired.push(span.role === 'marker' ? {role: 'marker', text} : {role: 'content', text, format: span.format}); + const previous = desired[desired.length - 1]; + if (span.role === 'marker' && span.start < quoteMarkerEnd) { + if (previous != null && previous.role === 'quote') { + previous.text += text; + } else { + desired.push({role: 'quote', text}); + } + } else { + desired.push(span.role === 'marker' ? {role: 'marker', text} : {role: 'content', text, format: span.format}); + } } } else { desired.push({role: 'keep', node}); @@ -309,8 +332,12 @@ function $descriptorsMatch(line: Array, desired: Array): b if (!(node instanceof TextNode) || node.getTextContent() !== want.text) { return false; } - if (want.role === 'marker') { - if (!$isSyntaxMarkerNode(node)) { + if (want.role === 'quote') { + if (!$isComposerBlockquoteMarkerNode(node)) { + return false; + } + } else if (want.role === 'marker') { + if (!$isSyntaxMarkerNode(node) || $isComposerBlockquoteMarkerNode(node)) { return false; } } else if ($isSyntaxMarkerNode(node) || !$contentNodeMatchesFormat(node, want.format)) { @@ -321,6 +348,9 @@ function $descriptorsMatch(line: Array, desired: Array): b } function $buildDescriptorNode(desired: BuildableDesired): TextNode { + if (desired.role === 'quote') { + return $createComposerBlockquoteMarkerNode(desired.text); + } if (desired.role === 'marker') { return $createSyntaxMarkerNode(desired.text); } diff --git a/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts b/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts index ee764bf66..725339483 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; import { $createComposerCommandNode, $isComposerCommandNode, @@ -227,7 +228,7 @@ export function $projectComposer(): ComposerProjection { wire += block.getTextContent(); continue; } - for (const child of block.getChildren()) { + for (const child of $getComposerLineNodes(block)) { if ($isLineBreakNode(child)) { display += '\n'; wire += '\n'; diff --git a/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.ts b/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.ts index fcf23f1a5..53e3ffe3c 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.ts @@ -6,6 +6,7 @@ import { $createComposerInsertNode, $selectComposerRange, } from '@app/features/lexical/composer/composerOffsets'; +import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode'; import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode'; import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode'; @@ -93,7 +94,7 @@ function $convertSpecialMention(root: RootNode, plainText: boolean): void { source += '\n'; displayLength += 1; } - for (const child of $isElementNode(block) ? block.getChildren() : [block]) { + for (const child of $isElementNode(block) ? $getComposerLineNodes(block) : [block]) { if ( $isTextNode(child) && child.isSimpleText() && diff --git a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx index f6ee83f05..e6056f9aa 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {AutocompleteOption, AutocompleteType} from '@app/features/channel/components/AutocompleteTypes'; +import {registerComposerBlockquote} from '@app/features/lexical/composer/ComposerBlockquote'; import {registerComposerClipboardCommands} from '@app/features/lexical/composer/ComposerClipboard'; import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent'; import { @@ -40,6 +41,8 @@ import { } from '@app/features/lexical/composer/composerOffsets'; import styles from '@app/features/lexical/composer/LexicalMessageComposer.module.css'; import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans'; +import {ComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; +import {ComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode'; import {ComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode'; import {ComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode'; import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode'; @@ -105,6 +108,8 @@ import {useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, u const THEME: InitialConfigType['theme'] = { paragraph: styles.paragraph, syntaxMarker: styles.marker, + composerBlockquoteLine: styles.blockquoteLine, + composerBlockquoteMarker: styles.blockquoteMarker, composerMention: clsx(styles.mentionHost, markupStyles.inlineFormat), composerCustomEmoji: styles.emojiHost, composerCommand: styles.command, @@ -209,6 +214,8 @@ export const LexicalComposerInput = observer((props: LexicalComposerInputProps) SlashSeparatorNode, SlashOptionalHintNode, SyntaxMarkerNode, + ComposerBlockquoteLineNode, + ComposerBlockquoteMarkerNode, ], theme: THEME, }; @@ -517,6 +524,7 @@ const ComposerInner = ({ cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current)); if (markdown) { cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix)); + cleanups.push(registerComposerBlockquote(editor)); } cleanups.push( registerComposerEmojiShortcode(editor, (shortcodeName) => { diff --git a/fluxer_app/src/features/lexical/composer/LexicalMessageComposer.module.css b/fluxer_app/src/features/lexical/composer/LexicalMessageComposer.module.css index 8deed41ab..a5f46c7ae 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalMessageComposer.module.css +++ b/fluxer_app/src/features/lexical/composer/LexicalMessageComposer.module.css @@ -54,6 +54,37 @@ opacity: 0.45; } +.blockquoteLine { + position: relative; + display: inline-block; + box-sizing: border-box; + width: 100%; + min-height: var(--composer-line-height, var(--textarea-line-height, 1.375rem)); + vertical-align: top; + padding-inline-start: 1rem; + color: var(--text-chat-muted); +} + +.blockquoteLine::before { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + width: 0.25rem; + border-radius: 0.25rem; + background-color: var(--interactive-muted); +} + +.marker.blockquoteMarker { + position: absolute; + inset-block-start: 0; + inset-inline-end: calc(100% - 1rem); + white-space: pre; + color: transparent; + opacity: 1; + pointer-events: none; +} + .bold { font-weight: 700; } diff --git a/fluxer_app/src/features/lexical/composer/blockquoteLines.test.ts b/fluxer_app/src/features/lexical/composer/blockquoteLines.test.ts new file mode 100644 index 000000000..792c82147 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/blockquoteLines.test.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type BlockquoteEdit, + type BlockquoteLine, + dropTrailingEmptyBlockquoteLines, + findBlockquoteMarkerEnds, + planBlockquoteArrowLeft, + planBlockquoteBackspace, + planBlockquoteLineBreak, + resolveBlockquoteCaret, +} from '@app/features/lexical/composer/blockquoteLines'; +import {computeMarkdownHighlightSpans} from '@app/features/lexical/composer/markdownSpans'; +import {describe, expect, it} from 'vitest'; + +const QUOTE_PREFIX_RE = /^[ \t]*> /; + +function quoteLines(text: string): Array { + const lines: Array = []; + let start = 0; + for (const line of text.split('\n')) { + const prefix = QUOTE_PREFIX_RE.exec(line); + if (prefix != null) { + lines.push({start, contentStart: start + prefix[0].length, end: start + line.length}); + } + start += line.length + 1; + } + return lines; +} + +function applyEdit(text: string, edit: BlockquoteEdit | null): string | null { + return edit == null ? null : `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`; +} + +describe('planBlockquoteLineBreak', () => { + const ROWS: Array<[string, number, string, number]> = [ + ['> test', 6, '> test\n> ', 9], + ['> test', 4, '> te\n> st', 7], + ['> test', 2, '> \n> test', 5], + ['> ', 2, '> \n> ', 5], + ['x\n> ', 4, 'x\n> \n> ', 7], + ['> test\n> ', 9, '> test\n', 7], + ['> \n> ', 5, '', 0], + ['> \n> \n> ', 8, '', 0], + ['> a\n> \n> ', 9, '> a\n', 4], + ['> a\n> \n> b', 6, '> a\n\n> b', 4], + ['x\n> \n> ', 7, 'x\n', 2], + ['> ', 3, '> \n> ', 6], + [' > a', 5, ' > a\n> ', 8], + ]; + + it.each(ROWS)('on %j at %i gives %j with the caret at %i', (text, caret, expected, expectedCaret) => { + const edit = planBlockquoteLineBreak(text, quoteLines(text), caret); + expect(applyEdit(text, edit)).toBe(expected); + expect(edit?.caret).toBe(expectedCaret); + }); + + it.each<[string, number]>([ + ['abc', 3], + ['', 0], + ['> test', 1], + ['x\n> a', 1], + ])('declines on %j at %i because the caret is not on a quote line', (text, caret) => { + expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull(); + }); + + it.each<[string, number]>([ + ['```\n> a', 7], + ['> ```', 5], + ['```\n> a\n```', 6], + ])('declines on %j at %i because the caret is inside a code block', (text, caret) => { + expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull(); + }); +}); + +describe('planBlockquoteBackspace', () => { + const ROWS: Array<[string, number, string, number]> = [ + ['> test', 2, 'test', 0], + ['> ', 2, '', 0], + ['> \n> ', 5, '> \n', 3], + ['> \n', 3, '', 0], + ['> \nabc', 3, 'abc', 0], + ['a\n> \nb', 5, 'a\nb', 2], + [' > a', 4, 'a', 0], + ['x\n> test', 4, 'x\ntest', 2], + ]; + + it.each(ROWS)('on %j at %i gives %j with the caret at %i', (text, caret, expected, expectedCaret) => { + const edit = planBlockquoteBackspace(text, quoteLines(text), caret); + expect(applyEdit(text, edit)).toBe(expected); + expect(edit?.caret).toBe(expectedCaret); + }); + + it.each<[string, number]>([ + ['> a\nb', 4], + ['> test', 4], + ['> ', 3], + ['abc', 3], + ['> \n> ', 3], + ['```\n> a', 6], + ])('declines on %j at %i', (text, caret) => { + expect(planBlockquoteBackspace(text, quoteLines(text), caret)).toBeNull(); + }); +}); + +describe('resolveBlockquoteCaret', () => { + it.each<[string, number, number]>([ + ['> test', 0, 2], + ['> test', 1, 2], + ['> test', 2, 2], + ['> test', 5, 5], + [' > a', 3, 4], + ['ab\n> cd', 3, 5], + ['ab\n> cd', 4, 5], + ['ab\n> cd', 1, 1], + ])('maps %j at %i to %i', (text, caret, expected) => { + expect(resolveBlockquoteCaret(quoteLines(text), caret)).toBe(expected); + }); +}); + +describe('planBlockquoteArrowLeft', () => { + it('moves to the end of the line above from a later quote line', () => { + expect(planBlockquoteArrowLeft(quoteLines('ab\n> cd'), 5)).toBe(2); + }); + + it('keeps the caret at content start on the first line', () => { + expect(planBlockquoteArrowLeft(quoteLines('> test'), 2)).toBe(2); + }); + + it.each<[string, number]>([ + ['> test', 4], + ['> test', 0], + ['abc', 2], + ])('declines on %j at %i', (text, caret) => { + expect(planBlockquoteArrowLeft(quoteLines(text), caret)).toBeNull(); + }); +}); + +describe('findBlockquoteMarkerEnds', () => { + it.each<[string, Array]>([ + ['> a', [2]], + [' > a', [4]], + ['> ', [2]], + ['>>> a\nb', [4, 0]], + ['>>> [!NOTE]\nbody', [0, 0]], + [' >>> [!tip] x\nbody', [0, 0]], + ['> > a', [2]], + ['> >>> a', [2]], + ['> [!NOTE]\n> body', [2, 2]], + ['> a\n> b', [2, 2]], + ['>a', [0]], + ['\\> a', [0]], + ['`> a`', [0]], + ['```\n> a', [0, 0]], + ['```\n> a\n```', [0, 0, 0]], + ['```\nx\n```\n> a', [0, 0, 0, 2]], + ])('gives %j the marker ends %j', (text, ends) => { + expect(findBlockquoteMarkerEnds(text, computeMarkdownHighlightSpans(text))).toEqual(ends); + }); + + it('treats whitespace after the prefix as content rather than marker', () => { + expect(computeMarkdownHighlightSpans('> ').find((span) => span.start === 2)).toMatchObject({ + role: 'content', + end: 3, + }); + }); +}); + +describe('dropTrailingEmptyBlockquoteLines', () => { + it.each<[string, string]>([ + ['> test\n> ', '> test'], + ['> ', ''], + ['> test\n> \n', '> test'], + ['> test\n', '> test'], + ['> \n> ', ''], + ['a\n > ', 'a'], + ['> a\n> ', '> a'], + ['> a\n>', '> a\n>'], + ['```\n> ', '```\n> '], + ['> a\n> b', '> a\n> b'], + ['', ''], + ])('turns %j into %j', (content, expected) => { + expect(dropTrailingEmptyBlockquoteLines(content)).toBe(expected); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/blockquoteLines.ts b/fluxer_app/src/features/lexical/composer/blockquoteLines.ts new file mode 100644 index 000000000..3c6739949 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/blockquoteLines.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {isOffsetInsideCodeBlock, scanCodeBlocks} from '@app/features/lexical/composer/codeBlockIndent'; +import {MarkdownHl, type MarkdownSpan} from '@app/features/lexical/composer/markdownSpans'; + +export interface BlockquoteLine { + start: number; + contentStart: number; + end: number; +} + +export interface BlockquoteEdit { + start: number; + end: number; + text: string; + caret: number; +} + +const CONTINUATION = '\n> '; +const DROPPABLE_TRAILING_LINE_RE = /^[ \t]*(?:> [ \t]*)?$/; +const MULTILINE_ALERT_RE = /^[ \t]*>>> \[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i; + +function isEmptyLine(line: BlockquoteLine): boolean { + return line.contentStart === line.end; +} + +function isDirectlyAbove(upper: BlockquoteLine, lower: BlockquoteLine): boolean { + return upper.end + 1 === lower.start; +} + +export function findBlockquoteMarkerEnds(source: string, spans: ReadonlyArray): Array { + const markerEndByStart = new Map(); + for (const span of spans) { + if (span.role === 'marker' && (span.format & MarkdownHl.blockquoteMarker) !== 0) { + markerEndByStart.set(span.start, span.end); + } + } + const blocks = scanCodeBlocks(source); + const ends: Array = []; + let lineStart = 0; + for (const line of source.split('\n')) { + const markerEnd = markerEndByStart.get(lineStart); + const insideCode = blocks.some((block) => block.start <= lineStart && lineStart <= block.end); + const suppressed = insideCode || MULTILINE_ALERT_RE.test(line); + ends.push(markerEnd == null || suppressed ? 0 : Math.min(markerEnd, lineStart + line.length) - lineStart); + lineStart += line.length + 1; + } + return ends; +} + +export function planBlockquoteLineBreak( + text: string, + lines: ReadonlyArray, + caret: number, +): BlockquoteEdit | null { + if (isOffsetInsideCodeBlock(text, caret)) { + return null; + } + const index = lines.findIndex((line) => line.contentStart <= caret && caret <= line.end); + if (index < 0) { + return null; + } + const line = lines[index]!; + const above = index > 0 ? lines[index - 1]! : null; + if (!isEmptyLine(line) || above == null || !isDirectlyAbove(above, line)) { + return {start: caret, end: caret, text: CONTINUATION, caret: caret + CONTINUATION.length}; + } + let top = index; + while (top > 0 && isDirectlyAbove(lines[top - 1]!, lines[top]!) && isEmptyLine(lines[top - 1]!)) { + top -= 1; + } + return {start: lines[top]!.start, end: line.contentStart, text: '', caret: lines[top]!.start}; +} + +export function planBlockquoteBackspace( + text: string, + lines: ReadonlyArray, + caret: number, +): BlockquoteEdit | null { + if (isOffsetInsideCodeBlock(text, caret)) { + return null; + } + const line = lines.find((candidate) => candidate.contentStart === caret); + if (line != null) { + return {start: line.start, end: line.contentStart, text: '', caret: line.start}; + } + const above = lines.find((candidate) => candidate.end + 1 === caret && isEmptyLine(candidate)); + if (above == null || lines.some((candidate) => candidate.start === caret)) { + return null; + } + return {start: above.start, end: caret, text: '', caret: above.start}; +} + +export function resolveBlockquoteCaret(lines: ReadonlyArray, caret: number): number { + const line = lines.find((candidate) => candidate.start <= caret && caret < candidate.contentStart); + return line == null ? caret : line.contentStart; +} + +export function planBlockquoteArrowLeft(lines: ReadonlyArray, caret: number): number | null { + const line = lines.find((candidate) => candidate.contentStart === caret); + if (line == null) { + return null; + } + return line.start > 0 ? line.start - 1 : line.contentStart; +} + +export function dropTrailingEmptyBlockquoteLines(content: string): string { + const blocks = scanCodeBlocks(content); + let end = content.length; + while (end > 0) { + const lineStart = content.lastIndexOf('\n', end - 1) + 1; + if ( + !DROPPABLE_TRAILING_LINE_RE.test(content.slice(lineStart, end)) || + blocks.some((block) => block.start <= lineStart && lineStart <= block.end) + ) { + break; + } + end = Math.max(0, lineStart - 1); + } + return content.slice(0, end); +} diff --git a/fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts b/fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts index e6eced11d..d452cef2d 100644 --- a/fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts +++ b/fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts @@ -11,6 +11,8 @@ import { $wrapComposerSelection, } from '@app/features/lexical/composer/composerOffsets'; import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans'; +import {ComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; +import {ComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode'; import { $isComposerCustomEmojiNode, ComposerCustomEmojiNode, @@ -261,7 +263,13 @@ const PARSER_ROWS: Array<[text: string, start: number, end: number, content: str function createComposer(): LexicalEditor { const editor = createEditor({ namespace: 'code-block-wrap-test', - nodes: [ComposerCustomEmojiNode, ComposerMentionNode, SyntaxMarkerNode], + nodes: [ + ComposerBlockquoteLineNode, + ComposerBlockquoteMarkerNode, + ComposerCustomEmojiNode, + ComposerMentionNode, + SyntaxMarkerNode, + ], onError: (error) => { throw error; }, diff --git a/fluxer_app/src/features/lexical/composer/composerOffsets.ts b/fluxer_app/src/features/lexical/composer/composerOffsets.ts index 65c2b8170..50f53dfbe 100644 --- a/fluxer_app/src/features/lexical/composer/composerOffsets.ts +++ b/fluxer_app/src/features/lexical/composer/composerOffsets.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type {BlockquoteLine} from '@app/features/lexical/composer/blockquoteLines'; import {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap'; +import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; +import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode'; import { $createComposerCustomEmojiNode, $isComposerCustomEmojiNode, @@ -199,6 +202,41 @@ function $captureSelectionOffsetsFromLayout(layout: DisplayLayout): ComposerSele }; } +export interface ComposerBlockquoteState { + scanText: string; + selection: ComposerSelectionOffsets | null; + lines: Array; +} + +function blockquoteLinesFromLayout(layout: DisplayLayout): Array { + const lines: Array = []; + for (const leaf of layout.leaves) { + const parent = leaf.node.getParent(); + if ( + !$isComposerBlockquoteMarkerNode(leaf.node) || + !$isComposerBlockquoteLineNode(parent) || + !leaf.node.is(parent.getFirstChild()) + ) { + continue; + } + const boundaries = layout.elementBoundaries.get(parent.getKey()); + if (boundaries == null) { + continue; + } + lines.push({start: leaf.start, contentStart: leaf.end, end: boundaries[boundaries.length - 1]!}); + } + return lines; +} + +export function $getComposerBlockquoteState(): ComposerBlockquoteState { + const layout = $buildDisplayLayout(); + return { + scanText: scanTextFromLayout(layout), + selection: $captureSelectionOffsetsFromLayout(layout), + lines: blockquoteLinesFromLayout(layout), + }; +} + export function $getComposerSelectionRange(): {start: number; end: number} | null { const offsets = $captureSelectionOffsets(); if (offsets == null) { @@ -220,10 +258,6 @@ export function $getComposerDisplayText(): string { return $buildDisplayLayout().text; } -export function $getComposerScanText(): string { - return scanTextFromLayout($buildDisplayLayout()); -} - function scanTextFromLayout({text, leaves}: DisplayLayout): string { let scanText = ''; let offset = 0; @@ -241,6 +275,10 @@ function scanTextFromLayout({text, leaves}: DisplayLayout): string { return scanText + text.slice(offset); } +export function $getComposerScanText(): string { + return scanTextFromLayout($buildDisplayLayout()); +} + function $pointAtDisplayOffset(layout: DisplayLayout, offset: number): DisplayPoint { const clampedOffset = Math.min(Math.max(0, offset), layout.text.length); for (const leaf of layout.leaves) { diff --git a/fluxer_app/src/features/lexical/composer/markdownSpans.ts b/fluxer_app/src/features/lexical/composer/markdownSpans.ts index 751fb4739..47469273d 100644 --- a/fluxer_app/src/features/lexical/composer/markdownSpans.ts +++ b/fluxer_app/src/features/lexical/composer/markdownSpans.ts @@ -22,6 +22,7 @@ export const MarkdownHl = { link: 1 << 9, codeBlock: 1 << 10, silent: 1 << 11, + blockquoteMarker: 1 << 12, } as const; export type MarkdownHlFormat = number; @@ -793,20 +794,21 @@ function alignBlockquote(children: Array, format: MarkdownHlFormat, ctx: A while (ctx.source[trimmedStart] === ' ' || ctx.source[trimmedStart] === '\t' || ctx.source[trimmedStart] === '\r') { trimmedStart += 1; } - const markerRanges: Array<{start: number; end: number}> = []; + const markerRanges: Array<{start: number; end: number; quote: boolean}> = []; + const blankRanges: Array<{start: number; end: number}> = []; const sourceOffsets: Array = []; let virtualSource = ''; let blockEnd = start; if (ctx.source.startsWith('>>> ', trimmedStart)) { const contentStart = trimmedStart + 4; - markerRanges.push({start, end: contentStart}); + markerRanges.push({start, end: contentStart, quote: true}); virtualSource = ctx.source.slice(contentStart); for (let position = contentStart; position < ctx.source.length; position += 1) { sourceOffsets.push(position); } blockEnd = ctx.source.length; } else { - const lines: Array<{start: number; end: number; contentStart: number}> = []; + const lines: Array<{start: number; end: number; prefixEnd: number; contentStart: number}> = []; let lineStart = start; while (lineStart <= ctx.source.length) { const newline = ctx.source.indexOf('\n', lineStart); @@ -820,7 +822,7 @@ function alignBlockquote(children: Array, format: MarkdownHlFormat, ctx: A } const rest = ctx.source.slice(quoteStart + 2, lineEnd); const contentStart = /^[ \t\r]*$/.test(rest) ? lineEnd : quoteStart + 2; - lines.push({start: lineStart, end: lineEnd, contentStart}); + lines.push({start: lineStart, end: lineEnd, prefixEnd: quoteStart + 2, contentStart}); if (newline < 0) { break; } @@ -832,10 +834,13 @@ function alignBlockquote(children: Array, format: MarkdownHlFormat, ctx: A } for (let index = 0; index < lines.length; index += 1) { const line = lines[index]!; - markerRanges.push({start: line.start, end: line.contentStart}); + markerRanges.push({start: line.start, end: line.prefixEnd, quote: true}); + if (line.contentStart > line.prefixEnd) { + blankRanges.push({start: line.prefixEnd, end: line.contentStart}); + } let repeatedPrefix = line.contentStart; while (ctx.source.startsWith('> ', repeatedPrefix) && repeatedPrefix < line.end) { - markerRanges.push({start: repeatedPrefix, end: repeatedPrefix + 2}); + markerRanges.push({start: repeatedPrefix, end: repeatedPrefix + 2, quote: false}); repeatedPrefix += 2; } for (let position = line.contentStart; position < line.end; position += 1) { @@ -890,12 +895,18 @@ function alignBlockquote(children: Array, format: MarkdownHlFormat, ctx: A return; } roles[sourceOffset - start] = span.role; - formats[sourceOffset - start] = span.format; + formats[sourceOffset - start] = span.format & ~MarkdownHl.blockquoteMarker; } } for (const range of markerRanges) { for (let position = range.start; position < range.end; position += 1) { roles[position - start] = 'marker'; + formats[position - start] = range.quote ? format | MarkdownHl.blockquoteMarker : format; + } + } + for (const range of blankRanges) { + for (let position = range.start; position < range.end; position += 1) { + roles[position - start] = 'content'; formats[position - start] = format; } } @@ -933,7 +944,7 @@ function alignAlert(format: MarkdownHlFormat, ctx: AlignContext): void { } const newline = ctx.source.indexOf('\n', ctx.pos); const lineEnd = newline < 0 ? ctx.source.length : newline; - pushMarker(ctx, quoteStart + 2 - ctx.pos, bodyFormat); + pushMarker(ctx, quoteStart + 2 - ctx.pos, bodyFormat | MarkdownHl.blockquoteMarker); if (firstLine) { const close = ctx.source.indexOf(']', ctx.pos); if (!ctx.source.startsWith('[!', ctx.pos) || close < 0 || close >= lineEnd) { diff --git a/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteLineNode.test.ts b/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteLineNode.test.ts new file mode 100644 index 000000000..b1fd4e77f --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteLineNode.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + $createComposerBlockquoteLineNode, + $getComposerLineNodes, + $isComposerBlockquoteLineNode, + ComposerBlockquoteLineNode, +} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + createEditor, + type EditorConfig, + type LexicalEditor, +} from 'lexical'; +import {describe, expect, it} from 'vitest'; + +const CONFIG: EditorConfig = {namespace: 'blockquote-line-test', theme: {composerBlockquoteLine: 'quote-line'}}; + +function createTestEditor(): LexicalEditor { + return createEditor({ + namespace: 'blockquote-line-test', + nodes: [ComposerBlockquoteLineNode], + onError: (error) => { + throw error; + }, + }); +} + +describe('ComposerBlockquoteLineNode', () => { + it('creates a span carrying the theme class', () => { + const editor = createTestEditor(); + let dom: HTMLElement | null = null; + editor.update( + () => { + dom = $createComposerBlockquoteLineNode().createDOM(CONFIG); + }, + {discrete: true}, + ); + expect(dom).not.toBeNull(); + expect(dom!.tagName).toBe('SPAN'); + expect(dom!.className).toBe('quote-line'); + }); + + it('is an inline node that cannot be left empty', () => { + const editor = createTestEditor(); + let shape: {inline: boolean; canBeEmpty: boolean} | null = null; + editor.update( + () => { + const wrapper = $createComposerBlockquoteLineNode(); + shape = {inline: wrapper.isInline(), canBeEmpty: wrapper.canBeEmpty()}; + }, + {discrete: true}, + ); + expect(shape).toEqual({inline: true, canBeEmpty: false}); + }); + + it('inserts an attached empty sibling wrapper on insertNewAfter', () => { + const editor = createTestEditor(); + let result: {isWrapper: boolean; attached: boolean; children: number; isNextSibling: boolean} | null = null; + editor.update( + () => { + const paragraph = $createParagraphNode(); + const wrapper = $createComposerBlockquoteLineNode(); + wrapper.append($createTextNode('a')); + paragraph.append(wrapper); + $getRoot().clear().append(paragraph); + const next = wrapper.insertNewAfter($createRangeSelection(), false); + result = { + isWrapper: $isComposerBlockquoteLineNode(next), + attached: next.isAttached(), + children: next.getChildrenSize(), + isNextSibling: next.is(wrapper.getNextSibling()), + }; + }, + {discrete: true}, + ); + expect(result).toEqual({isWrapper: true, attached: true, children: 0, isNextSibling: true}); + }); +}); + +describe('$getComposerLineNodes', () => { + it('expands wrappers in document order and leaves other children alone', () => { + const editor = createTestEditor(); + let texts: Array = []; + let keysMatch = false; + editor.update( + () => { + const paragraph = $createParagraphNode(); + const lead = $createTextNode('a'); + const wrapper = $createComposerBlockquoteLineNode(); + const marker = $createTextNode('> '); + const body = $createTextNode('b'); + wrapper.append(marker, body); + const tail = $createTextNode('c'); + paragraph.append(lead, wrapper, tail); + $getRoot().clear().append(paragraph); + const nodes = $getComposerLineNodes(paragraph); + texts = nodes.map((node) => node.getTextContent()); + keysMatch = nodes.every((node, index) => node.is([lead, marker, body, tail][index])); + }, + {discrete: true}, + ); + expect(texts).toEqual(['a', '> ', 'b', 'c']); + expect(keysMatch).toBe(true); + }); + + it('returns the children unchanged when there is no wrapper', () => { + const editor = createTestEditor(); + let texts: Array = []; + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('a'), $createTextNode('b')); + $getRoot().clear().append(paragraph); + texts = $getComposerLineNodes(paragraph).map((node) => node.getTextContent()); + }, + {discrete: true}, + ); + expect(texts).toEqual(['a', 'b']); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteLineNode.ts b/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteLineNode.ts new file mode 100644 index 000000000..b09337e50 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteLineNode.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + $applyNodeReplacement, + type EditorConfig, + ElementNode, + type LexicalNode, + type RangeSelection, + type SerializedElementNode, +} from 'lexical'; + +export class ComposerBlockquoteLineNode extends ElementNode { + static override getType(): string { + return 'composer-blockquote-line'; + } + + static override clone(node: ComposerBlockquoteLineNode): ComposerBlockquoteLineNode { + return new ComposerBlockquoteLineNode(node.__key); + } + + static override importJSON(serializedNode: SerializedElementNode): ComposerBlockquoteLineNode { + return $createComposerBlockquoteLineNode().updateFromJSON(serializedNode); + } + + override createDOM(config: EditorConfig): HTMLElement { + const span = document.createElement('span'); + const className = config.theme.composerBlockquoteLine; + if (typeof className === 'string') { + span.className = className; + } + return span; + } + + override updateDOM(): boolean { + return false; + } + + override isInline(): true { + return true; + } + + override canBeEmpty(): false { + return false; + } + + override insertNewAfter(_selection: RangeSelection, restoreSelection = true): ComposerBlockquoteLineNode { + const next = $createComposerBlockquoteLineNode(); + this.insertAfter(next, restoreSelection); + return next; + } +} + +export function $createComposerBlockquoteLineNode(): ComposerBlockquoteLineNode { + return $applyNodeReplacement(new ComposerBlockquoteLineNode()); +} + +export function $isComposerBlockquoteLineNode( + node: LexicalNode | null | undefined, +): node is ComposerBlockquoteLineNode { + return node instanceof ComposerBlockquoteLineNode; +} + +export function $getComposerLineNodes(element: ElementNode): Array { + const nodes: Array = []; + for (const child of element.getChildren()) { + if ($isComposerBlockquoteLineNode(child)) { + nodes.push(...$getComposerLineNodes(child)); + } else { + nodes.push(child); + } + } + return nodes; +} diff --git a/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode.ts b/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode.ts new file mode 100644 index 000000000..981a3a95b --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode'; +import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical'; + +export class ComposerBlockquoteMarkerNode extends SyntaxMarkerNode { + static override getType(): string { + return 'composer-blockquote-marker'; + } + + static override clone(node: ComposerBlockquoteMarkerNode): ComposerBlockquoteMarkerNode { + return new ComposerBlockquoteMarkerNode(node.__text, node.__key); + } + + static override importJSON(serializedNode: SerializedTextNode): ComposerBlockquoteMarkerNode { + return $createComposerBlockquoteMarkerNode(serializedNode.text).updateFromJSON(serializedNode); + } + + override createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + const className = config.theme.composerBlockquoteMarker; + if (typeof className === 'string') { + dom.classList.add(className); + } + return dom; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } +} + +export function $createComposerBlockquoteMarkerNode(text: string): ComposerBlockquoteMarkerNode { + return new ComposerBlockquoteMarkerNode(text); +} + +export function $isComposerBlockquoteMarkerNode( + node: LexicalNode | null | undefined, +): node is ComposerBlockquoteMarkerNode { + return node instanceof ComposerBlockquoteMarkerNode; +} diff --git a/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx b/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx index a390d2a17..3fba0d960 100644 --- a/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx +++ b/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx @@ -12,6 +12,7 @@ import Channels from '@app/features/channel/state/Channels'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet'; import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout'; +import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines'; import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput'; import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; import {MessageForwardFailedModal} from '@app/features/messaging/components/alerts/MessageForwardFailedModal'; @@ -225,7 +226,7 @@ export const ForwardModal = observer( return selectedChannel == null ? null : selectedChannel; }, [mostRecentlySelectedChannelId]); const handleCommentChange = useCallback((_display: string, _segments: Array, wire: string) => { - setActualOptionalMessage(wire); + setActualOptionalMessage(dropTrailingEmptyBlockquoteLines(wire)); }, []); const isCommentOverLimit = actualOptionalMessage.length > user.maxMessageLength; const isSendBlockedBySlowmode = slowmodeActiveSelectedChannelOptions.length > 0; diff --git a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts index e5ed06f30..cb62ba4cb 100644 --- a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts +++ b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts @@ -9,6 +9,7 @@ import Emoji from '@app/features/emoji/state/Emoji'; import {checkEmojiAvailabilityWithGuildFallback} from '@app/features/expressions/utils/ExpressionPermissionUtils'; import ChannelMemberCount from '@app/features/guild/state/ChannelMemberCount'; import Guilds from '@app/features/guild/state/Guilds'; +import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines'; import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle'; import { type LexicalMessageCommandResolution, @@ -446,7 +447,7 @@ export const useTextareaSubmit = ({ let actualContent = displayToActual(value).trim(); if (composerHandle !== null) { lexicalCommand = LexicalMessageCommandResolver.resolve(composerHandle); - actualContent = composerHandle.getWireValue().trim(); + actualContent = dropTrailingEmptyBlockquoteLines(composerHandle.getWireValue()).trim(); } const resolvedContent = resolveTypedEmojiContent(actualContent); let parsedCommand: CommandUtils.ParsedCommand | null = null; diff --git a/fluxer_app/src/features/user/components/modals/tabs/MyProfileTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/MyProfileTab.tsx index bd6a1cb1f..3eb7cdc1f 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/MyProfileTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/MyProfileTab.tsx @@ -12,6 +12,7 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet'; import Guilds from '@app/features/guild/state/Guilds'; +import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines'; import type {LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput'; import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberCommands'; import GuildMembers from '@app/features/member/state/GuildMembers'; @@ -448,7 +449,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({ const handleBioChange = useCallback((display: string, segments: Array, wire: string) => { setBioValue(display); setBioSegments(segments); - setBioActualValue(wire); + setBioActualValue(dropTrailingEmptyBlockquoteLines(wire)); }, []); const onSubmit = useCallback( async (data: FormInputs) => {