Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
138 changes: 138 additions & 0 deletions desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";

import { Schema } from "@tiptap/pm/model";
import { Decoration, DecorationSet } from "@tiptap/pm/view";

import {
buildHighlightPatterns,
findMentionBackspaceDeleteRange,
findMentionDeleteRangeBeforeCursor,
findHighlightMatches,
} from "./mentionHighlightExtension.ts";

const schema = new Schema({
nodes: {
doc: { content: "paragraph+" },
paragraph: {
content: "text*",
group: "block",
parseDOM: [{ tag: "p" }],
toDOM: () => ["p", 0],
},
text: { group: "inline" },
},
marks: {},
});

function textDoc(text) {
return schema.node("doc", null, [
schema.node("paragraph", null, text ? [schema.text(text)] : undefined),
]);
}

function mentionDeleteSpec(fromOffset, toOffset) {
return { mentionDelete: { fromOffset, toOffset } };
}

// ── buildHighlightPatterns ────────────────────────────────────────────

test("returns empty array when no names or channels provided", () => {
Expand Down Expand Up @@ -152,3 +181,112 @@ test("#general should NOT match inside #generally (trailing word boundary)", ()
const matches = findHighlightMatches("#generally", patterns);
assert.equal(matches.length, 0);
});

// ── findMentionDeleteRangeBeforeCursor ────────────────────────────────

test("finds a mention delete range when cursor is at the end of a mention", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);

assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, to), {
from,
to,
});
});

test("finds a mention delete range when cursor is inside a mention", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);

assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, 8), {
from,
to,
});
});

test("does not delete a mention when cursor is before it or after its trailing space", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);

assert.equal(findMentionDeleteRangeBeforeCursor(decorations, from), null);
assert.equal(findMentionDeleteRangeBeforeCursor(decorations, to + 1), null);
});

test("backspace range includes the separator space after a mention", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const cursorAfterSpace = 12;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);

assert.deepEqual(
findMentionBackspaceDeleteRange(doc, decorations, cursorAfterSpace),
{
from,
to: cursorAfterSpace,
},
);
});

test("finds the full agent mention range from either split decoration", () => {
const doc = textDoc("Ask @kit");
const from = 5;
const to = 9;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
from + 1,
{ class: "agent-mention-at-hidden" },
mentionDeleteSpec(0, to - (from + 1)),
),
Decoration.inline(
from + 1,
to,
{ class: "mention-highlight agent-mention-highlight" },
mentionDeleteSpec(-1, 0),
),
]);

assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, from + 1), {
from,
to,
});
assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, to), {
from,
to,
});
});
150 changes: 143 additions & 7 deletions desktop/src/features/messages/lib/mentionHighlightExtension.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { Extension } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";

export const mentionHighlightKey = new PluginKey("mentionHighlight");

export type MentionDeleteRange = { from: number; to: number };

type MentionDeleteDecorationSpec = {
mentionDelete?: {
fromOffset: number;
toOffset: number;
};
};

/**
* TipTap extension that applies inline `mention-highlight` decorations
* to `@Name` and `#channel-name` patterns in the document.
Expand Down Expand Up @@ -149,6 +159,62 @@ export function findHighlightMatches(
return results;
}

/**
* Returns the full @mention range touched by Backspace at `cursor`, if any.
*
* The stored offsets are relative to each decoration's current mapped
* position, so they stay valid when ProseMirror maps decorations after edits
* elsewhere in the document.
*/
export function findMentionDeleteRangeBeforeCursor(
decorations: DecorationSet,
cursor: number,
): MentionDeleteRange | null {
if (cursor <= 0) return null;

const touchedDecorations = decorations.find(
cursor - 1,
cursor,
hasMentionDeleteSpec,
);

for (const decoration of touchedDecorations) {
const range = getMentionDeleteRange(decoration);
if (range && range.from < cursor && cursor <= range.to) {
return range;
}
}

return null;
}

