Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/studio-web/src/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
113 changes: 113 additions & 0 deletions packages/studio-web/src/app/shared/textarea-overlay/document-words.ts
Original file line number Diff line number Diff line change
@@ -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 `<w>` 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="textarea-gutter" #gutter aria-hidden="true">
@for (row of rows; track $index) {
<div class="textarea-gutter__row" [style.height.px]="row.heightPx">
@if (row.marker === "paragraph") {
<span class="textarea-gutter__pilcrow">&para;</span>
}
@if (row.marker === "page") {
<mat-icon class="textarea-gutter__page-icon"
>insert_drive_file</mat-icon
>
}
</div>
}
</div>
<div class="textarea-gutter__measurer" #measurer aria-hidden="true"></div>
Loading
Loading