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 353f8844..47ee3823 100644
--- a/packages/studio-web/src/app/upload/upload.component.spec.ts
+++ b/packages/studio-web/src/app/upload/upload.component.spec.ts
@@ -1,14 +1,17 @@
-import { ToastrModule } from "ngx-toastr";
+import { of } from "rxjs";
+import { ToastrModule, ToastrService } from "ngx-toastr";
import { provideHttpClient } from "@angular/common/http";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { MaterialModule } from "../material.module";
import { UploadComponent } from "./upload.component";
import { provideHttpClientTesting } from "@angular/common/http/testing";
import { TiptapTextEditorComponent } from "../tiptap-text-editor/tiptap-text-editor.component";
+import { plainTextToDoc } from "../tiptap-text-editor/schema/serializers";
describe("UploadComponent", () => {
let component: UploadComponent;
@@ -24,7 +27,21 @@ describe("UploadComponent", () => {
MaterialModule,
],
declarations: [UploadComponent, TiptapTextEditorComponent],
- providers: [provideHttpClient(), provideHttpClientTesting()],
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ // Real toasts render an actual overlay component asynchronously,
+ // which can outlive a test that's already moved on to the next
+ // spec's TestBed reset — a spy keeps these tests hermetic.
+ {
+ provide: ToastrService,
+ useValue: jasmine.createSpyObj("ToastrService", [
+ "success",
+ "error",
+ "clear",
+ ]),
+ },
+ ],
}).compileComponents();
fixture = TestBed.createComponent(UploadComponent);
@@ -35,4 +52,40 @@ describe("UploadComponent", () => {
it("should create", () => {
expect(component).toBeTruthy();
});
+
+ it("loads an uploaded text file directly when the editor is empty", (done) => {
+ const dialogSpy = spyOn((component as any).dialog as MatDialog, "open");
+ const file = new File(["Hello from a file."], "hello.txt", {
+ type: "text/plain",
+ });
+ const input = { files: [file] } as unknown as HTMLInputElement;
+
+ component.studioService.textControl$.valueChanges.subscribe((doc) => {
+ expect(dialogSpy).not.toHaveBeenCalled();
+ expect(doc?.textContent).toContain("Hello from a file.");
+ done();
+ });
+ component.onTextFileSelected({ target: input } as unknown as Event);
+ });
+
+ it("asks for confirmation before an upload replaces existing text, and only replaces on confirm", (done) => {
+ component.studioService.textControl$.setValue(
+ plainTextToDoc("Existing text."),
+ );
+ const dialogSpy = spyOn(
+ (component as any).dialog as MatDialog,
+ "open",
+ ).and.returnValue({ afterClosed: () => of(true) } as any);
+ const file = new File(["Uploaded text."], "uploaded.txt", {
+ type: "text/plain",
+ });
+ const input = { files: [file] } as unknown as HTMLInputElement;
+
+ component.studioService.textControl$.valueChanges.subscribe((doc) => {
+ expect(dialogSpy).toHaveBeenCalled();
+ expect(doc?.textContent).toContain("Uploaded text.");
+ done();
+ });
+ component.onTextFileSelected({ target: input } as unknown as Event);
+ });
});
diff --git a/packages/studio-web/src/app/upload/upload.component.ts b/packages/studio-web/src/app/upload/upload.component.ts
index 64629a86..1c87e773 100644
--- a/packages/studio-web/src/app/upload/upload.component.ts
+++ b/packages/studio-web/src/app/upload/upload.component.ts
@@ -25,10 +25,12 @@ import {
ViewChild,
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
+import { MatDialog } from "@angular/material/dialog";
import { ProgressBarMode } from "@angular/material/progress-bar";
import { HttpErrorResponse } from "@angular/common/http";
import { environment } from "../../environments/environment";
+import { ConfirmDialogComponent } from "../shared/confirm-dialog/confirm-dialog.component";
import { FileService } from "../file.service";
import { MicrophoneService } from "../microphone.service";
import {
@@ -93,6 +95,7 @@ export class UploadComponent implements OnInit {
private ssjsService = inject(SoundswallowerService);
private microphoneService = inject(MicrophoneService);
private uploadService = inject(UploadService);
+ private dialog = inject(MatDialog);
public studioService = inject(StudioService);
constructor() {
@@ -114,13 +117,14 @@ export class UploadComponent implements OnInit {
this.isLoaded = loaded;
});
- // While in "edit" mode, keep the (hidden) file input's displayed
- // filename from going stale as the doc changes.
+ // Keep the (hidden) file input's own value from going stale as the doc
+ // changes, so re-selecting the same file again still fires a change
+ // event (browsers don't re-fire it if the input's value already
+ // reflects that file path).
this.studioService.textControl$.valueChanges
.pipe(
takeUntilDestroyed(),
filter(() => Boolean(this.textFileUpload)),
- filter(() => this.studioService.inputMethod.text === "edit"),
)
.subscribe(() => {
this.textFileUpload.nativeElement.value = "";
@@ -138,13 +142,11 @@ export class UploadComponent implements OnInit {
this.audioFileUpload.nativeElement.value = "";
});
- // As the user types, warn (without blocking) once the doc's text grows
- // past the size limit; the real gate is validateTextControl() on submit.
+ // As the doc changes (typing, pasting, or a completed upload), warn
+ // (without blocking) once its text grows past the size limit; the real
+ // gate is validateTextControl() on submit.
this.studioService.textControl$.valueChanges
- .pipe(
- takeUntilDestroyed(this.destroyRef$),
- filter(() => this.studioService.inputMethod.text === "edit"),
- )
+ .pipe(takeUntilDestroyed(this.destroyRef$))
.subscribe(() => this.checkIsTextSizeBelowLimit());
}
@@ -380,10 +382,6 @@ Please check it to make sure all words are spelled out completely, e.g. write "4
this.studioService.langMode$.next(event.value);
}
- toggleTextInput(event: any) {
- this.studioService.inputMethod.text = event.value;
- }
-
checkIsTextSizeBelowLimit(): boolean {
const doc = this.studioService.textControl$.value;
if (doc) {
@@ -424,33 +422,16 @@ Please check it to make sure all words are spelled out completely, e.g. write "4
private validateTextControl(): boolean {
const doc = this.studioService.textControl$.value;
- switch (this.studioService.inputMethod.text) {
- case "edit":
- if (doc && doc.textContent.trim()) {
- return this.checkIsTextSizeBelowLimit();
- }
-
- this.toastr.error(
- $localize`Please enter text to align.`,
- $localize`No text`,
- { timeOut: 15000 },
- );
- return false;
-
- case "upload":
- if (doc && doc.textContent.trim()) {
- return true;
- }
-
- this.toastr.error(
- $localize`Please select a text file.`,
- $localize`No text`,
- { timeOut: 15000 },
- );
- return false;
+ if (doc && doc.textContent.trim()) {
+ return this.checkIsTextSizeBelowLimit();
}
- return true;
+ this.toastr.error(
+ $localize`Please write or upload some text to align.`,
+ $localize`No text`,
+ { timeOut: 15000 },
+ );
+ return false;
}
private validateAudioControl(): boolean {
@@ -654,11 +635,6 @@ Please check it to make sure all words are spelled out completely, e.g. write "4
);
}
- deleteTextUpload() {
- this.textFileUpload.nativeElement.value = "";
- this.studioService.textControl$.setValue(null);
- }
-
onTextFileSelected(event: Event) {
const el = event.target as HTMLInputElement;
if (!el.files || el.files.length !== 1) {
@@ -692,9 +668,41 @@ Please check it to make sure all words are spelled out completely, e.g. write "4
return;
}
- // Parse the file into a doc now (readAlongXml -> tipTapDoc or
- // plainText -> tipTapDoc), since the doc is
- // the source of truth from here on, not the raw file.
+ const hasExistingText = Boolean(
+ this.studioService.textControl$.value?.textContent?.trim(),
+ );
+ if (!hasExistingText) {
+ this.loadTextFile(file, isReadAlongXml);
+ return;
+ }
+
+ // Uploading always replaces the whole doc (it's the source of truth,
+ // same as typed text) — warn first, since there's no undo for typed
+ // work once it's gone.
+ this.dialog
+ .open(ConfirmDialogComponent, {
+ data: {
+ title: $localize`Replace your text?`,
+ message: $localize`Uploading "${file.name}:fileName:" will replace the text currently in the editor. This can't be undone.`,
+ confirmLabel: $localize`Upload and replace`,
+ cancelLabel: $localize`Cancel`,
+ },
+ })
+ .afterClosed()
+ .pipe(take(1), takeUntilDestroyed(this.destroyRef$))
+ .subscribe((confirmed) => {
+ if (confirmed) {
+ this.loadTextFile(file, isReadAlongXml);
+ } else {
+ this.textFileUpload.nativeElement.value = "";
+ }
+ });
+ }
+
+ // Parses the file into a doc (readAlongXml -> tipTapDoc or plainText ->
+ // tipTapDoc), since the doc is the source of truth from here on, not the
+ // raw file.
+ private loadTextFile(file: File, isReadAlongXml: boolean) {
this.fileService
.readFile$(file)
.pipe(take(1), takeUntilDestroyed(this.destroyRef$))
diff --git a/packages/studio-web/src/i18n/messages.es.json b/packages/studio-web/src/i18n/messages.es.json
index 288347f0..9bc34b7f 100644
--- a/packages/studio-web/src/i18n/messages.es.json
+++ b/packages/studio-web/src/i18n/messages.es.json
@@ -70,10 +70,8 @@
"1242877753139152692": "Añadir sus datos",
"2521339316382884905": "Para crear su ReadAlong, necesitará añadir su texto y su audio.",
"8890553633144307762": "Atrás",
- "3439208209256809340": "Escribir su texto",
- "6934589696014916904": "Puede escribir su texto directamente en el Studio de ReadAlong si selecciona la opción «escribir».",
- "2135357114421532777": "Usar un fichero de texto",
- "8203636039276427127": "Puede usar también un fichero de texto (.txt) o un fichero en el formato RAS (.readalong).",
+ "3480572051348432447": "Añadir su texto",
+ "409309093862076403": "Puede escribir su texto directamente en ReadAlong Studio, o hacer clic en «Cargar» para usar un fichero de texto sin formato (.txt) o un fichero en formato RAS (.readalong).",
"8270964800848142984": "Grabar su propio audio",
"4200696836052759670": "Puede grabar su propio audio para que sea preprocesado si utiliza el micrófono en su navegador.",
"8619732226743260161": "Usar un fichero de audio",
@@ -137,6 +135,7 @@
"323794992596449638": "Seleccione un fichero de texto sin formato (.txt) o un fichero temporal del Studio de ReadAlong (.readalong)",
"5050307465636924350": "{$START_TAG_MAT_ICON}delete{$CLOSE_TAG_MAT_ICON} Borrar ",
"6329500169661407619": " Escriba o pegue su texto aquí ",
+ "1210416848615239651": "{$START_TAG_MAT_ICON}upload_file{$CLOSE_TAG_MAT_ICON} Cargar ",
"4289685560479120097": "{$START_TAG_MAT_ICON}save{$CLOSE_TAG_MAT_ICON} Guarde una copia ",
"7534891070879763001": "Ex. Hola, me llamo...",
"347407180135731058": "Audio",
@@ -176,9 +175,8 @@
"4346774921429520933": " Tamaño actual: ",
"3896053555277429649": "Por favor seleccione un idioma o la opción predeterminada",
"8052409322099101104": "Ningún idioma seleccionado",
- "3533349926767927338": "Por favor entre el texto que quiere alinear.",
"7881212750036563398": "Sin texto",
- "3578398528078428417": "Por favor seleccione un fichero de texto.",
+ "2794337938489949610": "Por favor escriba o cargue texto para alinear.",
"7528020111424948593": "Por favor grabe (o vuelva a grabar) el audio o seleccione un fichero de audio.",
"7997459583873215257": "No hay audio",
"4603453641249002294": "Perdón, el modelo de alineamiento no ha sido cargado. Por favor espere un rato e inténtelo de nuevo si está usando una conexión lenta. Si el problema persiste, contáctenos.",
@@ -192,6 +190,10 @@
"968476464320510530": "El fichero \"{$fileName}\" no es un fichero de texto compatible.",
"1957629163103268830": "Fichero .readalong demasiado grande. ",
"6695070918205441013": "Fichero de texto demasiado grande. ",
+ "1144033808196008582": "¿Reemplazar su texto?",
+ "6425468075091750081": "Cargar «{$fileName}» reemplazará el texto actual en el editor. Esta acción no se puede deshacer.",
+ "5796476683508712566": "Cargar y reemplazar",
+ "2159130950882492111": "Cancelar",
"2722548994886578004": " procesado. El texto se cargará mediante una conexión encriptada cuando pase al próximo paso."
}
}
diff --git a/packages/studio-web/src/i18n/messages.fr.json b/packages/studio-web/src/i18n/messages.fr.json
index fb75ba11..e06215d5 100644
--- a/packages/studio-web/src/i18n/messages.fr.json
+++ b/packages/studio-web/src/i18n/messages.fr.json
@@ -70,10 +70,8 @@
"1242877753139152692": "Ajouter vos données",
"2521339316382884905": "Pour créer un ReadAlong, il faut ajouter du texte et de l'audio.",
"8890553633144307762": "Retourner",
- "3439208209256809340": "Rédiger votre texte",
- "6934589696014916904": "Vous pouvez rédiger directement votre texte dans ReadAlongStudio en sélectionnant l'option « Rédiger ».",
- "2135357114421532777": "Utiliser un fichier texte",
- "8203636039276427127": "Vous pouvez aussi lire votre texte à partir d'un ficher en format texte brut (.txt) ou ReadAlong Studio (.readalong).",
+ "3480572051348432447": "Ajouter votre texte",
+ "409309093862076403": "Vous pouvez rédiger votre texte directement dans ReadAlong Studio, ou cliquer sur « Téléverser » pour utiliser un fichier texte brut (.txt) ou un fichier au format RAS (.readalong).",
"8270964800848142984": "Enregistrer votre propre audio",
"4200696836052759670": "Vous pouvez enregistrer votre propre audio avec votre microphone.",
"8619732226743260161": "Utiliser un fichier audio",
@@ -132,11 +130,7 @@
"8835207011849408799": " Sélectionner des données pour commencer votre ReadAlong ",
"8550195538234658887": " Pour créer un ReadAlong, nous n'avons besoin que du {$START_BOLD_TEXT}texte{$CLOSE_BOLD_TEXT} et d'un enregistrement {$START_BOLD_TEXT}audio{$CLOSE_BOLD_TEXT} correspondant. ",
"6162693758764653365": "Texte",
- "2746932133389515481": "{$START_TAG_MAT_ICON}insert_page_break{$CLOSE_TAG_MAT_ICON} Insérer un saut de page ",
- "3564500071535462356": "Saut de page",
- "323794992596449638": "Sélectionnez un fichier de texte brut (.txt) ou un fichier ReadAlong Studio (.readalong)",
- "5050307465636924350": "{$START_TAG_MAT_ICON}delete{$CLOSE_TAG_MAT_ICON} Effacer ",
- "6329500169661407619": " Rédigez ou collez votre texte ici ",
+ "1210416848615239651": "{$START_TAG_MAT_ICON}upload_file{$CLOSE_TAG_MAT_ICON} Téléverser ",
"4289685560479120097": "{$START_TAG_MAT_ICON}save{$CLOSE_TAG_MAT_ICON} Copie de sauvegarde ",
"7534891070879763001": "Ex. Bonjour, je m'appelle...",
"347407180135731058": "Audio",
@@ -176,9 +170,8 @@
"4346774921429520933": " Taille actuelle: ",
"3896053555277429649": "Prière de choisir une langue ou l'option par défaut",
"8052409322099101104": "Pas de langue choisie",
- "3533349926767927338": "Prière de saisir le texte à aligner.",
"7881212750036563398": "Pas de texte",
- "3578398528078428417": "Prière de choisir un fichier texte.",
+ "2794337938489949610": "Prière d'écrire ou de téléverser du texte à aligner.",
"7528020111424948593": "Prière de (ré-)enregistrer votre voix ou de choisir un fichier audio.",
"7997459583873215257": "Pas d'audio",
"4603453641249002294": "Désolé, le modèle d'alignement n'est pas encore chargé. Prière d'attendre un peu et de réessayer si vous utilisez une connection lente. Si le problème perdure, prière de nous contacter.",
@@ -192,6 +185,10 @@
"968476464320510530": "Le fichier \"{$fileName}\" n'est pas un fichier texte compatible.",
"1957629163103268830": "Fichier .readalong trop gros. ",
"6695070918205441013": "Fichier texte trop gros. ",
+ "1144033808196008582": "Remplacer votre texte ?",
+ "6425468075091750081": "Téléverser « {$fileName} » remplacera le texte actuellement dans l'éditeur. Cette action est irréversible.",
+ "5796476683508712566": "Téléverser et remplacer",
+ "2159130950882492111": "Annuler",
"2722548994886578004": " lu. Il sera téléversé à l'aide d'une connexion chiffrée quand vous passerez à la prochaine étape."
}
}
diff --git a/packages/studio-web/src/i18n/messages.json b/packages/studio-web/src/i18n/messages.json
index 351839d7..e9ad4b83 100644
--- a/packages/studio-web/src/i18n/messages.json
+++ b/packages/studio-web/src/i18n/messages.json
@@ -70,10 +70,8 @@
"1242877753139152692": "Adding your data",
"2521339316382884905": "To make your ReadAlong, you'll need to add your text and audio.",
"8890553633144307762": "Back",
- "3439208209256809340": "Write your text",
- "6934589696014916904": "You can write your text directly into ReadAlong Studio, by selecting the \"write\" option.",
- "2135357114421532777": "Use a text file",
- "8203636039276427127": "You can also use text from a plain text file (.txt) or a file in the RAS format (.readalong).",
+ "3480572051348432447": "Add your text",
+ "409309093862076403": "You can write your text directly into ReadAlong Studio, or click \"Upload\" to use a plain text file (.txt) or a file in the RAS format (.readalong).",
"8270964800848142984": "Record your own audio",
"4200696836052759670": "You can record your own audio for preprocessing using your browser's microphone.",
"8619732226743260161": "Use an audio file",
@@ -135,8 +133,8 @@
"8550195538234658887": " In order to make a ReadAlong, we just need some {$START_BOLD_TEXT}text{$CLOSE_BOLD_TEXT}, and corresponding {$START_BOLD_TEXT}audio{$CLOSE_BOLD_TEXT}. ",
"6162693758764653365": "Text",
"323794992596449638": "Select a plain text file (.txt) or a ReadAlong Studio temporary file (.readalong)",
- "5050307465636924350": "{$START_TAG_MAT_ICON}delete{$CLOSE_TAG_MAT_ICON} Delete ",
"6329500169661407619": " Write or paste your text here ",
+ "1210416848615239651": "{$START_TAG_MAT_ICON}upload_file{$CLOSE_TAG_MAT_ICON} Upload ",
"4289685560479120097": "{$START_TAG_MAT_ICON}save{$CLOSE_TAG_MAT_ICON} Save a copy ",
"7534891070879763001": "Ex. Hello my name is...",
"347407180135731058": "Audio",
@@ -148,6 +146,7 @@
"6543643564103016190": "Recording",
"8066570559817495723": "{$START_TAG_MAT_ICON}stop_circle{$CLOSE_TAG_MAT_ICON} Stop ",
"6710230498600005462": "{$START_TAG_MAT_ICON}play_circle{$CLOSE_TAG_MAT_ICON} Play ",
+ "5050307465636924350": "{$START_TAG_MAT_ICON}delete{$CLOSE_TAG_MAT_ICON} Delete ",
"7273199130532280751": " Optional: Change language settings ",
"643724100310033610": " Most of the time, you should just use the default selected below. You can also select a specific language if it is supported for improved results. If the default does not work well for you and your language is not supported, have a look at {$START_LINK}{$START_TAG_MAT_ICON}launch{$CLOSE_TAG_MAT_ICON} this blog post series{$CLOSE_LINK} to understand how you might get support for your language, or {$START_LINK_1}{$START_TAG_MAT_ICON}mail{$CLOSE_TAG_MAT_ICON} contact us{$CLOSE_LINK} for more info! ",
"7777031358286609836": "Default (should work with most languages)",
@@ -176,9 +175,8 @@
"4346774921429520933": " Current size: ",
"3896053555277429649": "Please select a language or choose the default option",
"8052409322099101104": "No language selected",
- "3533349926767927338": "Please enter text to align.",
+ "2794337938489949610": "Please write or upload some text to align.",
"7881212750036563398": "No text",
- "3578398528078428417": "Please select a text file.",
"7528020111424948593": "Please (re-)record some audio or select an audio file.",
"7997459583873215257": "No audio",
"4603453641249002294": "Sorry, the alignment model isn't loaded yet. Please wait a while and try again if you're on a slow connection. If the problem persists, please contact us.",
@@ -192,6 +190,10 @@
"968476464320510530": "The file \"{$fileName}\" is not a compatible text file.",
"1957629163103268830": ".readalong file too large. ",
"6695070918205441013": "Text file too large. ",
+ "1144033808196008582": "Replace your text?",
+ "6425468075091750081": "Uploading \"{$fileName}\" will replace the text currently in the editor. This can't be undone.",
+ "5796476683508712566": "Upload and replace",
+ "2159130950882492111": "Cancel",
"2722548994886578004": " processed. It will be uploaded through an encrypted connection when you go to the next step."
}
}
diff --git a/packages/studio-web/tests/studio-web/check-page-1.spec.ts b/packages/studio-web/tests/studio-web/check-page-1.spec.ts
index a4b86a62..20c02396 100644
--- a/packages/studio-web/tests/studio-web/check-page-1.spec.ts
+++ b/packages/studio-web/tests/studio-web/check-page-1.spec.ts
@@ -14,8 +14,8 @@ test.describe("test studio UI & UX", () => {
await disablePlausible(page);
//tour button is visible
await expect(page.getByText("Take the tour!")).toBeVisible();
- //check text button group
- await expect(page.getByTestId("text-btn-group")).toBeVisible();
+ //check text upload button
+ await expect(page.getByTestId("text-upload-btn")).toBeVisible();
//check audio button group
await expect(page.getByTestId("audio-btn-group")).toBeVisible();
//check the language list
@@ -32,8 +32,8 @@ test.describe("test studio UI & UX", () => {
await expect(
page.getByRole("button", { name: "Visite guidée" }),
).toBeVisible();
- //check text button group
- await expect(page.getByTestId("text-btn-group")).toBeVisible();
+ //check text upload button
+ await expect(page.getByTestId("text-upload-btn")).toBeVisible();
//check audio button group
await expect(page.getByTestId("audio-btn-group")).toBeVisible();
//check the language list
@@ -48,8 +48,8 @@ test.describe("test studio UI & UX", () => {
await disablePlausible(page);
//tour button is visible
await expect(page.getByText("¡Siga el tour!")).toBeVisible();
- //check text button group
- await expect(page.getByTestId("text-btn-group")).toBeVisible();
+ //check text upload button
+ await expect(page.getByTestId("text-upload-btn")).toBeVisible();
//check audio button group
await expect(page.getByTestId("audio-btn-group")).toBeVisible();
//check the language list
@@ -110,11 +110,6 @@ test.describe("test studio UI & UX", () => {
await expect(page.getByTestId("next-step")).toBeEnabled();
}).toPass();
- await page
- .getByTestId("text-btn-group")
- .getByRole("radio", { name: "File" })
- .click();
-
await page
.locator("#updateText")
.setInputFiles(testAssetsPath + "/ras-text-37kb.txt");
@@ -150,11 +145,6 @@ test.describe("test studio UI & UX", () => {
await expect(page.getByTestId("next-step")).toBeEnabled();
}).toPass();
- await page
- .getByTestId("text-btn-group")
- .getByRole("radio", { name: "File" })
- .click();
- await page.locator("#updateText").click();
await page
.locator("#updateText")
.setInputFiles(testAssetsPath + "/page1.png");