Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebVTT: support styled cues and voice spans #218

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
33 changes: 8 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,14 @@
"@stitches/react": "^1.2.8",
"flexsearch": "^0.7.43",
"hls.js": "^1.5.3",
"node-webvtt": "^1.9.4",
"openseadragon": "^4.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.12",
"sanitize-html": "^2.11.0",
"swiper": "^9.4.1",
"uuid": "^9.0.1"
"swiper": "^9.0.0",
"uuid": "^9.0.1",
"vtt.js": "^0.13.0"
},
"devDependencies": {
"@iiif/presentation-3": "^1.1.3",
Expand Down
22 changes: 10 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe("Information panel cue component", () => {
it("renders", () => {
render(
<Group>
<Cue label="Text" start={107} end={150} />
<Cue html="<div>Text</div>" text="Text" start={107} end={150} />
</Group>,
);
const cue = screen.getByTestId("information-panel-cue");
Expand Down
12 changes: 8 additions & 4 deletions src/components/Viewer/InformationPanel/Annotation/VTT/Cue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
const AutoScrollDisableTime = 750;

interface Props {
label: string;
html: string;
text: string;
start: number;
end: number;
}
Expand All @@ -34,7 +35,7 @@ const findScrollableParent = (
return null;
};

const Cue: React.FC<Props> = ({ label, start, end }) => {
const Cue: React.FC<Props> = ({ html, text, start, end }) => {
const dispatch: any = useViewerDispatch();
const {
configOptions,
Expand Down Expand Up @@ -132,9 +133,12 @@ const Cue: React.FC<Props> = ({ label, start, end }) => {
aria-checked={isActive}
data-testid="information-panel-cue"
onClick={handleClick}
value={label}
value={text}
>
{label}
<div
className="webvtt-cue"
dangerouslySetInnerHTML={{ __html: html }}
></div>
<strong>{convertTime(start)}</strong>
</Item>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import AnnotationItemVTT from "./VTT";
import Menu from "src/components/Viewer/InformationPanel/Menu";
import React from "react";

// Required to prevent an annoying but harmless error while running this test
import { VTTCue } from "vtt.js";
window.VTTCue = VTTCue;

vi.mock("src/components/Viewer/InformationPanel/Menu");
vi.mocked(Menu).mockReturnValue(<div>Menu Component</div>);

Expand Down Expand Up @@ -49,6 +53,7 @@ describe("AnnotationItemVTT", () => {
global.fetch = vitest.fn(() =>
Promise.reject(new Error("I am the error message")),
);

render(<AnnotationItemVTT {...props} />);
expect(await screen.findByTestId("error-message")).toHaveTextContent(
"Network Error: Error: I am the error message",
Expand Down
18 changes: 7 additions & 11 deletions src/components/Viewer/InformationPanel/Annotation/VTT/VTT.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import React, { useEffect } from "react";
import useWebVtt, {
NodeWebVttCue,
NodeWebVttCueNested,
} from "src/hooks/use-webvtt";
import useWebVtt, { NodeWebVttCueNested } from "src/hooks/use-webvtt";

import { Group } from "src/components/Viewer/InformationPanel/Annotation/VTT/Cue.styled";
import { InternationalString } from "@iiif/presentation-3";
import Menu from "src/components/Viewer/InformationPanel/Menu";
import { getLabel } from "src/hooks/use-iiif";
import { parse } from "node-webvtt";

type AnnotationItemVTTProps = {
label: InternationalString | undefined;
Expand All @@ -20,7 +16,7 @@ const AnnotationItemVTT: React.FC<AnnotationItemVTTProps> = ({
vttUri,
}) => {
const [cues, setCues] = React.useState<Array<NodeWebVttCueNested>>([]);
const { createNestedCues, orderCuesByTime } = useWebVtt();
const { createNestedCues, orderCuesByTime, parseVttData } = useWebVtt();
const [isNetworkError, setIsNetworkError] = React.useState<Error>();

useEffect(
Expand All @@ -34,11 +30,11 @@ const AnnotationItemVTT: React.FC<AnnotationItemVTTProps> = ({
})
.then((response) => response.text())
.then((data) => {
const flatCues = parse(data)
.cues as unknown as Array<NodeWebVttCue>;
const orderedCues = orderCuesByTime(flatCues);
const nestedCues = createNestedCues(orderedCues);
setCues(nestedCues);
parseVttData(data).then((flatCues) => {
const orderedCues = orderCuesByTime(flatCues);
const nestedCues = createNestedCues(orderedCues);
setCues(nestedCues);
});
})
.catch((error) => {
console.error(vttUri, error.toString());
Expand Down
4 changes: 2 additions & 2 deletions src/components/Viewer/InformationPanel/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ const Menu: React.FC<MenuProps> = ({ items }) => {
return (
<MenuStyled>
{items.map((item) => {
const { text, start, end, children, identifier } = item;
const { html, text, start, end, children, identifier } = item;
return (
<li key={identifier}>
<Cue label={text} start={start} end={end} />
<Cue html={html} text={text} start={start} end={end} />
{children && <Menu items={children} />}
</li>
);
Expand Down
40 changes: 36 additions & 4 deletions src/hooks/use-webvtt.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// @ts-nocheck

import { v4 as uuidv4 } from "uuid";
import { WebVTT, VTTCue } from "vtt.js";

export interface NodeWebVttCue {
identifier?: string;
start: number;
end: number;
html: string;
text: string;
styles?: string;
children?: Array<NodeWebVttCue>;
align?: "start" | "left" | "center" | "middle" | "end" | "right";
}
export interface NodeWebVttCueNested extends NodeWebVttCue {
children?: Array<NodeWebVttCueNested>;
Expand All @@ -26,7 +27,7 @@ const useWebVtt = () => {

/**
* This function takes an array of NodeWebVttCue items as input, where each item
* is an object with properties identifier, start, end, text, and styles. It
* is an object with properties identifier, start, end, html, text, and align. It
* iterates through the array of items and uses a stack to keep track of nested
* items. It compares the current item's start with the end of the items in the
* stack. If the current item's start is smaller than the end of the top item
Expand All @@ -36,7 +37,9 @@ const useWebVtt = () => {
* the nestedItems array. The resulting nestedItems array contains the items
* organized into nested structures based on their start and end values.
*/
function createNestedCues(flat: Array<NodeWebVttCue>): Array<NodeWebVttCue> {
function createNestedCues(
flat: Array<NodeWebVttCue>,
): Array<NodeWebVttCueNested> {
const nestedItems = [];
const stack = [];

Expand Down Expand Up @@ -89,11 +92,40 @@ const useWebVtt = () => {
return cues.sort((cue1, cue2) => cue1.start - cue2.start);
}

function parseVttData(data: string): Promise<Array<NodeWebVttCue>> {
return new Promise((resolve, reject) => {
const cues: Array<NodeWebVttCue> = [];
const parser = new WebVTT.Parser(window, WebVTT.StringDecoder());
parser.oncue = (cue: VTTCue) => {
const domTree: DocumentFragment = WebVTT.convertCueToDOMTree(
window,
cue.text,
);
const html = domTree.firstElementChild?.outerHTML || "&nbsp;";
const text = domTree.firstElementChild?.textContent || "";

cues.push({
identifier: uuidv4(),
start: cue.startTime,
end: cue.endTime,
align: cue.align,
html,
text,
});
};
parser.onflush = () => resolve(cues);
parser.onparsingerror = (err) => reject(err);
parser.parse(data);
parser.flush();
});
}

return {
addIdentifiersToParsedCues,
createNestedCues,
isChild,
orderCuesByTime,
parseVttData,
};
};

Expand Down
Loading