@bendyline/squisq-editor-react 1.2.1 → 1.3.0

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 (105) hide show
  1. package/dist/EditorContext.d.ts +65 -1
  2. package/dist/EditorContext.d.ts.map +1 -1
  3. package/dist/EditorContext.js +31 -4
  4. package/dist/EditorContext.js.map +1 -1
  5. package/dist/EditorShell.d.ts +101 -2
  6. package/dist/EditorShell.d.ts.map +1 -1
  7. package/dist/EditorShell.js +20 -8
  8. package/dist/EditorShell.js.map +1 -1
  9. package/dist/ImageNodeView.d.ts.map +1 -1
  10. package/dist/ImageNodeView.js +12 -2
  11. package/dist/ImageNodeView.js.map +1 -1
  12. package/dist/MediaBin.d.ts.map +1 -1
  13. package/dist/MediaBin.js +16 -1
  14. package/dist/MediaBin.js.map +1 -1
  15. package/dist/MentionExtension.d.ts +22 -0
  16. package/dist/MentionExtension.d.ts.map +1 -0
  17. package/dist/MentionExtension.js +242 -0
  18. package/dist/MentionExtension.js.map +1 -0
  19. package/dist/PreviewPanel.d.ts +3 -8
  20. package/dist/PreviewPanel.d.ts.map +1 -1
  21. package/dist/PreviewPanel.js +4 -282
  22. package/dist/PreviewPanel.js.map +1 -1
  23. package/dist/RawEditor.d.ts +8 -1
  24. package/dist/RawEditor.d.ts.map +1 -1
  25. package/dist/RawEditor.js +167 -30
  26. package/dist/RawEditor.js.map +1 -1
  27. package/dist/TemplateAnnotation.d.ts.map +1 -1
  28. package/dist/TemplateAnnotation.js +4 -2
  29. package/dist/TemplateAnnotation.js.map +1 -1
  30. package/dist/Toolbar.d.ts +7 -1
  31. package/dist/Toolbar.d.ts.map +1 -1
  32. package/dist/Toolbar.js +57 -18
  33. package/dist/Toolbar.js.map +1 -1
  34. package/dist/Tooltip.d.ts +10 -0
  35. package/dist/Tooltip.d.ts.map +1 -0
  36. package/dist/Tooltip.js +104 -0
  37. package/dist/Tooltip.js.map +1 -0
  38. package/dist/ViewSwitcher.d.ts +1 -1
  39. package/dist/ViewSwitcher.d.ts.map +1 -1
  40. package/dist/ViewSwitcher.js +10 -4
  41. package/dist/ViewSwitcher.js.map +1 -1
  42. package/dist/WysiwygEditor.d.ts +13 -2
  43. package/dist/WysiwygEditor.d.ts.map +1 -1
  44. package/dist/WysiwygEditor.js +239 -4
  45. package/dist/WysiwygEditor.js.map +1 -1
  46. package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
  47. package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
  48. package/dist/__tests__/detectMarkdown.test.js +69 -0
  49. package/dist/__tests__/detectMarkdown.test.js.map +1 -0
  50. package/dist/__tests__/fileKind.test.d.ts +2 -0
  51. package/dist/__tests__/fileKind.test.d.ts.map +1 -0
  52. package/dist/__tests__/fileKind.test.js +81 -0
  53. package/dist/__tests__/fileKind.test.js.map +1 -0
  54. package/dist/__tests__/tiptapBridge.test.js +36 -0
  55. package/dist/__tests__/tiptapBridge.test.js.map +1 -1
  56. package/dist/buildPreviewDoc.d.ts +22 -0
  57. package/dist/buildPreviewDoc.d.ts.map +1 -0
  58. package/dist/buildPreviewDoc.js +212 -0
  59. package/dist/buildPreviewDoc.js.map +1 -0
  60. package/dist/detectMarkdown.d.ts +20 -0
  61. package/dist/detectMarkdown.d.ts.map +1 -0
  62. package/dist/detectMarkdown.js +61 -0
  63. package/dist/detectMarkdown.js.map +1 -0
  64. package/dist/fileKind.d.ts +30 -0
  65. package/dist/fileKind.d.ts.map +1 -0
  66. package/dist/fileKind.js +123 -0
  67. package/dist/fileKind.js.map +1 -0
  68. package/dist/hooks/useFileDrop.d.ts.map +1 -1
  69. package/dist/hooks/useFileDrop.js +9 -7
  70. package/dist/hooks/useFileDrop.js.map +1 -1
  71. package/dist/index.d.ts +5 -1
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +6 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/mediaDragMime.d.ts +17 -0
  76. package/dist/mediaDragMime.d.ts.map +1 -0
  77. package/dist/mediaDragMime.js +22 -0
  78. package/dist/mediaDragMime.js.map +1 -0
  79. package/dist/tiptapBridge.d.ts.map +1 -1
  80. package/dist/tiptapBridge.js +58 -2
  81. package/dist/tiptapBridge.js.map +1 -1
  82. package/package.json +9 -7
  83. package/src/EditorContext.tsx +106 -3
  84. package/src/EditorShell.tsx +195 -15
  85. package/src/ImageNodeView.tsx +15 -2
  86. package/src/MediaBin.tsx +23 -1
  87. package/src/MentionExtension.tsx +258 -0
  88. package/src/PreviewPanel.tsx +5 -333
  89. package/src/RawEditor.tsx +193 -37
  90. package/src/TemplateAnnotation.ts +4 -2
  91. package/src/Toolbar.tsx +111 -48
  92. package/src/Tooltip.tsx +124 -0
  93. package/src/ViewSwitcher.tsx +15 -5
  94. package/src/WysiwygEditor.tsx +270 -5
  95. package/src/__tests__/detectMarkdown.test.ts +88 -0
  96. package/src/__tests__/fileKind.test.ts +96 -0
  97. package/src/__tests__/tiptapBridge.test.ts +44 -0
  98. package/src/buildPreviewDoc.ts +254 -0
  99. package/src/detectMarkdown.ts +62 -0
  100. package/src/fileKind.ts +134 -0
  101. package/src/hooks/useFileDrop.ts +10 -6
  102. package/src/index.ts +14 -0
  103. package/src/mediaDragMime.ts +32 -0
  104. package/src/styles/editor.css +214 -8
  105. package/src/tiptapBridge.ts +66 -2
