diff --git a/packages/studio-web/src/app/shared/shared.module.ts b/packages/studio-web/src/app/shared/shared.module.ts index ff68a7370..6740d9d43 100644 --- a/packages/studio-web/src/app/shared/shared.module.ts +++ b/packages/studio-web/src/app/shared/shared.module.ts @@ -1,13 +1,23 @@ import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; import { DownloadComponent } from "./download/download.component"; +import { TextareaOverlayComponent } from "./textarea-overlay/textarea-overlay.component"; +import { TextareaGutterComponent } from "./textarea-overlay/textarea-gutter.component"; import { BrowserModule } from "@angular/platform-browser"; import { MaterialModule } from "../material.module"; import { FormsModule } from "@angular/forms"; @NgModule({ - declarations: [DownloadComponent], + declarations: [ + DownloadComponent, + TextareaOverlayComponent, + TextareaGutterComponent, + ], imports: [BrowserModule, MaterialModule, FormsModule, CommonModule], - exports: [DownloadComponent], + exports: [ + DownloadComponent, + TextareaOverlayComponent, + TextareaGutterComponent, + ], }) export class SharedModule {} diff --git a/packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.spec.ts b/packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.spec.ts new file mode 100644 index 000000000..1b7548298 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.spec.ts @@ -0,0 +1,55 @@ +import { findBlankLineRuns } from "./blank-line-runs"; + +describe("findBlankLineRuns", () => { + it("returns nothing for text with no blank lines", () => { + expect(findBlankLineRuns("hello\nworld")).toEqual([]); + }); + + it("classifies a single blank line as a paragraph break", () => { + const runs = findBlankLineRuns("line one\n\nline two"); + expect(runs).toEqual([ + { startLine: 1, endLine: 1, length: 1, kind: "paragraph" }, + ]); + }); + + it("classifies two consecutive blank lines as a page break", () => { + const runs = findBlankLineRuns("line one\n\n\nline two"); + expect(runs).toEqual([ + { startLine: 1, endLine: 2, length: 2, kind: "page" }, + ]); + }); + + it("classifies more than two consecutive blank lines as a page break", () => { + const runs = findBlankLineRuns("line one\n\n\n\nline two"); + expect(runs).toEqual([ + { startLine: 1, endLine: 3, length: 3, kind: "page" }, + ]); + }); + + it("finds multiple runs", () => { + const runs = findBlankLineRuns("a\n\nb\n\n\nc"); + expect(runs).toEqual([ + { startLine: 1, endLine: 1, length: 1, kind: "paragraph" }, + { startLine: 3, endLine: 4, length: 2, kind: "page" }, + ]); + }); + + it("handles blank lines at the start and end of the text", () => { + const runs = findBlankLineRuns("\n\na\nb\n\n"); + expect(runs).toEqual([ + { startLine: 0, endLine: 1, length: 2, kind: "page" }, + { startLine: 4, endLine: 5, length: 2, kind: "page" }, + ]); + }); + + it("returns nothing for empty text", () => { + expect(findBlankLineRuns("")).toEqual([]); + }); + + it("treats a whitespace-only line as non-blank", () => { + // A line with a stray space is not a true blank line, so it should + // not be counted as part of a paragraph/page break run. + const runs = findBlankLineRuns("a\n \nb"); + expect(runs).toEqual([]); + }); +}); diff --git a/packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.ts b/packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.ts new file mode 100644 index 000000000..2c0f7683f --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.ts @@ -0,0 +1,56 @@ +/** + * A run's `kind` follows the ReadAlong plain-text convention: a single + * blank line separates paragraphs, two or more consecutive blank lines + * separate pages. + */ +export type BlankLineRunKind = "paragraph" | "page"; + +export interface BlankLineRun { + /** 0-based index (into `text.split("\n")`) of the first blank line. */ + startLine: number; + /** 0-based index of the last blank line in the run (inclusive). */ + endLine: number; + /** Number of consecutive blank lines in the run. */ + length: number; + kind: BlankLineRunKind; +} + +/** + * Finds every run of one or more consecutive blank lines in `text`. + * + * Shared between the overlay's background tint and, later, the gutter + * markers, so it deliberately returns raw line-index data rather than + * anything rendering-specific. + */ +export function findBlankLineRuns(text: string): BlankLineRun[] { + if (text === "") { + // An empty document has no lines to separate, not one blank line. + return []; + } + + const lines = text.split("\n"); + const runs: BlankLineRun[] = []; + let runStart: number | null = null; + + for (let i = 0; i <= lines.length; i++) { + const isBlankLine = i < lines.length && lines[i].length === 0; + if (isBlankLine) { + if (runStart === null) { + runStart = i; + } + continue; + } + if (runStart !== null) { + const length = i - runStart; + runs.push({ + startLine: runStart, + endLine: i - 1, + length, + kind: length >= 2 ? "page" : "paragraph", + }); + runStart = null; + } + } + + return runs; +} diff --git a/packages/studio-web/src/app/shared/textarea-overlay/document-words.spec.ts b/packages/studio-web/src/app/shared/textarea-overlay/document-words.spec.ts new file mode 100644 index 000000000..4618df482 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/document-words.spec.ts @@ -0,0 +1,125 @@ +import { + diffWordSequences, + remapWordIndex, + walkDocumentWords, +} from "./document-words"; + +describe("walkDocumentWords", () => { + it("returns nothing for empty text", () => { + expect(walkDocumentWords("")).toEqual([]); + }); + + it("splits a single line on whitespace, tracking offsets", () => { + const text = "hej verden 2"; + expect(walkDocumentWords(text)).toEqual([ + { text: "hej", start: 0, end: 3 }, + { text: "verden", start: 4, end: 10 }, + { text: "2", start: 11, end: 12 }, + ]); + }); + + it("collapses runs of whitespace between words", () => { + const text = "one two\tthree"; + expect(walkDocumentWords(text)).toEqual([ + { text: "one", start: 0, end: 3 }, + { text: "two", start: 6, end: 9 }, + { text: "three", start: 10, end: 15 }, + ]); + }); + + it("tracks offsets correctly across multiple lines", () => { + const text = "line one\nline two"; + expect(walkDocumentWords(text)).toEqual([ + { text: "line", start: 0, end: 4 }, + { text: "one", start: 5, end: 8 }, + { text: "line", start: 9, end: 13 }, + { text: "two", start: 14, end: 17 }, + ]); + expect(text.slice(9, 13)).toBe("line"); + expect(text.slice(14, 17)).toBe("two"); + }); + + it("contributes no words for blank paragraph/page break lines", () => { + const text = "para one\n\npara two\n\n\npage two"; + const words = walkDocumentWords(text); + expect(words.map((w) => w.text)).toEqual([ + "para", + "one", + "para", + "two", + "page", + "two", + ]); + for (const word of words) { + expect(text.slice(word.start, word.end)).toBe(word.text); + } + }); + + it("ignores leading/trailing whitespace on a line", () => { + const text = " leading and trailing "; + const words = walkDocumentWords(text); + expect(words[0]).toEqual({ text: "leading", start: 2, end: 9 }); + const last = words[words.length - 1]; + expect(last).toEqual({ text: "trailing", start: 14, end: 22 }); + }); +}); + +function words(text: string) { + return walkDocumentWords(text); +} + +describe("diffWordSequences / remapWordIndex", () => { + it("maps every index unchanged when nothing changed", () => { + const oldWords = words("hello 1 2 3"); + const newWords = words("hello 1 2 3"); + const diff = diffWordSequences(oldWords, newWords); + for (let i = 0; i < oldWords.length; i++) { + expect(remapWordIndex(diff, i)).toBe(i); + } + }); + + it("remaps a word after a deletion earlier in the sequence, rather than dropping it (regression: deleting one flagged word cleared unrelated marks after it)", () => { + // "hello 1 2 3" -> delete "2 " -> "hello 1 3" + const oldWords = words("hello 1 2 3"); + const newWords = words("hello 1 3"); + const diff = diffWordSequences(oldWords, newWords); + + // "1" (index 1) is untouched and before the edit: keeps its index. + expect(remapWordIndex(diff, 1)).toBe(1); + // "2" (index 2) was the word actually deleted: no longer present. + expect(remapWordIndex(diff, 2)).toBeNull(); + // "3" (index 3) shifted left to index 2, but is still "3" — must not + // be dropped just because a word before it was removed. + const newIndex = remapWordIndex(diff, 3); + expect(newIndex).toBe(2); + expect(newWords[newIndex!].text).toBe("3"); + }); + + it("remaps indices after an insertion earlier in the sequence", () => { + const oldWords = words("hello 1 2 3"); + const newWords = words("hello there 1 2 3"); + const diff = diffWordSequences(oldWords, newWords); + + expect(remapWordIndex(diff, 1)).toBe(2); // "1" shifted right by 1 + expect(remapWordIndex(diff, 3)).toBe(4); // "3" shifted right by 1 + }); + + it("returns null for an index whose word was actually edited", () => { + const oldWords = words("hello 1 2 3"); + const newWords = words("hello 1 two 3"); + const diff = diffWordSequences(oldWords, newWords); + + expect(remapWordIndex(diff, 1)).toBe(1); // "1" unaffected + expect(remapWordIndex(diff, 2)).toBeNull(); // "2" -> "two": edited + expect(remapWordIndex(diff, 3)).toBe(3); // "3" unaffected + }); + + it("returns null for every index when the whole sequence changed", () => { + const oldWords = words("hello 1 2 3"); + const newWords = words("completely different text"); + const diff = diffWordSequences(oldWords, newWords); + for (let i = 0; i < oldWords.length; i++) { + expect(remapWordIndex(diff, i)).toBeNull(); + } + }); +}); diff --git a/packages/studio-web/src/app/shared/textarea-overlay/document-words.ts b/packages/studio-web/src/app/shared/textarea-overlay/document-words.ts new file mode 100644 index 000000000..356019d02 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/document-words.ts @@ -0,0 +1,113 @@ +/** A character range in the raw document string: [start, end). */ +export interface TextRange { + start: number; + end: number; +} + +export interface DocumentWord extends TextRange { + /** The word's literal text (no surrounding whitespace). */ + text: string; +} + +/** + * Walks `text` in the same page → paragraph → line → word order used to + * build an alignment request, returning each word with its character + * offsets in the raw string. + * + * Blank separator lines (paragraph/page breaks) contribute no words, since + * they're pure whitespace by definition — no separate pass over + * findBlankLineRuns is needed to skip them. + * + * Shared by the g2p error underlining (matching a submitted word against + * the corresponding `` in the API's error response) and, later, any + * other feature that needs to map "the Nth word of the document" to "its + * range in the raw text." + */ +export function walkDocumentWords(text: string): DocumentWord[] { + const words: DocumentWord[] = []; + const lines = text.split("\n"); + + let lineStart = 0; + for (const line of lines) { + const wordPattern = /\S+/g; + let match: RegExpExecArray | null; + while ((match = wordPattern.exec(line)) !== null) { + words.push({ + text: match[0], + start: lineStart + match.index, + end: lineStart + match.index + match[0].length, + }); + } + lineStart += line.length + 1; // +1 for the "\n" this line was split on + } + + return words; +} + +/** + * Describes where two word sequences (e.g. the document's words before and + * after an edit) stop agreeing at the start, and resume agreeing at the + * end — a cheap (O(n)) alternative to a full sequence alignment, good + * enough to remap word indices across a *local* edit: everything before + * `prefixLength` and everything from `oldSuffixStart`/`newSuffixStart` + * onward is unchanged; everything between is the edited region. + */ +export interface WordSequenceDiff { + prefixLength: number; + oldSuffixStart: number; + newSuffixStart: number; +} + +export function diffWordSequences( + oldWords: readonly DocumentWord[], + newWords: readonly DocumentWord[], +): WordSequenceDiff { + const maxPrefix = Math.min(oldWords.length, newWords.length); + let prefixLength = 0; + while ( + prefixLength < maxPrefix && + oldWords[prefixLength].text === newWords[prefixLength].text + ) { + prefixLength++; + } + + const maxSuffix = Math.min( + oldWords.length - prefixLength, + newWords.length - prefixLength, + ); + let suffixLength = 0; + while ( + suffixLength < maxSuffix && + oldWords[oldWords.length - 1 - suffixLength].text === + newWords[newWords.length - 1 - suffixLength].text + ) { + suffixLength++; + } + + return { + prefixLength, + oldSuffixStart: oldWords.length - suffixLength, + newSuffixStart: newWords.length - suffixLength, + }; +} + +/** + * Maps `oldIndex` (an index into the word list `diff` was computed from) + * onto the corresponding index in the newer word list, using the + * unchanged prefix/suffix regions `diff` identified. Returns `null` if + * `oldIndex` falls in the edited middle region, where it has no reliable + * correspondent — words genuinely changed there, so callers should treat + * this as "no longer the same word" rather than guess. + */ +export function remapWordIndex( + diff: WordSequenceDiff, + oldIndex: number, +): number | null { + if (oldIndex < diff.prefixLength) { + return oldIndex; + } + if (oldIndex >= diff.oldSuffixStart) { + return diff.newSuffixStart + (oldIndex - diff.oldSuffixStart); + } + return null; +} diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.html b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.html new file mode 100644 index 000000000..d0e2e3552 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.html @@ -0,0 +1,15 @@ + + diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.sass b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.sass new file mode 100644 index 000000000..8c3c0d289 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.sass @@ -0,0 +1,45 @@ +// Static, non-interactive column for now (click behavior comes later) — +// see TextareaGutterComponent for why row heights are set explicitly in +// JS rather than derived from line-height here. +:host + display: contents + +.textarea-gutter + position: relative + flex: 0 0 28px + width: 28px + overflow: hidden + box-sizing: border-box + background-color: rgba(0, 0, 0, 0.025) + border-right: 1px solid rgba(0, 0, 0, 0.1) + + &__row + display: flex + align-items: center + justify-content: center + overflow: hidden + + &__pilcrow + font-size: 15px + line-height: 1 + color: #3f51b5 + opacity: 0.65 + + &__page-icon + font-size: 15px + width: 15px + height: 15px + line-height: 15px + color: #c2185b + +// Off-screen full-width mirror used only to measure wrapped line heights; +// never shown, never a source of truth for anything rendered above. +.textarea-gutter__measurer + position: absolute + top: 0 + left: 0 + visibility: hidden + pointer-events: none + white-space: pre-wrap + word-wrap: break-word + overflow-wrap: break-word diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.spec.ts b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.spec.ts new file mode 100644 index 000000000..bdd2e83a2 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.spec.ts @@ -0,0 +1,119 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { MaterialModule } from "../../material.module"; +import { TextareaGutterComponent } from "./textarea-gutter.component"; + +// A real template binding (rather than poking `component.text` directly) +// so `text`/`textareaEl` flow through Angular's normal top-down change +// detection on every `fixture.detectChanges()` call — see +// TextareaOverlayComponent's spec for why this matters. +@Component({ + template: ``, + standalone: false, +}) +class HostComponent { + text = ""; + textareaEl: HTMLTextAreaElement | null = null; +} + +describe("TextareaGutterComponent", () => { + let hostFixture: ComponentFixture; + let host: HostComponent; + let textarea: HTMLTextAreaElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MaterialModule], + declarations: [TextareaGutterComponent, HostComponent], + }).compileComponents(); + + hostFixture = TestBed.createComponent(HostComponent); + host = hostFixture.componentInstance; + + // The gutter only measures/renders rows once it has a textarea to + // mirror, so every test needs a real, attached one. + textarea = document.createElement("textarea"); + textarea.style.width = "300px"; + textarea.style.height = "200px"; + document.body.appendChild(textarea); + host.textareaEl = textarea; + }); + + afterEach(() => { + textarea.remove(); + }); + + function rowEls(): NodeListOf { + return hostFixture.nativeElement.querySelectorAll(".textarea-gutter__row"); + } + + it("should create", () => { + hostFixture.detectChanges(); + expect( + hostFixture.debugElement.query(By.css("app-textarea-gutter")), + ).toBeTruthy(); + }); + + it("renders one row per line with no marker for ordinary text", () => { + host.text = "line one\nline two"; + hostFixture.detectChanges(); + + const rows = rowEls(); + expect(rows.length).toBe(2); + rows.forEach((row) => { + expect(row.querySelector(".textarea-gutter__pilcrow")).toBeNull(); + expect(row.querySelector(".textarea-gutter__page-icon")).toBeNull(); + }); + }); + + it("renders a pilcrow on a single blank line (paragraph break)", () => { + host.text = "para one\n\npara two"; + hostFixture.detectChanges(); + + const rows = rowEls(); + expect(rows.length).toBe(3); + expect(rows[1].querySelector(".textarea-gutter__pilcrow")).not.toBeNull(); + expect(rows[1].querySelector(".textarea-gutter__page-icon")).toBeNull(); + }); + + it("merges a page-break run into a single row with a page icon", () => { + host.text = "page one\n\n\npage two"; + hostFixture.detectChanges(); + + const rows = rowEls(); + // 2 text rows + 1 merged blank-run row, not 2 text rows + 2 blank rows. + expect(rows.length).toBe(3); + expect(rows[1].querySelector(".textarea-gutter__page-icon")).not.toBeNull(); + expect(rows[1].querySelector(".textarea-gutter__pilcrow")).toBeNull(); + }); + + it("sizes the merged page-break row taller than an ordinary row", () => { + host.text = "a\n\n\nb"; + hostFixture.detectChanges(); + + const rows = rowEls(); + const ordinaryHeight = rows[0].getBoundingClientRect().height; + const pageRowHeight = rows[1].getBoundingClientRect().height; + expect(pageRowHeight).toBeGreaterThan(ordinaryHeight); + }); + + it("syncs scroll position with the textarea", () => { + textarea.style.height = "20px"; + textarea.value = Array.from({ length: 50 }, (_, i) => `line ${i}`).join( + "\n", + ); + host.text = textarea.value; + hostFixture.detectChanges(); + + textarea.scrollTop = 5; + textarea.dispatchEvent(new Event("scroll")); + + const gutter: HTMLElement = + hostFixture.nativeElement.querySelector(".textarea-gutter"); + expect(gutter.scrollTop).toBe(textarea.scrollTop); + }); +}); diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.ts b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.ts new file mode 100644 index 000000000..c3db29af0 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.ts @@ -0,0 +1,195 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from "@angular/core"; + +import { BlankLineRunKind, findBlankLineRuns } from "./blank-line-runs"; +import { TextareaOverlayService } from "./textarea-overlay.service"; + +interface GutterRow { + heightPx: number; + marker: BlankLineRunKind | null; +} + +/** + * Renders a narrow, non-interactive marker column beside (not behind) a + * ` + data-test-id="ras-text-input" + > + + diff --git a/packages/studio-web/src/app/upload/upload.component.sass b/packages/studio-web/src/app/upload/upload.component.sass index 1901963b1..66ffe495b 100644 --- a/packages/studio-web/src/app/upload/upload.component.sass +++ b/packages/studio-web/src/app/upload/upload.component.sass @@ -1,7 +1,37 @@ -#textInput +// Positioning context for the textarea-overlay decoration layer, and now +// the home of the visible box chrome that used to live on #textInput +// directly: the textarea itself is made transparent (see below) so the +// overlay can render tinted backgrounds behind its text, which means it +// can no longer paint its own opaque background/border without hiding +// the overlay. +#textInputHost + position: relative + display: flex + align-items: stretch border: 1px solid #222 + overflow: hidden min-height: 150px + +// Wraps the overlay + textarea pair from prompt 1 so the gutter can sit +// beside them as its own flex column without disturbing their existing +// position: relative / absolute-overlay relationship. +.textarea-editor-main + position: relative + flex: 1 1 auto + min-width: 0 + +#textInput font-family: 'BCSans', 'Noto Sans', 'Verdana', 'Arial', 'sans-serif' + position: relative + z-index: 1 + background: transparent + color: transparent + -webkit-text-fill-color: transparent + caret-color: #222 + + &::placeholder + color: #6c757d + -webkit-text-fill-color: #6c757d .audioControl width: 100% diff --git a/packages/studio-web/src/app/upload/upload.component.spec.ts b/packages/studio-web/src/app/upload/upload.component.spec.ts index f5bc8b965..8caac1821 100644 --- a/packages/studio-web/src/app/upload/upload.component.spec.ts +++ b/packages/studio-web/src/app/upload/upload.component.spec.ts @@ -34,4 +34,169 @@ describe("UploadComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + describe("reportRasError — g2p errors", () => { + const PARTIAL_RAS = ` + + + +
+

+ hej verden 2 +

+
+ +
+
`; + + // Simulates a request having just been submitted with `text`, and the + // textarea still holding that same value (the common case — the + // request typically resolves before the user types anything else). + function setLastSubmittedText(text: string): void { + ( + component as unknown as { lastSubmittedText: string } + ).lastSubmittedText = text; + component.studioService.$textInput.next(text); + } + + it("underlines the failing word and shows the adjusted toast", () => { + setLastSubmittedText("hej verden 2"); + const toastrSpy = spyOn((component as any).toastr, "error"); + + let ranges: { start: number; end: number }[] = []; + component.studioService.$textInputErrors.subscribe((r) => (ranges = r)); + + component.reportRasError({ + status: 422, + error: { + detail: "g2p could not be performed...", + g2p_error_words: ["2"], + partial_ras: PARTIAL_RAS, + }, + } as any); + + expect(ranges).toEqual([{ start: 11, end: 12 }]); + expect(toastrSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/highlighted text/), + jasmine.any(String), + jasmine.any(Object), + ); + }); + + it("falls back to the generic toast when the error has no partial_ras", () => { + setLastSubmittedText("hej verden 2"); + const toastrSpy = spyOn((component as any).toastr, "error"); + + component.reportRasError({ + status: 422, + error: { detail: "some generic failure" }, + } as any); + + expect(toastrSpy).toHaveBeenCalledWith( + "some generic failure", + jasmine.any(String), + jasmine.any(Object), + ); + }); + + it("falls back to the generic toast when word counts don't line up", () => { + setLastSubmittedText("hej verden 2 extra"); + const toastrSpy = spyOn((component as any).toastr, "error"); + + component.reportRasError({ + status: 422, + error: { + detail: "g2p could not be performed...", + g2p_error_words: ["2"], + partial_ras: PARTIAL_RAS, + }, + } as any); + + expect(toastrSpy).toHaveBeenCalledWith( + "g2p could not be performed...", + jasmine.any(String), + jasmine.any(Object), + ); + }); + + it("clears the error mark once the user edits that word", () => { + setLastSubmittedText("hej verden 2"); + spyOn((component as any).toastr, "error"); + component.reportRasError({ + status: 422, + error: { + detail: "x", + g2p_error_words: ["2"], + partial_ras: PARTIAL_RAS, + }, + } as any); + + let ranges: { start: number; end: number }[] = []; + component.studioService.$textInputErrors.subscribe((r) => (ranges = r)); + expect(ranges.length).toBe(1); + + component.studioService.$textInput.next("hej verden two"); + expect(ranges).toEqual([]); + }); + + it("keeps the error mark when a different word is edited", () => { + setLastSubmittedText("hej verden 2"); + spyOn((component as any).toastr, "error"); + component.reportRasError({ + status: 422, + error: { + detail: "x", + g2p_error_words: ["2"], + partial_ras: PARTIAL_RAS, + }, + } as any); + + const newText = "hi verden 2"; + component.studioService.$textInput.next(newText); + + let ranges: { start: number; end: number }[] = []; + component.studioService.$textInputErrors.subscribe((r) => (ranges = r)); + const expectedStart = newText.indexOf("2"); + expect(ranges).toEqual([ + { start: expectedStart, end: expectedStart + 1 }, + ]); + }); + + it("regression: deleting one flagged word doesn't clear a different flagged word after it", () => { + // "hello 1 2 3" — 1, 2 and 3 all fail g2p. + setLastSubmittedText("hello 1 2 3"); + spyOn((component as any).toastr, "error"); + const partialRas = `

+ hello + 1 + 2 + 3 +

`; + component.reportRasError({ + status: 422, + error: { + detail: "x", + g2p_error_words: ["1", "2", "3"], + partial_ras: partialRas, + }, + } as any); + + let ranges: { start: number; end: number }[] = []; + component.studioService.$textInputErrors.subscribe((r) => (ranges = r)); + expect(ranges.length).toBe(3); + + // Delete "2 " (the middle flagged word plus its trailing space). + const newText = "hello 1 3"; + component.studioService.$textInput.next(newText); + + // "1" and "3" are both still present (just shifted) and must stay + // flagged; only "2" (actually removed) should drop off. + const oneStart = newText.indexOf("1"); + const threeStart = newText.indexOf("3"); + expect(ranges).toEqual([ + { start: oneStart, end: oneStart + 1 }, + { start: threeStart, end: threeStart + 1 }, + ]); + }); + }); }); diff --git a/packages/studio-web/src/app/upload/upload.component.ts b/packages/studio-web/src/app/upload/upload.component.ts index 67bb12852..9e36e0b6b 100644 --- a/packages/studio-web/src/app/upload/upload.component.ts +++ b/packages/studio-web/src/app/upload/upload.component.ts @@ -46,6 +46,16 @@ import { StudioService } from "../studio/studio.service"; import { validateFileType } from "../utils/utils"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { IAudioBuffer } from "standardized-audio-context"; +import { + DocumentWord, + diffWordSequences, + remapWordIndex, + walkDocumentWords, +} from "../shared/textarea-overlay/document-words"; +import { + isG2pAssembleErrorBody, + mapG2pErrorsToRanges, +} from "./g2p-error-mapping"; @Component({ selector: "app-upload", @@ -70,6 +80,16 @@ export class UploadComponent implements OnInit { // Later bumped to 400 to accommodate a real life use case. private maxRasSizeKB = 400; private currentToast: number; + // The exact text sent in the last alignment request — g2p error offsets + // must be resolved against this, not the (possibly since-edited) live + // textarea value. + private lastSubmittedText = ""; + // Indices (into trackedWords) of words currently flagged by a g2p error. + private erroringIndices: number[] = []; + // The word sequence erroringIndices refers to, i.e. as of the last + // syncErrorMarks() call — needed to remap indices forward across edits + // elsewhere in the document. See syncErrorMarks(). + private trackedWords: DocumentWord[] = []; @ViewChild("textFileUpload") private textFileUpload: ElementRef; @ViewChild("audioFileUpload") @@ -143,6 +163,47 @@ export class UploadComponent implements OnInit { new Blob([textString], { type: "text/plain" }), ); }); + + // Keep g2p error underlines in sync with edits: remap their current + // positions across the latest edit, dropping any that fell inside it. + // Unfiltered (unlike the subscription above) so clearing the textarea + // also clears any lingering marks. + this.studioService.$textInput + .pipe(takeUntilDestroyed(this.destroyRef$)) + .subscribe((text) => this.syncErrorMarks(text)); + } + + /** + * Remaps `erroringIndices` from `trackedWords` onto `currentText`'s word + * sequence, dropping any that fell inside the edited region, and + * publishes the survivors' current character ranges for the overlay to + * underline. + * + * This is index-based rather than a simple "does the word at its + * original index still read the same" check: editing *any* word shifts + * the indices of every word after it, so a naive check would spuriously + * drop unrelated marks whenever an earlier flagged word was edited + * (e.g. "hello 1 2 3" with 1/2/3 all flagged — deleting "2" alone must + * not also clear the mark on "3", which simply moved to a new index). + * diffWordSequences finds the unaffected prefix/suffix around the edit + * so indices outside it can be carried forward correctly instead. + */ + private syncErrorMarks(currentText: string): void { + if (this.erroringIndices.length === 0) { + return; + } + const currentWords = walkDocumentWords(currentText); + const diff = diffWordSequences(this.trackedWords, currentWords); + this.erroringIndices = this.erroringIndices + .map((index) => remapWordIndex(diff, index)) + .filter((index): index is number => index !== null); + this.trackedWords = currentWords; + this.studioService.$textInputErrors.next( + this.erroringIndices.map((index) => { + const word = currentWords[index]; + return { start: word.start, end: word.end }; + }), + ); } async ngOnInit() { @@ -168,6 +229,18 @@ export class UploadComponent implements OnInit { reportRasError(err: HttpErrorResponse) { if (err.status == 422) { + if ( + isG2pAssembleErrorBody(err.error) && + this.applyG2pErrorRanges(err.error.partial_ras) + ) { + this.toastr.error( + $localize`Some words couldn't be processed — see the highlighted text.`, + $localize`Text processing failed.`, + { timeOut: 30000, closeButton: true }, + ); + return; + } + if (err.error.detail.includes("is empty")) { this.toastr.error( $localize`Your text may contain unpronounceable characters or numbers. @@ -190,6 +263,31 @@ Please check it to make sure all words are spelled out completely, e.g. write "4 } } + /** + * Attempts to positionally map a g2p assemble error onto the words that + * were actually submitted, and — if that succeeds — flags them for the + * overlay to underline. Returns false (without flagging anything) if the + * mapping isn't trustworthy, so the caller can fall back to the generic + * error toast rather than silently pointing at the wrong word. + */ + private applyG2pErrorRanges(partialRas: string | undefined): boolean { + if (!partialRas) { + return false; + } + const errors = mapG2pErrorsToRanges(this.lastSubmittedText, partialRas); + if (errors === null || errors.length === 0) { + return false; + } + // Seed tracking from the submitted text's word sequence, then + // immediately sync forward to whatever the textarea holds *now* (the + // user may have kept typing while the request was in flight) — same + // remapping logic as any later edit. + this.trackedWords = walkDocumentWords(this.lastSubmittedText); + this.erroringIndices = errors.map((error) => error.index); + this.syncErrorMarks(this.studioService.$textInput.value); + return true; + } + reportUnpronounceableError(err: Error) { this.toastr.error( $localize`Unable to align even with loose alignment parameters. Please check your text and audio for quality and make sure they are a good match.`, @@ -499,6 +597,11 @@ Please check it to make sure all words are spelled out completely, e.g. write "4 this.studioService.textControl$.setValue(inputText); } + // Don't leave stale g2p error marks from a previous failed attempt + // showing while this new one is in flight. + this.erroringIndices = []; + this.studioService.$textInputErrors.next([]); + // Show progress bar this.loading = true; this.progressMode = "query"; @@ -532,6 +635,7 @@ Please check it to make sure all words are spelled out completely, e.g. write "4 this.fileService.readFile$(this.studioService.textControl$.value!).pipe( switchMap((text: string): Observable => { body.input = text; + this.lastSubmittedText = text; this.progressMode = "determinate"; this.progressValue = 0; return this.rasService.assembleReadalong$(body);