mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
626 lines
21 KiB
TypeScript
626 lines
21 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {type BlockquoteLine, blockquoteContentRange} from '@app/features/lexical/composer/blockquoteLines';
|
|
import {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap';
|
|
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
|
|
import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
|
|
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 {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
|
|
import {
|
|
$createParagraphNode,
|
|
$createRangeSelection,
|
|
$createTextNode,
|
|
$getRoot,
|
|
$getSelection,
|
|
$isElementNode,
|
|
$isLineBreakNode,
|
|
$isRangeSelection,
|
|
$setSelection,
|
|
type ElementNode,
|
|
type LexicalEditor,
|
|
type LexicalNode,
|
|
type NodeKey,
|
|
type PointType,
|
|
TextNode,
|
|
} from 'lexical';
|
|
import invariant from 'tiny-invariant';
|
|
|
|
export type ComposerInsertPayload =
|
|
| {kind: 'text'; text: string}
|
|
| {kind: 'mention'; mentionType: ComposerMentionType; id: string; display: string; wire: string}
|
|
| {kind: 'customEmoji'; emojiId: string; animated: boolean; display: string; wire: string}
|
|
| {kind: 'standardEmoji'; name: string; surrogate: string; url: string | null; display: string};
|
|
|
|
function $firstParagraph(): ElementNode {
|
|
const root = $getRoot();
|
|
const first = root.getFirstChild();
|
|
if (first != null && first.getType() === 'paragraph') {
|
|
return first as ElementNode;
|
|
}
|
|
const paragraph = $createParagraphNode();
|
|
root.clear();
|
|
root.append(paragraph);
|
|
return paragraph;
|
|
}
|
|
|
|
interface DisplayPoint {
|
|
key: NodeKey;
|
|
offset: number;
|
|
type: 'text' | 'element';
|
|
}
|
|
|
|
interface DisplayLeaf {
|
|
node: LexicalNode;
|
|
start: number;
|
|
end: number;
|
|
before: DisplayPoint;
|
|
after: DisplayPoint;
|
|
}
|
|
|
|
interface DisplayLayout {
|
|
text: string;
|
|
leaves: Array<DisplayLeaf>;
|
|
elementBoundaries: Map<NodeKey, Array<number>>;
|
|
}
|
|
|
|
export interface ComposerSelectionOffsets {
|
|
anchor: number;
|
|
focus: number;
|
|
}
|
|
|
|
export interface ComposerSelectionWrapperQuery {
|
|
prefix: string;
|
|
suffix: string;
|
|
}
|
|
|
|
export interface ComposerSelectionWrapperResult {
|
|
offsets: ComposerSelectionOffsets | null;
|
|
wrapped: Array<boolean>;
|
|
}
|
|
|
|
function $buildDisplayLayout(): DisplayLayout {
|
|
const root = $getRoot();
|
|
const chunks: Array<string> = [];
|
|
const leaves: Array<DisplayLeaf> = [];
|
|
const elementBoundaries = new Map<NodeKey, Array<number>>();
|
|
let offset = 0;
|
|
const walkElement = (element: ElementNode) => {
|
|
const children = element.getChildren();
|
|
const boundaries: Array<number> = [offset];
|
|
for (let index = 0; index < children.length; index += 1) {
|
|
const child = children[index]!;
|
|
if ($isElementNode(child)) {
|
|
walkElement(child);
|
|
} else {
|
|
const text = $isLineBreakNode(child) ? '\n' : child.getTextContent();
|
|
const start = offset;
|
|
chunks.push(text);
|
|
offset += text.length;
|
|
leaves.push({
|
|
node: child,
|
|
start,
|
|
end: offset,
|
|
before: {key: element.getKey(), offset: index, type: 'element'},
|
|
after: {key: element.getKey(), offset: index + 1, type: 'element'},
|
|
});
|
|
}
|
|
boundaries.push(offset);
|
|
}
|
|
elementBoundaries.set(element.getKey(), boundaries);
|
|
};
|
|
const blocks = root.getChildren();
|
|
const rootBoundaries: Array<number> = [0];
|
|
for (let index = 0; index < blocks.length; index += 1) {
|
|
if (index > 0) {
|
|
chunks.push('\n');
|
|
offset += 1;
|
|
rootBoundaries[index] = offset;
|
|
}
|
|
const block = blocks[index]!;
|
|
if ($isElementNode(block)) {
|
|
walkElement(block);
|
|
} else {
|
|
const start = offset;
|
|
const text = block.getTextContent();
|
|
chunks.push(text);
|
|
offset += text.length;
|
|
leaves.push({
|
|
node: block,
|
|
start,
|
|
end: offset,
|
|
before: {key: root.getKey(), offset: index, type: 'element'},
|
|
after: {key: root.getKey(), offset: index + 1, type: 'element'},
|
|
});
|
|
}
|
|
rootBoundaries[index + 1] = offset;
|
|
}
|
|
elementBoundaries.set(root.getKey(), rootBoundaries);
|
|
return {text: chunks.join(''), leaves, elementBoundaries};
|
|
}
|
|
|
|
function pointToDisplayOffset(
|
|
layout: DisplayLayout,
|
|
pointNode: LexicalNode,
|
|
pointOffset: number,
|
|
pointType: 'text' | 'element',
|
|
): number {
|
|
if (pointType === 'text') {
|
|
const leaf = layout.leaves.find((candidate) => candidate.node.getKey() === pointNode.getKey());
|
|
invariant(leaf != null, 'Composer selection points to a text node outside the editor display');
|
|
return leaf.start + Math.min(Math.max(0, pointOffset), leaf.end - leaf.start);
|
|
}
|
|
const boundaries = layout.elementBoundaries.get(pointNode.getKey());
|
|
invariant(boundaries != null, 'Composer selection points to an element outside the editor display');
|
|
return boundaries[Math.min(Math.max(0, pointOffset), boundaries.length - 1)]!;
|
|
}
|
|
|
|
export function $getComposerDisplayOffset(): number | null {
|
|
return $getComposerDisplayOffsetFromLayout($buildDisplayLayout());
|
|
}
|
|
|
|
function $getComposerDisplayOffsetFromLayout(layout: DisplayLayout): number | null {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return null;
|
|
}
|
|
const {anchor} = selection;
|
|
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 {
|
|
const layout = $buildDisplayLayout();
|
|
const leaf = layout.leaves.find((candidate) => candidate.node.getKey() === node.getKey());
|
|
return leaf ? leaf.start : null;
|
|
}
|
|
|
|
export function $captureSelectionOffsets(): ComposerSelectionOffsets | null {
|
|
return $captureSelectionOffsetsFromLayout($buildDisplayLayout());
|
|
}
|
|
|
|
function $captureSelectionOffsetsFromLayout(layout: DisplayLayout): ComposerSelectionOffsets | null {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return null;
|
|
}
|
|
return {
|
|
anchor: pointToDisplayOffset(layout, selection.anchor.getNode(), selection.anchor.offset, selection.anchor.type),
|
|
focus: pointToDisplayOffset(layout, selection.focus.getNode(), selection.focus.offset, selection.focus.type),
|
|
};
|
|
}
|
|
|
|
export interface ComposerBlockquoteState {
|
|
scanText: string;
|
|
selection: ComposerSelectionOffsets | null;
|
|
lines: Array<BlockquoteLine>;
|
|
atoms: Array<{start: number; end: number}>;
|
|
}
|
|
|
|
function blockquoteLinesFromLayout(layout: DisplayLayout): Array<BlockquoteLine> {
|
|
const lines: Array<BlockquoteLine> = [];
|
|
for (const leaf of layout.leaves) {
|
|
const parent = leaf.node.getParent();
|
|
if (
|
|
!$isComposerBlockquoteMarkerNode(leaf.node) ||
|
|
!$isComposerBlockquoteLineNode(parent) ||
|
|
!leaf.node.is(parent.getFirstChild())
|
|
) {
|
|
continue;
|
|
}
|
|
const boundaries = layout.elementBoundaries.get(parent.getKey());
|
|
if (boundaries == null) {
|
|
continue;
|
|
}
|
|
lines.push({start: leaf.start, contentStart: leaf.end, end: boundaries[boundaries.length - 1]!});
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
export function $getComposerBlockquoteState(): ComposerBlockquoteState {
|
|
const layout = $buildDisplayLayout();
|
|
return {
|
|
scanText: scanTextFromLayout(layout),
|
|
selection: $captureSelectionOffsetsFromLayout(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 {
|
|
const offsets = $captureSelectionOffsets();
|
|
if (offsets == null) {
|
|
return null;
|
|
}
|
|
return {start: Math.min(offsets.anchor, offsets.focus), end: Math.max(offsets.anchor, offsets.focus)};
|
|
}
|
|
|
|
export function $getTextUpToCursor(): string {
|
|
const layout = $buildDisplayLayout();
|
|
const offset = $getComposerDisplayOffsetFromLayout(layout);
|
|
if (offset == null) {
|
|
return '';
|
|
}
|
|
return layout.text.slice(0, offset);
|
|
}
|
|
|
|
export function $getComposerDisplayText(): string {
|
|
return $buildDisplayLayout().text;
|
|
}
|
|
|
|
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 offset = 0;
|
|
for (const {start, end} of atomRangesFromLayout(layout)) {
|
|
scanText += layout.text.slice(offset, start) + 'x'.repeat(end - start);
|
|
offset = end;
|
|
}
|
|
return scanText + layout.text.slice(offset);
|
|
}
|
|
|
|
export function $getComposerScanText(): string {
|
|
return scanTextFromLayout($buildDisplayLayout());
|
|
}
|
|
|
|
function $pointAtDisplayOffset(layout: DisplayLayout, offset: number): DisplayPoint {
|
|
const clampedOffset = Math.min(Math.max(0, offset), layout.text.length);
|
|
for (const leaf of layout.leaves) {
|
|
if (leaf.node instanceof TextNode) {
|
|
if (clampedOffset > leaf.start && clampedOffset < leaf.end) {
|
|
return {key: leaf.node.getKey(), offset: clampedOffset - leaf.start, type: 'text'};
|
|
}
|
|
}
|
|
}
|
|
for (const leaf of layout.leaves) {
|
|
if (leaf.node instanceof TextNode && !$isSyntaxMarkerNode(leaf.node) && clampedOffset === leaf.start) {
|
|
return {key: leaf.node.getKey(), offset: 0, type: 'text'};
|
|
}
|
|
}
|
|
for (const leaf of layout.leaves) {
|
|
if (leaf.node instanceof TextNode && !$isSyntaxMarkerNode(leaf.node) && clampedOffset === leaf.end) {
|
|
return {key: leaf.node.getKey(), offset: leaf.end - leaf.start, type: 'text'};
|
|
}
|
|
}
|
|
for (const leaf of layout.leaves) {
|
|
if (clampedOffset <= leaf.start) {
|
|
return leaf.before;
|
|
}
|
|
if (clampedOffset <= leaf.end) {
|
|
return leaf.after;
|
|
}
|
|
}
|
|
const root = $getRoot();
|
|
const first = root.getFirstChild();
|
|
if (first != null && $isElementNode(first) && first.getChildrenSize() === 0) {
|
|
return {key: first.getKey(), offset: 0, type: 'element'};
|
|
}
|
|
return {key: root.getKey(), offset: root.getChildrenSize(), type: 'element'};
|
|
}
|
|
|
|
function $selectComposerRangeWithLayout(layout: DisplayLayout, anchor: number, focus: number): void {
|
|
const anchorPoint = $pointAtDisplayOffset(layout, anchor);
|
|
const focusPoint = $pointAtDisplayOffset(layout, focus);
|
|
const selection = $createRangeSelection();
|
|
selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type);
|
|
selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type);
|
|
$setSelection(selection);
|
|
}
|
|
|
|
export function $selectComposerRange(anchor: number, focus: number): void {
|
|
if ($getRoot().getChildrenSize() === 0) {
|
|
$firstParagraph();
|
|
}
|
|
$selectComposerRangeWithLayout($buildDisplayLayout(), anchor, focus);
|
|
}
|
|
|
|
export function $selectComposerOffset(offset: number): void {
|
|
$selectComposerRange(offset, offset);
|
|
}
|
|
|
|
export function $selectComposerNodeBoundary(node: LexicalNode, boundary: 'before' | 'after'): void {
|
|
const layout = $buildDisplayLayout();
|
|
const leaf = layout.leaves.find((candidate) => candidate.node.is(node));
|
|
if (leaf == null) {
|
|
$selectComposerRangeWithLayout(layout, layout.text.length, layout.text.length);
|
|
return;
|
|
}
|
|
const offset = boundary === 'before' ? leaf.start : leaf.end;
|
|
$selectComposerRangeWithLayout(layout, offset, offset);
|
|
}
|
|
|
|
export function $createComposerInsertNode(payload: ComposerInsertPayload, plainText: boolean): LexicalNode {
|
|
switch (payload.kind) {
|
|
case 'mention':
|
|
if (plainText) {
|
|
return $createComposerPlainSegmentNode(payload.mentionType, payload.id, payload.display, payload.wire);
|
|
}
|
|
return $createComposerMentionNode(payload.mentionType, payload.id, payload.display, payload.wire);
|
|
case 'customEmoji':
|
|
if (plainText) {
|
|
return $createComposerPlainSegmentNode('emoji', payload.emojiId, payload.display, payload.wire);
|
|
}
|
|
return $createComposerCustomEmojiNode(payload.emojiId, payload.animated, payload.display, payload.wire);
|
|
case 'standardEmoji':
|
|
if (plainText) {
|
|
return $createTextNode(payload.display);
|
|
}
|
|
return $createComposerStandardEmojiNode(payload.name, payload.surrogate, payload.url, payload.display);
|
|
default:
|
|
return $createTextNode(payload.text);
|
|
}
|
|
}
|
|
|
|
export interface ComposerInsertSpacing {
|
|
leading?: boolean;
|
|
trailing?: boolean;
|
|
}
|
|
|
|
export function $replaceComposerRange(
|
|
start: number,
|
|
end: number,
|
|
payload: ComposerInsertPayload,
|
|
spacing?: ComposerInsertSpacing,
|
|
plainText = false,
|
|
): void {
|
|
const leading = spacing == null || spacing.leading == null ? false : spacing.leading;
|
|
const trailing = spacing == null || spacing.trailing == null ? payload.kind !== 'text' : spacing.trailing;
|
|
const orderedStart = Math.min(start, end);
|
|
const orderedEnd = Math.max(start, end);
|
|
$selectComposerRange(orderedStart, orderedEnd);
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection)) {
|
|
return;
|
|
}
|
|
if (payload.kind === 'text') {
|
|
const text = `${leading ? ' ' : ''}${payload.text}${trailing ? ' ' : ''}`;
|
|
if (text.length === 0) {
|
|
selection.insertText('');
|
|
} else {
|
|
selection.insertRawText(text);
|
|
}
|
|
$selectComposerOffset(orderedStart + (leading ? 1 : 0) + payload.text.length + (trailing ? 1 : 0));
|
|
return;
|
|
}
|
|
const node = $createComposerInsertNode(payload, plainText);
|
|
const nodes: Array<LexicalNode> = [];
|
|
if (leading) nodes.push($createTextNode(' '));
|
|
nodes.push(node);
|
|
if (trailing) nodes.push($createTextNode(' '));
|
|
if (nodes.length > 0) {
|
|
selection.insertNodes(nodes);
|
|
} else {
|
|
selection.insertText('');
|
|
}
|
|
$selectComposerOffset(orderedStart + (leading ? 1 : 0) + node.getTextContent().length + (trailing ? 1 : 0));
|
|
}
|
|
|
|
type WrapClass = 'inside' | 'flank' | 'none';
|
|
|
|
function countCharacterRunForward(text: string, start: number, character: string): number {
|
|
let end = start;
|
|
while (end < text.length && text[end] === character) {
|
|
end += 1;
|
|
}
|
|
return end - start;
|
|
}
|
|
|
|
function countCharacterRunBackward(text: string, end: number, character: string): number {
|
|
let start = end;
|
|
while (start > 0 && text[start - 1] === character) {
|
|
start -= 1;
|
|
}
|
|
return end - start;
|
|
}
|
|
|
|
function asteriskRunContainsFormat(runLength: number, markerLength: number): boolean {
|
|
return markerLength === 1 ? runLength % 2 === 1 : runLength >= 2;
|
|
}
|
|
|
|
function classifyAsteriskWrap(display: string, start: number, end: number, markerLength: number): WrapClass {
|
|
const selectedLeading = countCharacterRunForward(display, start, '*');
|
|
const selectedTrailing = countCharacterRunBackward(display, end, '*');
|
|
if (
|
|
asteriskRunContainsFormat(selectedLeading, markerLength) &&
|
|
asteriskRunContainsFormat(selectedTrailing, markerLength) &&
|
|
end - start >= selectedLeading + selectedTrailing
|
|
) {
|
|
return 'inside';
|
|
}
|
|
const flankingLeading = countCharacterRunBackward(display, start, '*');
|
|
const flankingTrailing = countCharacterRunForward(display, end, '*');
|
|
if (
|
|
asteriskRunContainsFormat(flankingLeading, markerLength) &&
|
|
asteriskRunContainsFormat(flankingTrailing, markerLength)
|
|
) {
|
|
return 'flank';
|
|
}
|
|
return 'none';
|
|
}
|
|
|
|
function classifyWrap(display: string, start: number, end: number, prefix: string, suffix: string): WrapClass {
|
|
if (prefix === suffix && (prefix === '*' || prefix === '**')) {
|
|
return classifyAsteriskWrap(display, start, end, prefix.length);
|
|
}
|
|
const selected = display.slice(start, end);
|
|
if (selected.length >= prefix.length + suffix.length && selected.startsWith(prefix) && selected.endsWith(suffix)) {
|
|
return 'inside';
|
|
}
|
|
const before = display.slice(Math.max(0, start - prefix.length), start);
|
|
const after = display.slice(end, end + suffix.length);
|
|
if (start >= prefix.length && before === prefix && after === suffix) {
|
|
return 'flank';
|
|
}
|
|
return 'none';
|
|
}
|
|
|
|
function codeBlockPlanFor(
|
|
layout: DisplayLayout,
|
|
lines: ReadonlyArray<BlockquoteLine>,
|
|
start: number,
|
|
end: number,
|
|
prefix: string,
|
|
suffix: string,
|
|
): CodeBlockWrapPlan | null {
|
|
if (prefix !== '`' || suffix !== '`' || lines.some((line) => line.start < end && start < line.end)) {
|
|
return null;
|
|
}
|
|
const plan = planCodeBlockWrap(scanTextFromLayout(layout), start, end);
|
|
if (plan != null && !plan.wrapped && classifyWrap(layout.text, start, end, prefix, suffix) === 'flank') {
|
|
return null;
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
export function $isComposerSelectionWrapped(prefix: string, suffix: string): boolean {
|
|
const wrapped = $queryComposerSelectionWrappers([{prefix, suffix}]).wrapped[0];
|
|
return wrapped == null ? false : wrapped;
|
|
}
|
|
|
|
export function $queryComposerSelectionWrappers(
|
|
queries: ReadonlyArray<ComposerSelectionWrapperQuery>,
|
|
): ComposerSelectionWrapperResult {
|
|
const layout = $buildDisplayLayout();
|
|
const offsets = $captureSelectionOffsetsFromLayout(layout);
|
|
if (offsets == null) {
|
|
return {offsets: null, wrapped: queries.map(() => false)};
|
|
}
|
|
const lines = blockquoteLinesFromLayout(layout);
|
|
const {start, end} = blockquoteContentRange(lines, offsets.anchor, offsets.focus);
|
|
if (start === end) {
|
|
return {offsets, wrapped: queries.map(() => false)};
|
|
}
|
|
return {
|
|
offsets,
|
|
wrapped: queries.map(({prefix, suffix}) => {
|
|
const plan = codeBlockPlanFor(layout, lines, start, end, prefix, suffix);
|
|
return plan == null ? classifyWrap(layout.text, start, end, prefix, suffix) !== 'none' : plan.wrapped;
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function $wrapComposerSelection(prefix: string, suffix: string): void {
|
|
const layout = $buildDisplayLayout();
|
|
const offsets = $captureSelectionOffsetsFromLayout(layout);
|
|
if (offsets == null) {
|
|
return;
|
|
}
|
|
const lines = blockquoteLinesFromLayout(layout);
|
|
const {start, end} = blockquoteContentRange(lines, offsets.anchor, offsets.focus);
|
|
const backward = offsets.anchor > offsets.focus;
|
|
const display = layout.text;
|
|
const selected = display.slice(start, end);
|
|
const replaceTextRange = (rangeStart: number, rangeEnd: number, text: string) => {
|
|
$replaceComposerRange(rangeStart, rangeEnd, {kind: 'text', text}, {leading: false, trailing: false});
|
|
};
|
|
const restoreSelection = (nextStart: number, nextEnd: number) => {
|
|
$selectComposerRange(backward ? nextEnd : nextStart, backward ? nextStart : nextEnd);
|
|
};
|
|
const plan = codeBlockPlanFor(layout, lines, start, end, prefix, suffix);
|
|
if (plan != null) {
|
|
replaceTextRange(plan.closing.start, plan.closing.end, plan.closing.text);
|
|
replaceTextRange(plan.opening.start, plan.opening.end, plan.opening.text);
|
|
restoreSelection(plan.selectionStart, plan.selectionEnd);
|
|
return;
|
|
}
|
|
switch (classifyWrap(display, start, end, prefix, suffix)) {
|
|
case 'inside': {
|
|
replaceTextRange(end - suffix.length, end, '');
|
|
replaceTextRange(start, start + prefix.length, '');
|
|
restoreSelection(start, end - prefix.length - suffix.length);
|
|
return;
|
|
}
|
|
case 'flank': {
|
|
replaceTextRange(end, end + suffix.length, '');
|
|
replaceTextRange(start - prefix.length, start, '');
|
|
restoreSelection(start - prefix.length, end - prefix.length);
|
|
return;
|
|
}
|
|
default: {
|
|
if (prefix === '`' && suffix === '`' && selected.includes('\n')) {
|
|
return;
|
|
}
|
|
replaceTextRange(end, end, suffix);
|
|
replaceTextRange(start, start, prefix);
|
|
selected.length === 0
|
|
? $selectComposerOffset(start + prefix.length)
|
|
: restoreSelection(start + prefix.length, end + prefix.length);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function $isComposerEmpty(): boolean {
|
|
return $buildDisplayLayout().text.length === 0;
|
|
}
|