Files
fluxer/fluxer_app/src/features/lexical/composer/ComposerClipboard.ts
T

642 lines
20 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {planBlockquotePaste} from '@app/features/lexical/composer/blockquoteLines';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {resolvePastedLinkInsertion} from '@app/features/lexical/composer/ComposerLinkPaste';
import {
$createComposerSegmentNodes,
$projectComposer,
isValidComposerSegment,
} from '@app/features/lexical/composer/ComposerSerialization';
import {
$captureSelectionOffsets,
$getComposerBlockquoteState,
$getComposerContentSelectionRange,
$getComposerDisplayText,
$replaceComposerRange,
$selectComposerOffset,
} from '@app/features/lexical/composer/composerOffsets';
import {$isSlashSlotNode, type SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode';
import {COMPOSER_SLASH_SLOT_STATE_MAX_ID_LENGTH} from '@app/features/lexical/composer/SlashSlotPersistence';
import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {mergeRegister} from '@lexical/utils';
import {
$addUpdateTag,
$createLineBreakNode,
$createTextNode,
$getRoot,
$getSelection,
$isElementNode,
$isLineBreakNode,
$isNodeSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_NORMAL,
COPY_COMMAND,
CUT_COMMAND,
CUT_TAG,
DRAGSTART_COMMAND,
type LexicalEditor,
type LexicalNode,
PASTE_COMMAND,
PASTE_TAG,
} from 'lexical';
export const FLUXER_COMPOSER_CLIPBOARD_MIME = 'application/x-fluxer-composer+json';
export const COMPOSER_CLIPBOARD_VERSION = 1;
export const COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH = 262_144;
export const COMPOSER_CLIPBOARD_MAX_DISPLAY_LENGTH = 65_536;
export const COMPOSER_CLIPBOARD_MAX_SEGMENTS = 512;
export const COMPOSER_CLIPBOARD_MAX_TRUSTED_PAYLOADS = 32;
const COMPOSER_CLIPBOARD_MAX_SEGMENT_ID_LENGTH = COMPOSER_SLASH_SLOT_STATE_MAX_ID_LENGTH;
const COMPOSER_CLIPBOARD_MAX_SEGMENT_TEXT_LENGTH = 65_536;
const COMPOSER_CLIPBOARD_MAX_TRUSTED_PAYLOAD_LENGTH = 1_048_576;
const SEGMENT_TYPES = new Set<MentionSegment['type']>(['user', 'role', 'channel', 'emoji', 'special']);
const PAYLOAD_KEYS = ['version', 'token', 'display', 'segments'] as const;
const SEGMENT_KEYS = ['type', 'id', 'displayText', 'actualText', 'start', 'end'] as const;
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>();
let trustedPayloadLength = 0;
let trustedDragPayload: string | null = null;
export interface ComposerClipboardSlice {
display: string;
segments: Array<MentionSegment>;
}
export interface ComposerClipboardSelection extends ComposerClipboardSlice {
textPlain: string;
}
export interface ComposerClipboardCommandState {
getPlainText(): boolean;
isEditable(): boolean;
getMarkdownParserFlags(): number;
}
interface SerializedComposerClipboardPayload {
version: typeof COMPOSER_CLIPBOARD_VERSION;
token: string;
display: string;
segments: Array<MentionSegment>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value != null && !Array.isArray(value);
}
function hasExactKeys<const T extends ReadonlyArray<string>>(value: Record<string, unknown>, keys: T): boolean {
const actualKeys = Object.keys(value);
return actualKeys.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function isBoundedString(value: unknown, minimumLength: number, maximumLength: number): value is string {
return typeof value === 'string' && value.length >= minimumLength && value.length <= maximumLength;
}
function parseSegment(value: unknown): MentionSegment | null {
if (!isRecord(value) || !hasExactKeys(value, SEGMENT_KEYS)) {
return null;
}
if (typeof value.type !== 'string' || !SEGMENT_TYPES.has(value.type as MentionSegment['type'])) {
return null;
}
if (!isBoundedString(value.id, 1, COMPOSER_CLIPBOARD_MAX_SEGMENT_ID_LENGTH)) {
return null;
}
if (!isBoundedString(value.displayText, 1, COMPOSER_CLIPBOARD_MAX_SEGMENT_TEXT_LENGTH)) {
return null;
}
if (value.displayText.includes('\n') || value.displayText.includes('\r')) {
return null;
}
if (!isBoundedString(value.actualText, 1, COMPOSER_CLIPBOARD_MAX_SEGMENT_TEXT_LENGTH)) {
return null;
}
if (!Number.isInteger(value.start) || !Number.isInteger(value.end)) {
return null;
}
return {
type: value.type as MentionSegment['type'],
id: value.id,
displayText: value.displayText,
actualText: value.actualText,
start: value.start as number,
end: value.end as number,
};
}
function validateSlice(display: unknown, rawSegments: unknown): ComposerClipboardSlice | null {
if (!isBoundedString(display, 1, COMPOSER_CLIPBOARD_MAX_DISPLAY_LENGTH)) {
return null;
}
if (!Array.isArray(rawSegments) || rawSegments.length > COMPOSER_CLIPBOARD_MAX_SEGMENTS) {
return null;
}
const segments: Array<MentionSegment> = [];
let minimumStart = 0;
for (const rawSegment of rawSegments) {
const segment = parseSegment(rawSegment);
if (segment == null || !isValidComposerSegment(display, segment, minimumStart)) {
return null;
}
segments.push(segment);
minimumStart = segment.end;
}
return {display, segments};
}
function sliceToWire(slice: ComposerClipboardSlice): string {
const chunks: Array<string> = [];
let cursor = 0;
for (const segment of slice.segments) {
if (segment.start > cursor) {
chunks.push(slice.display.slice(cursor, segment.start));
}
chunks.push(segment.actualText);
cursor = segment.end;
}
if (cursor < slice.display.length) {
chunks.push(slice.display.slice(cursor));
}
return chunks.join('');
}
function $getNodeDisplayRange(targetNode: LexicalNode): {start: number; end: number} | null {
let result: {start: number; end: number} | null = null;
let offset = 0;
const visit = (node: LexicalNode) => {
const nodeStart = offset;
if ($isElementNode(node)) {
for (const child of node.getChildren()) {
visit(child);
}
} else {
offset += $isLineBreakNode(node) ? 1 : node.getTextContent().length;
}
if (node.is(targetNode)) {
result = {start: nodeStart, end: offset};
}
};
const blocks = $getRoot().getChildren();
for (let index = 0; index < blocks.length; index += 1) {
if (index > 0) {
offset += 1;
}
visit(blocks[index]!);
}
return result;
}
function $getNodeSelectionOffsets(selectedNodes: ReadonlyArray<LexicalNode>): {anchor: number; focus: number} | null {
if (selectedNodes.length !== 1) {
return null;
}
const range = $getNodeDisplayRange(selectedNodes[0]!);
return range != null && range.end > range.start ? {anchor: range.start, focus: range.end} : null;
}
function $getSelectionOffsets(): {anchor: number; focus: number} | null {
const selection = $getSelection();
if ($isNodeSelection(selection)) {
return $getNodeSelectionOffsets(selection.getNodes());
}
return $captureSelectionOffsets();
}
export function getComposerClipboardTextPlain(slice: ComposerClipboardSlice): string | null {
const validated = validateSlice(slice.display, slice.segments);
return validated == null ? null : sliceToWire(validated);
}
export function $getComposerClipboardSelection(): ComposerClipboardSelection | null {
const offsets = $getSelectionOffsets();
if (offsets == null) {
return null;
}
const start = Math.min(offsets.anchor, offsets.focus);
const end = Math.max(offsets.anchor, offsets.focus);
if (start === end) {
return null;
}
const projection = $projectComposer();
const display = projection.display.slice(start, end);
const segments = projection.segments
.filter((segment) => segment.start >= start && segment.end <= end)
.map((segment) => ({...segment, start: segment.start - start, end: segment.end - start}));
const validated = validateSlice(display, segments);
if (validated == null) {
return null;
}
return {...validated, textPlain: sliceToWire(validated)};
}
function mintTrustedPayload(slice: ComposerClipboardSlice): {token: string; serialized: string} | null {
const validated = validateSlice(slice.display, slice.segments);
if (validated == null) {
return null;
}
const token = globalThis.crypto.randomUUID();
const payload: SerializedComposerClipboardPayload = {
version: COMPOSER_CLIPBOARD_VERSION,
token,
display: validated.display,
segments: validated.segments,
};
const serialized = JSON.stringify(payload);
return serialized.length > COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH ? null : {token, serialized};
}
export function serializeComposerClipboardSlice(slice: ComposerClipboardSlice): string | null {
const minted = mintTrustedPayload(slice);
if (minted == null) {
return null;
}
const {token, serialized} = minted;
trustedPayloads.set(token, serialized);
trustedPayloadLength += serialized.length;
while (
trustedPayloads.size > COMPOSER_CLIPBOARD_MAX_TRUSTED_PAYLOADS ||
trustedPayloadLength > COMPOSER_CLIPBOARD_MAX_TRUSTED_PAYLOAD_LENGTH
) {
const oldest = trustedPayloads.entries().next().value;
if (oldest == null) {
break;
}
trustedPayloads.delete(oldest[0]);
trustedPayloadLength -= oldest[1].length;
}
return serialized;
}
function serializeComposerDragSlice(slice: ComposerClipboardSlice | null): string | null {
const minted = slice == null ? null : mintTrustedPayload(slice);
trustedDragPayload = minted == null ? null : minted.serialized;
return trustedDragPayload;
}
export function parseComposerClipboardSlice(serialized: string): ComposerClipboardSlice | null {
if (serialized.length === 0 || serialized.length > COMPOSER_CLIPBOARD_MAX_PAYLOAD_LENGTH) {
return null;
}
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return null;
}
if (!isRecord(value) || !hasExactKeys(value, PAYLOAD_KEYS)) {
return null;
}
if (value.version !== COMPOSER_CLIPBOARD_VERSION) {
return null;
}
if (typeof value.token !== 'string' || !TRUST_TOKEN_RE.test(value.token)) {
return null;
}
if (trustedPayloads.get(value.token) !== serialized && trustedDragPayload !== serialized) {
return null;
}
const validated = validateSlice(value.display, value.segments);
return validated;
}
function appendTextNodes(nodes: Array<LexicalNode>, text: string): void {
const lines = text.split('\n');
for (let index = 0; index < lines.length; index += 1) {
if (index > 0) {
nodes.push($createLineBreakNode());
}
const line = lines[index]!;
if (line.length > 0) {
nodes.push($createTextNode(line));
}
}
}
function $createSliceNodes(slice: ComposerClipboardSlice, plainText: boolean): Array<LexicalNode> {
const nodes: Array<LexicalNode> = [];
let cursor = 0;
for (const segment of slice.segments) {
if (segment.start > cursor) {
appendTextNodes(nodes, slice.display.slice(cursor, segment.start));
}
nodes.push(...$createComposerSegmentNodes(segment, plainText));
cursor = segment.end;
}
if (cursor < slice.display.length) {
appendTextNodes(nodes, slice.display.slice(cursor));
}
return nodes;
}
function $findEnclosingSlashSlot(node: LexicalNode): SlashSlotNode | null {
let current: LexicalNode | null = node;
while (current != null) {
if ($isSlashSlotNode(current)) {
return current;
}
current = current.getParent();
}
return null;
}
function $selectSlashSlotOffset(slot: SlashSlotNode, offset: number): void {
let consumed = 0;
const children = slot.getChildren();
for (let index = 0; index < children.length; index += 1) {
const child = children[index]!;
if ($isLineBreakNode(child)) {
if (offset <= consumed) {
slot.select(index, index);
return;
}
consumed += 1;
if (offset <= consumed) {
slot.select(index + 1, index + 1);
return;
}
continue;
}
if ($isTextNode(child)) {
const length = child.getTextContentSize();
if (offset <= consumed + length) {
const textOffset = Math.max(0, offset - consumed);
child.select(textOffset, textOffset);
return;
}
consumed += length;
}
}
slot.selectValueEnd();
}
function $replaceSlashSlotRange(
slot: SlashSlotNode,
globalStart: number,
globalEnd: number,
replacement: string,
): boolean {
const slotRange = $getNodeDisplayRange(slot);
if (slotRange == null) {
return false;
}
const slotText = slot.getTextContent();
const start = Math.min(Math.max(0, globalStart - slotRange.start), slotText.length);
const end = Math.min(Math.max(start, globalEnd - slotRange.start), slotText.length);
const nextText = `${slotText.slice(0, start)}${replacement}${slotText.slice(end)}`;
const nodes: Array<LexicalNode> = [];
appendTextNodes(nodes, nextText);
slot.clear();
slot.append(...nodes);
slot.setResolvedWire(null).setValidity('neutral').setValidationError(null).setTouched(true);
$selectSlashSlotOffset(slot, start + replacement.length);
return true;
}
export function $insertComposerClipboardSlice(
slice: ComposerClipboardSlice,
plainText: boolean,
parserFlags?: number,
): boolean {
const validated = validateSlice(slice.display, slice.segments);
if (validated == null) {
return false;
}
const selection = $getSelection();
if ($isNodeSelection(selection)) {
const selectedNodes = selection.getNodes();
if (selectedNodes.length !== 1) {
return false;
}
const selectedNode = selectedNodes[0]!;
const slot = $findEnclosingSlashSlot(selectedNode);
if (slot != null) {
const selectedRange = $getNodeDisplayRange(selectedNode);
return (
selectedRange != null &&
$replaceSlashSlotRange(slot, selectedRange.start, selectedRange.end, sliceToWire(validated))
);
}
selection.insertNodes($createSliceNodes(validated, plainText));
return true;
}
if (!$isRangeSelection(selection)) {
return false;
}
const {scanText, selection: offsets, lines} = $getComposerBlockquoteState();
if (offsets == null) {
return false;
}
const start = Math.min(offsets.anchor, offsets.focus);
const end = Math.max(offsets.anchor, offsets.focus);
const anchorSlot = $findEnclosingSlashSlot(selection.anchor.getNode());
const focusSlot = $findEnclosingSlashSlot(selection.focus.getNode());
if (anchorSlot != null && focusSlot != null && anchorSlot.is(focusSlot)) {
return $replaceSlashSlotRange(anchorSlot, start, end, sliceToWire(validated));
}
const pasted = planBlockquotePaste(scanText, lines, start, end, validated, parserFlags);
const nodes = $createSliceNodes(pasted, plainText);
if (nodes.length > 0) {
selection.insertNodes(nodes);
} else if (start !== end) {
selection.removeText();
}
$selectComposerOffset(start + pasted.display.length);
return true;
}
function 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 (
typeof event !== 'object' ||
event == null ||
!('clipboardData' in event) ||
!('preventDefault' in event) ||
event.clipboardData == null ||
typeof event.preventDefault !== 'function'
) {
return null;
}
return event as ClipboardEvent;
}
function writeComposerClipboardSelection(event: ClipboardEvent): boolean {
const selection = $getComposerClipboardSelection();
if (selection == null || event.clipboardData == null) {
return false;
}
const serialized = serializeComposerClipboardSlice(selection);
event.clipboardData.setData('text/plain', selection.textPlain);
if (serialized != null) {
event.clipboardData.setData(FLUXER_COMPOSER_CLIPBOARD_MIME, serialized);
}
return true;
}
function writeComposerDragSlice(dataTransfer: DataTransfer): void {
const serialized = serializeComposerDragSlice($getComposerClipboardSelection());
if (serialized != null) {
dataTransfer.setData(FLUXER_COMPOSER_CLIPBOARD_MIME, serialized);
}
}
function $deleteComposerClipboardSelection(): boolean {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
if (selection.isCollapsed()) {
return false;
}
selection.removeText();
return true;
}
if ($isNodeSelection(selection) && selection.getNodes().length > 0) {
selection.deleteNodes();
return true;
}
return false;
}
export function registerComposerClipboardCommands(
editor: LexicalEditor,
state: ComposerClipboardCommandState,
): () => void {
return mergeRegister(
editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!state.isEditable() || (state.getMarkdownParserFlags() & ParserFlags.ALLOW_MASKED_LINKS) === 0) {
return false;
}
const clipboardEvent = getClipboardEvent(event);
if (clipboardEvent == null || clipboardEvent.clipboardData == null) {
return false;
}
const range = $getComposerContentSelectionRange();
if (range == null || range.start === range.end) {
return false;
}
const replacement = resolvePastedLinkInsertion(
clipboardEvent.clipboardData.getData('text/plain'),
$getComposerDisplayText().slice(range.start, range.end),
);
if (replacement === null) {
return false;
}
$replaceComposerRange(
range.start,
range.end,
{kind: 'text', text: replacement},
{leading: false, trailing: false},
);
$addUpdateTag(PASTE_TAG);
clipboardEvent.preventDefault();
return true;
},
COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand(
DRAGSTART_COMMAND,
(event) => {
if (state.isEditable() && event.dataTransfer != null) {
writeComposerDragSlice(event.dataTransfer);
}
return false;
},
COMMAND_PRIORITY_NORMAL,
),
editor.registerCommand(
COPY_COMMAND,
(event) => {
const clipboardEvent = getClipboardEvent(event);
if (clipboardEvent == null || !writeComposerClipboardSelection(clipboardEvent)) {
return false;
}
clipboardEvent.preventDefault();
return true;
},
COMMAND_PRIORITY_NORMAL,
),
editor.registerCommand(
CUT_COMMAND,
(event) => {
if (!state.isEditable()) {
return false;
}
const clipboardEvent = getClipboardEvent(event);
if (clipboardEvent == null || !writeComposerClipboardSelection(clipboardEvent)) {
return false;
}
if (!$deleteComposerClipboardSelection()) {
return false;
}
$addUpdateTag(CUT_TAG);
clipboardEvent.preventDefault();
return true;
},
COMMAND_PRIORITY_NORMAL,
),
editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!state.isEditable()) {
return false;
}
const clipboardEvent = getClipboardEvent(event);
const serialized =
clipboardEvent == null || clipboardEvent.clipboardData == null
? null
: clipboardEvent.clipboardData.getData(FLUXER_COMPOSER_CLIPBOARD_MIME);
if (clipboardEvent == null || serialized == null || serialized.length === 0) {
return false;
}
const slice = parseComposerClipboardSlice(serialized);
if (
slice == null ||
!$insertComposerClipboardSlice(slice, state.getPlainText(), state.getMarkdownParserFlags())
) {
return false;
}
$addUpdateTag(PASTE_TAG);
clipboardEvent.preventDefault();
return true;
},
COMMAND_PRIORITY_NORMAL,
),
);
}