@djangocfg/ui-tools 2.1.431 → 2.1.433
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +88 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +267 -42
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +26 -2
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MiddleEllipsis - Finder/Quick-Look-style middle truncation for filenames.
|
|
5
|
+
*
|
|
6
|
+
* macOS truncates a too-long filename in the MIDDLE so the start AND the
|
|
7
|
+
* extension stay readable ("screencapture-referee…very-long-name.png"),
|
|
8
|
+
* unlike a plain CSS `truncate` which clips the (most meaningful) extension.
|
|
9
|
+
*
|
|
10
|
+
* This is a CSS-only technique — no JS measurement, no ResizeObserver:
|
|
11
|
+
* - The "head" span flex-shrinks and truncates with an end-ellipsis.
|
|
12
|
+
* - The "tail" span is pinned (flex-shrink-0) and always shows the last
|
|
13
|
+
* `tailChars` characters, which include the extension.
|
|
14
|
+
* When the name fits, the head shows in full and no ellipsis appears; when it
|
|
15
|
+
* overflows, the head clips while the tail (extension) is preserved.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
19
|
+
|
|
20
|
+
interface MiddleEllipsisProps {
|
|
21
|
+
/** Full text to display. The container caps the width; CSS does the rest. */
|
|
22
|
+
text: string;
|
|
23
|
+
/**
|
|
24
|
+
* How many trailing characters to keep pinned (and thus always visible).
|
|
25
|
+
* Sized to comfortably cover an extension plus a little of the name tail.
|
|
26
|
+
*/
|
|
27
|
+
tailChars?: number;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function MiddleEllipsis({ text, tailChars = 8, className }: MiddleEllipsisProps) {
|
|
32
|
+
// Split so the tail keeps the extension visible. Guard against short names
|
|
33
|
+
// (where head/tail would overlap) by rendering them whole in the head.
|
|
34
|
+
const splitAt = Math.max(0, text.length - tailChars);
|
|
35
|
+
const head = text.slice(0, splitAt);
|
|
36
|
+
const tail = text.slice(splitAt);
|
|
37
|
+
const short = splitAt === 0;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
// `title` gives the full, lossless name on hover.
|
|
41
|
+
<span className={cn('flex min-w-0 items-center', className)} title={text}>
|
|
42
|
+
<span className="min-w-0 flex-1 truncate">{short ? text : head}</span>
|
|
43
|
+
{!short && <span className="flex-shrink-0 whitespace-pre">{tail}</span>}
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -7,3 +7,6 @@ export type { UseImageTransformOptions, UseImageTransformReturn } from './useIma
|
|
|
7
7
|
|
|
8
8
|
export { useImageLoading } from './useImageLoading';
|
|
9
9
|
export type { UseImageLoadingOptions, UseImageLoadingReturn } from './useImageLoading';
|
|
10
|
+
|
|
11
|
+
export { useIdleChrome } from './useIdleChrome';
|
|
12
|
+
export type { UseIdleChromeReturn } from './useIdleChrome';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useIdleChrome - Auto-hides floating lightbox chrome when the pointer rests.
|
|
5
|
+
*
|
|
6
|
+
* macOS Quick Look / Preview hide their controls after a short idle period and
|
|
7
|
+
* bring them back on the slightest pointer movement. This hook tracks that
|
|
8
|
+
* "active" flag and gives back the pointer handlers to wire onto the canvas.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
import { CHROME_IDLE_MS } from '../utils';
|
|
14
|
+
|
|
15
|
+
export interface UseIdleChromeReturn {
|
|
16
|
+
/** True while the chrome should be visible (recent pointer activity). */
|
|
17
|
+
active: boolean;
|
|
18
|
+
/** Pointer-move handler — call to mark activity (resets the idle timer). */
|
|
19
|
+
onActivity: () => void;
|
|
20
|
+
/** Pin the chrome open (e.g. while hovering a control) regardless of idle. */
|
|
21
|
+
setPinned: (pinned: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useIdleChrome(enabled: boolean): UseIdleChromeReturn {
|
|
25
|
+
const [active, setActive] = useState(true);
|
|
26
|
+
const pinnedRef = useRef(false);
|
|
27
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
28
|
+
|
|
29
|
+
const clear = useCallback(() => {
|
|
30
|
+
if (timerRef.current) {
|
|
31
|
+
clearTimeout(timerRef.current);
|
|
32
|
+
timerRef.current = null;
|
|
33
|
+
}
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const schedule = useCallback(() => {
|
|
37
|
+
clear();
|
|
38
|
+
if (!enabled) return;
|
|
39
|
+
timerRef.current = setTimeout(() => {
|
|
40
|
+
if (!pinnedRef.current) setActive(false);
|
|
41
|
+
}, CHROME_IDLE_MS);
|
|
42
|
+
}, [clear, enabled]);
|
|
43
|
+
|
|
44
|
+
const onActivity = useCallback(() => {
|
|
45
|
+
setActive(true);
|
|
46
|
+
schedule();
|
|
47
|
+
}, [schedule]);
|
|
48
|
+
|
|
49
|
+
const setPinned = useCallback(
|
|
50
|
+
(pinned: boolean) => {
|
|
51
|
+
pinnedRef.current = pinned;
|
|
52
|
+
if (pinned) {
|
|
53
|
+
clear();
|
|
54
|
+
setActive(true);
|
|
55
|
+
} else {
|
|
56
|
+
schedule();
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
[clear, schedule],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Start the idle countdown on mount; chrome shows briefly then fades.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
setActive(true);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
schedule();
|
|
69
|
+
return clear;
|
|
70
|
+
}, [enabled, schedule, clear]);
|
|
71
|
+
|
|
72
|
+
return { active, onActivity, setPinned };
|
|
73
|
+
}
|
|
@@ -43,6 +43,16 @@ export interface ImageViewerProps {
|
|
|
43
43
|
initialIndex?: number;
|
|
44
44
|
/** Hide expand button when already in dialog */
|
|
45
45
|
inDialog?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Render the restrained macOS-grade fullscreen lightbox chrome instead of
|
|
48
|
+
* the embedded editing toolbar: a clean dark backdrop (no checkerboard),
|
|
49
|
+
* idle-fading floating controls (close + prev/next + status pill), and no
|
|
50
|
+
* rotate/flip slab. Intended for the click-to-expand image lightbox.
|
|
51
|
+
* Implies `inDialog`.
|
|
52
|
+
*/
|
|
53
|
+
lightbox?: boolean;
|
|
54
|
+
/** Close request from the lightbox chrome (Esc / close button). */
|
|
55
|
+
onRequestClose?: () => void;
|
|
46
56
|
/** Focus the viewer container on mount so its keyboard scope is active
|
|
47
57
|
* immediately (zoom/rotate/gallery hotkeys). Pair with `key={src}`
|
|
48
58
|
* upstream when the parent wants a fresh focus per source change. */
|
|
@@ -40,6 +40,34 @@ export const MAX_ZOOM = 8;
|
|
|
40
40
|
/** Pixels to pan per arrow-key press */
|
|
41
41
|
export const KEYBOARD_PAN_STEP = 60;
|
|
42
42
|
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// GESTURE SENSITIVITY (macOS-grade pinch / trackpad zoom)
|
|
45
|
+
// =============================================================================
|
|
46
|
+
//
|
|
47
|
+
// react-zoom-pan-pinch computes the per-event wheel zoom magnitude as
|
|
48
|
+
// `smoothStep * |event.deltaY|` (its `smooth` path), which means the scale
|
|
49
|
+
// tracks the gesture 1:1 — a harder pinch / faster wheel moves more. The
|
|
50
|
+
// library default `smoothStep` is `0.001`, which on a macOS trackpad
|
|
51
|
+
// (whose pinch `deltaY` arrives as small ~±1–6 values with `ctrlKey=true`)
|
|
52
|
+
// produces ~0.005 scale/frame — imperceptible, the "weak pinch" symptom.
|
|
53
|
+
//
|
|
54
|
+
// We raise it so a natural trackpad pinch reaches a meaningful zoom in a
|
|
55
|
+
// single gesture while a mouse-wheel-with-ctrl notch (large |deltaY| ~100)
|
|
56
|
+
// stays a sensible discrete step. The library clamps to min/max scale, so
|
|
57
|
+
// the larger coefficient cannot overshoot the bounds.
|
|
58
|
+
|
|
59
|
+
/** Wheel/trackpad-pinch zoom sensitivity. scaleΔ = WHEEL_SMOOTH_STEP * |deltaY|. */
|
|
60
|
+
export const WHEEL_SMOOTH_STEP = 0.0055;
|
|
61
|
+
|
|
62
|
+
/** Touch pinch sensitivity (two-finger pinch on touchscreens). */
|
|
63
|
+
export const PINCH_STEP = 5;
|
|
64
|
+
|
|
65
|
+
/** Double-click / double-tap zoom multiplier (fit ↔ zoomed toward the point). */
|
|
66
|
+
export const DOUBLE_CLICK_STEP = 1.4;
|
|
67
|
+
|
|
68
|
+
/** Idle delay (ms) before the lightbox chrome fades out when the pointer rests. */
|
|
69
|
+
export const CHROME_IDLE_MS = 2200;
|
|
70
|
+
|
|
43
71
|
/** Available zoom presets */
|
|
44
72
|
export const ZOOM_PRESETS: readonly ZoomPreset[] = [
|
|
45
73
|
{ label: 'Fit', value: 'fit' },
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* iframe sources where the embed renders its own UI).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import { MediaController } from 'media-chrome/react';
|
|
15
|
+
import { useEffect, useMemo, useRef, type CSSProperties } from 'react';
|
|
16
|
+
import { MediaController, MediaGestureReceiver } from 'media-chrome/react';
|
|
17
17
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
18
|
import './styles/video-player.css';
|
|
19
19
|
|
|
@@ -23,6 +23,7 @@ import { CanvasDispatcher } from './canvas/canvas-dispatcher';
|
|
|
23
23
|
import {
|
|
24
24
|
ControlsBar,
|
|
25
25
|
PlayButton,
|
|
26
|
+
CenterPlay,
|
|
26
27
|
SeekBar,
|
|
27
28
|
Volume,
|
|
28
29
|
PlaybackRate,
|
|
@@ -50,11 +51,20 @@ export function VideoPlayer({
|
|
|
50
51
|
[source],
|
|
51
52
|
);
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
54
|
+
// Provider embeds (YouTube / Vimeo) render their own native control set
|
|
55
|
+
// inside an opaque iframe. media-chrome can request play/pause but its
|
|
56
|
+
// `[mediapaused]` state desyncs from the real player, and YouTube's ToS
|
|
57
|
+
// chrome (title bar, branding tray, share/clock icons) bleeds through and
|
|
58
|
+
// CANNOT be hidden — so layering OUR controls on top produces a duplicate,
|
|
59
|
+
// jittery UI (two play affordances, two bottom bars). For these sources we
|
|
60
|
+
// let the native player own playback entirely: one coherent control set,
|
|
61
|
+
// native hover behaviour, zero duplication. Direct media (native `<video>`,
|
|
62
|
+
// HLS) is fully driven by media-chrome, so we keep OUR themed controls.
|
|
63
|
+
const isProviderEmbed =
|
|
64
|
+
normalized.type === 'iframe' ||
|
|
65
|
+
normalized.type === 'youtube' ||
|
|
66
|
+
normalized.type === 'vimeo';
|
|
67
|
+
const showControls = controls && !isProviderEmbed;
|
|
58
68
|
|
|
59
69
|
// MediaController is a custom element; without `tabindex` it cannot take
|
|
60
70
|
// focus, so its built-in keyboard shortcuts (space/arrows/f) never fire.
|
|
@@ -63,19 +73,6 @@ export function VideoPlayer({
|
|
|
63
73
|
// fields we don't need to see.
|
|
64
74
|
const controllerRef = useRef<HTMLElement | null>(null);
|
|
65
75
|
|
|
66
|
-
// Click-to-play overlay for YouTube — the underlying YT iframe is made
|
|
67
|
-
// `pointer-events: none` via CSS to suppress YouTube's own hover-state /
|
|
68
|
-
// UI (title bar, pause-screen, branding tray; ToS forbids hiding them,
|
|
69
|
-
// but we can make them inert). This overlay restores click-to-toggle
|
|
70
|
-
// by dispatching media-chrome request events the controller listens for.
|
|
71
|
-
const onYouTubeShieldClick = useCallback(() => {
|
|
72
|
-
const el = controllerRef.current;
|
|
73
|
-
if (!el) return;
|
|
74
|
-
const paused = el.hasAttribute('mediapaused');
|
|
75
|
-
const eventName = paused ? 'mediaplayrequest' : 'mediapauserequest';
|
|
76
|
-
el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, composed: true }));
|
|
77
|
-
}, []);
|
|
78
|
-
|
|
79
76
|
useEffect(() => {
|
|
80
77
|
if (!autoFocus) return;
|
|
81
78
|
const el = controllerRef.current;
|
|
@@ -100,28 +97,25 @@ export function VideoPlayer({
|
|
|
100
97
|
style={aspectRatioStyle(aspectRatio)}
|
|
101
98
|
>
|
|
102
99
|
<CanvasDispatcher source={normalized} {...settings} />
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// YouTube's own UI.
|
|
109
|
-
<div
|
|
110
|
-
className="vp-yt-click-shield"
|
|
111
|
-
onClick={onYouTubeShieldClick}
|
|
112
|
-
aria-hidden="true"
|
|
113
|
-
/>
|
|
114
|
-
)}
|
|
100
|
+
{/* Click / tap anywhere on the video toggles play. media-chrome's
|
|
101
|
+
gesture receiver handles desktop click + mobile tap (and on touch
|
|
102
|
+
a tap also reveals/hides the controls). Only mounted for direct
|
|
103
|
+
media — provider embeds own their own click handling natively. */}
|
|
104
|
+
{showControls && <MediaGestureReceiver />}
|
|
115
105
|
{children ??
|
|
116
106
|
(showControls && (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
107
|
+
<>
|
|
108
|
+
{/* Big centered play over the poster / paused frame. */}
|
|
109
|
+
<CenterPlay />
|
|
110
|
+
<ControlsBar>
|
|
111
|
+
<PlayButton />
|
|
112
|
+
<SeekBar />
|
|
113
|
+
<Volume iconOnly />
|
|
114
|
+
<PlaybackRate />
|
|
115
|
+
<Pip />
|
|
116
|
+
<Fullscreen />
|
|
117
|
+
</ControlsBar>
|
|
118
|
+
</>
|
|
125
119
|
))}
|
|
126
120
|
</MediaController>
|
|
127
121
|
);
|
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
* YouTube IFrame API and exposes an `HTMLVideoElement`-shaped surface
|
|
7
7
|
* that media-chrome's `<MediaController>` drives.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (
|
|
14
|
-
*
|
|
9
|
+
* YouTube embeds render their OWN native control set inside the iframe,
|
|
10
|
+
* and YouTube's ToS chrome (title bar, branding tray, share/clock icons)
|
|
11
|
+
* cannot be hidden via player params. Trying to drive the embed with our
|
|
12
|
+
* own media-chrome controls layered on top produced a duplicate, jittery
|
|
13
|
+
* UI (two play buttons, two bottom bars) — see VideoPlayer.tsx. So for
|
|
14
|
+
* YouTube we let the native player own playback entirely: `controls=1`
|
|
15
|
+
* gives YouTube's standard hover-to-reveal controls with seek bar, time,
|
|
16
|
+
* volume and fullscreen — one coherent, familiar control set.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* We still pass `rel=0`, `modestbranding=1`, `iv_load_policy=3` to trim
|
|
19
|
+
* end-screen suggestions, annotations and branding to the maximum the
|
|
20
|
+
* ToS allows.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import 'youtube-video-element';
|
|
@@ -33,10 +35,8 @@ export interface YouTubeCanvasProps extends VideoPlayerSettings {
|
|
|
33
35
|
* https://developers.google.com/youtube/player_parameters
|
|
34
36
|
*/
|
|
35
37
|
const YT_PLAYER_VARS = {
|
|
36
|
-
//
|
|
37
|
-
controls:
|
|
38
|
-
// Our keyboard shortcuts own the player; disable YouTube's.
|
|
39
|
-
disablekb: 1,
|
|
38
|
+
// YouTube's native controls own playback (no duplicate media-chrome bar).
|
|
39
|
+
controls: 1,
|
|
40
40
|
// Minimise the YouTube logo chrome (max suppression allowed by ToS).
|
|
41
41
|
modestbranding: 1,
|
|
42
42
|
// Do not surface "More videos" / related-channel end-screen suggestions.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { MediaPlayButton } from 'media-chrome/react';
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
5
|
+
import type { ComponentProps } from 'react';
|
|
6
|
+
|
|
7
|
+
export type CenterPlayProps = ComponentProps<typeof MediaPlayButton>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Big centered play affordance shown over the poster / paused frame
|
|
11
|
+
* (YouTube / Telegram style). A translucent dark disc with a white glyph so
|
|
12
|
+
* it stays legible on ANY poster — light or dark. It lives in media-chrome's
|
|
13
|
+
* `centered-chrome` slot and is faded out while playing via CSS (see
|
|
14
|
+
* `video-player.css` → `.vp-center-play`).
|
|
15
|
+
*/
|
|
16
|
+
export function CenterPlay({ className, ...props }: CenterPlayProps) {
|
|
17
|
+
return (
|
|
18
|
+
<MediaPlayButton
|
|
19
|
+
{...props}
|
|
20
|
+
slot="centered-chrome"
|
|
21
|
+
tabIndex={-1}
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
className={cn('vp-center-play', className)}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { PlayButton } from './play-button';
|
|
2
2
|
export type { PlayButtonProps } from './play-button';
|
|
3
|
+
export { CenterPlay } from './center-play';
|
|
4
|
+
export type { CenterPlayProps } from './center-play';
|
|
3
5
|
export { SeekBar } from './seek-bar';
|
|
4
6
|
export type { SeekBarProps } from './seek-bar';
|
|
5
7
|
export { Volume } from './volume';
|
|
@@ -33,7 +33,11 @@ media-controller {
|
|
|
33
33
|
--media-range-track-background: color-mix(in oklab, white 28%, transparent);
|
|
34
34
|
--media-range-track-border-radius: 9999px;
|
|
35
35
|
--media-range-track-height: 4px;
|
|
36
|
-
|
|
36
|
+
/* Pointer-fill (the lit rail from start → cursor on hover). The default
|
|
37
|
+
`white 40%` paints a muddy light bar over a bright video frame; keep
|
|
38
|
+
it barely above the base track so hover reads as a subtle highlight,
|
|
39
|
+
not a slab. */
|
|
40
|
+
--media-range-track-pointer-background: color-mix(in oklab, white 12%, transparent);
|
|
37
41
|
--media-range-bar-color: var(--primary);
|
|
38
42
|
--media-range-thumb-background: var(--primary);
|
|
39
43
|
--media-range-thumb-border-radius: 9999px;
|
|
@@ -82,6 +86,57 @@ media-control-bar {
|
|
|
82
86
|
background: transparent;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/*
|
|
90
|
+
* Big centered play affordance (YouTube / Telegram style). A translucent
|
|
91
|
+
* dark disc + white glyph so it reads on ANY poster, light or dark. Shown
|
|
92
|
+
* only while paused; fades out smoothly once playback starts. The
|
|
93
|
+
* `centered-chrome` slot is centered by media-chrome; we size the disc and
|
|
94
|
+
* scale the icon up via the play-button's `::part(button)`.
|
|
95
|
+
*/
|
|
96
|
+
.vp-center-play {
|
|
97
|
+
--media-control-background: rgb(0 0 0 / 0.55);
|
|
98
|
+
--media-control-hover-background: rgb(0 0 0 / 0.7);
|
|
99
|
+
width: 64px;
|
|
100
|
+
height: 64px;
|
|
101
|
+
padding: 0;
|
|
102
|
+
border-radius: 9999px;
|
|
103
|
+
color: rgb(255 255 255);
|
|
104
|
+
backdrop-filter: blur(2px);
|
|
105
|
+
box-shadow: 0 4px 16px rgb(0 0 0 / 0.35);
|
|
106
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.vp-center-play svg {
|
|
111
|
+
width: 28px;
|
|
112
|
+
height: 28px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Hide the big play once the video is actually playing. */
|
|
116
|
+
media-controller:not([mediapaused]) .vp-center-play {
|
|
117
|
+
opacity: 0;
|
|
118
|
+
transform: scale(0.9);
|
|
119
|
+
pointer-events: none;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* The gesture receiver catches click/tap anywhere on the video to toggle
|
|
123
|
+
play. It covers the whole frame (`inset: 0`) and must sit BELOW the
|
|
124
|
+
control bar / center play — otherwise it overlays those buttons and
|
|
125
|
+
swallows their clicks (the bug: clicking center-play did nothing while
|
|
126
|
+
the spacebar still worked). z-index:0 keeps it above the canvas but
|
|
127
|
+
under the controls, which we lift to z-index:2 below. */
|
|
128
|
+
media-gesture-receiver {
|
|
129
|
+
position: absolute;
|
|
130
|
+
inset: 0;
|
|
131
|
+
z-index: 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Controls sit ABOVE the gesture receiver so their clicks land on them. */
|
|
135
|
+
.vp-center-play,
|
|
136
|
+
.video-player__control-bar {
|
|
137
|
+
z-index: 2;
|
|
138
|
+
}
|
|
139
|
+
|
|
85
140
|
/*
|
|
86
141
|
* Suppress media-chrome's built-in tooltips. They are sticky elements
|
|
87
142
|
* slotted under each control button's shadow root (exposed via
|
|
@@ -106,16 +161,32 @@ media-seek-backward-button::part(tooltip) {
|
|
|
106
161
|
}
|
|
107
162
|
|
|
108
163
|
/*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
164
|
+
* Control-bar visibility — YouTube-style, exactly one play affordance.
|
|
165
|
+
*
|
|
166
|
+
* - PAUSED (incl. before first play): the big center-play owns the frame;
|
|
167
|
+
* the bottom bar is hidden so there are never TWO play buttons at once.
|
|
168
|
+
* - PLAYING: center-play is gone (faded by the rule above); the bottom bar
|
|
169
|
+
* reveals on hover / interaction and auto-hides after media-chrome flags
|
|
170
|
+
* inactivity (`userinactive`, driven by `autohide` on <media-controller>),
|
|
171
|
+
* so the frame stays clean while watching. It reappears on mousemove /
|
|
172
|
+
* focus.
|
|
173
|
+
*
|
|
174
|
+
* media-chrome sets `userinactive` only while playback runs and removes it
|
|
175
|
+
* on mousemove / pause / focus. We fade the whole bar (controls + gradient
|
|
176
|
+
* scrim) together.
|
|
113
177
|
*/
|
|
114
178
|
.video-player__control-bar {
|
|
115
179
|
opacity: 1;
|
|
116
180
|
transition: opacity 0.3s ease-out;
|
|
117
181
|
}
|
|
118
182
|
|
|
183
|
+
/* Hide the bar while paused — center-play is the sole control then. */
|
|
184
|
+
media-controller[mediapaused] .video-player__control-bar {
|
|
185
|
+
opacity: 0;
|
|
186
|
+
pointer-events: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Auto-hide the bar after inactivity while playing. */
|
|
119
190
|
media-controller[userinactive]:not([mediapaused]) .video-player__control-bar {
|
|
120
191
|
opacity: 0;
|
|
121
192
|
pointer-events: none;
|
|
@@ -129,8 +200,10 @@ media-controller[userinactive]:not([mediapaused]) .video-player__control-bar {
|
|
|
129
200
|
* dark `rgb(31,31,31)` background — it shows as a stray empty
|
|
130
201
|
* rectangle floating over the video.
|
|
131
202
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
203
|
+
* Both the empty `preview-box` and the `current-box` scrub chip are hidden
|
|
204
|
+
* (see the `::part` rules below) — without a thumbnails track neither
|
|
205
|
+
* carries useful content, and the elapsed time already shows in
|
|
206
|
+
* `<MediaTimeDisplay>`.
|
|
134
207
|
*/
|
|
135
208
|
media-time-range {
|
|
136
209
|
/* media-chrome's own preview/box vars — point them at our popover
|
|
@@ -168,54 +241,30 @@ media-time-range::part(preview-box) {
|
|
|
168
241
|
}
|
|
169
242
|
|
|
170
243
|
/*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* hover/pause. We can't remove them, but we CAN make them non-interactive
|
|
177
|
-
* — so the user never triggers YouTube's hover-state, never gets a
|
|
178
|
-
* "More videos" overlay, and clicks never reach the YT iframe.
|
|
179
|
-
*
|
|
180
|
-
* The <youtube-video> custom element renders an inner <iframe>; disable
|
|
181
|
-
* pointer events on it (and on the wrapper itself, just in case the
|
|
182
|
-
* element later swaps layout). The companion `.vp-yt-click-shield` div
|
|
183
|
-
* inside <media-controller> absorbs clicks and converts them into
|
|
184
|
-
* media-chrome play/pause requests — so click-to-toggle still works.
|
|
185
|
-
*
|
|
186
|
-
* Our control bar lives above the shield and re-enables pointer events
|
|
187
|
-
* for the slot, so the controls stay clickable.
|
|
244
|
+
* `current-box` (the scrub-time chip) renders as a small white box ABOVE
|
|
245
|
+
* the rail at the pointer position. Without a thumbnails track it carries
|
|
246
|
+
* no useful preview, and the elapsed time already lives in
|
|
247
|
+
* `<MediaTimeDisplay>` — so it only ever shows as a stray white dash over
|
|
248
|
+
* the video. Hide it entirely, same as `preview-box`.
|
|
188
249
|
*/
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
pointer-events: none;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
.vp-yt-click-shield {
|
|
195
|
-
position: absolute;
|
|
196
|
-
inset: 0;
|
|
197
|
-
cursor: pointer;
|
|
198
|
-
background: transparent;
|
|
199
|
-
pointer-events: auto;
|
|
200
|
-
/* Sit above the iframe but below the slotted control bar. */
|
|
201
|
-
z-index: 1;
|
|
250
|
+
media-time-range::part(current-box) {
|
|
251
|
+
display: none;
|
|
202
252
|
}
|
|
203
253
|
|
|
204
|
-
/*
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
media-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
254
|
+
/*
|
|
255
|
+
* Provider embeds (YouTube / Vimeo) own their native controls.
|
|
256
|
+
*
|
|
257
|
+
* media-chrome cannot reliably drive an opaque provider iframe, and
|
|
258
|
+
* YouTube's ToS chrome (title bar, branding tray, share/clock icons)
|
|
259
|
+
* bleeds through and can't be hidden — layering OUR controls on top
|
|
260
|
+
* produced a duplicate, jittery UI. So we render NO media-chrome control
|
|
261
|
+
* surface for these sources (see `showControls` in VideoPlayer.tsx) and
|
|
262
|
+
* let the native player handle playback, hover-reveal and fullscreen.
|
|
263
|
+
*
|
|
264
|
+
* The provider iframe must stay fully interactive so its native controls
|
|
265
|
+
* work — so we explicitly DON'T disable pointer events here.
|
|
266
|
+
*/
|
|
267
|
+
youtube-video iframe,
|
|
268
|
+
vimeo-video iframe {
|
|
211
269
|
pointer-events: auto;
|
|
212
270
|
}
|
|
213
|
-
|
|
214
|
-
media-time-range::part(current-box) {
|
|
215
|
-
padding: 2px 6px;
|
|
216
|
-
border-radius: 6px;
|
|
217
|
-
background: var(--popover) !important;
|
|
218
|
-
color: var(--popover-foreground);
|
|
219
|
-
font-size: 11px;
|
|
220
|
-
box-shadow: 0 4px 12px color-mix(in oklab, var(--foreground) 18%, transparent);
|
|
221
|
-
}
|