feat: add html preview time display (#58)

This commit is contained in:
Wesley Luyten
2025-10-13 16:11:14 -05:00
committed by GitHub
parent fee16708cb
commit 871701a453
4 changed files with 199 additions and 18 deletions
@@ -165,16 +165,23 @@ export class MediaPopoverPortal extends HTMLElement {
}
#setupPortal(): void {
const portalId = this.getAttribute('root-id');
const portalId = this.getAttribute('root-id') ?? '@default_portal_id';
if (!portalId) return;
const portalContainer = (this.getRootNode() as ShadowRoot | Document).getElementById(portalId);
/* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */
// NOTE: Hacky solution in part to ensure styling propogates from skin to container's baked in portal (TL;DR - Shadow DOM vs. Light DOM CSS) (CJP)
const portalContainer
= ((this.getRootNode() as ShadowRoot | Document).getElementById(portalId)
?? (this.getRootNode() as ShadowRoot | Document)
.querySelector('media-container')
?.shadowRoot
?.getElementById(portalId))
? (this.getRootNode() as ShadowRoot | Document).querySelector('media-container')
: undefined;
if (!portalContainer) return;
portalContainer.addEventListener('mouseenter', this);
portalContainer.addEventListener('mouseleave', this);
this.#portal = document.createElement('div');
this.#portal.slot = 'portal';
this.#portal.id = uniqueId();
portalContainer.append(this.#portal);
@@ -0,0 +1,85 @@
import type { PreviewTimeDisplayState } from '@vjs-10/media-store';
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
import { formatDisplayTime, previewTimeDisplayStateDefinition } from '@vjs-10/media-store';
import { toConnectedHTMLComponent } from '../utils/component-factory';
export class PreviewTimeDisplayBase extends HTMLElement {
static shadowRootOptions = {
mode: 'open' as ShadowRootMode,
};
static observedAttributes: string[] = ['show-remaining'];
_state:
| {
previewTime: number | undefined;
}
| undefined;
constructor() {
super();
if (!this.shadowRoot) {
this.attachShadow((this.constructor as typeof PreviewTimeDisplayBase).shadowRootOptions);
}
}
get previewTime(): number {
return this._state?.previewTime ?? 0;
}
get showRemaining(): boolean {
return this.hasAttribute('show-remaining');
}
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
if (name === 'show-remaining' && this._state) {
// Re-render with current state when show-remaining attribute changes
this._update({}, this._state);
}
}
_update(_props: any, state: any): void {
this._state = state;
/** @TODO Should this live here or elsewhere? (CJP) */
const timeLabel = formatDisplayTime(state.previewTime);
if (this.shadowRoot) {
this.shadowRoot.textContent = timeLabel;
}
}
}
export const usePreviewTimeDisplayState: StateHook<{
previewTime: number | undefined;
}> = {
keys: [...previewTimeDisplayStateDefinition.keys],
transform: (rawState, _mediaStore) => ({
...previewTimeDisplayStateDefinition.stateTransform(rawState),
// Preview time display is read-only, so no request methods needed
}),
};
export const usePreviewTimeDisplayProps: PropsHook<{
previewTime: number | undefined;
}> = (_state, _element) => {
const baseProps: Record<string, any> = {};
return baseProps;
};
export const PreviewTimeDisplay: ConnectedComponentConstructor<PreviewTimeDisplayState> = toConnectedHTMLComponent(
PreviewTimeDisplayBase,
usePreviewTimeDisplayState,
usePreviewTimeDisplayProps,
'PreviewTimeDisplay',
);
// Register the custom element
if (!globalThis.customElements.get('preview-time-display')) {
globalThis.customElements.define('preview-time-display', PreviewTimeDisplay);
}
export default PreviewTimeDisplay;
@@ -10,11 +10,13 @@ export class MediaTooltipRoot extends HTMLElement {
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
#cleanup: (() => void) | null = null;
#arrowElement: HTMLElement | null = null;
#mousePosition = { x: 0, y: 0 };
constructor() {
super();
this.addEventListener('mouseenter', this);
this.addEventListener('mouseleave', this);
this.addEventListener('mousemove', this);
}
handleEvent(event: Event): void {
@@ -22,6 +24,8 @@ export class MediaTooltipRoot extends HTMLElement {
this.#handleMouseEnter();
} else if (event.type === 'mouseleave') {
this.#handleMouseLeave();
} else if (event.type === 'mousemove') {
this.#handleMouseMove(event as MouseEvent);
}
}
@@ -35,17 +39,22 @@ export class MediaTooltipRoot extends HTMLElement {
}
static get observedAttributes(): string[] {
return ['delay', 'close-delay'];
return ['delay', 'close-delay', 'track-cursor-axis'];
}
get delay(): number {
return Number.parseInt(this.getAttribute('delay') ?? '600', 10);
return Number.parseInt(this.getAttribute('delay') ?? '0', 10);
}
get closeDelay(): number {
return Number.parseInt(this.getAttribute('close-delay') ?? '0', 10);
}
get trackCursorAxis(): 'x' | 'y' | 'both' | undefined {
const value = this.getAttribute('track-cursor-axis');
return value === 'x' || value === 'y' || value === 'both' ? value : undefined;
}
get #triggerElement(): MediaTooltipTrigger | null {
return this.querySelector('media-tooltip-trigger') as MediaTooltipTrigger | null;
}
@@ -121,7 +130,54 @@ export class MediaTooltipRoot extends HTMLElement {
middleware.push(arrow({ element: this.#arrowElement }));
}
computePosition(trigger, popup, {
// Use cursor position for positioning if trackCursorAxis is enabled
const referenceElement = this.trackCursorAxis
? {
getBoundingClientRect: () => {
const triggerRect = trigger.getBoundingClientRect();
if (this.trackCursorAxis === 'x') {
// Track horizontal cursor position, use trigger's vertical position
return {
width: 0,
height: 0,
top: triggerRect.top,
right: this.#mousePosition.x,
bottom: triggerRect.bottom,
left: this.#mousePosition.x,
x: this.#mousePosition.x,
y: triggerRect.top,
};
} else if (this.trackCursorAxis === 'y') {
// Track vertical cursor position, use trigger's horizontal position
return {
width: 0,
height: 0,
top: this.#mousePosition.y,
right: triggerRect.right,
bottom: this.#mousePosition.y,
left: triggerRect.left,
x: triggerRect.left,
y: this.#mousePosition.y,
};
} else {
// Track both axes (trackCursorAxis === 'both')
return {
width: 0,
height: 0,
top: this.#mousePosition.y,
right: this.#mousePosition.x,
bottom: this.#mousePosition.y,
left: this.#mousePosition.x,
x: this.#mousePosition.x,
y: this.#mousePosition.y,
};
}
},
}
: trigger;
computePosition(referenceElement, popup, {
placement,
middleware,
}).then(({ x, y, middlewareData }: { x: number; y: number; middlewareData: any }) => {
@@ -142,7 +198,16 @@ export class MediaTooltipRoot extends HTMLElement {
};
updatePosition();
this.#cleanup = autoUpdate(trigger, popup, updatePosition);
if (!this.trackCursorAxis) {
this.#cleanup = autoUpdate(trigger, popup, updatePosition);
}
}
#updatePosition(): void {
if (this.#open && this.trackCursorAxis) {
this.#setupFloating();
}
}
#clearHoverTimeout(): void {
@@ -165,6 +230,16 @@ export class MediaTooltipRoot extends HTMLElement {
this.#setOpen(false);
}, this.closeDelay);
}
#handleMouseMove(event: MouseEvent): void {
if (this.trackCursorAxis) {
this.#mousePosition = { x: event.clientX, y: event.clientY };
// Update position if tooltip is open
if (this.#open) {
this.#updatePosition();
}
}
}
}
export class MediaTooltipTrigger extends HTMLElement {
@@ -195,7 +270,7 @@ export class MediaTooltipTrigger extends HTMLElement {
}
const attributeName = mutation.attributeName;
if (!attributeName) {
if (!attributeName || !attributeName.startsWith('data-')) {
return;
}
@@ -8,6 +8,7 @@ import '../components/media-time-slider';
import '../components/media-fullscreen-button';
import '../components/media-duration-display';
import '../components/media-current-time-display';
import '../components/media-preview-time-display';
import '../components/media-popover';
import '../components/media-tooltip';
import '@vjs-10/html-icons';
@@ -293,13 +294,26 @@ export function getTemplateHTML() {
</media-tooltip-root>
<!-- Use the show-remaining attribute to show count down/remaining time -->
<media-current-time-display show-remaining></media-current-time-display>
<media-time-slider-root>
<media-time-slider-track>
<media-time-slider-progress></media-time-slider-progress>
<media-time-slider-pointer></media-time-slider-pointer>
</media-time-slider-track>
<media-time-slider-thumb></media-time-slider-thumb>
</media-time-slider-root>
<media-tooltip-root track-cursor-axis="x">
<media-tooltip-trigger>
<media-time-slider-root>
<media-time-slider-track>
<media-time-slider-progress></media-time-slider-progress>
<media-time-slider-pointer></media-time-slider-pointer>
</media-time-slider-track>
<media-time-slider-thumb></media-time-slider-thumb>
</media-time-slider-root>
</media-tooltip-trigger>
<media-tooltip-portal>
<media-tooltip-positioner side="top" side-offset="18" collision-padding="12">
<media-tooltip-popup>
<preview-time-display></preview-time-display>
</media-tooltip-popup>
</media-tooltip-positioner>
</media-tooltip-portal>
</media-tooltip-root>
<media-duration-display></media-duration-display>
<media-popover-root open-on-hover delay="200" close-delay="100">
<media-popover-trigger>
@@ -310,7 +324,7 @@ export function getTemplateHTML() {
</media-mute-button>
</media-popover-trigger>
<media-popover-portal>
<media-popover-positioner side="top" side-offset="0">
<media-popover-positioner side="top">
<media-popover-popup>
<media-volume-slider-root orientation="vertical">
<media-volume-slider-track>