// 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 = []; 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; 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, parserFlags?: number, precomputedSource?: string, precomputedSpans?: Array, quoteMarkerEnd = 0, ): void { const desired: Array = []; 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, desired: Array): 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, desired: Array): void { const lineSegments: Array> = []; const desiredSegments: Array> = []; let lineSegment: Array = []; let desiredSegment: Array = []; 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, descriptors: Array): 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(); } }