@@ -7,7 +7,13 @@
7
7
  */
8
8
 
9
9
  import { useEffect, useState, useCallback } from 'react';
10
- import { EditorProvider, useEditorContext, type EditorView } from './EditorContext';
10
+ import {
11
+ EditorProvider,
12
+ useEditorContext,
13
+ type EditorView,
14
+ type ImageDisplayMode,
15
+ type MentionProvider,
16
+ } from './EditorContext';
11
17
  import { Toolbar } from './Toolbar';
12
18
  import { StatusBar } from './StatusBar';
13
19
  import { RawEditor } from './RawEditor';
@@ -16,6 +22,7 @@ import { PreviewPanel } from './PreviewPanel';
16
22
  import { PreviewSettingsProvider, PreviewToolbarControls } from './PreviewControls';
17
23
  import { MediaBin } from './MediaBin';
18
24
  import { DropZoneOverlay } from './DropZoneOverlay';
25
+ import { TooltipLayer } from './Tooltip';
19
26
  import { useFileDrop, type DropTarget } from './hooks/useFileDrop';
20
27
  import {
21
28
  partitionFiles,
@@ -25,7 +32,7 @@ import {
25
32
  } from './utils/dropUtils';
26
33
  import type { MediaProvider } from '@bendyline/squisq/schemas';
27
34
  import type { ContentContainer } from '@bendyline/squisq/storage';
28
- import type { ReactNode } from 'react';
35
+ import type { CSSProperties, ReactNode } from 'react';
29
36
 
30
37
  export type { EditorTheme } from './EditorContext';
31
38
 
@@ -59,6 +66,105 @@ export interface EditorShellProps {
59
66
  toolbarSlotAfterActions?: ReactNode;
60
67
  /** Content rendered at the rightmost end of the toolbar, after all other elements. */
61
68
  toolbarSlotRight?: ReactNode;
69
+ /**
70
+ * Whether to show the "Play" (preview) tab in the toolbar. When false, the
71
+ * tab and its preview panel are hidden, and ⌘3 becomes a no-op. Use this
72
+ * when embedding the editor somewhere the slideshow preview doesn't make
73
+ * sense (e.g. editing free-form prompt documents). Defaults to true.
74
+ */
75
+ showPlayTab?: boolean;
76
+ /**
77
+ * Optional "submit on Enter" callback. When provided, a plain Enter
78
+ * keypress fires this callback instead of inserting a newline, and
79
+ * Cmd/Ctrl+Enter inserts a newline instead. Matches chat-composer UX
80
+ * (Slack, Discord). When omitted, the editor behaves normally.
81
+ */
82
+ submitOnEnter?: () => void;
83
+ /**
84
+ * Let the WYSIWYG editing surface fill its container instead of rendering
85
+ * as a centered 800px "page" column. Useful when embedding in chat
86
+ * composers, side panels, or any layout where the page metaphor doesn't
87
+ * fit. Defaults to false (page mode).
88
+ */
89
+ fullWidth?: boolean;
90
+ /**
91
+ * Font-family stack applied to the editor **chrome** — toolbar buttons,
92
+ * tabs, status bar, and control surfaces. The actual editing areas
93
+ * (Tiptap / Monaco) keep their own fonts so document editing isn't
94
+ * affected. Use this when the editor is embedded in a larger product
95
+ * that has its own UX type system and you want the controls to blend in.
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * <EditorShell uxFont="'Hanken Grotesk', system-ui, sans-serif" ... />
100
+ * ```
101
+ */
102
+ uxFont?: string;
103
+ /**
104
+ * Drop the editor's generous page-style padding in favor of a tight
105
+ * layout that hugs its container. The default WYSIWYG surface uses
106
+ * 16×24px padding suitable for editing long-form documents; chat
107
+ * composers want much less. Applies to the editing area only — the
108
+ * toolbar, tabs, and status bar keep their normal sizing.
109
+ */
110
+ thinMargins?: boolean;
111
+ /**
112
+ * Render the bottom status bar (word / character / line / block counts
113
+ * and parse-state indicator). Defaults to `true`. Set to `false` in
114
+ * embedded surfaces — chat composers and other short-form inputs —
115
+ * where the stats are noise.
116
+ */
117
+ showStatusBar?: boolean;
118
+ /**
119
+ * How images should be displayed in the WYSIWYG view. `'inline'`
120
+ * (default) flows them at natural size up to the container width;
121
+ * `'thumbnail'` constrains each image to a 100×100 box with
122
+ * aspect-preserving containment — useful for chat composers and other
123
+ * dense surfaces where a full-resolution paste would dominate the
124
+ * layout. Storage bytes are unchanged either way.
125
+ */
126
+ imageDisplayMode?: ImageDisplayMode;
127
+ /**
128
+ * File name (e.g. `foo.ts`) or bare extension that the content
129
+ * represents. When set to a non-markdown/text extension, the shell
130
+ * enters **code mode**: Monaco picks the right language based on the
131
+ * extension, the WYSIWYG and Preview tabs disappear, and the toolbar
132
+ * drops its markdown-specific formatting buttons. Markdown-ish
133
+ * extensions (`.md`, `.markdown`, `.mdown`, `.txt`) keep the full
134
+ * experience. Omit to get today's markdown behavior unchanged.
135
+ */
136
+ fileName?: string;
137
+ /**
138
+ * Explicit Monaco language ID override (e.g. `'typescript'`,
139
+ * `'python'`, `'json'`). Wins over the language derived from
140
+ * `fileName`. Anything other than `'markdown'` or `'plaintext'`
141
+ * switches the shell into code mode.
142
+ */
143
+ language?: string;
144
+ /**
145
+ * Optional async provider for `@`-mention suggestions. When supplied,
146
+ * typing `@` inside the editor opens a popover of candidates; selecting
147
+ * one inserts a `@[Label](scheme:id)` mention token. Used by chat
148
+ * composers and any other surface that wants to address named entities
149
+ * inline. Omit to disable mentions entirely.
150
+ */
151
+ mentionProvider?: MentionProvider | null;
152
+ /**
153
+ * Placeholder text shown in the WYSIWYG editor while the document is
154
+ * empty. When omitted, the editor rotates through its own generic
155
+ * "start typing…" prompts; pass a value here to override with copy
156
+ * that fits the embedding surface (e.g. a chat composer knows who
157
+ * the message is going to and can say so).
158
+ */
159
+ placeholder?: string;
160
+ /**
161
+ * When true, both editing surfaces become non-editable: Monaco runs in
162
+ * `readOnly` mode and Tiptap is set to `editable: false`. The toolbar
163
+ * still renders — hide it from the host side if you want a pure preview.
164
+ * Useful for reference panels that show file content without inviting
165
+ * accidental edits.
166
+ */
167
+ readOnly?: boolean;
62
168
  }
63
169
 
64
170
  /**
@@ -80,29 +186,58 @@ export function EditorShell({
80
186
  toolbarSlotLeft,
81
187
  toolbarSlotAfterActions,
82
188
  toolbarSlotRight,
189
+ showPlayTab = true,
190
+ submitOnEnter,
191
+ fullWidth = false,
192
+ uxFont,
193
+ thinMargins = false,
194
+ showStatusBar = true,
195
+ imageDisplayMode = 'inline',
196
+ fileName,
197
+ language,
198
+ mentionProvider,
199
+ placeholder,
200
+ readOnly = false,
83
201
  }: EditorShellProps) {
84
202
  // Show the toggle when explicitly opted in, or when mediaProvider prop was passed at all
85
203
  const filesToggleEnabled = showFilesToggle ?? mediaProvider !== undefined;
86
204
 
205
+ // If the host hides the Play tab but asked for it as the initial view,
206
+ // fall back to wysiwyg so we don't boot into a tab the user can't leave.
207
+ const effectiveInitialView: EditorView =
208
+ !showPlayTab && initialView === 'preview' ? 'wysiwyg' : initialView;
209
+
87
210
  return (
88
211
  <EditorProvider
89
212
  initialMarkdown={initialMarkdown}
90
- initialView={initialView}
213
+ initialView={effectiveInitialView}
91
214
  articleId={articleId}
92
215
  theme={theme}
93
216
  mediaProvider={mediaProvider}
217
+ imageDisplayMode={imageDisplayMode}
218
+ mentionProvider={mentionProvider}
219
+ fileName={fileName}
220
+ language={language}
94
221
  >
95
222
  <EditorShellInner
96
223
  basePath={basePath}
97
224
  onChange={onChange}
98
225
  className={className}
99
226
  height={height}
227
+ placeholder={placeholder}
100
228
  mediaProvider={mediaProvider ?? null}
101
229
  container={container}
102
230
  filesToggleEnabled={filesToggleEnabled}
103
231
  toolbarSlotLeft={toolbarSlotLeft}
104
232
  toolbarSlotAfterActions={toolbarSlotAfterActions}
105
233
  toolbarSlotRight={toolbarSlotRight}
234
+ showPlayTab={showPlayTab}
235
+ submitOnEnter={submitOnEnter}
236
+ fullWidth={fullWidth}
237
+ uxFont={uxFont}
238
+ thinMargins={thinMargins}
239
+ showStatusBar={showStatusBar}
240
+ readOnly={readOnly}
106
241
  />
107
242
  </EditorProvider>
108
243
  );
@@ -113,12 +248,20 @@ interface EditorShellInnerProps {
113
248
  onChange?: (source: string) => void;
114
249
  className?: string;
115
250
  height: string;
251
+ placeholder?: string;
116
252
  mediaProvider: MediaProvider | null;
117
253
  container?: ContentContainer | null;
118
254
  filesToggleEnabled: boolean;
119
255
  toolbarSlotLeft?: ReactNode;
120
256
  toolbarSlotAfterActions?: ReactNode;
121
257
  toolbarSlotRight?: ReactNode;
258
+ showPlayTab: boolean;
259
+ submitOnEnter?: () => void;
260
+ fullWidth: boolean;
261
+ uxFont?: string;
262
+ thinMargins: boolean;
263
+ showStatusBar: boolean;
264
+ readOnly: boolean;
122
265
  }
123
266
 
124
267
  function EditorShellInner({
@@ -126,15 +269,25 @@ function EditorShellInner({
126
269
  onChange,
127
270
  className,
128
271
  height,
272
+ placeholder,
129
273
  mediaProvider,
130
274
  container,
131
275
  filesToggleEnabled,
132
276
  toolbarSlotLeft,
133
277
  toolbarSlotAfterActions,
134
278
  toolbarSlotRight,
279
+ showPlayTab,
280
+ submitOnEnter,
281
+ fullWidth,
282
+ uxFont,
283
+ thinMargins,
284
+ showStatusBar,
285
+ readOnly,
135
286
  }: EditorShellInnerProps) {
136
- const { activeView, markdownSource, doc, theme, insertAtCursor, replaceAll } = useEditorContext();
287
+ const { activeView, markdownSource, doc, theme, editorMode, insertAtCursor, replaceAll } =
288
+ useEditorContext();
137
289
  const isPreview = activeView === 'preview';
290
+ const isCodeMode = editorMode === 'code';
138
291
  const [showFiles, setShowFiles] = useState(false);
139
292
  const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
140
293
  const isDark = theme === 'dark';
@@ -200,6 +353,7 @@ function EditorShellInner({
200
353
  document.querySelector<HTMLButtonElement>('[data-view="raw"]')?.click();
201
354
  break;
202
355
  case '3':
356
+ if (!showPlayTab) return;
203
357
  e.preventDefault();
204
358
  document.querySelector<HTMLButtonElement>('[data-view="preview"]')?.click();
205
359
  break;
@@ -208,17 +362,24 @@ function EditorShellInner({
208
362
  };
209
363
  window.addEventListener('keydown', handler);
210
364
  return () => window.removeEventListener('keydown', handler);
211
- }, []);
365
+ }, [showPlayTab]);
212
366
 
213
367
  return (
214
368
  <div
215
369
  className={`squisq-editor-shell ${className || ''}`}
216
370
  data-theme={theme}
371
+ data-full-width={fullWidth ? 'true' : undefined}
372
+ data-thin-margins={thinMargins ? 'true' : undefined}
217
373
  style={{
218
374
  display: 'flex',
219
375
  flexDirection: 'column',
220
376
  height,
221
377
  overflow: 'hidden',
378
+ // When a consumer supplies a UX font stack, expose it to the
379
+ // editor CSS via this custom property. Chrome elements (toolbar,
380
+ // tabs, status bar) consume `--squisq-ux-font` as their
381
+ // `font-family`, falling back to the system stack when unset.
382
+ ...(uxFont ? ({ '--squisq-ux-font': uxFont } as CSSProperties) : {}),
222
383
  }}
223
384
  {...containerProps}
224
385
  >
@@ -227,15 +388,16 @@ function EditorShellInner({
227
388
  <div className="squisq-editor-header">
228
389
  <Toolbar
229
390
  showFiles={showFiles}
230
- onToggleFiles={filesToggleEnabled ? handleToggleFiles : undefined}
391
+ onToggleFiles={!isCodeMode && filesToggleEnabled ? handleToggleFiles : undefined}
231
392
  slotLeft={toolbarSlotLeft}
232
393
  slotAfterActions={
233
394
  <>
234
395
  {toolbarSlotAfterActions}
235
- {isPreview && <PreviewToolbarControls />}
396
+ {!isCodeMode && isPreview && <PreviewToolbarControls />}
236
397
  </>
237
398
  }
238
399
  slotRight={toolbarSlotRight}
400
+ showPlayTab={showPlayTab}
239
401
  />
240
402
  </div>
241
403
 
@@ -245,17 +407,32 @@ function EditorShellInner({
245
407
  style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex' }}
246
408
  >
247
409
  <div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
248
- {activeView === 'raw' && <RawEditor theme={theme === 'dark' ? 'vs-dark' : 'vs'} />}
249
- {activeView === 'wysiwyg' && <WysiwygEditor />}
250
- {isPreview && <PreviewPanel basePath={basePath} container={container} />}
410
+ {activeView === 'raw' && (
411
+ <RawEditor
412
+ theme={theme === 'dark' ? 'vs-dark' : 'vs'}
413
+ submitOnEnter={submitOnEnter}
414
+ readOnly={readOnly}
415
+ />
416
+ )}
417
+ {/* WYSIWYG + Preview are markdown-only surfaces — skip them
418
+ entirely in code mode so Tiptap never initializes and the
419
+ preview pipeline stays idle. */}
420
+ {!isCodeMode && activeView === 'wysiwyg' && (
421
+ <WysiwygEditor
422
+ submitOnEnter={submitOnEnter}
423
+ placeholder={placeholder}
424
+ readOnly={readOnly}
425
+ />
426
+ )}
427
+ {!isCodeMode && isPreview && <PreviewPanel basePath={basePath} container={container} />}
251
428
  </div>
