From e74d3b9a6b3fa59ebe28834030cf332ab746d109 Mon Sep 17 00:00:00 2001 From: Aidan Pine Date: Thu, 2 Jul 2026 16:08:56 -0700 Subject: [PATCH 1/3] feat: add paragraph and new page highlighting accomplished via a shared text overlay component --- .../src/app/shared/shared.module.ts | 5 +- .../textarea-overlay/blank-line-runs.spec.ts | 55 ++++++ .../textarea-overlay/blank-line-runs.ts | 56 +++++++ .../textarea-overlay.component.html | 11 ++ .../textarea-overlay.component.sass | 34 ++++ .../textarea-overlay.component.spec.ts | 113 +++++++++++++ .../textarea-overlay.component.ts | 156 ++++++++++++++++++ .../textarea-overlay.service.spec.ts | 90 ++++++++++ .../textarea-overlay.service.ts | 79 +++++++++ .../src/app/upload/upload.component.html | 31 ++-- .../src/app/upload/upload.component.sass | 21 ++- 11 files changed, 636 insertions(+), 15 deletions(-) create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.spec.ts create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/blank-line-runs.ts create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.html create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.service.spec.ts create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.service.ts diff --git a/packages/studio-web/src/app/shared/shared.module.ts b/packages/studio-web/src/app/shared/shared.module.ts index ff68a7370..71c985994 100644 --- a/packages/studio-web/src/app/shared/shared.module.ts +++ b/packages/studio-web/src/app/shared/shared.module.ts @@ -1,13 +1,14 @@ 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 { BrowserModule } from "@angular/platform-browser"; import { MaterialModule } from "../material.module"; import { FormsModule } from "@angular/forms"; @NgModule({ - declarations: [DownloadComponent], + declarations: [DownloadComponent, TextareaOverlayComponent], imports: [BrowserModule, MaterialModule, FormsModule, CommonModule], - exports: [DownloadComponent], + exports: [DownloadComponent, TextareaOverlayComponent], }) 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/textarea-overlay.component.html b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.html new file mode 100644 index 000000000..b37aa6903 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.html @@ -0,0 +1,11 @@ + diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass new file mode 100644 index 000000000..c26f35815 --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass @@ -0,0 +1,34 @@ +// This layer only mirrors the textarea it is paired with: it never reads +// user input directly and never becomes a source of truth. The textarea's +// .value is authoritative; see TextareaOverlayComponent for the sync logic. +// +// display: contents keeps this component's own tag out of the box tree, +// so .textarea-overlay's `position: absolute` resolves against the +// nearest real positioned ancestor (the textarea's wrapper), not this +// element. +:host + display: contents + +.textarea-overlay + position: absolute + top: 0 + left: 0 + overflow: hidden + pointer-events: none + white-space: pre-wrap + word-wrap: break-word + overflow-wrap: break-word + + &__line + display: block + + // Zero-width space so an empty (blank) line still occupies a full + // line box and can show its background tint. + &::after + content: "\200B" + + &__line--paragraph + background-color: rgba(63, 81, 181, 0.08) + + &__line--page + background-color: rgba(255, 64, 129, 0.18) diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts new file mode 100644 index 000000000..49617cf9f --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts @@ -0,0 +1,113 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { TextareaOverlayComponent } from "./textarea-overlay.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, exactly as they do +// in the real app. Setting an @Input directly from test code bypasses +// that and can trip Angular's dev-mode "changed after checked" guard when +// a test changes the input more than once. +@Component({ + template: ``, + standalone: false, +}) +class HostComponent { + text = ""; + textareaEl: HTMLTextAreaElement | null = null; +} + +describe("TextareaOverlayComponent", () => { + let hostFixture: ComponentFixture; + let host: HostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TextareaOverlayComponent, HostComponent], + }).compileComponents(); + + hostFixture = TestBed.createComponent(HostComponent); + host = hostFixture.componentInstance; + }); + + it("should create", () => { + hostFixture.detectChanges(); + expect( + hostFixture.debugElement.query(By.css("app-textarea-overlay")), + ).toBeTruthy(); + }); + + it("renders one line per source line, with no tint on plain text", () => { + host.text = "line one\nline two"; + hostFixture.detectChanges(); + + const lineEls: NodeListOf = + hostFixture.nativeElement.querySelectorAll(".textarea-overlay__line"); + expect(lineEls.length).toBe(2); + expect(lineEls[0].textContent).toBe("line one"); + expect(lineEls[1].textContent).toBe("line two"); + lineEls.forEach((el) => { + expect(el.classList).not.toContain("textarea-overlay__line--paragraph"); + expect(el.classList).not.toContain("textarea-overlay__line--page"); + }); + }); + + it("tints a single blank line as a paragraph break", () => { + host.text = "para one\n\npara two"; + hostFixture.detectChanges(); + + const lineEls: NodeListOf = + hostFixture.nativeElement.querySelectorAll(".textarea-overlay__line"); + expect(lineEls.length).toBe(3); + expect( + lineEls[1].classList.contains("textarea-overlay__line--paragraph"), + ).toBeTrue(); + expect( + lineEls[1].classList.contains("textarea-overlay__line--page"), + ).toBeFalse(); + }); + + it("tints a run of two blank lines as a page break", () => { + host.text = "page one\n\n\npage two"; + hostFixture.detectChanges(); + + const lineEls: NodeListOf = + hostFixture.nativeElement.querySelectorAll(".textarea-overlay__line"); + expect(lineEls.length).toBe(4); + expect( + lineEls[1].classList.contains("textarea-overlay__line--page"), + ).toBeTrue(); + expect( + lineEls[2].classList.contains("textarea-overlay__line--page"), + ).toBeTrue(); + }); + + it("attaches to the given textarea and syncs its scroll position", () => { + const textarea = document.createElement("textarea"); + document.body.appendChild(textarea); + textarea.style.height = "20px"; + textarea.value = Array.from({ length: 50 }, (_, i) => `line ${i}`).join( + "\n", + ); + + host.textareaEl = textarea; + hostFixture.detectChanges(); + + textarea.scrollTop = 5; + textarea.dispatchEvent(new Event("scroll")); + + const mirror: HTMLElement = + hostFixture.nativeElement.querySelector(".textarea-overlay"); + // The real textarea may itself clamp the requested scrollTop depending + // on layout; assert the mirror matches whatever that landed on, not a + // hardcoded value. + expect(mirror.scrollTop).toBe(textarea.scrollTop); + expect(textarea.scrollTop).toBeGreaterThan(0); + + textarea.remove(); + }); +}); diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts new file mode 100644 index 000000000..225d35f4e --- /dev/null +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts @@ -0,0 +1,156 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from "@angular/core"; + +import { BlankLineRunKind, findBlankLineRuns } from "./blank-line-runs"; +import { TextareaOverlayService } from "./textarea-overlay.service"; + +interface OverlayLine { + text: string; + tint: BlankLineRunKind | null; +} + +/** + * Renders a non-interactive decoration layer positioned exactly 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..ccf926424 100644 --- a/packages/studio-web/src/app/upload/upload.component.sass +++ b/packages/studio-web/src/app/upload/upload.component.sass @@ -1,7 +1,26 @@ -#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 border: 1px solid #222 min-height: 150px + +#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% From daca5889cd0ad3b80ee27791fe092de80b8bb607 Mon Sep 17 00:00:00 2001 From: Aidan Pine Date: Thu, 2 Jul 2026 16:37:27 -0700 Subject: [PATCH 2/3] feat: add text input area gutter assists with indicating paragraph/page boundaries --- .../src/app/shared/shared.module.ts | 13 +- .../textarea-gutter.component.html | 15 ++ .../textarea-gutter.component.sass | 45 ++++ .../textarea-gutter.component.spec.ts | 119 +++++++++++ .../textarea-gutter.component.ts | 195 ++++++++++++++++++ .../textarea-overlay.component.html | 5 +- .../textarea-overlay.service.ts | 17 ++ .../src/app/upload/upload.component.html | 38 ++-- .../src/app/upload/upload.component.sass | 11 + 9 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.html create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.sass create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.spec.ts create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/textarea-gutter.component.ts diff --git a/packages/studio-web/src/app/shared/shared.module.ts b/packages/studio-web/src/app/shared/shared.module.ts index 71c985994..6740d9d43 100644 --- a/packages/studio-web/src/app/shared/shared.module.ts +++ b/packages/studio-web/src/app/shared/shared.module.ts @@ -2,13 +2,22 @@ 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, TextareaOverlayComponent], + declarations: [ + DownloadComponent, + TextareaOverlayComponent, + TextareaGutterComponent, + ], imports: [BrowserModule, MaterialModule, FormsModule, CommonModule], - exports: [DownloadComponent, TextareaOverlayComponent], + exports: [ + DownloadComponent, + TextareaOverlayComponent, + TextareaGutterComponent, + ], }) export class SharedModule {} 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 ccf926424..66ffe495b 100644 --- a/packages/studio-web/src/app/upload/upload.component.sass +++ b/packages/studio-web/src/app/upload/upload.component.sass @@ -6,9 +6,20 @@ // 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 From d785e2e4abfb3117247926bacaaba90afc4c2b3d Mon Sep 17 00:00:00 2001 From: Aidan Pine Date: Thu, 2 Jul 2026 17:34:43 -0700 Subject: [PATCH 3/3] feat: add localized g2p error handling --- .../textarea-overlay/document-words.spec.ts | 125 +++++++++++++ .../shared/textarea-overlay/document-words.ts | 113 ++++++++++++ .../textarea-overlay.component.html | 10 +- .../textarea-overlay.component.sass | 9 + .../textarea-overlay.component.spec.ts | 63 +++++++ .../textarea-overlay.component.ts | 72 +++++++- .../src/app/studio/studio.service.ts | 5 + .../src/app/upload/g2p-error-mapping.spec.ts | 103 +++++++++++ .../src/app/upload/g2p-error-mapping.ts | 91 ++++++++++ .../src/app/upload/upload.component.html | 1 + .../src/app/upload/upload.component.spec.ts | 165 ++++++++++++++++++ .../src/app/upload/upload.component.ts | 104 +++++++++++ 12 files changed, 854 insertions(+), 7 deletions(-) create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/document-words.spec.ts create mode 100644 packages/studio-web/src/app/shared/textarea-overlay/document-words.ts create mode 100644 packages/studio-web/src/app/upload/g2p-error-mapping.spec.ts create mode 100644 packages/studio-web/src/app/upload/g2p-error-mapping.ts 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-overlay.component.html b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.html index 05897294c..cb79de303 100644 --- a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.html +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.html @@ -4,7 +4,13 @@ class="textarea-overlay__line" [class.textarea-overlay__line--paragraph]="line.tint === 'paragraph'" [class.textarea-overlay__line--page]="line.tint === 'page'" - [textContent]="line.text" - > + > + @for (span of line.spans; track $index) { + + } + } diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass index c26f35815..530c4a60e 100644 --- a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.sass @@ -32,3 +32,12 @@ &__line--page background-color: rgba(255, 64, 129, 0.18) + + // A word that failed g2p conversion during alignment (see + // UploadComponent.reportRasError / g2p-error-mapping.ts). + &__error + text-decoration-line: underline + text-decoration-style: wavy + text-decoration-color: #f44336 + text-decoration-thickness: 1.5px + text-underline-offset: 2px diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts index 49617cf9f..ff4040d98 100644 --- a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.spec.ts @@ -1,6 +1,7 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { TextRange } from "./document-words"; import { TextareaOverlayComponent } from "./textarea-overlay.component"; // A real template binding (rather than poking `component.text` directly) @@ -13,12 +14,14 @@ import { TextareaOverlayComponent } from "./textarea-overlay.component"; template: ``, standalone: false, }) class HostComponent { text = ""; textareaEl: HTMLTextAreaElement | null = null; + errorRanges: TextRange[] = []; } describe("TextareaOverlayComponent", () => { @@ -86,6 +89,66 @@ describe("TextareaOverlayComponent", () => { ).toBeTrue(); }); + it("underlines the word at the given error range and nothing else", () => { + host.text = "hej verden 2"; + host.errorRanges = [{ start: 11, end: 12 }]; // "2" + hostFixture.detectChanges(); + + const errorSpans: NodeListOf = + hostFixture.nativeElement.querySelectorAll(".textarea-overlay__error"); + expect(errorSpans.length).toBe(1); + expect(errorSpans[0].textContent).toBe("2"); + + const line = hostFixture.nativeElement.querySelector( + ".textarea-overlay__line", + ); + expect(line.textContent).toBe("hej verden 2"); + }); + + it("supports multiple error ranges on the same line", () => { + host.text = "one 2 three 4"; + host.errorRanges = [ + { start: 4, end: 5 }, + { start: 12, end: 13 }, + ]; + hostFixture.detectChanges(); + + const errorSpans: NodeListOf = + hostFixture.nativeElement.querySelectorAll(".textarea-overlay__error"); + expect(Array.from(errorSpans).map((el) => el.textContent)).toEqual([ + "2", + "4", + ]); + }); + + it("underlines a word on the correct line in multi-line text", () => { + host.text = "line one\nline two has a bad-word here"; + const wordStart = host.text.indexOf("bad-word"); + host.errorRanges = [ + { start: wordStart, end: wordStart + "bad-word".length }, + ]; + hostFixture.detectChanges(); + + const lineEls: NodeListOf = + hostFixture.nativeElement.querySelectorAll(".textarea-overlay__line"); + expect(lineEls[0].querySelectorAll(".textarea-overlay__error").length).toBe( + 0, + ); + const errorSpans = lineEls[1].querySelectorAll(".textarea-overlay__error"); + expect(errorSpans.length).toBe(1); + expect(errorSpans[0].textContent).toBe("bad-word"); + }); + + it("renders no underline when errorRanges is empty", () => { + host.text = "hej verden 2"; + host.errorRanges = []; + hostFixture.detectChanges(); + expect( + hostFixture.nativeElement.querySelectorAll(".textarea-overlay__error") + .length, + ).toBe(0); + }); + it("attaches to the given textarea and syncs its scroll position", () => { const textarea = document.createElement("textarea"); document.body.appendChild(textarea); diff --git a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts index 225d35f4e..c3e1ac425 100644 --- a/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts +++ b/packages/studio-web/src/app/shared/textarea-overlay/textarea-overlay.component.ts @@ -10,10 +10,16 @@ import { } from "@angular/core"; import { BlankLineRunKind, findBlankLineRuns } from "./blank-line-runs"; +import { TextRange } from "./document-words"; import { TextareaOverlayService } from "./textarea-overlay.service"; -interface OverlayLine { +interface OverlaySpan { text: string; + error: boolean; +} + +interface OverlayLine { + spans: OverlaySpan[]; tint: BlankLineRunKind | null; } @@ -55,6 +61,20 @@ export class TextareaOverlayComponent return this._text; } + private _errorRanges: readonly TextRange[] = []; + + // Same rationale as the `text` setter above: recompute synchronously at + // assignment time so both inputs are always reflected together in the + // next render, whichever one changed. + @Input() + set errorRanges(value: readonly TextRange[] | null) { + this._errorRanges = value ?? []; + this.renderLines(); + } + get errorRanges(): readonly TextRange[] { + return this._errorRanges; + } + @Input() textareaEl: HTMLTextAreaElement | null = null; @ViewChild("mirror", { static: true }) @@ -96,10 +116,52 @@ export class TextareaOverlayComponent tintByLine.set(i, run.kind); } } - this.lines = lines.map((line, index) => ({ - text: line, - tint: tintByLine.get(index) ?? null, - })); + + let lineStart = 0; + this.lines = lines.map((line, index) => { + const overlayLine: OverlayLine = { + spans: this.buildLineSpans(line, lineStart), + tint: tintByLine.get(index) ?? null, + }; + lineStart += line.length + 1; // +1 for the "\n" this line was split on + return overlayLine; + }); + } + + /** + * Splits `lineText` (which starts at absolute offset `lineStart` in the + * full document) into spans at any `errorRanges` boundaries that fall + * within it, so those substrings can be rendered with an error + * decoration while the rest of the line renders normally. A word never + * spans a newline, so a given error range only ever intersects one line. + */ + private buildLineSpans(lineText: string, lineStart: number): OverlaySpan[] { + const lineEnd = lineStart + lineText.length; + const errorsInLine = this._errorRanges + .map((range) => ({ + start: Math.max(range.start, lineStart) - lineStart, + end: Math.min(range.end, lineEnd) - lineStart, + })) + .filter((range) => range.start < range.end) + .sort((a, b) => a.start - b.start); + + if (errorsInLine.length === 0) { + return [{ text: lineText, error: false }]; + } + + const spans: OverlaySpan[] = []; + let cursor = 0; + for (const range of errorsInLine) { + if (range.start > cursor) { + spans.push({ text: lineText.slice(cursor, range.start), error: false }); + } + spans.push({ text: lineText.slice(range.start, range.end), error: true }); + cursor = range.end; + } + if (cursor < lineText.length) { + spans.push({ text: lineText.slice(cursor), error: false }); + } + return spans; } private attachToTextarea(): void { diff --git a/packages/studio-web/src/app/studio/studio.service.ts b/packages/studio-web/src/app/studio/studio.service.ts index 46f73acab..50d493e94 100644 --- a/packages/studio-web/src/app/studio/studio.service.ts +++ b/packages/studio-web/src/app/studio/studio.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@angular/core"; import { ReadAlongSlots } from "../ras.service"; import { BehaviorSubject } from "rxjs"; import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { TextRange } from "../shared/textarea-overlay/document-words"; export enum langMode { generic = "generic", @@ -42,6 +43,10 @@ export class StudioService { Validators.required, ); public $textInput = new BehaviorSubject(""); + // Character ranges of words currently flagged by a g2p alignment error, + // for the text-input overlay to underline. Kept in sync with edits by + // UploadComponent (cleared per-word once the flagged word is changed). + public $textInputErrors = new BehaviorSubject([]); public uploadFormGroup = new FormGroup({ lang: this.langControl$, diff --git a/packages/studio-web/src/app/upload/g2p-error-mapping.spec.ts b/packages/studio-web/src/app/upload/g2p-error-mapping.spec.ts new file mode 100644 index 000000000..37e6c2550 --- /dev/null +++ b/packages/studio-web/src/app/upload/g2p-error-mapping.spec.ts @@ -0,0 +1,103 @@ +import { + isG2pAssembleErrorBody, + mapG2pErrorsToRanges, +} from "./g2p-error-mapping"; + +const EXAMPLE_PARTIAL_RAS = ` + + + + +
+

