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
8,720 changes: 7,170 additions & 1,550 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
"@angular/platform-browser": "^21.2.17",
"@angular/router": "^21.2.17",
"@sindresorhus/slugify": "^2.2.1",
"@tiptap/core": "^3.27.1",
"@tiptap/extension-history": "^3.27.1",
"@tiptap/extension-horizontal-rule": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"bootstrap": "^5.3.7",
"file-saver": "^2.0.5",
"image-conversion": "^2.1.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/studio-web/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1.5mb"
"maximumWarning": "2mb",
"maximumError": "3mb"
},
{
"type": "anyComponentStyle",
Expand Down
10 changes: 8 additions & 2 deletions packages/studio-web/src/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import { DownloadComponent } from "./download/download.component";
import { BrowserModule } from "@angular/platform-browser";
import { MaterialModule } from "../material.module";
import { FormsModule } from "@angular/forms";
import { ErrorPopoverComponent } from "./tiptap-editor/popover/error-popover.component";
import { TiptapEditorComponent } from "./tiptap-editor/tiptap-editor.component";

@NgModule({
declarations: [DownloadComponent],
declarations: [
DownloadComponent,
TiptapEditorComponent,
ErrorPopoverComponent,
],
imports: [BrowserModule, MaterialModule, FormsModule, CommonModule],
exports: [DownloadComponent],
exports: [DownloadComponent, TiptapEditorComponent],
})
export class SharedModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { getSchema } from "@tiptap/core";
import { Node as PMNode, Schema } from "@tiptap/pm/model";

import { readAlongExtensions } from "../schema/nodes";
import { walkDocumentWords } from "./document-words";

const schema: Schema = buildTestSchema();

function buildTestSchema(): Schema {
return getSchema(readAlongExtensions);
}

function sentence(text: string) {
return schema.nodes["sentence"].createChecked(
null,
text ? schema.text(text) : undefined,
);
}

function paragraph(...sentences: string[]) {
return schema.nodes["paragraph"].createChecked(
null,
sentences.map((text) => sentence(text)),
);
}

/** A one-paragraph, one-sentence doc, for tests only concerned with word text. */
function docOf(text: string): PMNode {
return schema.nodes["doc"].createChecked(null, [paragraph(text)]);
}

describe("walkDocumentWords", () => {
it("returns nothing for the schema's empty-doc fallback", () => {
const doc = schema.nodes["doc"].createAndFill() as PMNode;
expect(walkDocumentWords(doc)).toEqual([]);
});

it("splits a single sentence on whitespace, tracking positions", () => {
const doc = docOf("hej verden 2");
// doc-start(0), paragraph content(1), sentence content/text start(2).
expect(walkDocumentWords(doc)).toEqual([
{ text: "hej", from: 2, to: 5 },
{ text: "verden", from: 6, to: 12 },
{ text: "2", from: 13, to: 14 },
]);
});

it("collapses runs of whitespace between words", () => {
const doc = docOf("one two\tthree");
expect(walkDocumentWords(doc).map((w) => w.text)).toEqual([
"one",
"two",
"three",
]);
});

it("tracks positions correctly across multiple sentences and paragraphs, resolvable via textBetween", () => {
const doc = schema.nodes["doc"].createChecked(null, [
paragraph("line one", "line two"),
paragraph("para two"),
]);
const words = walkDocumentWords(doc);
expect(words.map((w) => w.text)).toEqual([
"line",
"one",
"line",
"two",
"para",
"two",
]);
for (const word of words) {
expect(doc.textBetween(word.from, word.to)).toBe(word.text);
}
});

it("contributes no words for a pagebreak node", () => {
const doc = schema.nodes["doc"].createChecked(null, [
paragraph("page one"),
schema.nodes["pagebreak"].createChecked(),
paragraph("page two"),
]);
const words = walkDocumentWords(doc);
expect(words.map((w) => w.text)).toEqual(["page", "one", "page", "two"]);
});

it("ignores leading/trailing whitespace in a sentence", () => {
const doc = docOf(" leading and trailing ");
const words = walkDocumentWords(doc);
expect(words[0].text).toBe("leading");
expect(words[words.length - 1].text).toBe("trailing");
for (const word of words) {
expect(doc.textBetween(word.from, word.to)).toBe(word.text);
}
});

it("skips a standalone punctuation-only token (matches the assemble API's own tokenizer, which never emits a <w> for one)", () => {
const doc = docOf("Colon : really");
expect(walkDocumentWords(doc).map((w) => w.text)).toEqual([
"Colon",
"really",
]);
});

it("counts punctuation attached to a real word as part of that one word, not a separate token", () => {
const doc = docOf('punctuation, but "und" considers it a letter...');
expect(walkDocumentWords(doc).map((w) => w.text)).toEqual([
"punctuation,",
"but",
'"und"',
"considers",
"it",
"a",
"letter...",
]);
});

it("counts a digit run as a word", () => {
const doc = docOf("Numbers 234 should be spelled out!");
expect(walkDocumentWords(doc).map((w) => w.text)).toEqual([
"Numbers",
"234",
"should",
"be",
"spelled",
"out!",
]);
});

it("counts an orphan combining diacritic as a word (it gets its own <w> from the API, unlike bare punctuation)", () => {
const doc = docOf("Stray diacritics ̓ are a problem.");
expect(walkDocumentWords(doc).map((w) => w.text)).toEqual([
"Stray",
"diacritics",
"̓",
"are",
"a",
"problem.",
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Node as PMNode } from "@tiptap/pm/model";

/** A word in the doc, with its ProseMirror position range: [from, to). */
export interface DocumentWord {
/** The word's literal text (no surrounding whitespace). */
text: string;
from: number;
to: number;
}

/**
* Whether `token` has any word-forming content — a letter, number, or
* combining mark (e.g. a stray diacritic) — as opposed to being pure
* punctuation/symbols.
*/
function hasWordFormingContent(token: string): boolean {
return /[\p{L}\p{N}\p{M}]/u.test(token);
}

/**
* Walks `doc` in document order, returning each word (a run of
* non-whitespace characters within a text node that contains at least
* one letter, number, or mark) with its absolute ProseMirror position
* range.
*
* A standalone run of pure punctuation (a lone `:`, an em dash used as
* its own token, etc.) is *not* a word here, matching the assemble API's
* own tokenizer: verified against a real 422 response where a
* space-delimited `:` never got a `<w>` element at all (left as bare
* text between its neighbors' `<w>`s), while punctuation *attached* to a
* real word (`"und"`, `letter...`) stayed part of that word's single
* `<w>`, and a digit run (`234`) or an orphan combining diacritic (a
* `Mn`-category mark with no base letter) each got their own `<w>`.
* Skipping punctuation-only tokens here keeps `walkDocumentWords`'s
* count aligned with the API's `<w>` count — without it, one dropped
* `:` shifts every later index by one, `mapG2pErrorsToPositions`'s
* length check fails, and *no* word in the whole document gets flagged,
* not just the mismatched one.
*
* Non-text nodes (in particular `pagebreak`) contribute no words on
* their own — `doc.descendants` simply skips over them since they have
* no text content to match against.
*
* Used once per g2p error response, to match the API's `<w>` elements
* positionally against the doc that was submitted (see
* g2p-error-mapping.ts). Unlike the old textarea overlay's equivalent
* (which had to hand-roll a word-index diff to survive edits, since raw
* string offsets don't track position across changes), the *positions*
* this returns are real ProseMirror positions — `tr.mapping` already
* keeps those valid across further edits, so no re-walk or index-remap
* is needed after the fact (see error-highlight-extension.ts).
*/
export function walkDocumentWords(doc: PMNode): DocumentWord[] {
const words: DocumentWord[] = [];
doc.descendants((node, pos) => {
if (!node.isText) {
return;
}
const text = node.text ?? "";
const wordPattern = /\S+/g;
let match: RegExpExecArray | null;
while ((match = wordPattern.exec(text)) !== null) {
if (!hasWordFormingContent(match[0])) {
continue;
}
words.push({
text: match[0],
from: pos + match.index,
to: pos + match.index + match[0].length,
});
}
});
return words;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { getSchema } from "@tiptap/core";
import { Schema } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
import { DecorationSet } from "@tiptap/pm/view";

import { readAlongExtensions } from "../schema/nodes";
import {
applyErrorHighlightTransaction,
errorHighlightPluginKey,
} from "./error-highlight-extension";

const schema: Schema = getSchema(readAlongExtensions);

function docOf(text: string) {
return schema.nodes["doc"].createChecked(null, [
schema.nodes["paragraph"].createChecked(null, [
schema.nodes["sentence"].createChecked(null, schema.text(text)),
]),
]);
}

describe("applyErrorHighlightTransaction", () => {
it("passes an unrelated transaction through unchanged", () => {
const state = EditorState.create({ doc: docOf("hello world") });
const decorations = DecorationSet.empty;
const result = applyErrorHighlightTransaction(state.tr, decorations);
expect(result).toBe(decorations);
});

it("builds a decoration set from a setErrorRanges meta", () => {
const doc = docOf("hello world");
const state = EditorState.create({ doc });
// "world" is at position 8..13 (doc-start 0, paragraph 1, sentence
// text-start 2, "hello "=6 chars -> 2+6=8, +5="world".length=13).
const tr = state.tr.setMeta(errorHighlightPluginKey, [{ from: 8, to: 13 }]);
const result = applyErrorHighlightTransaction(tr, DecorationSet.empty);
const found = result.find();
expect(found.length).toBe(1);
expect(found[0].from).toBe(8);
expect(found[0].to).toBe(13);
});

it("shifts a flagged range across an edit elsewhere in the doc", () => {
const doc = docOf("hello world");
let state = EditorState.create({ doc });
let tr = state.tr.setMeta(errorHighlightPluginKey, [{ from: 8, to: 13 }]);
let decorations = applyErrorHighlightTransaction(tr, DecorationSet.empty);
state = state.apply(tr);

// Insert "XX" before "hello", shifting "world" 2 positions to the right.
tr = state.tr.insertText("XX", 2);
decorations = applyErrorHighlightTransaction(tr, decorations);
state = state.apply(tr);

const found = decorations.find();
expect(found.length).toBe(1);
expect(state.doc.textBetween(found[0].from, found[0].to)).toBe("world");
});

it("drops a flagged range once its word is deleted", () => {
const doc = docOf("hello world");
let state = EditorState.create({ doc });
let tr = state.tr.setMeta(errorHighlightPluginKey, [{ from: 8, to: 13 }]);
let decorations = applyErrorHighlightTransaction(tr, DecorationSet.empty);
state = state.apply(tr);

tr = state.tr.delete(8, 13);
decorations = applyErrorHighlightTransaction(tr, decorations);

expect(decorations.find().length).toBe(0);
});

it("drops a flagged range once its word is edited into different text", () => {
const doc = docOf("hello world");
let state = EditorState.create({ doc });
let tr = state.tr.setMeta(errorHighlightPluginKey, [{ from: 8, to: 13 }]);
let decorations = applyErrorHighlightTransaction(tr, DecorationSet.empty);
state = state.apply(tr);

// Replace "world" with "there".
tr = state.tr.insertText("there", 8, 13);
decorations = applyErrorHighlightTransaction(tr, decorations);

expect(decorations.find().length).toBe(0);
});

it("keeps an unrelated flagged range when a different word elsewhere is edited", () => {
const doc = docOf("one two three");
let state = EditorState.create({ doc });
const words = { one: [2, 5], two: [6, 9], three: [10, 15] };
let tr = state.tr.setMeta(errorHighlightPluginKey, [
{ from: words.two[0], to: words.two[1] },
]);
let decorations = applyErrorHighlightTransaction(tr, DecorationSet.empty);
state = state.apply(tr);

// Edit "one" -> "1", well before "two".
tr = state.tr.insertText("1", 2, 5);
decorations = applyErrorHighlightTransaction(tr, decorations);
state = state.apply(tr);

const found = decorations.find();
expect(found.length).toBe(1);
expect(state.doc.textBetween(found[0].from, found[0].to)).toBe("two");
});
});
Loading
Loading