252
429
 
253
- {showFiles && (
430
+ {!isCodeMode && showFiles && (
254
431
  <MediaBin mediaProvider={mediaProvider} isDark={isDark} refreshKey={mediaRefreshKey} />
255
432
  )}
256
433
 
257
- {/* Drop zone overlay */}
258
- {isDragging && (
434
+ {/* Drop zone overlay — image / text drop UX is markdown-specific. */}
435
+ {!isCodeMode && isDragging && (
259
436
  <DropZoneOverlay
260
437
  dragContentType={dragContentType}
261
438
  zoneProps={zoneProps}
@@ -264,9 +441,12 @@ function EditorShellInner({
264
441
  )}
265
442
  </div>
266
443
 
267
- {/* Status bar */}
268
- <StatusBar />
444
+ {/* Status bar — word / char / line / block counts. Host can
445
+ suppress via `showStatusBar={false}` for embedded chat-style
446
+ composers where the stats are noise. */}
447
+ {showStatusBar && <StatusBar />}
269
448
  </PreviewSettingsProvider>
449
+ <TooltipLayer />
270
450
  </div>
271
451
  );
272
452
  }
@@ -16,8 +16,9 @@ import { useEditorContext } from './EditorContext';
16
16
 
17
17
  function ImageComponent({ node }: NodeViewProps) {
18
18
  const { src, alt, title } = node.attrs as { src: string; alt: string; title: string };
19
- const { mediaProvider } = useEditorContext();
19
+ const { mediaProvider, imageDisplayMode } = useEditorContext();
20
20
  const [resolvedSrc, setResolvedSrc] = useState(src);
21
+ const isThumbnail = imageDisplayMode === 'thumbnail';
21
22
 
22
23
  const isRelative =
23
24
  src &&
@@ -53,7 +54,19 @@ function ImageComponent({ node }: NodeViewProps) {
53
54
  src={resolvedSrc}
54
55
  alt={alt || ''}
55
56
  title={title || undefined}
56
- style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
57
+ className={isThumbnail ? 'squisq-image squisq-image--thumbnail' : 'squisq-image'}
58
+ style={
59
+ isThumbnail
60
+ ? {
61
+ maxWidth: '100px',
62
+ maxHeight: '100px',
63
+ width: 'auto',
64
+ height: 'auto',
65
+ objectFit: 'contain',
66
+ display: 'block',
67
+ }
68
+ : { maxWidth: '100%', height: 'auto', display: 'block' }
69
+ }
57
70
  />
58
71
  </NodeViewWrapper>
59
72
  );
package/src/MediaBin.tsx CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { useState, useEffect, useRef, useCallback } from 'react';
10
10
  import type { MediaProvider, MediaEntry } from '@bendyline/squisq/schemas';
11
+ import { SQUISQ_MEDIA_MIME } from './mediaDragMime';
11
12
 
12
13
  // ============================================
13
14
  // Types
@@ -186,16 +187,37 @@ export function MediaBin({ mediaProvider, isDark, refreshKey }: MediaBinProps) {
186
187
  {entries.map((entry) => {
187
188
  const thumb = thumbUrls[entry.name];
188
189
  const basename = entry.name.includes('/') ? entry.name.split('/').pop()! : entry.name;
190
+ const isImage = isImageMime(entry.mimeType);
191
+ const altText = basename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
192
+
193
+ const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
194
+ if (!isImage) return;
195
+ const payload = JSON.stringify({
196
+ name: entry.name,
197
+ mimeType: entry.mimeType,
198
+ alt: altText,
199
+ });
200
+ e.dataTransfer.setData(SQUISQ_MEDIA_MIME, payload);
201
+ e.dataTransfer.setData('text/plain', `![${altText}](${entry.name})`);
202
+ e.dataTransfer.effectAllowed = 'copy';
203
+ };
189
204
 
190
205
  return (
191
206
  <div
192
207
  key={entry.name}
193
208
  className="squisq-media-bin-item"
194
209
  title={`${entry.name}\n${entry.mimeType}\n${formatSize(entry.size)}`}
210
+ draggable={isImage}
211
+ onDragStart={handleDragStart}
195
212
  >
196
213
  {/* Thumbnail or icon */}
197
214
  {thumb ? (
198
- <img src={thumb} alt={basename} className="squisq-media-bin-thumb" />
215
+ <img
216
+ src={thumb}
217
+ alt={basename}
218
+ className="squisq-media-bin-thumb"
219
+ draggable={false}
220
+ />
199
221
  ) : (
200
222
  <span className="squisq-media-bin-icon">{iconForMime(entry.mimeType)}</span>
201
223
  )}
@@ -0,0 +1,258 @@
1
+ /**
2
+ * MentionExtension
3
+ *
4
+ * Tiptap mention configuration paired with a small absolutely-positioned
5
+ * suggestion popover. Shares a caller-supplied async provider (see
6
+ * `MentionProvider` in EditorContext) with the Monaco `@` completion
7
+ * provider in `RawEditor`, so both editing modes surface the same roster.
8
+ *
9
+ * The mention chip renders as `<span data-mention data-kind data-id
10
+ * data-label class="mention">@Label</span>`, matching the wire format that
11
+ * `tiptapBridge` emits when converting markdown → Tiptap HTML. On serialize
12
+ * back to markdown, the bridge emits `@[Label](kind:id)`.
13
+ */
14
+
15
+ import Mention from '@tiptap/extension-mention';
16
+ import { PluginKey } from '@tiptap/pm/state';
17
+ import type { Editor, Range } from '@tiptap/core';
18
+ import type { MentionCandidate, MentionProvider } from './EditorContext';
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ type SuggestionProps = any;
22
+
23
+ /**
24
+ * Fallback namespace for defensive code paths — used when a mention node
25
+ * somehow lacks a `kind` attribute (e.g. legacy HTML parsed without one).
26
+ * Inserts from the suggestion popover always carry the candidate's own
27
+ * `scheme`, so this only surfaces for malformed/legacy content.
28
+ */
29
+ const FALLBACK_KIND = 'mention';
30
+
31
+ type SuggestionState = {
32
+ items: MentionCandidate[];
33
+ selected: number;
34
+ };
35
+
36
+ /**
37
+ * Build the Tiptap mention extension for an editor. The returned extension
38
+ * captures a reference to `getProvider` at configure-time and calls it on
39
+ * every keystroke — keep the reference stable so we don't recreate the
40
+ * editor just to change who answers the `@` query.
41
+ */
42
+ export function buildMentionExtension(getProvider: () => MentionProvider | null) {
43
+ return Mention.configure({
44
+ HTMLAttributes: {
45
+ class: 'mention',
46
+ 'data-mention': 'true',
47
+ },
48
+ renderHTML({ options, node }) {
49
+ const label =
50
+ (node.attrs.label as string | undefined) ?? (node.attrs.id as string | undefined) ?? '';
51
+ const id = (node.attrs.id as string | undefined) ?? '';
52
+ const kind = (node.attrs.kind as string | undefined) ?? FALLBACK_KIND;
53
+ return [
54
+ 'span',
55
+ {
56
+ ...options.HTMLAttributes,
57
+ 'data-kind': kind,
58
+ 'data-id': id,
59
+ 'data-label': label,
60
+ },
61
+ `@${label}`,
62
+ ];
63
+ },
64
+ renderText({ node }) {
65
+ const label =
66
+ (node.attrs.label as string | undefined) ?? (node.attrs.id as string | undefined) ?? '';
67
+ const id = (node.attrs.id as string | undefined) ?? '';
68
+ const kind = (node.attrs.kind as string | undefined) ?? FALLBACK_KIND;
69
+ return `@[${label}](${kind}:${id})`;
70
+ },
71
+ }).extend({
72
+ addAttributes() {
73
+ return {
74
+ id: {
75
+ default: null,
76
+ parseHTML: (el) => el.getAttribute('data-id'),
77
+ renderHTML: (attrs) => (attrs.id ? { 'data-id': attrs.id } : {}),
78
+ },
79
+ label: {
80
+ default: null,
81
+ parseHTML: (el) => el.getAttribute('data-label'),
82
+ renderHTML: (attrs) => (attrs.label ? { 'data-label': attrs.label } : {}),
83
+ },
84
+ kind: {
85
+ default: FALLBACK_KIND,
86
+ parseHTML: (el) => el.getAttribute('data-kind') ?? FALLBACK_KIND,
87
+ renderHTML: (attrs) => ({ 'data-kind': attrs.kind ?? FALLBACK_KIND }),
88
+ },
89
+ };
90
+ },
91
+ addOptions() {
92
+ return {
93
+ ...(this.parent?.() ?? {}),
94
+ suggestion: {
95
+ char: '@',
96
+ // Custom plugin key so the mention suggestion doesn't collide
97
+ // with any future `:` or `/` popovers.
98
+ pluginKey: new PluginKey('mentionSuggestion'),
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
101
+ const id = (props?.id as string | null) ?? '';
102
+ const label = (props?.label as string | null) ?? id;
103
+ const kind = (props?.kind as string | undefined) ?? FALLBACK_KIND;
104
+ editor
105
+ .chain()
106
+ .focus()
107
+ .insertContentAt(range, [
108
+ {
109
+ type: 'mention',
110
+ attrs: { id, label, kind },
111
+ },
112
+ { type: 'text', text: ' ' },
113
+ ])
114
+ .run();
115
+ },
116
+ items: async ({ query }: { query: string }) => {
117
+ const provider = getProvider();
118
+ if (!provider) return [];
119
+ try {
120
+ return await provider(query);
121
+ } catch {
122
+ return [];
123
+ }
124
+ },
125
+ render: renderSuggestionFactory(),
126
+ },
127
+ };
128
+ },
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Lightweight suggestion popover. Uses a plain absolutely-positioned div
134
+ * anchored to the caret rect — no tippy.js needed. Keyboard nav handled via
135
+ * the `onKeyDown` hook Tiptap wires up.
136
+ */
137
+ function renderSuggestionFactory() {
138
+ return () => {
139
+ let container: HTMLDivElement | null = null;
140
+ let state: SuggestionState = { items: [], selected: 0 };
141
+ let currentProps: SuggestionProps | null = null;
142
+
143
+ const update = () => {
144
+ if (!container || !currentProps) return;
145
+ container.innerHTML = '';
146
+ if (state.items.length === 0) {
147
+ container.style.display = 'none';
148
+ return;
149
+ }
150
+ container.style.display = 'block';
151
+
152
+ for (let i = 0; i < state.items.length; i++) {
153
+ const item = state.items[i];
154
+ const btn = document.createElement('button');
155
+ btn.type = 'button';
156
+ btn.className = 'squisq-mention-item' + (i === state.selected ? ' is-selected' : '');
157
+ btn.dataset.index = String(i);
158
+ btn.innerHTML = '';
159
+ const label = document.createElement('span');
160
+ label.className = 'squisq-mention-label';
161
+ label.textContent = item.label;
162
+ btn.appendChild(label);
163
+ if (item.description) {
164
+ const desc = document.createElement('span');
165
+ desc.className = 'squisq-mention-desc';
166
+ desc.textContent = item.description;
167
+ btn.appendChild(desc);
168
+ }
169
+ btn.addEventListener('mousedown', (ev) => {
170
+ ev.preventDefault();
171
+ selectAt(i);
172
+ });
173
+ container.appendChild(btn);
174
+ }
175
+
176
+ positionTo(container, currentProps.clientRect);
177
+ };
178
+
179
+ const selectAt = (index: number) => {
180
+ const item = state.items[index];
181
+ if (!item || !currentProps) return;
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
+ const command = (currentProps as any).command;
184
+ if (typeof command === 'function') {
185
+ command({ id: item.id, label: item.label, kind: item.scheme });
186
+ }
187
+ };
188
+
189
+ return {
190
+ onStart: (props: SuggestionProps) => {
191
+ currentProps = props;
192
+ state = { items: props.items ?? [], selected: 0 };
193
+ if (!container) {
194
+ container = document.createElement('div');
195
+ container.className = 'squisq-mention-popover';
196
+ container.style.position = 'absolute';
197
+ container.style.zIndex = '10000';
198
+ document.body.appendChild(container);
199
+ }
200
+ update();
201
+ },
202
+ onUpdate: (props: SuggestionProps) => {
203
+ currentProps = props;
204
+ if (Array.isArray(props.items)) {
205
+ state = { items: props.items, selected: 0 };
206
+ }
207
+ update();
208
+ },
209
+ onKeyDown: ({ event }: { event: KeyboardEvent }) => {
210
+ if (!state.items.length) return false;
211
+ if (event.key === 'ArrowDown') {
212
+ state.selected = (state.selected + 1) % state.items.length;
213
+ update();
214
+ return true;
215
+ }
216
+ if (event.key === 'ArrowUp') {
217
+ state.selected = (state.selected - 1 + state.items.length) % state.items.length;
218
+ update();
219
+ return true;
220
+ }
221
+ if (event.key === 'Enter' || event.key === 'Tab') {
222
+ selectAt(state.selected);
223
+ return true;
224
+ }
225
+ if (event.key === 'Escape') {
226
+ state = { items: [], selected: 0 };
227
+ update();
228
+ return true;
229
+ }
230
+ return false;
231
+ },
232
+ onExit: () => {
233
+ if (container?.parentNode) container.parentNode.removeChild(container);
234
+ container = null;
235
+ currentProps = null;
236
+ },
237
+ };
238
+ };
239
+ }
240
+
241
+ function positionTo(
242
+ el: HTMLDivElement,
243
+ clientRect: (() => DOMRect | null) | null | undefined,
244
+ ): void {
245
+ const rect = clientRect?.();
246
+ if (!rect) return;
247
+ // Anchor just below the caret; fall back to above when there's no room.
248
+ const viewportH = window.innerHeight;
249
+ const below = rect.bottom + 4;
250
+ const estH = Math.min(240, el.offsetHeight || 200);
251
+ const fitsBelow = below + estH < viewportH;
252
+ el.style.left = `${rect.left + window.scrollX}px`;
253
+ if (fitsBelow) {
254
+ el.style.top = `${below + window.scrollY}px`;
255
+ } else {
256
+ el.style.top = `${rect.top + window.scrollY - estH - 4}px`;
257
+ }
258
+ }