+ hej verden 2 +

+
+ +
+
`; + +describe("isG2pAssembleErrorBody", () => { + it("recognizes a body with g2p_error_words and partial_ras", () => { + expect( + isG2pAssembleErrorBody({ + detail: "g2p could not be performed...", + g2p_error_words: ["2"], + partial_ras: EXAMPLE_PARTIAL_RAS, + }), + ).toBeTrue(); + }); + + it("rejects a plain error body", () => { + expect(isG2pAssembleErrorBody({ detail: "text is empty" })).toBeFalse(); + }); + + it("rejects null/non-object bodies", () => { + expect(isG2pAssembleErrorBody(null)).toBeFalse(); + expect(isG2pAssembleErrorBody("just a string")).toBeFalse(); + }); + + it("rejects a body missing partial_ras", () => { + expect( + isG2pAssembleErrorBody({ detail: "x", g2p_error_words: ["2"] }), + ).toBeFalse(); + }); +}); + +describe("mapG2pErrorsToRanges", () => { + it("maps the failing word to its index and character range in the submitted text (real example)", () => { + const submittedText = "hej verden 2"; + const errors = mapG2pErrorsToRanges(submittedText, EXAMPLE_PARTIAL_RAS); + expect(errors).toEqual([{ index: 2, start: 11, end: 12 }]); + expect(submittedText.slice(11, 12)).toBe("2"); + }); + + it("returns one entry per failing word, in document order", () => { + const submittedText = "one 2 three 4"; + const partialRas = `

