@bendyline/squisq-editor-react 1.6.0 → 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/README.md +57 -10
- package/dist/index.d.ts +476 -105
- package/dist/index.js +8703 -6741
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +14617 -0
- package/package.json +15 -7
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorContext.tsx +22 -16
- package/src/EditorShell.tsx +121 -35
- package/src/MediaBin.tsx +171 -28
- package/src/OutlinePanel.tsx +26 -4
- package/src/PreviewControls.tsx +475 -145
- package/src/PreviewPanel.tsx +17 -9
- package/src/RawEditor.tsx +10 -4
- 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 +547 -217
- package/src/TransitionPicker.tsx +8 -1
- package/src/VersionHistoryPanel.tsx +2 -2
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +363 -0
- 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 +163 -0
- 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/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +17 -0
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +34 -2
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/mediaReferences.ts +299 -0
- package/src/recorder/hooks/useMediaRecorder.ts +9 -10
- 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/code-context.css +155 -0
- package/src/styles/editor.css +991 -84
- package/src/styles/index.css +1 -0
- package/src/templateContentPreviewResolver.ts +353 -0
- package/src/tiptapBridge.ts +60 -33
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}
|
package/src/OutlinePanel.tsx
CHANGED
|
@@ -17,14 +17,30 @@ import { templateLabel } from './TemplatePicker';
|
|
|
17
17
|
import { useHeadingLayout } from './useHeadingLayout';
|
|
18
18
|
import { usePreviewSettingsOptional } from './PreviewControls';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Responsive default width for the outline pane, used when no fixed `width`
|
|
22
|
+
* (or `--squisq-outline-width`) is supplied: never narrower than 260px, grows
|
|
23
|
+
* with the viewport so it stretches out to fill horizontal space when there's
|
|
24
|
+
* room, and caps at 460px so it never dominates the editor. A viewport unit
|
|
25
|
+
* (rather than
|
|
26
|
+
* a container unit) keeps the pane and the toolbar's view-tabs — which read the
|
|
27
|
+
* same value — resolving to an identical width, so their right edges stay
|
|
28
|
+
* aligned.
|
|
29
|
+
*/
|
|
30
|
+
export const OUTLINE_RESPONSIVE_WIDTH = 'clamp(260px, 30vw, 460px)';
|
|
31
|
+
|
|
20
32
|
export interface OutlinePanelProps {
|
|
21
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Fixed width of the pane in pixels. When omitted, the pane sizes
|
|
35
|
+
* responsively from `--squisq-outline-width` (falling back to
|
|
36
|
+
* {@link OUTLINE_RESPONSIVE_WIDTH}) so it stretches on wider screens.
|
|
37
|
+
*/
|
|
22
38
|
width?: number;
|
|
23
39
|
/** Optional CSS class for the outer container. */
|
|
24
40
|
className?: string;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
|
-
export function OutlinePanel({ width
|
|
43
|
+
export function OutlinePanel({ width, className }: OutlinePanelProps) {
|
|
28
44
|
const {
|
|
29
45
|
doc,
|
|
30
46
|
markdownSource,
|
|
@@ -88,9 +104,15 @@ export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
|
|
|
88
104
|
const accentColor = previewSettings?.activeTheme?.colors?.primary;
|
|
89
105
|
|
|
90
106
|
const isEmpty = !doc || doc.blocks.length === 0 || !hasAnyHeading(doc.blocks);
|
|
107
|
+
// Fixed px when a width is supplied; otherwise size from the shared
|
|
108
|
+
// `--squisq-outline-width` variable (the shell sets it, and the toolbar's
|
|
109
|
+
// view-tabs read the same value so their right edges stay aligned). The
|
|
110
|
+
// clamp fallback lets a standalone OutlinePanel stretch on wide screens too.
|
|
111
|
+
const basis =
|
|
112
|
+
width != null ? `${width}px` : `var(--squisq-outline-width, ${OUTLINE_RESPONSIVE_WIDTH})`;
|
|
91
113
|
const paneStyle: CSSProperties = {
|
|
92
|
-
width:
|
|
93
|
-
flex: `0 0 ${
|
|
114
|
+
width: basis,
|
|
115
|
+
flex: `0 0 ${basis}`,
|
|
94
116
|
overflow: 'auto',
|
|
95
117
|
...(accentColor
|
|
96
118
|
? ({ ['--squisq-outline-accent' as string]: accentColor } as CSSProperties)
|