@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.
Files changed (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. 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({ 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}
@@ -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
- /** Width of the pane in pixels (default: 240). */
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 = 240, className }: OutlinePanelProps) {
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: `${width}px`,
93
- flex: `0 0 ${width}px`,
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)