mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): keep blockquotes open across pasted lines (#2771)
This commit is contained in:
1 parent
a3d6cf37cb
commit
04481d7235
24 files changed
+3174
-229
No files matched your search
@@ -293,6 +293,7 @@ export const EditingMessageInput = observer(
|
|||||||
markdown={true}
|
markdown={true}
|
||||||
singleLine={!mobileLayout.enabled}
|
singleLine={!mobileLayout.enabled}
|
||||||
size="chat"
|
size="chat"
|
||||||
|
maxWireLength={maxMessageLength}
|
||||||
className={editingStyles.editor}
|
className={editingStyles.editor}
|
||||||
autocompleteAnchor={containerRef.current}
|
autocompleteAnchor={containerRef.current}
|
||||||
ariaLabel={i18n._(EDIT_MESSAGE_DESCRIPTOR)}
|
ariaLabel={i18n._(EDIT_MESSAGE_DESCRIPTOR)}
|
||||||
|
|||||||
@@ -1356,6 +1356,7 @@ export const LexicalChannelTextareaContent = observer(
|
|||||||
channelId={channel.id}
|
channelId={channel.id}
|
||||||
guildId={channel.guildId}
|
guildId={channel.guildId}
|
||||||
submitOnEnter={!mobileLayout.enabled}
|
submitOnEnter={!mobileLayout.enabled}
|
||||||
|
maxWireLength={maxMessageLength}
|
||||||
silentMessagePrefix={!isEditingMessageOnMobile}
|
silentMessagePrefix={!isEditingMessageOnMobile}
|
||||||
focusRingTarget={containerRef}
|
focusRingTarget={containerRef}
|
||||||
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
|
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
|
||||||
|
|||||||
+1
-1
@@ -157,7 +157,7 @@ export const ChannelOverviewTopicEditor = observer(
|
|||||||
});
|
});
|
||||||
}, [actualTopic, form, isTopicInitialized]);
|
}, [actualTopic, form, isTopicInitialized]);
|
||||||
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
||||||
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
|
setActualTopic(dropTrailingEmptyBlockquoteLines(wire, TOPIC_MARKDOWN_PARSER_FLAGS));
|
||||||
}, []);
|
}, []);
|
||||||
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
|
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
|
||||||
const composer = composerRef.current;
|
const composer = composerRef.current;
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
|
|||||||
import type {GuildEmoji} from '@app/features/expressions/models/GuildEmoji';
|
import type {GuildEmoji} from '@app/features/expressions/models/GuildEmoji';
|
||||||
import GuildGuilds from '@app/features/guild/state/Guilds';
|
import GuildGuilds from '@app/features/guild/state/Guilds';
|
||||||
import {
|
import {
|
||||||
$insertComposerClipboardSlice,
|
$insertComposerPastedText,
|
||||||
type ComposerClipboardSlice,
|
|
||||||
FLUXER_COMPOSER_CLIPBOARD_MIME,
|
FLUXER_COMPOSER_CLIPBOARD_MIME,
|
||||||
getComposerClipboardTextPlain,
|
getComposerClipboardTextPlain,
|
||||||
parseComposerClipboardSlice,
|
parseComposerClipboardSlice,
|
||||||
@@ -23,7 +22,6 @@ import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentP
|
|||||||
import {getClipboardDataFiles, readClipboardImageFiles} from '@app/features/messaging/utils/ClipboardFilePasteUtils';
|
import {getClipboardDataFiles, readClipboardImageFiles} from '@app/features/messaging/utils/ClipboardFilePasteUtils';
|
||||||
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
|
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
|
||||||
import {detectPastedSegments, type LookupFunctions} from '@app/features/messaging/utils/PasteSegmentUtils';
|
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 {isDialogPasteTarget} from '@app/features/messaging/utils/TextInputEditUtils';
|
||||||
import {canFocusTextarea, safeFocus} from '@app/features/platform/utils/InputFocusManager';
|
import {canFocusTextarea, safeFocus} from '@app/features/platform/utils/InputFocusManager';
|
||||||
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
|
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
|
||||||
@@ -47,27 +45,6 @@ interface UseChannelComposerPasteParams {
|
|||||||
textareaInputDisabled: boolean;
|
textareaInputDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createComposerPasteSlice(pastedText: string, segments: ReadonlyArray<MentionSegment>): ComposerClipboardSlice {
|
|
||||||
const displayParts: Array<string> = [];
|
|
||||||
const projectedSegments: Array<MentionSegment> = [];
|
|
||||||
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({
|
export function useChannelComposerPaste({
|
||||||
channel,
|
channel,
|
||||||
handleRef,
|
handleRef,
|
||||||
@@ -173,13 +150,14 @@ export function useChannelComposerPaste({
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const segments = detectPastedSegments(pastedText, 0, lookups);
|
return $insertComposerPastedText(
|
||||||
const richSlice = createComposerPasteSlice(pastedText, segments);
|
pastedText,
|
||||||
const plainText = getComposerClipboardTextPlain(richSlice);
|
detectPastedSegments(pastedText, 0, lookups),
|
||||||
const slice = plainText === null || plainText === undefined ? {display: pastedText, segments: []} : richSlice;
|
ChatInputSettings.renderComposerAsPlainText,
|
||||||
return $insertComposerClipboardSlice(slice, ChatInputSettings.renderComposerAsPlainText);
|
handleRef.current,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[channel.guildId],
|
[channel.guildId, handleRef],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = handleRef.current;
|
const handle = handleRef.current;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,19 +1,36 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type BlockquoteDeleteUnit,
|
||||||
type BlockquoteEdit,
|
type BlockquoteEdit,
|
||||||
type BlockquoteLine,
|
isInsideBlockquoteContent,
|
||||||
planBlockquoteArrowLeft,
|
type MultilineBlockquoteMarker,
|
||||||
planBlockquoteBackspace,
|
planBlockquoteBackspace,
|
||||||
|
planBlockquoteContentStartDelete,
|
||||||
|
planBlockquoteDropCaret,
|
||||||
|
planBlockquoteForwardDelete,
|
||||||
planBlockquoteLineBreak,
|
planBlockquoteLineBreak,
|
||||||
resolveBlockquoteCaret,
|
planBlockquoteMove,
|
||||||
|
planBlockquotePaste,
|
||||||
|
resolveBlockquoteSelection,
|
||||||
} from '@app/features/lexical/composer/blockquoteLines';
|
} 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 {
|
import {
|
||||||
$captureSelectionOffsets,
|
$captureSelectionOffsets,
|
||||||
$getComposerBlockquoteState,
|
$getComposerBlockquoteState,
|
||||||
|
$getComposerDisplayText,
|
||||||
|
$getComposerDropOffset,
|
||||||
$replaceComposerRange,
|
$replaceComposerRange,
|
||||||
$selectComposerOffset,
|
$selectComposerOffset,
|
||||||
$selectComposerRange,
|
$selectComposerRange,
|
||||||
|
type ComposerBlockquoteState,
|
||||||
} from '@app/features/lexical/composer/composerOffsets';
|
} from '@app/features/lexical/composer/composerOffsets';
|
||||||
import {
|
import {
|
||||||
$createComposerBlockquoteLineNode,
|
$createComposerBlockquoteLineNode,
|
||||||
@@ -26,18 +43,38 @@ import {$isComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/
|
|||||||
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
|
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
|
||||||
import {mergeRegister} from '@lexical/utils';
|
import {mergeRegister} from '@lexical/utils';
|
||||||
import {
|
import {
|
||||||
|
$addUpdateTag,
|
||||||
$createTextNode,
|
$createTextNode,
|
||||||
|
$getEditor,
|
||||||
|
$getSelection,
|
||||||
|
$hasUpdateTag,
|
||||||
$isLineBreakNode,
|
$isLineBreakNode,
|
||||||
|
$isRangeSelection,
|
||||||
COMMAND_PRIORITY_CRITICAL,
|
COMMAND_PRIORITY_CRITICAL,
|
||||||
COMMAND_PRIORITY_HIGH,
|
COMMAND_PRIORITY_HIGH,
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
CONTROLLED_TEXT_INSERTION_COMMAND,
|
||||||
DELETE_CHARACTER_COMMAND,
|
DELETE_CHARACTER_COMMAND,
|
||||||
|
DELETE_LINE_COMMAND,
|
||||||
|
DELETE_WORD_COMMAND,
|
||||||
|
DROP_COMMAND,
|
||||||
type ElementNode,
|
type ElementNode,
|
||||||
|
findAllLexicalElementsDeep,
|
||||||
|
getEditorPropertyFromDOMNode,
|
||||||
|
HISTORIC_TAG,
|
||||||
INSERT_LINE_BREAK_COMMAND,
|
INSERT_LINE_BREAK_COMMAND,
|
||||||
INSERT_PARAGRAPH_COMMAND,
|
INSERT_PARAGRAPH_COMMAND,
|
||||||
|
IS_APPLE,
|
||||||
|
isHTMLElement,
|
||||||
|
isLexicalEditor,
|
||||||
KEY_ARROW_LEFT_COMMAND,
|
KEY_ARROW_LEFT_COMMAND,
|
||||||
|
KEY_ARROW_RIGHT_COMMAND,
|
||||||
|
KEY_DOWN_COMMAND,
|
||||||
type LexicalEditor,
|
type LexicalEditor,
|
||||||
type LexicalNode,
|
type LexicalNode,
|
||||||
type LineBreakNode,
|
type LineBreakNode,
|
||||||
|
PASTE_COMMAND,
|
||||||
|
PASTE_TAG,
|
||||||
SELECTION_CHANGE_COMMAND,
|
SELECTION_CHANGE_COMMAND,
|
||||||
TextNode,
|
TextNode,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
@@ -47,7 +84,9 @@ export interface ComposerLine {
|
|||||||
lineBreak: LineBreakNode | null;
|
lineBreak: LineBreakNode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BlockquotePlanner = (text: string, lines: ReadonlyArray<BlockquoteLine>, 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<ComposerLine> {
|
export function $splitComposerLines(paragraph: ElementNode): Array<ComposerLine> {
|
||||||
const lines: Array<ComposerLine> = [{nodes: [], lineBreak: null}];
|
const lines: Array<ComposerLine> = [{nodes: [], lineBreak: null}];
|
||||||
@@ -65,11 +104,11 @@ function $applyBlockquotePlan(editor: LexicalEditor, planner: BlockquotePlanner)
|
|||||||
if (editor.isComposing()) {
|
if (editor.isComposing()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const {scanText, selection, lines} = $getComposerBlockquoteState();
|
const state = $getComposerBlockquoteState();
|
||||||
if (selection == null || selection.anchor !== selection.focus || lines.length === 0) {
|
if (state.selection == null || state.selection.anchor !== state.selection.focus || state.lines.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const edit = planner(scanText, lines, selection.anchor);
|
const edit = planner(state, state.selection.anchor);
|
||||||
if (edit == null) {
|
if (edit == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -78,17 +117,167 @@ function $applyBlockquotePlan(editor: LexicalEditor, planner: BlockquotePlanner)
|
|||||||
return true;
|
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();
|
const {selection, lines} = $getComposerBlockquoteState();
|
||||||
if (selection == null || selection.anchor !== selection.focus || lines.length === 0) {
|
if (selection == null || lines.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const caret = resolveBlockquoteCaret(lines, selection.anchor);
|
const resolved = resolveBlockquoteSelection(lines, selection.anchor, selection.focus);
|
||||||
if (caret !== selection.anchor) {
|
if (resolved.anchor !== selection.anchor || resolved.focus !== selection.focus) {
|
||||||
$selectComposerOffset(caret);
|
$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 {
|
function shouldWrapLine(line: ComposerLine, quoted: boolean | undefined): boolean {
|
||||||
return quoted === true && $isComposerBlockquoteMarkerNode(line.nodes[0]);
|
return quoted === true && $isComposerBlockquoteMarkerNode(line.nodes[0]);
|
||||||
}
|
}
|
||||||
@@ -197,18 +386,29 @@ function $prefixComposerLine(line: ComposerLine): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function $rewriteMultilineBlockquoteMarker(
|
function $insertedBeforeCaret(caret: number): boolean {
|
||||||
lines: ReadonlyArray<ComposerLine>,
|
if ($hasUpdateTag(HISTORIC_TAG)) {
|
||||||
lineSources: ReadonlyArray<string>,
|
|
||||||
markerEnds: ReadonlyArray<number>,
|
|
||||||
): boolean {
|
|
||||||
const index = markerEnds.findIndex(
|
|
||||||
(end, lineIndex) => end > 0 && lineSources[lineIndex]!.slice(0, end).trimStart().startsWith('>>> '),
|
|
||||||
);
|
|
||||||
if (index < 0) {
|
|
||||||
return false;
|
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<ComposerLine>,
|
||||||
|
marker: MultilineBlockquoteMarker | null,
|
||||||
|
): boolean {
|
||||||
|
if (marker == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const index = marker.line;
|
||||||
|
const indent = marker.indent;
|
||||||
const lineStarts: Array<number> = [];
|
const lineStarts: Array<number> = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -221,15 +421,17 @@ export function $rewriteMultilineBlockquoteMarker(
|
|||||||
selection != null &&
|
selection != null &&
|
||||||
selection.anchor === selection.focus &&
|
selection.anchor === selection.focus &&
|
||||||
selection.anchor > markerStart &&
|
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, '> ')) {
|
if (!$spliceLeadingText(lines[index]!.nodes, indent, 4, '> ')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const prefixedStarts = typed ? [] : lineStarts.slice(index + 1);
|
const prefixedStarts = lineStarts.slice(index + 1);
|
||||||
if (!typed) {
|
for (const line of lines.slice(index + 1)) {
|
||||||
for (const line of lines.slice(index + 1)) {
|
$prefixComposerLine(line);
|
||||||
$prefixComposerLine(line);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (selection != null) {
|
if (selection != null) {
|
||||||
const map = (value: number): number => {
|
const map = (value: number): number => {
|
||||||
@@ -249,48 +451,78 @@ export function $rewriteMultilineBlockquoteMarker(
|
|||||||
return true;
|
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(
|
return mergeRegister(
|
||||||
editor.registerCommand(
|
editor.registerCommand(
|
||||||
INSERT_LINE_BREAK_COMMAND,
|
INSERT_LINE_BREAK_COMMAND,
|
||||||
(selectStart) => !selectStart && $applyBlockquotePlan(editor, planBlockquoteLineBreak),
|
(selectStart) => !selectStart && insertLineBreak(),
|
||||||
COMMAND_PRIORITY_HIGH,
|
COMMAND_PRIORITY_HIGH,
|
||||||
),
|
),
|
||||||
editor.registerCommand(
|
editor.registerCommand(INSERT_PARAGRAPH_COMMAND, insertLineBreak, COMMAND_PRIORITY_HIGH),
|
||||||
INSERT_PARAGRAPH_COMMAND,
|
editor.registerCommand(DELETE_CHARACTER_COMMAND, deleteAcrossMarker('character'), COMMAND_PRIORITY_CRITICAL),
|
||||||
() => $applyBlockquotePlan(editor, planBlockquoteLineBreak),
|
editor.registerCommand(DELETE_WORD_COMMAND, deleteAcrossMarker('word'), COMMAND_PRIORITY_CRITICAL),
|
||||||
COMMAND_PRIORITY_HIGH,
|
editor.registerCommand(DELETE_LINE_COMMAND, deleteAcrossMarker('line'), COMMAND_PRIORITY_CRITICAL),
|
||||||
),
|
|
||||||
editor.registerCommand(
|
|
||||||
DELETE_CHARACTER_COMMAND,
|
|
||||||
(isBackward) => isBackward && $applyBlockquotePlan(editor, planBlockquoteBackspace),
|
|
||||||
COMMAND_PRIORITY_CRITICAL,
|
|
||||||
),
|
|
||||||
editor.registerCommand(
|
editor.registerCommand(
|
||||||
KEY_ARROW_LEFT_COMMAND,
|
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) => {
|
(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;
|
return false;
|
||||||
}
|
}
|
||||||
const {selection, lines} = $getComposerBlockquoteState();
|
$addUpdateTag(PASTE_TAG);
|
||||||
if (selection == null || selection.anchor !== selection.focus) {
|
clipboardEvent.preventDefault();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const caret = planBlockquoteArrowLeft(lines, selection.anchor);
|
|
||||||
if (caret == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
$selectComposerOffset(caret);
|
|
||||||
return true;
|
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(
|
editor.registerCommand(
|
||||||
SELECTION_CHANGE_COMMAND,
|
SELECTION_CHANGE_COMMAND,
|
||||||
() => {
|
() => {
|
||||||
if (!editor.isComposing()) {
|
if (!editor.isComposing()) {
|
||||||
$snapCaretOutOfBlockquoteMarker();
|
$snapSelectionOutOfBlockquoteMarker();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {resolvePastedLinkInsertion} from '@app/features/lexical/composer/ComposerLinkPaste';
|
||||||
import {
|
import {
|
||||||
$createComposerSegmentNodes,
|
$createComposerSegmentNodes,
|
||||||
$projectComposer,
|
$projectComposer,
|
||||||
isValidComposerSegment,
|
isValidComposerSegment,
|
||||||
} from '@app/features/lexical/composer/ComposerSerialization';
|
} 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 {$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 {COMPOSER_SLASH_SLOT_STATE_MAX_ID_LENGTH} from '@app/features/lexical/composer/SlashSlotPersistence';
|
||||||
import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums';
|
import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums';
|
||||||
@@ -28,6 +37,7 @@ import {
|
|||||||
COPY_COMMAND,
|
COPY_COMMAND,
|
||||||
CUT_COMMAND,
|
CUT_COMMAND,
|
||||||
CUT_TAG,
|
CUT_TAG,
|
||||||
|
DRAGSTART_COMMAND,
|
||||||
type LexicalEditor,
|
type LexicalEditor,
|
||||||
type LexicalNode,
|
type LexicalNode,
|
||||||
PASTE_COMMAND,
|
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 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<string, string>();
|
const trustedPayloads = new Map<string, string>();
|
||||||
let trustedPayloadLength = 0;
|
let trustedPayloadLength = 0;
|
||||||
|
let trustedDragPayload: string | null = null;
|
||||||
|
|
||||||
export interface ComposerClipboardSlice {
|
export interface ComposerClipboardSlice {
|
||||||
display: string;
|
display: string;
|
||||||
@@ -223,7 +234,7 @@ export function $getComposerClipboardSelection(): ComposerClipboardSelection | n
|
|||||||
return {...validated, textPlain: sliceToWire(validated)};
|
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);
|
const validated = validateSlice(slice.display, slice.segments);
|
||||||
if (validated == null) {
|
if (validated == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -236,9 +247,15 @@ export function serializeComposerClipboardSlice(slice: ComposerClipboardSlice):
|
|||||||
segments: validated.segments,
|
segments: validated.segments,
|
||||||
};
|
};
|
||||||
const serialized = JSON.stringify(payload);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
const {token, serialized} = minted;
|
||||||
trustedPayloads.set(token, serialized);
|
trustedPayloads.set(token, serialized);
|
||||||
trustedPayloadLength += serialized.length;
|
trustedPayloadLength += serialized.length;
|
||||||
while (
|
while (
|
||||||
@@ -255,6 +272,12 @@ export function serializeComposerClipboardSlice(slice: ComposerClipboardSlice):
|
|||||||
return serialized;
|
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 {
|
export function parseComposerClipboardSlice(serialized: string): ComposerClipboardSlice | null {
|
||||||
if (serialized.length === 0 || serialized.length > COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH) {
|
if (serialized.length === 0 || serialized.length > COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH) {
|
||||||
return null;
|
return null;
|
||||||
@@ -274,7 +297,7 @@ export function parseComposerClipboardSlice(serialized: string): ComposerClipboa
|
|||||||
if (typeof value.token !== 'string' || !TRUST_TOKEN_RE.test(value.token)) {
|
if (typeof value.token !== 'string' || !TRUST_TOKEN_RE.test(value.token)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (trustedPayloads.get(value.token) !== serialized) {
|
if (trustedPayloads.get(value.token) !== serialized && trustedDragPayload !== serialized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const validated = validateSlice(value.display, value.segments);
|
const validated = validateSlice(value.display, value.segments);
|
||||||
@@ -374,7 +397,11 @@ function $replaceSlashSlotRange(
|
|||||||
return true;
|
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);
|
const validated = validateSlice(slice.display, slice.segments);
|
||||||
if (validated == null) {
|
if (validated == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -400,23 +427,61 @@ export function $insertComposerClipboardSlice(slice: ComposerClipboardSlice, pla
|
|||||||
if (!$isRangeSelection(selection)) {
|
if (!$isRangeSelection(selection)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const offsets = $captureSelectionOffsets();
|
const {scanText, selection: offsets, lines} = $getComposerBlockquoteState();
|
||||||
if (offsets == null) {
|
if (offsets == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const start = Math.min(offsets.anchor, offsets.focus);
|
const start = Math.min(offsets.anchor, offsets.focus);
|
||||||
|
const end = Math.max(offsets.anchor, offsets.focus);
|
||||||
const anchorSlot = $findEnclosingSlashSlot(selection.anchor.getNode());
|
const anchorSlot = $findEnclosingSlashSlot(selection.anchor.getNode());
|
||||||
const focusSlot = $findEnclosingSlashSlot(selection.focus.getNode());
|
const focusSlot = $findEnclosingSlashSlot(selection.focus.getNode());
|
||||||
if (anchorSlot != null && focusSlot != null && anchorSlot.is(focusSlot)) {
|
if (anchorSlot != null && focusSlot != null && anchorSlot.is(focusSlot)) {
|
||||||
const end = Math.max(offsets.anchor, offsets.focus);
|
|
||||||
return $replaceSlashSlotRange(anchorSlot, start, end, sliceToWire(validated));
|
return $replaceSlashSlotRange(anchorSlot, start, end, sliceToWire(validated));
|
||||||
}
|
}
|
||||||
selection.insertNodes($createSliceNodes(validated, plainText));
|
const pasted = planBlockquotePaste(scanText, lines, start, end, validated, parserFlags);
|
||||||
$selectComposerOffset(start + validated.display.length);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClipboardEvent(event: unknown): ClipboardEvent | null {
|
function createPastedSlice(pastedText: string, segments: ReadonlyArray<MentionSegment>): ComposerClipboardSlice {
|
||||||
|
const displayParts: Array<string> = [];
|
||||||
|
const projectedSegments: Array<MentionSegment> = [];
|
||||||
|
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<MentionSegment>,
|
||||||
|
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 (
|
if (
|
||||||
typeof event !== 'object' ||
|
typeof event !== 'object' ||
|
||||||
event == null ||
|
event == null ||
|
||||||
@@ -443,6 +508,13 @@ function writeComposerClipboardSelection(event: ClipboardEvent): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeComposerDragSlice(dataTransfer: DataTransfer): void {
|
||||||
|
const serialized = serializeComposerDragSlice($getComposerClipboardSelection());
|
||||||
|
if (serialized != null) {
|
||||||
|
dataTransfer.setData(FLUXER_COMPOSER_CLIPBOARD_MIME, serialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function $deleteComposerClipboardSelection(): boolean {
|
function $deleteComposerClipboardSelection(): boolean {
|
||||||
const selection = $getSelection();
|
const selection = $getSelection();
|
||||||
if ($isRangeSelection(selection)) {
|
if ($isRangeSelection(selection)) {
|
||||||
@@ -474,24 +546,39 @@ export function registerComposerClipboardCommands(
|
|||||||
if (clipboardEvent == null || clipboardEvent.clipboardData == null) {
|
if (clipboardEvent == null || clipboardEvent.clipboardData == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const selection = $getSelection();
|
const range = $getComposerContentSelectionRange();
|
||||||
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
|
if (range == null || range.start === range.end) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const replacement = resolvePastedLinkInsertion(
|
const replacement = resolvePastedLinkInsertion(
|
||||||
clipboardEvent.clipboardData.getData('text/plain'),
|
clipboardEvent.clipboardData.getData('text/plain'),
|
||||||
selection.getTextContent(),
|
$getComposerDisplayText().slice(range.start, range.end),
|
||||||
);
|
);
|
||||||
if (replacement === null) {
|
if (replacement === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
selection.insertText(replacement);
|
$replaceComposerRange(
|
||||||
|
range.start,
|
||||||
|
range.end,
|
||||||
|
{kind: 'text', text: replacement},
|
||||||
|
{leading: false, trailing: false},
|
||||||
|
);
|
||||||
$addUpdateTag(PASTE_TAG);
|
$addUpdateTag(PASTE_TAG);
|
||||||
clipboardEvent.preventDefault();
|
clipboardEvent.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
COMMAND_PRIORITY_CRITICAL,
|
COMMAND_PRIORITY_CRITICAL,
|
||||||
),
|
),
|
||||||
|
editor.registerCommand(
|
||||||
|
DRAGSTART_COMMAND,
|
||||||
|
(event) => {
|
||||||
|
if (state.isEditable() && event.dataTransfer != null) {
|
||||||
|
writeComposerDragSlice(event.dataTransfer);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_NORMAL,
|
||||||
|
),
|
||||||
editor.registerCommand(
|
editor.registerCommand(
|
||||||
COPY_COMMAND,
|
COPY_COMMAND,
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -538,7 +625,10 @@ export function registerComposerClipboardCommands(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const slice = parseComposerClipboardSlice(serialized);
|
const slice = parseComposerClipboardSlice(serialized);
|
||||||
if (slice == null || !$insertComposerClipboardSlice(slice, state.getPlainText())) {
|
if (
|
||||||
|
slice == null ||
|
||||||
|
!$insertComposerClipboardSlice(slice, state.getPlainText(), state.getMarkdownParserFlags())
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$addUpdateTag(PASTE_TAG);
|
$addUpdateTag(PASTE_TAG);
|
||||||
|
|||||||
@@ -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<typeof import('@app/features/lexical/composer/composerOffsets')>();
|
||||||
|
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<MentionSegment> = [],
|
||||||
|
): 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<string, string> {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
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<string, string>, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ export interface ComposerHandle {
|
|||||||
getDisplayValue: () => string;
|
getDisplayValue: () => string;
|
||||||
getWireValue: () => string;
|
getWireValue: () => string;
|
||||||
getSegments: () => Array<MentionSegment>;
|
getSegments: () => Array<MentionSegment>;
|
||||||
|
getMarkdownParserFlags: () => number;
|
||||||
getTextUpToCursor: () => string;
|
getTextUpToCursor: () => string;
|
||||||
getSelection: () => ComposerSelectionRange | null;
|
getSelection: () => ComposerSelectionRange | null;
|
||||||
replaceRange: (start: number, end: number, payload: ComposerInsertPayload, spacing?: ComposerInsertSpacing) => void;
|
replaceRange: (start: number, end: number, payload: ComposerInsertPayload, spacing?: ComposerInsertSpacing) => void;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {
|
import {
|
||||||
$rewriteMultilineBlockquoteMarker,
|
$rewriteMultilineBlockquoteMarker,
|
||||||
$snapCaretOutOfBlockquoteMarker,
|
$snapSelectionOutOfBlockquoteMarker,
|
||||||
$splitComposerLines,
|
$splitComposerLines,
|
||||||
$syncComposerBlockquoteLines,
|
$syncComposerBlockquoteLines,
|
||||||
} from '@app/features/lexical/composer/ComposerBlockquote';
|
} from '@app/features/lexical/composer/ComposerBlockquote';
|
||||||
@@ -93,6 +93,7 @@ export function registerComposerMarkdownHighlight(
|
|||||||
editor: LexicalEditor,
|
editor: LexicalEditor,
|
||||||
parserFlags?: number,
|
parserFlags?: number,
|
||||||
silentMessagePrefix = false,
|
silentMessagePrefix = false,
|
||||||
|
maxWireLength?: number,
|
||||||
): () => void {
|
): () => void {
|
||||||
return editor.registerNodeTransform(RootNode, (root) => {
|
return editor.registerNodeTransform(RootNode, (root) => {
|
||||||
if (editor.isComposing()) {
|
if (editor.isComposing()) {
|
||||||
@@ -100,7 +101,7 @@ export function registerComposerMarkdownHighlight(
|
|||||||
}
|
}
|
||||||
for (const child of root.getChildren()) {
|
for (const child of root.getChildren()) {
|
||||||
if (child instanceof ParagraphNode) {
|
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);
|
$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 lines = $splitComposerLines(paragraph);
|
||||||
const lineSources = lines.map((line) => line.nodes.map($nodeWireText).join(''));
|
const lineSources = lines.map((line) => line.nodes.map($nodeWireText).join(''));
|
||||||
const source = lineSources.join('\n');
|
const source = lineSources.join('\n');
|
||||||
const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags);
|
const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags);
|
||||||
const quoteMarkerEnds = findBlockquoteMarkerEnds(source, markdownSpans);
|
const {markerEnds: quoteMarkerEnds, multiline} = findBlockquoteMarkers(
|
||||||
if ($rewriteMultilineBlockquoteMarker(lines, lineSources, quoteMarkerEnds)) {
|
source,
|
||||||
|
markdownSpans,
|
||||||
|
parserFlags,
|
||||||
|
maxWireLength,
|
||||||
|
);
|
||||||
|
if ($rewriteMultilineBlockquoteMarker(lines, multiline)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans;
|
const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans;
|
||||||
@@ -155,7 +166,7 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, sil
|
|||||||
quoteMarkerEnds.map((end) => end > 0),
|
quoteMarkerEnds.map((end) => end > 0),
|
||||||
);
|
);
|
||||||
if (quoteMarkerEnds.some((end) => end > 0)) {
|
if (quoteMarkerEnds.some((end) => end > 0)) {
|
||||||
$snapCaretOutOfBlockquoteMarker();
|
$snapSelectionOutOfBlockquoteMarker();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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';
|
import {COMMAND_PRIORITY_HIGH, KEY_DOWN_COMMAND, type LexicalEditor} from 'lexical';
|
||||||
|
|
||||||
const SHORTCUTS = new Map([
|
const SHORTCUTS = new Map([
|
||||||
@@ -21,7 +24,7 @@ export function registerComposerMarkdownShortcuts(editor: LexicalEditor): () =>
|
|||||||
if (shortcut == null || event.shiftKey !== shortcut.shift) {
|
if (shortcut == null || event.shiftKey !== shortcut.shift) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const selection = $getComposerSelectionRange();
|
const selection = $getComposerContentSelectionRange();
|
||||||
if (selection == null || selection.start === selection.end) {
|
if (selection == null || selection.start === selection.end) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import {
|
|||||||
$createComposerPlainSegmentNode,
|
$createComposerPlainSegmentNode,
|
||||||
$isComposerPlainSegmentNode,
|
$isComposerPlainSegmentNode,
|
||||||
} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
|
} 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 {$createSlashSeparatorNode} from '@app/features/lexical/composer/nodes/SlashSeparatorNode';
|
||||||
import {
|
import {
|
||||||
$createSlashSlotNode,
|
$createSlashSlotNode,
|
||||||
@@ -53,12 +56,17 @@ export interface ComposerProjection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CUSTOM_EMOJI_WIRE_RE = /^<(a)?:([A-Za-z0-9_+~-]+):(\d+)>$/;
|
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_USER_WIRE_RE = /^<@!?(\d+)>$/;
|
||||||
const SLOT_ROLE_WIRE_RE = /^<@&(\d+)>$/;
|
const SLOT_ROLE_WIRE_RE = /^<@&(\d+)>$/;
|
||||||
const SLOT_CHANNEL_WIRE_RE = /^<#(\d+)>$/;
|
const SLOT_CHANNEL_WIRE_RE = /^<#(\d+)>$/;
|
||||||
export const COMPOSER_SLASH_SLOT_SEGMENT_PREFIX = 'slash-slot:';
|
export const COMPOSER_SLASH_SLOT_SEGMENT_PREFIX = 'slash-slot:';
|
||||||
const SLASH_SLOT_OPTION_NAME_RE = /^[a-z0-9_-]{1,32}$/;
|
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 {
|
function isValidSegmentWire(segment: MentionSegment): boolean {
|
||||||
if (parseSlashCommandStateSegment(segment) != null) {
|
if (parseSlashCommandStateSegment(segment) != null) {
|
||||||
return true;
|
return true;
|
||||||
@@ -78,7 +86,7 @@ function isValidSegmentWire(segment: MentionSegment): boolean {
|
|||||||
switch (segment.type) {
|
switch (segment.type) {
|
||||||
case 'emoji': {
|
case 'emoji': {
|
||||||
const match = CUSTOM_EMOJI_WIRE_RE.exec(segment.actualText);
|
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': {
|
case 'user': {
|
||||||
const match = SLOT_USER_WIRE_RE.exec(segment.actualText);
|
const match = SLOT_USER_WIRE_RE.exec(segment.actualText);
|
||||||
@@ -308,8 +316,22 @@ export function $projectComposer(): ComposerProjection {
|
|||||||
end: display.length,
|
end: display.length,
|
||||||
});
|
});
|
||||||
} else if ($isComposerStandardEmojiNode(child)) {
|
} else if ($isComposerStandardEmojiNode(child)) {
|
||||||
display += child.getTextContent();
|
const start = display.length;
|
||||||
wire += child.getWireText();
|
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)) {
|
} else if ($isSlashSlotNode(child)) {
|
||||||
const displayText = child.getTextContent();
|
const displayText = child.getTextContent();
|
||||||
const start = display.length;
|
const start = display.length;
|
||||||
@@ -403,6 +425,9 @@ export function $createComposerSegmentNodes(segment: MentionSegment, plainText:
|
|||||||
if (match) {
|
if (match) {
|
||||||
return [$createComposerCustomEmojiNode(match[3]!, Boolean(match[1]), segment.displayText, segment.actualText)];
|
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 [$createTextNode(segment.displayText)];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export interface LexicalComposerInputProps {
|
|||||||
slotResolvers?: SlashSlotResolvers;
|
slotResolvers?: SlashSlotResolvers;
|
||||||
markdown?: boolean;
|
markdown?: boolean;
|
||||||
markdownParserFlags?: number;
|
markdownParserFlags?: number;
|
||||||
|
maxWireLength?: number;
|
||||||
silentMessagePrefix?: boolean;
|
silentMessagePrefix?: boolean;
|
||||||
emojiShortcodeResolver?: ComposerEmojiResolver;
|
emojiShortcodeResolver?: ComposerEmojiResolver;
|
||||||
specialMentionsAllowed: boolean;
|
specialMentionsAllowed: boolean;
|
||||||
@@ -242,6 +243,7 @@ const ComposerInner = ({
|
|||||||
slotResolvers,
|
slotResolvers,
|
||||||
markdown = true,
|
markdown = true,
|
||||||
markdownParserFlags,
|
markdownParserFlags,
|
||||||
|
maxWireLength,
|
||||||
silentMessagePrefix = false,
|
silentMessagePrefix = false,
|
||||||
emojiShortcodeResolver,
|
emojiShortcodeResolver,
|
||||||
specialMentionsAllowed,
|
specialMentionsAllowed,
|
||||||
@@ -362,6 +364,7 @@ const ComposerInner = ({
|
|||||||
);
|
);
|
||||||
return segments;
|
return segments;
|
||||||
},
|
},
|
||||||
|
getMarkdownParserFlags: () => markdownParserFlagsRef.current,
|
||||||
getTextUpToCursor: () => {
|
getTextUpToCursor: () => {
|
||||||
let text = '';
|
let text = '';
|
||||||
editor.getEditorState().read(
|
editor.getEditorState().read(
|
||||||
@@ -523,8 +526,10 @@ const ComposerInner = ({
|
|||||||
cleanups.push(registerSlashSlotPlugin(editor, () => slotResolversRef.current, typeaheadActiveState));
|
cleanups.push(registerSlashSlotPlugin(editor, () => slotResolversRef.current, typeaheadActiveState));
|
||||||
cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current));
|
cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current));
|
||||||
if (markdown) {
|
if (markdown) {
|
||||||
cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix));
|
cleanups.push(
|
||||||
cleanups.push(registerComposerBlockquote(editor));
|
registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix, maxWireLength),
|
||||||
|
);
|
||||||
|
cleanups.push(registerComposerBlockquote(editor, markdownParserFlags));
|
||||||
}
|
}
|
||||||
cleanups.push(
|
cleanups.push(
|
||||||
registerComposerEmojiShortcode(editor, (shortcodeName) => {
|
registerComposerEmojiShortcode(editor, (shortcodeName) => {
|
||||||
@@ -558,7 +563,7 @@ const ComposerInner = ({
|
|||||||
{discrete: true, tag: HISTORY_MERGE_TAG},
|
{discrete: true, tag: HISTORY_MERGE_TAG},
|
||||||
);
|
);
|
||||||
return mergeRegister(...cleanups);
|
return mergeRegister(...cleanups);
|
||||||
}, [editor, markdown, markdownParserFlags, plainText, silentMessagePrefix]);
|
}, [editor, markdown, markdownParserFlags, maxWireLength, plainText, silentMessagePrefix]);
|
||||||
|
|
||||||
useLayoutEffect(
|
useLayoutEffect(
|
||||||
() => registerComposerSpecialMention(editor, specialMentionsAllowed, plainText),
|
() => registerComposerSpecialMention(editor, specialMentionsAllowed, plainText),
|
||||||
|
|||||||
@@ -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<LexicalRichInputHandle>();
|
||||||
|
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}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ export interface LexicalRichInputProps {
|
|||||||
singleLine?: boolean;
|
singleLine?: boolean;
|
||||||
size?: 'chat' | 'form';
|
size?: 'chat' | 'form';
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
maxWireLength?: number;
|
||||||
onExceedMaxLength?: () => void;
|
onExceedMaxLength?: () => void;
|
||||||
autocompleteAnchor?: HTMLElement | null;
|
autocompleteAnchor?: HTMLElement | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -75,6 +76,7 @@ export const LexicalRichInput = ({
|
|||||||
singleLine = false,
|
singleLine = false,
|
||||||
size = 'chat',
|
size = 'chat',
|
||||||
maxLength,
|
maxLength,
|
||||||
|
maxWireLength = maxLength,
|
||||||
onExceedMaxLength,
|
onExceedMaxLength,
|
||||||
autocompleteAnchor,
|
autocompleteAnchor,
|
||||||
className,
|
className,
|
||||||
@@ -229,6 +231,7 @@ export const LexicalRichInput = ({
|
|||||||
initialSegments={initialSegments}
|
initialSegments={initialSegments}
|
||||||
markdown={markdown}
|
markdown={markdown}
|
||||||
markdownParserFlags={markdownParserFlags}
|
markdownParserFlags={markdownParserFlags}
|
||||||
|
maxWireLength={maxWireLength}
|
||||||
emojiShortcodeResolver={emojiShortcodeResolver}
|
emojiShortcodeResolver={emojiShortcodeResolver}
|
||||||
specialMentionsAllowed={specialMentionsAllowed}
|
specialMentionsAllowed={specialMentionsAllowed}
|
||||||
channelId={channel == null ? undefined : channel.id}
|
channelId={channel == null ? undefined : channel.id}
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type BlockquoteDeleteUnit,
|
||||||
type BlockquoteEdit,
|
type BlockquoteEdit,
|
||||||
type BlockquoteLine,
|
type BlockquoteLine,
|
||||||
|
blockquoteContentRange,
|
||||||
dropTrailingEmptyBlockquoteLines,
|
dropTrailingEmptyBlockquoteLines,
|
||||||
findBlockquoteMarkerEnds,
|
findBlockquoteMarkers,
|
||||||
planBlockquoteArrowLeft,
|
|
||||||
planBlockquoteBackspace,
|
planBlockquoteBackspace,
|
||||||
|
planBlockquoteContentStartDelete,
|
||||||
|
planBlockquoteDropCaret,
|
||||||
|
planBlockquoteForwardDelete,
|
||||||
planBlockquoteLineBreak,
|
planBlockquoteLineBreak,
|
||||||
resolveBlockquoteCaret,
|
planBlockquoteMove,
|
||||||
|
planBlockquotePaste,
|
||||||
|
resolveBlockquoteSelection,
|
||||||
} from '@app/features/lexical/composer/blockquoteLines';
|
} 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';
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
const QUOTE_PREFIX_RE = /^[ \t]*> /;
|
const QUOTE_PREFIX_RE = /^[ \t]*> /;
|
||||||
|
const NO_CODE_BLOCK_FLAGS = DEFAULT_COMPOSER_MARKDOWN_FLAGS & ~ParserFlags.ALLOW_CODE_BLOCKS;
|
||||||
|
|
||||||
function quoteLines(text: string): Array<BlockquoteLine> {
|
function quoteLines(text: string): Array<BlockquoteLine> {
|
||||||
const lines: Array<BlockquoteLine> = [];
|
const lines: Array<BlockquoteLine> = [];
|
||||||
@@ -71,6 +84,16 @@ describe('planBlockquoteLineBreak', () => {
|
|||||||
])('declines on %j at %i because the caret is inside a code block', (text, caret) => {
|
])('declines on %j at %i because the caret is inside a code block', (text, caret) => {
|
||||||
expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull();
|
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', () => {
|
describe('planBlockquoteBackspace', () => {
|
||||||
@@ -101,9 +124,290 @@ describe('planBlockquoteBackspace', () => {
|
|||||||
])('declines on %j at %i', (text, caret) => {
|
])('declines on %j at %i', (text, caret) => {
|
||||||
expect(planBlockquoteBackspace(text, quoteLines(text), caret)).toBeNull();
|
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<MentionSegment> = [
|
||||||
|
{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]>([
|
it.each<[string, number, number]>([
|
||||||
['> test', 0, 2],
|
['> test', 0, 2],
|
||||||
['> test', 1, 2],
|
['> test', 1, 2],
|
||||||
@@ -113,35 +417,72 @@ describe('resolveBlockquoteCaret', () => {
|
|||||||
['ab\n> cd', 3, 5],
|
['ab\n> cd', 3, 5],
|
||||||
['ab\n> cd', 4, 5],
|
['ab\n> cd', 4, 5],
|
||||||
['ab\n> cd', 1, 1],
|
['ab\n> cd', 1, 1],
|
||||||
])('maps %j at %i to %i', (text, caret, expected) => {
|
])('maps the caret on %j at %i to %i', (text, caret, expected) => {
|
||||||
expect(resolveBlockquoteCaret(quoteLines(text), caret)).toBe(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', () => {
|
describe('blockquoteContentRange', () => {
|
||||||
it('moves to the end of the line above from a later quote line', () => {
|
it.each<[string, number, number, number, number]>([
|
||||||
expect(planBlockquoteArrowLeft(quoteLines('ab\n> cd'), 5)).toBe(2);
|
['> hello', 0, 7, 2, 7],
|
||||||
});
|
['> a\n> b', 2, 4, 2, 6],
|
||||||
|
['> a\n> b', 4, 7, 6, 7],
|
||||||
it('keeps the caret at content start on the first line', () => {
|
['ab\n> cd', 1, 4, 1, 5],
|
||||||
expect(planBlockquoteArrowLeft(quoteLines('> test'), 2)).toBe(2);
|
['> test', 2, 2, 2, 2],
|
||||||
});
|
['abc', 0, 3, 0, 3],
|
||||||
|
])('clamps %j from %i..%i to %i..%i', (text, start, end, expectedStart, expectedEnd) => {
|
||||||
it.each<[string, number]>([
|
expect(blockquoteContentRange(quoteLines(text), start, end)).toEqual({start: expectedStart, end: expectedEnd});
|
||||||
['> test', 4],
|
|
||||||
['> test', 0],
|
|
||||||
['abc', 2],
|
|
||||||
])('declines on %j at %i', (text, caret) => {
|
|
||||||
expect(planBlockquoteArrowLeft(quoteLines(text), caret)).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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<number> {
|
||||||
|
return findBlockquoteMarkers(text, computeMarkdownHighlightSpans(text, parserFlags), parserFlags).markerEnds;
|
||||||
|
}
|
||||||
|
|
||||||
it.each<[string, Array<number>]>([
|
it.each<[string, Array<number>]>([
|
||||||
['> a', [2]],
|
['> a', [2]],
|
||||||
[' > a', [4]],
|
[' > a', [4]],
|
||||||
['> ', [2]],
|
['> ', [2]],
|
||||||
['>>> a\nb', [4, 0]],
|
['>>> a\nb', [0, 0]],
|
||||||
['>>> [!NOTE]\nbody', [0, 0]],
|
['>>> [!NOTE]\nbody', [0, 0]],
|
||||||
[' >>> [!tip] x\nbody', [0, 0]],
|
[' >>> [!tip] x\nbody', [0, 0]],
|
||||||
['> > a', [2]],
|
['> > a', [2]],
|
||||||
@@ -155,7 +496,13 @@ describe('findBlockquoteMarkerEnds', () => {
|
|||||||
['```\n> a\n```', [0, 0, 0]],
|
['```\n> a\n```', [0, 0, 0]],
|
||||||
['```\nx\n```\n> a', [0, 0, 0, 2]],
|
['```\nx\n```\n> a', [0, 0, 0, 2]],
|
||||||
])('gives %j the marker ends %j', (text, ends) => {
|
])('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', () => {
|
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', () => {
|
describe('dropTrailingEmptyBlockquoteLines', () => {
|
||||||
it.each<[string, string]>([
|
it.each<[string, string]>([
|
||||||
['> test\n> ', '> test'],
|
['> test\n> ', '> test'],
|
||||||
@@ -182,4 +599,9 @@ describe('dropTrailingEmptyBlockquoteLines', () => {
|
|||||||
])('turns %j into %j', (content, expected) => {
|
])('turns %j into %j', (content, expected) => {
|
||||||
expect(dropTrailingEmptyBlockquoteLines(content)).toBe(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('> ```');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {isOffsetInsideCodeBlock, scanCodeBlocks} from '@app/features/lexical/composer/codeBlockIndent';
|
import {type CodeBlockBody, scanCodeBlocks} from '@app/features/lexical/composer/codeBlockIndent';
|
||||||
import {MarkdownHl, type MarkdownSpan} from '@app/features/lexical/composer/markdownSpans';
|
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 {
|
export interface BlockquoteLine {
|
||||||
start: number;
|
start: number;
|
||||||
@@ -9,6 +16,12 @@ export interface BlockquoteLine {
|
|||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MultilineBlockquoteMarker {
|
||||||
|
line: number;
|
||||||
|
indent: number;
|
||||||
|
splits: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BlockquoteEdit {
|
export interface BlockquoteEdit {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
@@ -16,9 +29,28 @@ export interface BlockquoteEdit {
|
|||||||
caret: number;
|
caret: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BlockquoteDeleteUnit = 'character' | 'word' | 'line';
|
||||||
|
|
||||||
const CONTINUATION = '\n> ';
|
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 DROPPABLE_TRAILING_LINE_RE = /^[ \t]*(?:> [ \t]*)?$/;
|
||||||
const MULTILINE_ALERT_RE = /^[ \t]*>>> \[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i;
|
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<CodeBlockBody> {
|
||||||
|
return (parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) === 0 ? [] : scanCodeBlocks(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideCodeBlock(blocks: ReadonlyArray<CodeBlockBody>, offset: number): boolean {
|
||||||
|
return blocks.some((block) => block.start <= offset && offset <= block.end);
|
||||||
|
}
|
||||||
|
|
||||||
function isEmptyLine(line: BlockquoteLine): boolean {
|
function isEmptyLine(line: BlockquoteLine): boolean {
|
||||||
return line.contentStart === line.end;
|
return line.contentStart === line.end;
|
||||||
@@ -28,20 +60,50 @@ function isDirectlyAbove(upper: BlockquoteLine, lower: BlockquoteLine): boolean
|
|||||||
return upper.end + 1 === lower.start;
|
return upper.end + 1 === lower.start;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findBlockquoteMarkerEnds(source: string, spans: ReadonlyArray<MarkdownSpan>): Array<number> {
|
function isMultilineMarker(line: string, markerEnd: number): boolean {
|
||||||
|
return line.slice(0, markerEnd).trimStart().startsWith('>>> ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBlockquoteMarkers(
|
||||||
|
source: string,
|
||||||
|
spans: ReadonlyArray<MarkdownSpan>,
|
||||||
|
parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS,
|
||||||
|
maxWireLength?: number,
|
||||||
|
): {markerEnds: Array<number>; 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<string>,
|
||||||
|
spans: ReadonlyArray<MarkdownSpan>,
|
||||||
|
parserFlags: number,
|
||||||
|
): Array<number> {
|
||||||
const markerEndByStart = new Map<number, number>();
|
const markerEndByStart = new Map<number, number>();
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
if (span.role === 'marker' && (span.format & MarkdownHl.blockquoteMarker) !== 0) {
|
if (span.role === 'marker' && (span.format & MarkdownHl.blockquoteMarker) !== 0) {
|
||||||
markerEndByStart.set(span.start, span.end);
|
markerEndByStart.set(span.start, span.end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const blocks = scanCodeBlocks(source);
|
const blocks = scanAllowedCodeBlocks(source, parserFlags);
|
||||||
const ends: Array<number> = [];
|
const ends: Array<number> = [];
|
||||||
let lineStart = 0;
|
let lineStart = 0;
|
||||||
for (const line of source.split('\n')) {
|
for (const line of lines) {
|
||||||
const markerEnd = markerEndByStart.get(lineStart);
|
const markerEnd = markerEndByStart.get(lineStart);
|
||||||
const insideCode = blocks.some((block) => block.start <= lineStart && lineStart <= block.end);
|
const suppressed = isInsideCodeBlock(blocks, lineStart) || MULTILINE_ALERT_RE.test(line);
|
||||||
const suppressed = insideCode || MULTILINE_ALERT_RE.test(line);
|
|
||||||
ends.push(markerEnd == null || suppressed ? 0 : Math.min(markerEnd, lineStart + line.length) - lineStart);
|
ends.push(markerEnd == null || suppressed ? 0 : Math.min(markerEnd, lineStart + line.length) - lineStart);
|
||||||
lineStart += line.length + 1;
|
lineStart += line.length + 1;
|
||||||
}
|
}
|
||||||
@@ -52,8 +114,9 @@ export function planBlockquoteLineBreak(
|
|||||||
text: string,
|
text: string,
|
||||||
lines: ReadonlyArray<BlockquoteLine>,
|
lines: ReadonlyArray<BlockquoteLine>,
|
||||||
caret: number,
|
caret: number,
|
||||||
|
parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS,
|
||||||
): BlockquoteEdit | null {
|
): BlockquoteEdit | null {
|
||||||
if (isOffsetInsideCodeBlock(text, caret)) {
|
if (isInsideCodeBlock(scanAllowedCodeBlocks(text, parserFlags), caret)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const index = lines.findIndex((line) => line.contentStart <= caret && caret <= line.end);
|
const index = lines.findIndex((line) => line.contentStart <= caret && caret <= line.end);
|
||||||
@@ -76,8 +139,9 @@ export function planBlockquoteBackspace(
|
|||||||
text: string,
|
text: string,
|
||||||
lines: ReadonlyArray<BlockquoteLine>,
|
lines: ReadonlyArray<BlockquoteLine>,
|
||||||
caret: number,
|
caret: number,
|
||||||
|
parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS,
|
||||||
): BlockquoteEdit | null {
|
): BlockquoteEdit | null {
|
||||||
if (isOffsetInsideCodeBlock(text, caret)) {
|
if (isInsideCodeBlock(scanAllowedCodeBlocks(text, parserFlags), caret)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const line = lines.find((candidate) => candidate.contentStart === caret);
|
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};
|
return {start: above.start, end: caret, text: '', caret: above.start};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveBlockquoteCaret(lines: ReadonlyArray<BlockquoteLine>, caret: number): number {
|
export function planBlockquoteForwardDelete(
|
||||||
|
text: string,
|
||||||
|
lines: ReadonlyArray<BlockquoteLine>,
|
||||||
|
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<BlockquoteLine>,
|
||||||
|
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<MentionSegment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInsideBlockquoteContent(lines: ReadonlyArray<BlockquoteLine>, offset: number): boolean {
|
||||||
|
return lines.some((line) => line.contentStart <= offset && offset <= line.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planBlockquotePaste(
|
||||||
|
text: string,
|
||||||
|
lines: ReadonlyArray<BlockquoteLine>,
|
||||||
|
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<number> = [];
|
||||||
|
const ends: Array<number> = [];
|
||||||
|
const sizes: Array<number> = [];
|
||||||
|
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<number> = [];
|
||||||
|
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<MentionSegment> = [];
|
||||||
|
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<BlockquoteLine>, caret: number): number {
|
||||||
const line = lines.find((candidate) => candidate.start <= caret && caret < candidate.contentStart);
|
const line = lines.find((candidate) => candidate.start <= caret && caret < candidate.contentStart);
|
||||||
return line == null ? caret : line.contentStart;
|
return line == null ? caret : line.contentStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function planBlockquoteArrowLeft(lines: ReadonlyArray<BlockquoteLine>, caret: number): number | null {
|
export function resolveBlockquoteSelection(
|
||||||
const line = lines.find((candidate) => candidate.contentStart === caret);
|
lines: ReadonlyArray<BlockquoteLine>,
|
||||||
|
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<BlockquoteLine>,
|
||||||
|
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<BlockquoteLine>,
|
||||||
|
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) {
|
if (line == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return line.start > 0 ? line.start - 1 : line.contentStart;
|
return line.start > 0 ? line.start - 1 : line.contentStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dropTrailingEmptyBlockquoteLines(content: string): string {
|
export function dropTrailingEmptyBlockquoteLines(
|
||||||
const blocks = scanCodeBlocks(content);
|
content: string,
|
||||||
|
parserFlags = DEFAULT_COMPOSER_MARKDOWN_FLAGS,
|
||||||
|
): string {
|
||||||
|
const blocks = scanAllowedCodeBlocks(content, parserFlags);
|
||||||
let end = content.length;
|
let end = content.length;
|
||||||
while (end > 0) {
|
while (end > 0) {
|
||||||
const lineStart = content.lastIndexOf('\n', end - 1) + 1;
|
const lineStart = content.lastIndexOf('\n', end - 1) + 1;
|
||||||
if (
|
if (!DROPPABLE_TRAILING_LINE_RE.test(content.slice(lineStart, end)) || isInsideCodeBlock(blocks, lineStart)) {
|
||||||
!DROPPABLE_TRAILING_LINE_RE.test(content.slice(lineStart, end)) ||
|
|
||||||
blocks.some((block) => block.start <= lineStart && lineStart <= block.end)
|
|
||||||
) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
end = Math.max(0, lineStart - 1);
|
end = Math.max(0, lineStart - 1);
|
||||||
|
|||||||
@@ -204,6 +204,79 @@ const INDENT_ROWS: Array<IndentRow> = [
|
|||||||
['recognises a fence indented by four spaces', ' ```\ncode', 12, 12, false, tabAt(12)],
|
['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 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],
|
['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]> = [
|
const HIGHLIGHT_ROWS: Array<[text: string, offset: number, expected: boolean]> = [
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ interface CodeBlockParse {
|
|||||||
|
|
||||||
interface ScanScope {
|
interface ScanScope {
|
||||||
bodies: Array<CodeBlockBody>;
|
bodies: Array<CodeBlockBody>;
|
||||||
|
quoteContentStarts: Array<number>;
|
||||||
end: number;
|
end: number;
|
||||||
quotes: boolean;
|
quotes: boolean;
|
||||||
multilineQuotes: boolean;
|
multilineQuotes: boolean;
|
||||||
@@ -61,6 +62,7 @@ interface ScanScope {
|
|||||||
interface PhysicalLine {
|
interface PhysicalLine {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
contentStart: number;
|
||||||
text: string;
|
text: string;
|
||||||
isCodeContent: boolean;
|
isCodeContent: boolean;
|
||||||
}
|
}
|
||||||
@@ -255,7 +257,9 @@ function scanQuote(lines: Array<SourceLine>, index: number, scope: ScanScope): n
|
|||||||
if (!trimmed.startsWith('> ')) {
|
if (!trimmed.startsWith('> ')) {
|
||||||
break;
|
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;
|
next += 1;
|
||||||
}
|
}
|
||||||
scanBlocks(quoted, {...scope, quotes: false, end: linesEnd(quoted)});
|
scanBlocks(quoted, {...scope, quotes: false, end: linesEnd(quoted)});
|
||||||
@@ -363,10 +367,21 @@ function scanBlocks(lines: Array<SourceLine>, scope: ScanScope): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scanCodeBlocks(text: string): Array<CodeBlockBody> {
|
function scanSource(text: string): ScanScope {
|
||||||
const scope: ScanScope = {bodies: [], end: text.length, quotes: true, multilineQuotes: true, rendered: true};
|
const scope: ScanScope = {
|
||||||
|
bodies: [],
|
||||||
|
quoteContentStarts: [],
|
||||||
|
end: text.length,
|
||||||
|
quotes: true,
|
||||||
|
multilineQuotes: true,
|
||||||
|
rendered: true,
|
||||||
|
};
|
||||||
scanBlocks(splitLines(text), scope);
|
scanBlocks(splitLines(text), scope);
|
||||||
return scope.bodies;
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanCodeBlocks(text: string): Array<CodeBlockBody> {
|
||||||
|
return scanSource(text).bodies;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOffsetInsideCodeBlock(text: string, offset: number): boolean {
|
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<PhysicalLine> {
|
function scanLines(text: string): Array<PhysicalLine> {
|
||||||
const bodies = scanCodeBlocks(text);
|
const {bodies, quoteContentStarts} = scanSource(text);
|
||||||
return splitLines(text).map(({text: content, offset: start}) => {
|
return splitLines(text).map(({text: content, offset: start}) => {
|
||||||
const end = start + content.length;
|
const end = start + content.length;
|
||||||
|
const contentStart = quoteContentStarts.reduce(
|
||||||
|
(current, offset) => (current <= offset && offset <= end ? offset : current),
|
||||||
|
start,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
text: content,
|
contentStart,
|
||||||
|
text: text.slice(contentStart, end),
|
||||||
isCodeContent: bodies.some(
|
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,
|
selectionEnd: selectionStart + INDENT.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const rangeStart = Math.max(selectionStart, lines[startLine]!.contentStart);
|
||||||
|
const rangeEnd = Math.max(rangeStart, selectionEnd);
|
||||||
const edits: Array<CodeIndentEdit> = [];
|
const edits: Array<CodeIndentEdit> = [];
|
||||||
let startDelta = 0;
|
let startDelta = 0;
|
||||||
let endDelta = 0;
|
let endDelta = 0;
|
||||||
@@ -438,15 +461,15 @@ export function analyzeCodeIndent(
|
|||||||
if (removed === 0) {
|
if (removed === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
edits.push({start: line.start, end: line.start + removed, text: ''});
|
edits.push({start: line.contentStart, end: line.contentStart + removed, text: ''});
|
||||||
startDelta -= Math.min(removed, Math.max(0, selectionStart - line.start));
|
startDelta -= Math.min(removed, Math.max(0, rangeStart - line.contentStart));
|
||||||
endDelta -= Math.min(removed, Math.max(0, selectionEnd - line.start));
|
endDelta -= Math.min(removed, Math.max(0, rangeEnd - line.contentStart));
|
||||||
} else {
|
} else {
|
||||||
edits.push({start: line.start, end: line.start, text: INDENT});
|
edits.push({start: line.contentStart, end: line.contentStart, text: INDENT});
|
||||||
if (line.start <= selectionStart) {
|
if (line.contentStart <= rangeStart) {
|
||||||
startDelta += INDENT.length;
|
startDelta += INDENT.length;
|
||||||
}
|
}
|
||||||
if (line.start <= selectionEnd) {
|
if (line.contentStart <= rangeEnd) {
|
||||||
endDelta += INDENT.length;
|
endDelta += INDENT.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,7 +479,7 @@ export function analyzeCodeIndent(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
edits,
|
edits,
|
||||||
selectionStart: Math.max(0, selectionStart + startDelta),
|
selectionStart: Math.max(0, rangeStart + startDelta),
|
||||||
selectionEnd: Math.max(0, selectionEnd + endDelta),
|
selectionEnd: Math.max(0, rangeEnd + endDelta),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap';
|
||||||
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
|
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
|
||||||
import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
|
import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
|
||||||
@@ -33,8 +33,10 @@ import {
|
|||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
$setSelection,
|
$setSelection,
|
||||||
type ElementNode,
|
type ElementNode,
|
||||||
|
type LexicalEditor,
|
||||||
type LexicalNode,
|
type LexicalNode,
|
||||||
type NodeKey,
|
type NodeKey,
|
||||||
|
type PointType,
|
||||||
TextNode,
|
TextNode,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
import invariant from 'tiny-invariant';
|
import invariant from 'tiny-invariant';
|
||||||
@@ -181,6 +183,44 @@ function $getComposerDisplayOffsetFromLayout(layout: DisplayLayout): number | nu
|
|||||||
return pointToDisplayOffset(layout, anchor.getNode(), anchor.offset, anchor.type);
|
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 {
|
export function $getComposerNodeDisplayStart(node: LexicalNode): number | null {
|
||||||
const layout = $buildDisplayLayout();
|
const layout = $buildDisplayLayout();
|
||||||
const leaf = layout.leaves.find((candidate) => candidate.node.getKey() === node.getKey());
|
const leaf = layout.leaves.find((candidate) => candidate.node.getKey() === node.getKey());
|
||||||
@@ -206,6 +246,7 @@ export interface ComposerBlockquoteState {
|
|||||||
scanText: string;
|
scanText: string;
|
||||||
selection: ComposerSelectionOffsets | null;
|
selection: ComposerSelectionOffsets | null;
|
||||||
lines: Array<BlockquoteLine>;
|
lines: Array<BlockquoteLine>;
|
||||||
|
atoms: Array<{start: number; end: number}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockquoteLinesFromLayout(layout: DisplayLayout): Array<BlockquoteLine> {
|
function blockquoteLinesFromLayout(layout: DisplayLayout): Array<BlockquoteLine> {
|
||||||
@@ -234,9 +275,18 @@ export function $getComposerBlockquoteState(): ComposerBlockquoteState {
|
|||||||
scanText: scanTextFromLayout(layout),
|
scanText: scanTextFromLayout(layout),
|
||||||
selection: $captureSelectionOffsetsFromLayout(layout),
|
selection: $captureSelectionOffsetsFromLayout(layout),
|
||||||
lines: blockquoteLinesFromLayout(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 {
|
export function $getComposerSelectionRange(): {start: number; end: number} | null {
|
||||||
const offsets = $captureSelectionOffsets();
|
const offsets = $captureSelectionOffsets();
|
||||||
if (offsets == null) {
|
if (offsets == null) {
|
||||||
@@ -258,21 +308,26 @@ export function $getComposerDisplayText(): string {
|
|||||||
return $buildDisplayLayout().text;
|
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 scanText = '';
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const {node, start, end} of leaves) {
|
for (const {start, end} of atomRangesFromLayout(layout)) {
|
||||||
if (
|
scanText += layout.text.slice(offset, start) + 'x'.repeat(end - start);
|
||||||
$isComposerMentionNode(node) ||
|
offset = end;
|
||||||
$isComposerCustomEmojiNode(node) ||
|
|
||||||
$isComposerStandardEmojiNode(node) ||
|
|
||||||
$isComposerPlainSegmentNode(node)
|
|
||||||
) {
|
|
||||||
scanText += 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 {
|
export function $getComposerScanText(): string {
|
||||||
@@ -472,12 +527,13 @@ function classifyWrap(display: string, start: number, end: number, prefix: strin
|
|||||||
|
|
||||||
function codeBlockPlanFor(
|
function codeBlockPlanFor(
|
||||||
layout: DisplayLayout,
|
layout: DisplayLayout,
|
||||||
|
lines: ReadonlyArray<BlockquoteLine>,
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
prefix: string,
|
prefix: string,
|
||||||
suffix: string,
|
suffix: string,
|
||||||
): CodeBlockWrapPlan | null {
|
): CodeBlockWrapPlan | null {
|
||||||
if (prefix !== '`' || suffix !== '`') {
|
if (prefix !== '`' || suffix !== '`' || lines.some((line) => line.start < end && start < line.end)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const plan = planCodeBlockWrap(scanTextFromLayout(layout), start, end);
|
const plan = planCodeBlockWrap(scanTextFromLayout(layout), start, end);
|
||||||
@@ -500,15 +556,15 @@ export function $queryComposerSelectionWrappers(
|
|||||||
if (offsets == null) {
|
if (offsets == null) {
|
||||||
return {offsets: null, wrapped: queries.map(() => false)};
|
return {offsets: null, wrapped: queries.map(() => false)};
|
||||||
}
|
}
|
||||||
const start = Math.min(offsets.anchor, offsets.focus);
|
const lines = blockquoteLinesFromLayout(layout);
|
||||||
const end = Math.max(offsets.anchor, offsets.focus);
|
const {start, end} = blockquoteContentRange(lines, offsets.anchor, offsets.focus);
|
||||||
if (start === end) {
|
if (start === end) {
|
||||||
return {offsets, wrapped: queries.map(() => false)};
|
return {offsets, wrapped: queries.map(() => false)};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
offsets,
|
offsets,
|
||||||
wrapped: queries.map(({prefix, suffix}) => {
|
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;
|
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) {
|
if (offsets == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const start = Math.min(offsets.anchor, offsets.focus);
|
const lines = blockquoteLinesFromLayout(layout);
|
||||||
const end = Math.max(offsets.anchor, offsets.focus);
|
const {start, end} = blockquoteContentRange(lines, offsets.anchor, offsets.focus);
|
||||||
const backward = offsets.anchor > offsets.focus;
|
const backward = offsets.anchor > offsets.focus;
|
||||||
const display = layout.text;
|
const display = layout.text;
|
||||||
const selected = display.slice(start, end);
|
const selected = display.slice(start, end);
|
||||||
@@ -531,7 +587,7 @@ export function $wrapComposerSelection(prefix: string, suffix: string): void {
|
|||||||
const restoreSelection = (nextStart: number, nextEnd: number) => {
|
const restoreSelection = (nextStart: number, nextEnd: number) => {
|
||||||
$selectComposerRange(backward ? nextEnd : nextStart, backward ? nextStart : nextEnd);
|
$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) {
|
if (plan != null) {
|
||||||
replaceTextRange(plan.closing.start, plan.closing.end, plan.closing.text);
|
replaceTextRange(plan.closing.start, plan.closing.end, plan.closing.text);
|
||||||
replaceTextRange(plan.opening.start, plan.opening.end, plan.opening.text);
|
replaceTextRange(plan.opening.start, plan.opening.end, plan.opening.text);
|
||||||
|
|||||||
@@ -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:😀');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
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 {ComposerMentionContext} from '@app/features/lexical/composer/ComposerMentionContext';
|
||||||
import styles from '@app/features/lexical/composer/nodes/ComposerInline.module.css';
|
import styles from '@app/features/lexical/composer/nodes/ComposerInline.module.css';
|
||||||
import {EmojiWithTooltip} from '@app/features/ui/emoji_tooltip_content/EmojiWithTooltip';
|
import {EmojiWithTooltip} from '@app/features/ui/emoji_tooltip_content/EmojiWithTooltip';
|
||||||
@@ -26,17 +27,18 @@ export const ComposerStandardEmoji = ({name, surrogate, url, display}: ComposerS
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const imageUrl = url == null ? EmojiUtils.getEmojiURL(surrogate) : url;
|
||||||
const emojiForSubtext: FlatEmoji = {
|
const emojiForSubtext: FlatEmoji = {
|
||||||
name,
|
name,
|
||||||
uniqueName: name,
|
uniqueName: name,
|
||||||
allNamesString: display,
|
allNamesString: display,
|
||||||
surrogates: surrogate,
|
surrogates: surrogate,
|
||||||
animated: false,
|
animated: false,
|
||||||
url: url == null ? undefined : url,
|
url: imageUrl == null ? undefined : imageUrl,
|
||||||
};
|
};
|
||||||
const image = url ? (
|
const image = imageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={imageUrl}
|
||||||
alt={display}
|
alt={display}
|
||||||
className={styles.customEmoji}
|
className={styles.customEmoji}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -56,7 +58,7 @@ export const ComposerStandardEmoji = ({name, surrogate, url, display}: ComposerS
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<EmojiWithTooltip
|
<EmojiWithTooltip
|
||||||
emojiUrl={url}
|
emojiUrl={imageUrl}
|
||||||
emojiName={display}
|
emojiName={display}
|
||||||
emojiForSubtext={emojiForSubtext}
|
emojiForSubtext={emojiForSubtext}
|
||||||
data-flx="lexical.composer.nodes.composer-standard-emoji.emoji-with-tooltip"
|
data-flx="lexical.composer.nodes.composer-standard-emoji.emoji-with-tooltip"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import styles from '@app/features/user/components/modals/tabs/MyProfileTab.modul
|
|||||||
import {AccentColorPicker} from '@app/features/user/components/modals/tabs/my_profile_tab/AccentColorPicker';
|
import {AccentColorPicker} from '@app/features/user/components/modals/tabs/my_profile_tab/AccentColorPicker';
|
||||||
import {AvatarUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
|
import {AvatarUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
|
||||||
import {BannerUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/BannerUploader';
|
import {BannerUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/BannerUploader';
|
||||||
import {BioEditor} from '@app/features/user/components/modals/tabs/my_profile_tab/BioEditor';
|
import {BIO_MARKDOWN_PARSER_FLAGS, BioEditor} from '@app/features/user/components/modals/tabs/my_profile_tab/BioEditor';
|
||||||
import {UsernameSection} from '@app/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection';
|
import {UsernameSection} from '@app/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection';
|
||||||
import {PerGuildPremiumUpsell} from '@app/features/user/components/modals/tabs/my_profile_tab/PerGuildPremiumUpsell';
|
import {PerGuildPremiumUpsell} from '@app/features/user/components/modals/tabs/my_profile_tab/PerGuildPremiumUpsell';
|
||||||
import {PremiumBadgeSettings} from '@app/features/user/components/modals/tabs/my_profile_tab/PremiumBadgeSettings';
|
import {PremiumBadgeSettings} from '@app/features/user/components/modals/tabs/my_profile_tab/PremiumBadgeSettings';
|
||||||
@@ -449,7 +449,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
|||||||
const handleBioChange = useCallback((display: string, segments: Array<MentionSegment>, wire: string) => {
|
const handleBioChange = useCallback((display: string, segments: Array<MentionSegment>, wire: string) => {
|
||||||
setBioValue(display);
|
setBioValue(display);
|
||||||
setBioSegments(segments);
|
setBioSegments(segments);
|
||||||
setBioActualValue(dropTrailingEmptyBlockquoteLines(wire));
|
setBioActualValue(dropTrailingEmptyBlockquoteLines(wire, BIO_MARKDOWN_PARSER_FLAGS));
|
||||||
}, []);
|
}, []);
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
async (data: FormInputs) => {
|
async (data: FormInputs) => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {useCallback, useEffect, useId, useRef, useState} from 'react';
|
|||||||
|
|
||||||
const ABOUT_ME_DESCRIPTOR = msg({message: 'About me'});
|
const ABOUT_ME_DESCRIPTOR = msg({message: 'About me'});
|
||||||
const OPEN_EMOJI_PICKER_DESCRIPTOR = msg({message: 'Open emoji picker'});
|
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({
|
const ABOUT_ME_IS_TOO_LONG_DESCRIPTOR = msg({
|
||||||
message: 'About me is too long',
|
message: 'About me is too long',
|
||||||
comment: 'Error modal title shown when inserting an emoji into the profile bio would exceed the bio limit.',
|
comment: 'Error modal title shown when inserting an emoji into the profile bio would exceed the bio limit.',
|
||||||
|
|||||||
Reference in new issue
Block a user