Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -1898,6 +1898,16 @@ async function expectPelicanToHoldStill(list: HTMLElement) {
expect(after!.bottom).toBeLessThanOrEqual(listRect.bottom);
}

// The element the list places for a message's row: the outermost one that
// holds that message and no other.
function rowElement(messageId: string): HTMLElement {
let element = document.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`)!;
while (element.parentElement?.querySelectorAll("[data-message-id]").length === 1) {
element = element.parentElement;
}
return element;
}

function selectionPopover(): HTMLElement | null {
return document.querySelector<HTMLElement>(SELECTION_POPOVER);
}
Expand Down Expand Up @@ -1983,6 +1993,58 @@ describe("text selection popover with the real virtual list", () => {
}
});

it("keeps the selection and its actions when the list moves the text's row, unless the reader moved on", async () => {
const { screen } = await renderLongChat();
try {
dragSelectWord("message-51", "pelican");
await vi.waitFor(() => expect(isShown(selectionPopover())).toBe(true));
const gapBelowWord = () =>
selectionPopover()!.getBoundingClientRect().top - wordRect("message-51", "pelican")!.bottom;
const gap = gapBelowWord();

// The list keeps its rows' elements in order by moving them, which a
// scroll can set off. A move collapses every range inside the row, the
// page's selection too, and nothing says so.
const moveRow = () => {
const row = rowElement("message-51");
row.parentElement!.moveBefore(row, null);
};
const expectSelectionBack = () =>
vi.waitFor(() => {
expect(window.getSelection()!.toString()).toBe("pelican");
expect(isShown(selectionPopover())).toBe(true);
expect(Math.abs(gapBelowWord() - gap)).toBeLessThanOrEqual(1);
});
moveRow();
await expectSelectionBack();

// An edit inside the message in the same moment as the move.
const body = document.querySelector<HTMLElement>(
'[data-transcript-message-body="true"][data-transcript-message-id="message-51"]',
)!;
const edit = document.createTextNode("");
body.append(edit);
edit.remove();
moveRow();
await expectSelectionBack();

// A click elsewhere before the next frame is the reader's: the actions go.
moveRow();
const elsewhere = document
.createTreeWalker(
document.querySelector('[data-message-id="message-52"]')!,
NodeFilter.SHOW_TEXT,
)
.nextNode()!;
window.getSelection()!.collapse(elsewhere, 0);
await vi.waitFor(() => expect(selectionPopover()).toBeNull());
await nextFrame();
expect(window.getSelection()!.isCollapsed).toBe(true);
} finally {
await screen.unmount();
}
});

it("keeps a note in progress, and its highlight, while the list redraws the text", async () => {
const onAddTranscriptHighlightContext = vi.fn();
const writeText = vi.fn(async () => undefined);
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/components/chat/TranscriptSelectionPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,21 +252,54 @@ function TranscriptSelectionPopover({
range: Range;
clips: ReadonlyArray<ClippingAncestor>;
} | null = null;
// The message body the observers watch. Kept apart from `anchor`, which
// an edit inside the message drops before the next frame finds it again.
let observedBody: HTMLElement | null = null;
const bodyObserver = new MutationObserver(() => {
anchor = null;
reshapeHighlight = true;
scheduleUpdate();
});
// The list keeps its rows' elements in order by moving them, and a scroll
// can set that off. A move collapses every live range inside the row, this
// one and the page's selection, to the spot the row was taken from, and no
// event says so. Seen here as the row's element leaving its parent: the
// text is found again, and the reader gets back the selection the move
// took. Only while it is still a caret in the rows' parent, where the move
// left it (its offset shifts as the list moves other rows): a caret in any
// text is the reader's. A note keeps its own highlight and its box's focus.
let collapsedInto: Node | null = null;
const moveObserver = new MutationObserver((records) => {
const body = observedBody;
const moves =
body === null
? []
: records.filter((record) =>
[...record.removedNodes].some((node) => node.contains(body)),
);
if (moves.length === 0) {
return;
}
anchor = null;
reshapeHighlight = true;
const page = document.getSelection();
const caret = page?.isCollapsed ? page.anchorNode : null;
collapsedInto =
!noting && caret && moves.some((record) => record.target === caret) ? caret : null;
scheduleUpdate();
});
const resolveAnchor = () => {
if (anchor?.body.isConnected) {
return anchor;
}
anchor = null;
bodyObserver.disconnect();
observedBody = null;
const body = findTranscriptMessageBodyById(container, sourceMessageId);
const range = body ? rangeAtTextOffsets(body, textStart, textEnd) : null;
if (body && range) {
bodyObserver.observe(body, { childList: true, characterData: true, subtree: true });
observedBody = body;
anchor = { body, range, clips: findClippingAncestors(range, container) };
}
return anchor;
Expand All @@ -275,6 +308,15 @@ function TranscriptSelectionPopover({
frameId = null;
const resolved = resolveAnchor();
const range = resolved?.range ?? null;
if (collapsedInto !== null) {
const left = collapsedInto;
collapsedInto = null;
const page = document.getSelection();
if (range && page?.isCollapsed && page.anchorNode === left) {
page.removeAllRanges();
page.addRange(range.cloneRange());
}
}
const containerRect = container.getBoundingClientRect();
const toContainerBox = (rect: DOMRect) => ({
top: rect.top - containerRect.top,
Expand Down Expand Up @@ -347,10 +389,12 @@ function TranscriptSelectionPopover({
});
resizeObserver.observe(container);
resizeObserver.observe(popover);
moveObserver.observe(container, { childList: true, subtree: true });
return () => {
container.removeEventListener("scroll", scheduleUpdate, { capture: true });
resizeObserver.disconnect();
bodyObserver.disconnect();
moveObserver.disconnect();
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
Expand Down