@bendyline/squisq-editor-react 1.6.1 → 1.6.2

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.
Files changed (44) hide show
  1. package/dist/index.d.ts +87 -41
  2. package/dist/index.js +8263 -6705
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +841 -91
  5. package/package.json +4 -4
  6. package/src/BlockPropertiesPopover.tsx +23 -7
  7. package/src/EditorShell.tsx +65 -20
  8. package/src/MediaBin.tsx +171 -28
  9. package/src/PreviewControls.tsx +177 -44
  10. package/src/PreviewPanel.tsx +2 -0
  11. package/src/TemplateAnnotation.ts +22 -7
  12. package/src/TemplateContentPreview.tsx +56 -0
  13. package/src/TemplatePicker.tsx +295 -128
  14. package/src/ThemeCustomizerPanel.tsx +22 -15
  15. package/src/Toolbar.tsx +527 -207
  16. package/src/TransitionPicker.tsx +8 -1
  17. package/src/ViewSwitcher.tsx +4 -4
  18. package/src/WysiwygEditor.tsx +45 -3
  19. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  20. package/src/__tests__/editorShellProps.test.tsx +268 -1
  21. package/src/__tests__/headingTransition.test.ts +59 -9
  22. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  23. package/src/__tests__/mediaReferences.test.ts +82 -0
  24. package/src/__tests__/previewControls.test.tsx +94 -1
  25. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  26. package/src/__tests__/templateContentPreview.test.ts +101 -0
  27. package/src/__tests__/tiptapBridge.test.ts +47 -0
  28. package/src/diagram/DiagramWidget.tsx +6 -4
  29. package/src/headingTransition.ts +96 -21
  30. package/src/index.ts +2 -1
  31. package/src/mediaReferences.ts +299 -0
  32. package/src/scene/Scene.tsx +53 -7
  33. package/src/scene/SceneBlockWidget.tsx +6 -3
  34. package/src/scene/SceneSelection.tsx +19 -15
  35. package/src/scene/SceneSideToolbar.tsx +89 -0
  36. package/src/scene/layers/DiagramEdges.tsx +4 -3
  37. package/src/scene/layers/edgeGeometry.ts +23 -4
  38. package/src/scene/scene.css +142 -2
  39. package/src/scene/tools/ConnectTool.ts +113 -24
  40. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  41. package/src/scene/tools/SceneTool.ts +2 -0
  42. package/src/styles/editor.css +862 -101
  43. package/src/templateContentPreviewResolver.ts +353 -0
  44. package/src/tiptapBridge.ts +60 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-editor-react",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "React editor shell with raw/WYSIWYG/preview modes for Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -52,9 +52,9 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
- "@bendyline/squisq": "1.5.1",
56
- "@bendyline/squisq-formats": "1.4.1",
57
- "@bendyline/squisq-react": "1.4.1",
55
+ "@bendyline/squisq": "1.5.2",
56
+ "@bendyline/squisq-formats": "1.4.2",
57
+ "@bendyline/squisq-react": "1.4.2",
58
58
  "@fortawesome/fontawesome-free": "7.2.0",
59
59
  "@monaco-editor/react": "4.7.0",
60
60
  "@tiptap/extension-image": "2.27.2",
@@ -3,12 +3,11 @@
3
3
  *
4
4
  * The on-canvas "block properties" palette — the sibling of the block-template
5
5
  * badge. Anchored at the `.squisq-props-badge` chip on a heading, it edits the
6
- * block's playback/animation metadata, all stored in the heading's Pandoc `{…}`
7
- * attribute block (`dataBlockAttrs`):
6
+ * block's playback/animation metadata:
8
7
  *
9
- * - Transition (type / direction / duration) — reuses `TransitionPicker`
10
- * - Duration (`duration`) — how long the block is shown
11
- * - Start time (`startTime`) — timeline position
8
+ * - Transition (type / direction / duration) — stored in `{[…]}` params
9
+ * - Duration (`duration`) — stored in Pandoc `dataBlockAttrs`
10
+ * - Start time (`startTime`) — stored in Pandoc `dataBlockAttrs`
12
11
  *
13
12
  * The popover holds the `dataBlockAttrs` inner string as working state and
14
13
  * re-derives each control from it, so successive edits compose. Every change