export function findMentionBackspaceDeleteRange(
doc: ProseMirrorNode,
decorations: DecorationSet,
cursor: number,
): MentionDeleteRange | null {
const directRange = findMentionDeleteRangeBeforeCursor(decorations, cursor);
if (directRange) return directRange;

if (cursor <= 1) return null;

// Autocomplete inserts a separator space after @mentions. When the caret is
// in that natural post-insert position, make one Backspace remove the tag
// and its separator instead of requiring a first press just to eat the space.
const previousChar = doc.textBetween(cursor - 1, cursor, "\n", "\0");
if (previousChar !== " ") return null;

const beforeSpaceRange = findMentionDeleteRangeBeforeCursor(
decorations,
cursor - 1,
);
if (!beforeSpaceRange || beforeSpaceRange.to !== cursor - 1) {
return null;
}

return { from: beforeSpaceRange.from, to: cursor };
}

/**
* Returns true if the transaction's changed ranges touch text that contains
* `@` or `#` — meaning a mention/channel-link boundary may have been
Expand Down Expand Up @@ -265,14 +331,15 @@ function buildDecorations(
pos,
mentionPatterns,
"mention-highlight",
{ deleteAsMention: true },
);
addMatchesForPatterns(
decorations,
node.text,
pos,
agentMentionPatterns,
"mention-highlight agent-mention-highlight",
{ hideMentionPrefix: true },
{ deleteAsMention: true, hideMentionPrefix: true },
);
addMatchesForPatterns(
decorations,
Expand All @@ -292,25 +359,94 @@ function addMatchesForPatterns(
position: number,
patterns: RegExp[],
className: string,
options?: { hideMentionPrefix?: boolean },
options?: { deleteAsMention?: boolean; hideMentionPrefix?: boolean },
) {
for (const pattern of patterns) {
pattern.lastIndex = 0;
let match: RegExpExecArray | null = pattern.exec(text);
while (match !== null) {
const from = position + match.index;
const to = from + match[0].length;
const deleteRangeLength = to - from;
if (options?.hideMentionPrefix && match[0].startsWith("@")) {
decorations.push(
Decoration.inline(from, from + 1, {
class: "agent-mention-at-hidden",
}),
Decoration.inline(
from,
from + 1,
{
class: "agent-mention-at-hidden",
},
options.deleteAsMention
? mentionDeleteSpec(0, deleteRangeLength - 1)
: undefined,
),
);
decorations.push(
Decoration.inline(
from + 1,
to,
{ class: className },
options.deleteAsMention ? mentionDeleteSpec(-1, 0) : undefined,
),
);
decorations.push(Decoration.inline(from + 1, to, { class: className }));
} else {
decorations.push(Decoration.inline(from, to, { class: className }));
decorations.push(
Decoration.inline(
from,
to,
{ class: className },
options?.deleteAsMention ? mentionDeleteSpec(0, 0) : undefined,
),
);
}
match = pattern.exec(text);
}
}
}

function mentionDeleteSpec(
fromOffset: number,
toOffset: number,
): MentionDeleteDecorationSpec {
return {
mentionDelete: {
fromOffset,
toOffset,
},
};
}

function hasMentionDeleteSpec(spec: unknown): boolean {
return getMentionDeleteOffsets(spec) !== null;
}

function getMentionDeleteRange(
decoration: Decoration,
): MentionDeleteRange | null {
const offsets = getMentionDeleteOffsets(decoration.spec);
if (!offsets) return null;

const from = decoration.from + offsets.fromOffset;
const to = decoration.to + offsets.toOffset;
if (!Number.isInteger(from) || !Number.isInteger(to) || from >= to) {
return null;
}

return { from, to };
}

function getMentionDeleteOffsets(
spec: unknown,
): MentionDeleteDecorationSpec["mentionDelete"] | null {
if (!spec || typeof spec !== "object") return null;

const mentionDelete = (spec as MentionDeleteDecorationSpec).mentionDelete;
if (!mentionDelete || typeof mentionDelete !== "object") return null;

const { fromOffset, toOffset } = mentionDelete;
if (!Number.isInteger(fromOffset) || !Number.isInteger(toOffset)) {
return null;
}

return mentionDelete;
}
Loading
Loading