diff --git a/fluxer_app/src/features/channel/components/EditingMessageInput.tsx b/fluxer_app/src/features/channel/components/EditingMessageInput.tsx index 42b2ed06e..17a33178d 100644 --- a/fluxer_app/src/features/channel/components/EditingMessageInput.tsx +++ b/fluxer_app/src/features/channel/components/EditingMessageInput.tsx @@ -293,6 +293,7 @@ export const EditingMessageInput = observer( markdown={true} singleLine={!mobileLayout.enabled} size="chat" + maxWireLength={maxMessageLength} className={editingStyles.editor} autocompleteAnchor={containerRef.current} ariaLabel={i18n._(EDIT_MESSAGE_DESCRIPTOR)} diff --git a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx index 94fdf725c..9341b0a97 100644 --- a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx +++ b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx @@ -1356,6 +1356,7 @@ export const LexicalChannelTextareaContent = observer( channelId={channel.id} guildId={channel.guildId} submitOnEnter={!mobileLayout.enabled} + maxWireLength={maxMessageLength} silentMessagePrefix={!isEditingMessageOnMobile} focusRingTarget={containerRef} focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing} 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 22232d631..a430af5a3 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 @@ -157,7 +157,7 @@ export const ChannelOverviewTopicEditor = observer( }); }, [actualTopic, form, isTopicInitialized]); const handleTopicChange = useCallback((_display: string, _segments: Array, wire: string) => { - setActualTopic(dropTrailingEmptyBlockquoteLines(wire)); + setActualTopic(dropTrailingEmptyBlockquoteLines(wire, TOPIC_MARKDOWN_PARSER_FLAGS)); }, []); const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => { const composer = composerRef.current; diff --git a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx index 05bf6e5b5..7738ee0a1 100644 --- a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx +++ b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx @@ -6,8 +6,7 @@ import Emoji from '@app/features/emoji/state/Emoji'; import type {GuildEmoji} from '@app/features/expressions/models/GuildEmoji'; import GuildGuilds from '@app/features/guild/state/Guilds'; import { - $insertComposerClipboardSlice, - type ComposerClipboardSlice, + $insertComposerPastedText, FLUXER_COMPOSER_CLIPBOARD_MIME, getComposerClipboardTextPlain, parseComposerClipboardSlice, @@ -23,7 +22,6 @@ import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentP import {getClipboardDataFiles, readClipboardImageFiles} from '@app/features/messaging/utils/ClipboardFilePasteUtils'; import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils'; import {detectPastedSegments, type LookupFunctions} from '@app/features/messaging/utils/PasteSegmentUtils'; -import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; import {isDialogPasteTarget} from '@app/features/messaging/utils/TextInputEditUtils'; import {canFocusTextarea, safeFocus} from '@app/features/platform/utils/InputFocusManager'; import QuickSwitcher from '@app/features/search/state/QuickSwitcher'; @@ -47,27 +45,6 @@ interface UseChannelComposerPasteParams { textareaInputDisabled: boolean; } -function createComposerPasteSlice(pastedText: string, segments: ReadonlyArray): ComposerClipboardSlice { - const displayParts: Array = []; - const projectedSegments: Array = []; - let sourceCursor = 0; - let displayLength = 0; - for (const segment of segments) { - const plainText = pastedText.slice(sourceCursor, segment.start); - displayParts.push(plainText, segment.displayText); - displayLength += plainText.length; - projectedSegments.push({ - ...segment, - start: displayLength, - end: displayLength + segment.displayText.length, - }); - displayLength += segment.displayText.length; - sourceCursor = segment.end; - } - displayParts.push(pastedText.slice(sourceCursor)); - return {display: displayParts.join(''), segments: projectedSegments}; -} - export function useChannelComposerPaste({ channel, handleRef, @@ -173,13 +150,14 @@ export function useChannelComposerPaste({ return null; }, }; - const segments = detectPastedSegments(pastedText, 0, lookups); - const richSlice = createComposerPasteSlice(pastedText, segments); - const plainText = getComposerClipboardTextPlain(richSlice); - const slice = plainText === null || plainText === undefined ? {display: pastedText, segments: []} : richSlice; - return $insertComposerClipboardSlice(slice, ChatInputSettings.renderComposerAsPlainText); + return $insertComposerPastedText( + pastedText, + detectPastedSegments(pastedText, 0, lookups), + ChatInputSettings.renderComposerAsPlainText, + handleRef.current, + ); }, - [channel.guildId], + [channel.guildId, handleRef], ); useEffect(() => { const handle = handleRef.current; diff --git a/fluxer_app/src/features/lexical/composer/ComposerBlockquote.test.ts b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.test.ts index 78e2d8803..48c04cec3 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerBlockquote.test.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.test.ts @@ -2,12 +2,28 @@ 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 { + $getComposerClipboardSelection, + $insertComposerClipboardSlice, + $insertComposerPastedText, + COMPOSER_CLIPBOARD_MAX_TRUSTED_PAYLOADS, + FLUXER_COMPOSER_CLIPBOARD_MIME, + parseComposerClipboardSlice, + registerComposerClipboardCommands, +} from '@app/features/lexical/composer/ComposerClipboard'; +import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent'; import {registerComposerEmojiShortcode} from '@app/features/lexical/composer/ComposerEmojiShortcode'; +import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle'; import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight'; +import {registerComposerMarkdownShortcuts} from '@app/features/lexical/composer/ComposerMarkdownShortcuts'; 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 { + $getComposerBlockquoteState, + $queryComposerSelectionWrappers, + $selectComposerRange, + $wrapComposerSelection, +} from '@app/features/lexical/composer/composerOffsets'; import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans'; import { $isComposerBlockquoteLineNode, @@ -24,7 +40,10 @@ import { } 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 { + $isComposerStandardEmojiNode, + 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'; @@ -32,6 +51,7 @@ import {SlashSlotPlaceholderNode} from '@app/features/lexical/composer/nodes/Sla 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 type {ResolvedTypedEmoji} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import { $createParagraphNode, @@ -47,13 +67,23 @@ import { COMMAND_PRIORITY_HIGH, COMPOSITION_START_COMMAND, CONTROLLED_TEXT_INSERTION_COMMAND, + COPY_COMMAND, createEditor, DELETE_CHARACTER_COMMAND, + DELETE_LINE_COMMAND, + DELETE_WORD_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + HISTORY_MERGE_TAG, INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_DOWN_COMMAND, + KEY_TAB_COMMAND, type LexicalCommand, type LexicalEditor, + PASTE_COMMAND, REDO_COMMAND, SELECTION_CHANGE_COMMAND, UNDO_COMMAND, @@ -67,6 +97,24 @@ vi.mock('@app/features/lexical/composer/nodes/SlashOptionalHintPill', () => ({Sl vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({getEmojiURL: () => null})); vi.mock('@lingui/core/macro', () => ({msg: (descriptor: unknown) => descriptor})); +const platform = vi.hoisted(() => ({apple: false})); +const dropOffset = vi.hoisted(() => ({value: null as number | null})); + +vi.mock('lexical', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get IS_APPLE() { + return platform.apple; + }, + }; +}); + +vi.mock('@app/features/lexical/composer/composerOffsets', async (importOriginal) => { + const actual = await importOriginal(); + return {...actual, $getComposerDropOffset: () => dropOffset.value}; +}); + const NODES = [ ComposerMentionNode, ComposerCustomEmojiNode, @@ -99,12 +147,31 @@ const BIO_LIKE_FLAGS = const COMPOSITION_EVENT = {timeStamp: Number.MAX_SAFE_INTEGER, data: ''} as unknown as CompositionEvent; +const GRINNING: ResolvedTypedEmoji = { + kind: 'standard', + name: 'grinning', + surrogate: '😀', + url: null, + display: ':grinning:', +}; + +function parserFlagsHandle(flags: number): ComposerHandle { + return {getMarkdownParserFlags: () => flags} as unknown as ComposerHandle; +} + +const RTL_ELEMENT = { + ownerDocument: {defaultView: {getComputedStyle: () => ({direction: 'rtl'})}}, +} as unknown as HTMLElement; + const disposers: Array<() => void> = []; afterEach(() => { while (disposers.length > 0) { disposers.pop()!(); } + platform.apple = false; + dropOffset.value = null; + vi.unstubAllGlobals(); }); function createBareEditor(): LexicalEditor { @@ -146,9 +213,12 @@ function registerPlainTextFallbacks(editor: LexicalEditor): void { ); } -function createHarness(options: {flags?: number; history?: boolean} = {}): LexicalEditor { +function createHarness(options: {flags?: number; history?: boolean; maxWireLength?: number} = {}): LexicalEditor { const editor = createBareEditor(); - disposers.push(registerComposerMarkdownHighlight(editor, options.flags), registerComposerBlockquote(editor)); + disposers.push( + registerComposerMarkdownHighlight(editor, options.flags, false, options.maxWireLength), + registerComposerBlockquote(editor, options.flags), + ); registerPlainTextFallbacks(editor); if (options.history === true) { disposers.push(registerHistory(editor, createEmptyHistoryState(), 0)); @@ -156,6 +226,20 @@ function createHarness(options: {flags?: number; history?: boolean} = {}): Lexic return editor; } +function createFormattingHarness(flags = DEFAULT_COMPOSER_MARKDOWN_FLAGS): LexicalEditor { + const editor = createHarness({flags}); + disposers.push( + registerComposerMarkdownShortcuts(editor), + registerComposerCodeIndent(editor, {current: false}), + registerComposerClipboardCommands(editor, { + getPlainText: () => false, + isEditable: () => true, + getMarkdownParserFlags: () => flags, + }), + ); + return editor; +} + function update(editor: LexicalEditor, fn: () => void): void { editor.update(fn, {discrete: true}); } @@ -343,17 +427,17 @@ describe('blockquote line splits', () => { }); }); - it('keeps text order when a clipboard slice with a newline lands mid quote line', () => { + it('continues the quote 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, + wire: '> abx\n> ycd', + wrappers: 2, breaksInsideWrappers: 0, - selection: {anchor: 7, focus: 7}, + selection: {anchor: 9, focus: 9}, }); }); @@ -372,6 +456,650 @@ describe('blockquote line splits', () => { }); }); +describe('blockquote paste', () => { + function pasteEvent(text: string): {clipboardData: DataTransfer; preventDefault: ReturnType} { + return { + clipboardData: {getData: (type: string) => (type === 'text/plain' ? text : '')} as unknown as DataTransfer, + preventDefault: vi.fn(), + }; + } + + function paste(editor: LexicalEditor, text: string): {handled: boolean; prevented: boolean} { + const event = pasteEvent(text); + const handled = run(editor, PASTE_COMMAND, event as unknown as ClipboardEvent); + return {handled, prevented: event.preventDefault.mock.calls.length > 0}; + } + + function createRawPasteHarness(): LexicalEditor { + const editor = createHarness(); + disposers.push( + editor.registerCommand( + PASTE_COMMAND, + (event) => { + const selection = $getSelection(); + const data = (event as ClipboardEvent).clipboardData; + if (!$isRangeSelection(selection) || data == null) { + return false; + } + selection.insertRawText(data.getData('text/plain')); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + ); + return editor; + } + + const PASTE_ROWS: Array<[string, number, number, string, string, number]> = [ + ['> abcd', 4, 4, 'x\ny', '> abx\n> ycd', 9], + ['> abcd', 2, 2, 'x\ny', '> x\n> yabcd', 7], + ['> ', 2, 2, 'x\ny', '> x\n> y', 7], + [' > a', 5, 5, 'x\ny', ' > ax\n> y', 10], + ['> abcd', 4, 4, 'x\n\ny', '> abx\n> \n> ycd', 12], + ['> abcd', 6, 6, '\n', '> abcd\n> ', 9], + ['> a\n> ', 6, 6, '\n', '> a\n> \n> ', 9], + ['> abcd', 6, 6, 'x\r\ny', '> abcdx\n> y', 11], + ['> ab\n> cd', 3, 8, 'x\ny', '> ax\n> yd', 8], + ['> ', 2, 2, '> x\n> y', '> x\n> y', 7], + ['> ', 2, 2, '> x', '> x', 3], + ['x\n> ', 4, 4, ' > a\r\n> b', 'x\n> a\n> b', 9], + ['> ab', 2, 2, '> x', '> xab', 3], + ['> ab', 2, 4, '> x\n> y', '> x\n> y', 7], + ]; + + it.each( + PASTE_ROWS, + )('on %j at %i..%i pasting %j gives %j with the caret at %i', (text, anchor, focus, pasted, wire, caret) => { + const editor = createHarness(); + setComposer(editor, text, anchor, focus); + expect(paste(editor, pasted)).toEqual({handled: true, prevented: true}); + expect(snapshot(editor)).toMatchObject({ + wire, + selection: {anchor: caret, focus: caret}, + wrappers: expectedQuoteLines(wire), + breaksInsideWrappers: 0, + }); + }); + + const DECLINE_ROWS: Array<[string, number, number, string]> = [ + ['abcd', 2, 2, 'x\ny'], + ['> abcd', 4, 4, 'xy'], + ['```\n> a', 7, 7, 'x\ny'], + ['> ab', 4, 4, 'x\n> y'], + ['> ```', 5, 5, 'x\ny'], + ['> ab', 4, 4, '> x'], + ['> ', 2, 2, 'x'], + ]; + + it.each(DECLINE_ROWS)('declines %j at %i..%i pasting %j', (text, anchor, focus, pasted) => { + const editor = createHarness(); + setComposer(editor, text, anchor, focus); + expect(paste(editor, pasted)).toEqual({handled: false, prevented: false}); + expect(snapshot(editor).wire).toBe(text); + }); + + it('declines a paste inside a quote line without ALLOW_BLOCKQUOTES', () => { + const editor = createHarness({flags: NO_BLOCKQUOTE_FLAGS}); + setComposer(editor, '> abcd', 4); + expect(paste(editor, 'x\ny')).toEqual({handled: false, prevented: false}); + expect(snapshot(editor)).toMatchObject({wire: '> abcd', wrappers: 0}); + }); + + it('plans a paste below a quoted fence with the flags the handler was registered with', () => { + const editor = createHarness({flags: BIO_LIKE_FLAGS}); + setComposer(editor, '> ```', 5); + expect(paste(editor, 'x\ny')).toEqual({handled: true, prevented: true}); + expect(snapshot(editor)).toMatchObject({ + wire: '> ```x\n> y', + selection: {anchor: 10, focus: 10}, + wrappers: 2, + }); + }); + + const BARE_PREFIX_ROWS: Array<[string, number, number, string, string, number]> = [ + ['> ', 2, 2, '> ', '> ', 2], + ['> ab', 2, 4, '> ', '> ', 2], + [' > ab', 4, 6, ' > ', ' > ', 4], + ['> ab', 2, 4, '\t> ', '> ', 2], + ]; + + it.each( + BARE_PREFIX_ROWS, + )('on %j at %i..%i pasting the bare prefix %j gives %j with the caret at %i', (text, anchor, focus, pasted, wire, caret) => { + const editor = createHarness(); + setComposer(editor, text, anchor, focus); + expect(paste(editor, pasted)).toEqual({handled: true, prevented: true}); + expect(snapshot(editor)).toMatchObject({ + wire, + selection: {anchor: caret, focus: caret}, + wrappers: expectedQuoteLines(wire), + }); + }); + + it.each<[string, number, number, string, number]>([ + ['> ', 2, 2, '> ', 2], + ['> ab', 2, 4, '> ', 2], + ])('inserts a bare prefix slice over %j at %i..%i, giving %j with the caret at %i', (text, anchor, focus, wire, caret) => { + const editor = createHarness(); + setComposer(editor, text, anchor, focus); + let inserted = false; + update(editor, () => { + inserted = $insertComposerClipboardSlice({display: '> ', segments: []}, false); + }); + expect(inserted).toBe(true); + expect(snapshot(editor)).toMatchObject({wire, selection: {anchor: caret, focus: caret}, wrappers: 1}); + }); + + it('removes the selected range when a dropped bare prefix plans to nothing', () => { + const editor = createHarness(); + setComposer(editor, '> ab', 2, 4); + const payload = {dataTransfer: {getData: () => '> '}} as unknown as InputEvent; + expect(run(editor, CONTROLLED_TEXT_INSERTION_COMMAND, payload)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ', selection: {anchor: 2, focus: 2}, wrappers: 1}); + }); + + it('keeps every line quoted when multi-line text is inserted at the cursor inside a quote', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + let inserted = false; + update(editor, () => { + inserted = $insertComposerClipboardSlice({display: 'x\ny\nz', segments: []}, false); + }); + expect(inserted).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: '> abx\n> y\n> zcd', + selection: {anchor: 13, focus: 13}, + wrappers: 3, + }); + }); + + it.each<[string, number, string, string, number]>([ + ['> ab', 4, 'x\n> y', '> abx\n> y', 9], + ['> ```', 5, 'x\ny', '> ```x\ny', 8], + ['> ab', 4, '> x', '> ab> x', 7], + ])('leaves %j at %i pasting %j to lexical, giving %j and the caret at %i', (text, caret, pasted, wire, after) => { + const editor = createRawPasteHarness(); + setComposer(editor, text, caret); + expect(paste(editor, pasted)).toEqual({handled: true, prevented: false}); + expect(snapshot(editor)).toMatchObject({ + wire, + selection: {anchor: after, focus: after}, + wrappers: expectedQuoteLines(wire), + }); + }); + + it('continues the quote when text is dropped on a quote line', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + const payload = {dataTransfer: {getData: () => 'x\ny'}} as unknown as InputEvent; + expect(run(editor, CONTROLLED_TEXT_INSERTION_COMMAND, payload)).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: '> abx\n> ycd', + selection: {anchor: 9, focus: 9}, + wrappers: 2, + }); + }); + + it('merges a dropped quote line into an empty quote line', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + const payload = {dataTransfer: {getData: () => '> x'}} as unknown as InputEvent; + expect(run(editor, CONTROLLED_TEXT_INSERTION_COMMAND, payload)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> x', selection: {anchor: 3, focus: 3}, wrappers: 1}); + }); + + it('drops the quote prefix of the first slice line at a quote content start and shifts its mention', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + const segment: MentionSegment = { + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start: 2, + end: 7, + }; + update(editor, () => { + $insertComposerClipboardSlice({display: '> @name\n> b', segments: [segment]}, false); + }); + expect(snapshot(editor)).toMatchObject({wire: '> <@1>\n> b', selection: {anchor: 11, focus: 11}, wrappers: 2}); + expect(editor.getEditorState().read(() => $projectComposer(), {editor}).segments).toMatchObject([ + {start: 2, end: 7}, + ]); + }); + + it('deletes forward from the quote content start a pasted trailing newline leaves the caret at', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + expect(paste(editor, 'x\n').handled).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> abx\n> cd', selection: {anchor: 8, focus: 8}}); + expect(run(editor, DELETE_CHARACTER_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> abx\n> d', selection: {anchor: 8, focus: 8}, wrappers: 2}); + }); + + it('declines a paste and a drop while composing', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + update(editor, () => { + $setCompositionKey($getRoot().getAllTextNodes()[1]!.getKey()); + }); + expect(editor.isComposing()).toBe(true); + expect(paste(editor, 'x\ny')).toEqual({handled: false, prevented: false}); + const payload = {dataTransfer: {getData: () => 'x\ny'}} as unknown as InputEvent; + expect(run(editor, CONTROLLED_TEXT_INSERTION_COMMAND, payload)).toBe(false); + expect(snapshot(editor).wire).toBe('> abcd'); + }); + + it('leaves a controlled insertion without a data transfer to the editor fallback', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + expect(run(editor, CONTROLLED_TEXT_INSERTION_COMMAND, {dataTransfer: null} as unknown as InputEvent)).toBe(false); + expect(run(editor, CONTROLLED_TEXT_INSERTION_COMMAND, 'xy')).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> abxycd', wrappers: 1}); + }); + + it('remaps a mention on a continued slice line', () => { + const editor = createHarness(); + setComposer(editor, '> abcd', 4); + const segment: MentionSegment = { + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start: 3, + end: 8, + }; + update(editor, () => { + $insertComposerClipboardSlice({display: 'x\r\n@name y', segments: [segment]}, false); + }); + expect(snapshot(editor)).toMatchObject({ + wire: '> abx\n> <@1> ycd', + selection: {anchor: 15, focus: 15}, + wrappers: 2, + }); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toMatchObject({ + display: '> abx\n> @name ycd', + segments: [{...segment, start: 8, end: 13}], + }); + }); + + it('quotes both slice lines when a mention lands on each', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + const mention = (start: number): MentionSegment => ({ + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start, + end: start + 5, + }); + update(editor, () => { + $insertComposerClipboardSlice({display: '@name\n@name', segments: [mention(0), mention(6)]}, false); + }); + expect(snapshot(editor)).toMatchObject({ + wire: '> <@1>\n> <@1>', + selection: {anchor: 15, focus: 15}, + wrappers: 2, + }); + expect(editor.getEditorState().read(() => $projectComposer(), {editor}).segments).toMatchObject([ + {start: 2, end: 7}, + {start: 10, end: 15}, + ]); + }); + + it('keeps a mention quoted after a blank slice line', () => { + const editor = createHarness(); + setComposer(editor, '> ab', 4); + const segment: MentionSegment = { + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start: 3, + end: 8, + }; + update(editor, () => { + $insertComposerClipboardSlice({display: 'x\n\n@name', segments: [segment]}, false); + }); + expect(snapshot(editor)).toMatchObject({wire: '> abx\n> \n> <@1>', wrappers: 3}); + expect(editor.getEditorState().read(() => $projectComposer(), {editor}).segments).toMatchObject([ + {start: 11, end: 16}, + ]); + }); + + it('continues the quote when a slice replaces a range across two quote lines', () => { + const editor = createHarness(); + setComposer(editor, '> ab\n> cd', 3, 8); + const segment: MentionSegment = { + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start: 2, + end: 7, + }; + update(editor, () => { + $insertComposerClipboardSlice({display: 'x\n@name', segments: [segment]}, false); + }); + expect(snapshot(editor)).toMatchObject({ + wire: '> ax\n> <@1>d', + selection: {anchor: 12, focus: 12}, + wrappers: 2, + }); + expect(editor.getEditorState().read(() => $projectComposer(), {editor}).segments).toMatchObject([ + {start: 7, end: 12}, + ]); + }); + + it('drops carriage returns from a slice so a pasted blank quote line exits like a typed one', () => { + const editor = createHarness(); + setComposer(editor, '', 0); + update(editor, () => { + $insertComposerClipboardSlice({display: '> a\r\n> \r\n> b', segments: []}, false); + }); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> \n> b', wrappers: 3}); + select(editor, 6); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> a\n\n> b', wrappers: 2}); + }); + + it('restores both quote lines when a cut slice is pasted back', () => { + const editor = createHarness(); + setComposer(editor, '> ab\n> cd', 3, 8); + update(editor, () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.removeText(); + } + }); + update(editor, () => { + $insertComposerClipboardSlice({display: 'b\n> c', segments: []}, false); + }); + expect(snapshot(editor)).toMatchObject({ + wire: '> ab\n> cd', + selection: {anchor: 8, focus: 8}, + wrappers: 2, + }); + }); + + it('reverts a whole paste in one undo step', () => { + const editor = createHarness({history: true}); + setComposer(editor, '> abcd', 4); + type(editor, 'q'); + expect(paste(editor, 'x\ny').handled).toBe(true); + expect(snapshot(editor).wire).toBe('> abqx\n> ycd'); + historyStep(editor, UNDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: '> abqcd', selection: {anchor: 5, focus: 5}, wrappers: 1}); + }); + + it('plans pasted text with the parser flags its composer handle carries', () => { + const editor = createHarness({flags: BIO_LIKE_FLAGS}); + setComposer(editor, '> ```', 5); + let inserted = false; + update(editor, () => { + inserted = $insertComposerPastedText('x\ny', [], false, parserFlagsHandle(BIO_LIKE_FLAGS)); + }); + expect(inserted).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: '> ```x\n> y', + selection: {anchor: 10, focus: 10}, + wrappers: 2, + }); + }); + + it('plans pasted text with the default flags when no composer handle is mounted', () => { + const editor = createHarness({flags: BIO_LIKE_FLAGS}); + setComposer(editor, '> ```', 5); + update(editor, () => { + $insertComposerPastedText('x\ny', [], false, null); + }); + expect(snapshot(editor).wire).toBe('> ```x\ny'); + }); + + it('maps detected segment offsets from the pasted text onto the display', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + update(editor, () => { + $insertComposerPastedText( + 'hi <@1>!', + [{type: 'user', id: '1', displayText: '@name', actualText: '<@1>', start: 3, end: 7}], + false, + null, + ); + }); + expect(snapshot(editor).wire).toBe('> hi <@1>!'); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toMatchObject({ + display: '> hi @name!', + segments: [{start: 5, end: 10, actualText: '<@1>'}], + }); + }); + + it('quotes every line of a 200 line paste', () => { + const editor = createHarness(); + setComposer(editor, '> ', 2); + expect(paste(editor, Array.from({length: 200}, (_, index) => `line ${index}`).join('\n')).handled).toBe(true); + const state = snapshot(editor); + expect(state.wrappers).toBe(200); + expect(state.selection).toEqual({anchor: state.wire.length, focus: state.wire.length}); + }); +}); + +describe('blockquote drag and drop', () => { + function dropEvent(data: Record): { + clientX: number; + clientY: number; + dataTransfer: DataTransfer; + preventDefault: ReturnType; + } { + return { + clientX: 0, + clientY: 0, + dataTransfer: {getData: (type: string) => data[type] ?? ''} as unknown as DataTransfer, + preventDefault: vi.fn(), + }; + } + + function drop(editor: LexicalEditor, data: Record): {handled: boolean; prevented: boolean} { + const event = dropEvent(data); + const handled = run(editor, DROP_COMMAND, event as unknown as DragEvent); + return {handled, prevented: event.preventDefault.mock.calls.length > 0}; + } + + function ownMarker(editor: LexicalEditor): string { + return JSON.stringify({editorKey: editor.getKey()}); + } + + function createDragHarness(): LexicalEditor { + const editor = createHarness(); + disposers.push( + registerComposerClipboardCommands(editor, { + getPlainText: () => false, + isEditable: () => true, + getMarkdownParserFlags: () => DEFAULT_COMPOSER_MARKDOWN_FLAGS, + }), + ); + return editor; + } + + function copy(editor: LexicalEditor): Record { + const data: Record = {}; + const clipboardData = { + getData: (type: string) => data[type] ?? '', + setData: (type: string, value: string) => { + data[type] = value; + }, + } as unknown as DataTransfer; + run(editor, COPY_COMMAND, {clipboardData, preventDefault: vi.fn()} as unknown as ClipboardEvent); + return data; + } + + function dragStart(editor: LexicalEditor, moved: string): Record { + const data: Record = {}; + const dataTransfer = { + getData: (type: string) => data[type] ?? '', + setData: (type: string, value: string) => { + data[type] = value; + }, + } as unknown as DataTransfer; + run(editor, DRAGSTART_COMMAND, {dataTransfer} as unknown as DragEvent); + data['text/plain'] = moved; + data['application/x-lexical-drag'] = ownMarker(editor); + return data; + } + + it('declines a drop carrying no lexical drag marker', () => { + const editor = createHarness(); + setComposer(editor, 'one\ntwo\n> alpha', 0, 7); + dropOffset.value = 10; + expect(drop(editor, {'text/plain': 'one\ntwo'})).toEqual({handled: false, prevented: false}); + expect(snapshot(editor).wire).toBe('one\ntwo\n> alpha'); + }); + + it('declines a drop whose lexical marker is unreadable', () => { + const editor = createHarness(); + setComposer(editor, 'one\ntwo\n> alpha', 0, 7); + dropOffset.value = 10; + for (const marker of ['not json', JSON.stringify({}), JSON.stringify({editorKey: 7})]) { + expect(drop(editor, {'text/plain': 'one\ntwo', 'application/x-lexical-drag': marker}).handled).toBe(false); + } + expect(snapshot(editor).wire).toBe('one\ntwo\n> alpha'); + }); + + it('declines a cross-editor drop whose source editor root is not on the page', () => { + const editor = createHarness(); + setComposer(editor, 'one\ntwo\n> alpha', 0, 7); + dropOffset.value = 10; + expect( + drop(editor, {'text/plain': 'one\ntwo', 'application/x-lexical-drag': JSON.stringify({editorKey: 'other'})}), + ).toEqual({handled: false, prevented: false}); + expect(snapshot(editor).wire).toBe('one\ntwo\n> alpha'); + }); + + it('keeps a dragged mention a mention by preferring the composer payload over text/plain', () => { + const editor = createDragHarness(); + setComposer(editor, 'hello @name world', 6, 11, [ + {type: 'user', id: '1', displayText: '@name', actualText: '<@1>', start: 6, end: 11}, + ]); + const data = dragStart(editor, '@name'); + expect(parseComposerClipboardSlice(data[FLUXER_COMPOSER_CLIPBOARD_MIME] ?? '')).toMatchObject({ + display: '@name', + segments: [{start: 0, end: 5, actualText: '<@1>'}], + }); + dropOffset.value = 17; + expect(drop(editor, data)).toEqual({handled: true, prevented: true}); + expect(snapshot(editor).wire).toBe('hello world<@1>'); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toMatchObject({ + display: 'hello world@name', + segments: [{start: 12, end: 17, actualText: '<@1>'}], + }); + }); + + it('continues the quote and keeps the segment offsets when a mention is dragged into a quote line', () => { + const editor = createDragHarness(); + setComposer(editor, 'hi @name\n> quoted', 3, 8, [ + {type: 'user', id: '1', displayText: '@name', actualText: '<@1>', start: 3, end: 8}, + ]); + const data = dragStart(editor, '@name'); + dropOffset.value = 11; + expect(drop(editor, data).handled).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: 'hi \n> <@1>quoted', wrappers: 1}); + expect(editor.getEditorState().read(() => $projectComposer(), {editor}).segments).toMatchObject([ + {start: 6, end: 11, actualText: '<@1>'}, + ]); + }); + + it('keeps a dragged standard emoji an emoji where its shortcode would not convert back', () => { + const editor = createDragHarness(); + disposers.push(registerComposerEmojiShortcode(editor, (name) => (name === 'grinning' ? GRINNING : null))); + setComposer(editor, 'hi :grinning:\n> quoted', 3, 13); + expect(snapshot(editor).wire).toBe('hi 😀\n> quoted'); + const data = dragStart(editor, ':grinning:'); + expect(parseComposerClipboardSlice(data[FLUXER_COMPOSER_CLIPBOARD_MIME] ?? '')).toMatchObject({ + display: ':grinning:', + segments: [{type: 'emoji', id: 'grinning', actualText: '😀', start: 0, end: 10}], + }); + dropOffset.value = 16; + expect(drop(editor, data).handled).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: 'hi \n> 😀quoted', wrappers: 1}); + const emojiInWrapper = editor.getEditorState().read( + () => { + const paragraph = $getRoot().getFirstChild(); + const wrapper = $isElementNode(paragraph) ? paragraph.getLastChild() : null; + return $isComposerBlockquoteLineNode(wrapper) && wrapper.getChildren().some($isComposerStandardEmojiNode); + }, + {editor}, + ); + expect(emojiInWrapper).toBe(true); + }); + + it('falls back to text/plain when the drag carries no composer payload', () => { + const editor = createHarness(); + setComposer(editor, 'one\ntwo\n> alpha', 0, 7); + dropOffset.value = 10; + expect(drop(editor, {'text/plain': 'one\ntwo', 'application/x-lexical-drag': ownMarker(editor)}).handled).toBe( + true, + ); + expect(snapshot(editor)).toMatchObject({wire: '\n> one\n> twoalpha', wrappers: 2}); + }); + + it('trusts only the newest drag payload', () => { + const editor = createDragHarness(); + setComposer(editor, 'alpha', 0, 5); + const oldest = dragStart(editor, 'alpha')[FLUXER_COMPOSER_CLIPBOARD_MIME] ?? ''; + expect(parseComposerClipboardSlice(oldest)).not.toBeNull(); + const newest = dragStart(editor, 'alpha')[FLUXER_COMPOSER_CLIPBOARD_MIME] ?? ''; + expect(parseComposerClipboardSlice(oldest)).toBeNull(); + expect(parseComposerClipboardSlice(newest)).not.toBeNull(); + }); + + it('keeps a copied slice trusted while more drags than the cache holds accumulate', () => { + const editor = createDragHarness(); + setComposer(editor, 'hello @name world', 6, 11, [ + {type: 'user', id: '1', displayText: '@name', actualText: '<@1>', start: 6, end: 11}, + ]); + const copied = copy(editor)[FLUXER_COMPOSER_CLIPBOARD_MIME] ?? ''; + expect(parseComposerClipboardSlice(copied)).toMatchObject({display: '@name', segments: [{actualText: '<@1>'}]}); + let dragged = ''; + for (let index = 0; index < COMPOSER_CLIPBOARD_MAX_TRUSTED_PAYLOADS + 8; index += 1) { + dragged = dragStart(editor, '@name')[FLUXER_COMPOSER_CLIPBOARD_MIME] ?? ''; + } + expect(parseComposerClipboardSlice(copied)).toMatchObject({display: '@name', segments: [{actualText: '<@1>'}]}); + expect(parseComposerClipboardSlice(dragged)).not.toBeNull(); + }); + + it('keeps the copied payload cache bounded', () => { + const editor = createDragHarness(); + setComposer(editor, 'alpha', 0, 5); + const oldest = copy(editor)[FLUXER_COMPOSER_CLIPBOARD_MIME] ?? ''; + expect(parseComposerClipboardSlice(oldest)).not.toBeNull(); + for (let index = 0; index < COMPOSER_CLIPBOARD_MAX_TRUSTED_PAYLOADS; index += 1) { + copy(editor); + } + expect(parseComposerClipboardSlice(oldest)).toBeNull(); + }); + + it('declines a same-editor drag from a collapsed selection', () => { + const editor = createHarness(); + setComposer(editor, 'one\ntwo\n> alpha', 4); + expect(drop(editor, {'text/plain': 'one\ntwo', 'application/x-lexical-drag': ownMarker(editor)}).handled).toBe( + false, + ); + expect(snapshot(editor).wire).toBe('one\ntwo\n> alpha'); + }); + + it('keeps the dragged range when the drop caret cannot be resolved', () => { + const editor = createHarness(); + setComposer(editor, 'one\ntwo\n> alpha', 0, 7); + expect(drop(editor, {'text/plain': 'one\ntwo', 'application/x-lexical-drag': ownMarker(editor)})).toEqual({ + handled: false, + prevented: false, + }); + expect(snapshot(editor).wire).toBe('one\ntwo\n> alpha'); + expect(snapshot(editor).selection).toEqual({anchor: 0, focus: 7}); + }); +}); + describe('blockquote line breaks', () => { const LINE_BREAK_ROWS: Array<[string, number, string, number]> = [ ['> test', 6, '> test\n> ', 9], @@ -488,6 +1216,19 @@ describe('blockquote backspace', () => { }); }); + it.each( + BACKSPACE_ROWS, + )('removes the same text on a backward word delete on %j at %i, giving %j with the caret at %i', (text, before, expected, after) => { + const editor = createHarness(); + setComposer(editor, text, before); + expect(run(editor, DELETE_WORD_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); @@ -497,32 +1238,38 @@ describe('blockquote backspace', () => { expect(snapshot(editor)).toMatchObject({wire: '', selection: {anchor: 0, focus: 0}, wrappers: 0}); }); - const BACKSPACE_DECLINED: Array<[string, number, number?]> = [ + it.each<[string, 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) => { + ])('declines a backspace on %j with the caret at %i', (text, caret) => { const editor = createHarness(); - setComposer(editor, text, anchor, focus); + setComposer(editor, text, caret); expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(false); }); - it('declines a forward delete and a backspace while composing', () => { + it('declines a backspace over a range inside a quote line', () => { + const editor = createHarness(); + setComposer(editor, '> test', 2, 4); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(false); + }); + + it('declines every delete 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); + for (const command of [DELETE_CHARACTER_COMMAND, DELETE_WORD_COMMAND, DELETE_LINE_COMMAND]) { + expect(run(editor, command, true)).toBe(false); + expect(run(editor, command, false)).toBe(false); + } + expect(snapshot(editor).wire).toBe('> test'); update(editor, () => { $setCompositionKey(null); }); @@ -551,6 +1298,131 @@ describe('blockquote backspace', () => { }); }); +describe('blockquote forward delete', () => { + const FORWARD_DELETE_COMMANDS = { + character: DELETE_CHARACTER_COMMAND, + word: DELETE_WORD_COMMAND, + line: DELETE_LINE_COMMAND, + }; + + const FORWARD_DELETE_ROWS: Array<[string, number, string, number]> = [ + ['> a\n> b', 3, '> ab', 3], + ['a\n> b', 1, 'ab', 1], + ['> a\n> ', 3, '> a', 3], + ['> \n> b', 2, '> b', 2], + ['a\n> \n> b', 4, 'a\n> b', 4], + ['> \nb', 2, 'b', 0], + ['a\n> \nb', 4, 'a\nb', 2], + ]; + + it.each(FORWARD_DELETE_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, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: expected, + selection: {anchor: after, focus: after}, + wrappers: expectedQuoteLines(expected), + }); + }); + + it('removes an empty quote line when the caret sits at the end of its marker text', () => { + const editor = createHarness(); + setComposer(editor, '> \n> b'); + selectFirstMarkerText(editor, 2); + expect(run(editor, DELETE_CHARACTER_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> b', selection: {anchor: 2, focus: 2}, wrappers: 1}); + }); + + it('plans line deletes that start at a quote line boundary', () => { + const editor = createHarness(); + setComposer(editor, '> a\n> b', 3); + expect(run(editor, DELETE_LINE_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ab', selection: {anchor: 3, focus: 3}, wrappers: 1}); + setComposer(editor, '> \nabc', 3); + expect(run(editor, DELETE_LINE_COMMAND, true)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: 'abc', selection: {anchor: 0, focus: 0}, wrappers: 0}); + }); + + it.each<[keyof typeof FORWARD_DELETE_COMMANDS, string, number, string, number]>([ + ['character', '> ab', 2, '> b', 2], + ['character', 'x\n> ab', 4, 'x\n> b', 4], + ['character', '> \u{1F468}\u200D\u{1F469}\u200D\u{1F467}x', 2, '> x', 2], + ['character', '> e\u0301x', 2, '> ex', 3], + ['word', '> ab ef', 2, '> ef', 2], + ['word', 'x\n> ab\n> cd', 4, 'x\n> \n> cd', 4], + ['word', '> \n> cd', 2, '> \n> cd', 2], + ['line', 'x\n> ab ef\n> gh', 4, 'x\n> \n> gh', 4], + ])('deletes forward by %s from the quote content start of %j at %i, giving %j with the caret at %i', (unit, text, before, expected, after) => { + const editor = createHarness(); + setComposer(editor, text, before); + expect(run(editor, FORWARD_DELETE_COMMANDS[unit], false)).toBe(true); + expect(snapshot(editor)).toMatchObject({ + wire: expected, + selection: {anchor: after, focus: after}, + wrappers: expectedQuoteLines(expected), + }); + }); + + it.each<[keyof typeof FORWARD_DELETE_COMMANDS, string, Array, string]>([ + ['character', 'x\n> @name@name y', [4, 9], 'x\n> <@1> y'], + ['word', 'x\n> @name@name y', [4, 9], 'x\n> <@1> y'], + ['line', 'x\n> @name@name y', [4, 9], 'x\n> '], + ['word', 'x\n> ab@name y', [6], 'x\n> y'], + ])('deletes forward by %s over whole mentions from the quote content start of %j', (unit, text, starts, wire) => { + const editor = createHarness(); + const segments = starts.map( + (start): MentionSegment => ({ + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start, + end: start + 5, + }), + ); + setComposer(editor, text, 4, 4, segments); + expect(run(editor, FORWARD_DELETE_COMMANDS[unit], false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire, selection: {anchor: 4, focus: 4}, wrappers: 1}); + }); + + it('restores the text of a forward delete at a quote content start in one undo step', () => { + const editor = createHarness({history: true}); + setComposer(editor, 'x\n> ab ef', 4); + expect(run(editor, DELETE_WORD_COMMAND, false)).toBe(true); + expect(snapshot(editor).wire).toBe('x\n> ef'); + historyStep(editor, UNDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: 'x\n> ab ef', selection: {anchor: 4, focus: 4}, wrappers: 1}); + }); + + function expectForwardDeleteDeclined(text: string, anchor: number, focus?: number): void { + const editor = createHarness(); + setComposer(editor, text, anchor, focus); + for (const command of Object.values(FORWARD_DELETE_COMMANDS)) { + expect(run(editor, command, false)).toBe(false); + } + expect(snapshot(editor).wire).toBe(text); + } + + it.each<[string, number]>([ + ['\n> b', 0], + ['a\n\n> b', 2], + ['> a\nb', 3], + ['> test', 4], + ['> ', 2], + ['```\n> a\n> b', 7], + ])('declines every forward delete on %j with the caret at %i', (text, caret) => { + expectForwardDeleteDeclined(text, caret); + }); + + it.each<[string, number, number]>([ + ['> a\n> b', 3, 6], + ['> a\n> b', 2, 3], + ])('declines every forward delete on %j over the range %i..%i', (text, anchor, focus) => { + expectForwardDeleteDeclined(text, anchor, focus); + }); +}); + describe('blockquote history', () => { it('treats one line break exit and one backspace as single steps', () => { const editor = createHarness({history: true}); @@ -586,7 +1458,7 @@ describe('blockquote caret, typing and composition', () => { expect(snapshot(editor).selection).toEqual({anchor: after, focus: after}); }); - it('leaves range selections alone', () => { + it('keeps a range that starts at the start of a quote line', () => { const editor = createHarness(); setComposer(editor, '> test'); select(editor, 0, 4); @@ -594,6 +1466,20 @@ describe('blockquote caret, typing and composition', () => { expect(snapshot(editor).selection).toEqual({anchor: 0, focus: 4}); }); + it.each([ + ['ab\n> cd', 1, 3, 1, 5], + ['ab\n> cd', 4, 1, 5, 1], + ['ab\n> cd', 4, 7, 5, 7], + ['ab\n> cd', 3, 7, 3, 7], + ['> a\n> b', 0, 7, 0, 7], + ])('moves range endpoints on %j from %i..%i to %i..%i on a selection change', (text, anchor, focus, expectedAnchor, expectedFocus) => { + const editor = createHarness(); + setComposer(editor, text); + select(editor, anchor, focus); + run(editor, SELECTION_CHANGE_COMMAND, undefined); + expect(snapshot(editor).selection).toEqual({anchor: expectedAnchor, focus: expectedFocus}); + }); + it('snaps a text point inside the marker to content start', () => { const editor = createHarness(); setComposer(editor, '> '); @@ -728,19 +1614,136 @@ describe('blockquote caret, typing and composition', () => { 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', () => { + it('declines ArrowLeft while composing 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); }); + + it.each<[boolean, Record, string, number, number, number]>([ + [true, {altKey: true}, 'ab\n> cd', 5, 2, 2], + [false, {ctrlKey: true}, 'ab\n> cd', 5, 2, 2], + [true, {altKey: true, shiftKey: true}, 'ab\n> cd', 5, 5, 2], + [false, {ctrlKey: true, shiftKey: true}, 'ab\n> cd', 5, 5, 2], + [true, {altKey: true}, '> a\n> cd', 6, 3, 3], + [true, {altKey: true}, '> cd', 2, 2, 2], + [true, {altKey: true, shiftKey: true}, '> cd', 2, 2, 2], + ])('with apple=%s and %j moves a word back from the quote content start of %j at %i to %i..%i', (apple, modifiers, text, caret, anchor, focus) => { + platform.apple = apple; + const editor = createHarness(); + setComposer(editor, text, caret); + const event = keyEvent(modifiers); + expect(run(editor, KEY_DOWN_COMMAND, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(snapshot(editor).selection).toEqual({anchor, focus}); + }); + + it.each<[boolean, Record, string, number]>([ + [true, {ctrlKey: true}, 'ab\n> cd', 5], + [false, {altKey: true}, 'ab\n> cd', 5], + [true, {altKey: true, metaKey: true}, 'ab\n> cd', 5], + [false, {ctrlKey: true, altKey: true}, 'ab\n> cd', 5], + [true, {altKey: true, key: 'ArrowRight', keyCode: 39}, 'ab\n> cd', 2], + [true, {altKey: true, shiftKey: true, key: 'ArrowRight', keyCode: 39}, 'ab\n> cd', 2], + [true, {altKey: true}, 'ab\n> cd', 6], + [true, {altKey: true, isComposing: true}, 'ab\n> cd', 5], + [true, {altKey: true, key: 'b', keyCode: 66}, 'ab\n> cd', 5], + ])('with apple=%s leaves %j on %j at %i to the browser', (apple, overrides, text, caret) => { + platform.apple = apple; + const editor = createHarness(); + setComposer(editor, text, caret); + const event = keyEvent(overrides); + run(editor, KEY_DOWN_COMMAND, event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(snapshot(editor).selection).toEqual({anchor: caret, focus: caret}); + }); + + it('replaces a word selection extended over the hidden marker without leaving marker text', () => { + platform.apple = false; + const editor = createHarness(); + setComposer(editor, 'ab\n> cd', 5); + expect(run(editor, KEY_DOWN_COMMAND, keyEvent({ctrlKey: true, shiftKey: true}))).toBe(true); + type(editor, 'y'); + expect(snapshot(editor)).toMatchObject({wire: 'abycd', selection: {anchor: 3, focus: 3}, wrappers: 0}); + }); + + it('moves a word back on the arrow that points backward in right-to-left text', () => { + platform.apple = true; + const editor = createHarness(); + vi.spyOn(editor, 'getElementByKey').mockReturnValue(RTL_ELEMENT); + setComposer(editor, 'سطر\n> مرحبا', 6); + const forward = keyEvent({altKey: true}); + run(editor, KEY_DOWN_COMMAND, forward); + expect(forward.preventDefault).not.toHaveBeenCalled(); + expect(snapshot(editor).selection).toEqual({anchor: 6, focus: 6}); + expect(run(editor, KEY_DOWN_COMMAND, keyEvent({key: 'ArrowRight', keyCode: 39, altKey: true}))).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 3, focus: 3}); + }); + + it('jumps over the hidden marker on ArrowRight at the end of the line above', () => { + const editor = createHarness(); + setComposer(editor, 'ab\n> cd', 2); + const event = keyEvent({key: 'ArrowRight', keyCode: 39}); + expect(run(editor, KEY_ARROW_RIGHT_COMMAND, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(snapshot(editor).selection).toEqual({anchor: 5, focus: 5}); + expect(run(editor, KEY_ARROW_RIGHT_COMMAND, keyEvent({key: 'ArrowRight', keyCode: 39}))).toBe(false); + }); + + it('extends a shift selection over the hidden marker and back', () => { + const editor = createHarness(); + setComposer(editor, 'x\n> ab', 4); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent({shiftKey: true}))).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 4, focus: 1}); + expect(run(editor, KEY_ARROW_RIGHT_COMMAND, keyEvent({key: 'ArrowRight', keyCode: 39, shiftKey: true}))).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 4, focus: 4}); + }); + + it.each<[string, number, 'left' | 'right', number, string, string, number]>([ + ['x\n> ab', 4, 'left', 1, 'y', 'xyab', 2], + ['> a\n> b', 3, 'right', 6, 'x', '> axb', 4], + ])('replaces a shift selection on %j from %i without leaving marker text', (text, caret, direction, extended, typed, expected, after) => { + const editor = createHarness(); + setComposer(editor, text, caret); + const command = direction === 'left' ? KEY_ARROW_LEFT_COMMAND : KEY_ARROW_RIGHT_COMMAND; + expect(run(editor, command, keyEvent({shiftKey: true}))).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: caret, focus: extended}); + type(editor, typed); + expect(snapshot(editor)).toMatchObject({ + wire: expected, + selection: {anchor: after, focus: after}, + wrappers: expectedQuoteLines(expected), + }); + }); + + it('keeps a shift selection at content start of the first quote line', () => { + const editor = createHarness(); + setComposer(editor, '> ab', 2); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent({shiftKey: true}))).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 2, focus: 2}); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: 'ab', selection: {anchor: 0, focus: 0}, wrappers: 0}); + }); + + it('swaps the arrow keys in right-to-left text', () => { + const editor = createHarness(); + vi.spyOn(editor, 'getElementByKey').mockReturnValue(RTL_ELEMENT); + setComposer(editor, 'سطر\n> مرحبا', 6); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent())).toBe(false); + expect(run(editor, KEY_ARROW_RIGHT_COMMAND, keyEvent({key: 'ArrowRight', keyCode: 39}))).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 3, focus: 3}); + expect(run(editor, KEY_ARROW_RIGHT_COMMAND, keyEvent({key: 'ArrowRight', keyCode: 39}))).toBe(false); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent())).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 6, focus: 6}); + setComposer(editor, '> مرحبا', 2); + expect(run(editor, KEY_ARROW_LEFT_COMMAND, keyEvent())).toBe(false); + expect(run(editor, KEY_ARROW_RIGHT_COMMAND, keyEvent({key: 'ArrowRight', keyCode: 39}))).toBe(true); + expect(snapshot(editor).selection).toEqual({anchor: 2, focus: 2}); + }); }); describe('multi-line blockquote markers', () => { @@ -762,11 +1765,44 @@ describe('multi-line blockquote markers', () => { 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); + it.each<[string, number, string, number, number]>([ + ['a\n>>>\nb', 5, 'a\n> \n> b', 4, 2], + ['>>>\nb\nc', 3, '> \n> b\n> c', 2, 3], + ])('quotes every line below a marker typed on %j at %i and restores it on undo', (text, caret, wire, after, wrappers) => { + const editor = createHarness({history: true}); + setComposer(editor, text, caret); type(editor, ' '); - expect(snapshot(editor)).toMatchObject({wire: 'a\n> \nb', selection: {anchor: 4, focus: 4}, wrappers: 1}); + expect(snapshot(editor)).toMatchObject({wire, selection: {anchor: after, focus: after}, wrappers}); + historyStep(editor, UNDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: text, selection: {anchor: caret, focus: caret}}); + }); + + it.each<[string, number, string, number]>([ + ['>>>\n> b', 3, '>>> \n> b', 4], + ['> x\n>>>\nb', 7, '> x\n>>> \nb', 8], + ])('leaves a marker typed on %j at %i as text because line quotes would change the rendered message', (text, caret, wire, after) => { + const editor = createHarness(); + setComposer(editor, text, caret); + type(editor, ' '); + expect(snapshot(editor)).toMatchObject({wire, selection: {anchor: after, focus: after}}); + }); + + it('quotes every line below a pasted ">>> "', () => { + const editor = createHarness(); + setComposer(editor, 'a\nb', 0); + update(editor, () => { + $insertComposerClipboardSlice({display: '>>> ', segments: []}, false); + }); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> b', selection: {anchor: 2, focus: 2}, wrappers: 2}); + }); + + it('leaves a ">>> " pasted on the last line below a quote as text instead of taking the typed branch', () => { + const editor = createHarness(); + setComposer(editor, '> x\n', 4); + update(editor, () => { + $insertComposerClipboardSlice({display: '>>> ', segments: []}, false); + }); + expect(snapshot(editor)).toMatchObject({wire: '> x\n>>> ', selection: {anchor: 8, focus: 8}}); }); it('keeps the rendered meaning of arrived text and creates no selection', () => { @@ -808,6 +1844,144 @@ describe('multi-line blockquote markers', () => { expect(snapshot(editor)).toMatchObject({wire: '>>> [!NOTE]\nbody', wrappers: 0}); }); + it.each<[string, number]>([ + ['>>> a\n> b', 0], + ['>>> > a\nb', 0], + ['>>> a\n>>> b', 0], + ['>>> a\n> [!NOTE]\n> b', 0], + ['> x\n>>> a\nb', 1], + ['> [!NOTE]\n>>> a\nb', 1], + ['||s\n>>> a\nb||\nc', 0], + ])('leaves arrived %j as text because line quotes would change the rendered message', (text, wrappers) => { + const editor = createHarness(); + update(editor, () => { + $hydrateComposerFromDraft(text, []); + }); + expect(snapshot(editor)).toMatchObject({wire: text, wrappers}); + }); + + it('leaves a pasted ">>> " block that holds a quote line as text', () => { + const editor = createHarness(); + setComposer(editor, '', 0); + update(editor, () => { + $insertComposerClipboardSlice({display: '>>> a\n> b', segments: []}, false); + }); + expect(snapshot(editor)).toMatchObject({wire: '>>> a\n> b', selection: {anchor: 9, focus: 9}, wrappers: 0}); + }); + + it('inserts a plain line break inside an arrived block that stays text', () => { + const editor = createHarness(); + setComposer(editor, '>>> a\n> b', 5); + run(editor, INSERT_LINE_BREAK_COMMAND, false); + expect(snapshot(editor)).toMatchObject({wire: '>>> a\n\n> b', wrappers: 0}); + }); + + it('rewrites every line once an edit removes the nested quote line', () => { + const editor = createHarness(); + setComposer(editor, '>>> a\n> b', 6, 8); + type(editor, ''); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> b', selection: {anchor: 6, focus: 6}, wrappers: 2}); + }); + + it.each([2, 4])('rewrites every line when the transforms register over a caret at %i on the marker', (caret) => { + const editor = createBareEditor(); + setComposer(editor, '>>> a\nb\nc', caret); + disposers.push(registerComposerMarkdownHighlight(editor), registerComposerBlockquote(editor)); + editor.update( + () => { + for (const node of $getRoot().getAllTextNodes()) { + node.markDirty(); + } + }, + {discrete: true, tag: HISTORY_MERGE_TAG}, + ); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> b\n> c', selection: {anchor: 2, focus: 2}, wrappers: 3}); + }); + + it('keeps an arrived block that stays text when a pass runs with the caret on its marker', () => { + const editor = createHarness(); + setComposer(editor, '>>> a\n> b'); + select(editor, 4); + update(editor, () => { + for (const node of $getRoot().getAllTextNodes()) { + node.markDirty(); + } + }); + expect(snapshot(editor)).toMatchObject({wire: '>>> a\n> b', selection: {anchor: 4, focus: 4}, wrappers: 0}); + }); + + it('rewrites a marker typed on the last line where an arrived block would stay text', () => { + const editor = createHarness(); + setComposer(editor, '> x\n>>>', 7); + type(editor, ' '); + expect(snapshot(editor)).toMatchObject({wire: '> x\n> ', selection: {anchor: 6, focus: 6}}); + }); + + it.each<[number, string]>([ + [10, '>>> a\nb\nc'], + [11, '> a\n> b\n> c'], + ])('with a max wire length of %i turns arrived ">>> a\\nb\\nc" into %j', (maxWireLength, wire) => { + const editor = createHarness({maxWireLength}); + update(editor, () => { + $hydrateComposerFromDraft('>>> a\nb\nc', []); + }); + expect(snapshot(editor).wire).toBe(wire); + }); + + it.each<[number, string]>([ + [10, '>>> a\nb\nc'], + [11, '> a\n> b\n> c'], + ])('with a max wire length of %i the mount pass over a hydrated ">>> a\\nb\\nc" gives %j', (maxWireLength, wire) => { + const editor = createBareEditor(); + update(editor, () => { + $hydrateComposerFromDraft('>>> a\nb\nc', []); + }); + disposers.push( + registerComposerMarkdownHighlight(editor, undefined, false, maxWireLength), + registerComposerBlockquote(editor), + ); + editor.update( + () => { + for (const node of $getRoot().getAllTextNodes()) { + node.markDirty(); + } + }, + {discrete: true, tag: HISTORY_MERGE_TAG}, + ); + expect(snapshot(editor).wire).toBe(wire); + }); + + it.each([1, 2, 3, 4])('leaves arrived ">>> a\\n> b" as text when the hydrate places the caret at %i', (caret) => { + const editor = createHarness(); + setComposer(editor, '>>> a\n> b', caret); + expect(snapshot(editor)).toMatchObject({wire: '>>> a\n> b', selection: {anchor: caret, focus: caret}, wrappers: 0}); + }); + + it('leaves an arrived block as text when deleting its first character leaves the caret after the marker', () => { + const editor = createHarness(); + setComposer(editor, '>>> za\n> b', 4, 5); + type(editor, ''); + expect(snapshot(editor)).toMatchObject({wire: '>>> a\n> b', selection: {anchor: 4, focus: 4}, wrappers: 0}); + }); + + it.each<[number, number, string]>([ + [4, 4, 'z'], + [3, 4, ''], + ])('keeps arrived ">>> a\\n> b" as text after undoing an edit over %i..%i', (anchor, focus, text) => { + const editor = createHarness({history: true}); + setComposer(editor, '>>> a\n> b'); + select(editor, 4); + update(editor, () => { + $selectComposerRange(anchor, focus); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(text); + } + }); + historyStep(editor, UNDO_COMMAND); + expect(snapshot(editor)).toMatchObject({wire: '>>> a\n> b', selection: {anchor: 4, focus: 4}, wrappers: 0}); + }); + it('leaves ">>> a" as text without ALLOW_MULTILINE_BLOCKQUOTES', () => { const editor = createHarness({flags: NO_MULTILINE_FLAGS}); update(editor, () => { @@ -826,6 +2000,8 @@ describe('blockquote transform convergence', () => { '```\n> a', '> ', '> a\n\nb', + '>>> a\n> b', + '> x\n>>> a\nb', ])('reaches a fixed point on %j', (text) => { const editor = createHarness(); setComposer(editor, text); @@ -910,7 +2086,7 @@ describe('blockquote code blocks', () => { }); describe('blockquote parser flags', () => { - it('gives no wrappers and declines every handler without ALLOW_BLOCKQUOTES', () => { + it('makes no quote markers without ALLOW_BLOCKQUOTES, so every handler finds no quote line', () => { const editor = createHarness({flags: NO_BLOCKQUOTE_FLAGS}); setComposer(editor, '> a', 2); expect(snapshot(editor).wrappers).toBe(0); @@ -922,6 +2098,21 @@ describe('blockquote parser flags', () => { select(editor, 3); run(editor, INSERT_LINE_BREAK_COMMAND, false); expect(snapshot(editor).wire).toBe('> a\n'); + setComposer(editor, '> a\n> b', 3); + expect(run(editor, DELETE_CHARACTER_COMMAND, false)).toBe(false); + expect(run(editor, DELETE_LINE_COMMAND, false)).toBe(false); + expect(run(editor, KEY_ARROW_RIGHT_COMMAND, keyEvent({key: 'ArrowRight', keyCode: 39}))).toBe(false); + select(editor, 6); + expect(run(editor, DELETE_WORD_COMMAND, false)).toBe(false); + expect(run(editor, DELETE_WORD_COMMAND, true)).toBe(false); + platform.apple = true; + const wordMove = keyEvent({altKey: true}); + run(editor, KEY_DOWN_COMMAND, wordMove); + expect(wordMove.preventDefault).not.toHaveBeenCalled(); + expect(snapshot(editor).selection).toEqual({anchor: 6, focus: 6}); + const payload = {dataTransfer: {getData: () => 'x\ny'}} as unknown as InputEvent; + expect(run(editor, CONTROLLED_TEXT_INSERTION_COMMAND, payload)).toBe(false); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> b', wrappers: 0}); }); it('continues quotes under bio style flags', () => { @@ -930,6 +2121,157 @@ describe('blockquote parser flags', () => { expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); expect(snapshot(editor)).toMatchObject({wire: '> a\n> ', selection: {anchor: 6, focus: 6}}); }); + + it('treats a fence as text under bio style flags, so every quoted line keeps its wrapper', () => { + const editor = createHarness({flags: BIO_LIKE_FLAGS}); + setComposer(editor, '> ```\n> b\n> ```'); + expect(snapshot(editor)).toMatchObject({wrappers: 3, breaksInsideWrappers: 0}); + }); + + it('continues the quote on and below a quoted fence under bio style flags', () => { + const editor = createHarness({flags: BIO_LIKE_FLAGS}); + setComposer(editor, '> ```', 5); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ```\n> ', selection: {anchor: 8, focus: 8}, wrappers: 2}); + setComposer(editor, '> ```\n> b', 9); + expect(run(editor, INSERT_LINE_BREAK_COMMAND, false)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ```\n> b\n> ', selection: {anchor: 12, focus: 12}, wrappers: 3}); + }); + + it('removes only the marker below a quoted fence under bio style flags', () => { + const editor = createHarness({flags: BIO_LIKE_FLAGS}); + setComposer(editor, '> ```\n> b', 8); + expect(run(editor, DELETE_CHARACTER_COMMAND, true)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ```\nb', selection: {anchor: 6, focus: 6}, wrappers: 1}); + }); +}); + +describe('formatting over hidden quote markers', () => { + const LINK = 'https://fluxer.app'; + + function wrap(editor: LexicalEditor, wrapper: string): void { + update(editor, () => { + $wrapComposerSelection(wrapper, wrapper); + }); + } + + function wrappedFlags(editor: LexicalEditor, wrappers: ReadonlyArray): Array { + return editor + .getEditorState() + .read( + () => $queryComposerSelectionWrappers(wrappers.map((wrapper) => ({prefix: wrapper, suffix: wrapper}))).wrapped, + {editor}, + ); + } + + function pasteLink(editor: LexicalEditor): boolean { + return run(editor, PASTE_COMMAND, { + clipboardData: {getData: (type: string) => (type === 'text/plain' ? LINK : '')}, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent); + } + + it.each<[string, number, number, string, string, number, number, number]>([ + ['> hello', 0, 7, '**', '> **hello**', 4, 9, 1], + ['> hello', 7, 0, '**', '> **hello**', 9, 4, 1], + ['> hello', 0, 7, '`', '> `hello`', 3, 8, 1], + ['> a\n> b', 4, 7, '~~', '> a\n> ~~b~~', 8, 9, 2], + ['> a\n> b', 2, 4, '**', '> **a\n> **b', 4, 8, 2], + ])('wraps only the content of %j selected from %i to %i with %j', (text, anchor, focus, wrapper, wire, expectedAnchor, expectedFocus, wrappers) => { + const editor = createFormattingHarness(); + setComposer(editor, text, anchor, focus); + wrap(editor, wrapper); + expect(snapshot(editor)).toMatchObject({ + wire, + selection: {anchor: expectedAnchor, focus: expectedFocus}, + wrappers, + }); + }); + + it.each<[string, string]>([ + ['> *hi*', '*'], + ['> **hi**', '**'], + ])('reports %j selected from its line start as wrapped in %j', (text, wrapper) => { + const editor = createFormattingHarness(); + setComposer(editor, text, 0, text.length); + expect(wrappedFlags(editor, [wrapper])).toEqual([true]); + }); + + it('removes the bold of a quote line selected from its line start', () => { + const editor = createFormattingHarness(); + setComposer(editor, '> **hi**', 0, 8); + wrap(editor, '**'); + expect(snapshot(editor)).toMatchObject({wire: '> hi', selection: {anchor: 2, focus: 4}, wrappers: 1}); + }); + + it('bolds only the content on Ctrl+B over a select all', () => { + const editor = createFormattingHarness(); + setComposer(editor, '> hello', 0, 7); + run(editor, KEY_DOWN_COMMAND, keyEvent({key: 'b', ctrlKey: true})); + expect(snapshot(editor).wire).toBe('> **hello**'); + }); + + it('leaves the text alone on Ctrl+B over a range that covers only the marker', () => { + const editor = createFormattingHarness(); + setComposer(editor, '> hello', 2, 0); + run(editor, KEY_DOWN_COMMAND, keyEvent({key: 'b', ctrlKey: true})); + expect(snapshot(editor).wire).toBe('> hello'); + }); + + it.each<[number, number]>([ + [2, 7], + [0, 7], + ])('declines the code block toggle over quote lines selected from %i to %i', (anchor, focus) => { + const editor = createFormattingHarness(); + setComposer(editor, '> a\n> b', anchor, focus); + expect(wrappedFlags(editor, ['`'])).toEqual([false]); + wrap(editor, '`'); + expect(snapshot(editor)).toMatchObject({wire: '> a\n> b', selection: {anchor, focus}, wrappers: 2}); + }); + + it('indents the code of selected quoted fence lines and unindents it again', () => { + const editor = createFormattingHarness(); + setComposer(editor, '> ```\n> a\n> b\n> ```', 6, 13); + expect(run(editor, KEY_TAB_COMMAND, keyEvent({key: 'Tab'}))).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ```\n> \ta\n> \tb\n> ```', selection: {anchor: 9, focus: 15}}); + expect(run(editor, KEY_TAB_COMMAND, keyEvent({key: 'Tab', shiftKey: true}))).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ```\n> a\n> b\n> ```', selection: {anchor: 8, focus: 13}}); + }); + + it('unindents the code of a selected indented quoted fence line', () => { + const editor = createFormattingHarness(); + setComposer(editor, '> ```\n> \tcode\n> ```', 6, 13); + expect(run(editor, KEY_TAB_COMMAND, keyEvent({key: 'Tab', shiftKey: true}))).toBe(true); + expect(snapshot(editor)).toMatchObject({wire: '> ```\n> code\n> ```', selection: {anchor: 8, focus: 12}}); + }); + + it.each<[string, number, number, string, number]>([ + ['> hello world', 0, 13, `> [hello world](<${LINK}>)`, 37], + ['intro\n> hello world', 6, 19, `intro\n> [hello world](<${LINK}>)`, 43], + ['> hello world', 13, 8, `> hello [world](<${LINK}>)`, 37], + ])('keeps the quote outside a link label pasted over %j from %i to %i', (text, anchor, focus, wire, caret) => { + const editor = createFormattingHarness(); + setComposer(editor, text, anchor, focus); + expect(pasteLink(editor)).toBe(true); + expect(snapshot(editor)).toMatchObject({wire, selection: {anchor: caret, focus: caret}, wrappers: 1}); + }); + + it('declines a link paste over a range that covers only the marker', () => { + const editor = createFormattingHarness(); + setComposer(editor, '> hello world', 2, 0); + expect(pasteLink(editor)).toBe(false); + expect(snapshot(editor).wire).toBe('> hello world'); + }); + + it('formats the literal "> " without ALLOW_BLOCKQUOTES', () => { + const editor = createFormattingHarness(NO_BLOCKQUOTE_FLAGS); + setComposer(editor, '> a', 0, 3); + wrap(editor, '**'); + expect(snapshot(editor)).toMatchObject({wire: '**> a**', selection: {anchor: 2, focus: 5}, wrappers: 0}); + setComposer(editor, '> a\n> b', 0, 7); + wrap(editor, '`'); + expect(snapshot(editor).wire).toBe('```\n> a\n> b\n```'); + }); }); describe('nodes inside quote lines', () => { @@ -983,6 +2325,100 @@ describe('nodes inside quote lines', () => { expect(snapshot(editor)).toMatchObject({wire: '> <@1>\nq', selection: {anchor: 9, focus: 9}, wrappers: 1}); }); + it('projects a standard emoji inside a quote line and copies its surrogate', () => { + const editor = createHarness(); + disposers.push(registerComposerEmojiShortcode(editor, (name) => (name === 'grinning' ? GRINNING : null))); + setComposer(editor, '> :grinning:', 12, 12); + const projection = editor.getEditorState().read(() => $projectComposer(), {editor}); + expect(projection).toEqual({ + display: '> :grinning:', + wire: '> 😀', + segments: [{type: 'emoji', id: 'grinning', displayText: ':grinning:', actualText: '😀', start: 2, end: 12}], + }); + select(editor, 2, 12); + const copied = editor.getEditorState().read(() => $getComposerClipboardSelection(), {editor}); + expect(copied).toMatchObject({display: ':grinning:', textPlain: '😀'}); + setComposer(editor, projection.display, undefined, undefined, projection.segments); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toEqual(projection); + const emojiInWrapper = editor.getEditorState().read( + () => { + const paragraph = $getRoot().getFirstChild(); + const wrapper = $isElementNode(paragraph) ? paragraph.getFirstChild() : null; + return $isComposerBlockquoteLineNode(wrapper) && wrapper.getChildren().some($isComposerStandardEmojiNode); + }, + {editor}, + ); + expect(emojiInWrapper).toBe(true); + }); + + it.each<[string, string]>([ + ['zero', '0️⃣'], + ['one', '1️⃣'], + ['two', '2️⃣'], + ['three', '3️⃣'], + ['four', '4️⃣'], + ['five', '5️⃣'], + ['six', '6️⃣'], + ['seven', '7️⃣'], + ['eight', '8️⃣'], + ['nine', '9️⃣'], + ['hash', '#️⃣'], + ['asterisk', '*️⃣'], + ])('keeps the keycap emoji :%s: an emoji when it is copied into the middle of a quoted word', (name, surrogate) => { + const editor = createHarness(); + const display = `:${name}:`; + disposers.push( + registerComposerEmojiShortcode(editor, (shortcode) => + shortcode === name ? {kind: 'standard', name, surrogate, url: null, display} : null, + ), + ); + setComposer(editor, `> ${display} word`, 2, 2 + display.length); + const copied = editor.getEditorState().read(() => $getComposerClipboardSelection(), {editor}); + expect(copied).toMatchObject({ + display, + textPlain: surrogate, + segments: [{type: 'emoji', displayText: display, actualText: surrogate, start: 0, end: display.length}], + }); + select(editor, display.length + 5); + update(editor, () => { + $insertComposerClipboardSlice(copied!, false); + }); + expect(snapshot(editor)).toMatchObject({wire: `> ${surrogate} wo${surrogate}rd`, wrappers: 1}); + const emojiWires = editor.getEditorState().read( + () => { + const paragraph = $getRoot().getFirstChild(); + const wrapper = $isElementNode(paragraph) ? paragraph.getFirstChild() : null; + return $isComposerBlockquoteLineNode(wrapper) + ? wrapper + .getChildren() + .filter($isComposerStandardEmojiNode) + .map((node) => node.getWireText()) + : []; + }, + {editor}, + ); + expect(emojiWires).toEqual([surrogate, surrogate]); + }); + + it.each([ + '*😀*', + '😀<@1>', + '`😀`', + '1😀', + '#😀', + ':one:', + ])('keeps an emoji segment whose wire %j is shaped like markup as text', (actualText) => { + const editor = createHarness(); + setComposer(editor, '> :x:', undefined, undefined, [ + {type: 'emoji', id: 'x', displayText: ':x:', actualText, start: 2, end: 5}, + ]); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toEqual({ + display: '> :x:', + wire: '> :x:', + segments: [], + }); + }); + it('projects a mention inside a quote line and survives a round trip', () => { const editor = createHarness(); const segment: MentionSegment = { diff --git a/fluxer_app/src/features/lexical/composer/ComposerBlockquote.ts b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.ts index 33e4fe3ae..29113d941 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerBlockquote.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerBlockquote.ts @@ -1,19 +1,36 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { + type BlockquoteDeleteUnit, type BlockquoteEdit, - type BlockquoteLine, - planBlockquoteArrowLeft, + isInsideBlockquoteContent, + type MultilineBlockquoteMarker, planBlockquoteBackspace, + planBlockquoteContentStartDelete, + planBlockquoteDropCaret, + planBlockquoteForwardDelete, planBlockquoteLineBreak, - resolveBlockquoteCaret, + planBlockquoteMove, + planBlockquotePaste, + resolveBlockquoteSelection, } from '@app/features/lexical/composer/blockquoteLines'; +import { + $insertComposerClipboardSlice, + COMPOSER_CLIPBOARD_MAX_DISPLAY_LENGTH, + type ComposerClipboardSlice, + FLUXER_COMPOSER_CLIPBOARD_MIME, + getClipboardEvent, + parseComposerClipboardSlice, +} from '@app/features/lexical/composer/ComposerClipboard'; import { $captureSelectionOffsets, $getComposerBlockquoteState, + $getComposerDisplayText, + $getComposerDropOffset, $replaceComposerRange, $selectComposerOffset, $selectComposerRange, + type ComposerBlockquoteState, } from '@app/features/lexical/composer/composerOffsets'; import { $createComposerBlockquoteLineNode, @@ -26,18 +43,38 @@ import {$isComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils'; import {mergeRegister} from '@lexical/utils'; import { + $addUpdateTag, $createTextNode, + $getEditor, + $getSelection, + $hasUpdateTag, $isLineBreakNode, + $isRangeSelection, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + CONTROLLED_TEXT_INSERTION_COMMAND, DELETE_CHARACTER_COMMAND, + DELETE_LINE_COMMAND, + DELETE_WORD_COMMAND, + DROP_COMMAND, type ElementNode, + findAllLexicalElementsDeep, + getEditorPropertyFromDOMNode, + HISTORIC_TAG, INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, + IS_APPLE, + isHTMLElement, + isLexicalEditor, KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_DOWN_COMMAND, type LexicalEditor, type LexicalNode, type LineBreakNode, + PASTE_COMMAND, + PASTE_TAG, SELECTION_CHANGE_COMMAND, TextNode, } from 'lexical'; @@ -47,7 +84,9 @@ export interface ComposerLine { lineBreak: LineBreakNode | null; } -type BlockquotePlanner = (text: string, lines: ReadonlyArray, caret: number) => BlockquoteEdit | null; +type BlockquotePlanner = (state: ComposerBlockquoteState, caret: number) => BlockquoteEdit | null; + +const LEXICAL_DRAG_MIME = 'application/x-lexical-drag'; export function $splitComposerLines(paragraph: ElementNode): Array { const lines: Array = [{nodes: [], lineBreak: null}]; @@ -65,11 +104,11 @@ function $applyBlockquotePlan(editor: LexicalEditor, planner: BlockquotePlanner) if (editor.isComposing()) { return false; } - const {scanText, selection, lines} = $getComposerBlockquoteState(); - if (selection == null || selection.anchor !== selection.focus || lines.length === 0) { + const state = $getComposerBlockquoteState(); + if (state.selection == null || state.selection.anchor !== state.selection.focus || state.lines.length === 0) { return false; } - const edit = planner(scanText, lines, selection.anchor); + const edit = planner(state, state.selection.anchor); if (edit == null) { return false; } @@ -78,17 +117,167 @@ function $applyBlockquotePlan(editor: LexicalEditor, planner: BlockquotePlanner) return true; } -export function $snapCaretOutOfBlockquoteMarker(): void { +function $insertTextIntoBlockquote(text: string, parserFlags?: number): boolean { + if ($getEditor().isComposing()) { + return false; + } + const {scanText, selection, lines} = $getComposerBlockquoteState(); + if (selection == null) { + return false; + } + const start = Math.min(selection.anchor, selection.focus); + const end = Math.max(selection.anchor, selection.focus); + if (!isInsideBlockquoteContent(lines, start)) { + return false; + } + const slice = {display: text, segments: []}; + if (planBlockquotePaste(scanText, lines, start, end, slice, parserFlags).display === text) { + return false; + } + return $insertComposerClipboardSlice(slice, false, parserFlags); +} + +function readDragSourceKey(dataTransfer: DataTransfer): string | null { + const marker = dataTransfer.getData(LEXICAL_DRAG_MIME); + if (marker.length === 0) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(marker); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed == null || !('editorKey' in parsed)) { + return null; + } + return typeof parsed.editorKey === 'string' ? parsed.editorKey : null; +} + +function readDraggedSlice(dataTransfer: DataTransfer): ComposerClipboardSlice | null { + const serialized = dataTransfer.getData(FLUXER_COMPOSER_CLIPBOARD_MIME); + const slice = serialized.length === 0 ? null : parseComposerClipboardSlice(serialized); + if (slice != null) { + return slice; + } + const moved = dataTransfer.getData('text/plain'); + if (moved.length === 0 || moved.length > COMPOSER_CLIPBOARD_MAX_DISPLAY_LENGTH) { + return null; + } + return {display: moved, segments: []}; +} + +function findDragSourceRoot(editor: LexicalEditor, sourceKey: string): HTMLElement | null { + const root = editor.getRootElement(); + if (root == null) { + return null; + } + for (const element of findAllLexicalElementsDeep(root.ownerDocument)) { + const source = getEditorPropertyFromDOMNode(element); + if (isLexicalEditor(source) && source.getKey() === sourceKey && isHTMLElement(element)) { + return element; + } + } + return null; +} + +function $insertDroppedSlice(caret: number, slice: ComposerClipboardSlice, parserFlags?: number): void { + $selectComposerOffset(caret); + $snapSelectionOutOfBlockquoteMarker(); + $insertComposerClipboardSlice(slice, false, parserFlags); + $addUpdateTag(PASTE_TAG); +} + +function $moveDroppedTextIntoBlockquote(editor: LexicalEditor, event: DragEvent, parserFlags?: number): boolean { + if (editor.isComposing() || event.dataTransfer == null) { + return false; + } + const sourceKey = readDragSourceKey(event.dataTransfer); + const slice = sourceKey == null ? null : readDraggedSlice(event.dataTransfer); + const dropped = slice == null ? null : $getComposerDropOffset(editor, event.clientX, event.clientY); + if (sourceKey == null || slice == null || dropped == null) { + return false; + } + if (sourceKey !== editor.getKey()) { + const sourceRoot = findDragSourceRoot(editor, sourceKey); + if (sourceRoot == null) { + return false; + } + $insertDroppedSlice(dropped, slice, parserFlags); + sourceRoot.dispatchEvent( + new InputEvent('beforeinput', {bubbles: true, cancelable: true, inputType: 'deleteByDrag'}), + ); + event.preventDefault(); + return true; + } + const selection = $captureSelectionOffsets(); + if (selection == null || selection.anchor === selection.focus) { + return false; + } + const start = Math.min(selection.anchor, selection.focus); + const end = Math.max(selection.anchor, selection.focus); + const caret = planBlockquoteDropCaret(start, end, dropped); + if (caret == null) { + return false; + } + $replaceComposerRange(start, end, {kind: 'text', text: ''}, {leading: false, trailing: false}); + $insertDroppedSlice(caret, slice, parserFlags); + event.preventDefault(); + return true; +} + +export function $snapSelectionOutOfBlockquoteMarker(): void { const {selection, lines} = $getComposerBlockquoteState(); - if (selection == null || selection.anchor !== selection.focus || lines.length === 0) { + if (selection == null || lines.length === 0) { return; } - const caret = resolveBlockquoteCaret(lines, selection.anchor); - if (caret !== selection.anchor) { - $selectComposerOffset(caret); + const resolved = resolveBlockquoteSelection(lines, selection.anchor, selection.focus); + if (resolved.anchor !== selection.anchor || resolved.focus !== selection.focus) { + $selectComposerRange(resolved.anchor, resolved.focus); } } +function $isFocusRTL(editor: LexicalEditor): boolean { + const selection = $getSelection(); + const block = $isRangeSelection(selection) ? selection.focus.getNode().getTopLevelElement() : null; + const dom = block == null ? null : editor.getElementByKey(block.getKey()); + const view = dom == null ? null : dom.ownerDocument.defaultView; + return dom != null && view != null && view.getComputedStyle(dom).direction === 'rtl'; +} + +function isWordMove(event: KeyboardEvent): boolean { + const apple = IS_APPLE; + return ( + (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && + !event.metaKey && + event.altKey === apple && + event.ctrlKey !== apple + ); +} + +function $moveAcrossBlockquoteMarker( + editor: LexicalEditor, + event: KeyboardEvent, + isArrowLeft: boolean, + byWord: boolean, +): boolean { + if (isIMEComposing(event) || editor.isComposing()) { + return false; + } + const {selection, lines} = $getComposerBlockquoteState(); + if (selection == null || lines.length === 0 || (!event.shiftKey && selection.anchor !== selection.focus)) { + return false; + } + const isBackward = isArrowLeft !== $isFocusRTL(editor); + const focus = byWord && !isBackward ? null : planBlockquoteMove(lines, selection.focus, isBackward); + if (focus == null) { + return false; + } + event.preventDefault(); + $selectComposerRange(event.shiftKey ? selection.anchor : focus, focus); + return true; +} + function shouldWrapLine(line: ComposerLine, quoted: boolean | undefined): boolean { return quoted === true && $isComposerBlockquoteMarkerNode(line.nodes[0]); } @@ -197,18 +386,29 @@ function $prefixComposerLine(line: ComposerLine): void { } } -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) { +function $insertedBeforeCaret(caret: number): boolean { + if ($hasUpdateTag(HISTORIC_TAG)) { return false; } - const indent = markerEnds[index]! - 4; + const editor = $getEditor(); + const previous = editor.getEditorState().read(() => $getComposerDisplayText(), {editor}); + const current = $getComposerDisplayText(); + return ( + caret > 0 && + current.length === previous.length + 1 && + `${current.slice(0, caret - 1)}${current.slice(caret)}` === previous + ); +} + +export function $rewriteMultilineBlockquoteMarker( + lines: ReadonlyArray, + marker: MultilineBlockquoteMarker | null, +): boolean { + if (marker == null) { + return false; + } + const index = marker.line; + const indent = marker.indent; const lineStarts: Array = []; let offset = 0; for (const line of lines) { @@ -221,15 +421,17 @@ export function $rewriteMultilineBlockquoteMarker( selection != null && selection.anchor === selection.focus && selection.anchor > markerStart && - selection.anchor <= markerStart + 4; + selection.anchor <= markerStart + 4 && + $insertedBeforeCaret(selection.anchor); + if (!marker.splits && !(typed && index === lines.length - 1)) { + return false; + } 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); - } + const prefixedStarts = lineStarts.slice(index + 1); + for (const line of lines.slice(index + 1)) { + $prefixComposerLine(line); } if (selection != null) { const map = (value: number): number => { @@ -249,48 +451,78 @@ export function $rewriteMultilineBlockquoteMarker( return true; } -export function registerComposerBlockquote(editor: LexicalEditor): () => void { +export function registerComposerBlockquote(editor: LexicalEditor, parserFlags?: number): () => void { + const insertLineBreak = () => + $applyBlockquotePlan(editor, ({scanText, lines}, caret) => + planBlockquoteLineBreak(scanText, lines, caret, parserFlags), + ); + const deleteAcrossMarker = (unit: BlockquoteDeleteUnit) => (isBackward: boolean) => + $applyBlockquotePlan(editor, ({scanText, lines, atoms}, caret) => + isBackward + ? planBlockquoteBackspace(scanText, lines, caret, parserFlags) + : (planBlockquoteForwardDelete(scanText, lines, caret, parserFlags) ?? + planBlockquoteContentStartDelete(scanText, lines, atoms, caret, unit)), + ); return mergeRegister( editor.registerCommand( INSERT_LINE_BREAK_COMMAND, - (selectStart) => !selectStart && $applyBlockquotePlan(editor, planBlockquoteLineBreak), + (selectStart) => !selectStart && insertLineBreak(), 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(INSERT_PARAGRAPH_COMMAND, insertLineBreak, COMMAND_PRIORITY_HIGH), + editor.registerCommand(DELETE_CHARACTER_COMMAND, deleteAcrossMarker('character'), COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(DELETE_WORD_COMMAND, deleteAcrossMarker('word'), COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(DELETE_LINE_COMMAND, deleteAcrossMarker('line'), COMMAND_PRIORITY_CRITICAL), editor.registerCommand( KEY_ARROW_LEFT_COMMAND, + (event) => $moveAcrossBlockquoteMarker(editor, event, true, false), + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + (event) => $moveAcrossBlockquoteMarker(editor, event, false, false), + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + KEY_DOWN_COMMAND, + (event) => isWordMove(event) && $moveAcrossBlockquoteMarker(editor, event, event.key === 'ArrowLeft', true), + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + PASTE_COMMAND, (event) => { - if (event.shiftKey || event.metaKey || isIMEComposing(event) || editor.isComposing()) { + const clipboardEvent = getClipboardEvent(event); + if ( + clipboardEvent == null || + clipboardEvent.clipboardData == null || + !$insertTextIntoBlockquote(clipboardEvent.clipboardData.getData('text/plain'), parserFlags) + ) { 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); + $addUpdateTag(PASTE_TAG); + clipboardEvent.preventDefault(); return true; }, - COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => $moveDroppedTextIntoBlockquote(editor, event, parserFlags), + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CONTROLLED_TEXT_INSERTION_COMMAND, + (payload) => + typeof payload !== 'string' && + payload.dataTransfer != null && + $insertTextIntoBlockquote(payload.dataTransfer.getData('text/plain'), parserFlags), + COMMAND_PRIORITY_LOW, ), editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { if (!editor.isComposing()) { - $snapCaretOutOfBlockquoteMarker(); + $snapSelectionOutOfBlockquoteMarker(); } return false; }, diff --git a/fluxer_app/src/features/lexical/composer/ComposerClipboard.ts b/fluxer_app/src/features/lexical/composer/ComposerClipboard.ts index 146ce5545..3ce941838 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerClipboard.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerClipboard.ts @@ -1,12 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {planBlockquotePaste} from '@app/features/lexical/composer/blockquoteLines'; +import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle'; import {resolvePastedLinkInsertion} from '@app/features/lexical/composer/ComposerLinkPaste'; import { $createComposerSegmentNodes, $projectComposer, isValidComposerSegment, } from '@app/features/lexical/composer/ComposerSerialization'; -import {$captureSelectionOffsets, $selectComposerOffset} from '@app/features/lexical/composer/composerOffsets'; +import { + $captureSelectionOffsets, + $getComposerBlockquoteState, + $getComposerContentSelectionRange, + $getComposerDisplayText, + $replaceComposerRange, + $selectComposerOffset, +} from '@app/features/lexical/composer/composerOffsets'; import {$isSlashSlotNode, type SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode'; import {COMPOSER_SLASH_SLOT_STATE_MAX_ID_LENGTH} from '@app/features/lexical/composer/SlashSlotPersistence'; import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums'; @@ -28,6 +37,7 @@ import { COPY_COMMAND, CUT_COMMAND, CUT_TAG, + DRAGSTART_COMMAND, type LexicalEditor, type LexicalNode, PASTE_COMMAND, @@ -50,6 +60,7 @@ const SEGMENT_KEYS = ['type', 'id', 'displayText', 'actualText', 'start', 'end'] const TRUST_TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const trustedPayloads = new Map(); let trustedPayloadLength = 0; +let trustedDragPayload: string | null = null; export interface ComposerClipboardSlice { display: string; @@ -223,7 +234,7 @@ export function $getComposerClipboardSelection(): ComposerClipboardSelection | n return {...validated, textPlain: sliceToWire(validated)}; } -export function serializeComposerClipboardSlice(slice: ComposerClipboardSlice): string | null { +function mintTrustedPayload(slice: ComposerClipboardSlice): {token: string; serialized: string} | null { const validated = validateSlice(slice.display, slice.segments); if (validated == null) { return null; @@ -236,9 +247,15 @@ export function serializeComposerClipboardSlice(slice: ComposerClipboardSlice): segments: validated.segments, }; const serialized = JSON.stringify(payload); - if (serialized.length > COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH) { + return serialized.length > COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH ? null : {token, serialized}; +} + +export function serializeComposerClipboardSlice(slice: ComposerClipboardSlice): string | null { + const minted = mintTrustedPayload(slice); + if (minted == null) { return null; } + const {token, serialized} = minted; trustedPayloads.set(token, serialized); trustedPayloadLength += serialized.length; while ( @@ -255,6 +272,12 @@ export function serializeComposerClipboardSlice(slice: ComposerClipboardSlice): return serialized; } +function serializeComposerDragSlice(slice: ComposerClipboardSlice | null): string | null { + const minted = slice == null ? null : mintTrustedPayload(slice); + trustedDragPayload = minted == null ? null : minted.serialized; + return trustedDragPayload; +} + export function parseComposerClipboardSlice(serialized: string): ComposerClipboardSlice | null { if (serialized.length === 0 || serialized.length > COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH) { return null; @@ -274,7 +297,7 @@ export function parseComposerClipboardSlice(serialized: string): ComposerClipboa if (typeof value.token !== 'string' || !TRUST_TOKEN_RE.test(value.token)) { return null; } - if (trustedPayloads.get(value.token) !== serialized) { + if (trustedPayloads.get(value.token) !== serialized && trustedDragPayload !== serialized) { return null; } const validated = validateSlice(value.display, value.segments); @@ -374,7 +397,11 @@ function $replaceSlashSlotRange( return true; } -export function $insertComposerClipboardSlice(slice: ComposerClipboardSlice, plainText: boolean): boolean { +export function $insertComposerClipboardSlice( + slice: ComposerClipboardSlice, + plainText: boolean, + parserFlags?: number, +): boolean { const validated = validateSlice(slice.display, slice.segments); if (validated == null) { return false; @@ -400,23 +427,61 @@ export function $insertComposerClipboardSlice(slice: ComposerClipboardSlice, pla if (!$isRangeSelection(selection)) { return false; } - const offsets = $captureSelectionOffsets(); + const {scanText, selection: offsets, lines} = $getComposerBlockquoteState(); if (offsets == null) { return false; } const start = Math.min(offsets.anchor, offsets.focus); + const end = Math.max(offsets.anchor, offsets.focus); const anchorSlot = $findEnclosingSlashSlot(selection.anchor.getNode()); const focusSlot = $findEnclosingSlashSlot(selection.focus.getNode()); if (anchorSlot != null && focusSlot != null && anchorSlot.is(focusSlot)) { - const end = Math.max(offsets.anchor, offsets.focus); return $replaceSlashSlotRange(anchorSlot, start, end, sliceToWire(validated)); } - selection.insertNodes($createSliceNodes(validated, plainText)); - $selectComposerOffset(start + validated.display.length); + const pasted = planBlockquotePaste(scanText, lines, start, end, validated, parserFlags); + const nodes = $createSliceNodes(pasted, plainText); + if (nodes.length > 0) { + selection.insertNodes(nodes); + } else if (start !== end) { + selection.removeText(); + } + $selectComposerOffset(start + pasted.display.length); return true; } -function getClipboardEvent(event: unknown): ClipboardEvent | null { +function createPastedSlice(pastedText: string, segments: ReadonlyArray): ComposerClipboardSlice { + const displayParts: Array = []; + const projectedSegments: Array = []; + let sourceCursor = 0; + let displayLength = 0; + for (const segment of segments) { + const plainText = pastedText.slice(sourceCursor, segment.start); + displayParts.push(plainText, segment.displayText); + displayLength += plainText.length; + projectedSegments.push({ + ...segment, + start: displayLength, + end: displayLength + segment.displayText.length, + }); + displayLength += segment.displayText.length; + sourceCursor = segment.end; + } + displayParts.push(pastedText.slice(sourceCursor)); + return {display: displayParts.join(''), segments: projectedSegments}; +} + +export function $insertComposerPastedText( + pastedText: string, + segments: ReadonlyArray, + plainText: boolean, + handle: ComposerHandle | null, +): boolean { + const detected = createPastedSlice(pastedText, segments); + const slice = getComposerClipboardTextPlain(detected) == null ? {display: pastedText, segments: []} : detected; + return $insertComposerClipboardSlice(slice, plainText, handle == null ? undefined : handle.getMarkdownParserFlags()); +} + +export function getClipboardEvent(event: unknown): ClipboardEvent | null { if ( typeof event !== 'object' || event == null || @@ -443,6 +508,13 @@ function writeComposerClipboardSelection(event: ClipboardEvent): boolean { return true; } +function writeComposerDragSlice(dataTransfer: DataTransfer): void { + const serialized = serializeComposerDragSlice($getComposerClipboardSelection()); + if (serialized != null) { + dataTransfer.setData(FLUXER_COMPOSER_CLIPBOARD_MIME, serialized); + } +} + function $deleteComposerClipboardSelection(): boolean { const selection = $getSelection(); if ($isRangeSelection(selection)) { @@ -474,24 +546,39 @@ export function registerComposerClipboardCommands( if (clipboardEvent == null || clipboardEvent.clipboardData == null) { return false; } - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { + const range = $getComposerContentSelectionRange(); + if (range == null || range.start === range.end) { return false; } const replacement = resolvePastedLinkInsertion( clipboardEvent.clipboardData.getData('text/plain'), - selection.getTextContent(), + $getComposerDisplayText().slice(range.start, range.end), ); if (replacement === null) { return false; } - selection.insertText(replacement); + $replaceComposerRange( + range.start, + range.end, + {kind: 'text', text: replacement}, + {leading: false, trailing: false}, + ); $addUpdateTag(PASTE_TAG); clipboardEvent.preventDefault(); return true; }, COMMAND_PRIORITY_CRITICAL, ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + if (state.isEditable() && event.dataTransfer != null) { + writeComposerDragSlice(event.dataTransfer); + } + return false; + }, + COMMAND_PRIORITY_NORMAL, + ), editor.registerCommand( COPY_COMMAND, (event) => { @@ -538,7 +625,10 @@ export function registerComposerClipboardCommands( return false; } const slice = parseComposerClipboardSlice(serialized); - if (slice == null || !$insertComposerClipboardSlice(slice, state.getPlainText())) { + if ( + slice == null || + !$insertComposerClipboardSlice(slice, state.getPlainText(), state.getMarkdownParserFlags()) + ) { return false; } $addUpdateTag(PASTE_TAG); diff --git a/fluxer_app/src/features/lexical/composer/ComposerCrossEditorDrop.test.ts b/fluxer_app/src/features/lexical/composer/ComposerCrossEditorDrop.test.ts new file mode 100644 index 000000000..aed5ee49e --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerCrossEditorDrop.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {registerComposerBlockquote} from '@app/features/lexical/composer/ComposerBlockquote'; +import {registerComposerClipboardCommands} from '@app/features/lexical/composer/ComposerClipboard'; +import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight'; +import { + $hydrateComposerFromDraft, + $projectComposer, + type ComposerProjection, +} from '@app/features/lexical/composer/ComposerSerialization'; +import {$selectComposerRange} 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 {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'; +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 {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode'; +import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR, + createEditor, + DRAGSTART_COMMAND, + DROP_COMMAND, + type LexicalEditor, + REMOVE_TEXT_COMMAND, +} from 'lexical'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +vi.hoisted(() => { + if (!('getTargetRanges' in InputEvent.prototype)) { + Object.defineProperty(InputEvent.prototype, 'getTargetRanges', {configurable: true, value: () => []}); + } +}); + +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 dropOffset = vi.hoisted(() => ({value: null as number | null})); + +vi.mock('@app/features/lexical/composer/composerOffsets', async (importOriginal) => { + const actual = await importOriginal(); + return {...actual, $getComposerDropOffset: () => dropOffset.value}; +}); + +const NODES = [ + ComposerMentionNode, + ComposerCustomEmojiNode, + ComposerStandardEmojiNode, + ComposerPlainSegmentNode, + ComposerCommandNode, + SlashSlotNode, + SlashSlotPlaceholderNode, + SlashSeparatorNode, + SlashOptionalHintNode, + SyntaxMarkerNode, + ComposerBlockquoteLineNode, + ComposerBlockquoteMarkerNode, +]; + +const LEXICAL_DRAG_MIME = 'application/x-lexical-drag'; + +interface MountedComposer { + editor: LexicalEditor; + element: HTMLDivElement; + removals: {count: number}; +} + +const disposers: Array<() => void> = []; + +afterEach(() => { + while (disposers.length > 0) { + disposers.pop()!(); + } + dropOffset.value = null; + document.body.replaceChildren(); +}); + +function mount( + text: string, + anchor: number, + focus: number, + segments: ReadonlyArray = [], +): MountedComposer { + const element = document.createElement('div'); + element.contentEditable = 'true'; + document.body.append(element); + const editor = createEditor({ + namespace: 'composer-cross-editor-drop-test', + nodes: NODES, + onError: (error) => { + throw error; + }, + }); + editor.setRootElement(element); + const removals = {count: 0}; + disposers.push( + () => editor.setRootElement(null), + registerComposerMarkdownHighlight(editor, DEFAULT_COMPOSER_MARKDOWN_FLAGS, false), + registerComposerBlockquote(editor, DEFAULT_COMPOSER_MARKDOWN_FLAGS), + registerComposerClipboardCommands(editor, { + getPlainText: () => false, + isEditable: () => true, + getMarkdownParserFlags: () => DEFAULT_COMPOSER_MARKDOWN_FLAGS, + }), + editor.registerCommand( + REMOVE_TEXT_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + removals.count += 1; + selection.removeText(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + ); + editor.update( + () => { + $hydrateComposerFromDraft(text, segments); + $selectComposerRange(anchor, focus); + }, + {discrete: true}, + ); + return {editor, element, removals}; +} + +function projection(composer: MountedComposer): ComposerProjection { + return composer.editor.read(() => $projectComposer()); +} + +function dragStart(source: MountedComposer, moved: string): Record { + const data: Record = {}; + const dataTransfer = { + getData: (type: string) => data[type] ?? '', + setData: (type: string, value: string) => { + data[type] = value; + }, + } as unknown as DataTransfer; + source.editor.update( + () => { + source.editor.dispatchCommand(DRAGSTART_COMMAND, {dataTransfer} as unknown as DragEvent); + }, + {discrete: true}, + ); + data['text/plain'] = moved; + data[LEXICAL_DRAG_MIME] = JSON.stringify({editorKey: source.editor.getKey()}); + return data; +} + +function drop(target: MountedComposer, data: Record, offset: number): boolean { + dropOffset.value = offset; + const event = { + clientX: 0, + clientY: 0, + dataTransfer: {getData: (type: string) => data[type] ?? ''} as unknown as DataTransfer, + preventDefault: vi.fn(), + }; + let handled = false; + target.editor.update( + () => { + handled = target.editor.dispatchCommand(DROP_COMMAND, event as unknown as DragEvent); + }, + {discrete: true}, + ); + return handled && event.preventDefault.mock.calls.length > 0; +} + +describe('drops carrying another composer as their drag source', () => { + it('continues the quote in the target and removes the dragged range from the source once', () => { + const target = mount('> alpha', 2, 2); + const source = mount('one\ntwo keep', 0, 7); + expect(drop(target, dragStart(source, 'one\ntwo'), 2)).toBe(true); + expect(projection(target).wire).toBe('> one\n> twoalpha'); + expect(projection(source).wire).toBe(' keep'); + expect(source.removals.count).toBe(1); + expect(target.removals.count).toBe(0); + }); + + it('keeps a mention dragged out of another composer a mention', () => { + const target = mount('> quoted', 2, 2); + const source = mount('hi @name', 3, 8, [ + {type: 'user', id: '1', displayText: '@name', actualText: '<@1>', start: 3, end: 8}, + ]); + expect(drop(target, dragStart(source, '@name'), 2)).toBe(true); + expect(projection(target).wire).toBe('> <@1>quoted'); + expect(projection(target).segments).toMatchObject([{type: 'user', id: '1', start: 2, end: 7}]); + expect(projection(source).wire).toBe('hi '); + expect(source.removals.count).toBe(1); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/ComposerHandle.ts b/fluxer_app/src/features/lexical/composer/ComposerHandle.ts index 37ce30a98..38b69fff7 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerHandle.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerHandle.ts @@ -22,6 +22,7 @@ export interface ComposerHandle { getDisplayValue: () => string; getWireValue: () => string; getSegments: () => Array; + getMarkdownParserFlags: () => number; getTextUpToCursor: () => string; getSelection: () => ComposerSelectionRange | null; replaceRange: (start: number, end: number, payload: ComposerInsertPayload, spacing?: ComposerInsertSpacing) => void; diff --git a/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts b/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts index edfad8501..a0cf75b0a 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {findBlockquoteMarkerEnds} from '@app/features/lexical/composer/blockquoteLines'; +import {findBlockquoteMarkers} from '@app/features/lexical/composer/blockquoteLines'; import { $rewriteMultilineBlockquoteMarker, - $snapCaretOutOfBlockquoteMarker, + $snapSelectionOutOfBlockquoteMarker, $splitComposerLines, $syncComposerBlockquoteLines, } from '@app/features/lexical/composer/ComposerBlockquote'; @@ -93,6 +93,7 @@ export function registerComposerMarkdownHighlight( editor: LexicalEditor, parserFlags?: number, silentMessagePrefix = false, + maxWireLength?: number, ): () => void { return editor.registerNodeTransform(RootNode, (root) => { if (editor.isComposing()) { @@ -100,7 +101,7 @@ export function registerComposerMarkdownHighlight( } for (const child of root.getChildren()) { if (child instanceof ParagraphNode) { - $reconcileParagraph(child, parserFlags, silentMessagePrefix && child.is(root.getFirstChild())); + $reconcileParagraph(child, parserFlags, silentMessagePrefix && child.is(root.getFirstChild()), maxWireLength); } } }); @@ -123,13 +124,23 @@ export function $reconcileLineOf(node: TextNode, parserFlags?: number): void { $reconcileParagraph(block as ParagraphNode, parserFlags); } -function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, silentMessagePrefix = false): void { +function $reconcileParagraph( + paragraph: ParagraphNode, + parserFlags?: number, + silentMessagePrefix = false, + maxWireLength?: number, +): void { 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)) { + const {markerEnds: quoteMarkerEnds, multiline} = findBlockquoteMarkers( + source, + markdownSpans, + parserFlags, + maxWireLength, + ); + if ($rewriteMultilineBlockquoteMarker(lines, multiline)) { return; } const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans; @@ -155,7 +166,7 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, sil quoteMarkerEnds.map((end) => end > 0), ); if (quoteMarkerEnds.some((end) => end > 0)) { - $snapCaretOutOfBlockquoteMarker(); + $snapSelectionOutOfBlockquoteMarker(); } } diff --git a/fluxer_app/src/features/lexical/composer/ComposerMarkdownShortcuts.ts b/fluxer_app/src/features/lexical/composer/ComposerMarkdownShortcuts.ts index 4d2e5bdb5..7d4b4ec21 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerMarkdownShortcuts.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerMarkdownShortcuts.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {$getComposerSelectionRange, $wrapComposerSelection} from '@app/features/lexical/composer/composerOffsets'; +import { + $getComposerContentSelectionRange, + $wrapComposerSelection, +} from '@app/features/lexical/composer/composerOffsets'; import {COMMAND_PRIORITY_HIGH, KEY_DOWN_COMMAND, type LexicalEditor} from 'lexical'; const SHORTCUTS = new Map([ @@ -21,7 +24,7 @@ export function registerComposerMarkdownShortcuts(editor: LexicalEditor): () => if (shortcut == null || event.shiftKey !== shortcut.shift) { return false; } - const selection = $getComposerSelectionRange(); + const selection = $getComposerContentSelectionRange(); if (selection == null || selection.start === selection.end) { return false; } diff --git a/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts b/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts index 725339483..4be194a89 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts @@ -18,7 +18,10 @@ import { $createComposerPlainSegmentNode, $isComposerPlainSegmentNode, } from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode'; -import {$isComposerStandardEmojiNode} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode'; +import { + $createComposerStandardEmojiNode, + $isComposerStandardEmojiNode, +} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode'; import {$createSlashSeparatorNode} from '@app/features/lexical/composer/nodes/SlashSeparatorNode'; import { $createSlashSlotNode, @@ -53,12 +56,17 @@ export interface ComposerProjection { } const CUSTOM_EMOJI_WIRE_RE = /^<(a)?:([A-Za-z0-9_+~-]+):(\d+)>$/; +const STANDARD_EMOJI_WIRE_RE = /^(?:[0-9#*]\uFE0F?\u20E3|\P{ASCII})+$/u; const SLOT_USER_WIRE_RE = /^<@!?(\d+)>$/; const SLOT_ROLE_WIRE_RE = /^<@&(\d+)>$/; const SLOT_CHANNEL_WIRE_RE = /^<#(\d+)>$/; export const COMPOSER_SLASH_SLOT_SEGMENT_PREFIX = 'slash-slot:'; const SLASH_SLOT_OPTION_NAME_RE = /^[a-z0-9_-]{1,32}$/; +function isStandardEmojiSegment(segment: MentionSegment): boolean { + return segment.type === 'emoji' && STANDARD_EMOJI_WIRE_RE.test(segment.actualText); +} + function isValidSegmentWire(segment: MentionSegment): boolean { if (parseSlashCommandStateSegment(segment) != null) { return true; @@ -78,7 +86,7 @@ function isValidSegmentWire(segment: MentionSegment): boolean { switch (segment.type) { case 'emoji': { const match = CUSTOM_EMOJI_WIRE_RE.exec(segment.actualText); - return match != null && match[3] === segment.id; + return match == null ? isStandardEmojiSegment(segment) : match[3] === segment.id; } case 'user': { const match = SLOT_USER_WIRE_RE.exec(segment.actualText); @@ -308,8 +316,22 @@ export function $projectComposer(): ComposerProjection { end: display.length, }); } else if ($isComposerStandardEmojiNode(child)) { - display += child.getTextContent(); - wire += child.getWireText(); + const start = display.length; + const displayText = child.getTextContent(); + const actualText = child.getWireText(); + display += displayText; + wire += actualText; + const segment: MentionSegment = { + type: 'emoji', + id: child.getEmojiName(), + displayText, + actualText, + start, + end: display.length, + }; + if (isStandardEmojiSegment(segment)) { + segments.push(segment); + } } else if ($isSlashSlotNode(child)) { const displayText = child.getTextContent(); const start = display.length; @@ -403,6 +425,9 @@ export function $createComposerSegmentNodes(segment: MentionSegment, plainText: if (match) { return [$createComposerCustomEmojiNode(match[3]!, Boolean(match[1]), segment.displayText, segment.actualText)]; } + if (isStandardEmojiSegment(segment)) { + return [$createComposerStandardEmojiNode(segment.id, segment.actualText, null, segment.displayText)]; + } return [$createTextNode(segment.displayText)]; } return [ diff --git a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx index bcf95447b..abc02cec9 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx @@ -133,6 +133,7 @@ export interface LexicalComposerInputProps { slotResolvers?: SlashSlotResolvers; markdown?: boolean; markdownParserFlags?: number; + maxWireLength?: number; silentMessagePrefix?: boolean; emojiShortcodeResolver?: ComposerEmojiResolver; specialMentionsAllowed: boolean; @@ -242,6 +243,7 @@ const ComposerInner = ({ slotResolvers, markdown = true, markdownParserFlags, + maxWireLength, silentMessagePrefix = false, emojiShortcodeResolver, specialMentionsAllowed, @@ -362,6 +364,7 @@ const ComposerInner = ({ ); return segments; }, + getMarkdownParserFlags: () => markdownParserFlagsRef.current, getTextUpToCursor: () => { let text = ''; editor.getEditorState().read( @@ -523,8 +526,10 @@ const ComposerInner = ({ cleanups.push(registerSlashSlotPlugin(editor, () => slotResolversRef.current, typeaheadActiveState)); cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current)); if (markdown) { - cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix)); - cleanups.push(registerComposerBlockquote(editor)); + cleanups.push( + registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix, maxWireLength), + ); + cleanups.push(registerComposerBlockquote(editor, markdownParserFlags)); } cleanups.push( registerComposerEmojiShortcode(editor, (shortcodeName) => { @@ -558,7 +563,7 @@ const ComposerInner = ({ {discrete: true, tag: HISTORY_MERGE_TAG}, ); return mergeRegister(...cleanups); - }, [editor, markdown, markdownParserFlags, plainText, silentMessagePrefix]); + }, [editor, markdown, markdownParserFlags, maxWireLength, plainText, silentMessagePrefix]); useLayoutEffect( () => registerComposerSpecialMention(editor, specialMentionsAllowed, plainText), diff --git a/fluxer_app/src/features/lexical/composer/LexicalRichInput.test.tsx b/fluxer_app/src/features/lexical/composer/LexicalRichInput.test.tsx new file mode 100644 index 000000000..40784b5b5 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/LexicalRichInput.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; +import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput'; +import type {I18n} from '@lingui/core'; +import {act, createElement, createRef} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const stub = vi.hoisted(() => ({ + handle: { + getDisplayValue: () => '', + getSegments: () => [], + getSelection: () => ({start: 0, end: 0}), + getMarkdownParserFlags: () => 0, + replaceRange: () => undefined, + }, + composerMaxWireLength: undefined as number | undefined, + autocompleteMaxActualLength: undefined as number | undefined, + emojiLimits: [] as Array<{maxWireLength?: number}>, +})); + +vi.mock('@app/features/lexical/composer/LexicalComposerInput', () => ({ + LexicalComposerInput: (props: {maxWireLength?: number; handleRef: {current: unknown}}) => { + stub.composerMaxWireLength = props.maxWireLength; + props.handleRef.current = stub.handle; + return null; + }, +})); + +vi.mock('@app/features/lexical/composer/useLexicalAutocomplete', () => ({ + useLexicalAutocomplete: (params: {maxActualLength?: number}) => { + stub.autocompleteMaxActualLength = params.maxActualLength; + return { + autocompleteQuery: '', + autocompleteOptions: [], + autocompleteType: null, + isSlotMenu: false, + onCursorMove: () => undefined, + handleSelect: () => undefined, + specialMentionsAllowed: true, + }; + }, +})); + +vi.mock('@app/features/lexical/composer/ComposerInsertion', () => ({ + insertComposerEmoji: (_handle: unknown, _emoji: unknown, limit: {maxWireLength?: number}) => { + stub.emojiLimits.push(limit); + return true; + }, +})); + +vi.mock('@app/features/messaging/utils/TypedEmojiShortcodeUtils', () => ({resolveTypedEmojiToken: () => null})); + +(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + +const I18N = {} as I18n; +const EMOJI = {} as FlatEmoji; + +describe('LexicalRichInput length limits', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + stub.composerMaxWireLength = undefined; + stub.autocompleteMaxActualLength = undefined; + stub.emojiLimits.length = 0; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.replaceChildren(); + }); + + it.each<[string, {maxLength?: number; maxWireLength?: number}, number, number | undefined]>([ + ['only maxWireLength', {maxWireLength: 40}, 40, undefined], + ['only maxLength', {maxLength: 40}, 40, 40], + ['both', {maxLength: 30, maxWireLength: 40}, 40, 30], + ])('budgets the composer by maxWireLength and caps autocomplete and emoji inserts by maxLength when a host passes %s', (_name, props, composerLimit, insertLimit) => { + const richInputRef = createRef(); + act(() => { + root.render(createElement(LexicalRichInput, {placeholder: 'placeholder', i18n: I18N, richInputRef, ...props})); + }); + expect(stub.composerMaxWireLength).toBe(composerLimit); + expect(stub.autocompleteMaxActualLength).toBe(insertLimit); + act(() => { + richInputRef.current?.insertEmoji(EMOJI); + }); + expect(stub.emojiLimits).toEqual([{maxWireLength: insertLimit, onExceedMaxLength: undefined}]); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx index dc44e8e51..0c6fd18b3 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx @@ -39,6 +39,7 @@ export interface LexicalRichInputProps { singleLine?: boolean; size?: 'chat' | 'form'; maxLength?: number; + maxWireLength?: number; onExceedMaxLength?: () => void; autocompleteAnchor?: HTMLElement | null; className?: string; @@ -75,6 +76,7 @@ export const LexicalRichInput = ({ singleLine = false, size = 'chat', maxLength, + maxWireLength = maxLength, onExceedMaxLength, autocompleteAnchor, className, @@ -229,6 +231,7 @@ export const LexicalRichInput = ({ initialSegments={initialSegments} markdown={markdown} markdownParserFlags={markdownParserFlags} + maxWireLength={maxWireLength} emojiShortcodeResolver={emojiShortcodeResolver} specialMentionsAllowed={specialMentionsAllowed} channelId={channel == null ? undefined : channel.id} diff --git a/fluxer_app/src/features/lexical/composer/blockquoteLines.test.ts b/fluxer_app/src/features/lexical/composer/blockquoteLines.test.ts index 792c82147..5bf1d6590 100644 --- a/fluxer_app/src/features/lexical/composer/blockquoteLines.test.ts +++ b/fluxer_app/src/features/lexical/composer/blockquoteLines.test.ts @@ -1,19 +1,32 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { + type BlockquoteDeleteUnit, type BlockquoteEdit, type BlockquoteLine, + blockquoteContentRange, dropTrailingEmptyBlockquoteLines, - findBlockquoteMarkerEnds, - planBlockquoteArrowLeft, + findBlockquoteMarkers, planBlockquoteBackspace, + planBlockquoteContentStartDelete, + planBlockquoteDropCaret, + planBlockquoteForwardDelete, planBlockquoteLineBreak, - resolveBlockquoteCaret, + planBlockquoteMove, + planBlockquotePaste, + resolveBlockquoteSelection, } from '@app/features/lexical/composer/blockquoteLines'; -import {computeMarkdownHighlightSpans} from '@app/features/lexical/composer/markdownSpans'; +import { + computeMarkdownHighlightSpans, + DEFAULT_COMPOSER_MARKDOWN_FLAGS, +} from '@app/features/lexical/composer/markdownSpans'; +import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums'; +import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm'; +import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; import {describe, expect, it} from 'vitest'; const QUOTE_PREFIX_RE = /^[ \t]*> /; +const NO_CODE_BLOCK_FLAGS = DEFAULT_COMPOSER_MARKDOWN_FLAGS & ~ParserFlags.ALLOW_CODE_BLOCKS; function quoteLines(text: string): Array { const lines: Array = []; @@ -71,6 +84,16 @@ describe('planBlockquoteLineBreak', () => { ])('declines on %j at %i because the caret is inside a code block', (text, caret) => { expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull(); }); + + it.each<[string, number, string, number]>([ + ['> ```', 5, '> ```\n> ', 8], + ['> ```\n> b', 9, '> ```\n> b\n> ', 12], + ])('continues %j at %i into %j with the caret at %i when code blocks are off', (text, caret, expected, caretAfter) => { + expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull(); + const edit = planBlockquoteLineBreak(text, quoteLines(text), caret, NO_CODE_BLOCK_FLAGS); + expect(applyEdit(text, edit)).toBe(expected); + expect(edit?.caret).toBe(caretAfter); + }); }); describe('planBlockquoteBackspace', () => { @@ -101,9 +124,290 @@ describe('planBlockquoteBackspace', () => { ])('declines on %j at %i', (text, caret) => { expect(planBlockquoteBackspace(text, quoteLines(text), caret)).toBeNull(); }); + + it('removes the marker below a quoted fence when code blocks are off', () => { + const text = '> ```\n> b'; + expect(planBlockquoteBackspace(text, quoteLines(text), 8)).toBeNull(); + const edit = planBlockquoteBackspace(text, quoteLines(text), 8, NO_CODE_BLOCK_FLAGS); + expect(applyEdit(text, edit)).toBe('> ```\nb'); + expect(edit?.caret).toBe(6); + }); }); -describe('resolveBlockquoteCaret', () => { +describe('planBlockquoteForwardDelete', () => { + const ROWS: Array<[string, number, string, number]> = [ + ['> a\n> b', 3, '> ab', 3], + ['a\n> b', 1, 'ab', 1], + ['> a\n> ', 3, '> a', 3], + [' > a\n> b', 5, ' > ab', 5], + ['> \n> b', 2, '> b', 2], + ['a\n> \n> b', 4, 'a\n> b', 4], + ['> \nb', 2, 'b', 0], + ['> \n', 2, '', 0], + ['a\n> \nb', 4, 'a\nb', 2], + ]; + + it.each(ROWS)('on %j at %i gives %j with the caret at %i', (text, caret, expected, expectedCaret) => { + const edit = planBlockquoteForwardDelete(text, quoteLines(text), caret); + expect(applyEdit(text, edit)).toBe(expected); + expect(edit?.caret).toBe(expectedCaret); + }); + + it.each<[string, number]>([ + ['\n> b', 0], + ['a\n\n> b', 2], + ['> a\nb', 3], + ['> test', 4], + ['> ', 2], + ['abc', 1], + ['```\n> a\n> b', 7], + ])('declines on %j at %i', (text, caret) => { + expect(planBlockquoteForwardDelete(text, quoteLines(text), caret)).toBeNull(); + }); + + it('joins the line below a quoted fence when code blocks are off', () => { + const text = '> ```\n> b'; + expect(planBlockquoteForwardDelete(text, quoteLines(text), 5)).toBeNull(); + const edit = planBlockquoteForwardDelete(text, quoteLines(text), 5, NO_CODE_BLOCK_FLAGS); + expect(applyEdit(text, edit)).toBe('> ```b'); + expect(edit?.caret).toBe(5); + }); +}); + +describe('planBlockquoteContentStartDelete', () => { + function plan( + text: string, + caret: number, + unit: BlockquoteDeleteUnit, + atoms: Array<{start: number; end: number}> = [], + ) { + return planBlockquoteContentStartDelete(text, quoteLines(text), atoms, caret, unit); + } + + it.each<[string, number, string, number]>([ + ['> ab', 2, '> b', 2], + ['x\n> ab', 4, 'x\n> b', 4], + [' > ab', 4, ' > b', 4], + ['> e\u0301x', 2, '> ex', 3], + ['> a\u0301\u0302x', 2, '> a\u0301x', 4], + ['> क\u093Fx', 2, '> कx', 3], + ['> क\u094Dषx', 2, '> क\u094Dx', 4], + ['> \u1100\u1161x', 2, '> \u1100x', 3], + ['> 가각', 2, '> 각', 2], + ['> a\u200Db', 2, '> ab', 3], + ['> \u{1F468}\u200D\u{1F469}\u200D\u{1F467}x', 2, '> x', 2], + ['> \u{1F44B}\u200D\u{1F3FF}x', 2, '> x', 2], + ['> \u{1F44D}\u{1F3FD}x', 2, '> x', 2], + ['> \u{1F1F8}\u{1F1EA}\u{1F1F3}\u{1F1F4}', 2, '> \u{1F1F3}\u{1F1F4}', 2], + ['> #\uFE0F\u20E3x', 2, '> x', 2], + ['> ❤\uFE0Fx', 2, '> x', 2], + ['> 1\uFE0Fx', 2, '> x', 2], + ['> \u{1D4B3}x', 2, '> x', 2], + ])('deletes one character from the content start of %j at %i, giving %j with the caret at %i', (text, caret, expected, expectedCaret) => { + const edit = plan(text, caret, 'character'); + expect(applyEdit(text, edit)).toBe(expected); + expect(edit?.caret).toBe(expectedCaret); + }); + + it.each<[string, number, string]>([ + ['> ab ef', 2, '> ef'], + ['> ab', 2, '> '], + ['> ab cd', 2, '> cd'], + ['> \tab c', 2, '> c'], + ['> foo.bar baz', 2, '> .bar baz'], + ["> don't stop", 2, '> stop'], + ['> it’s x', 2, '> x'], + ["> ab' x", 2, "> ' x"], + ['> a_b c', 2, '> c'], + ['> a-b c', 2, '> -b c'], + ['> 3.14 x', 2, '> x'], + ['> ...ab cd', 2, '> ab cd'], + ['> 日本語テキスト x', 2, '> テキスト x'], + ['> \u{1F600} ab', 2, '> ab'], + ['> ab\u{1F600}cd x', 2, '> \u{1F600}cd x'], + ['> #\uFE0F\u20E3x', 2, '> x'], + ['> क\u094Dषx y', 2, '> y'], + ['> ', 2, '> '], + ['x\n> ab\n> cd', 4, 'x\n> \n> cd'], + ])('deletes one word from the content start of %j at %i, giving %j', (text, caret, expected) => { + const edit = plan(text, caret, 'word'); + expect(applyEdit(text, edit)).toBe(expected); + expect(edit?.caret).toBe(caret); + }); + + it.each<[string, number, string]>([ + ['> ab ef', 2, '> '], + ['x\n> ab ef\n> gh', 4, 'x\n> \n> gh'], + ])('deletes to the line end from the content start of %j at %i, giving %j', (text, caret, expected) => { + const edit = plan(text, caret, 'line'); + expect(applyEdit(text, edit)).toBe(expected); + expect(edit?.caret).toBe(caret); + }); + + it.each<[BlockquoteDeleteUnit, string, Array<{start: number; end: number}>, string]>([ + [ + 'character', + 'x\n> xxxxxxxxxx y', + [ + {start: 4, end: 9}, + {start: 9, end: 14}, + ], + 'x\n> xxxxx y', + ], + [ + 'word', + 'x\n> xxxxxxxxxx y', + [ + {start: 4, end: 9}, + {start: 9, end: 14}, + ], + 'x\n> xxxxx y', + ], + [ + 'line', + 'x\n> xxxxxxxxxx y', + [ + {start: 4, end: 9}, + {start: 9, end: 14}, + ], + 'x\n> ', + ], + ['word', 'x\n> abxxxxx y', [{start: 6, end: 11}], 'x\n> y'], + ])('deletes by %s over whole atoms from the content start of %j', (unit, text, atoms, expected) => { + expect(applyEdit(text, plan(text, 4, unit, atoms))).toBe(expected); + }); + + it.each<[string, number]>([ + ['> ab', 3], + ['> ab', 1], + ['> ab', 0], + ['> ', 2], + ['ab\n> cd', 2], + ['abc', 0], + ])('declines on %j at %i', (text, caret) => { + for (const unit of ['character', 'word', 'line'] as const) { + expect(plan(text, caret, unit)).toBeNull(); + } + }); +}); + +describe('planBlockquotePaste', () => { + function plan(text: string, start: number, end: number, display: string, parserFlags?: number): string { + return planBlockquotePaste(text, quoteLines(text), start, end, {display, segments: []}, parserFlags).display; + } + + const ROWS: Array<[string, number, number, string, string]> = [ + ['> abcd', 4, 4, 'x\ny', 'x\n> y'], + ['> abcd', 2, 2, 'x\ny', 'x\n> y'], + ['> ', 2, 2, 'x\n\ny', 'x\n> \n> y'], + [' > a', 5, 5, 'x\ny', 'x\n> y'], + ['> abcd', 6, 6, '\n', '\n> '], + ['> abcd', 6, 6, 'x\n', 'x\n> '], + ['> ab\n> cd', 3, 8, 'x\ny', 'x\n> y'], + ['> ab\ncd', 3, 6, 'x\ny', 'x\n> y'], + ['> ab', 4, 4, 'x\n>>> y', 'x\n> >>> y'], + ['> ab', 4, 4, 'x\n```\ncode\n```\ny', 'x\n> ```\ncode\n```\ny'], + ['```\nx\n```\n> a', 13, 13, 'x\ny', 'x\n> y'], + ['> abcd', 4, 4, 'x\r\ny', 'x\n> y'], + ['abcd', 2, 2, 'x\r\ny', 'x\ny'], + ['> ', 2, 2, '> x\n> y', 'x\n> y'], + ['> ', 2, 2, '> x', 'x'], + ['> ', 2, 2, '> > x', '> x'], + ['> ab', 2, 2, '\t> x', 'x'], + ['x\n> ', 4, 4, '> x\r\n> y', 'x\n> y'], + ['> ab\n> cd', 2, 8, '> x\ny', 'x\n> y'], + ]; + + it.each(ROWS)('on %j at %i..%i pasting %j gives %j', (text, start, end, display, expected) => { + expect(plan(text, start, end, display)).toBe(expected); + }); + + const UNCHANGED_ROWS: Array<[string, number, number, string]> = [ + ['> ab', 4, 4, 'b\n> c'], + ['> ab', 4, 4, 'x\n > y'], + ['abcd', 2, 2, 'x\ny'], + ['> abcd', 1, 1, 'x\ny'], + ['> ```', 5, 5, 'x\ny'], + ['```\n> a', 7, 7, 'x\ny'], + ['> ab', 4, 4, 'x\r'], + ['> ab', 4, 4, '> x'], + ['> ', 1, 1, '> x'], + ['abcd', 0, 0, '> x'], + ]; + + it.each(UNCHANGED_ROWS)('on %j at %i..%i leaves %j as it is', (text, start, end, display) => { + expect(plan(text, start, end, display)).toBe(display); + }); + + it.each<[string, number, string, string, string]>([ + ['> ab', 4, 'x\n```\ncode\n```\ny', 'x\n> ```\ncode\n```\ny', 'x\n> ```\n> code\n> ```\n> y'], + ['> ```', 5, 'x\ny', 'x\ny', 'x\n> y'], + ])('on %j at %i pasting %j gives %j and %j when code blocks are off', (text, caret, display, expected, offValue) => { + expect(plan(text, caret, caret, display)).toBe(expected); + expect(plan(text, caret, caret, display, NO_CODE_BLOCK_FLAGS)).toBe(offValue); + }); + + it('shifts mention segments and hides mention text from the fence scan', () => { + const segments: Array = [ + {type: 'user', id: '1', displayText: '@name', actualText: '<@1>', start: 3, end: 8}, + {type: 'user', id: '2', displayText: '@a```b', actualText: '<@2>', start: 12, end: 18}, + ]; + expect( + planBlockquotePaste('> abcd', quoteLines('> abcd'), 4, 4, {display: 'x\r\n@name y\n\n@a```b', segments}), + ).toEqual({ + display: 'x\n> @name y\n> \n> @a```b', + segments: [ + {type: 'user', id: '1', displayText: '@name', actualText: '<@1>', start: 4, end: 9}, + {type: 'user', id: '2', displayText: '@a```b', actualText: '<@2>', start: 17, end: 23}, + ], + }); + }); + + it('shifts a mention on a first line whose quote prefix is dropped', () => { + const mention = (start: number): MentionSegment => ({ + type: 'user', + id: '1', + displayText: '@name', + actualText: '<@1>', + start, + end: start + 5, + }); + expect( + planBlockquotePaste('> ', quoteLines('> '), 2, 2, { + display: '> @name\n@name', + segments: [mention(2), mention(8)], + }), + ).toEqual({display: '@name\n> @name', segments: [mention(0), mention(8)]}); + }); + + it('quotes every line of a 200 line paste', () => { + const display = Array.from({length: 200}, (_, index) => `line ${index}`).join('\n'); + const lines = `> ${plan('> ', 2, 2, display)}`.split('\n'); + expect(lines).toHaveLength(200); + expect(lines.filter((line) => QUOTE_PREFIX_RE.test(line))).toHaveLength(200); + }); +}); + +describe('planBlockquoteDropCaret', () => { + const ROWS: Array<[number, number, number, number | null]> = [ + [0, 7, 10, 3], + [0, 7, 0, 0], + [0, 7, 7, 0], + [4, 11, 2, 2], + [0, 7, 4, null], + [0, 4, 10, 6], + [0, 5, 5, 0], + [2, 7, 7, 2], + [0, 8, 12, 4], + [0, 10, 12, 2], + ]; + + it.each(ROWS)('maps a drop of the range %i-%i at %i', (start, end, dropped, expected) => { + expect(planBlockquoteDropCaret(start, end, dropped)).toBe(expected); + }); +}); + +describe('resolveBlockquoteSelection', () => { it.each<[string, number, number]>([ ['> test', 0, 2], ['> test', 1, 2], @@ -113,35 +417,72 @@ describe('resolveBlockquoteCaret', () => { ['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); + ])('maps the caret on %j at %i to %i', (text, caret, expected) => { + expect(resolveBlockquoteSelection(quoteLines(text), caret, caret)).toEqual({anchor: expected, focus: expected}); + }); + + it.each<[string, number, number, number, number]>([ + ['ab\n> cd', 1, 3, 1, 5], + ['ab\n> cd', 3, 1, 5, 1], + ['ab\n> cd', 1, 4, 1, 5], + ['ab\n> cd', 4, 7, 5, 7], + ['ab\n> cd', 3, 7, 3, 7], + ['> a\n> b', 0, 7, 0, 7], + ['x\n> ab', 4, 1, 4, 1], + ])('maps the range on %j from %i..%i to %i..%i', (text, anchor, focus, expectedAnchor, expectedFocus) => { + expect(resolveBlockquoteSelection(quoteLines(text), anchor, focus)).toEqual({ + anchor: expectedAnchor, + focus: expectedFocus, + }); }); }); -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('blockquoteContentRange', () => { + it.each<[string, number, number, number, number]>([ + ['> hello', 0, 7, 2, 7], + ['> a\n> b', 2, 4, 2, 6], + ['> a\n> b', 4, 7, 6, 7], + ['ab\n> cd', 1, 4, 1, 5], + ['> test', 2, 2, 2, 2], + ['abc', 0, 3, 0, 3], + ])('clamps %j from %i..%i to %i..%i', (text, start, end, expectedStart, expectedEnd) => { + expect(blockquoteContentRange(quoteLines(text), start, end)).toEqual({start: expectedStart, end: expectedEnd}); }); }); -describe('findBlockquoteMarkerEnds', () => { +describe('planBlockquoteMove', () => { + it.each<[string, number, boolean, number]>([ + ['ab\n> cd', 5, true, 2], + ['> test', 2, true, 2], + ['ab\n> cd', 2, false, 5], + ['> a\n> b', 3, false, 6], + ['\n> b', 0, false, 3], + ])('moves across the marker on %j from %i backward %s to %i', (text, offset, isBackward, expected) => { + expect(planBlockquoteMove(quoteLines(text), offset, isBackward)).toBe(expected); + }); + + it.each<[string, number, boolean]>([ + ['> test', 4, true], + ['> test', 0, true], + ['abc', 2, true], + ['> test', 2, false], + ['ab\n> cd', 1, false], + ['abc', 3, false], + ])('declines on %j at %i backward %s', (text, offset, isBackward) => { + expect(planBlockquoteMove(quoteLines(text), offset, isBackward)).toBeNull(); + }); +}); + +describe('findBlockquoteMarkers marker ends', () => { + function markerEnds(text: string, parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS): Array { + return findBlockquoteMarkers(text, computeMarkdownHighlightSpans(text, parserFlags), parserFlags).markerEnds; + } + it.each<[string, Array]>([ ['> a', [2]], [' > a', [4]], ['> ', [2]], - ['>>> a\nb', [4, 0]], + ['>>> a\nb', [0, 0]], ['>>> [!NOTE]\nbody', [0, 0]], [' >>> [!tip] x\nbody', [0, 0]], ['> > a', [2]], @@ -155,7 +496,13 @@ describe('findBlockquoteMarkerEnds', () => { ['```\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); + expect(markerEnds(text)).toEqual(ends); + }); + + it('marks every line of a quoted fence when code blocks are off', () => { + const text = '> ```\n> b\n> ```'; + expect(markerEnds(text)).toEqual([2, 0, 0]); + expect(markerEnds(text, NO_CODE_BLOCK_FLAGS)).toEqual([2, 2, 2]); }); it('treats whitespace after the prefix as content rather than marker', () => { @@ -166,6 +513,76 @@ describe('findBlockquoteMarkerEnds', () => { }); }); +describe('findBlockquoteMarkers', () => { + function findMarker(text: string, maxWireLength?: number) { + return findBlockquoteMarkers( + text, + computeMarkdownHighlightSpans(text), + DEFAULT_COMPOSER_MARKDOWN_FLAGS, + maxWireLength, + ).multiline; + } + + function splitIntoLineQuotes(text: string, line: number, indent: number): string { + return text + .split('\n') + .map((source, index) => { + if (index < line) { + return source; + } + return index === line ? `${source.slice(0, indent)}> ${source.slice(indent + 4)}` : `> ${source}`; + }) + .join('\n'); + } + + function renderedNodes(text: string): string { + return JSON.stringify(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes); + } + + it.each<[string, number, number]>([ + ['>>> a\nb', 0, 0], + [' >>> a\n b', 0, 2], + ['x\n>>> a\nb', 1, 0], + ['>>> a\n\nb', 0, 0], + ['>>> a\n\\> b', 0, 0], + ['>>> a\n`> b`', 0, 0], + ['>>> a\n>b', 0, 0], + ['> x\n\n>>> a\nb', 2, 0], + ])('splits %j at line %i with indent %i and keeps the rendered message', (text, line, indent) => { + expect(findMarker(text)).toEqual({line, indent, splits: true}); + expect(renderedNodes(splitIntoLineQuotes(text, line, indent))).toBe(renderedNodes(text)); + }); + + it.each<[string, number]>([ + ['>>> a\n> b', 0], + ['>>> intro\n> quoted reply\nclosing words', 0], + ['>>> > a\nb', 0], + ['>>> a\n> [!NOTE]\n> b', 0], + ['>>> a\n>>> b', 0], + ['>>> a\n > b', 0], + ['>>> a\n\t> b', 0], + ['> x\n>>> a\nb', 1], + ['> [!NOTE]\n>>> a\nb', 1], + ['||s\n>>> a\nb||\nc', 1], + ['>>> a\n', 0], + ['>>> a\nb\n', 0], + ])('does not split %j because line quotes would change the rendered message', (text, line) => { + expect(findMarker(text)).toEqual({line, indent: 0, splits: false}); + expect(renderedNodes(splitIntoLineQuotes(text, line, 0))).not.toBe(renderedNodes(text)); + }); + + it.each<[number, boolean]>([ + [10, false], + [11, true], + ])('splits ">>> a\\nb\\nc" under a max wire length of %i: %s', (maxWireLength, splits) => { + expect(findMarker('>>> a\nb\nc', maxWireLength)).toMatchObject({splits}); + }); + + it.each(['> a', '>>>a', '>>> [!NOTE]\nbody', '```\n>>> a', '> >>> a'])('finds no marker in %j', (text) => { + expect(findMarker(text)).toBeNull(); + }); +}); + describe('dropTrailingEmptyBlockquoteLines', () => { it.each<[string, string]>([ ['> test\n> ', '> test'], @@ -182,4 +599,9 @@ describe('dropTrailingEmptyBlockquoteLines', () => { ])('turns %j into %j', (content, expected) => { expect(dropTrailingEmptyBlockquoteLines(content)).toBe(expected); }); + + it('drops a trailing empty quote line below a quoted fence when code blocks are off', () => { + expect(dropTrailingEmptyBlockquoteLines('> ```\n> ')).toBe('> ```\n> '); + expect(dropTrailingEmptyBlockquoteLines('> ```\n> ', NO_CODE_BLOCK_FLAGS)).toBe('> ```'); + }); }); diff --git a/fluxer_app/src/features/lexical/composer/blockquoteLines.ts b/fluxer_app/src/features/lexical/composer/blockquoteLines.ts index 3c6739949..1aabea3e7 100644 --- a/fluxer_app/src/features/lexical/composer/blockquoteLines.ts +++ b/fluxer_app/src/features/lexical/composer/blockquoteLines.ts @@ -1,7 +1,14 @@ // 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'; +import {type CodeBlockBody, scanCodeBlocks} from '@app/features/lexical/composer/codeBlockIndent'; +import { + DEFAULT_COMPOSER_MARKDOWN_FLAGS, + MarkdownHl, + type MarkdownSpan, +} from '@app/features/lexical/composer/markdownSpans'; +import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums'; +import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm'; +import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; export interface BlockquoteLine { start: number; @@ -9,6 +16,12 @@ export interface BlockquoteLine { end: number; } +export interface MultilineBlockquoteMarker { + line: number; + indent: number; + splits: boolean; +} + export interface BlockquoteEdit { start: number; end: number; @@ -16,9 +29,28 @@ export interface BlockquoteEdit { caret: number; } +export type BlockquoteDeleteUnit = 'character' | 'word' | 'line'; + const CONTINUATION = '\n> '; +const GRAPHEME_RE = + /(?:\p{Regional_Indicator}{2}|[\u1100-\u115F\uA960-\uA97C]*(?:[\u1160-\u11A7\uD7B0-\uD7C6]+|[\uAC00-\uD7A3][\u1160-\u11A7\uD7B0-\uD7C6]*)[\u11A8-\u11FF\uD7CB-\uD7FB]*|[\u1100-\u115F\uA960-\uA97C]+|[\u11A8-\u11FF\uD7CB-\uD7FB]+|[\s\S])(?:[\p{M}\p{Grapheme_Extend}\p{Emoji_Modifier}\u200D]|(?<=\p{Extended_Pictographic}[\p{Grapheme_Extend}\p{Emoji_Modifier}]*\u200D)\p{Extended_Pictographic}|(?<=[\u0900-\u0D7F][\p{M}\u200D]*[\u094D\u09CD\u0ACD\u0B4D\u0C4D\u0D4D][\p{M}\u200D]*)[\u0900-\u0D7F])*/uy; +const WORD_RE = + /\s*(?:\p{Script=Han}+|\p{Script=Hiragana}+|\p{Script=Katakana}+|\p{Extended_Pictographic}(?:[\p{M}\p{Grapheme_Extend}\p{Emoji_Modifier}\u200D]|(?<=\u200D)\p{Extended_Pictographic})*|[\p{L}\p{N}\p{M}\p{Pc}\u200C\u200D]+(?:(?:['\u2019]|(?<=\p{N})[.,](?=\p{N}))[\p{L}\p{N}\p{M}\p{Pc}\u200C\u200D]+)*|(?:[^\s\p{L}\p{N}\p{M}\p{Pc}]\p{M}*)+)/uy; +const WHOLE_GRAPHEME_RE = /[\u{10000}-\u{10FFFF}]|\p{Emoji}/u; +const QUOTE_PREFIX_RE = /^[ \t]*> /; const DROPPABLE_TRAILING_LINE_RE = /^[ \t]*(?:> [ \t]*)?$/; const MULTILINE_ALERT_RE = /^[ \t]*>>> \[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i; +const QUOTE_LINE_START_RE = /^[ \t\r]*> /; +const QUOTE_BLOCK_START_RE = /^[ \t\r]*>(?:>>)? /; +const BLANK_LINE_RE = /^[ \t\r]*$/; + +function scanAllowedCodeBlocks(text: string, parserFlags: number): Array { + return (parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) === 0 ? [] : scanCodeBlocks(text); +} + +function isInsideCodeBlock(blocks: ReadonlyArray, offset: number): boolean { + return blocks.some((block) => block.start <= offset && offset <= block.end); +} function isEmptyLine(line: BlockquoteLine): boolean { return line.contentStart === line.end; @@ -28,20 +60,50 @@ function isDirectlyAbove(upper: BlockquoteLine, lower: BlockquoteLine): boolean return upper.end + 1 === lower.start; } -export function findBlockquoteMarkerEnds(source: string, spans: ReadonlyArray): Array { +function isMultilineMarker(line: string, markerEnd: number): boolean { + return line.slice(0, markerEnd).trimStart().startsWith('>>> '); +} + +export function findBlockquoteMarkers( + source: string, + spans: ReadonlyArray, + parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS, + maxWireLength?: number, +): {markerEnds: Array; multiline: MultilineBlockquoteMarker | null} { + const lines = source.split('\n'); + const ends = scanMarkerEnds(source, lines, spans, parserFlags); + const markerEnds = ends.map((end, index) => (isMultilineMarker(lines[index]!, end) ? 0 : end)); + const line = ends.findIndex((end, index) => isMultilineMarker(lines[index]!, end)); + if (line < 0) { + return {markerEnds, multiline: null}; + } + const splits = + (line === 0 || !QUOTE_LINE_START_RE.test(lines[line - 1]!)) && + ![lines[line]!.slice(ends[line]), ...lines.slice(line + 1)].some((text) => QUOTE_BLOCK_START_RE.test(text)) && + !BLANK_LINE_RE.test(lines[lines.length - 1]!) && + (maxWireLength == null || source.length - 2 + 2 * (lines.length - line - 1) <= maxWireLength) && + parseMarkdownAstWithWasm(source, parserFlags & DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes.at(-1)?.type === 'Blockquote'; + return {markerEnds, multiline: {line, indent: ends[line]! - 4, splits}}; +} + +function scanMarkerEnds( + source: string, + lines: ReadonlyArray, + spans: ReadonlyArray, + parserFlags: number, +): 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 blocks = scanAllowedCodeBlocks(source, parserFlags); const ends: Array = []; let lineStart = 0; - for (const line of source.split('\n')) { + for (const line of lines) { const markerEnd = markerEndByStart.get(lineStart); - const insideCode = blocks.some((block) => block.start <= lineStart && lineStart <= block.end); - const suppressed = insideCode || MULTILINE_ALERT_RE.test(line); + const suppressed = isInsideCodeBlock(blocks, lineStart) || MULTILINE_ALERT_RE.test(line); ends.push(markerEnd == null || suppressed ? 0 : Math.min(markerEnd, lineStart + line.length) - lineStart); lineStart += line.length + 1; } @@ -52,8 +114,9 @@ export function planBlockquoteLineBreak( text: string, lines: ReadonlyArray, caret: number, + parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS, ): BlockquoteEdit | null { - if (isOffsetInsideCodeBlock(text, caret)) { + if (isInsideCodeBlock(scanAllowedCodeBlocks(text, parserFlags), caret)) { return null; } const index = lines.findIndex((line) => line.contentStart <= caret && caret <= line.end); @@ -76,8 +139,9 @@ export function planBlockquoteBackspace( text: string, lines: ReadonlyArray, caret: number, + parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS, ): BlockquoteEdit | null { - if (isOffsetInsideCodeBlock(text, caret)) { + if (isInsideCodeBlock(scanAllowedCodeBlocks(text, parserFlags), caret)) { return null; } const line = lines.find((candidate) => candidate.contentStart === caret); @@ -91,28 +155,193 @@ export function planBlockquoteBackspace( return {start: above.start, end: caret, text: '', caret: above.start}; } -export function resolveBlockquoteCaret(lines: ReadonlyArray, caret: number): number { +export function planBlockquoteForwardDelete( + text: string, + lines: ReadonlyArray, + caret: number, + parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS, +): BlockquoteEdit | null { + if (text[caret] !== '\n' || isInsideCodeBlock(scanAllowedCodeBlocks(text, parserFlags), caret)) { + return null; + } + const below = lines.find((candidate) => candidate.start === caret + 1); + if (below != null && caret > 0 && text[caret - 1] !== '\n') { + return {start: caret, end: below.contentStart, text: '', caret}; + } + const line = lines.find((candidate) => candidate.end === caret && isEmptyLine(candidate)); + return line == null ? null : {start: line.start, end: caret + 1, text: '', caret: line.start}; +} + +function matchEnd(pattern: RegExp, text: string, offset: number): number { + pattern.lastIndex = offset; + return pattern.test(text) ? pattern.lastIndex : offset; +} + +export function planBlockquoteContentStartDelete( + text: string, + lines: ReadonlyArray, + atoms: ReadonlyArray<{start: number; end: number}>, + caret: number, + unit: BlockquoteDeleteUnit, +): BlockquoteEdit | null { + const line = lines.find((candidate) => candidate.contentStart === caret && caret < candidate.end); + if (line == null) { + return null; + } + if (unit === 'line') { + return {start: caret, end: line.end, text: '', caret}; + } + const atom = atoms.find((candidate) => candidate.start === caret); + if (atom != null) { + return {start: caret, end: atom.end, text: '', caret}; + } + const content = text.slice(0, line.end); + if (unit === 'word') { + const end = matchEnd(WORD_RE, content, caret); + return {start: caret, end: end === caret ? line.end : end, text: '', caret}; + } + const end = matchEnd(GRAPHEME_RE, content, caret); + if (end - caret > 1 && !WHOLE_GRAPHEME_RE.test(content.slice(caret, end))) { + return {start: end - 1, end, text: '', caret: end - 1}; + } + return {start: caret, end, text: '', caret}; +} + +interface PastedText { + display: string; + segments: Array; +} + +export function isInsideBlockquoteContent(lines: ReadonlyArray, offset: number): boolean { + return lines.some((line) => line.contentStart <= offset && offset <= line.end); +} + +export function planBlockquotePaste( + text: string, + lines: ReadonlyArray, + start: number, + end: number, + pasted: PastedText, + parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS, +): PastedText { + let scan = pasted.display; + for (const segment of pasted.segments) { + scan = `${scan.slice(0, segment.start)}${'x'.repeat(segment.end - segment.start)}${scan.slice(segment.end)}`; + } + const starts: Array = []; + const ends: Array = []; + const sizes: Array = []; + let lineStart = 0; + for (const line of pasted.display.split('\n')) { + const lineEnd = lineStart + line.length; + starts.push(lineStart); + ends.push(lineEnd); + sizes.push(lineEnd < pasted.display.length && line.endsWith('\r') ? line.length - 1 : line.length); + lineStart = lineEnd + 1; + } + const sliceOf = (source: string, index: number) => source.slice(starts[index]!, starts[index]! + sizes[index]!); + const firstPrefix = lines.some((line) => line.contentStart === start) ? QUOTE_PREFIX_RE.exec(sliceOf(scan, 0)) : null; + if (firstPrefix != null) { + starts[0] = firstPrefix[0].length; + sizes[0]! -= firstPrefix[0].length; + } + const quoted = isInsideBlockquoteContent(lines, start); + const prefixed = starts.map((_, index) => quoted && index > 0 && !QUOTE_PREFIX_RE.test(sliceOf(scan, index))); + if (quoted) { + const breaks: Array = []; + let merged = text.slice(0, start); + for (let index = 0; index < starts.length; index += 1) { + if (index > 0) { + breaks.push(merged.length); + merged += prefixed[index] ? CONTINUATION : '\n'; + } + merged += sliceOf(scan, index); + } + const blocks = scanAllowedCodeBlocks(merged + text.slice(end), parserFlags); + const stop = breaks.findIndex((offset) => isInsideCodeBlock(blocks, offset)); + if (stop >= 0) { + prefixed.fill(false, stop + 1); + } + } + let display = ''; + const segments: Array = []; + let next = 0; + for (let index = 0; index < starts.length; index += 1) { + if (index > 0) { + display += prefixed[index] ? CONTINUATION : '\n'; + } + const shift = display.length - starts[index]!; + while (next < pasted.segments.length && pasted.segments[next]!.start < ends[index]!) { + const segment = pasted.segments[next]!; + segments.push({...segment, start: segment.start + shift, end: segment.end + shift}); + next += 1; + } + display += sliceOf(pasted.display, index); + } + return {display, segments}; +} + +export function planBlockquoteDropCaret(start: number, end: number, dropped: number): number | null { + if (dropped > start && dropped < end) { + return null; + } + return dropped <= start ? dropped : dropped - (end - start); +} + +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); +export function resolveBlockquoteSelection( + lines: ReadonlyArray, + anchor: number, + focus: number, +): {anchor: number; focus: number} { + const rangeStart = anchor === focus ? null : Math.min(anchor, focus); + const resolve = (offset: number): number => + offset === rangeStart && lines.some((candidate) => candidate.start === offset) + ? offset + : resolveBlockquoteCaret(lines, offset); + return {anchor: resolve(anchor), focus: resolve(focus)}; +} + +export function blockquoteContentRange( + lines: ReadonlyArray, + start: number, + end: number, +): {start: number; end: number} { + return { + start: resolveBlockquoteCaret(lines, Math.min(start, end)), + end: resolveBlockquoteCaret(lines, Math.max(start, end)), + }; +} + +export function planBlockquoteMove( + lines: ReadonlyArray, + offset: number, + isBackward: boolean, +): number | null { + if (!isBackward) { + const below = lines.find((candidate) => candidate.start === offset + 1); + return below == null ? null : below.contentStart; + } + const line = lines.find((candidate) => candidate.contentStart === offset); if (line == null) { return null; } return line.start > 0 ? line.start - 1 : line.contentStart; } -export function dropTrailingEmptyBlockquoteLines(content: string): string { - const blocks = scanCodeBlocks(content); +export function dropTrailingEmptyBlockquoteLines( + content: string, + parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS, +): string { + const blocks = scanAllowedCodeBlocks(content, parserFlags); 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) - ) { + if (!DROPPABLE_TRAILING_LINE_RE.test(content.slice(lineStart, end)) || isInsideCodeBlock(blocks, lineStart)) { break; } end = Math.max(0, lineStart - 1); diff --git a/fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts b/fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts index a8594a7d4..c3bd03fe5 100644 --- a/fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts +++ b/fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts @@ -204,6 +204,79 @@ const INDENT_ROWS: Array = [ ['recognises a fence indented by four spaces', ' ```\ncode', 12, 12, false, tabAt(12)], ['no longer indents the line after an unclosed quoted fence', '> ```\ncode\nnormal text', 22, 22, false, null], ['no longer indents the line after a fence on a list item line', '- ```\ncode', 10, 10, false, null], + [ + 'indents the code after the quote prefix on a selected quoted line', + '> ```\n> code\n> ```', + 6, + 12, + false, + {edits: [{start: 8, end: 8, text: '\t'}], selectionStart: 9, selectionEnd: 13}, + ], + [ + 'indents every selected quoted body line after its prefix', + '> ```\n> a\n> b\n> ```', + 6, + 13, + false, + { + edits: [ + {start: 8, end: 8, text: '\t'}, + {start: 12, end: 12, text: '\t'}, + ], + selectionStart: 9, + selectionEnd: 15, + }, + ], + [ + 'unindents a tab after the quote prefix', + '> ```\n> \tcode\n> ```', + 6, + 13, + true, + {edits: [{start: 8, end: 9, text: ''}], selectionStart: 8, selectionEnd: 12}, + ], + [ + 'unindents four spaces after the quote prefix', + '> ```\n> code\n> ```', + 6, + 16, + true, + {edits: [{start: 8, end: 12, text: ''}], selectionStart: 8, selectionEnd: 12}, + ], + [ + 'indents after an indented quote prefix', + ' > ```\n > a\n > ```', + 8, + 13, + false, + {edits: [{start: 12, end: 12, text: '\t'}], selectionStart: 13, selectionEnd: 14}, + ], + [ + 'keeps "> " as code inside a multiline quote fence', + '>>> ```\n> a\n```', + 8, + 11, + false, + {edits: [{start: 8, end: 8, text: '\t'}], selectionStart: 9, selectionEnd: 12}, + ], + ['ignores a caret at the start of a quoted closing fence', '> ```\n> code\n> ```', 15, 15, false, null], + ['ignores a caret at the end of a quoted closing fence', '> ```\n> code\n> ```', 18, 18, false, null], + [ + 'collapses a selection inside a quote prefix onto the indented content', + '> ```\n> a\n> ```', + 6, + 7, + false, + {edits: [{start: 8, end: 8, text: '\t'}], selectionStart: 9, selectionEnd: 9}, + ], + [ + 'collapses a caret inside a quote prefix onto the unindented content', + '> ```\n> \ta\n> b\n> ```', + 7, + 7, + true, + {edits: [{start: 8, end: 9, text: ''}], selectionStart: 8, selectionEnd: 8}, + ], ]; const HIGHLIGHT_ROWS: Array<[text: string, offset: number, expected: boolean]> = [ diff --git a/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts b/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts index b42386c9a..bd4856813 100644 --- a/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts +++ b/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts @@ -52,6 +52,7 @@ interface CodeBlockParse { interface ScanScope { bodies: Array; + quoteContentStarts: Array; end: number; quotes: boolean; multilineQuotes: boolean; @@ -61,6 +62,7 @@ interface ScanScope { interface PhysicalLine { start: number; end: number; + contentStart: number; text: string; isCodeContent: boolean; } @@ -255,7 +257,9 @@ function scanQuote(lines: Array, index: number, scope: ScanScope): n if (!trimmed.startsWith('> ')) { break; } - quoted.push({text: trimmed.slice(2), offset: line.offset + line.text.length - trimmed.length + 2}); + const offset = line.offset + line.text.length - trimmed.length + 2; + quoted.push({text: trimmed.slice(2), offset}); + scope.quoteContentStarts.push(offset); next += 1; } scanBlocks(quoted, {...scope, quotes: false, end: linesEnd(quoted)}); @@ -363,10 +367,21 @@ function scanBlocks(lines: Array, scope: ScanScope): void { } } -export function scanCodeBlocks(text: string): Array { - const scope: ScanScope = {bodies: [], end: text.length, quotes: true, multilineQuotes: true, rendered: true}; +function scanSource(text: string): ScanScope { + const scope: ScanScope = { + bodies: [], + quoteContentStarts: [], + end: text.length, + quotes: true, + multilineQuotes: true, + rendered: true, + }; scanBlocks(splitLines(text), scope); - return scope.bodies; + return scope; +} + +export function scanCodeBlocks(text: string): Array { + return scanSource(text).bodies; } export function isOffsetInsideCodeBlock(text: string, offset: number): boolean { @@ -374,15 +389,21 @@ export function isOffsetInsideCodeBlock(text: string, offset: number): boolean { } function scanLines(text: string): Array { - const bodies = scanCodeBlocks(text); + const {bodies, quoteContentStarts} = scanSource(text); return splitLines(text).map(({text: content, offset: start}) => { const end = start + content.length; + const contentStart = quoteContentStarts.reduce( + (current, offset) => (current <= offset && offset <= end ? offset : current), + start, + ); return { start, end, - text: content, + contentStart, + text: text.slice(contentStart, end), isCodeContent: bodies.some( - (body) => start >= body.start && (end <= body.end || trimStart(text.slice(start, body.end)) !== ''), + (body) => + contentStart >= body.start && (end <= body.end || trimStart(text.slice(contentStart, body.end)) !== ''), ), }; }); @@ -421,6 +442,8 @@ export function analyzeCodeIndent( selectionEnd: selectionStart + INDENT.length, }; } + const rangeStart = Math.max(selectionStart, lines[startLine]!.contentStart); + const rangeEnd = Math.max(rangeStart, selectionEnd); const edits: Array = []; let startDelta = 0; let endDelta = 0; @@ -438,15 +461,15 @@ export function analyzeCodeIndent( if (removed === 0) { continue; } - edits.push({start: line.start, end: line.start + removed, text: ''}); - startDelta -= Math.min(removed, Math.max(0, selectionStart - line.start)); - endDelta -= Math.min(removed, Math.max(0, selectionEnd - line.start)); + edits.push({start: line.contentStart, end: line.contentStart + removed, text: ''}); + startDelta -= Math.min(removed, Math.max(0, rangeStart - line.contentStart)); + endDelta -= Math.min(removed, Math.max(0, rangeEnd - line.contentStart)); } else { - edits.push({start: line.start, end: line.start, text: INDENT}); - if (line.start <= selectionStart) { + edits.push({start: line.contentStart, end: line.contentStart, text: INDENT}); + if (line.contentStart <= rangeStart) { startDelta += INDENT.length; } - if (line.start <= selectionEnd) { + if (line.contentStart <= rangeEnd) { endDelta += INDENT.length; } } @@ -456,7 +479,7 @@ export function analyzeCodeIndent( } return { edits, - selectionStart: Math.max(0, selectionStart + startDelta), - selectionEnd: Math.max(0, selectionEnd + endDelta), + selectionStart: Math.max(0, rangeStart + startDelta), + selectionEnd: Math.max(0, rangeEnd + endDelta), }; } diff --git a/fluxer_app/src/features/lexical/composer/composerOffsets.ts b/fluxer_app/src/features/lexical/composer/composerOffsets.ts index 50f53dfbe..9e0bc5765 100644 --- a/fluxer_app/src/features/lexical/composer/composerOffsets.ts +++ b/fluxer_app/src/features/lexical/composer/composerOffsets.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {BlockquoteLine} from '@app/features/lexical/composer/blockquoteLines'; +import {type BlockquoteLine, blockquoteContentRange} 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'; @@ -33,8 +33,10 @@ import { $isRangeSelection, $setSelection, type ElementNode, + type LexicalEditor, type LexicalNode, type NodeKey, + type PointType, TextNode, } from 'lexical'; import invariant from 'tiny-invariant'; @@ -181,6 +183,44 @@ function $getComposerDisplayOffsetFromLayout(layout: DisplayLayout): number | nu return pointToDisplayOffset(layout, anchor.getNode(), anchor.offset, anchor.type); } +function caretRangeAtPoint(doc: Document, clientX: number, clientY: number): AbstractRange | null { + if (typeof doc.caretRangeFromPoint === 'function') { + return doc.caretRangeFromPoint(clientX, clientY); + } + if (typeof doc.caretPositionFromPoint !== 'function') { + return null; + } + const position = doc.caretPositionFromPoint(clientX, clientY); + return position == null + ? null + : new StaticRange({ + startContainer: position.offsetNode, + startOffset: position.offset, + endContainer: position.offsetNode, + endOffset: position.offset, + }); +} + +function displayOffsetOfPoint(layout: DisplayLayout, point: PointType): number | null { + if (point.type === 'text') { + const leaf = layout.leaves.find((candidate) => candidate.node.getKey() === point.key); + return leaf == null ? null : leaf.start + Math.min(Math.max(0, point.offset), leaf.end - leaf.start); + } + const boundaries = layout.elementBoundaries.get(point.key); + return boundaries == null ? null : boundaries[Math.min(Math.max(0, point.offset), boundaries.length - 1)]!; +} + +export function $getComposerDropOffset(editor: LexicalEditor, clientX: number, clientY: number): number | null { + const root = editor.getRootElement(); + const hit = root == null ? null : caretRangeAtPoint(root.ownerDocument, clientX, clientY); + if (root == null || hit == null || !root.contains(hit.startContainer)) { + return null; + } + const selection = $createRangeSelection(); + selection.applyDOMRange(hit); + return displayOffsetOfPoint($buildDisplayLayout(), selection.anchor); +} + export function $getComposerNodeDisplayStart(node: LexicalNode): number | null { const layout = $buildDisplayLayout(); const leaf = layout.leaves.find((candidate) => candidate.node.getKey() === node.getKey()); @@ -206,6 +246,7 @@ export interface ComposerBlockquoteState { scanText: string; selection: ComposerSelectionOffsets | null; lines: Array; + atoms: Array<{start: number; end: number}>; } function blockquoteLinesFromLayout(layout: DisplayLayout): Array { @@ -234,9 +275,18 @@ export function $getComposerBlockquoteState(): ComposerBlockquoteState { scanText: scanTextFromLayout(layout), selection: $captureSelectionOffsetsFromLayout(layout), lines: blockquoteLinesFromLayout(layout), + atoms: atomRangesFromLayout(layout), }; } +export function $getComposerContentSelectionRange(): {start: number; end: number} | null { + const layout = $buildDisplayLayout(); + const offsets = $captureSelectionOffsetsFromLayout(layout); + return offsets == null + ? null + : blockquoteContentRange(blockquoteLinesFromLayout(layout), offsets.anchor, offsets.focus); +} + export function $getComposerSelectionRange(): {start: number; end: number} | null { const offsets = $captureSelectionOffsets(); if (offsets == null) { @@ -258,21 +308,26 @@ export function $getComposerDisplayText(): string { return $buildDisplayLayout().text; } -function scanTextFromLayout({text, leaves}: DisplayLayout): string { +function atomRangesFromLayout({leaves}: DisplayLayout): Array<{start: number; end: number}> { + return leaves + .filter( + ({node}) => + $isComposerMentionNode(node) || + $isComposerCustomEmojiNode(node) || + $isComposerStandardEmojiNode(node) || + $isComposerPlainSegmentNode(node), + ) + .map(({start, end}) => ({start, end})); +} + +function scanTextFromLayout(layout: DisplayLayout): string { let scanText = ''; let offset = 0; - for (const {node, start, end} of leaves) { - if ( - $isComposerMentionNode(node) || - $isComposerCustomEmojiNode(node) || - $isComposerStandardEmojiNode(node) || - $isComposerPlainSegmentNode(node) - ) { - scanText += text.slice(offset, start) + 'x'.repeat(end - start); - offset = end; - } + for (const {start, end} of atomRangesFromLayout(layout)) { + scanText += layout.text.slice(offset, start) + 'x'.repeat(end - start); + offset = end; } - return scanText + text.slice(offset); + return scanText + layout.text.slice(offset); } export function $getComposerScanText(): string { @@ -472,12 +527,13 @@ function classifyWrap(display: string, start: number, end: number, prefix: strin function codeBlockPlanFor( layout: DisplayLayout, + lines: ReadonlyArray, start: number, end: number, prefix: string, suffix: string, ): CodeBlockWrapPlan | null { - if (prefix !== '`' || suffix !== '`') { + if (prefix !== '`' || suffix !== '`' || lines.some((line) => line.start < end && start < line.end)) { return null; } const plan = planCodeBlockWrap(scanTextFromLayout(layout), start, end); @@ -500,15 +556,15 @@ export function $queryComposerSelectionWrappers( if (offsets == null) { return {offsets: null, wrapped: queries.map(() => false)}; } - const start = Math.min(offsets.anchor, offsets.focus); - const end = Math.max(offsets.anchor, offsets.focus); + const lines = blockquoteLinesFromLayout(layout); + const {start, end} = blockquoteContentRange(lines, offsets.anchor, offsets.focus); if (start === end) { return {offsets, wrapped: queries.map(() => false)}; } return { offsets, wrapped: queries.map(({prefix, suffix}) => { - const plan = codeBlockPlanFor(layout, start, end, prefix, suffix); + const plan = codeBlockPlanFor(layout, lines, start, end, prefix, suffix); return plan == null ? classifyWrap(layout.text, start, end, prefix, suffix) !== 'none' : plan.wrapped; }), }; @@ -520,8 +576,8 @@ export function $wrapComposerSelection(prefix: string, suffix: string): void { if (offsets == null) { return; } - const start = Math.min(offsets.anchor, offsets.focus); - const end = Math.max(offsets.anchor, offsets.focus); + const lines = blockquoteLinesFromLayout(layout); + const {start, end} = blockquoteContentRange(lines, offsets.anchor, offsets.focus); const backward = offsets.anchor > offsets.focus; const display = layout.text; const selected = display.slice(start, end); @@ -531,7 +587,7 @@ export function $wrapComposerSelection(prefix: string, suffix: string): void { const restoreSelection = (nextStart: number, nextEnd: number) => { $selectComposerRange(backward ? nextEnd : nextStart, backward ? nextStart : nextEnd); }; - const plan = codeBlockPlanFor(layout, start, end, prefix, suffix); + const plan = codeBlockPlanFor(layout, lines, start, end, prefix, suffix); if (plan != null) { replaceTextRange(plan.closing.start, plan.closing.end, plan.closing.text); replaceTextRange(plan.opening.start, plan.opening.end, plan.opening.text); diff --git a/fluxer_app/src/features/lexical/composer/nodes/ComposerStandardEmoji.test.tsx b/fluxer_app/src/features/lexical/composer/nodes/ComposerStandardEmoji.test.tsx new file mode 100644 index 000000000..f7b97094b --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/nodes/ComposerStandardEmoji.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ComposerStandardEmoji} from '@app/features/lexical/composer/nodes/ComposerStandardEmoji'; +import type React from 'react'; +import {act, createElement} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({getEmojiURL: (surrogate: string) => `url:${surrogate}`})); + +vi.mock('@app/features/ui/emoji_tooltip_content/EmojiWithTooltip', () => ({ + EmojiWithTooltip: ({children}: {children: React.ReactNode}) => children, +})); + +(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + +describe('ComposerStandardEmoji', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.replaceChildren(); + }); + + function render(url: string | null): HTMLImageElement | null { + act(() => { + root.render( + createElement(ComposerStandardEmoji, {name: 'grinning', surrogate: '😀', url, display: ':grinning:'}), + ); + }); + return host.querySelector('img'); + } + + it('keeps the image the node was created with', () => { + expect(render('url:given')?.getAttribute('src')).toBe('url:given'); + }); + + it('derives the image from the surrogate for a node created without one', () => { + expect(render(null)?.getAttribute('src')).toBe('url:😀'); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/nodes/ComposerStandardEmoji.tsx b/fluxer_app/src/features/lexical/composer/nodes/ComposerStandardEmoji.tsx index 7573188e8..1afdf4709 100644 --- a/fluxer_app/src/features/lexical/composer/nodes/ComposerStandardEmoji.tsx +++ b/fluxer_app/src/features/lexical/composer/nodes/ComposerStandardEmoji.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; +import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils'; import {ComposerMentionContext} from '@app/features/lexical/composer/ComposerMentionContext'; import styles from '@app/features/lexical/composer/nodes/ComposerInline.module.css'; import {EmojiWithTooltip} from '@app/features/ui/emoji_tooltip_content/EmojiWithTooltip'; @@ -26,17 +27,18 @@ export const ComposerStandardEmoji = ({name, surrogate, url, display}: ComposerS ); } + const imageUrl = url == null ? EmojiUtils.getEmojiURL(surrogate) : url; const emojiForSubtext: FlatEmoji = { name, uniqueName: name, allNamesString: display, surrogates: surrogate, animated: false, - url: url == null ? undefined : url, + url: imageUrl == null ? undefined : imageUrl, }; - const image = url ? ( + const image = imageUrl ? ( {display}, wire: string) => { setBioValue(display); setBioSegments(segments); - setBioActualValue(dropTrailingEmptyBlockquoteLines(wire)); + setBioActualValue(dropTrailingEmptyBlockquoteLines(wire, BIO_MARKDOWN_PARSER_FLAGS)); }, []); const onSubmit = useCallback( async (data: FormInputs) => { diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BioEditor.tsx b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BioEditor.tsx index b118cddbe..ceab4f3d6 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BioEditor.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BioEditor.tsx @@ -25,7 +25,7 @@ import {useCallback, useEffect, useId, useRef, useState} from 'react'; const ABOUT_ME_DESCRIPTOR = msg({message: 'About me'}); const OPEN_EMOJI_PICKER_DESCRIPTOR = msg({message: 'Open emoji picker'}); -const BIO_MARKDOWN_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.RESTRICTED_USER_BIO); +export const BIO_MARKDOWN_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.RESTRICTED_USER_BIO); const ABOUT_ME_IS_TOO_LONG_DESCRIPTOR = msg({ message: 'About me is too long', comment: 'Error modal title shown when inserting an emoji into the profile bio would exceed the bio limit.',