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
13 changes: 13 additions & 0 deletions packages/ui/hooks/useAnnotationHighlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import Highlighter from '@plannotator/web-highlighter';
import type { Annotation, EditorMode, ImageAttachment } from '../types';
import { AnnotationType } from '../types';
import type { QuickLabel } from '../utils/quickLabels';
import {
trimWhitespaceOnlyBoundaryNodes,
type SelectedNodeLike,
} from '../utils/selectionBoundary';
import { getIdentity } from '../utils/identity';
import { transformPlainText } from '../utils/inlineTransforms';

Expand Down Expand Up @@ -845,6 +849,15 @@ export function useAnnotationHighlighter({
style: { className: 'annotation-highlight' },
});

// Chromium can extend a triple-clicked line into empty or indentation nodes of the next block.
// Trim only boundary whitespace so spacing inside genuine multi-node selections stays highlighted.
// The hook's callback type is `(...args: unknown[]) => SelectedNode[]`; a SelectedNode
// structurally satisfies SelectedNodeLike, so the trimmed subset goes back through the
// hook's own callback type rather than importing SelectedNode from the package's dist/.
type SelectedNodesTap = Parameters<typeof highlighter.hooks.Render.SelectedNodes.tap>[0];
highlighter.hooks.Render.SelectedNodes.tap(((...args: unknown[]) =>
trimWhitespaceOnlyBoundaryNodes(args[1] as SelectedNodeLike[])) as SelectedNodesTap);

highlighterRef.current = highlighter;

highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
Expand Down
70 changes: 70 additions & 0 deletions packages/ui/utils/selectionBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import { trimWhitespaceOnlyBoundaryNodes, type SelectedNodeLike } from './selectionBoundary';

// Failure this guards: a triple-click leaves the selection's end boundary on the
// NEXT block's text node at offset 0, so web-highlighter's splitText(0) hands the
// painter an empty (or pure-indentation) boundary node. Wrapping that node in a
// styled <mark> paints a visible sliver on the following line. A boundary node
// with no non-whitespace character must never reach the painter, while
// whitespace BETWEEN real nodes must survive, or genuine multi-node selections
// lose their inter-node spacing.
//
// Lives here rather than in useAnnotationHighlighter.test.tsx because that suite
// is DOM-gated (skipped unless DOM_TESTS=1) and so guards nothing in CI.

const nodes = (...texts: (string | null)[]): SelectedNodeLike[] =>
texts.map((textContent) => ({ $node: { textContent } }));

const texts = (list: SelectedNodeLike[]): (string | null)[] =>
list.map((node) => node.$node.textContent);

describe('trimWhitespaceOnlyBoundaryNodes', () => {
test('drops an empty trailing node (the splitText(0) sliver)', () => {
expect(texts(trimWhitespaceOnlyBoundaryNodes(nodes('a line of text', '')))).toEqual([
'a line of text',
]);
});

test('drops a trailing pure-indentation node', () => {
expect(texts(trimWhitespaceOnlyBoundaryNodes(nodes('a line of text', '\n ')))).toEqual([
'a line of text',
]);
});

test('drops leading whitespace-only nodes', () => {
expect(texts(trimWhitespaceOnlyBoundaryNodes(nodes('\n ', '', 'a line of text')))).toEqual([
'a line of text',
]);
});

test('trims both ends at once', () => {
expect(
texts(trimWhitespaceOnlyBoundaryNodes(nodes(' ', 'first', 'second', '\n\t'))),
).toEqual(['first', 'second']);
});

test('keeps whitespace-only nodes between content nodes', () => {
expect(texts(trimWhitespaceOnlyBoundaryNodes(nodes('first', ' ', 'second')))).toEqual([
'first',
' ',
'second',
]);
});

test('returns nothing when every node is whitespace-only', () => {
expect(trimWhitespaceOnlyBoundaryNodes(nodes('', ' ', '\n '))).toEqual([]);
});

test('leaves an ordinary selection untouched', () => {
const selection = nodes('first', 'second', 'third');
expect(trimWhitespaceOnlyBoundaryNodes(selection)).toEqual(selection);
});

test('treats a null textContent as whitespace-only', () => {
expect(texts(trimWhitespaceOnlyBoundaryNodes(nodes(null, 'text', null)))).toEqual(['text']);
});

test('handles an empty node list', () => {
expect(trimWhitespaceOnlyBoundaryNodes([])).toEqual([]);
});
});
40 changes: 40 additions & 0 deletions packages/ui/utils/selectionBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Boundary trimming for web-highlighter's `Render.SelectedNodes` hook.
*
* Lives in its own pure module so it can be unit-tested without a DOM:
* useAnnotationHighlighter imports @plannotator/web-highlighter, whose UMD
* bundle reads `window` at module-eval time.
*/

/**
* Minimal structural view of web-highlighter's `SelectedNode`: only the field
* the trim actually reads. Declared locally rather than imported from the
* package's `dist/`, which resolves today only because the fork ships no
* `exports` map; a republish that adds one would break consumers compiling
* @plannotator/ui from source.
*/
export interface SelectedNodeLike {
$node: { textContent: string | null };
}

/**
* Drop whitespace-only nodes from the leading and trailing ends of the node
* list web-highlighter is about to wrap. Whitespace-only nodes in the MIDDLE of
* a genuine multi-node selection are kept, so inter-node spacing stays
* highlighted.
*/
export const trimWhitespaceOnlyBoundaryNodes = (
selectedNodes: SelectedNodeLike[],
): SelectedNodeLike[] => {
let start = 0;
while (start < selectedNodes.length && !/\S/.test(selectedNodes[start].$node.textContent ?? '')) {
start += 1;
}

let end = selectedNodes.length;
while (end > start && !/\S/.test(selectedNodes[end - 1].$node.textContent ?? '')) {
end -= 1;
}

return selectedNodes.slice(start, end);
};