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

482 lines
15 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {
$createComposerCommandNode,
$isComposerCommandNode,
} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {
$createComposerCustomEmojiNode,
$isComposerCustomEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
$createComposerMentionNode,
$isComposerMentionNode,
type ComposerMentionType,
} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {
$createComposerPlainSegmentNode,
$isComposerPlainSegmentNode,
} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {
$createComposerStandardEmojiNode,
$isComposerStandardEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
import {$createSlashSeparatorNode} from '@app/features/lexical/composer/nodes/SlashSeparatorNode';
import {
$createSlashSlotNode,
$isSlashSlotNode,
type SlashSlotNode,
} from '@app/features/lexical/composer/nodes/SlashSlotNode';
import {
createSlashCommandStateSegmentId,
createSlashSlotStateSegmentId,
type PersistedSlashSlotState,
parseSlashCommandStateSegment,
parseSlashSlotStateSegment,
parseSlashSlotStateSegmentId,
} from '@app/features/lexical/composer/SlashSlotPersistence';
import {isSpecialMentionKind} from '@app/features/lexical/composer/specialMentions';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
$isElementNode,
$isLineBreakNode,
type LexicalNode,
} from 'lexical';
import invariant from 'tiny-invariant';
export interface ComposerProjection {
display: string;
segments: Array<MentionSegment>;
wire: string;
}
const CUSTOM_EMOJI_WIRE_RE = /^<(a)?:([A-Za-z0-9_+~-]+):(\d+)>$/;
const STANDARD_EMOJI_WIRE_RE = /^(?:[0-9#*]\uFE0F?\u20E3|\P{ASCII})+$/u;
const SLOT_USER_WIRE_RE = /^<@!?(\d+)>$/;
const SLOT_ROLE_WIRE_RE = /^<@&(\d+)>$/;
const SLOT_CHANNEL_WIRE_RE = /^<#(\d+)>$/;
export const COMPOSER_SLASH_SLOT_SEGMENT_PREFIX = 'slash-slot:';
const SLASH_SLOT_OPTION_NAME_RE = /^[a-z0-9_-]{1,32}$/;
function isStandardEmojiSegment(segment: MentionSegment): boolean {
return segment.type === 'emoji' && STANDARD_EMOJI_WIRE_RE.test(segment.actualText);
}
function isValidSegmentWire(segment: MentionSegment): boolean {
if (parseSlashCommandStateSegment(segment) != null) {
return true;
}
if (parseSlashSlotStateSegment(segment) != null) {
return true;
}
if (segment.id.startsWith(COMPOSER_SLASH_SLOT_SEGMENT_PREFIX)) {
return (
segment.type === 'special' &&
SLASH_SLOT_OPTION_NAME_RE.test(segment.id.slice(COMPOSER_SLASH_SLOT_SEGMENT_PREFIX.length)) &&
segment.actualText.length > 0 &&
!segment.actualText.includes('\n') &&
!segment.actualText.includes('\r')
);
}
switch (segment.type) {
case 'emoji': {
const match = CUSTOM_EMOJI_WIRE_RE.exec(segment.actualText);
return match == null ? isStandardEmojiSegment(segment) : match[3] === segment.id;
}
case 'user': {
const match = SLOT_USER_WIRE_RE.exec(segment.actualText);
return match != null && match[1] === segment.id;
}
case 'role': {
const match = SLOT_ROLE_WIRE_RE.exec(segment.actualText);
return match != null && match[1] === segment.id;
}
case 'channel': {
const match = SLOT_CHANNEL_WIRE_RE.exec(segment.actualText);
return match != null && match[1] === segment.id;
}
case 'special':
return (
isSpecialMentionKind(segment.id) && segment.actualText === segment.id && segment.displayText === segment.id
);
}
}
export function isValidComposerSegment(display: string, segment: MentionSegment, minimumStart = 0): boolean {
return (
Number.isInteger(segment.start) &&
Number.isInteger(segment.end) &&
segment.start >= minimumStart &&
segment.start >= 0 &&
segment.end > segment.start &&
segment.end <= display.length &&
display.slice(segment.start, segment.end) === segment.displayText &&
isValidSegmentWire(segment)
);
}
function $slashSlotState(node: SlashSlotNode): PersistedSlashSlotState {
return {
version: 2,
optionName: node.getOptionName(),
optionType: node.getOptionType(),
required: node.isRequired(),
allowEmpty: node.allowsEmpty(),
description: node.getDescription(),
choices: node.getChoices(),
validity: node.getValidity(),
validationError: node.getValidationError(),
touched: node.isTouched(),
};
}
function normalizeEditedSlashSlotState(state: PersistedSlashSlotState, displayText: string): PersistedSlashSlotState {
const emptyRequired = state.required && state.allowEmpty !== true && displayText.slice(1).length === 0;
return {
...state,
validity: emptyRequired ? 'invalid' : 'neutral',
validationError: emptyRequired ? 'required' : null,
touched: true,
};
}
function createSlashSlotStateSegment(
state: PersistedSlashSlotState,
displayText: string,
actualText: string,
start: number,
): MentionSegment | null {
const segment: MentionSegment = {
type: 'special',
id: createSlashSlotStateSegmentId(state),
displayText,
actualText,
start,
end: start + displayText.length,
};
return parseSlashSlotStateSegment(segment) == null ? null : segment;
}
function createLegacySlashSlotSegment(
node: SlashSlotNode,
displayText: string,
actualText: string,
start: number,
): MentionSegment | null {
if (displayText.length === 0 || actualText === displayText) {
return null;
}
const userMatch = SLOT_USER_WIRE_RE.exec(actualText);
if (userMatch) {
return {type: 'user', id: userMatch[1]!, displayText, actualText, start, end: start + displayText.length};
}
const roleMatch = SLOT_ROLE_WIRE_RE.exec(actualText);
if (roleMatch) {
return {type: 'role', id: roleMatch[1]!, displayText, actualText, start, end: start + displayText.length};
}
const channelMatch = SLOT_CHANNEL_WIRE_RE.exec(actualText);
if (channelMatch) {
return {type: 'channel', id: channelMatch[1]!, displayText, actualText, start, end: start + displayText.length};
}
return {
type: 'special',
id: `${COMPOSER_SLASH_SLOT_SEGMENT_PREFIX}${node.getOptionName()}`,
displayText,
actualText,
start,
end: start + displayText.length,
};
}
function $createSlashSlotValueNode(
optionType: PersistedSlashSlotState['optionType'],
displayText: string,
wireText: string,
): LexicalNode {
if (optionType === 'user') {
const match = SLOT_USER_WIRE_RE.exec(wireText);
if (match) {
return $createComposerMentionNode('user', match[1]!, displayText, wireText);
}
}
if (optionType === 'role') {
const match = SLOT_ROLE_WIRE_RE.exec(wireText);
if (match) {
return $createComposerMentionNode('role', match[1]!, displayText, wireText);
}
}
if (optionType === 'channel') {
const match = SLOT_CHANNEL_WIRE_RE.exec(wireText);
if (match) {
return $createComposerMentionNode('channel', match[1]!, displayText, wireText);
}
}
return $createTextNode(displayText);
}
export function $projectComposer(): ComposerProjection {
const root = $getRoot();
const blocks = root.getChildren();
let display = '';
let wire = '';
const segments: Array<MentionSegment> = [];
for (let b = 0; b < blocks.length; b += 1) {
if (b > 0) {
display += '\n';
wire += '\n';
}
const block = blocks[b]!;
if (!$isElementNode(block)) {
display += block.getTextContent();
wire += block.getTextContent();
continue;
}
for (const child of $getComposerLineNodes(block)) {
if ($isLineBreakNode(child)) {
display += '\n';
wire += '\n';
} else if ($isComposerCommandNode(child)) {
const start = display.length;
const displayText = child.getTextContent();
const segmentId = createSlashCommandStateSegmentId(displayText, child.getOptionalOptions());
invariant(segmentId != null, 'Composer slash command metadata violates persistence bounds');
display += displayText;
wire += displayText;
segments.push({
type: 'special',
id: segmentId,
displayText,
actualText: displayText,
start,
end: display.length,
});
} else if ($isComposerPlainSegmentNode(child)) {
const start = display.length;
const displayText = child.getTextContent();
display += displayText;
const slashState = parseSlashSlotStateSegmentId(child.getSegmentId());
if (slashState != null && displayText.startsWith(' ') && !displayText.includes('\n')) {
const segment = child.isSegmentValid()
? createSlashSlotStateSegment(slashState, displayText, child.getWireText(), start)
: createSlashSlotStateSegment(
normalizeEditedSlashSlotState(slashState, displayText),
displayText,
displayText,
start,
);
if (segment != null) {
wire += segment.actualText;
segments.push(segment);
} else {
wire += displayText;
}
} else if (child.isSegmentValid()) {
const actualText = child.getWireText();
wire += actualText;
segments.push({
type: child.getSegmentType(),
id: child.getSegmentId(),
displayText,
actualText,
start,
end: display.length,
});
} else {
wire += displayText;
}
} else if ($isComposerMentionNode(child)) {
const start = display.length;
const displayText = child.getTextContent();
display += displayText;
wire += child.getWireText();
segments.push({
type: child.getSegmentType(),
id: child.getMentionId(),
displayText,
actualText: child.getWireText(),
start,
end: display.length,
});
} else if ($isComposerCustomEmojiNode(child)) {
const start = display.length;
const displayText = child.getTextContent();
display += displayText;
wire += child.getWireText();
segments.push({
type: 'emoji',
id: child.getEmojiId(),
displayText,
actualText: child.getWireText(),
start,
end: display.length,
});
} else if ($isComposerStandardEmojiNode(child)) {
const start = display.length;
const displayText = child.getTextContent();
const actualText = child.getWireText();
display += displayText;
wire += actualText;
const segment: MentionSegment = {
type: 'emoji',
id: child.getEmojiName(),
displayText,
actualText,
start,
end: display.length,
};
if (isStandardEmojiSegment(segment)) {
segments.push(segment);
}
} else if ($isSlashSlotNode(child)) {
const displayText = child.getTextContent();
const start = display.length;
let slotWire = child.getWireText();
display += displayText;
const previousSibling = child.getPreviousSibling();
const previousSegment = segments.at(-1);
const previousSegmentEnd = previousSegment == null ? 0 : previousSegment.end;
let persistedStructure = false;
if (
start > 0 &&
display[start - 1] === ' ' &&
wire[wire.length - 1] === ' ' &&
previousSibling != null &&
previousSibling.getTextContent().endsWith(' ') === true &&
previousSegmentEnd <= start - 1
) {
const stateDisplay = ` ${displayText}`;
let segment = createSlashSlotStateSegment($slashSlotState(child), stateDisplay, ` ${slotWire}`, start - 1);
if (segment == null) {
slotWire = displayText;
segment = createSlashSlotStateSegment(
normalizeEditedSlashSlotState($slashSlotState(child), stateDisplay),
stateDisplay,
stateDisplay,
start - 1,
);
}
if (segment != null) {
segments.push(segment);
persistedStructure = true;
}
}
if (!persistedStructure) {
const legacySegment = createLegacySlashSlotSegment(child, displayText, slotWire, start);
if (legacySegment != null) {
segments.push(legacySegment);
}
}
wire += slotWire;
} else {
display += child.getTextContent();
wire += child.getTextContent();
}
}
}
return {display, segments, wire};
}
export function $createComposerSegmentNodes(segment: MentionSegment, plainText: boolean): Array<LexicalNode> {
const commandState = parseSlashCommandStateSegment(segment);
if (commandState != null) {
if (plainText) {
return [$createComposerPlainSegmentNode(segment.type, segment.id, segment.displayText, segment.actualText)];
}
return [$createComposerCommandNode(segment.displayText, commandState.optionalOptions)];
}
const slashState = parseSlashSlotStateSegment(segment);
if (slashState != null) {
if (plainText) {
return [$createComposerPlainSegmentNode(segment.type, segment.id, segment.displayText, segment.actualText)];
}
const displayText = segment.displayText.slice(1);
const wireText = segment.actualText.slice(1);
const slot = $createSlashSlotNode(
slashState.optionName,
slashState.optionType,
slashState.required,
slashState.choices,
slashState.description,
)
.setAllowEmpty(slashState.allowEmpty == null ? false : slashState.allowEmpty)
.setTouched(slashState.touched)
.setValidity(slashState.validity)
.setValidationError(slashState.validationError);
if (slashState.validity === 'valid') {
if (displayText.length > 0) {
slot.append($createSlashSlotValueNode(slashState.optionType, displayText, wireText));
}
slot.setResolvedWire(wireText, displayText);
} else if (displayText.length > 0) {
slot.append($createTextNode(displayText));
}
return [$createSlashSeparatorNode(), slot];
}
if (plainText || segment.id.startsWith(COMPOSER_SLASH_SLOT_SEGMENT_PREFIX)) {
return [$createComposerPlainSegmentNode(segment.type, segment.id, segment.displayText, segment.actualText)];
}
if (segment.type === 'emoji') {
const match = CUSTOM_EMOJI_WIRE_RE.exec(segment.actualText);
if (match) {
return [$createComposerCustomEmojiNode(match[3]!, Boolean(match[1]), segment.displayText, segment.actualText)];
}
if (isStandardEmojiSegment(segment)) {
return [$createComposerStandardEmojiNode(segment.id, segment.actualText, null, segment.displayText)];
}
return [$createTextNode(segment.displayText)];
}
return [
$createComposerMentionNode(
segment.type as ComposerMentionType,
segment.id,
segment.displayText,
segment.actualText,
),
];
}
export function $hydrateComposerFromDraft(
display: string,
segments: ReadonlyArray<MentionSegment>,
plainText = false,
): void {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
const appendText = (text: string) => {
if (text.length === 0) {
return;
}
const parts = text.split('\n');
for (let i = 0; i < parts.length; i += 1) {
if (i > 0) {
paragraph.append($createLineBreakNode());
}
if (parts[i]!.length > 0) {
paragraph.append($createTextNode(parts[i]!));
}
}
};
const sorted = [...segments].sort((a, b) => a.start - b.start);
let cursor = 0;
for (const segment of sorted) {
if (!isValidComposerSegment(display, segment, cursor)) {
continue;
}
if (segment.start > cursor) {
appendText(display.slice(cursor, segment.start));
}
paragraph.append(...$createComposerSegmentNodes(segment, plainText));
cursor = segment.end;
}
if (cursor < display.length) {
appendText(display.slice(cursor));
}
}