From 3b26c18c3f19830b07d73f7fa7894f2a128bbc06 Mon Sep 17 00:00:00 2001 From: Mike Mulligan <130580+thebeline@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:45:30 -0400 Subject: [PATCH 1/6] feat(notes): highlight a selection note's words, found again after a reprocess A selection note keeps the span its words were selected at and the words as the user saw them. The processed view now finds those words in the transcript as it reads now and marks them, tinted and underlined: solid where found exactly, dotted where found with changes (the original words in the title). The underline keeps a mark visible on a bubble of the tint's own colour. When they can't be found near the note's time nothing is marked, and the note line strikes the quote through, says the words changed, and quotes what the span reads now. A click on the tinted words, or the new pencil on a saved note's line, opens the note popover to edit the note or delete it, each write against the revision shown; a 409 shows the note as it is now and keeps the text typed, so a second Save replaces it knowingly. Decision record (anchoring rule) Context: a reprocess deletes every utterance and finalize supersedes the live ones, so nothing in the transcript keeps a stable id or offset. The audio clock is what survives: the note's start_ms/end_ms. Rule: resolved at read time, never written back. Look only among words timed within 2 s of the stored span; compare case-folded with punctuation stripped; take the run with the fewest word edits from the quote (approximate substring matching), ties to the run nearest the span's middle. All words equal is exact, 1 - edits/words >= 0.5 is fuzzy, less is unanchored. Placement still reads only the stored span, and prompts keep the stored quote. Alternatives: time span only (option C) would tint whatever words now sit there, silently; prefix/suffix context columns (E) are a migration for a gain only on repeated phrases, which the time tie-break already handles; re-anchoring at finalize and writing back (F) would make the pipeline a second writer of notes, bumping revisions under users' open edits. Trade-offs: the margin and floor come from 7,500 selections made the way the client makes them, on live and processed text of three benchmark recordings, resolved on their final and reprocessed text. At 2 s and 0.5: 78% exact, 19% fuzzy, 4% unanchored; 95% marked on the right words, 1.5% on wrong ones. A 5 s margin finds 0.2% more and marks 0.4% more wrongly. A 0.6 floor (the design's 0.7 was a guess) refuses 1.6% more, 113 of 117 of them right, mostly two-word quotes with one word changed; 0.4 adds 0.1%. 92 of the 110 wrong marks are one-word quotes; the clear cases are a word said twice within a few seconds, where the neighbouring copy is marked. --- docs/ARCHITECTURE.md | 1 + docs/USAGE.md | 2 + .../TranscriptSection.highlights.test.tsx | 319 ++++++++++++++++++ .../[id]/_components/TranscriptSection.tsx | 26 +- frontend/src/components/TranscriptView.tsx | 48 ++- .../notes/NoteOnSelectionPopover.tsx | 101 ++++-- .../src/components/notes/TimelineNoteLine.tsx | 34 +- .../components/transcript/TranscriptGroup.tsx | 16 +- .../components/transcript/TranscriptLine.tsx | 16 +- .../transcript/TranscriptLineMenu.tsx | 2 +- .../transcript/TranscriptLineText.test.tsx | 49 +++ .../transcript/TranscriptLineText.tsx | 52 ++- .../transcript/TranscriptNoteEditor.tsx | 100 ++++++ .../transcript/TranscriptSelectionToolbar.tsx | 2 +- .../transcript/_hooks/useNoteHighlights.ts | 62 ++++ .../components/transcript/noteHighlights.ts | 74 ++++ .../components/transcript/textDecorations.ts | 14 +- frontend/src/lib/notes/noteOutbox.ts | 2 + .../src/lib/notes/resolveSelection.test.ts | 285 ++++++++++++++++ frontend/src/lib/notes/resolveSelection.ts | 189 +++++++++++ frontend/src/lib/notes/useNoteEdits.ts | 125 +++++++ 21 files changed, 1466 insertions(+), 53 deletions(-) create mode 100644 frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx create mode 100644 frontend/src/components/transcript/TranscriptNoteEditor.tsx create mode 100644 frontend/src/components/transcript/_hooks/useNoteHighlights.ts create mode 100644 frontend/src/components/transcript/noteHighlights.ts create mode 100644 frontend/src/lib/notes/resolveSelection.test.ts create mode 100644 frontend/src/lib/notes/resolveSelection.ts create mode 100644 frontend/src/lib/notes/useNoteEdits.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 290d2881d..d2f7f6545 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -359,6 +359,7 @@ The user's notes are `timeline_notes` rows (`backend/models/timeline_note.py`) a - **Reading them.** The detail read carries `timeline_notes_list_signature` (`count:hash` of each note's id and revision), not the notes, and the page fetches the list when it changes (`frontend/src/lib/notes/useTimelineNotes.ts`), and after its own writes when nothing polls. Every write that changes a listed note makes a new revision, so the signature changes whenever the list does. - **Placement.** One rule, implemented twice: `place_timeline_notes` (`backend/utils/timeline_notes.py`) for prompts, and `placeTimelineNotes` (`frontend/src/lib/notes/placement.ts`) for the live and finished transcripts, both pinned by `backend/tests/fixtures/timeline_note_placement_cases.json`. A note at t (a selection at its last millisecond) follows the last non-blank line, in list order, starting at or before t. A note before the first line opens the transcript. - **Views** place notes on their whole line list. A note after a hidden line shows after the visible line before it, and a note ends the run of grouped lines it follows. The views sort lines by start, while the prompts keep the stored order, which every writer emits by start: lines tied on start may sort either way without moving a note past a non-blank line (a property test in `placement.test.ts`). A list stored out of start order, which `PUT /transcripts/{id}/segments` accepts, would place a note differently in the views than in the prompts. +- **Highlights.** A selection's `quoted_text` is a snapshot of the words the user saw, and its `start_ms`/`end_ms` are where they were. The processed view finds them again on every render, never writing anything back (`frontend/src/lib/notes/resolveSelection.ts`). The rule: look only among the words timed within 2 s of the span, compare them case-folded with punctuation stripped, and take the run with the fewest word edits from the quote, ties to the run nearest the span's middle. All words equal is *exact*; a score `1 − edits/quote words` of at least 0.5 is *fuzzy*; anything less is *unanchored*, and nothing is marked. A reprocess, a line edit or Find & Replace therefore never moves a note or its placement, which reads only the stored span, and the prompts keep the stored quote. Utterance ids and character offsets are never stored on a note, since a reprocess replaces both. - **Prompts.** An anchored note is its own line after the line it follows, folded so it cannot pass for transcript. Head and tail notes go in `# User Notes Context`, quoted with `> `; chat puts them first and last. Speaker inference reads every note as a timestamped list. Generated notes end with `## User Notes`, which speaker renames never rewrite. - **Meeting Edge** refreshes at once, ahead of its throttle, when `timeline_notes_signature` changes. It hashes each note's id and content (kind, text, anchors, quote, author, creation time), not its revision as the list signature does, so an edit that changes none of these refreshes nothing. - **Revisions.** An edit names its base revision, and a delete may (`?expected_revision=`). A stale one is a 409 `revision_conflict` with the current note. diff --git a/docs/USAGE.md b/docs/USAGE.md index 227c8d8dd..c3d2f838b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -129,6 +129,8 @@ In the transcript: - Notes you placed during the meeting ([Live Notes](#live-notes)) show on their own line after the line they follow. When a line has two or more saved notes, they fold into a count, such as **2 notes**, which shows them when you click or tap it; a single note shows as it is. A note you write on this page stays shown once it saves, and one not saved yet always shows. - The **⋯** button beside a speaker's name opens the line's menu: **Note on this line**, **Edit text**, **Change speaker** and **Play from here**, which plays on rather than stopping at the line's end. It shows when you point at the line or tab to it, and always on a touch screen, where pressing and holding the speaker's name also opens it. In the menu, the arrow keys move and Escape closes it. A note on a line quotes the line's words; Enter saves it and Shift+Enter starts a new line. One that can't be saved waits on its line with **Retry** and **Discard**. - Select some text, in one line or across several, for a bar with **Add note** and **Copy**. A note on selected text quotes those words, and its time runs from the first word you touched to the last, estimated from the line's start and end. Escape or a click elsewhere puts the bar away. A note you have started typing stays open, whatever you click or select, until you save or cancel it. +- The words a note is on are tinted and underlined in the transcript. After processing or a reprocess the words may read a little differently: Nojoin looks for them again near the note's time, and words found with changes are underlined with dots instead, the original words shown when you point at them. When they can no longer be found, nothing is marked, and the note shows its quote struck through, says the words changed, and quotes what the transcript says at that time now. +- Click tinted words, or the pencil beside a saved note, to edit the note or delete it. If the note was changed on another device meanwhile, Nojoin shows its new text and keeps yours in the box: save again to replace it. **Export** saves the notes, analytics and transcript as one PDF, Word or text file, or the audio as MP3 ([Exporting A Meeting](#exporting-a-meeting)). diff --git a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx new file mode 100644 index 000000000..69dc4be75 --- /dev/null +++ b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx @@ -0,0 +1,319 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { forgetNoteOutboxes, noteOutboxFor } from "@/lib/notes/noteOutboxes"; +import { interpolatedTokens } from "@/lib/notes/selectionRange"; +import { + RecordingStatus, + type Recording, + type TimelineNote, + type TranscriptSegment, +} from "@/types"; + +import TranscriptSection from "./TranscriptSection"; + +const createTimelineNote = vi.fn(); +const updateTimelineNote = vi.fn(); +const deleteTimelineNote = vi.fn(); + +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + createTimelineNote: (...args: unknown[]) => createTimelineNote(...args), + updateTimelineNote: (...args: unknown[]) => updateTimelineNote(...args), + deleteTimelineNote: (...args: unknown[]) => deleteTimelineNote(...args), +})); +vi.mock("@/lib/notificationStore", () => ({ + useNotificationStore: () => ({ addNotification: vi.fn() }), +})); + +const originalScrollIntoView = Element.prototype.scrollIntoView; +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); +afterAll(() => { + Element.prototype.scrollIntoView = originalScrollIntoView; +}); + +const VENUE: TranscriptSegment = { + id: "u1", + speaker: "SPEAKER_1", + start: 65, + end: 68.6, + text: "We need forty thousand for the venue", +}; +const SEGMENTS: TranscriptSegment[] = [ + { id: "u0", speaker: "SPEAKER_0", start: 0, end: 2, text: "Good morning" }, + VENUE, +]; + +/** The span a selection of `words` in the venue line had (B3c), with `quote` as written. */ +const spanOf = (words: string) => { + const from = VENUE.text.indexOf(words); + const tokens = interpolatedTokens(VENUE.text, VENUE.start * 1000, VENUE.end * 1000).filter( + (token) => token.from < from + words.length && token.to > from, + ); + return { start_ms: tokens[0].startMs, end_ms: tokens[tokens.length - 1].endMs }; +}; + +const selectionNote = (id: string, words: string, quote: string, body: string): TimelineNote => ({ + id, + recording_id: "rec-1", + kind: "selection", + body, + ...spanOf(words), + quoted_text: quote, + revision: 3, + created_at: "2026-10-23T10:00:00", + updated_at: "2026-10-23T10:00:00", +}); + +/** Written on the words as they read now. */ +const EXACT = selectionNote("n-exact", "forty thousand", "forty thousand", "Check the figure"); +/** Written on the live text, which heard "fourteen"; the final pass heard "forty". */ +const FUZZY = selectionNote("n-fuzzy", "forty thousand for the venue", "fourteen thousand for the venue", "Is that per day?"); +/** Written on words the final pass no longer has anywhere near. */ +const GONE = selectionNote("n-gone", "forty thousand", "a budget of twelve", "Ask finance"); + +const RECORDING = { + id: "rec-1", + name: "Weekly sync", + status: RecordingStatus.PROCESSED, + duration_seconds: 600, + speakers: [], +} as unknown as Recording; + +const renderSection = (timelineNotes: TimelineNote[]) => + render( + , + ); + +const venueText = () => screen.getByText((_, element) => element?.tagName === "P" && element.textContent === VENUE.text); +const marks = () => Array.from(venueText().querySelectorAll("mark[data-note-id]")); +const noteLine = (id: string) => + screen.getAllByTestId("timeline-note").find((line) => line.dataset.noteId === id)!; + +const conflict = (note: TimelineNote) => ({ + isAxiosError: true, + response: { + status: 409, + data: { detail: { code: "revision_conflict", message: "changed", note } }, + }, +}); + +beforeEach(() => { + forgetNoteOutboxes(); + createTimelineNote.mockReset(); + updateTimelineNote.mockReset(); + deleteTimelineNote.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("the words a note is on, in the processed transcript", () => { + it("are tinted and underlined where the quote is found as written, the line's text unchanged", () => { + renderSection([EXACT]); + + expect(marks()).toHaveLength(1); + expect(marks()[0]).toHaveTextContent(/^forty thousand$/); + expect(marks()[0].dataset).toMatchObject({ noteId: "n-exact", noteAnchor: "exact" }); + expect(marks()[0].className).toMatch(/decoration-solid/); + expect(marks()[0].className).not.toMatch(/decoration-dotted/); + expect(marks()[0]).toHaveAttribute("title", "Note: “Check the figure”"); + expect(venueText().textContent).toBe(VENUE.text); + expect(within(noteLine("n-exact")).queryByTestId("timeline-note-words-changed")).toBeNull(); + }); + + it("are underlined with dots where found with changes, the quote as written in the title", () => { + renderSection([FUZZY]); + + expect(marks()).toHaveLength(1); + expect(marks()[0]).toHaveTextContent(/^forty thousand for the venue$/); + expect(marks()[0].dataset.noteAnchor).toBe("fuzzy"); + expect(marks()[0].className).toMatch(/decoration-dotted/); + expect(marks()[0].title).toContain("Written on “fourteen thousand for the venue”"); + expect(within(noteLine("n-fuzzy")).queryByTestId("timeline-note-words-changed")).toBeNull(); + }); + + it("are marked once the note is saved, not before, so no mark opens an editor that can't save", async () => { + createTimelineNote.mockRejectedValueOnce({ isAxiosError: true, message: "Network Error" }); + noteOutboxFor("rec-1").submit({ + kind: "selection", + body: "Check the figure", + anchorMs: null, + selection: { startMs: EXACT.start_ms!, endMs: EXACT.end_ms!, quotedText: "forty thousand" }, + }); + renderSection([]); + + const line = screen.getByTestId("timeline-note"); + await waitFor(() => expect(line).toHaveAttribute("data-note-status", "failed")); + expect(marks()).toHaveLength(0); + expect(within(line).queryByRole("button", { name: /^Edit note/ })).toBeNull(); + + createTimelineNote.mockImplementationOnce(async (_recording: string, payload: { id: string }) => ({ + ...EXACT, + id: payload.id, + revision: 1, + })); + fireEvent.click(within(line).getByRole("button", { name: /^Retry note/ })); + await waitFor(() => expect(marks()).toHaveLength(1)); + expect(marks()[0]).toHaveTextContent(/^forty thousand$/); + expect( + within(screen.getByTestId("timeline-note")).getByRole("button", { name: /^Edit note/ }), + ).toBeInTheDocument(); + }); + + it("are not marked when gone; the note line strikes the quote and says what is there now", () => { + renderSection([GONE]); + + expect(marks()).toHaveLength(0); + const line = noteLine("n-gone"); + expect(within(line).getByTestId("timeline-note-quote").className).toMatch(/line-through/); + expect(within(line).getByTestId("timeline-note-words-changed")).toHaveTextContent( + "Words changed since this note. Here it now reads “forty thousand”.", + ); + }); +}); + +describe("editing a note in the processed transcript", () => { + const openFromLine = (id: string, body: string) => { + fireEvent.click(within(noteLine(id)).getByRole("button", { name: `Edit note at 01:05, “${body}”` })); + return screen.getByRole("dialog", { name: "Edit the note at 01:05" }); + }; + + it("saves new text against the note's revision, shows it, and gives focus back", async () => { + updateTimelineNote.mockImplementation(async (_recording, _id, update) => ({ + ...EXACT, + body: update.body, + revision: 4, + })); + renderSection([EXACT]); + + const editor = openFromLine("n-exact", "Check the figure"); + const box = within(editor).getByRole("textbox", { name: "Note" }); + expect(box).toHaveValue("Check the figure"); + fireEvent.change(box, { target: { value: " Check it with finance " } }); + fireEvent.click(within(editor).getByRole("button", { name: "Save note" })); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(updateTimelineNote).toHaveBeenCalledWith("rec-1", "n-exact", { + expected_revision: 3, + body: "Check it with finance", + }); + expect(noteLine("n-exact")).toHaveTextContent("Check it with finance"); + expect(document.activeElement).toBe( + within(noteLine("n-exact")).getByRole("button", { name: /^Edit note at 01:05/ }), + ); + // Opened again, it edits the revision the save made. + fireEvent.change(within(openFromLine("n-exact", "Check it with finance")).getByRole("textbox"), { + target: { value: "Again" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save note" })); + await waitFor(() => expect(updateTimelineNote).toHaveBeenCalledTimes(2)); + expect(updateTimelineNote.mock.calls[1][2]).toEqual({ expected_revision: 4, body: "Again" }); + }); + + it("opens from a click on the marked words, but not from a drag that selected text", () => { + renderSection([EXACT]); + + const selection = { isCollapsed: false } as Selection; + const getSelection = vi.spyOn(window, "getSelection").mockReturnValue(selection); + fireEvent.click(marks()[0]); + expect(screen.queryByRole("dialog")).toBeNull(); + + getSelection.mockReturnValue({ isCollapsed: true } as Selection); + fireEvent.click(marks()[0]); + expect( + within(screen.getByRole("dialog", { name: "Edit the note at 01:05" })).getByRole("textbox"), + ).toHaveValue("Check the figure"); + }); + + it("on a change made elsewhere, shows the note as it is now and keeps the text typed", async () => { + updateTimelineNote.mockRejectedValueOnce(conflict({ ...EXACT, body: "Changed on my phone", revision: 5 })); + renderSection([EXACT]); + + const editor = openFromLine("n-exact", "Check the figure"); + fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Mine" } }); + fireEvent.click(within(editor).getByRole("button", { name: "Save note" })); + + await within(editor).findByText( + "This note was changed elsewhere. It now reads “Changed on my phone”. Save again to keep yours.", + ); + expect(within(editor).getByRole("textbox")).toHaveValue("Mine"); + expect(noteLine("n-exact")).toHaveTextContent("Changed on my phone"); + + updateTimelineNote.mockResolvedValueOnce({ ...EXACT, body: "Mine", revision: 6 }); + fireEvent.click(within(editor).getByRole("button", { name: "Save note" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(updateTimelineNote.mock.calls[1][2]).toEqual({ expected_revision: 5, body: "Mine" }); + expect(noteLine("n-exact")).toHaveTextContent("Mine"); + }); + + it("counts a save sent again after a lost answer as saved, when the note already reads so", async () => { + updateTimelineNote.mockRejectedValueOnce(conflict({ ...EXACT, body: "Mine", revision: 4 })); + renderSection([EXACT]); + + const editor = openFromLine("n-exact", "Check the figure"); + fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Mine" } }); + fireEvent.click(within(editor).getByRole("button", { name: "Save note" })); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(noteLine("n-exact")).toHaveTextContent("Mine"); + }); + + it("deletes on a second press, against its revision: its line and marks go, focus to the transcript", async () => { + deleteTimelineNote.mockResolvedValue(undefined); + renderSection([EXACT]); + + const editor = openFromLine("n-exact", "Check the figure"); + fireEvent.click(within(editor).getByRole("button", { name: "Delete" })); + expect(deleteTimelineNote).not.toHaveBeenCalled(); + fireEvent.click(within(editor).getByRole("button", { name: "Confirm delete" })); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(deleteTimelineNote).toHaveBeenCalledWith("rec-1", "n-exact", { expectedRevision: 3 }); + expect(screen.queryByTestId("timeline-note")).toBeNull(); + expect(marks()).toHaveLength(0); + expect(document.activeElement).toHaveAttribute("id", "panel-transcript"); + }); + + it("keeps a note changed elsewhere when deleting it, and says what it reads now", async () => { + deleteTimelineNote.mockRejectedValueOnce(conflict({ ...EXACT, body: "Changed on my phone", revision: 5 })); + renderSection([EXACT]); + + const editor = openFromLine("n-exact", "Check the figure"); + fireEvent.click(within(editor).getByRole("button", { name: "Delete" })); + fireEvent.click(within(editor).getByRole("button", { name: "Confirm delete" })); + + await within(editor).findByText( + "This note was changed elsewhere. It now reads “Changed on my phone”. Delete again to remove it anyway.", + ); + expect(noteLine("n-exact")).toHaveTextContent("Changed on my phone"); + expect(marks()).toHaveLength(1); + }); +}); diff --git a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx index 631ed2edb..acde0f120 100644 --- a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx +++ b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx @@ -6,6 +6,7 @@ import { AlertCircle } from "lucide-react"; import TranscriptView from "@/components/TranscriptView"; import RecordingUnsavedNotes from "@/components/notes/RecordingUnsavedNotes"; import { inlineNotes, type OutboxDraft } from "@/lib/notes/noteOutbox"; +import { useNoteEdits, type EditableNote } from "@/lib/notes/useNoteEdits"; import { useNoteOutbox } from "@/lib/notes/useNoteOutbox"; import { RecordingStatus, @@ -96,14 +97,19 @@ export default function TranscriptSection({ saved: outboxSaved, discarded: outboxDiscarded, } = outboxState; + // Edits and deletes made here show at once: a finished recording's list + // is not fetched again. + const { apply: applyEdits, edit: editNote, remove: removeNote } = useNoteEdits(recording.id); const notes = useMemo( () => - inlineNotes( - timelineNotes, - { entries: outboxEntries, saved: outboxSaved, discarded: outboxDiscarded }, - SELECTION_NOTES, + applyEdits( + inlineNotes( + timelineNotes, + { entries: outboxEntries, saved: outboxSaved, discarded: outboxDiscarded }, + SELECTION_NOTES, + ), ), - [timelineNotes, outboxEntries, outboxSaved, outboxDiscarded], + [applyEdits, timelineNotes, outboxEntries, outboxSaved, outboxDiscarded], ); const sectionRef = useRef(null); const durationSeconds = recording.duration_seconds; @@ -120,6 +126,14 @@ export default function TranscriptSection({ }, [outbox, focusSection], ); + const deleteNote = useCallback( + async (note: EditableNote) => { + const refusal = await removeNote(note); + if (refusal === null) focusSection(); + return refusal; + }, + [removeNote, focusSection], + ); // A failed transcription is reported, never shown as an empty transcript, // which would let a crashed ASR run pass for a silent meeting. // Only while the recording is in ERROR: a retry or speaker-inference run @@ -193,6 +207,8 @@ export default function TranscriptSection({ onAddNote={addNote} onRetryNote={outbox.retry} onDiscardNote={discardNote} + onEditNote={editNote} + onDeleteNote={deleteNote} /> ) : ( diff --git a/frontend/src/components/TranscriptView.tsx b/frontend/src/components/TranscriptView.tsx index 04a438b9a..41e5a533e 100644 --- a/frontend/src/components/TranscriptView.tsx +++ b/frontend/src/components/TranscriptView.tsx @@ -8,7 +8,7 @@ import { TimelineNote, TranscriptSpeakerAssignment, } from "@/types"; -import { Fragment, useMemo, useState } from "react"; +import { Fragment, useMemo, useState, type MouseEvent } from "react"; import { Radio } from "lucide-react"; import { useTranscriptSearch } from "./transcript/_hooks/useTranscriptSearch"; import { useTranscriptScroll } from "./transcript/_hooks/useTranscriptScroll"; @@ -25,6 +25,11 @@ import { formatTime, isRecentlyUpdatedSegment } from "./transcript/transcriptDis import TranscriptGroup, { type TranscriptLineShared } from "./transcript/TranscriptGroup"; import TranscriptSelectionNotes from "./transcript/TranscriptSelectionToolbar"; import TranscriptToolbar from "./transcript/TranscriptToolbar"; +import TranscriptNoteEditor, { + type EditingNote, + type NoteEditHandlers, +} from "./transcript/TranscriptNoteEditor"; +import { isSavedNote, useNoteHighlights } from "./transcript/_hooks/useNoteHighlights"; import { bodyNotesAmongLines, placeTimelineNotes } from "@/lib/notes/placement"; import type { InlineNote, OutboxDraft } from "@/lib/notes/noteOutbox"; import CollapsedLineNotes from "./notes/CollapsedLineNotes"; @@ -79,6 +84,10 @@ interface TranscriptViewProps { onRetryNote?: (key: string) => void; /** Removes a failed note's line: the caller moves focus somewhere that stays. */ onDiscardNote?: (key: string) => void; + /** Saves a saved note's new text; without it, notes can't be edited here. */ + onEditNote?: NoteEditHandlers["onEditNote"]; + /** Deletes a saved note: the caller moves focus somewhere that stays. */ + onDeleteNote?: NoteEditHandlers["onDeleteNote"]; } // One empty list for every render, so the memoised lines it reaches hold. @@ -87,9 +96,6 @@ const NO_PENDING_UPDATES: readonly string[] = []; const NOTE_TIME_COLUMN = "min-w-16 md:min-w-[60px] shrink-0 pt-1 text-right font-mono text-sm text-contrast-icon-muted"; -/** A note the server holds; one still on its way, or refused, is not. */ -const isSavedNote = (note: TimelineNote | InlineNote) => !("status" in note) || note.status === "saved"; - /** * The finished transcript: toolbar, then the lines in groups of overlapping * speech, with the recording's notes among them. The parts live in @@ -127,6 +133,8 @@ export default function TranscriptView({ onAddNote, onRetryNote, onDiscardNote, + onEditNote, + onDeleteNote, }: TranscriptViewProps) { // Find & Replace toolbar visibility (search match state lives in the hook) const [showSearch, setShowSearch] = useState(false); @@ -224,6 +232,21 @@ export default function TranscriptView({ ), [notePlacements, displaySegments], ); + // Each saved selection note's words, found in the lines as they read now. + const { noteDecorations, wordsChanged } = useNoteHighlights(segments, timelineNotes); + const [editingNote, setEditingNote] = useState(null); + const canEditNote = (note: TimelineNote | InlineNote) => + onEditNote !== undefined && isSavedNote(note) && note.revision !== undefined; + // A click on a note's marked words edits the note, unless it ended a + // drag that selected text, which is for a new note or a copy. + const editNoteFromText = (event: MouseEvent) => { + const mark = + event.target instanceof Element ? event.target.closest("mark[data-note-id]") : null; + const noteId = mark?.dataset.noteId; + if (!mark || !noteId || !onEditNote) return; + if (window.getSelection()?.isCollapsed === false) return; + setEditingNote({ noteId, anchor: mark }); + }; const renderNote = (note: TimelineNote | InlineNote) => { const key = "key" in note ? note.key : note.id; return ( @@ -232,6 +255,12 @@ export default function TranscriptView({ noteId={note.id} body={note.body} quote={note.kind === "selection" ? note.quoted_text : null} + wordsChanged={note.kind === "selection" ? wordsChanged(note.id) : null} + onEdit={ + canEditNote(note) + ? (trigger) => setEditingNote({ noteId: note.id, anchor: trigger }) + : undefined + } timeLabel={formatTime( (note.kind === "selection" ? (note.start_ms ?? 0) : (note.anchor_ms ?? 0)) / 1000, )} @@ -335,6 +364,7 @@ export default function TranscriptView({ data-testid="transcript-scroll-region" className="space-y-4 px-2 md:px-4 pt-3 pb-clear-floating-3 overflow-y-auto flex-1 min-h-0" onScroll={updateScrollAnchor} + onClick={editNoteFromText} > {trackGroups.length === 0 ? (
@@ -380,6 +410,7 @@ export default function TranscriptView({ )} recentLineKeys={lineKeys(group.items, isRecentlyUpdatedSegment)} searchDecorations={search.searchDecorations} + noteDecorations={noteDecorations} editingSpeakerLineId={editing.editingSpeakerLineId} editingTextId={editing.editingTextId} activePopover={editing.activePopover} @@ -399,6 +430,15 @@ export default function TranscriptView({ ]}
+ {onEditNote ? ( + setEditingNote(null)} + /> + ) : null} ); } diff --git a/frontend/src/components/notes/NoteOnSelectionPopover.tsx b/frontend/src/components/notes/NoteOnSelectionPopover.tsx index cfb2af59b..aec2c10b6 100644 --- a/frontend/src/components/notes/NoteOnSelectionPopover.tsx +++ b/frontend/src/components/notes/NoteOnSelectionPopover.tsx @@ -4,17 +4,22 @@ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from import { createPortal } from "react-dom"; import { stripNote } from "@/lib/notes/headNoteSync"; -import type { NoteSelection } from "@/lib/notes/noteOutbox"; import Button from "../ui/Button"; import { useAnchoredPanel } from "../ui/useAnchoredPanel"; import { NOTE_BUTTON_TOUCH } from "./noteButtons"; +/** Why a write was refused, or null once it is done or on its way. */ +type Refusal = string | null; + interface NoteOnSelectionPopoverProps { - selection: NoteSelection; + /** The words the note is on; null for a note on a point in time. */ + quote: string | null; /** Names the popover: "Note on the line at 01:05". */ title: string; + /** The note's text when it opens: an edit's, or empty for a new note. */ + initialText?: string; /** * What it sits against, which stays mounted while it is open: a line's ⋯ * button, or the place of a text selection. Focus goes back there when it @@ -23,27 +28,36 @@ interface NoteOnSelectionPopoverProps { anchor: HTMLElement; /** Puts focus back when it closes, for an anchor that can't take focus. */ returnFocus?: () => void; - /** Queues the note; returns why it was refused, or null once it is on its way. */ - onSubmit: (body: string) => string | null; + /** + * Queues or saves the note. Returns, or resolves to, why it was refused, and + * the popover stays open with the text as typed; null closes it. + */ + onSubmit: (body: string) => Refusal | Promise; + /** Deletes the note, after a second press to confirm; null closes the popover. */ + onDelete?: () => Promise; onClose: () => void; } /** - * A note on chosen transcript text, in the finished view: the quote, a box - * and Save. Enter saves and Shift+Enter starts a new line, as in the live - * note box; Escape and Cancel close it. A press outside closes it only while - * the box is empty, so a stray tap never throws a note away. + * A note in the finished view: the quote it is on, a box and Save, and for a + * note being edited, Delete. Enter saves and Shift+Enter starts a new line, as + * in the live note box; Escape and Cancel close it. A press outside closes it + * only while the text is as it opened, so a stray tap never throws a note away. */ export default function NoteOnSelectionPopover({ - selection, + quote, title, + initialText = "", anchor, returnFocus, onSubmit, + onDelete, onClose, }: NoteOnSelectionPopoverProps) { - const [text, setText] = useState(""); + const [text, setText] = useState(initialText); const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); const anchorRef = useMemo(() => ({ current: anchor }), [anchor]); const { panelRef, panelStyle } = useAnchoredPanel(true, anchorRef); const boxRef = useRef(null); @@ -51,6 +65,7 @@ export default function NoteOnSelectionPopover({ const titleId = useId(); const errorId = useId(); const isBlank = stripNote(text) === ""; + const isUnchanged = stripNote(text) === stripNote(initialText); const close = () => { onClose(); @@ -65,7 +80,7 @@ export default function NoteOnSelectionPopover({ }, [placed]); useEffect(() => { - if (!isBlank) return; + if (!isUnchanged) return; const closeOnOutsidePress = (event: PointerEvent) => { const target = event.target as Node; if (panelRef.current?.contains(target) || anchor.contains(target)) return; @@ -73,16 +88,42 @@ export default function NoteOnSelectionPopover({ }; document.addEventListener("pointerdown", closeOnOutsidePress, true); return () => document.removeEventListener("pointerdown", closeOnOutsidePress, true); - }, [isBlank, anchor, onClose, panelRef]); + }, [isUnchanged, anchor, onClose, panelRef]); + + const finish = (refusal: Refusal) => { + if (refusal) setError(refusal); + else close(); + }; + + /** + * Runs a write; closes once it is done or on its way, else says why and + * keeps the text. A queued note answers at once; a save waits for Nojoin. + */ + const settle = (write: () => Refusal | Promise) => { + const outcome = write(); + if (!(outcome instanceof Promise)) { + finish(outcome); + return; + } + setBusy(true); + void outcome.then((refusal) => { + setBusy(false); + finish(refusal); + }); + }; const save = () => { - if (isBlank) return; - const refusal = onSubmit(text); - if (refusal) { - setError(refusal); + if (isBlank || busy) return; + settle(() => onSubmit(text)); + }; + + const remove = () => { + if (!onDelete || busy) return; + if (!confirmingDelete) { + setConfirmingDelete(true); return; } - close(); + settle(onDelete); }; const handleKeyDown = (event: KeyboardEvent) => { @@ -109,7 +150,7 @@ export default function NoteOnSelectionPopover({ ); const edge = event.shiftKey ? stops[0] : stops[stops.length - 1]; if (document.activeElement !== edge) return; - if (isBlank) { + if (isUnchanged) { close(); return; } @@ -135,9 +176,11 @@ export default function NoteOnSelectionPopover({

{title}

-
- {selection.quotedText} -
+ {quote ? ( +
+ {quote} +
+ ) : null}