@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.
- package/dist/index.d.ts +87 -41
- package/dist/index.js +8263 -6705
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +841 -91
- package/package.json +4 -4
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorShell.tsx +65 -20
- package/src/MediaBin.tsx +171 -28
- package/src/PreviewControls.tsx +177 -44
- package/src/PreviewPanel.tsx +2 -0
- package/src/TemplateAnnotation.ts +22 -7
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +295 -128
- package/src/ThemeCustomizerPanel.tsx +22 -15
- package/src/Toolbar.tsx +527 -207
- package/src/TransitionPicker.tsx +8 -1
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/editorShellProps.test.tsx +268 -1
- package/src/__tests__/headingTransition.test.ts +59 -9
- package/src/__tests__/imageEditorShell.test.tsx +23 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/previewControls.test.tsx +94 -1
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/tiptapBridge.test.ts +47 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +2 -1
- package/src/mediaReferences.ts +299 -0
- package/src/scene/Scene.tsx +53 -7
- package/src/scene/SceneBlockWidget.tsx +6 -3
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/layers/DiagramEdges.tsx +4 -3
- package/src/scene/layers/edgeGeometry.ts +23 -4
- package/src/scene/scene.css +142 -2
- package/src/scene/tools/ConnectTool.ts +113 -24
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +2 -0
- package/src/styles/editor.css +862 -101
- package/src/templateContentPreviewResolver.ts +353 -0
- 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.
|
|
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.
|
|
56
|
-
"@bendyline/squisq-formats": "1.4.
|
|
57
|
-
"@bendyline/squisq-react": "1.4.
|
|
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
|
|
7
|
-
* attribute block (`dataBlockAttrs`):
|
|
6
|
+
* block's playback/animation metadata:
|
|
8
7
|
*
|
|
9
|
-
* - Transition (type / direction / duration) —
|
|
10
|
-
* - Duration (`duration`) —
|
|
11
|
-
* - Start time (`startTime`) —
|
|
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,
|
|
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) =>
|
|
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
|
|
package/src/EditorShell.tsx
CHANGED
|
@@ -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
|
-
|
|
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={
|
|
997
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
245
|
+
await updateEntries(mediaProvider);
|
|
164
246
|
} finally {
|
|
165
247
|
setLoading(false);
|
|
166
|
-
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
167
248
|
}
|
|
168
249
|
},
|
|
169
|
-
[mediaProvider,
|
|
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-
|
|
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}
|