Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions desktop/src/features/messages/lib/selectionBlockFormatting.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";

import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { EditorState, TextSelection } from "@tiptap/pm/state";

import { isolateSelectionForBlockFormatting } from "./selectionBlockFormatting.ts";

// Matching useRichTextEditor's StarterKit configuration (minus things
// irrelevant to block isolation).
const schema = getSchema([
StarterKit.configure({
hardBreak: { keepMarks: true },
heading: false,
trailingNode: false,
link: false,
}),
]);

const para = (...content) => schema.nodes.paragraph.create(null, content);
const br = () => schema.nodes.hardBreak.create();
const t = (text) => schema.text(text);

function doc(...content) {
return schema.nodes.doc.create(null, content);
}

function stateWithCaret(documentNode, caret) {
return EditorState.create({
doc: documentNode,
selection: TextSelection.create(documentNode, caret),
});
}

function paragraphTexts(documentNode) {
const texts = [];
documentNode.forEach((node) => {
texts.push(node.textContent);
});
return texts;
}

test("caret between hard breaks isolates only its line", () => {
// <p>before␍target␍after</p> with the caret inside "target".
const state = stateWithCaret(
doc(para(t("before"), br(), t("target"), br(), t("after"))),
10,
);

const transaction = state.tr;
assert.equal(isolateSelectionForBlockFormatting(transaction), true);

const next = state.apply(transaction);
assert.deepEqual(paragraphTexts(next.doc), ["before", "target", "after"]);
assert.equal(next.selection.empty, true);
assert.equal(next.selection.$from.parent.textContent, "target");
});

test("caret on an empty trailing line isolates an empty paragraph", () => {
// "before" + Shift+Enter, caret at the end — the reported bug shape.
const state = stateWithCaret(doc(para(t("before"), br())), 8);

const transaction = state.tr;
assert.equal(isolateSelectionForBlockFormatting(transaction), true);

const next = state.apply(transaction);
assert.deepEqual(paragraphTexts(next.doc), ["before", ""]);
assert.equal(next.selection.empty, true);
assert.equal(next.selection.$from.parent.textContent, "");
});

test("caret on the first line splits only after that line", () => {
const state = stateWithCaret(doc(para(t("first"), br(), t("rest"))), 3);

const transaction = state.tr;
assert.equal(isolateSelectionForBlockFormatting(transaction), true);

const next = state.apply(transaction);
assert.deepEqual(paragraphTexts(next.doc), ["first", "rest"]);
assert.equal(next.selection.$from.parent.textContent, "first");
});

test("caret on the last line splits only before that line", () => {
const state = stateWithCaret(doc(para(t("rest"), br(), t("last"))), 8);

const transaction = state.tr;
assert.equal(isolateSelectionForBlockFormatting(transaction), true);

const next = state.apply(transaction);
assert.deepEqual(paragraphTexts(next.doc), ["rest", "last"]);
assert.equal(next.selection.$from.parent.textContent, "last");
});

test("caret in a single-line paragraph is a no-op", () => {
const state = stateWithCaret(doc(para(t("only line"))), 4);

const transaction = state.tr;
assert.equal(isolateSelectionForBlockFormatting(transaction), false);
assert.equal(transaction.steps.length, 0);
});

test("selection isolation still splits around the selected text", () => {
const documentNode = doc(para(t("before selected after")));
const state = EditorState.create({
doc: documentNode,
// "selected" spans positions 8..16.
selection: TextSelection.create(documentNode, 8, 16),
});

const transaction = state.tr;
assert.equal(isolateSelectionForBlockFormatting(transaction), true);

const next = state.apply(transaction);
assert.deepEqual(paragraphTexts(next.doc), ["before ", "selected", " after"]);
assert.equal(
next.doc.textBetween(next.selection.from, next.selection.to),
"selected",
);
});
168 changes: 162 additions & 6 deletions desktop/src/features/messages/lib/selectionBlockFormatting.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TextSelection, type Transaction } from "@tiptap/pm/state";
import { canSplit } from "@tiptap/pm/transform";

Expand Down Expand Up @@ -29,27 +30,92 @@ function mapRangeThroughLatestStep(
}

