From 871701a453e49192ef986d286aaf957c0f533a93 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Mon, 13 Oct 2025 16:11:14 -0500 Subject: [PATCH] feat: add html preview time display (#58) --- .../html/html/src/components/media-popover.ts | 17 ++-- .../components/media-preview-time-display.ts | 85 +++++++++++++++++++ .../html/html/src/components/media-tooltip.ts | 85 +++++++++++++++++-- .../html/html/src/skins/media-skin-default.ts | 30 +++++-- 4 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 packages/html/html/src/components/media-preview-time-display.ts diff --git a/packages/html/html/src/components/media-popover.ts b/packages/html/html/src/components/media-popover.ts index eb915247..1e578887 100644 --- a/packages/html/html/src/components/media-popover.ts +++ b/packages/html/html/src/components/media-popover.ts @@ -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); diff --git a/packages/html/html/src/components/media-preview-time-display.ts b/packages/html/html/src/components/media-preview-time-display.ts new file mode 100644 index 00000000..e7ab9353 --- /dev/null +++ b/packages/html/html/src/components/media-preview-time-display.ts @@ -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 = {}; + return baseProps; +}; + +export const PreviewTimeDisplay: ConnectedComponentConstructor = 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; diff --git a/packages/html/html/src/components/media-tooltip.ts b/packages/html/html/src/components/media-tooltip.ts index 3cbd0772..5cb538bf 100644 --- a/packages/html/html/src/components/media-tooltip.ts +++ b/packages/html/html/src/components/media-tooltip.ts @@ -10,11 +10,13 @@ export class MediaTooltipRoot extends HTMLElement { #hoverTimeout: ReturnType | 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; } diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index a7f2dfa6..18693af1 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -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() { - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -310,7 +324,7 @@ export function getTemplateHTML() { - +