+ one + 2 + three + 4 +

`; + const errors = mapG2pErrorsToRanges(submittedText, partialRas); + expect(errors).toEqual([ + { index: 1, start: 4, end: 5 }, + { index: 3, start: 12, end: 13 }, + ]); + }); + + it("disambiguates repeated words by position, not by string matching", () => { + // Both instances of "2" are the same string, but only the second one + // failed g2p in this partial_ras — a name-based match against + // g2p_error_words couldn't tell them apart. + const submittedText = "2 is not 2"; + const partialRas = `

+ 2 + is + not + 2 +

`; + const errors = mapG2pErrorsToRanges(submittedText, partialRas); + expect(errors).toEqual([{ index: 3, start: 9, end: 10 }]); + }); + + it("returns an empty array when every word succeeded", () => { + const submittedText = "hej verden"; + const partialRas = `

+ hej + verden +

`; + expect(mapG2pErrorsToRanges(submittedText, partialRas)).toEqual([]); + }); + + it("returns null for unparseable XML rather than guessing", () => { + expect(mapG2pErrorsToRanges("hej verden 2", " { + const submittedText = "hej verden 2 extra"; + const ranges = mapG2pErrorsToRanges(submittedText, EXAMPLE_PARTIAL_RAS); + expect(ranges).toBeNull(); + }); +}); diff --git a/packages/studio-web/src/app/upload/g2p-error-mapping.ts b/packages/studio-web/src/app/upload/g2p-error-mapping.ts new file mode 100644 index 000000000..9e56e9b8e --- /dev/null +++ b/packages/studio-web/src/app/upload/g2p-error-mapping.ts @@ -0,0 +1,91 @@ +import { + DocumentWord, + TextRange, + walkDocumentWords, +} from "../shared/textarea-overlay/document-words"; + +/** + * The `/assemble` API's error body for a g2p-conversion failure: alongside + * the generic `detail` message, it includes the words it couldn't convert + * and a partially-assembled RAS document containing a `` element for + * every word actually submitted, in document order, with the words that + * failed g2p carrying an empty `ARPABET` attribute. + */ +export interface G2pAssembleErrorBody { + detail: string; + g2p_error_words?: string[]; + partial_ras?: string; +} + +export function isG2pAssembleErrorBody( + body: unknown, +): body is G2pAssembleErrorBody { + const candidate = body as Partial | null; + return ( + !!candidate && + Array.isArray(candidate.g2p_error_words) && + typeof candidate.partial_ras === "string" + ); +} + +/** + * A word that failed g2p, both by its position in the document's word + * sequence (stable identity, for tracking whether the user has since + * edited it) and its current character range (for rendering). + */ +export interface G2pErrorWord extends TextRange { + /** Index into `walkDocumentWords(submittedText)`. */ + index: number; +} + +/** + * Maps a g2p assemble error's `partial_ras` back onto the words in the + * text that was actually submitted, by matching each `` element in + * `partial_ras` positionally against `walkDocumentWords(submittedText)` — + * the Nth word submitted is assumed to be the Nth `` returned. This is + * deliberately *not* a string match against `g2p_error_words`: repeated + * words/symbols in the document would be indistinguishable that way. + * + * Returns `null` (rather than a best-effort guess) if `partial_ras` isn't + * parseable XML, or if its word count doesn't match the submitted text's + * word count — callers should fall back to non-positional error reporting + * in that case, since the mapping can no longer be trusted to point at the + * right word. + */ +export function mapG2pErrorsToRanges( + submittedText: string, + partialRas: string, +): G2pErrorWord[] | null { + const wordElements = parsePartialRasWords(partialRas); + if (wordElements === null) { + return null; + } + + const words = walkDocumentWords(submittedText); + if (words.length !== wordElements.length) { + return null; + } + + const errors: G2pErrorWord[] = []; + words.forEach((word: DocumentWord, index: number) => { + if (!wordElements[index]) { + errors.push({ index, start: word.start, end: word.end }); + } + }); + return errors; +} + +/** + * Parses `partial_ras` and returns, for each `` element in document + * order, whether it has a non-empty `ARPABET` attribute (i.e. g2p + * succeeded for that word). Returns `null` if the string isn't valid XML. + */ +function parsePartialRasWords(partialRas: string): boolean[] | null { + const doc = new DOMParser().parseFromString(partialRas, "application/xml"); + if (doc.querySelector("parsererror")) { + return null; + } + return Array.from(doc.querySelectorAll("w")).map( + (w) => (w.getAttribute("ARPABET") ?? "").length > 0, + ); +} diff --git a/packages/studio-web/src/app/upload/upload.component.html b/packages/studio-web/src/app/upload/upload.component.html index 6448d183c..8493f4ce7 100644 --- a/packages/studio-web/src/app/upload/upload.component.html +++ b/packages/studio-web/src/app/upload/upload.component.html @@ -118,6 +118,7 @@

Text