@webwriter/interactive-video
Version:
(WIP) Enhance learning by adding interactive content in popups to videos for an engaging, interactive experience.
337 lines (296 loc) • 10.5 kB
text/typescript
import { html, css, LitElement, PropertyValues } from "lit";
import { LitElementWw } from "@webwriter/lit";
import { customElement, property, query } from "lit/decorators.js";
import {
videoContext,
InteractiveVideoContext,
} from "../../utils/interactive-video-context";
import {
SlIconButton,
SlRange,
SlDropdown,
SlMenu,
SlMenuItem,
SlButton,
SlIcon,
} from "@shoelace-style/shoelace";
import "@shoelace-style/shoelace/dist/themes/light.css";
import { WwVideoInteraction } from "../../widgets/webwriter-video-interaction/webwriter-video-interaction.component";
import { WebwriterInteractiveVideo } from "../../widgets/webwriter-interactive-video/webwriter-interactive-video.component";
import { WwInteractiveBauble } from "../webwriter-interactive-bauble/webwriter-interactive-bauble";
import { consume } from "@lit/context";
import add from "@tabler/icons/outline/timeline-event-plus.svg";
import bookmark from "@tabler/icons/filled/bookmark.svg";
//CSS
import styles from "./interactions-progress-bar.styles";
export class InteractionsProgressBar extends LitElementWw {
({ context: videoContext, subscribe: true })
accessor videoContext: InteractiveVideoContext;
("#drop-area")
accessor dropArea;
("#controls-upper")
accessor upperControls: HTMLDivElement;
/**
* Query for the add interactions button.
*/
("#add-button")
accessor addButton: SlIconButton;
/**
* Returns an object that maps custom element names to their corresponding classes.
* These custom elements can be used within the scope of the `webwriter-interactive-video` component.
*
* @returns An object mapping custom element names to their corresponding classes.
*/
static get scopedElements() {
return {
"webwriter-interactive-bauble": WwInteractiveBauble,
"sl-icon-button": SlIconButton,
"sl-range": SlRange,
"sl-dropdown": SlDropdown,
"sl-menu": SlMenu,
"sl-menu-item": SlMenuItem,
"sl-button": SlButton,
"sl-icon": SlIcon,
};
}
//import CSS
static styles = [styles];
/*
*/
//TODO: On resize, the offset of baubles need to be recalculated
render() {
return html`
<div class="interactions-progress-bar">
<sl-button
variant="default"
size="small"
id="add-button"
@click=${this.handleAddClick}
?disabled=${!this.isContentEditable}
>
<sl-icon
slot="prefix"
src=${add}
style="height: 20px; width: 20px;"
></sl-icon>
Add Popup
</sl-button>
<div
id="drop-area"
@drop=${this.handleBaubleDroppedOnDropArea}
@dragover=${this.handleBaubleDraggedOverDropArea}
@dragleave=${this.handleBaubleLeaveDropArea}
>
<div id="controls-upper">
${Array.from(
(
(this.getRootNode() as ShadowRoot)
.host as WebwriterInteractiveVideo
).videoInteractions
).map((interaction) => {
return html`<webwriter-interactive-bauble
contenteditable=${this.isContentEditable}
style=${this.isContentEditable
? "cursor: grab; position: absolute;"
: "cursor: pointer; position: absolute;"}
offset=${this.calculateOffset(
(interaction as WwVideoInteraction).startTime
)}
@dragstart=${this.handleBaubleDragStart}
@dragend=${this.handleBaubleDragEnd}
draggable=${this.isContentEditable ? "true" : "false"}
@click=${this.handleBaubleClick}
id=${(interaction as WwVideoInteraction).id}
>
</webwriter-interactive-bauble>`;
})}
${Array.from(JSON.parse(this.videoContext.chapterConfig)).map(
({ title, startTime }) => {
return html`
${startTime !== 0
? html` <div
style="
width: 1px;
height: 15px;
background-color: #E9E9E9;
position: absolute;
offset: ${this.calculateOffset(startTime)}px;
"
></div>`
: null}
`;
}
)}
${Array.from(JSON.parse(this.videoContext.chapterConfig)).map(
({ title, startTime }) => {
return html`
${startTime !== 0
? html` <sl-icon
src=${bookmark}
style="
width: 15px;
height: 15px;
color: #E9E9E9;
position: absolute;
left: ${this.calculateOffset(startTime)}px;
"
@mouseover=${(e) => (e.target.style.color = "#0084C6")}
@mouseleave=${(e) => (e.target.style.color = "#E9E9E9")}
@click=${() => {
this.dispatchEvent(
new CustomEvent("jumpToChapter", {
detail: { startTime: startTime },
bubbles: true,
composed: true,
})
);
}}
></sl-icon>`
: null}
`;
}
)}
</div>
</div>
</div>
`;
}
/**
* Handles the click event on a bauble element.
*
* @param event - The MouseEvent object representing the click event.
* @remarks
* This function is called when a bauble is clicked. It checks if the control key is pressed and if so, it sets the video time to the bauble's start time.
* Otherwise, it calls the clickEventHelper function to handle the click event.
*
*
*
*
*/
handleBaubleClick(event: MouseEvent) {
const clickedElement = event.target as WwInteractiveBauble;
this.dispatchEvent(
new CustomEvent("interactionBaubleClicked", {
detail: { id: clickedElement.id },
bubbles: true,
composed: true,
})
);
}
/**
* Handles the drag start event for a bauble.
*
* @param e - The drag event.
*/
handleBaubleDragStart = (e: DragEvent) => {
e.dataTransfer.setData("id", (e.target as WwInteractiveBauble).id);
};
/**
* Handles the event when a bauble is dropped on the drop area.
*
* @param e - The DragEvent object representing the drop event.
* @remarks
* Dropping a bauble on the drop area changes the corresponding interactions starttime to whatever the bauble was dropped at
*/
handleBaubleDroppedOnDropArea(e: DragEvent) {
const rect = this.dropArea.getBoundingClientRect();
const distanceFromLeft = e.clientX - rect.left;
const videoElement = this.parentNode.parentNode.querySelector(
"#video"
) as HTMLVideoElement;
this.dispatchEvent(
new CustomEvent("changeInteractionStartTime", {
detail: {
newTime: Math.floor(
videoElement.duration * (distanceFromLeft / rect.width)
),
index: parseInt(e.dataTransfer.getData("id")),
},
bubbles: true,
composed: true,
})
);
this.dropArea.style.background = "none";
this.updateBaublePositions();
}
/**
* Handles the event when a bauble is dragged over the drop area.
* Changes the background color of the drop area to a semi-transparent gray.
*
* @param e - The DragEvent object representing the drag event.
*/
handleBaubleDraggedOverDropArea(e: DragEvent) {
this.dropArea.style.background = "rgba(0.5,0.5,0.5,0.5)";
}
/**
* Handles the event when a bauble is dragged out of the drop area.
* Resets the background of the drop area.
*
* @param e - The DragEvent object representing the drag event.
*/
handleBaubleLeaveDropArea(e: DragEvent) {
this.dropArea.style.background = "none";
}
/**
* Handles the drag end event for the bauble.
*
* @param e - The drag event.
*/
handleBaubleDragEnd = (e: DragEvent) => {
this.dropArea.style.background = "none";
};
/**
* Updates the positions of the baubles in the widget.
*/
updateBaublePositions() {
if (!this.upperControls) return;
const children = this.upperControls.children;
Array.from(children).forEach((child: Element) => {
if (child instanceof WwInteractiveBauble) {
const id = parseInt(child.id);
const slottedInteraction = (
(this.getRootNode() as ShadowRoot).host as WebwriterInteractiveVideo
).videoInteractions.filter(
(interaction) => Number(interaction.id) === Number(id)
)[0] as WwVideoInteraction;
if (slottedInteraction) {
const newOffset = this.calculateOffset(slottedInteraction.startTime);
if (newOffset !== undefined) {
child.setAttribute("offset", `${newOffset}`);
}
}
}
});
this.requestUpdate();
}
/**
* Calculates the offset based on the given time.
*
* @param time - The time in seconds.
* @returns The calculated offset.
*/
calculateOffset(time: number): number {
if (!this.videoContext.videoLoaded) return;
console.log(time);
const videoElement = this.parentNode.parentNode.querySelector(
"#video"
) as HTMLVideoElement;
return (
(time / videoElement.duration) *
0.97 *
videoElement.getBoundingClientRect().width
);
}
/**
* Handles the click event for the add button.
*/
handleAddClick = () => {
if (!this.videoContext.videoLoaded) return;
this.dispatchEvent(
new CustomEvent("addInteraction", {
bubbles: true,
composed: true,
})
);
};
}