@djangocfg/widget-media 0.1.1
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/LICENSE +21 -0
- package/README.md +58 -0
- package/package.json +81 -0
- package/src/code/code.tsx +116 -0
- package/src/code/index.ts +10 -0
- package/src/diagram/diagram-source.tsx +29 -0
- package/src/diagram/diagram.tsx +64 -0
- package/src/diagram/index.ts +22 -0
- package/src/diagram/lazy.tsx +45 -0
- package/src/diagram/plugin.ts +62 -0
- package/src/diagram/render.ts +328 -0
- package/src/i18n/index.ts +60 -0
- package/src/image-viewer/components/ImageInfo.tsx +55 -0
- package/src/image-viewer/components/ImageToolbar.tsx +167 -0
- package/src/image-viewer/components/ImageViewer.tsx +508 -0
- package/src/image-viewer/components/LightboxChrome.tsx +157 -0
- package/src/image-viewer/components/MiddleEllipsis.tsx +46 -0
- package/src/image-viewer/components/index.ts +9 -0
- package/src/image-viewer/hooks/index.ts +12 -0
- package/src/image-viewer/hooks/useIdleChrome.ts +73 -0
- package/src/image-viewer/hooks/useImageLoading.ts +220 -0
- package/src/image-viewer/hooks/useImageTransform.ts +102 -0
- package/src/image-viewer/index.ts +65 -0
- package/src/image-viewer/lazy.tsx +33 -0
- package/src/image-viewer/types.ts +139 -0
- package/src/image-viewer/utils/constants.ts +90 -0
- package/src/image-viewer/utils/debug.ts +14 -0
- package/src/image-viewer/utils/index.ts +22 -0
- package/src/image-viewer/utils/lqip.ts +47 -0
- package/src/index.ts +10 -0
- package/src/internal/media-cache.ts +537 -0
- package/src/markdown/index.ts +6 -0
- package/src/markdown/markdown.tsx +113 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { create } from 'zustand';
|
|
5
|
+
import { devtools, persist } from 'zustand/middleware';
|
|
6
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
7
|
+
|
|
8
|
+
import { createLogger } from '@djangocfg/ui-core/lib';
|
|
9
|
+
|
|
10
|
+
const cacheDebug = createLogger('MediaCache');
|
|
11
|
+
|
|
12
|
+
// Types
|
|
13
|
+
interface BlobUrlEntry {
|
|
14
|
+
url: string;
|
|
15
|
+
refCount: number;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ImageDimensions {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface VideoMetadata {
|
|
25
|
+
duration: number;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
codec?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface EffectConfig {
|
|
32
|
+
opacity: number;
|
|
33
|
+
scale: number;
|
|
34
|
+
blur: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Video player settings (persisted) */
|
|
38
|
+
interface VideoPlayerSettings {
|
|
39
|
+
volume: number;
|
|
40
|
+
isLooping: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Stream URL TTL (30 seconds - shorter to avoid stale session/token issues)
|
|
44
|
+
const STREAM_URL_TTL = 30 * 1000;
|
|
45
|
+
|
|
46
|
+
interface MediaCacheState {
|
|
47
|
+
// Blob URL cache (shared by Image, Audio, Video)
|
|
48
|
+
blobUrls: Map<string, BlobUrlEntry>;
|
|
49
|
+
|
|
50
|
+
// Image-specific
|
|
51
|
+
imageDimensions: Map<string, ImageDimensions>;
|
|
52
|
+
|
|
53
|
+
// Audio-specific
|
|
54
|
+
audioPlaybackPositions: Map<string, number>;
|
|
55
|
+
audioEffectConfigs: Map<string, EffectConfig>;
|
|
56
|
+
|
|
57
|
+
// Video-specific
|
|
58
|
+
videoStreamUrls: Map<string, { url: string; timestamp: number }>;
|
|
59
|
+
videoPosterUrls: Map<string, string>;
|
|
60
|
+
videoPlaybackPositions: Map<string, number>;
|
|
61
|
+
videoMetadata: Map<string, VideoMetadata>;
|
|
62
|
+
videoPlayerSettings: VideoPlayerSettings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface MediaCacheActions {
|
|
66
|
+
// Blob URL management (shared)
|
|
67
|
+
getOrCreateBlobUrl: (
|
|
68
|
+
key: string,
|
|
69
|
+
content: ArrayBuffer,
|
|
70
|
+
mimeType: string
|
|
71
|
+
) => string;
|
|
72
|
+
releaseBlobUrl: (key: string) => void;
|
|
73
|
+
hasBlobUrl: (key: string) => boolean;
|
|
74
|
+
|
|
75
|
+
// Image actions
|
|
76
|
+
cacheDimensions: (src: string, dims: ImageDimensions) => void;
|
|
77
|
+
getDimensions: (src: string) => ImageDimensions | null;
|
|
78
|
+
|
|
79
|
+
// Audio actions
|
|
80
|
+
saveAudioPosition: (uri: string, time: number) => void;
|
|
81
|
+
getAudioPosition: (uri: string) => number | null;
|
|
82
|
+
getEffectConfig: (key: string) => EffectConfig | null;
|
|
83
|
+
cacheEffectConfig: (key: string, config: EffectConfig) => void;
|
|
84
|
+
|
|
85
|
+
// Video actions
|
|
86
|
+
getOrCreateStreamUrl: (
|
|
87
|
+
sessionId: string,
|
|
88
|
+
path: string,
|
|
89
|
+
generator: (sessionId: string, path: string) => string
|
|
90
|
+
) => string;
|
|
91
|
+
getPosterUrl: (title: string) => string | null;
|
|
92
|
+
cachePosterUrl: (title: string, url: string) => void;
|
|
93
|
+
saveVideoPosition: (key: string, time: number) => void;
|
|
94
|
+
getVideoPosition: (key: string) => number | null;
|
|
95
|
+
cacheVideoMetadata: (url: string, meta: VideoMetadata) => void;
|
|
96
|
+
getVideoMetadata: (url: string) => VideoMetadata | null;
|
|
97
|
+
invalidateSession: (sessionId: string) => void;
|
|
98
|
+
getVideoPlayerSettings: () => VideoPlayerSettings;
|
|
99
|
+
saveVideoPlayerSettings: (settings: Partial<VideoPlayerSettings>) => void;
|
|
100
|
+
|
|
101
|
+
// Global
|
|
102
|
+
clearCache: () => void;
|
|
103
|
+
getCacheStats: () => {
|
|
104
|
+
blobUrls: number;
|
|
105
|
+
dimensions: number;
|
|
106
|
+
audioPositions: number;
|
|
107
|
+
videoPositions: number;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type MediaCacheStore = MediaCacheState & MediaCacheActions;
|
|
112
|
+
|
|
113
|
+
// Initial state
|
|
114
|
+
const DEFAULT_VIDEO_SETTINGS: VideoPlayerSettings = {
|
|
115
|
+
volume: 1,
|
|
116
|
+
isLooping: false,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const initialState: MediaCacheState = {
|
|
120
|
+
blobUrls: new Map(),
|
|
121
|
+
imageDimensions: new Map(),
|
|
122
|
+
audioPlaybackPositions: new Map(),
|
|
123
|
+
audioEffectConfigs: new Map(),
|
|
124
|
+
videoStreamUrls: new Map(),
|
|
125
|
+
videoPosterUrls: new Map(),
|
|
126
|
+
videoPlaybackPositions: new Map(),
|
|
127
|
+
videoMetadata: new Map(),
|
|
128
|
+
videoPlayerSettings: DEFAULT_VIDEO_SETTINGS,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const useMediaCacheStore = create<MediaCacheStore>()(
|
|
132
|
+
devtools(
|
|
133
|
+
persist(
|
|
134
|
+
(set, get) => ({
|
|
135
|
+
...initialState,
|
|
136
|
+
|
|
137
|
+
// ========== Blob URL Management ==========
|
|
138
|
+
|
|
139
|
+
getOrCreateBlobUrl: (key, content, mimeType) => {
|
|
140
|
+
const existing = get().blobUrls.get(key);
|
|
141
|
+
if (existing) {
|
|
142
|
+
// Increment ref count
|
|
143
|
+
cacheDebug.debug(`Blob URL reused: ${key}`, { refCount: existing.refCount + 1 });
|
|
144
|
+
set(
|
|
145
|
+
(state) => ({
|
|
146
|
+
blobUrls: new Map(state.blobUrls).set(key, {
|
|
147
|
+
...existing,
|
|
148
|
+
refCount: existing.refCount + 1,
|
|
149
|
+
}),
|
|
150
|
+
}),
|
|
151
|
+
false,
|
|
152
|
+
'blobUrl/incrementRef'
|
|
153
|
+
);
|
|
154
|
+
return existing.url;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Create new blob URL
|
|
158
|
+
const blob = new Blob([content], { type: mimeType });
|
|
159
|
+
const url = URL.createObjectURL(blob);
|
|
160
|
+
const sizeMB = (content.byteLength / 1024 / 1024).toFixed(2);
|
|
161
|
+
cacheDebug.debug(`Blob URL created: ${key}`, { mimeType, size: `${sizeMB}MB` });
|
|
162
|
+
set(
|
|
163
|
+
(state) => ({
|
|
164
|
+
blobUrls: new Map(state.blobUrls).set(key, {
|
|
165
|
+
url,
|
|
166
|
+
refCount: 1,
|
|
167
|
+
createdAt: Date.now(),
|
|
168
|
+
}),
|
|
169
|
+
}),
|
|
170
|
+
false,
|
|
171
|
+
'blobUrl/create'
|
|
172
|
+
);
|
|
173
|
+
return url;
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
releaseBlobUrl: (key) => {
|
|
177
|
+
const entry = get().blobUrls.get(key);
|
|
178
|
+
if (!entry) return;
|
|
179
|
+
|
|
180
|
+
if (entry.refCount <= 1) {
|
|
181
|
+
// Last reference - revoke and remove
|
|
182
|
+
cacheDebug.debug(`Blob URL revoked: ${key}`);
|
|
183
|
+
URL.revokeObjectURL(entry.url);
|
|
184
|
+
set(
|
|
185
|
+
(state) => {
|
|
186
|
+
const newMap = new Map(state.blobUrls);
|
|
187
|
+
newMap.delete(key);
|
|
188
|
+
return { blobUrls: newMap };
|
|
189
|
+
},
|
|
190
|
+
false,
|
|
191
|
+
'blobUrl/revoke'
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
// Decrement ref count
|
|
195
|
+
cacheDebug.debug(`Blob URL released: ${key}`, { refCount: entry.refCount - 1 });
|
|
196
|
+
set(
|
|
197
|
+
(state) => ({
|
|
198
|
+
blobUrls: new Map(state.blobUrls).set(key, {
|
|
199
|
+
...entry,
|
|
200
|
+
refCount: entry.refCount - 1,
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
false,
|
|
204
|
+
'blobUrl/decrementRef'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
hasBlobUrl: (key) => get().blobUrls.has(key),
|
|
210
|
+
|
|
211
|
+
// ========== Image Cache ==========
|
|
212
|
+
|
|
213
|
+
cacheDimensions: (src, dims) => {
|
|
214
|
+
set(
|
|
215
|
+
(state) => ({
|
|
216
|
+
imageDimensions: new Map(state.imageDimensions).set(src, dims),
|
|
217
|
+
}),
|
|
218
|
+
false,
|
|
219
|
+
'image/cacheDimensions'
|
|
220
|
+
);
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
getDimensions: (src) => get().imageDimensions.get(src) ?? null,
|
|
224
|
+
|
|
225
|
+
// ========== Audio Cache ==========
|
|
226
|
+
|
|
227
|
+
saveAudioPosition: (uri, time) => {
|
|
228
|
+
set(
|
|
229
|
+
(state) => ({
|
|
230
|
+
audioPlaybackPositions: new Map(state.audioPlaybackPositions).set(
|
|
231
|
+
uri,
|
|
232
|
+
time
|
|
233
|
+
),
|
|
234
|
+
}),
|
|
235
|
+
false,
|
|
236
|
+
'audio/savePosition'
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
getAudioPosition: (uri) =>
|
|
241
|
+
get().audioPlaybackPositions.get(uri) ?? null,
|
|
242
|
+
|
|
243
|
+
getEffectConfig: (key) => get().audioEffectConfigs.get(key) ?? null,
|
|
244
|
+
|
|
245
|
+
cacheEffectConfig: (key, config) => {
|
|
246
|
+
set(
|
|
247
|
+
(state) => ({
|
|
248
|
+
audioEffectConfigs: new Map(state.audioEffectConfigs).set(
|
|
249
|
+
key,
|
|
250
|
+
config
|
|
251
|
+
),
|
|
252
|
+
}),
|
|
253
|
+
false,
|
|
254
|
+
'audio/cacheEffectConfig'
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
// ========== Video Cache ==========
|
|
259
|
+
|
|
260
|
+
getOrCreateStreamUrl: (sessionId, path, generator) => {
|
|
261
|
+
const key = `${sessionId}:${path}`;
|
|
262
|
+
const cached = get().videoStreamUrls.get(key);
|
|
263
|
+
|
|
264
|
+
// Return if fresh
|
|
265
|
+
if (cached && Date.now() - cached.timestamp < STREAM_URL_TTL) {
|
|
266
|
+
return cached.url;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Generate and cache
|
|
270
|
+
const url = generator(sessionId, path);
|
|
271
|
+
set(
|
|
272
|
+
(state) => ({
|
|
273
|
+
videoStreamUrls: new Map(state.videoStreamUrls).set(key, {
|
|
274
|
+
url,
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
}),
|
|
277
|
+
}),
|
|
278
|
+
false,
|
|
279
|
+
'video/cacheStreamUrl'
|
|
280
|
+
);
|
|
281
|
+
return url;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
getPosterUrl: (title) => get().videoPosterUrls.get(title) ?? null,
|
|
285
|
+
|
|
286
|
+
cachePosterUrl: (title, url) => {
|
|
287
|
+
set(
|
|
288
|
+
(state) => ({
|
|
289
|
+
videoPosterUrls: new Map(state.videoPosterUrls).set(title, url),
|
|
290
|
+
}),
|
|
291
|
+
false,
|
|
292
|
+
'video/cachePosterUrl'
|
|
293
|
+
);
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
saveVideoPosition: (key, time) => {
|
|
297
|
+
set(
|
|
298
|
+
(state) => ({
|
|
299
|
+
videoPlaybackPositions: new Map(state.videoPlaybackPositions).set(
|
|
300
|
+
key,
|
|
301
|
+
time
|
|
302
|
+
),
|
|
303
|
+
}),
|
|
304
|
+
false,
|
|
305
|
+
'video/savePosition'
|
|
306
|
+
);
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
getVideoPosition: (key) =>
|
|
310
|
+
get().videoPlaybackPositions.get(key) ?? null,
|
|
311
|
+
|
|
312
|
+
cacheVideoMetadata: (url, meta) => {
|
|
313
|
+
set(
|
|
314
|
+
(state) => ({
|
|
315
|
+
videoMetadata: new Map(state.videoMetadata).set(url, meta),
|
|
316
|
+
}),
|
|
317
|
+
false,
|
|
318
|
+
'video/cacheMetadata'
|
|
319
|
+
);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
getVideoMetadata: (url) => get().videoMetadata.get(url) ?? null,
|
|
323
|
+
|
|
324
|
+
invalidateSession: (sessionId) => {
|
|
325
|
+
set(
|
|
326
|
+
(state) => {
|
|
327
|
+
const newUrls = new Map(state.videoStreamUrls);
|
|
328
|
+
for (const [key] of newUrls) {
|
|
329
|
+
if (key.startsWith(`${sessionId}:`)) {
|
|
330
|
+
newUrls.delete(key);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { videoStreamUrls: newUrls };
|
|
334
|
+
},
|
|
335
|
+
false,
|
|
336
|
+
'video/invalidateSession'
|
|
337
|
+
);
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
getVideoPlayerSettings: () => get().videoPlayerSettings,
|
|
341
|
+
|
|
342
|
+
saveVideoPlayerSettings: (settings) => {
|
|
343
|
+
set(
|
|
344
|
+
(state) => ({
|
|
345
|
+
videoPlayerSettings: { ...state.videoPlayerSettings, ...settings },
|
|
346
|
+
}),
|
|
347
|
+
false,
|
|
348
|
+
'video/savePlayerSettings'
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
// ========== Global ==========
|
|
353
|
+
|
|
354
|
+
clearCache: () => {
|
|
355
|
+
const stats = get().getCacheStats();
|
|
356
|
+
cacheDebug.info('Clearing cache', stats);
|
|
357
|
+
// Revoke all blob URLs before clearing
|
|
358
|
+
get().blobUrls.forEach(({ url }) => URL.revokeObjectURL(url));
|
|
359
|
+
set(initialState, false, 'clearCache');
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
getCacheStats: () => ({
|
|
363
|
+
blobUrls: get().blobUrls.size,
|
|
364
|
+
dimensions: get().imageDimensions.size,
|
|
365
|
+
audioPositions: get().audioPlaybackPositions.size,
|
|
366
|
+
videoPositions: get().videoPlaybackPositions.size,
|
|
367
|
+
}),
|
|
368
|
+
}),
|
|
369
|
+
{
|
|
370
|
+
name: 'media-cache-storage',
|
|
371
|
+
// Only persist playback positions, poster URLs, and player settings
|
|
372
|
+
partialize: (state) => ({
|
|
373
|
+
audioPlaybackPositions: Array.from(
|
|
374
|
+
state.audioPlaybackPositions.entries()
|
|
375
|
+
),
|
|
376
|
+
videoPlaybackPositions: Array.from(
|
|
377
|
+
state.videoPlaybackPositions.entries()
|
|
378
|
+
),
|
|
379
|
+
videoPosterUrls: Array.from(state.videoPosterUrls.entries()),
|
|
380
|
+
videoPlayerSettings: state.videoPlayerSettings,
|
|
381
|
+
}),
|
|
382
|
+
// Rehydrate Maps from arrays
|
|
383
|
+
onRehydrateStorage: () => (state) => {
|
|
384
|
+
if (state) {
|
|
385
|
+
// Type assertion for persisted data
|
|
386
|
+
const persistedAudio = state.audioPlaybackPositions as unknown;
|
|
387
|
+
const persistedVideo = state.videoPlaybackPositions as unknown;
|
|
388
|
+
const persistedPosters = state.videoPosterUrls as unknown;
|
|
389
|
+
const persistedSettings = state.videoPlayerSettings as unknown;
|
|
390
|
+
|
|
391
|
+
state.audioPlaybackPositions = new Map(
|
|
392
|
+
Array.isArray(persistedAudio)
|
|
393
|
+
? (persistedAudio as [string, number][])
|
|
394
|
+
: []
|
|
395
|
+
);
|
|
396
|
+
state.videoPlaybackPositions = new Map(
|
|
397
|
+
Array.isArray(persistedVideo)
|
|
398
|
+
? (persistedVideo as [string, number][])
|
|
399
|
+
: []
|
|
400
|
+
);
|
|
401
|
+
state.videoPosterUrls = new Map(
|
|
402
|
+
Array.isArray(persistedPosters)
|
|
403
|
+
? (persistedPosters as [string, string][])
|
|
404
|
+
: []
|
|
405
|
+
);
|
|
406
|
+
// Merge persisted settings with defaults (handles missing fields)
|
|
407
|
+
state.videoPlayerSettings = {
|
|
408
|
+
...DEFAULT_VIDEO_SETTINGS,
|
|
409
|
+
...(persistedSettings && typeof persistedSettings === 'object'
|
|
410
|
+
? (persistedSettings as Partial<VideoPlayerSettings>)
|
|
411
|
+
: {}),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
),
|
|
417
|
+
{ name: 'MediaCacheStore' }
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// ========== Selective Hooks ==========
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Hook for image-related cache operations
|
|
425
|
+
* Uses useShallow to prevent infinite re-renders
|
|
426
|
+
*/
|
|
427
|
+
export const useImageCache = () =>
|
|
428
|
+
useMediaCacheStore(
|
|
429
|
+
useShallow((state) => ({
|
|
430
|
+
getOrCreateBlobUrl: state.getOrCreateBlobUrl,
|
|
431
|
+
releaseBlobUrl: state.releaseBlobUrl,
|
|
432
|
+
hasBlobUrl: state.hasBlobUrl,
|
|
433
|
+
cacheDimensions: state.cacheDimensions,
|
|
434
|
+
getDimensions: state.getDimensions,
|
|
435
|
+
}))
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Hook for audio-related cache operations
|
|
440
|
+
* Uses useShallow to prevent infinite re-renders
|
|
441
|
+
*/
|
|
442
|
+
export const useAudioCache = () =>
|
|
443
|
+
useMediaCacheStore(
|
|
444
|
+
useShallow((state) => ({
|
|
445
|
+
getOrCreateBlobUrl: state.getOrCreateBlobUrl,
|
|
446
|
+
releaseBlobUrl: state.releaseBlobUrl,
|
|
447
|
+
hasBlobUrl: state.hasBlobUrl,
|
|
448
|
+
saveAudioPosition: state.saveAudioPosition,
|
|
449
|
+
getAudioPosition: state.getAudioPosition,
|
|
450
|
+
getEffectConfig: state.getEffectConfig,
|
|
451
|
+
cacheEffectConfig: state.cacheEffectConfig,
|
|
452
|
+
}))
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Hook for video-related cache operations
|
|
457
|
+
* Uses useShallow to prevent infinite re-renders
|
|
458
|
+
*/
|
|
459
|
+
export const useVideoCache = () =>
|
|
460
|
+
useMediaCacheStore(
|
|
461
|
+
useShallow((state) => ({
|
|
462
|
+
getOrCreateBlobUrl: state.getOrCreateBlobUrl,
|
|
463
|
+
releaseBlobUrl: state.releaseBlobUrl,
|
|
464
|
+
hasBlobUrl: state.hasBlobUrl,
|
|
465
|
+
getOrCreateStreamUrl: state.getOrCreateStreamUrl,
|
|
466
|
+
getPosterUrl: state.getPosterUrl,
|
|
467
|
+
cachePosterUrl: state.cachePosterUrl,
|
|
468
|
+
saveVideoPosition: state.saveVideoPosition,
|
|
469
|
+
getVideoPosition: state.getVideoPosition,
|
|
470
|
+
cacheVideoMetadata: state.cacheVideoMetadata,
|
|
471
|
+
getVideoMetadata: state.getVideoMetadata,
|
|
472
|
+
invalidateSession: state.invalidateSession,
|
|
473
|
+
getVideoPlayerSettings: state.getVideoPlayerSettings,
|
|
474
|
+
saveVideoPlayerSettings: state.saveVideoPlayerSettings,
|
|
475
|
+
}))
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Hook for video player settings only
|
|
480
|
+
* Returns current settings and save function
|
|
481
|
+
*/
|
|
482
|
+
export const useVideoPlayerSettings = () =>
|
|
483
|
+
useMediaCacheStore(
|
|
484
|
+
useShallow((state) => ({
|
|
485
|
+
settings: state.videoPlayerSettings,
|
|
486
|
+
saveSettings: state.saveVideoPlayerSettings,
|
|
487
|
+
}))
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Hook for blob URL cleanup on unmount
|
|
492
|
+
*/
|
|
493
|
+
export function useBlobUrlCleanup(key: string | null) {
|
|
494
|
+
const releaseBlobUrl = useMediaCacheStore((s) => s.releaseBlobUrl);
|
|
495
|
+
|
|
496
|
+
// useEffect must run unconditionally — the previous `if (typeof window ...)`
|
|
497
|
+
// guard plus a render-time `require('react')` meant the hook ran on the
|
|
498
|
+
// client but not on the server, so the hook count differed between the SSR
|
|
499
|
+
// and hydration renders (rules-of-hooks / #310). The effect is a no-op on the
|
|
500
|
+
// server anyway (effects never run there), and the `key` null-check below
|
|
501
|
+
// already handles the "nothing to release" case.
|
|
502
|
+
useEffect(() => {
|
|
503
|
+
return () => {
|
|
504
|
+
if (key) {
|
|
505
|
+
releaseBlobUrl(key);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}, [key, releaseBlobUrl]);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ========== Utilities ==========
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Generate a cache key from ArrayBuffer content
|
|
515
|
+
* Uses first and last 1KB + length for fast hashing
|
|
516
|
+
*/
|
|
517
|
+
export function generateContentKey(content: ArrayBuffer): string {
|
|
518
|
+
const arr = new Uint8Array(content);
|
|
519
|
+
const len = arr.length;
|
|
520
|
+
|
|
521
|
+
// Take first 1KB
|
|
522
|
+
const start = arr.slice(0, Math.min(1024, len));
|
|
523
|
+
// Take last 1KB
|
|
524
|
+
const end = arr.slice(Math.max(0, len - 1024));
|
|
525
|
+
|
|
526
|
+
// Simple hash from bytes. `for..of` over a Uint8Array yields `number`;
|
|
527
|
+
// indexing it yields `number | undefined` under noUncheckedIndexedAccess.
|
|
528
|
+
let hash = len;
|
|
529
|
+
for (const byte of start) {
|
|
530
|
+
hash = ((hash << 5) - hash + byte) | 0;
|
|
531
|
+
}
|
|
532
|
+
for (const byte of end) {
|
|
533
|
+
hash = ((hash << 5) - hash + byte) | 0;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return `blob-${len}-${hash.toString(16)}`;
|
|
537
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Markdown for a reader, outside any conversation: a news post, a file preview.
|
|
2
|
+
//
|
|
3
|
+
// Sibling to `chat`'s `Response`, which renders the user's own agent and
|
|
4
|
+
// relaxes link safety accordingly. This one does not — see `markdown.tsx`.
|
|
5
|
+
|
|
6
|
+
export { Markdown, type MarkdownProps, type MarkdownSize } from './markdown';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { code } from '@streamdown/code';
|
|
4
|
+
import { harden } from 'rehype-harden';
|
|
5
|
+
import { memo } from 'react';
|
|
6
|
+
import { Streamdown } from 'streamdown';
|
|
7
|
+
|
|
8
|
+
import { DiagramSource, diagramPlugin } from '../diagram';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Markdown a READER is shown, outside any conversation.
|
|
12
|
+
*
|
|
13
|
+
* Sibling to `chat`'s `Response`, deliberately not the same component. Both
|
|
14
|
+
* wrap `streamdown`; they differ on the question that decides a markdown
|
|
15
|
+
* renderer's configuration — WHO WROTE THIS.
|
|
16
|
+
*
|
|
17
|
+
* `Response` renders the user's own agent, on the user's own machine. What
|
|
18
|
+
* reaches this component was written somewhere else: a news post from the
|
|
19
|
+
* platform, a file the reader opened. The difference shows in the PROTOCOL
|
|
20
|
+
* list below — this one grants no app scheme — and it is why there are two
|
|
21
|
+
* components rather than one with a `trusted` prop, a boolean that silently
|
|
22
|
+
* relaxes link handling being the prop most likely to be passed wrong.
|
|
23
|
+
*
|
|
24
|
+
* It does NOT show in `linkSafety`, which both switch off. That default turns
|
|
25
|
+
* every link into a `<button>` with no `href` and an "are you sure" modal, and
|
|
26
|
+
* measuring it here is what settled the question: the protocols it would guard
|
|
27
|
+
* are already refused by `harden` one layer up, so on this surface it adds a
|
|
28
|
+
* second click to every link in a news post and no protection at all.
|
|
29
|
+
*
|
|
30
|
+
* No streaming, either: this text arrives whole. `Response` carries the
|
|
31
|
+
* incomplete-markdown machinery because a token-by-token fence must not throw;
|
|
32
|
+
* a document has no such state to be in.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Link hardening, restated because `rehype-harden` defaults `allowedProtocols`
|
|
37
|
+
* to EMPTY — an unrecognised scheme loses its `href` and the anchor renders as
|
|
38
|
+
* grey `<label> [blocked]` text mid-sentence.
|
|
39
|
+
*
|
|
40
|
+
* The list is SHORTER than the chat's on purpose: an app scheme is absent,
|
|
41
|
+
* because it is meaningful only where a host assigns it meaning, and nothing
|
|
42
|
+
* rendering a news post or a file preview does.
|
|
43
|
+
*/
|
|
44
|
+
const HARDEN_OPTIONS = {
|
|
45
|
+
allowedLinkPrefixes: ['*'],
|
|
46
|
+
allowedImagePrefixes: ['*'],
|
|
47
|
+
allowedProtocols: ['http:', 'https:', 'mailto:', 'tel:'],
|
|
48
|
+
allowDataImages: true,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
const REHYPE_PLUGINS = [[harden, HARDEN_OPTIONS]] as const;
|
|
52
|
+
|
|
53
|
+
/** See the note above: the modal guards protocols `harden` has already refused. */
|
|
54
|
+
const LINK_SAFETY = { enabled: false } as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The same renderers the transcript uses.
|
|
58
|
+
*
|
|
59
|
+
* A document is where a diagram most often LIVES — an agent asked to draw one
|
|
60
|
+
* writes a `.md` file, and the preview is the only place that file is read. So
|
|
61
|
+
* the surface that renders it needs the plugin as much as the chat does;
|
|
62
|
+
* shipping the diagram on one of the two would leave a fenced diagram in a file
|
|
63
|
+
* rendering as a code block, which is precisely the defect this package exists
|
|
64
|
+
* to fix, reproduced one surface over.
|
|
65
|
+
*/
|
|
66
|
+
const PLUGINS = { code, mermaid: diagramPlugin };
|
|
67
|
+
|
|
68
|
+
/** A rejected diagram shows its source, never streamdown's red error box. */
|
|
69
|
+
const MERMAID_OPTIONS = { errorComponent: DiagramSource } as const;
|
|
70
|
+
|
|
71
|
+
/** Body scale. Headings track it a notch above, through the stylesheet. */
|
|
72
|
+
export type MarkdownSize = 'xs' | 'sm' | 'base';
|
|
73
|
+
|
|
74
|
+
const SIZE_CLASS: Record<MarkdownSize, string> = {
|
|
75
|
+
xs: 'text-xs',
|
|
76
|
+
sm: 'text-sm',
|
|
77
|
+
base: 'text-base',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export interface MarkdownProps {
|
|
81
|
+
/** The markdown source. Arrives whole — this surface does not stream. */
|
|
82
|
+
readonly children: string;
|
|
83
|
+
readonly size?: MarkdownSize;
|
|
84
|
+
readonly className?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const Markdown = memo(
|
|
88
|
+
({ children, size = 'sm', className }: MarkdownProps) => (
|
|
89
|
+
<div className={`djc-markdown ${SIZE_CLASS[size]} ${className ?? ''}`} data-slot="markdown">
|
|
90
|
+
<Streamdown
|
|
91
|
+
// Static: the source is complete, so the incomplete-markdown
|
|
92
|
+
// completion that a chat needs would only be guessing at text that is
|
|
93
|
+
// not going to change.
|
|
94
|
+
mode="static"
|
|
95
|
+
parseIncompleteMarkdown={false}
|
|
96
|
+
plugins={PLUGINS}
|
|
97
|
+
mermaid={MERMAID_OPTIONS}
|
|
98
|
+
linkSafety={LINK_SAFETY}
|
|
99
|
+
rehypePlugins={REHYPE_PLUGINS as never}
|
|
100
|
+
>
|
|
101
|
+
{children}
|
|
102
|
+
</Streamdown>
|
|
103
|
+
</div>
|
|
104
|
+
),
|
|
105
|
+
// Same comparator reasoning as the chat's: parsing markdown is the expensive
|
|
106
|
+
// step, and a parent re-rendering for an unrelated reason must not repeat it.
|
|
107
|
+
(prev, next) =>
|
|
108
|
+
prev.children === next.children &&
|
|
109
|
+
prev.size === next.size &&
|
|
110
|
+
prev.className === next.className,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
Markdown.displayName = 'Markdown';
|