/**
* Isolate a non-empty text selection at exact block boundaries.
* Isolate the hard-break-delimited line under a collapsed caret.
*
* The composer represents Shift+Enter lines as `hardBreak` nodes inside one
* paragraph, so a block toggle at a collapsed caret otherwise reformats every
* line of the draft. Replacing the line's bordering hard breaks with block
* splits gives the caret's line its own textblock, which scopes the following
* block toggle to just that line.
*/
function isolateCaretLineForBlockFormatting(transaction: Transaction): boolean {
const { $from } = transaction.selection;
if (!$from.parent.isTextblock || !$from.parent.inlineContent) return false;

const blockStart = $from.start();
const blockEnd = $from.end();
let caret = transaction.selection.from;

let lineFrom = blockStart;
let lineTo = blockEnd;
$from.parent.forEach((child, offset) => {
if (child.type.name !== "hardBreak") return;
const breakFrom = blockStart + offset;
const breakTo = breakFrom + child.nodeSize;
if (breakTo <= caret) lineFrom = breakTo;
if (breakFrom >= caret) lineTo = Math.min(lineTo, breakFrom);
});

// No hard breaks around the caret — the line already is the whole
// textblock, so the block toggle is correctly scoped as-is.
if (lineFrom === blockStart && lineTo === blockEnd) return false;

const nodeAfterLine = transaction.doc.resolve(lineTo).nodeAfter;
if (nodeAfterLine?.type.name === "hardBreak") {
transaction.delete(lineTo, lineTo + nodeAfterLine.nodeSize);
if (canSplit(transaction.doc, lineTo)) {
transaction.split(lineTo);
const stepMap = transaction.steps.at(-1)?.getMap();
if (stepMap) {
caret = stepMap.map(caret, -1);
lineFrom = stepMap.map(lineFrom, -1);
}
}
}

const nodeBeforeLine = transaction.doc.resolve(lineFrom).nodeBefore;
if (nodeBeforeLine?.type.name === "hardBreak") {
transaction.delete(lineFrom - nodeBeforeLine.nodeSize, lineFrom);
let stepMap = transaction.steps.at(-1)?.getMap();
if (stepMap) {
caret = stepMap.map(caret, 1);
lineFrom = stepMap.map(lineFrom, -1);
}
if (canSplit(transaction.doc, lineFrom)) {
transaction.split(lineFrom);
stepMap = transaction.steps.at(-1)?.getMap();
if (stepMap) caret = stepMap.map(caret, 1);
}
}

transaction.setSelection(TextSelection.create(transaction.doc, caret));
return true;
}

/**
* Isolate the current text selection at exact block boundaries.
*
* ProseMirror's block commands operate on whole textblocks. The composer can
* hold an entire draft in one paragraph, so toggling a list or code block for
* a substring otherwise formats the whole draft. Splitting at the selection
* end and start first gives the selected text its own block while preserving
* the surrounding content as sibling paragraphs.
* the surrounding content as sibling paragraphs. A collapsed caret isolates
* its hard-break-delimited line so the block format starts at that line.
*
* This mutates the transaction supplied by a Tiptap command chain so the
* isolation and the following block toggle remain one undoable edit.
*/
export function isolateSelectionForBlockFormatting(
transaction: Transaction,
): boolean {
if (
!(transaction.selection instanceof TextSelection) ||
transaction.selection.empty
) {
if (!(transaction.selection instanceof TextSelection)) {
return false;
}

if (transaction.selection.empty) {
return isolateCaretLineForBlockFormatting(transaction);
}

const isBackward = transaction.selection.anchor > transaction.selection.head;
let { from, to } = transaction.selection;

Expand Down Expand Up @@ -84,3 +150,93 @@ export function isolateSelectionForBlockFormatting(
);
return true;
}

/** Split each selected hard-break line into a textblock before list wrapping. */
export function splitSelectedLinesForListFormatting(
transaction: Transaction,
): boolean {
if (!(transaction.selection instanceof TextSelection)) return false;
if (transaction.selection.empty) {
return isolateCaretLineForBlockFormatting(transaction);
}

const isBackward = transaction.selection.anchor > transaction.selection.head;
isolateSelectionForBlockFormatting(transaction);
let { from, to } = transaction.selection;
const breakPositions: number[] = [];

transaction.doc.nodesBetween(from, to, (node, position) => {
if (node.type.name === "hardBreak") breakPositions.push(position);
});

for (const position of breakPositions.reverse()) {
transaction.delete(position, position + 1);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
if (!canSplit(transaction.doc, position)) continue;
transaction.split(position);
({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
}

transaction.setSelection(
TextSelection.create(
transaction.doc,
isBackward ? to : from,
isBackward ? from : to,
),
);
return true;
}

function selectedTextblocks(
transaction: Transaction,
): Array<{ node: ProseMirrorNode; position: number }> {
const blocks: Array<{ node: ProseMirrorNode; position: number }> = [];
const { from, to } = transaction.selection;
transaction.doc.nodesBetween(
Math.max(0, from - 1),
Math.min(transaction.doc.content.size, to + 1),
Comment thread
tellaho marked this conversation as resolved.
Outdated
(node, position) => {
if (node.isTextblock) {
blocks.push({ node, position });
return false;
}
return true;
},
);
return blocks;
}

function textblockTextForCode(node: ProseMirrorNode): string {
return node.textBetween(0, node.content.size, "\n", (leaf) =>
leaf.type.name === "hardBreak"
? "\n"
: (leaf.type.spec.leafText?.(leaf) ?? ""),
);
}

/** Replace selected textblocks with one newline-joined code block. */
export function mergeSelectedTextblocksIntoCodeBlock(
transaction: Transaction,
): boolean {
if (!(transaction.selection instanceof TextSelection)) return false;
if (transaction.selection.empty) {
isolateCaretLineForBlockFormatting(transaction);
return false;
}

const blocks = selectedTextblocks(transaction);
const codeBlock = transaction.doc.type.schema.nodes.codeBlock;
const first = blocks[0];
const last = blocks.at(-1);
if (!(codeBlock && first && last)) return false;

const text = blocks.map(({ node }) => textblockTextForCode(node)).join("\n");
const from = first.position;
const to = last.position + last.node.nodeSize;
const content = text ? transaction.doc.type.schema.text(text) : undefined;
transaction.replaceWith(from, to, codeBlock.create(null, content));
transaction.setSelection(
TextSelection.create(transaction.doc, from + 1, from + 1 + text.length),
);
return true;
}
Loading
Loading