diff --git a/packages/studio-web/src/app/app.module.ts b/packages/studio-web/src/app/app.module.ts index 8e291425..8aa7654f 100644 --- a/packages/studio-web/src/app/app.module.ts +++ b/packages/studio-web/src/app/app.module.ts @@ -20,6 +20,7 @@ import { PrivacyDialog } from "./app.component"; import { ErrorPageComponent } from "./error-page/error-page.component"; import { EditorComponent } from "./editor/editor.component"; import { SharedModule } from "./shared/shared.module"; +import { WcStylingComponent } from "./shared/wc-styling/wc-styling.component"; defineCustomElements(); @@ -34,6 +35,7 @@ defineCustomElements(); ErrorPageComponent, EditorComponent, // ShepherdComponent + WcStylingComponent, ], imports: [ BrowserModule, diff --git a/packages/studio-web/src/app/demo/demo.component.html b/packages/studio-web/src/app/demo/demo.component.html index f0d7136c..17f967d2 100644 --- a/packages/studio-web/src/app/demo/demo.component.html +++ b/packages/studio-web/src/app/demo/demo.component.html @@ -38,7 +38,7 @@ [(ngModel)]="studioService.slots.title" [ngStyle]="{ 'width.ch': studioService.slots.title.length, - 'min-width.ch': 20 + 'min-width.ch': 20, }" style="border: none" placeholder="Enter your title here" @@ -50,7 +50,7 @@ [(ngModel)]="studioService.slots.subtitle" [ngStyle]="{ 'width.ch': studioService.slots.subtitle.length, - 'min-width.ch': 20 + 'min-width.ch': 20, }" style="border: none" placeholder="Enter your subtitle here" @@ -60,6 +60,5 @@ -
diff --git a/packages/studio-web/src/app/demo/demo.component.ts b/packages/studio-web/src/app/demo/demo.component.ts index 6d61dcad..9144e061 100644 --- a/packages/studio-web/src/app/demo/demo.component.ts +++ b/packages/studio-web/src/app/demo/demo.component.ts @@ -8,7 +8,7 @@ import { StudioService } from "../studio/studio.service"; import { DownloadService } from "../shared/download/download.service"; import { SupportedOutputs } from "../ras.service"; import { ToastrService } from "ngx-toastr"; - +import { WcStylingService } from "../shared/wc-styling/wc-styling.service"; @Component({ selector: "app-demo", templateUrl: "./demo.component.html", @@ -24,6 +24,7 @@ export class DemoComponent implements OnDestroy, OnInit { public studioService: StudioService, private downloadService: DownloadService, private toastr: ToastrService, + private wcStylingService: WcStylingService, ) { // If we do more languages, this should be a lookup table if ($localize.locale == "fr") { @@ -31,6 +32,12 @@ export class DemoComponent implements OnDestroy, OnInit { } else if ($localize.locale == "es") { this.language = "spa"; } + this.wcStylingService.$wcStyleInput.subscribe((css) => + this.updateWCStyle(css), + ); + this.wcStylingService.$wcStyleFonts.subscribe((font) => + this.addWCCustomFont(font), + ); } ngOnInit(): void {} @@ -47,6 +54,8 @@ export class DemoComponent implements OnDestroy, OnInit { this.studioService.b64Inputs$.value[1], this.studioService.slots, this.readalong, + "Studio", + this.wcStylingService, ); } else { this.toastr.error($localize`Download failed.`, $localize`Sorry!`, { @@ -72,4 +81,12 @@ export class DemoComponent implements OnDestroy, OnInit { ); } } + async updateWCStyle($event: string) { + this.readalong?.setCss( + `data:text/css;base64,${this.b64Service.utf8_to_b64($event ?? "")}`, + ); + } + async addWCCustomFont($font: string) { + this.readalong?.addCustomFont($font); + } } diff --git a/packages/studio-web/src/app/editor/editor.component.html b/packages/studio-web/src/app/editor/editor.component.html index 22931eea..994c8082 100644 --- a/packages/studio-web/src/app/editor/editor.component.html +++ b/packages/studio-web/src/app/editor/editor.component.html @@ -53,13 +53,36 @@

-
-
-
-
-
+ +
+
+
+
+
+
+
diff --git a/packages/studio-web/src/app/editor/editor.component.sass b/packages/studio-web/src/app/editor/editor.component.sass index 392ed934..66d1df37 100644 --- a/packages/studio-web/src/app/editor/editor.component.sass +++ b/packages/studio-web/src/app/editor/editor.component.sass @@ -6,3 +6,36 @@ .row --bs-gutter-x: 0 + +#readalongContainer + margin: 0 auto + +#styleWindow + justify-content: start + margin: 0 auto + +@media screen and (min-width:1200px) and (max-width:1500px) + .full-height + max-height: 95vmin + +@media screen and (min-width:1200px) + div > #handle + position: relative + width: 13px + height: 100% + background-color: rgba(0,0,0,0.7) + border: 3px solid #eee + border-width: 0 5px + margin-right: 1em + margin-top: 1em + cursor: col-resize + z-index: 10 + display: block + #styleWindow + margin: 0 + max-height: 95vh + overflow-y: auto + + app-wc-styling:has(#style-section.collapsed) + width: 100px + overflow: hidden !important diff --git a/packages/studio-web/src/app/editor/editor.component.ts b/packages/studio-web/src/app/editor/editor.component.ts index f9e913a7..2f035964 100644 --- a/packages/studio-web/src/app/editor/editor.component.ts +++ b/packages/studio-web/src/app/editor/editor.component.ts @@ -1,6 +1,6 @@ import WaveSurfer from "wavesurfer.js"; -import { takeUntil, Subject, take } from "rxjs"; +import { takeUntil, Subject, take, fromEvent, debounceTime } from "rxjs"; import { AfterViewInit, Component, @@ -31,6 +31,8 @@ import { DownloadService } from "../shared/download/download.service"; import { SupportedOutputs } from "../ras.service"; import { ToastrService } from "ngx-toastr"; import { validateFileType } from "../utils/utils"; +import { WcStylingService } from "../shared/wc-styling/wc-styling.service"; +import { WcStylingComponent } from "../shared/wc-styling/wc-styling.component"; @Component({ selector: "app-editor", templateUrl: "./editor.component.html", @@ -41,7 +43,8 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { @ViewChild("wavesurferContainer") wavesurferContainer!: ElementRef; wavesurfer: WaveSurfer; @ViewChild("readalongContainer") readalongContainerElement: ElementRef; - + @ViewChild("handle") handleElement!: ElementRef; + @ViewChild("styleWindow") styleElement!: WcStylingComponent; readalong: Components.ReadAlong; language: "eng" | "fra" | "spa" = "eng"; @@ -51,6 +54,7 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { htmlUploadAccepts = ".html"; unsubscribe$ = new Subject(); + rasFileIsLoaded = false; constructor( public b64Service: B64Service, private fileService: FileService, @@ -58,7 +62,23 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { public editorService: EditorService, private toastr: ToastrService, private downloadService: DownloadService, - ) {} + private wcStylingService: WcStylingService, + ) { + this.wcStylingService.$wcStyleInput.subscribe((css) => + this.updateWCStyle(css), + ); + this.wcStylingService.$wcStyleFonts.subscribe((font) => + this.addWCCustomFont(font), + ); + fromEvent(window, "resize") + .pipe(debounceTime(100), takeUntil(this.unsubscribe$)) // wait for 1 second after the last resize event + .subscribe(() => { + // When the window is resized, we want to reset the style window size + // so that it does not get squeezed too small + console.log("[DEBUG] window resized"); + this.resetStyleWindowSize(); + }); + } async ngAfterViewInit(): Promise { this.wavesurfer = WaveSurfer.create({ @@ -121,6 +141,40 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { if (window.location.hash.endsWith("startTour=yes")) { this.startTour(); } + if (this.handleElement) { + fromEvent(this.handleElement.nativeElement, "dragend") + .pipe(takeUntil(this.unsubscribe$)) + .subscribe((event) => { + const ev = event as DragEvent; + console.log("[DEBUG] dragged"); + if (this.styleElement.collapsed$.getValue()) { + this.resetStyleWindowSize(); + return; + } + if (ev.x < 600) { + return; + } // do not let the read along be squeezed past 600px width + if (window.innerWidth - ev.x < 400) return; // do not let the style window be squeezed past 600px width) + // When the handle is dragged, we want to resize the readalong and style containers + const styleEle = this.styleElement?.styleSection + .nativeElement as HTMLElement; + const readAlong = this.readalongContainerElement + ?.nativeElement as HTMLElement; + if (styleEle?.style) { + styleEle.style.width = `calc(100vw - ${ev.x + 50}px)`; + } + + if (readAlong?.style) { + readAlong.style.width = `${ev.x}px`; + } + }); + } else { + this.resetStyleWindowSize(); + } + this.styleElement.collapsed$.subscribe((collapsed) => { + // When the style element is collapsed, we want to reset the style window size + this.resetStyleWindowSize(); + }); } ngOnInit(): void {} @@ -138,8 +192,10 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { this.readalong, this.editorService.slots, this.editorService.audioB64Control$.value, + this.wcStylingService, ); } + this.rasFileIsLoaded = false; } download(download_type: SupportedOutputs) { @@ -154,6 +210,7 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { this.editorService.slots, this.readalong, "Editor", //from + this.wcStylingService, ); } else { this.toastr.error($localize`Download failed.`, $localize`Sorry!`, { @@ -216,10 +273,14 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { } async loadRasFile(file: File | Blob) { + //reset css + this.wcStylingService.$wcStyleInput.next(""); + this.wcStylingService.$wcStyleFonts.next(""); const text = await file.text(); const readalong = await this.parseReadalong(text); this.loadAudioIntoWavesurferElement(); this.renderReadalong(readalong); + this.rasFileIsLoaded = true; } async renderReadalong(readalongBody: string | undefined) { @@ -259,6 +320,12 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { // Make Editable rasElement.setAttribute("mode", "EDIT"); this.readalong = rasElement; + //set custom fonts + if (this.wcStylingService.$wcStyleFonts.getValue().length) { + this.readalong.addCustomFont( + this.wcStylingService.$wcStyleFonts.getValue(), + ); + } const currentWord$ = await this.readalong.getCurrentWord(); const alignments = await this.readalong.getAlignments(); // Subscribe to the current word of the readalong and center the wavesurfer element on it @@ -359,6 +426,34 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { this.createSegments(this.editorService.rasControl$.value); } + // stylesheet linked + + const css = element.getAttribute("css-url"); + + if (css !== null && css.length > 0) { + if (css.startsWith("data:text/css;base64,")) { + this.wcStylingService.$wcStyleInput.next( + this.b64Service.b64_to_utf8(css.substring(css.indexOf(",") + 1)), + ); + } else { + const reply = await fetch(css); + // Did that work? Great! + if (reply.ok) { + reply.text().then((cssText) => { + this.wcStylingService.$wcStyleInput.next(cssText); + }); + } + } + } else { + this.wcStylingService.$wcStyleInput.next(""); + } + //check for custom fonts + const customFont = readalong.querySelector("#ra-wc-custom-font"); + if (customFont !== null) { + this.wcStylingService.$wcStyleFonts.next(customFont.innerHTML); + } else { + this.wcStylingService.$wcStyleFonts.next(""); + } return readalong.querySelector("body")?.innerHTML; } @@ -460,4 +555,28 @@ export class EditorComponent implements OnDestroy, OnInit, AfterViewInit { ]); this.shepherdService.start(); } + async updateWCStyle($event: string) { + this.readalong?.setCss( + `data:text/css;base64,${this.b64Service.utf8_to_b64($event ?? "")}`, + ); + } + async addWCCustomFont($font: string) { + this.readalong?.addCustomFont($font); + } + resetStyleWindowSize() { + const styleEle = this.styleElement?.styleSection + .nativeElement as HTMLElement; + const readAlong = this.readalongContainerElement + ?.nativeElement as HTMLElement; + + if (window.innerWidth > 1199) { + styleEle.style.width = this.styleElement.collapsed$.value + ? `65vh` + : "calc(30vw - 50px)"; + readAlong.style.width = `70vw`; + } else { + styleEle.style.width = `95vw`; + readAlong.style.width = `95vw`; + } + } } diff --git a/packages/studio-web/src/app/shared/download/download.service.ts b/packages/studio-web/src/app/shared/download/download.service.ts index c34daba4..94598cf0 100644 --- a/packages/studio-web/src/app/shared/download/download.service.ts +++ b/packages/studio-web/src/app/shared/download/download.service.ts @@ -17,6 +17,7 @@ import { SupportedOutputs, } from "../../ras.service"; import { Components } from "@readalongs/web-component/loader"; +import { WcStylingService } from "../wc-styling/wc-styling.service"; interface Image { path: string; @@ -180,10 +181,17 @@ Please host all assets on your server, include the font and package imports defi readalong: Components.ReadAlong, slots: ReadAlongSlots, b64Audio: string, + wcStylingService: WcStylingService, ) { await this.updateImages(rasDoc, true, "image", readalong); await this.updateTranslations(rasDoc, readalong); let rasB64 = this.b64Service.xmlToB64(rasDoc); + let b64Css = ""; + const cssText = wcStylingService.$wcStyleInput.getValue(); + const customFont = wcStylingService.$wcStyleFonts.getValue(); + if (cssText) { + b64Css = `\n css-url="data:text/css;base64,${this.b64Service.utf8_to_b64(cssText)}"`; + } if (this.b64Service.jsAndFontsBundle$.value !== null) { let blob = new Blob( [ @@ -222,6 +230,7 @@ Please host all assets on your server, include the font and package imports defi + @@ -232,6 +241,7 @@ Please host all assets on your server, include the font and package imports defi href="data:application/readalong+xml;base64,${rasB64}" audio="${b64Audio}" image-assets-folder="" + ${b64Css} > ${slots.title} ${slots.subtitle} @@ -263,7 +273,10 @@ Please host all assets on your server, include the font and package imports defi slots: ReadAlongSlots, readalong: Components.ReadAlong, from: "Studio" | "Editor" = "Studio", + wcStylingService: WcStylingService, ) { + const cssText = wcStylingService.$wcStyleInput.getValue(); + const customFont = wcStylingService.$wcStyleFonts.getValue(); if (selectedOutputFormat == SupportedOutputs.html) { var element = document.createElement("a"); const blob = await this.createSingleFileBlob( @@ -271,6 +284,7 @@ Please host all assets on your server, include the font and package imports defi readalong, slots, b64Audio, + wcStylingService, ); if (blob) { const basename = this.createRASBasename(slots.title); @@ -301,6 +315,7 @@ Please host all assets on your server, include the font and package imports defi readalong, slots, b64Audio, + wcStylingService, ); const basename = this.createRASBasename(slots.title); @@ -348,6 +363,12 @@ Please host all assets on your server, include the font and package imports defi .serializeToString(rasXML) .replace("?>\n ${slots.title} ${slots.subtitle} diff --git a/packages/studio-web/src/app/shared/wc-styling/wc-styling-helper.html b/packages/studio-web/src/app/shared/wc-styling/wc-styling-helper.html new file mode 100644 index 00000000..a51d3d41 --- /dev/null +++ b/packages/studio-web/src/app/shared/wc-styling/wc-styling-helper.html @@ -0,0 +1,214 @@ +

Advanced styling

+ +
+

+ To customize the look and feel of your read-along, you need to upload or + write some Cascading Style Sheets (CSS) in the styling section. +

+

+ We are providing a table below to help you customize. Please note that + your design must include both the light (.theme--light) and + dark (.theme--dark) themes. +

+ +

Common classes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassDescription
+ .sentence__word + The aligned text
+ .sentence__word.reading + The aligned text being read
+ .sentence__text + The unaligned text
+ .sentence__translation + The translated text
+ .sentence + The sentence container
+ .paragraph + The paragraph containing the
+

Sample Style

+ + +
+
+/**
+  define your colors and variables
+**/
+
+    
+:root,
+#read-along-container {
+  --theme-light-foreground: rgb(80, 70, 70) !important;
+  --theme-light-background: rgb(250, 240, 240) !important;
+  --theme-light-system-background: rgb(240, 230, 230) !important;
+  --theme-light-system-foreground: rgb(70, 60, 60) !important;
+  --theme-dark-foreground: rgb(250, 242, 242) !important;
+  --theme-dark-background: rgb(90, 80, 80) !important;
+  --theme-dark-system-background: rgb(80, 70, 70) !important;
+  --theme-dark-system-foreground: rgb(240, 232, 232) !important;
+}
+
+
+/**
+  change the color of the aligned read along text
+**/
+
+
+.theme--light.sentence__word,
+.theme--light.sentence__text,
+.theme--dark.sentence__word.reading,
+.theme--dark.sentence__word:hover {
+  color: var(--theme-light-foreground) !important;
+}
+
+.theme--dark.sentence__word,
+.theme--dark.sentence__text,
+.theme--light.sentence__word.reading,
+.theme--light.sentence__word:hover {
+  color: var(--theme-dark-foreground) !important;
+}
+
+
+/**
+  change the color of the page counter
+**/
+
+.theme--dark .page__counter {
+  color: var(--theme-dark-foreground) !important;
+}
+
+.theme--light .page__counter {
+  color: var(--theme-light-foreground) !important;
+}
+
+
+/**
+  change the scrollbar color
+**/
+
+.theme--light.pages__container {
+  scrollbar-color: var(--theme-light-foreground) var(--theme-light-system-background) !important;
+}
+
+.theme--dark.pages__container {
+  scrollbar-color: var(--theme-dark-foreground) var(--theme-dark-system-background) !important;
+}
+
+
+/**
+  change the background color and text color
+**/
+
+.theme--light.page__container,
+.theme--light.page__col__image,
+.page__container,
+.theme--light.settings {
+  background-color: var(--theme-light-background) !important;
+  color: var(--theme-light-foreground) !important;
+}
+
+.theme--dark.page__container,
+.theme--dark.page__col__image,
+.page__container,
+.theme--dark.settings {
+  background-color: var(--theme-dark-background) !important;
+  color: var(--theme-dark-foreground) !important;
+}
+
+
+/**
+  set backgrounds
+**/
+
+.background--dark,
+.theme--light.sentence__word.reading,
+.theme--light.sentence__word:hover {
+  background: var(--theme-dark-system-background) !important;
+}
+
+.background--light,
+.theme--dark.sentence__word.reading,
+.theme--dark.sentence__word:hover {
+  background: var(--theme-light-system-background) !important;
+}
+
+
+/** control panel section **/
+
+
+/**
+  change the button color
+**/
+
+
+.theme--light.ripple,
+.color--light {
+  color: var(--theme-light-foreground) !important;
+}
+
+
+/** style playback speed button **/
+
+.theme--light input[type="range"] {
+  accent-color: var(--theme-light-system-foreground) !important;
+  background-color: var(--theme-light-background) !important;
+}
+
+.theme--light input[type="range"]::-webkit-slider-runnable-track,
+input[type="range"]::-moz-range-track {
+  background-color: var(--theme-light-system-background) !important;
+}
+
+.theme--dark.ripple,
+.color--dark {
+  color: var(--theme-dark-system-foreground) !important;
+}
+
+.theme--dark input[type="range"] {
+  accent-color: var(--theme-dark-system-foreground) !important;
+  background-color: var(--theme-dark-system-background) !important;
+}
+
+.theme--dark input[type="range"]::-webkit-slider-runnable-track,
+input[type="range"]::-moz-range-track {
+  background-color: var(--theme-dark-system-foreground) !important;
+}
+
+  
+
+
+ + + diff --git a/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.html b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.html new file mode 100644 index 00000000..af556619 --- /dev/null +++ b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.html @@ -0,0 +1,226 @@ +
+
+
+ +
+
+

Advanced styling

+
+
+
+
+
+ +

+ + Advanced styling + +

+
+
+ +
+ + Write + File + +
+
+
+
+
+
+ + + + +
+
+
+
+
+ +

+ Write or paste your style sheet here +

+
+ + + + +
+
+
+ + +
+
+
+ + + +
+
+
+ + + + +
+
+
+
+
diff --git a/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.sass b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.sass new file mode 100644 index 00000000..449d8ef4 --- /dev/null +++ b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.sass @@ -0,0 +1,68 @@ +mat-dialog-actions.mat-mdc-dialog-actions + justify-content: flex-end !important + +#styleInput + border: 1px solid #222 + font-family: 'Noto Sans', 'Verdana', 'Arial', 'sans-serif' + min-height: 35vh +.collapsed + max-height: 8em + overflow: hidden !important + @media (min-width: 960px) + max-height: 5em + +pre + color: var(--bs-code-color) + padding: 1em + margin: .5em + font-size: 1.1em + +code + white-space: pre + +.comment + color: #696 + +.css-class + color: #ffe66d + +.css-block + background: rgba(0,0,0,0.8) + font-family: 'Courier new' + font-size: 1.4em + line-height: 1.5em + +@media only screen and (max-width:1200px) + #style-section + width: 95vw + margin: auto + + div.header > div.helper-text + text-align: left + +@media only screen and (min-width:1200px) + #style-section + transform: rotate(0deg) translateX(0%) + height: 70vh + width: 100% + + #style-section.collapsed + transform: rotate(90deg) + transform-origin: bottom left + width: 85em + max-height: 4.7em + max-width: calc( 55vh + 100px) + + div.header > div + display: none + + div.header > div:first-of-type, div.header > div.helper-text + display: inline-block + cursor: pointer + + div.justify-content-between + justify-content: flex-start !important + +@media only screen and (min-width:1200px) and (max-width:1900px) + .flex-md-row + flex-direction: column !important diff --git a/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.spec.ts b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.spec.ts new file mode 100644 index 00000000..cc6ca6ee --- /dev/null +++ b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { WcStylingComponent } from "./wc-styling.component"; +import { ToastrModule } from "ngx-toastr"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { MaterialModule } from "../../material.module"; +import { FormsModule } from "@angular/forms"; +describe("WcStylingComponent", () => { + let component: WcStylingComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + BrowserAnimationsModule, + FormsModule, + MaterialModule, + ToastrModule.forRoot(), + ], + declarations: [WcStylingComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(WcStylingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.ts b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.ts new file mode 100644 index 00000000..35344582 --- /dev/null +++ b/packages/studio-web/src/app/shared/wc-styling/wc-styling.component.ts @@ -0,0 +1,291 @@ +import { + Component, + ElementRef, + OnDestroy, + OnInit, + ViewChild, +} from "@angular/core"; +import { ToastrService } from "ngx-toastr"; +import { BehaviorSubject, Subject, takeUntil } from "rxjs"; +import { WcStylingService } from "./wc-styling.service"; +import { + MatDialog, + MatDialogRef, + MatDialogModule, +} from "@angular/material/dialog"; +import { B64Service } from "../../b64.service"; +import { MatButtonModule } from "@angular/material/button"; + +@Component({ + selector: "app-wc-styling", + templateUrl: "./wc-styling.component.html", + styleUrl: "./wc-styling.component.sass", + standalone: false, +}) +export class WcStylingComponent implements OnDestroy, OnInit { + styleText$ = new BehaviorSubject(""); + fontDeclaration$ = new BehaviorSubject(""); + inputType = "edit"; + unsubscribe$ = new Subject(); + collapsed$ = new BehaviorSubject(true); + @ViewChild("styleInputElement") styleInputElement: ElementRef; + @ViewChild("fontInputElement") fontInputElement: ElementRef; + @ViewChild("styleSection") styleSection: ElementRef; + canUseClipBoard = false; + + constructor( + private toastr: ToastrService, + private wcStylingService: WcStylingService, + private dialog: MatDialog, + private b64Service: B64Service, + ) { + //when a new file is uploaded + this.wcStylingService.$wcStyleInput.subscribe((css) => { + if (css !== this.styleText$.getValue()) { + this.styleText$.next(css); + //this.collapsed = false; + } + }); + this.wcStylingService.$wcStyleFonts.subscribe((font) => { + if (font !== this.fontDeclaration$.getValue()) { + this.fontDeclaration$.next(font); + //this.collapsed = false; + } + }); + //check if clipboard access is allowed + navigator.permissions + .query({ name: "clipboard-write" as PermissionName }) + .then((result) => { + if (result.state === "granted" || result.state === "prompt") { + this.canUseClipBoard = true; + } else { + this.canUseClipBoard = false; + } + }) + .catch((err) => { + console.error("Failed to query clipboard permissions", err); + this.canUseClipBoard = false; + }); + } + onFontSelected(event: any) { + const file: File = event.target.files[0]; + const type = file.name.split(".").pop(); + if (file.size > 10048576) { + //10MB + this.toastr.error( + $localize`File ` + file.name + $localize` could not be processed.`, + $localize`File is too big.`, + { timeOut: 2000 }, + ); + + return; + } + // type == "ttf" ? "font/ttf" : "application/x-font-" + type + ";charset=utf-8" + this.b64Service + .blobToB64(file) + .then((data) => { + this.fontDeclaration$.next( + this.fontDeclaration$.getValue() + + (this.fontDeclaration$.getValue().length > 1 ? ", " : "") + + `url(${(data as string).replace("application/octet-stream", type == "ttf" ? "application/x-ttf;charset=utf-8" : "application/x-font-" + type + ";charset=utf-8")}) format('${type?.replace("ttf", "truetype")}')`, + ); + this.updateStyle(); + this.toastr.success( + $localize`File ` + file.name + $localize` processed.`, + $localize`Great!`, + { timeOut: 10000 }, + ); + }) + .catch((err) => { + this.toastr.error( + $localize`File ` + file.name + $localize` could not be processed.`, + err, + { timeOut: 2000 }, + ); + }); + } + + onFileSelected(event: any) { + const file: File = event.target.files[0]; + if (file.size > 1048576) { + //1MB + this.toastr.error( + $localize`File ` + file.name + $localize` could not be processed.`, + $localize`File is too big.`, + { timeOut: 2000 }, + ); + + return; + } + + file + .text() + .then((val) => { + this.styleText$.next(val); + + this.wcStylingService.$wcStyleInput.next(val); + this.inputType = "edit"; + this.toastr.success( + $localize`File ` + + file.name + + $localize` processed.` + + $localize` Content loaded in the text box.`, + $localize`Great!`, + { timeOut: 10000 }, + ); + }) + .catch((err) => { + this.toastr.error( + $localize`File ` + file.name + $localize` could not be processed.`, + err, + { timeOut: 2000 }, + ); + }); + } + getFontDeclarations(): string { + return this.fontDeclaration$.getValue().length > 1 + ? `@charset "utf-8"; + +/* Define default font */ +@font-face { + font-family: "RADefault"; + src: ${this.fontDeclaration$.getValue()}; + font-weight: normal; + font-style: normal; +} +/* Replace aligned text font*/ +span.theme--light.sentence__word, +span.theme--light.sentence__text, +span.theme--dark.sentence__word, +span.theme--dark.sentence__text { + font-family: RADefault, BCSans, "Noto Sans", Verdana, Arial, sans-serif !important; + } +` + : ""; + } + updateStyle() { + this.wcStylingService.$wcStyleInput.next( + (this.fontDeclaration$.getValue().length > 1 + ? `/* Replace aligned text font*/ +span.theme--light.sentence__word, +span.theme--light.sentence__text, +span.theme--dark.sentence__word, +span.theme--dark.sentence__text { + font-family: RADefault, BCSans, "Noto Sans", Verdana, Arial, sans-serif !important; + }` + : "") + this.styleText$.getValue(), + ); + this.wcStylingService.$wcStyleFonts.next(this.getFontDeclarations()); + } + + downloadStyle() { + if (this.styleText$) { + let textBlob = new Blob([this.styleText$.getValue()], { + type: "text/css", + }); + var url = window.URL.createObjectURL(textBlob); + var a = document.createElement("a"); + a.href = url; + a.download = "ras-style-" + Date.now() + ".css"; + a.click(); + a.remove(); + } else { + this.toastr.error($localize`No text to download.`, $localize`Sorry!`); + } + } + toggleStyleInput(event: any) { + this.inputType = event.value; + } + async ngOnInit() { + this.wcStylingService.$wcStyleInput + .pipe(takeUntil(this.unsubscribe$)) + .subscribe((css) => { + if (this.styleText$.getValue().length < 1) { + this.styleText$.next(css); + this.collapsed$.next(css.length < 1); + } + }); + } + + ngOnDestroy(): void { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + openHelpDialog(): void { + this.dialog.open(WCStylingHelper, { + width: "80vw", + maxWidth: "80vw", // maxWidth is required to force material to use justify-content: flex-start + minWidth: "50vw", + }); + } + toggleCollapse() { + this.collapsed$.next(!this.collapsed$.getValue()); + } + pasteStyle() { + if (this.canUseClipBoard) { + navigator.clipboard + .readText() + .then((text) => { + this.styleText$.next(text); + this.wcStylingService.$wcStyleInput.next(text); + this.inputType = "edit"; + this.toastr.success( + $localize`Style sheet pasted from clipboard.`, + undefined, + { timeOut: 10000 }, + ); + }) + .catch((err) => { + this.toastr.error($localize`Failed to read clipboard content.`, err, { + timeOut: 2000, + }); + }); + } else { + this.toastr.error( + $localize`Clipboard access is not allowed.`, + $localize`Error`, + ); + } + } + + copyStyle() { + if (this.canUseClipBoard) { + navigator.clipboard + .writeText(this.styleText$.getValue()) + .then(() => { + this.toastr.success( + $localize`Style sheet copied to clipboard.`, + undefined, + { timeOut: 10000 }, + ); + }) + .catch((err) => { + this.toastr.error( + $localize`Failed to copy style sheet to clipboard.`, + err, + { + timeOut: 2000, + }, + ); + }); + } else { + this.toastr.error( + $localize`Clipboard access is not allowed.`, + $localize`Error`, + ); + } + } +} + +@Component({ + selector: "wc-styling-helper", + templateUrl: "./wc-styling-helper.html", + styleUrl: "./wc-styling.component.sass", + imports: [MatDialogModule, MatButtonModule], +}) +export class WCStylingHelper { + constructor(public dialogRef: MatDialogRef) {} + closeDialog(): void { + this.dialogRef.close(); + } +} diff --git a/packages/studio-web/src/app/shared/wc-styling/wc-styling.service.ts b/packages/studio-web/src/app/shared/wc-styling/wc-styling.service.ts new file mode 100644 index 00000000..1da0a52b --- /dev/null +++ b/packages/studio-web/src/app/shared/wc-styling/wc-styling.service.ts @@ -0,0 +1,9 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +@Injectable({ + providedIn: "root", +}) +export class WcStylingService { + $wcStyleInput = new BehaviorSubject(""); + $wcStyleFonts = new BehaviorSubject(""); +} diff --git a/packages/studio-web/src/app/studio/studio.component.ts b/packages/studio-web/src/app/studio/studio.component.ts index fe983b91..9d847d56 100644 --- a/packages/studio-web/src/app/studio/studio.component.ts +++ b/packages/studio-web/src/app/studio/studio.component.ts @@ -134,7 +134,7 @@ export class StudioComponent implements OnDestroy, OnInit { async ngOnDestroy() { // step us back to the previously left step - this.studioService.lastStepperIndex = this.stepper.selectedIndex; + this.studioService.lastStepperIndex = this.stepper?.selectedIndex; this.unsubscribe$.next(); this.unsubscribe$.complete(); } diff --git a/packages/studio-web/src/i18n/messages.es.json b/packages/studio-web/src/i18n/messages.es.json index 69d8e102..6e26fbd5 100644 --- a/packages/studio-web/src/i18n/messages.es.json +++ b/packages/studio-web/src/i18n/messages.es.json @@ -40,6 +40,30 @@ "6874638171510323302": "Subtítulos (formato WebVTT)", "8762728484338173358": "La conversión del formato ReadAlong falló.", "6017683769837067192": "Ah, no logramos conectarnos en este momentos a la API de ReadAlong. Inténtelo otra vez más tarde.", + "6111551964425362195": "Estilo avanzado", + "7911416166208830577": "Ayuda", + "2603482535677312633": "Escriba", + "8455204924704616723": "Fichero", + "8259771249755479803": "Seleccionar un fichero de hojas de estilos en cascada (.css)", + "3796650812518266523": " Escriba o pegue sus hojas de estilos aquí ", + "4934000757746180538": "{$START_TAG_MAT_ICON}save{$CLOSE_TAG_MAT_ICON} Guarde una copia", + "7071695380382454380": "{$START_TAG_MAT_ICON}sync{$CLOSE_TAG_MAT_ICON} Aplicar", + "4323470180912194028": "Copiar", + "8890504809170904482": "Pegar", + "7604046252874749392": "Opcional: utilice una fuente personalizada (.woff2)", + "7719309746449095739": "Fichero ", + "7466581557533667662": " no se pudo procesarlo.", + "7149293924451414591": "Fichero demasiado grande.", + "7331307748742296558": " procesado.", + "6899344040225872362": "¡Genial!", + "5700078517045291790": " Contenido cargado en el cuadro de texto.", + "6558433540988178003": "No hay texto para descargar.", + "4533795875760195674": "Hojas de estilos pegadas desde el portapapeles.", + "3715050097325047804": "Error al leer el contenido del portapapeles.", + "5048497709983421225": "Acceso al portapapeles no permitido.", + "1519954996184640001": "Error", + "1205008396748653088": "Hojas de estilos copiadas al portapapeles.", + "7492776625383944837": "Error al copiar las hojas de estilos al portapapeles.", "6731392928829867425": "Bienvenidos al Studio de ReadAlong", "1309246714146466494": "¡Crear un ReadAlong es fácil! Esta guía le mostrará todas las funcionalidades del Studio.", "3885497195825665706": "Próximo", @@ -115,8 +139,6 @@ "8835207011849408799": " Seleccione los datos para empezar a crear su ReadAlong ", "8550195538234658887": " Para crear un ReadAlong, solo se necesitan algo de {$START_BOLD_TEXT}texto{$CLOSE_BOLD_TEXT} y el {$START_BOLD_TEXT}audio{$CLOSE_BOLD_TEXT} correspondiente. ", "6162693758764653365": "Texto", - "2603482535677312633": "Escriba", - "8455204924704616723": "Fichero", "323794992596449638": "Seleccione un fichero de texto sin formato (.txt) o un fichero temporal del Studio de ReadAlong (.readalong)", "6329500169661407619": " Escriba o pegue su texto aquí ", "887019029800317757": "{$START_TAG_MAT_ICON}help_outline{$CLOSE_TAG_MAT_ICON} Formato ", @@ -146,19 +168,18 @@ "1549389605329660619": "Esto es realmente difícil. Lo intentaremos una última vez, pero puede llevar mucho tiempo y producir malos resultados. Asegúrese de que su texto coincida con su audio y que haya el menor ruido de fondo posible.", "6071928720301938306": "El procesamiento del audio falló.", "3763839702998678686": "No hay audio para descargar.", - "6558433540988178003": "No hay texto para descargar.", "4183225119057268962": "¡No se pudo empezar la grabación!", "2596823344081631983": "El audio se grabó con éxito. Por favor escuche su grabación para asegurarse de que está correcta y si lo está, guárdela para reusarla luego.", "1317075918959775059": "¡Hurra!", "3585637900550692820": "No pudimos grabar nada, ¿su micrófono está bloqueado o desconectado? Si el problema persiste, por favor inténtelo con un auricular u otro micrófono.", "779265781994803872": "¡El audio no se grabó!", "1983793909601149790": "Por favor inténtelo de nuevo o seleccione un fichero pre-grabado.", - "3896053555277429649": "Por favor seleccione un idioma o la opción predeterminada", - "8052409322099101104": "Ningún idioma seleccionado", "6423301374041491740": "El texto es demasiado grande. ", "8484763613240787586": "Tamaño máximo: ", "1227277325872790936": " KB.", "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.", @@ -172,9 +193,7 @@ "5640828320811588897": "Por favor seleccione o escriba el texto, seleccione o grabe el audio y seleccione el idioma.", "7065107025201081158": "Plantilla incompleta", "6817170311264143962": "El fichero \"{$fileName}\" no es un fichero de audio compatible.", - "7719309746449095739": "Fichero ", "1326685349515945581": " procesado pero no cargado. Su audio se mantendrá en su computadora.", - "6899344040225872362": "¡Genial!", "968476464320510530": "El fichero \"{$fileName}\" no es un fichero de texto compatible.", "1957629163103268830": "Fichero .readalong demasiado grande. ", "6695070918205441013": "Fichero de texto demasiado grande. ", diff --git a/packages/studio-web/src/i18n/messages.fr.json b/packages/studio-web/src/i18n/messages.fr.json index 412d6ac1..09e181a3 100644 --- a/packages/studio-web/src/i18n/messages.fr.json +++ b/packages/studio-web/src/i18n/messages.fr.json @@ -40,6 +40,30 @@ "6874638171510323302": "Sous-titres WebVTT", "8762728484338173358": "Échec de conversion de fichier.", "6017683769837067192": "Désolé, nous ne pouvons pas rejoindre l'API ReadAlongs. Prière de réessayer plus tard.", + "6111551964425362195": "Options de style avancées", + "7911416166208830577": "Aide", + "2603482535677312633": "Rédiger", + "8455204924704616723": "Fichier", + "8259771249755479803": "Choisir un fichier de feuilles de style (.css)", + "3796650812518266523": " Écrivez ou copiez-collez vos feuilles de style ici ", + "4934000757746180538": "{$START_TAG_MAT_ICON}save{$CLOSE_TAG_MAT_ICON} Copie de sauvegarde", + "7071695380382454380": "{$START_TAG_MAT_ICON}sync{$CLOSE_TAG_MAT_ICON} Appliquer", + "4323470180912194028": "Copier", + "8890504809170904482": "Coller", + "7604046252874749392": "Optionnel: utiliser une police personnalisée (.woff2)", + "7719309746449095739": "Fichier ", + "7466581557533667662": " erroné.", + "7149293924451414591": "Fichier trop lourd.", + "7331307748742296558": " lu.", + "6899344040225872362": "Bravo!", + "5700078517045291790": " Contenu chargé dans la zone de texte.", + "6558433540988178003": "Pas de texte à télécharger.", + "4533795875760195674": "Feuilles de style collées du presse-papier.", + "3715050097325047804": "Échec de lecture du contenu du presse-papier.", + "5048497709983421225": "Accès au presse-papier non autorisé.", + "1519954996184640001": "Erreur", + "1205008396748653088": "Feuilles de style copiées dans le presse-papier.", + "7492776625383944837": "Échec de copie des feuilles de style dans le presse-papier.", "6731392928829867425": "Bienvenue au Studio ReadAlong", "1309246714146466494": "Il est facile de créer un ReadAlong! Vous trouverez tous les trucs et astuces pour utiliser le Studio dans cette visite guidée.", "3885497195825665706": "Continuer", @@ -115,8 +139,6 @@ "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", - "2603482535677312633": "Rédiger", - "8455204924704616723": "Fichier", "323794992596449638": "Sélectionnez un fichier de texte brut (.txt) ou un fichier ReadAlong Studio (.readalong)", "6329500169661407619": " Rédigez ou collez votre texte ici ", "887019029800317757": "{$START_TAG_MAT_ICON}help_outline{$CLOSE_TAG_MAT_ICON} Format ", @@ -146,19 +168,18 @@ "1549389605329660619": "C'est vraiment difficile. Nous allons essayer une dernière fois, mais ça peut être long et donner de mauvais résultats. Veuillez vous assurer que votre texte correspond à votre audio et qu'il y a le moins de bruit de fond possible.", "6071928720301938306": "Échec de traitement de l'audio.", "3763839702998678686": "Pas d'audio à télécharger.", - "6558433540988178003": "Pas de texte à télécharger.", "4183225119057268962": "Impossible de démarrer l'enregistrement!", "2596823344081631983": "Audio enregistré avec succès. Prière d'écouter votre enregistrement pour le valider et de le sauvegarder s'il est bon.", "1317075918959775059": "Bravo!", "3585637900550692820": "Impossible d'enregistrer, prière de vérifier que votre microphone est bien connecté et activé. Si le problème perdure, réessayez avec une casque d'écoute ou autre microphone.", "779265781994803872": "Erreur d'enregistrement", "1983793909601149790": "Prière de réessayer ou de choisir un fichier pré-enregistré.", - "3896053555277429649": "Prière de choisir une langue ou l'option par défaut", - "8052409322099101104": "Pas de langue choisie", "6423301374041491740": "Texte trop long. ", "8484763613240787586": "Limite: ", "1227277325872790936": " Ko.", "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.", @@ -172,9 +193,7 @@ "5640828320811588897": "Prière de préparer votre texte et votre audio et de choisir une langue.", "7065107025201081158": "Formulaire incomplet", "6817170311264143962": "Le fichier \"{$fileName}\" n'est pas un fichier audio compatible.", - "7719309746449095739": "Fichier ", "1326685349515945581": " lu, mais pas téléversé. Votre audio restera sur votre ordinateur.", - "6899344040225872362": "Bravo!", "968476464320510530": "Le fichier \"{$fileName}\" n'est pas un fichier texte compatible.", "1957629163103268830": "Fichier .readalong trop gros. ", "6695070918205441013": "Fichier texte trop gros. ", diff --git a/packages/studio-web/src/i18n/messages.json b/packages/studio-web/src/i18n/messages.json index c6de4c38..a0554616 100644 --- a/packages/studio-web/src/i18n/messages.json +++ b/packages/studio-web/src/i18n/messages.json @@ -40,6 +40,30 @@ "6874638171510323302": "WebVTT Subtitles", "8762728484338173358": "ReadAlong format conversion failed.", "6017683769837067192": "Hmm, we can't connect to the ReadAlongs API. Please try again later.", + "6111551964425362195": "Advanced styling", + "7911416166208830577": "Help", + "2603482535677312633": "Write", + "8455204924704616723": "File", + "8259771249755479803": "Select a style sheet file (.css)", + "3796650812518266523": " Write or paste your style sheet here ", + "4934000757746180538": "{$START_TAG_MAT_ICON}save{$CLOSE_TAG_MAT_ICON} Save a copy", + "7071695380382454380": "{$START_TAG_MAT_ICON}sync{$CLOSE_TAG_MAT_ICON} Apply ", + "4323470180912194028": "Copy", + "8890504809170904482": "Paste", + "7604046252874749392": "Optional: use a custom font (.woff2)", + "7719309746449095739": "File ", + "7466581557533667662": " could not be processed.", + "7149293924451414591": "File is too big.", + "7331307748742296558": " processed.", + "6899344040225872362": "Great!", + "5700078517045291790": " Content loaded in the text box.", + "6558433540988178003": "No text to download.", + "4533795875760195674": "Style sheet pasted from clipboard.", + "3715050097325047804": "Failed to read clipboard content.", + "5048497709983421225": "Clipboard access is not allowed.", + "1519954996184640001": "Error", + "1205008396748653088": "Style sheet copied to clipboard.", + "7492776625383944837": "Failed to copy style sheet to clipboard.", "6731392928829867425": "Welcome to ReadAlong Studio", "1309246714146466494": "Creating a ReadAlong is easy! This guide will show you all the bells and whistles of the Studio.", "3885497195825665706": "Next", @@ -115,8 +139,6 @@ "8835207011849408799": " Select data to start creating your ReadAlong ", "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", - "2603482535677312633": "Write", - "8455204924704616723": "File", "323794992596449638": "Select a plain text file (.txt) or a ReadAlong Studio temporary file (.readalong)", "6329500169661407619": " Write or paste your text here ", "887019029800317757": "{$START_TAG_MAT_ICON}help_outline{$CLOSE_TAG_MAT_ICON} Format ", @@ -146,7 +168,6 @@ "1549389605329660619": "This is really difficult. We'll try one last time, but it might take a long time and produce poor results. Please make sure your text matches your audio and that there is as little background noise as possible.", "6071928720301938306": "Audio processing failed.", "3763839702998678686": "No audio to download.", - "6558433540988178003": "No text to download.", "4183225119057268962": "Could not start recording!", "2596823344081631983": "Audio was successfully recorded. Please listen to your recording to make sure it's OK, and save it for reuse if so.", "1317075918959775059": "Yay!", @@ -172,9 +193,7 @@ "5640828320811588897": "Please select or write text, select or record audio data, and select the language.", "7065107025201081158": "Form not complete", "6817170311264143962": "The file \"{$fileName}\" is not a compatible audio file.", - "7719309746449095739": "File ", "1326685349515945581": " processed, but not uploaded. Your audio will stay on your computer.", - "6899344040225872362": "Great!", "968476464320510530": "The file \"{$fileName}\" is not a compatible text file.", "1957629163103268830": ".readalong file too large. ", "6695070918205441013": "Text file too large. ", diff --git a/packages/studio-web/tests/editor/custom-css.spec.ts b/packages/studio-web/tests/editor/custom-css.spec.ts new file mode 100644 index 00000000..5f16110b --- /dev/null +++ b/packages/studio-web/tests/editor/custom-css.spec.ts @@ -0,0 +1,154 @@ +import { test, expect } from "@playwright/test"; +import { testAssetsPath, disablePlausible } from "../test-commands"; +import fs from "fs"; + +test.describe.configure({ mode: "parallel" }); +test.beforeEach(async ({ page, isMobile }) => { + //await context.grantPermissions(["clipboard-write", "clipboard-read"]); + await page.goto("/", { waitUntil: "load" }); + disablePlausible(page); + if (isMobile) { + await page.getByTestId("menu-toggle").click(); + } + await page + .getByRole(isMobile ? "menuitem" : "button", { name: /Editor/ }) + .click(); + + await page.locator("#updateRAS").waitFor({ state: "visible" }); + let fileChooserPromise = page.waitForEvent("filechooser"); + await page.locator("#updateRAS").click(); + let fileChooser = await fileChooserPromise; + fileChooser.setFiles(testAssetsPath + "sentence-paragr-cust-css.html"); + await expect( + page.locator("#audioToolbar"), + "audio bar should exist", + ).toHaveCount(1); + //check readalong + await expect( + page.locator("#readalongContainer"), + "should check that readalong is loading", + ).not.toBeEmpty(); + await page.locator("#t0b0d0").waitFor({ state: "visible" }); + + await expect( + page.locator("#t0b0d0"), + "read along has been loaded", + ).toHaveCount(1); + await expect( + page.locator("#style-section"), + "css editor to be hidden", + ).toHaveClass(/\bcollapsed\b/); + await page.getByTestId("toggle-css-box").click(); + await expect( + page.locator("#style-section"), + "css editor to be visible", + ).not.toHaveClass(/\bcollapsed\b/); + await expect(async () => { + await expect(page.locator("#styleInput"), "has style data").toHaveValue( + /\.theme--light/, + ); + }).toPass(); +}); +test("should edit css (editor)", async ({ page, isMobile }) => { + await expect( + page + .locator('[data-test-id="text-container"]') + .getByText("This", { exact: true }), + "check the color of the text", + ).toHaveCSS("color", "rgba(80, 70, 70, 0.9)"); + let downloadPromise = page.waitForEvent("download"); + await page.getByLabel("Style download button").click(); + let download = await downloadPromise; + let filePath = `${testAssetsPath}/${download.suggestedFilename()}`; + await download.saveAs(filePath); + let fileData = fs.readFileSync(filePath, { encoding: "utf8", flag: "r" }); + await expect( + fileData, + "check that the css file export matches the original", + ).toContain(".theme--light.sentence__word,"); + download.delete(); + fs.unlinkSync(filePath); + await page + .locator("#styleInput") + .fill( + ".theme--light.sentence__word,\n.theme--light.sentence__text {\n color: rgba(180, 170, 70, .9) !important;\n}", + ); + + await page.getByRole("button", { name: "Apply" }).click(); + await expect( + page + .locator('[data-test-id="text-container"]') + .getByText("This", { exact: true }), + "check the color of the text", + ).toHaveCSS("color", "rgba(180, 170, 70, 0.9)"); + downloadPromise = page.waitForEvent("download"); + await page.getByLabel("Style download button").click(); + download = await downloadPromise; + filePath = await download.path(); + fileData = fs.readFileSync(filePath, { encoding: "utf8", flag: "r" }); + await expect( + fileData, + "check that the css file export matches the original", + ).toContain( + ".theme--light.sentence__word,\n.theme--light.sentence__text {\n color: rgba(180, 170, 70, .9) !important;\n}", + ); +}); +test("should use with custom font", async ({ page, isMobile }) => { + let fileChooserPromise = page.waitForEvent("filechooser"); + await page.locator("#defaultFont").click(); + let fileChooser = await fileChooserPromise; + fileChooser.setFiles(testAssetsPath + "cour.ttf"); + await expect( + page.getByText("File cour.ttf processed."), + "font successfully loaded", + ).toBeVisible(); +}); + +test("should paste in style", async ({ page, context }) => { + const style = fs.readFileSync( + testAssetsPath + "sentence-paragr-cust-css.css", + { encoding: "utf8", flag: "r" }, + ); + // Ensure clipboard permissions are granted + await context.grantPermissions(["clipboard-write", "clipboard-read"]); + await page.evaluate(async (text) => { + await navigator.clipboard.writeText(text); + }, style); + await page.getByRole("button", { name: "Paste" }).click(); + // Wait for the style input to be updated after paste + await expect( + page.locator("#styleInput"), + "style input should not be empty", + ).not.toBeEmpty(); + await expect + .poll(async () => await page.locator("#styleInput").inputValue(), { + message: "check that the style input has been replaced", + }) + .toContain(style); +}); +test("should load and copy style", async ({ page, context }) => { + await context.grantPermissions(["clipboard-write", "clipboard-read"]); + let fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("radio", { name: "File" }).click(); + await page + .locator("#updateStyle") + .waitFor({ state: "visible", timeout: 10000 }); + await page.locator("#updateStyle").click(); + let fileChooser = await fileChooserPromise; + fileChooser.setFiles(testAssetsPath + "sentence-paragr-cust-css.css"); + await expect( + page.getByText( + "File sentence-paragr-cust-css.css processed. Content loaded in the text box.", + ), + "css successfully loaded", + ).toBeVisible(); + await expect( + page.locator("#styleInput"), + "style input should not be empty", + ).not.toBeEmpty(); + await page.getByRole("button", { name: "Copy" }).click(); + const css = await page.evaluate(() => navigator.clipboard.readText()); + await expect(css.length, "clipboard css should not be empty").toBeGreaterThan( + 0, + ); +}); diff --git a/packages/studio-web/tests/editor/download-web-bundle.spec.ts b/packages/studio-web/tests/editor/download-web-bundle.spec.ts index fd220297..08debd66 100644 --- a/packages/studio-web/tests/editor/download-web-bundle.spec.ts +++ b/packages/studio-web/tests/editor/download-web-bundle.spec.ts @@ -26,6 +26,27 @@ test("should Download web bundle (zip file format) from the Editor", async ({ let fileChooser = await fileChooserPromise; await fileChooser.setFiles(testAssetsPath + "sentence-paragr.html"); + //add custom style + await page.getByTestId("toggle-css-box").click(); + await page.getByRole("radio", { name: "File" }).click(); + fileChooserPromise = page.waitForEvent("filechooser"); + await page.locator("#updateStyle").click(); + fileChooser = await fileChooserPromise; + await fileChooser.setFiles(`${testAssetsPath}/sentence-paragr-cust-css.css`); + await expect(async () => + expect(page.locator("#styleInput"), "has style data").toHaveValue( + /\.theme--light/, + ), + ).toPass(); + await page.getByRole("button", { name: "Apply" }).click(); + await expect + .soft( + page + .locator('[data-test-id="text-container"]') + .getByText("This", { exact: true }), + "check the color of the text", + ) + .toHaveCSS("color", "rgb(80, 70, 70)"); // Test download web-bundle functionality await page.getByTestId("download-formats").click(); await page.getByRole("option", { name: "Web Bundle" }).click(); @@ -84,4 +105,8 @@ async function verifyWebBundle(zip: JSZip) { xmlString, "download file should contain XML declaration", ).toMatch(/^<\?xml/); + await expect( + zip.file(/www\/assets\/sentence\-paragr\-[0-9]*\.css/), + "should have stylesheet file", + ).toHaveLength(1); //www/assets audio exists } diff --git a/packages/studio-web/tests/fixtures/CutiveMono-Regular.ttf b/packages/studio-web/tests/fixtures/CutiveMono-Regular.ttf new file mode 100644 index 00000000..dd54b2f5 Binary files /dev/null and b/packages/studio-web/tests/fixtures/CutiveMono-Regular.ttf differ diff --git a/packages/studio-web/tests/fixtures/cour.ttf b/packages/studio-web/tests/fixtures/cour.ttf new file mode 100644 index 00000000..46a0712a Binary files /dev/null and b/packages/studio-web/tests/fixtures/cour.ttf differ diff --git a/packages/studio-web/tests/fixtures/sentence-paragr-cust-css.css b/packages/studio-web/tests/fixtures/sentence-paragr-cust-css.css new file mode 100644 index 00000000..38917b22 --- /dev/null +++ b/packages/studio-web/tests/fixtures/sentence-paragr-cust-css.css @@ -0,0 +1,123 @@ +:root, +#read-along-container { + --theme-light-foreground: rgb(80, 70, 70) !important; + --theme-light-background: rgb(250, 240, 240) !important; + --theme-light-system-background: rgb(240, 230, 230) !important; + --theme-light-system-foreground: rgb(70, 60, 60) !important; + --theme-dark-foreground: rgb(250, 242, 242) !important; + --theme-dark-background: rgb(90, 80, 80) !important; + --theme-dark-system-background: rgb(80, 70, 70) !important; + --theme-dark-system-foreground: rgb(240, 232, 232) !important; +} + +/** +change the color of the read along +**/ + +.theme--light.sentence__word, +.theme--light.sentence__text, +.theme--dark.sentence__word.reading, +.theme--dark.sentence__word:hover { + color: var(--theme-light-foreground) !important; +} + +.theme--dark.sentence__word, +.theme--dark.sentence__text, +.theme--light.sentence__word.reading, +.theme--light.sentence__word:hover { + color: var(--theme-dark-foreground) !important; +} + +/** +change the color of the page counter +**/ +.theme--dark .page__counter { + color: var(--theme-dark-foreground) !important; +} + +.theme--light .page__counter { + color: var(--theme-light-foreground) !important; +} + +/** +change the scrollbar color +**/ +.theme--light.pages__container { + scrollbar-color: var(--theme-light-foreground) + var(--theme-light-system-background) !important; +} + +.theme--dark.pages__container { + scrollbar-color: var(--theme-dark-foreground) + var(--theme-dark-system-background) !important; +} + +/** +change the background color and text color +**/ +.theme--light.page__container, +.theme--light.page__col__image, +.page__container, +.theme--light.settings { + background-color: var(--theme-light-background) !important; + color: var(--theme-light-foreground) !important; +} + +.theme--dark.page__container, +.theme--dark.page__col__image, +.page__container, +.theme--dark.settings { + background-color: var(--theme-dark-background) !important; + color: var(--theme-dark-foreground) !important; +} + +/** +set backgrounds +**/ +.background--dark, +.theme--light.sentence__word.reading, +.theme--light.sentence__word:hover { + background: var(--theme-dark-system-background) !important; +} + +.background--light, +.theme--dark.sentence__word.reading, +.theme--dark.sentence__word:hover { + background: var(--theme-light-system-background) !important; +} + +/** control panel section **/ +/** +change the button color +**/ + +.theme--light.ripple, +.color--light { + color: var(--theme-light-foreground) !important; +} + +/** style playback speed button **/ +.theme--light input[type="range"] { + accent-color: var(--theme-light-system-foreground) !important; + background-color: var(--theme-light-background) !important; +} + +.theme--light input[type="range"]::-webkit-slider-runnable-track, +input[type="range"]::-moz-range-track { + background-color: var(--theme-light-system-background) !important; +} + +.theme--dark.ripple, +.color--dark { + color: var(--theme-dark-system-foreground) !important; +} + +.theme--dark input[type="range"] { + accent-color: var(--theme-dark-system-foreground) !important; + background-color: var(--theme-dark-system-background) !important; +} + +.theme--dark input[type="range"]::-webkit-slider-runnable-track, +input[type="range"]::-moz-range-track { + background-color: var(--theme-dark-system-foreground) !important; +} diff --git a/packages/studio-web/tests/fixtures/sentence-paragr-cust-css.html b/packages/studio-web/tests/fixtures/sentence-paragr-cust-css.html new file mode 100644 index 00000000..c10e2b1d --- /dev/null +++ b/packages/studio-web/tests/fixtures/sentence-paragr-cust-css.html @@ -0,0 +1,56 @@ + + + + + + + + Test + + + + + + Test + LJ TTS + + + diff --git a/packages/studio-web/tests/studio-web/download-web-bundle.spec.ts b/packages/studio-web/tests/studio-web/download-web-bundle.spec.ts index c37ee36d..e0361c6f 100644 --- a/packages/studio-web/tests/studio-web/download-web-bundle.spec.ts +++ b/packages/studio-web/tests/studio-web/download-web-bundle.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { testMakeAReadAlong, defaultBeforeEach } from "../test-commands"; +import { + testMakeAReadAlong, + defaultBeforeEach, + testAssetsPath, +} from "../test-commands"; import fs from "fs"; import JSZip from "jszip"; @@ -24,8 +28,9 @@ test("should Download web bundle (zip file format)", async ({ download1.suggestedFilename(), "should have the expected filename", ).toMatch(/sentence\-paragr\-[0-9]*\.zip/); - //await download1.saveAs(testAssetsPath + download1.suggestedFilename()); - const zipPath = await download1.path(); + const zipPath = testAssetsPath + download1.suggestedFilename(); + await download1.saveAs(zipPath); + const zipBin = await fs.readFileSync(zipPath); const zip = await JSZip.loadAsync(zipBin); await expect( @@ -52,6 +57,7 @@ test("should Download web bundle (zip file format)", async ({ zip.file(/www\/assets\/sentence\-paragr\-[0-9]*\.wav/), "should have wav file", ).toHaveLength(1); //www/assets audio exists + await expect( zip.file(/www\/assets\/image-sentence\-paragr\-[0-9\-]*\.png/), "should have image files", @@ -76,4 +82,5 @@ test("should Download web bundle (zip file format)", async ({ xmlString, "download file should contain XML declaration", ).toMatch(/^<\?xml/); + fs.unlinkSync(zipPath); }); diff --git a/packages/studio-web/tests/test-commands.ts b/packages/studio-web/tests/test-commands.ts index 0213e8ab..927cc87f 100644 --- a/packages/studio-web/tests/test-commands.ts +++ b/packages/studio-web/tests/test-commands.ts @@ -57,23 +57,42 @@ export const testMakeAReadAlong = async (page: Page) => { await expect(page.getByTestId("ra-subheader")).toHaveValue("by me"); //add translations - await page.locator("#t0b0d0p0s0").getByRole("button").click({ timeout: 0 }); - await page.locator("#t0b0d0p0s1").getByRole("button").click({ timeout: 0 }); - await page.locator("#t0b0d0p1s0").getByRole("button").click({ timeout: 0 }); + await page + .locator("#t0b0d0p0s1") + .getByTestId("add-translation-button") + .waitFor({ state: "visible" }); + await page + .locator("#t0b0d0p0s0") + .getByTestId("add-translation-button") + .click(); //{ force: true, timeout: 0 }); + await page + .locator("#t0b0d0p0s1") + .getByTestId("add-translation-button") + .click(); //{ force: true, timeout: 0 }); + await page + .locator("#t0b0d0p1s0") + .getByTestId("add-translation-button") + .click(); //{ force: true, timeout: 0 }); //update translations - await expect(page.locator("#t0b0d0p0s0translation")).toBeEditable(); await page .locator("#t0b0d0p0s0translation") .fill("Ceci est un test.", { force: true, timeout: 0 }); - await expect(page.locator("#t0b0d0p0s1translation")).toBeEditable(); + await expect + .soft(page.locator("#t0b0d0p0s0translation")) + .toContainText("Ceci est un test."); await page .locator("#t0b0d0p0s1translation") .fill("Phrase.", { force: true, timeout: 0 }); - await expect(page.locator("#t0b0d0p1s0translation")).toBeEditable(); + await expect + .soft(page.locator("#t0b0d0p0s1translation")) + .toContainText("Phrase."); await page .locator("#t0b0d0p1s0translation") .fill("Paragraphe.", { force: true, timeout: 0 }); + await expect + .soft(page.locator("#t0b0d0p1s0translation")) + .toContainText("Paragraphe."); //upload a photo to page 1 let fileChooserPromise = page.waitForEvent("filechooser"); @@ -88,6 +107,7 @@ export const testMakeAReadAlong = async (page: Page) => { page.locator("#fileElem--t0b0d1").dispatchEvent("click"); fileChooser = await fileChooserPromise; fileChooser.setFiles(testAssetsPath + "page2.png"); + await page.locator("div.toast-message").last().click({ force: true }); await expect(async () => { await expect(page.locator("div.toast-message")).not.toBeVisible(); }).toPass(); diff --git a/packages/web-component/README.md b/packages/web-component/README.md index 40f2692f..6d03fa64 100644 --- a/packages/web-component/README.md +++ b/packages/web-component/README.md @@ -180,16 +180,21 @@ and modify only the UI. Use the web inspector of your browser to find the classe } ``` -Here is a list of classes you want to override: +Here is a list of the minimum classes you want to override: - .sentence\_\_word.theme--light - .sentence\_\_word.theme--light.reading - .sentence\_\_text.theme--light +- .sentence\_\_word.theme--dark +- .sentence\_\_word.theme--dark.reading +- .sentence\_\_text.theme--dark - .sentence\_\_translation - .sentence - .paragraph - .page\_\_container.theme--light (to set page background) +[look at this sample stylesheet to get an idea of what is needed](../studio-web/tests/fixtures/sentence-paragr-cust-css.css) + ## XML customizations You can add classes to the xml tags in the `.readalong` XML file. When coupled with the custom css, it will produce most of the diff --git a/packages/web-component/src/components.d.ts b/packages/web-component/src/components.d.ts index b3a46de0..3dcdaf22 100644 --- a/packages/web-component/src/components.d.ts +++ b/packages/web-component/src/components.d.ts @@ -27,6 +27,10 @@ export namespace Components { "timeout"?: number; } interface ReadAlong { + /** + * Add custom font + */ + "addCustomFont": (fontData: string) => Promise; /** * URL of the audio file */ @@ -95,6 +99,11 @@ export namespace Components { * Select whether scrolling between pages should be "smooth" (default nicely animated, good for fast computers) or "auto" (choppy but much less compute intensive) */ "scrollBehaviour": ScrollBehaviour; + /** + * Update stylesheet + * @param url + */ + "setCss": (url: any) => Promise; /** * Overlay This is an SVG overlay to place over the progress bar */ diff --git a/packages/web-component/src/components/read-along-component/read-along.tsx b/packages/web-component/src/components/read-along-component/read-along.tsx index 68252b64..029633f1 100644 --- a/packages/web-component/src/components/read-along-component/read-along.tsx +++ b/packages/web-component/src/components/read-along-component/read-along.tsx @@ -115,7 +115,7 @@ export class ReadAlongComponent { /** * Optional custom Stylesheet to override defaults */ - @Prop() cssUrl?: string; + @Prop({ mutable: true }) cssUrl?: string; /** * DEPRECATED @@ -726,7 +726,26 @@ export class ReadAlongComponent { this.theme = "light"; } } + /** + * Update stylesheet + * @param url + */ + @Method() + async setCss(url) { + this.cssUrl = url; + } + /** + * Add custom font + */ + @Method() + async addCustomFont(fontData: string) { + const style = document.createElement("style"); + style.setAttribute("id", "ra-wc-custom-font"); + style.setAttribute("type", "text/css"); + style.innerHTML = fontData; + document.head.appendChild(style); + } /** * Return the Sentence Container of Word * Currently the 3rd parent up the tree node diff --git a/packages/web-component/src/components/read-along-component/readme.md b/packages/web-component/src/components/read-along-component/readme.md index 80f8befe..9e5e1745 100644 --- a/packages/web-component/src/components/read-along-component/readme.md +++ b/packages/web-component/src/components/read-along-component/readme.md @@ -27,6 +27,22 @@ ## Methods +### `addCustomFont(fontData: string) => Promise` + +Add custom font + +#### Parameters + +| Name | Type | Description | +| ---------- | -------- | ----------- | +| `fontData` | `string` | | + +#### Returns + +Type: `Promise` + + + ### `changeTheme() => Promise` Change theme @@ -87,6 +103,22 @@ Type: `Promise` +### `setCss(url: any) => Promise` + +Update stylesheet + +#### Parameters + +| Name | Type | Description | +| ----- | ----- | ----------- | +| `url` | `any` | | + +#### Returns + +Type: `Promise` + + + ### `updateSpriteAlignments(alignment: Alignment) => Promise` Update Single Sprite diff --git a/packages/web-component/src/scss/modules/_settings.scss b/packages/web-component/src/scss/modules/_settings.scss index 9231b83a..5a78226c 100644 --- a/packages/web-component/src/scss/modules/_settings.scss +++ b/packages/web-component/src/scss/modules/_settings.scss @@ -13,6 +13,7 @@ -webkit-backdrop-filter: blur(5px) !important; backdrop-filter: blur(5px) !important; } + .settings { height: config.$page-size + 0vh; max-height: config.$page-size + 0vh; @@ -34,11 +35,13 @@ h3 { border-bottom: 1px solid #ccc; + & button { float: inline-start; margin-top: 0; margin-bottom: 0; } + padding: 10px; margin: 0; } @@ -84,18 +87,23 @@ cursor: pointer; text-align: left; color: inherit; + & select, & button { margin-right: 1em; width: 80px; text-align: justify; + background-color: transparent !important; } + & button { margin: 0 1em 0 0; padding: 0 0.0625em; } + margin: 0.5em 1em; } + & > .footer { position: absolute; bottom: 0; @@ -114,19 +122,23 @@ & > div { flex-grow: 8; } + & > button { border: 1px solid #ccc; padding: 0.25em 0.5em !important; } + & > button:hover { background-color: rgba(0, 0, 0, 0.15); box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.15); } } + & button:disabled { background-color: rgba(200, 200, 200, 0.15); color: #ccc; } + & p.version { padding: 1em; font-size: 0.7em; @@ -134,26 +146,31 @@ color: #ccc; } } + .settings.theme--dark { color: #fff; } + @media screen and (max-width: 385px) { .settings { width: 95vw; max-width: 95vw; left: 2.5vw; overflow: auto; + & p { & select, & button { display: block; width: 80%; } + margin-bottom: 1em; border-bottom: 1px solid #ccc; } } } + button:focus, input:focus { box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); diff --git a/update-translations.sh b/update-translations.sh new file mode 100755 index 00000000..8c642d27 --- /dev/null +++ b/update-translations.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# Run this script to extract new translations from the code and updated the +# Spanish and French message files with placeholders for missing translations. +# WARNING: overwrites messages.es.json and messages.fr.json, so commit any +# changes first! + +npx nx extract-i18n studio-web +cd packages/studio-web/ || exit 1 +npx tsx extract-i18n-lang.ts src/i18n/messages.json src/i18n/messages.es.json >src/i18n/messages.es-updated.json +mv src/i18n/messages.es-updated.json src/i18n/messages.es.json +npx tsx extract-i18n-lang.ts src/i18n/messages.json src/i18n/messages.fr.json >src/i18n/messages.fr-updated.json +mv src/i18n/messages.fr-updated.json src/i18n/messages.fr.json