mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {findBlockquoteMarkers} from '@app/features/lexical/composer/blockquoteLines';
|
|
import {
|
|
$rewriteMultilineBlockquoteMarker,
|
|
$snapSelectionOutOfBlockquoteMarker,
|
|
$splitComposerLines,
|
|
$syncComposerBlockquoteLines,
|
|
} from '@app/features/lexical/composer/ComposerBlockquote';
|
|
import {$captureSelectionOffsets, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets';
|
|
import {
|
|
computeMarkdownHighlightSpans,
|
|
MarkdownHl,
|
|
type MarkdownHlFormat,
|
|
type MarkdownSpan,
|
|
markSilentMessagePrefix,
|
|
} from '@app/features/lexical/composer/markdownSpans';
|
|
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
|
|
import {
|
|
$createComposerBlockquoteMarkerNode,
|
|
$isComposerBlockquoteMarkerNode,
|
|
} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
|
|
import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
|
|
import {$isComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
|
|
import {
|
|
$isComposerMentionNode,
|
|
ComposerMentionPresentation,
|
|
type ComposerMentionPresentationFormat,
|
|
} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
|
|
import {$isComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
|
|
import {$isComposerStandardEmojiNode} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
|
|
import {$isSlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode';
|
|
import {$createSyntaxMarkerNode, $isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
|
|
import {
|
|
$createNodeSelection,
|
|
$createTextNode,
|
|
$getNodeByKey,
|
|
$getSelection,
|
|
$isNodeSelection,
|
|
$isRangeSelection,
|
|
$setSelection,
|
|
type LexicalEditor,
|
|
type LexicalNode,
|
|
ParagraphNode,
|
|
RootNode,
|
|
type TextFormatType,
|
|
TextNode,
|
|
} from 'lexical';
|
|
|
|
const STYLE_BY_BIT: ReadonlyArray<{bit: number; style: string}> = [
|
|
{
|
|
bit: MarkdownHl.spoiler,
|
|
style: 'background-color:var(--background-modifier-active,rgba(0,0,0,0.15));border-radius:0.1875rem',
|
|
},
|
|
{bit: MarkdownHl.heading, style: 'font-weight:700'},
|
|
{bit: MarkdownHl.subtext, style: 'font-size:0.85em;color:var(--text-muted,var(--text-secondary))'},
|
|
{bit: MarkdownHl.link, style: 'color:var(--text-link)'},
|
|
{bit: MarkdownHl.codeBlock, style: 'font-size:0.75em'},
|
|
{bit: MarkdownHl.silent, style: 'color:var(--markup-mention-text);font-weight:500'},
|
|
];
|
|
|
|
const LEXICAL_TEXT_FORMATS: ReadonlyArray<{bit: number; type: TextFormatType}> = [
|
|
{bit: MarkdownHl.bold, type: 'bold'},
|
|
{bit: MarkdownHl.italic, type: 'italic'},
|
|
{bit: MarkdownHl.underline, type: 'underline'},
|
|
{bit: MarkdownHl.strike, type: 'strikethrough'},
|
|
{bit: MarkdownHl.code, type: 'code'},
|
|
];
|
|
|
|
function expectedStyle(format: MarkdownHlFormat): string {
|
|
const styles: Array<string> = [];
|
|
for (const {bit, style} of STYLE_BY_BIT) {
|
|
if (format & bit) {
|
|
styles.push(style);
|
|
}
|
|
}
|
|
return styles.join(';');
|
|
}
|
|
|
|
function applyMarkdownFormat(node: TextNode, format: MarkdownHlFormat): void {
|
|
for (const {bit, type} of LEXICAL_TEXT_FORMATS) {
|
|
if (format & bit) {
|
|
node.toggleFormat(type);
|
|
}
|
|
}
|
|
const style = expectedStyle(format);
|
|
if (style.length > 0) {
|
|
node.setStyle(style);
|
|
}
|
|
}
|
|
|
|
export function registerComposerMarkdownHighlight(
|
|
editor: LexicalEditor,
|
|
parserFlags?: number,
|
|
silentMessagePrefix = false,
|
|
maxWireLength?: number,
|
|
): () => void {
|
|
return editor.registerNodeTransform(RootNode, (root) => {
|
|
if (editor.isComposing()) {
|
|
return;
|
|
}
|
|
for (const child of root.getChildren()) {
|
|
if (child instanceof ParagraphNode) {
|
|
$reconcileParagraph(child, parserFlags, silentMessagePrefix && child.is(root.getFirstChild()), maxWireLength);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
type Desired =
|
|
| {role: 'marker'; text: string}
|
|
| {role: 'quote'; text: string}
|
|
| {role: 'content'; text: string; format: MarkdownHlFormat}
|
|
| {role: 'keep'; node: LexicalNode};
|
|
|
|
type BuildableDesired = Exclude<Desired, {role: 'keep'}>;
|
|
|
|
export function $reconcileLineOf(node: TextNode, parserFlags?: number): void {
|
|
const parent = node.getParent();
|
|
const block = $isComposerBlockquoteLineNode(parent) ? parent.getParent() : parent;
|
|
if (block == null || block.getType() !== 'paragraph') {
|
|
return;
|
|
}
|
|
$reconcileParagraph(block as ParagraphNode, parserFlags);
|
|
}
|
|
|
|
function $reconcileParagraph(
|
|
paragraph: ParagraphNode,
|
|
parserFlags?: number,
|
|
silentMessagePrefix = false,
|
|
maxWireLength?: number,
|
|
): void {
|
|
const lines = $splitComposerLines(paragraph);
|
|
const lineSources = lines.map((line) => line.nodes.map($nodeWireText).join(''));
|
|
const source = lineSources.join('\n');
|
|
const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags);
|
|
const {markerEnds: quoteMarkerEnds, multiline} = findBlockquoteMarkers(
|
|
source,
|
|
markdownSpans,
|
|
parserFlags,
|
|
maxWireLength,
|
|
);
|
|
if ($rewriteMultilineBlockquoteMarker(lines, multiline)) {
|
|
return;
|
|
}
|
|
const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans;
|
|
let lineStart = 0;
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const nodes = lines[index]!.nodes;
|
|
const lineSource = lineSources[index]!;
|
|
const lineEnd = lineStart + lineSource.length;
|
|
if (nodes.length > 0) {
|
|
const lineSpans = spans
|
|
.map((span) => ({
|
|
...span,
|
|
start: Math.max(span.start, lineStart) - lineStart,
|
|
end: Math.min(span.end, lineEnd) - lineStart,
|
|
}))
|
|
.filter((span) => span.end > span.start);
|
|
$reconcileLine(nodes, parserFlags, lineSource, lineSpans, quoteMarkerEnds[index]);
|
|
}
|
|
lineStart = lineEnd + 1;
|
|
}
|
|
$syncComposerBlockquoteLines(
|
|
paragraph,
|
|
quoteMarkerEnds.map((end) => end > 0),
|
|
);
|
|
if (quoteMarkerEnds.some((end) => end > 0)) {
|
|
$snapSelectionOutOfBlockquoteMarker();
|
|
}
|
|
}
|
|
|
|
function $isEscapedAtOffset(source: string, offset: number): boolean {
|
|
let backslashes = 0;
|
|
let cursor = offset - 1;
|
|
while (cursor >= 0 && source[cursor] === '\\') {
|
|
backslashes += 1;
|
|
cursor -= 1;
|
|
}
|
|
return backslashes % 2 === 1;
|
|
}
|
|
|
|
function $reconcileLine(
|
|
line: Array<LexicalNode>,
|
|
parserFlags?: number,
|
|
precomputedSource?: string,
|
|
precomputedSpans?: Array<MarkdownSpan>,
|
|
quoteMarkerEnd = 0,
|
|
): void {
|
|
const desired: Array<Desired> = [];
|
|
const source = precomputedSource == null ? line.map($nodeWireText).join('') : precomputedSource;
|
|
const spans = precomputedSpans == null ? computeMarkdownHighlightSpans(source, parserFlags) : precomputedSpans;
|
|
let sourceOffset = 0;
|
|
for (const node of line) {
|
|
const nodeText = $nodeWireText(node);
|
|
const nodeEnd = sourceOffset + nodeText.length;
|
|
if ($isComposerMentionNode(node) || $isComposerCustomEmojiNode(node) || $isComposerStandardEmojiNode(node)) {
|
|
const literal =
|
|
$isEscapedAtOffset(source, sourceOffset) ||
|
|
spans.some(
|
|
(span) =>
|
|
span.role === 'content' &&
|
|
(span.format & MarkdownHl.code) !== 0 &&
|
|
span.start <= sourceOffset &&
|
|
span.end >= nodeEnd,
|
|
);
|
|
if (node.isLiteral() !== literal) {
|
|
node.setLiteral(literal);
|
|
}
|
|
const spoiler = spans.some(
|
|
(span) =>
|
|
span.role === 'content' &&
|
|
(span.format & MarkdownHl.spoiler) !== 0 &&
|
|
span.start <= sourceOffset &&
|
|
span.end >= nodeEnd,
|
|
);
|
|
if (node.isSpoiler() !== spoiler) {
|
|
node.setSpoiler(spoiler);
|
|
}
|
|
}
|
|
if ($isComposerMentionNode(node)) {
|
|
const coveringSpan = spans.find(
|
|
(span) => span.role === 'content' && span.start <= sourceOffset && span.end >= nodeEnd,
|
|
);
|
|
const presentation = mentionPresentation(coveringSpan == null ? MarkdownHl.none : coveringSpan.format);
|
|
if (node.getPresentation() !== presentation) {
|
|
node.setPresentation(presentation);
|
|
}
|
|
}
|
|
if (node instanceof TextNode && !$isComposerPlainSegmentNode(node) && !$isComposerCommandNode(node)) {
|
|
for (const span of spans) {
|
|
const start = Math.max(sourceOffset, span.start);
|
|
const end = Math.min(nodeEnd, span.end);
|
|
if (end <= start) {
|
|
continue;
|
|
}
|
|
const text = source.slice(start, end);
|
|
const previous = desired[desired.length - 1];
|
|
if (span.role === 'marker' && span.start < quoteMarkerEnd) {
|
|
if (previous != null && previous.role === 'quote') {
|
|
previous.text += text;
|
|
} else {
|
|
desired.push({role: 'quote', text});
|
|
}
|
|
} else {
|
|
desired.push(span.role === 'marker' ? {role: 'marker', text} : {role: 'content', text, format: span.format});
|
|
}
|
|
}
|
|
} else {
|
|
desired.push({role: 'keep', node});
|
|
}
|
|
sourceOffset += nodeText.length;
|
|
}
|
|
if (desired.length === 0) {
|
|
return;
|
|
}
|
|
if ($descriptorsMatch(line, desired)) {
|
|
return;
|
|
}
|
|
const currentSelection = $getSelection();
|
|
const selectionKeys = $isRangeSelection(currentSelection)
|
|
? {anchor: currentSelection.anchor.key, focus: currentSelection.focus.key}
|
|
: null;
|
|
const nodeSelectionKeys = $isNodeSelection(currentSelection)
|
|
? currentSelection.getNodes().map((node) => node.getKey())
|
|
: null;
|
|
const selection = $captureSelectionOffsets();
|
|
$applyDescriptors(line, desired);
|
|
if (nodeSelectionKeys != null) {
|
|
if (nodeSelectionKeys.every((key) => $getNodeByKey(key)?.isAttached() === true)) {
|
|
const restored = $createNodeSelection();
|
|
for (const key of nodeSelectionKeys) {
|
|
restored.add(key);
|
|
}
|
|
$setSelection(restored);
|
|
}
|
|
return;
|
|
}
|
|
const anchorNode = selectionKeys == null ? null : $getNodeByKey(selectionKeys.anchor);
|
|
const focusNode = selectionKeys == null ? null : $getNodeByKey(selectionKeys.focus);
|
|
const selectionNodesSurvived =
|
|
selectionKeys != null &&
|
|
anchorNode != null &&
|
|
anchorNode.isAttached() === true &&
|
|
focusNode != null &&
|
|
focusNode.isAttached() === true;
|
|
if (selection != null && !selectionNodesSurvived) {
|
|
$selectComposerRange(selection.anchor, selection.focus);
|
|
}
|
|
}
|
|
|
|
function mentionPresentation(format: MarkdownHlFormat): ComposerMentionPresentationFormat {
|
|
let presentation = ComposerMentionPresentation.none;
|
|
if ((format & MarkdownHl.italic) !== 0) {
|
|
presentation |= ComposerMentionPresentation.italic;
|
|
}
|
|
if ((format & MarkdownHl.underline) !== 0) {
|
|
presentation |= ComposerMentionPresentation.underline;
|
|
}
|
|
if ((format & MarkdownHl.strike) !== 0) {
|
|
presentation |= ComposerMentionPresentation.strike;
|
|
}
|
|
if ((format & MarkdownHl.subtext) !== 0) {
|
|
presentation |= ComposerMentionPresentation.subtext;
|
|
}
|
|
return presentation;
|
|
}
|
|
|
|
export function $nodeWireText(node: LexicalNode): string {
|
|
if (
|
|
$isComposerMentionNode(node) ||
|
|
$isComposerCustomEmojiNode(node) ||
|
|
$isComposerPlainSegmentNode(node) ||
|
|
$isSlashSlotNode(node)
|
|
) {
|
|
return node.getWireText();
|
|
}
|
|
return node.getTextContent();
|
|
}
|
|
|
|
function $contentNodeMatchesFormat(node: TextNode, format: MarkdownHlFormat): boolean {
|
|
for (const {bit, type} of LEXICAL_TEXT_FORMATS) {
|
|
if (node.hasFormat(type) !== ((format & bit) !== 0)) {
|
|
return false;
|
|
}
|
|
}
|
|
return node.getStyle() === expectedStyle(format);
|
|
}
|
|
|
|
function $descriptorsMatch(line: Array<LexicalNode>, desired: Array<Desired>): boolean {
|
|
if (line.length !== desired.length) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < desired.length; i += 1) {
|
|
const want = desired[i]!;
|
|
const node = line[i]!;
|
|
if (want.role === 'keep') {
|
|
if (node.getKey() !== want.node.getKey()) {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
if (!(node instanceof TextNode) || node.getTextContent() !== want.text) {
|
|
return false;
|
|
}
|
|
if (want.role === 'quote') {
|
|
if (!$isComposerBlockquoteMarkerNode(node)) {
|
|
return false;
|
|
}
|
|
} else if (want.role === 'marker') {
|
|
if (!$isSyntaxMarkerNode(node) || $isComposerBlockquoteMarkerNode(node)) {
|
|
return false;
|
|
}
|
|
} else if ($isSyntaxMarkerNode(node) || !$contentNodeMatchesFormat(node, want.format)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function $buildDescriptorNode(desired: BuildableDesired): TextNode {
|
|
if (desired.role === 'quote') {
|
|
return $createComposerBlockquoteMarkerNode(desired.text);
|
|
}
|
|
if (desired.role === 'marker') {
|
|
return $createSyntaxMarkerNode(desired.text);
|
|
}
|
|
const node = $createTextNode(desired.text);
|
|
applyMarkdownFormat(node, desired.format);
|
|
return node;
|
|
}
|
|
|
|
function $applyDescriptors(line: Array<LexicalNode>, desired: Array<Desired>): void {
|
|
const lineSegments: Array<Array<LexicalNode>> = [];
|
|
const desiredSegments: Array<Array<BuildableDesired>> = [];
|
|
let lineSegment: Array<LexicalNode> = [];
|
|
let desiredSegment: Array<BuildableDesired> = [];
|
|
for (const node of line) {
|
|
if (node instanceof TextNode && !$isComposerPlainSegmentNode(node) && !$isComposerCommandNode(node)) {
|
|
lineSegment.push(node);
|
|
} else {
|
|
lineSegments.push(lineSegment);
|
|
lineSegment = [];
|
|
}
|
|
}
|
|
lineSegments.push(lineSegment);
|
|
for (const want of desired) {
|
|
if (want.role === 'keep') {
|
|
desiredSegments.push(desiredSegment);
|
|
desiredSegment = [];
|
|
} else {
|
|
desiredSegment.push(want);
|
|
}
|
|
}
|
|
desiredSegments.push(desiredSegment);
|
|
for (let i = 0; i < lineSegments.length; i += 1) {
|
|
$replaceSegment(lineSegments[i]!, desiredSegments[i] == null ? [] : desiredSegments[i]!);
|
|
}
|
|
}
|
|
|
|
function $replaceSegment(oldNodes: Array<LexicalNode>, descriptors: Array<BuildableDesired>): void {
|
|
if (oldNodes.length === 0) {
|
|
return;
|
|
}
|
|
const anchor = oldNodes[0]!;
|
|
let previous: LexicalNode | null = null;
|
|
for (const descriptor of descriptors) {
|
|
const built = $buildDescriptorNode(descriptor);
|
|
if (previous == null) {
|
|
anchor.insertBefore(built);
|
|
} else {
|
|
previous.insertAfter(built);
|
|
}
|
|
previous = built;
|
|
}
|
|
for (const old of oldNodes) {
|
|
old.remove();
|
|
}
|
|
}
|