@@ -23,6 +22,7 @@ import { TransitionPicker } from './TransitionPicker';
23
22
  import {
24
23
  readBlockAttrsTransition,
25
24
  setBlockAttrsTransition,
25
+ setHeadingAttrsTransition,
26
26
  type TransitionFields,
27
27
  } from './headingTransition';
28
28
  import { readBlockAttrsValue, setBlockAttrsValue } from './blockProperties';
@@ -36,6 +36,11 @@ export interface BlockPropertiesPopoverProps {
36
36
  templateParams: string | null;
37
37
  /** Apply a new `dataBlockAttrs` inner to the heading (null clears it). */
38
38
  onChange: (nextInner: string | null) => void;
39
+ /** Apply a paired `dataBlockAttrs` / `dataTemplateParams` transition rewrite. */
40
+ onAnnotationChange?: (next: {
41
+ blockAttrsInner: string | null;
42
+ templateParams: string | null;
43
+ }) => void;
39
44
  onClose: () => void;
40
45
  }
41
46
 
@@ -48,10 +53,12 @@ export function BlockPropertiesPopover({
48
53
  blockAttrs,
49
54
  templateParams,
50
55
  onChange,
56
+ onAnnotationChange,
51
57
  onClose,
52
58
  }: BlockPropertiesPopoverProps) {
53
59
  // Working copy of the Pandoc inner; successive edits compose off it.
54
60
  const [inner, setInner] = useState<string | null>(blockAttrs);
61
+ const [templateInner, setTemplateInner] = useState<string | null>(templateParams);
55
62
  const [style, setStyle] = useState<React.CSSProperties>(() => computeStyle(anchorRect));
56
63
 
57
64
  useEffect(() => {
@@ -84,11 +91,20 @@ export function BlockPropertiesPopover({
84
91
  onChange(next);
85
92
  };
86
93
 
87
- const transition = readBlockAttrsTransition(inner, templateParams);
94
+ const transition = readBlockAttrsTransition(inner, templateInner);
88
95
  const duration = readBlockAttrsValue(inner, 'duration');
89
96
  const startTime = readBlockAttrsValue(inner, 'startTime');
90
97
 
91
- const onTransition = (next: TransitionFields) => apply(setBlockAttrsTransition(inner, next));
98
+ const onTransition = (next: TransitionFields) => {
99
+ if (!onAnnotationChange) {
100
+ apply(setBlockAttrsTransition(inner, next));
101
+ return;
102
+ }
103
+ const updated = setHeadingAttrsTransition(inner, templateInner, next);
104
+ setInner(updated.blockAttrsInner);
105
+ setTemplateInner(updated.templateParams);
106
+ onAnnotationChange(updated);
107
+ };
92
108
  const onDuration = (v: string) => apply(setBlockAttrsValue(inner, 'duration', v));
93
109
  const onStartTime = (v: string) => apply(setBlockAttrsValue(inner, 'startTime', v));
94
110
 
@@ -34,8 +34,6 @@ import { ImageEditor } from './ImageEditor';
34
34
  import {
35
35
  PreviewSettingsProvider,
36
36
  PreviewToolbarControls,
37
- PreviewModeSwitch,
38
- PreviewFormatSwitch,
39
37
  ThemeDesignerDock,
40
38
  } from './PreviewControls';
41
39
  import { CustomThemeProvider, useDocCustomThemes } from './customThemes';
@@ -49,6 +47,10 @@ import {
49
47
  processTextFile,
50
48
  processTextFiles,
51
49
  } from './utils/dropUtils';
50
+ import {
51
+ collectMediaReferencesFromMarkdown,
52
+ removeMediaReferencesFromMarkdown,
53
+ } from './mediaReferences';
52
54
  import type { MediaProvider, Theme } from '@bendyline/squisq/schemas';
53
55
  import { DARK_SURFACE, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
54
56
  import type { ContentContainer } from '@bendyline/squisq/storage';
@@ -614,6 +616,7 @@ function EditorShellInner({
614
616
  imageEditTarget,
615
617
  closeImageEdit,
616
618
  bumpMediaRevision,
619
+ mediaRevision,
617
620
  } = useEditorContext();
618
621
  // Dual-catalog custom themes (doc + browser library), mirroring how
619
622
  // CustomTemplateProvider is fed. Wraps the preview subtree so the theme
@@ -632,6 +635,12 @@ function EditorShellInner({
632
635
  const isTimelineMode = isMarkdownMode && layoutMode === 'timeline' && !isPreview;
633
636
  const [showFiles, setShowFiles] = useState(false);
634
637
  const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
638
+ const [mediaCount, setMediaCount] = useState(0);
639
+ const mediaListRefreshKey = mediaRefreshKey + mediaRevision;
640
+ const usedMediaPaths = useMemo(
641
+ () => collectMediaReferencesFromMarkdown(markdownSource),
642
+ [markdownSource],
643
+ );
635
644
  // Persistent fallback container for image-edit sidecars when the host
636
645
  // didn't supply one. Lifted to shell scope so opening the same image
637
646
  // multiple times sees the same `.imageEdits/<sanitized>/.versions/...`
@@ -644,6 +653,33 @@ function EditorShellInner({
644
653
  const imageEditFallbackContainer = imageEditFallbackContainerRef.current;
645
654
  const isDark = colorScheme === 'dark';
646
655
 
656
+ useEffect(() => {
657
+ if (!mediaProvider) {
658
+ setMediaCount(0);
659
+ return;
660
+ }
661
+
662
+ let cancelled = false;
663
+ mediaProvider.listMedia().then(
664
+ (entries) => {
665
+ if (!cancelled) setMediaCount(entries.length);
666
+ },
667
+ (err: unknown) => {
668
+ if (!cancelled) {
669
+ setMediaCount(0);
670
+ console.warn(
671
+ '[squisq-editor] Failed to read media list:',
672
+ err instanceof Error ? err.message : err,
673
+ );
674
+ }
675
+ },
676
+ );
677
+
678
+ return () => {
679
+ cancelled = true;
680
+ };
681
+ }, [mediaProvider, mediaListRefreshKey]);
682
+
647
683
  const handleToggleFiles = useCallback(() => {
648
684
  setShowFiles((prev) => !prev);
649
685
  }, []);
@@ -701,6 +737,24 @@ function EditorShellInner({
701
737
  [activeView, tiptapEditor, monacoEditor, insertAtCursor, markdownSource, setMarkdownSource],
702
738
  );
703
739
 
740
+ const handleMediaUploaded = useCallback(
741
+ (relativePath: string, name: string, mimeType: string) => {
742
+ insertMediaRef(relativePath, name, mimeType);
743
+ setMediaRefreshKey((k) => k + 1);
744
+ },
745
+ [insertMediaRef],
746
+ );
747
+
748
+ const handleMediaRemoved = useCallback(
749
+ (relativePath: string) => {
750
+ const nextSource = removeMediaReferencesFromMarkdown(markdownSource, relativePath);
751
+ if (nextSource !== markdownSource) replaceAll(nextSource);
752
+ setMediaRefreshKey((k) => k + 1);
753
+ bumpMediaRevision();
754
+ },
755
+ [bumpMediaRevision, markdownSource, replaceAll],
756
+ );
757
+
704
758
  const handleFileDrop = useCallback(
705
759
  async (files: File[], target: DropTarget) => {
706
760
  try {
@@ -827,24 +881,11 @@ function EditorShellInner({
827
881
  <div className="squisq-editor-header">
828
882
  <Toolbar
829
883
  showFiles={showFiles}
884
+ fileCount={mediaCount}
830
885
  onToggleFiles={!isCodeMode && filesToggleEnabled ? handleToggleFiles : undefined}
831
886
  slotLeft={toolbarSlotLeft}
832
- slotAfterTabs={
833
- !isCodeMode &&
834
- isPreview && (
835
- <div className="squisq-preview-left-controls">
836
- <PreviewModeSwitch />
837
- <span className="squisq-preview-seg-divider" aria-hidden="true" />
838
- <PreviewFormatSwitch />
839
- </div>
840
- )
841
- }
842
- slotAfterActions={
843
- <>
844
- {toolbarSlotAfterActions}
845
- {!isCodeMode && isPreview && <PreviewToolbarControls />}
846
- </>
847
- }
887
+ slotAfterTabs={!isCodeMode && isPreview && <PreviewToolbarControls />}
888
+ slotAfterActions={toolbarSlotAfterActions}
848
889
  slotRight={toolbarSlotRight}
849
890
  showPlayTab={showPlayTab}
850
891
  />
@@ -880,6 +921,7 @@ function EditorShellInner({
880
921
  allowVersioning={allowVersioning}
881
922
  versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
882
923
  onExport={onImageExport}
924
+ surface={colorScheme === 'dark' ? DARK_SURFACE : LIGHT_SURFACE}
883
925
  />
884
926
  ) : (
885
927
  <ImageViewer src={imageSrc} alt={imageAlt} theme={colorScheme} />
@@ -993,8 +1035,11 @@ function EditorShellInner({
993
1035
  <MediaBin
994
1036
  mediaProvider={mediaProvider}
995
1037
  isDark={isDark}
996
- refreshKey={mediaRefreshKey}
997
- onMediaUploaded={insertMediaRef}
1038
+ refreshKey={mediaListRefreshKey}
1039
+ usedMediaPaths={usedMediaPaths}
1040
+ onMediaUploaded={handleMediaUploaded}
1041
+ onMediaRemoved={handleMediaRemoved}
1042
+ onCountChange={setMediaCount}
998
1043
  />
999
1044
  )}
1000
1045
 
package/src/MediaBin.tsx CHANGED
@@ -21,6 +21,8 @@ export interface MediaBinProps {
21
21
  isDark: boolean;
22
22
  /** Incremented externally to signal a re-scan of the media list */
23
23
  refreshKey?: number;
24
+ /** Relative media paths currently referenced by the document. */
25
+ usedMediaPaths?: ReadonlySet<string>;
24
26
  /**
25
27
  * Fired after a successful upload via the MediaBin's own "+ Upload"
26
28
  * button. `relativePath` is what the provider returned (the same
@@ -32,6 +34,13 @@ export interface MediaBinProps {
32
34
  * else, leaving the message body empty when the user hit Send.
33
35
  */
34
36
  onMediaUploaded?: (relativePath: string, name: string, mimeType: string) => void | Promise<void>;
37
+ /**
38
+ * Fired after a file is removed through the MediaBin context menu.
39
+ * Hosts use this to remove matching markdown refs from the document.
40
+ */
41
+ onMediaRemoved?: (relativePath: string, entry: MediaEntry) => void | Promise<void>;
42
+ /** Fired whenever the panel scans media and knows the current entry count. */
43
+ onCountChange?: (count: number) => void;
35
44
  }
36
45
 
37
46
  // ============================================
@@ -57,21 +66,91 @@ function isImageMime(mimeType: string): boolean {
57
66
  return mimeType.startsWith('image/');
58
67
  }
59
68
 
69
+ function sortMediaEntries(entries: MediaEntry[]): MediaEntry[] {
70
+ return entries.sort((a, b) => {
71
+ const aImg = isImageMime(a.mimeType) ? 0 : 1;
72
+ const bImg = isImageMime(b.mimeType) ? 0 : 1;
73
+ if (aImg !== bImg) return aImg - bImg;
74
+ return a.name.localeCompare(b.name);
75
+ });
76
+ }
77
+
60
78
  // ============================================
61
79
  // Component
62
80
  // ============================================
63
81
 
64
- export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }: MediaBinProps) {
82
+ export function MediaBin({
83
+ mediaProvider,
84
+ isDark,
85
+ refreshKey,
86
+ usedMediaPaths,
87
+ onMediaUploaded,
88
+ onMediaRemoved,
89
+ onCountChange,
90
+ }: MediaBinProps) {
65
91
  const [entries, setEntries] = useState<MediaEntry[]>([]);
66
92
  const [thumbUrls, setThumbUrls] = useState<Record<string, string>>({});
67
93
  const [loading, setLoading] = useState(false);
94
+ const [contextMenu, setContextMenu] = useState<{
95
+ entry: MediaEntry;
96
+ x: number;
97
+ y: number;
98
+ } | null>(null);
68
99
  const fileInputRef = useRef<HTMLInputElement>(null);
100
+ const contextMenuRef = useRef<HTMLDivElement>(null);
101
+
102
+ const updateEntries = useCallback(
103
+ async (provider: MediaProvider) => {
104
+ const list = sortMediaEntries(await provider.listMedia());
105
+ setEntries(list);
106
+ onCountChange?.(list.length);
107
+
108
+ const urls: Record<string, string> = {};
109
+ for (const entry of list) {
110
+ if (isImageMime(entry.mimeType)) {
111
+ try {
112
+ urls[entry.name] = await provider.resolveUrl(entry.name);
113
+ } catch {
114
+ // skip failed resolve
115
+ }
116
+ }
117
+ }
118
+ setThumbUrls(urls);
119
+ },
120
+ [onCountChange],
121
+ );
122
+
123
+ useEffect(() => {
124
+ if (!contextMenu) return;
125
+
126
+ const handlePointerDown = (event: MouseEvent) => {
127
+ if (contextMenuRef.current?.contains(event.target as Node)) return;
128
+ setContextMenu(null);
129
+ };
130
+ const handleKeyDown = (event: KeyboardEvent) => {
131
+ if (event.key === 'Escape') setContextMenu(null);
132
+ };
133
+ const close = () => setContextMenu(null);
134
+
135
+ document.addEventListener('mousedown', handlePointerDown);
136
+ document.addEventListener('keydown', handleKeyDown);
137
+ window.addEventListener('scroll', close, true);
138
+ window.addEventListener('resize', close);
139
+
140
+ return () => {
141
+ document.removeEventListener('mousedown', handlePointerDown);
142
+ document.removeEventListener('keydown', handleKeyDown);
143
+ window.removeEventListener('scroll', close, true);
144
+ window.removeEventListener('resize', close);
145
+ };
146
+ }, [contextMenu]);
69
147
 
70
148
  // Scan media entries whenever the provider changes or refreshKey bumps
71
149
  useEffect(() => {
72
150
  if (!mediaProvider) {
73
151
  setEntries([]);
74
152
  setThumbUrls({});
153
+ onCountChange?.(0);
75
154
  return;
76
155
  }
77
156
 
@@ -83,13 +162,9 @@ export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }:
83
162
  const list = await mediaProvider!.listMedia();
84
163
  if (cancelled) return;
85
164
 
86
- list.sort((a, b) => {
87
- const aImg = isImageMime(a.mimeType) ? 0 : 1;
88
- const bImg = isImageMime(b.mimeType) ? 0 : 1;
89
- if (aImg !== bImg) return aImg - bImg;
90
- return a.name.localeCompare(b.name);
91
- });
165
+ sortMediaEntries(list);
92
166
  setEntries(list);
167
+ onCountChange?.(list.length);
93
168
 
94
169
  const urls: Record<string, string> = {};
95
170
  for (const entry of list) {
@@ -111,7 +186,7 @@ export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }:
111
186
  return () => {
112
187
  cancelled = true;
113
188
  };
114
- }, [mediaProvider, refreshKey]);
189
+ }, [mediaProvider, refreshKey, onCountChange]);
115
190
 
116
191
  // ---- Upload ----
117
192
 
@@ -141,32 +216,66 @@ export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }:
141
216
  }
142
217
  }
143
218
  // Re-scan
144
- const list = await mediaProvider.listMedia();
145
- list.sort((a, b) => {
146
- const aImg = isImageMime(a.mimeType) ? 0 : 1;
147
- const bImg = isImageMime(b.mimeType) ? 0 : 1;
148
- if (aImg !== bImg) return aImg - bImg;
149
- return a.name.localeCompare(b.name);
150
- });
151
- setEntries(list);
219
+ await updateEntries(mediaProvider);
220
+ } finally {
221
+ setLoading(false);
222
+ if (fileInputRef.current) fileInputRef.current.value = '';
223
+ }
224
+ },
225
+ [mediaProvider, onMediaUploaded, updateEntries],
226
+ );
152
227
 
153
- const urls: Record<string, string> = {};
154
- for (const entry of list) {
155
- if (isImageMime(entry.mimeType)) {
156
- try {
157
- urls[entry.name] = await mediaProvider.resolveUrl(entry.name);
158
- } catch {
159
- // skip
160
- }
228
+ const handleRemoveEntry = useCallback(
229
+ async (entry: MediaEntry) => {
230
+ if (!mediaProvider) return;
231
+ setContextMenu(null);
232
+ setLoading(true);
233
+ try {
234
+ await mediaProvider.removeMedia(entry.name);
235
+ if (onMediaRemoved) {
236
+ try {
237
+ await onMediaRemoved(entry.name, entry);
238
+ } catch (err: unknown) {
239
+ console.warn(
240
+ '[squisq-editor] Failed to remove media reference from document:',
241
+ err instanceof Error ? err.message : err,
242
+ );
161
243
  }
162
244
  }
163
- setThumbUrls(urls);
245
+ await updateEntries(mediaProvider);
164
246
  } finally {
165
247
  setLoading(false);
166
- if (fileInputRef.current) fileInputRef.current.value = '';
167
248
  }
168
249
  },
169
- [mediaProvider, onMediaUploaded],
250
+ [mediaProvider, onMediaRemoved, updateEntries],
251
+ );
252
+
253
+ const openContextMenu = useCallback((entry: MediaEntry, x: number, y: number) => {
254
+ setContextMenu({ entry, x, y });
255
+ }, []);
256
+
257
+ const removeLabel = contextMenu
258
+ ? isImageMime(contextMenu.entry.mimeType)
259
+ ? 'Remove image'
260
+ : 'Remove file'
261
+ : 'Remove file';
262
+
263
+ const menuLeft = contextMenu ? Math.min(contextMenu.x, Math.max(8, window.innerWidth - 168)) : 0;
264
+ const menuTop = contextMenu ? Math.min(contextMenu.y, Math.max(8, window.innerHeight - 48)) : 0;
265
+
266
+ const contextMenuStyle = {
267
+ left: menuLeft,
268
+ top: menuTop,
269
+ };
270
+
271
+ const handleItemKeyDown = useCallback(
272
+ (entry: MediaEntry, event: React.KeyboardEvent<HTMLDivElement>) => {
273
+ if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return;
274
+ event.preventDefault();
275
+ const rect = event.currentTarget.getBoundingClientRect();
276
+ openContextMenu(entry, rect.left + 16, rect.top + 16);
277
+ },
278
+ [openContextMenu],
170
279
  );
171
280
 
172
281
  return (
@@ -208,6 +317,7 @@ export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }:
208
317
  const basename = entry.name.includes('/') ? entry.name.split('/').pop()! : entry.name;
209
318
  const isImage = isImageMime(entry.mimeType);
210
319
  const altText = basename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
320
+ const isUnused = !!usedMediaPaths && !usedMediaPaths.has(entry.name);
211
321
 
212
322
  const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
213
323
  if (!isImage) return;
@@ -227,7 +337,13 @@ export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }:
227
337
  className="squisq-media-bin-item"
228
338
  title={`${entry.name}\n${entry.mimeType}\n${formatSize(entry.size)}`}
229
339
  draggable={isImage}
340
+ tabIndex={0}
341
+ onContextMenu={(e) => {
342
+ e.preventDefault();
343
+ openContextMenu(entry, e.clientX, e.clientY);
344
+ }}
230
345
  onDragStart={handleDragStart}
346
+ onKeyDown={(e) => handleItemKeyDown(entry, e)}
231
347
  >
232
348
  {/* Thumbnail or icon */}
233
349
  {thumb ? (
@@ -244,13 +360,40 @@ export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }:
244
360
  {/* Name + size */}
245
361
  <div className="squisq-media-bin-meta">
246
362
  <div className="squisq-media-bin-name">{basename}</div>
247
- <div className="squisq-media-bin-size">{formatSize(entry.size)}</div>
363
+ <div className="squisq-media-bin-detail-row">
364
+ <span className="squisq-media-bin-size">{formatSize(entry.size)}</span>
365
+ {isUnused && (
366
+ <span className="squisq-media-bin-unused-badge" title="Not used in document">
367
+ Unused
368
+ </span>
369
+ )}
370
+ </div>
248
371
  </div>
249
372
  </div>
250
373
  );
251
374
  })}
252
375
  </div>
253
376
 
377
+ {contextMenu && (
378
+ <div
379
+ ref={contextMenuRef}
380
+ className="squisq-media-bin-context-menu"
381
+ role="menu"
382
+ aria-label={`${contextMenu.entry.name} actions`}
383
+ style={contextMenuStyle}
384
+ >
385
+ <button
386
+ type="button"
387
+ role="menuitem"
388
+ className="squisq-media-bin-context-menu-item squisq-media-bin-context-menu-item--danger"
389
+ onClick={() => handleRemoveEntry(contextMenu.entry)}
390
+ disabled={!mediaProvider || loading}
391
+ >
392
+ {removeLabel}
393
+ </button>
394
+ </div>
395
+ )}
396
+
254
397
  {/* Hidden file input */}
255
398
  <input
256
399
  ref={fileInputRef}