@bendyline/squisq-editor-react 1.2.2 → 1.4.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 (106) 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 +112 -2
  6. package/dist/EditorShell.d.ts.map +1 -1
  7. package/dist/EditorShell.js +95 -11
  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 +12 -1
  13. package/dist/MediaBin.d.ts.map +1 -1
  14. package/dist/MediaBin.js +29 -4
  15. package/dist/MediaBin.js.map +1 -1
  16. package/dist/MentionExtension.d.ts +22 -0
  17. package/dist/MentionExtension.d.ts.map +1 -0
  18. package/dist/MentionExtension.js +242 -0
  19. package/dist/MentionExtension.js.map +1 -0
  20. package/dist/RawEditor.d.ts +8 -1
  21. package/dist/RawEditor.d.ts.map +1 -1
  22. package/dist/RawEditor.js +167 -30
  23. package/dist/RawEditor.js.map +1 -1
  24. package/dist/TemplateAnnotation.d.ts.map +1 -1
  25. package/dist/TemplateAnnotation.js +4 -2
  26. package/dist/TemplateAnnotation.js.map +1 -1
  27. package/dist/Toolbar.d.ts +7 -1
  28. package/dist/Toolbar.d.ts.map +1 -1
  29. package/dist/Toolbar.js +57 -18
  30. package/dist/Toolbar.js.map +1 -1
  31. package/dist/Tooltip.d.ts +10 -0
  32. package/dist/Tooltip.d.ts.map +1 -0
  33. package/dist/Tooltip.js +104 -0
  34. package/dist/Tooltip.js.map +1 -0
  35. package/dist/ViewSwitcher.d.ts +1 -1
  36. package/dist/ViewSwitcher.d.ts.map +1 -1
  37. package/dist/ViewSwitcher.js +10 -4
  38. package/dist/ViewSwitcher.js.map +1 -1
  39. package/dist/WysiwygEditor.d.ts +13 -2
  40. package/dist/WysiwygEditor.d.ts.map +1 -1
  41. package/dist/WysiwygEditor.js +239 -4
  42. package/dist/WysiwygEditor.js.map +1 -1
  43. package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
  44. package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
  45. package/dist/__tests__/detectMarkdown.test.js +69 -0
  46. package/dist/__tests__/detectMarkdown.test.js.map +1 -0
  47. package/dist/__tests__/fileKind.test.d.ts +2 -0
  48. package/dist/__tests__/fileKind.test.d.ts.map +1 -0
  49. package/dist/__tests__/fileKind.test.js +81 -0
  50. package/dist/__tests__/fileKind.test.js.map +1 -0
  51. package/dist/__tests__/mediaAttachmentFlow.test.d.ts +2 -0
  52. package/dist/__tests__/mediaAttachmentFlow.test.d.ts.map +1 -0
  53. package/dist/__tests__/mediaAttachmentFlow.test.js +99 -0
  54. package/dist/__tests__/mediaAttachmentFlow.test.js.map +1 -0
  55. package/dist/__tests__/tiptapBridge.test.js +49 -0
  56. package/dist/__tests__/tiptapBridge.test.js.map +1 -1
  57. package/dist/__tests__/tiptapImageRoundTrip.test.d.ts +2 -0
  58. package/dist/__tests__/tiptapImageRoundTrip.test.d.ts.map +1 -0
  59. package/dist/__tests__/tiptapImageRoundTrip.test.js +68 -0
  60. package/dist/__tests__/tiptapImageRoundTrip.test.js.map +1 -0
  61. package/dist/detectMarkdown.d.ts +20 -0
  62. package/dist/detectMarkdown.d.ts.map +1 -0
  63. package/dist/detectMarkdown.js +61 -0
  64. package/dist/detectMarkdown.js.map +1 -0
  65. package/dist/fileKind.d.ts +30 -0
  66. package/dist/fileKind.d.ts.map +1 -0
  67. package/dist/fileKind.js +123 -0
  68. package/dist/fileKind.js.map +1 -0
  69. package/dist/hooks/useFileDrop.d.ts.map +1 -1
  70. package/dist/hooks/useFileDrop.js +9 -7
  71. package/dist/hooks/useFileDrop.js.map +1 -1
  72. package/dist/index.d.ts +4 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +4 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/mediaDragMime.d.ts +17 -0
  77. package/dist/mediaDragMime.d.ts.map +1 -0
  78. package/dist/mediaDragMime.js +22 -0
  79. package/dist/mediaDragMime.js.map +1 -0
  80. package/dist/tiptapBridge.d.ts.map +1 -1
  81. package/dist/tiptapBridge.js +99 -6
  82. package/dist/tiptapBridge.js.map +1 -1
  83. package/package.json +9 -7
  84. package/src/EditorContext.tsx +106 -3
  85. package/src/EditorShell.tsx +313 -21
  86. package/src/ImageNodeView.tsx +15 -2
  87. package/src/MediaBin.tsx +45 -4
  88. package/src/MentionExtension.tsx +258 -0
  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__/mediaAttachmentFlow.test.ts +110 -0
  98. package/src/__tests__/tiptapBridge.test.ts +58 -0
  99. package/src/__tests__/tiptapImageRoundTrip.test.ts +73 -0
  100. package/src/detectMarkdown.ts +62 -0
  101. package/src/fileKind.ts +134 -0
  102. package/src/hooks/useFileDrop.ts +10 -6
  103. package/src/index.ts +11 -0
  104. package/src/mediaDragMime.ts +32 -0
  105. package/src/styles/editor.css +214 -8
  106. package/src/tiptapBridge.ts +107 -6
@@ -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
 
@@ -47,6 +54,17 @@ export interface EditorShellProps {
47
54
  className?: string;
48
55
  /** CSS height for the shell container (default: '100vh') */
49
56
  height?: string;
57
+ /**
58
+ * Minimum CSS height for the shell. When either `minHeight` or
59
+ * `maxHeight` is set, the shell switches to **auto-grow mode**:
60
+ * `height` is ignored, the root becomes `height: auto` between the
61
+ * bounds, and the content area scrolls internally when content
62
+ * exceeds `maxHeight`. Useful for chat composers that should grow
63
+ * with content up to some cap.
64
+ */
65
+ minHeight?: string;
66
+ /** See `minHeight`. Upper bound of the auto-grow range. */
67
+ maxHeight?: string;
50
68
  /** Optional MediaProvider for the Files panel. When set (even to null), a Files toggle appears in the toolbar. */
51
69
  mediaProvider?: MediaProvider | null;
52
70
  /** Optional ContentContainer for audio mapping (MP3 discovery + timing.json reading). */
@@ -59,6 +77,105 @@ export interface EditorShellProps {
59
77
  toolbarSlotAfterActions?: ReactNode;
60
78
  /** Content rendered at the rightmost end of the toolbar, after all other elements. */
61
79
  toolbarSlotRight?: ReactNode;
80
+ /**
81
+ * Whether to show the "Play" (preview) tab in the toolbar. When false, the
82
+ * tab and its preview panel are hidden, and ⌘3 becomes a no-op. Use this
83
+ * when embedding the editor somewhere the slideshow preview doesn't make
84
+ * sense (e.g. editing free-form prompt documents). Defaults to true.
85
+ */
86
+ showPlayTab?: boolean;
87
+ /**
88
+ * Optional "submit on Enter" callback. When provided, a plain Enter
89
+ * keypress fires this callback instead of inserting a newline, and
90
+ * Cmd/Ctrl+Enter inserts a newline instead. Matches chat-composer UX
91
+ * (Slack, Discord). When omitted, the editor behaves normally.
92
+ */
93
+ submitOnEnter?: () => void;
94
+ /**
95
+ * Let the WYSIWYG editing surface fill its container instead of rendering
96
+ * as a centered 800px "page" column. Useful when embedding in chat
97
+ * composers, side panels, or any layout where the page metaphor doesn't
98
+ * fit. Defaults to false (page mode).
99
+ */
100
+ fullWidth?: boolean;
101
+ /**
102
+ * Font-family stack applied to the editor **chrome** — toolbar buttons,
103
+ * tabs, status bar, and control surfaces. The actual editing areas
104
+ * (Tiptap / Monaco) keep their own fonts so document editing isn't
105
+ * affected. Use this when the editor is embedded in a larger product
106
+ * that has its own UX type system and you want the controls to blend in.
107
+ *
108
+ * @example
109
+ * ```tsx
110
+ * <EditorShell uxFont="'Hanken Grotesk', system-ui, sans-serif" ... />
111
+ * ```
112
+ */
113
+ uxFont?: string;
114
+ /**
115
+ * Drop the editor's generous page-style padding in favor of a tight
116
+ * layout that hugs its container. The default WYSIWYG surface uses
117
+ * 16×24px padding suitable for editing long-form documents; chat
118
+ * composers want much less. Applies to the editing area only — the
119
+ * toolbar, tabs, and status bar keep their normal sizing.
120
+ */
121
+ thinMargins?: boolean;
122
+ /**
123
+ * Render the bottom status bar (word / character / line / block counts
124
+ * and parse-state indicator). Defaults to `true`. Set to `false` in
125
+ * embedded surfaces — chat composers and other short-form inputs —
126
+ * where the stats are noise.
127
+ */
128
+ showStatusBar?: boolean;
129
+ /**
130
+ * How images should be displayed in the WYSIWYG view. `'inline'`
131
+ * (default) flows them at natural size up to the container width;
132
+ * `'thumbnail'` constrains each image to a 100×100 box with
133
+ * aspect-preserving containment — useful for chat composers and other
134
+ * dense surfaces where a full-resolution paste would dominate the
135
+ * layout. Storage bytes are unchanged either way.
136
+ */
137
+ imageDisplayMode?: ImageDisplayMode;
138
+ /**
139
+ * File name (e.g. `foo.ts`) or bare extension that the content
140
+ * represents. When set to a non-markdown/text extension, the shell
141
+ * enters **code mode**: Monaco picks the right language based on the
142
+ * extension, the WYSIWYG and Preview tabs disappear, and the toolbar
143
+ * drops its markdown-specific formatting buttons. Markdown-ish
144
+ * extensions (`.md`, `.markdown`, `.mdown`, `.txt`) keep the full
145
+ * experience. Omit to get today's markdown behavior unchanged.
146
+ */
147
+ fileName?: string;
148
+ /**
149
+ * Explicit Monaco language ID override (e.g. `'typescript'`,
150
+ * `'python'`, `'json'`). Wins over the language derived from
151
+ * `fileName`. Anything other than `'markdown'` or `'plaintext'`
152
+ * switches the shell into code mode.
153
+ */
154
+ language?: string;
155
+ /**
156
+ * Optional async provider for `@`-mention suggestions. When supplied,
157
+ * typing `@` inside the editor opens a popover of candidates; selecting
158
+ * one inserts a `@[Label](scheme:id)` mention token. Used by chat
159
+ * composers and any other surface that wants to address named entities
160
+ * inline. Omit to disable mentions entirely.
161
+ */
162
+ mentionProvider?: MentionProvider | null;
163
+ /**
164
+ * Placeholder text shown in the WYSIWYG editor while the document is
165
+ * empty. When omitted, the editor rotates through its own generic
166
+ * "start typing…" prompts; pass a value here to override with copy
167
+ * that fits the embedding surface (e.g. a chat composer knows who
168
+ * the message is going to and can say so).
169
+ */
170
+ placeholder?: string;
171
+ /**
172
+ * When true, both editing surfaces become non-editable: Monaco runs in
173
+ * `readOnly` mode and Tiptap is set to `editable: false`. The toolbar
174
+ * still renders — hide it from the host side if you want a pure preview.
175
+ * Useful for reference panels that show file content without inviting
176
+ * accidental edits.
177
+ */
178
+ readOnly?: boolean;
62
179
  }
63
180
 
64
181
  /**
@@ -74,35 +191,68 @@ export function EditorShell({
74
191
  theme = 'light',
75
192
  className,
76
193
  height = '100vh',
194
+ minHeight,
195
+ maxHeight,
77
196
  mediaProvider,
78
197
  container,
79
198
  showFilesToggle,
80
199
  toolbarSlotLeft,
81
200
  toolbarSlotAfterActions,
82
201
  toolbarSlotRight,
202
+ showPlayTab = true,
203
+ submitOnEnter,
204
+ fullWidth = false,
205
+ uxFont,
206
+ thinMargins = false,
207
+ showStatusBar = true,
208
+ imageDisplayMode = 'inline',
209
+ fileName,
210
+ language,
211
+ mentionProvider,
212
+ placeholder,
213
+ readOnly = false,
83
214
  }: EditorShellProps) {
84
215
  // Show the toggle when explicitly opted in, or when mediaProvider prop was passed at all
85
216
  const filesToggleEnabled = showFilesToggle ?? mediaProvider !== undefined;
86
217
 
218
+ // If the host hides the Play tab but asked for it as the initial view,
219
+ // fall back to wysiwyg so we don't boot into a tab the user can't leave.
220
+ const effectiveInitialView: EditorView =
221
+ !showPlayTab && initialView === 'preview' ? 'wysiwyg' : initialView;
222
+
87
223
  return (
88
224
  <EditorProvider
89
225
  initialMarkdown={initialMarkdown}
90
- initialView={initialView}
226
+ initialView={effectiveInitialView}
91
227
  articleId={articleId}
92
228
  theme={theme}
93
229
  mediaProvider={mediaProvider}
230
+ imageDisplayMode={imageDisplayMode}
231
+ mentionProvider={mentionProvider}
232
+ fileName={fileName}
233
+ language={language}
94
234
  >
95
235
  <EditorShellInner
96
236
  basePath={basePath}
97
237
  onChange={onChange}
98
238
  className={className}
99
239
  height={height}
240
+ minHeight={minHeight}
241
+ maxHeight={maxHeight}
242
+ placeholder={placeholder}
100
243
  mediaProvider={mediaProvider ?? null}
101
244
  container={container}
102
245
  filesToggleEnabled={filesToggleEnabled}
103
246
  toolbarSlotLeft={toolbarSlotLeft}
104
247
  toolbarSlotAfterActions={toolbarSlotAfterActions}
105
248
  toolbarSlotRight={toolbarSlotRight}
249
+ showPlayTab={showPlayTab}
250
+ submitOnEnter={submitOnEnter}
251
+ fullWidth={fullWidth}
252
+ uxFont={uxFont}
253
+ thinMargins={thinMargins}
254
+ showStatusBar={showStatusBar}
255
+ readOnly={readOnly}
106
256
  />
107
257
  </EditorProvider>
108
258
  );
@@ -113,12 +263,22 @@ interface EditorShellInnerProps {
113
263
  onChange?: (source: string) => void;
114
264
  className?: string;
115
265
  height: string;
266
+ minHeight?: string;
267
+ maxHeight?: string;
268
+ placeholder?: string;
116
269
  mediaProvider: MediaProvider | null;
117
270
  container?: ContentContainer | null;
118
271
  filesToggleEnabled: boolean;
119
272
  toolbarSlotLeft?: ReactNode;
120
273
  toolbarSlotAfterActions?: ReactNode;
121
274
  toolbarSlotRight?: ReactNode;
275
+ showPlayTab: boolean;
276
+ submitOnEnter?: () => void;
277
+ fullWidth: boolean;
278
+ uxFont?: string;
279
+ thinMargins: boolean;
280
+ showStatusBar: boolean;
281
+ readOnly: boolean;
122
282
  }
123
283
 
124
284
  function EditorShellInner({
@@ -126,15 +286,37 @@ function EditorShellInner({
126
286
  onChange,
127
287
  className,
128
288
  height,
289
+ minHeight,
290
+ maxHeight,
291
+ placeholder,
129
292
  mediaProvider,
130
293
  container,
131
294
  filesToggleEnabled,
132
295
  toolbarSlotLeft,
133
296
  toolbarSlotAfterActions,
134
297
  toolbarSlotRight,
298
+ showPlayTab,
299
+ submitOnEnter,
300
+ fullWidth,
301
+ uxFont,
302
+ thinMargins,
303
+ showStatusBar,
304
+ readOnly,
135
305
  }: EditorShellInnerProps) {
136
- const { activeView, markdownSource, doc, theme, insertAtCursor, replaceAll } = useEditorContext();
306
+ const {
307
+ activeView,
308
+ markdownSource,
309
+ doc,
310
+ theme,
311
+ editorMode,
312
+ insertAtCursor,
313
+ replaceAll,
314
+ tiptapEditor,
315
+ monacoEditor,
316
+ setMarkdownSource,
317
+ } = useEditorContext();
137
318
  const isPreview = activeView === 'preview';
319
+ const isCodeMode = editorMode === 'code';
138
320
  const [showFiles, setShowFiles] = useState(false);
139
321
  const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
140
322
  const isDark = theme === 'dark';
@@ -145,6 +327,57 @@ function EditorShellInner({
145
327
 
146
328
  // ── Drag-and-drop file handling ──
147
329
 
330
+ /**
331
+ * Insert an uploaded media file at the editor's current cursor.
332
+ *
333
+ * - In **WYSIWYG** mode, insert an actual tiptap image node via
334
+ * `setImage({src, alt})` (images) or a link mark (non-images).
335
+ * Going through `setImage` directly mirrors the Toolbar's image
336
+ * button and avoids the round-trip through `markdownToTiptap`
337
+ * that historically lost `<img>` tags to tag-strip passes.
338
+ * - In **raw (Monaco)** or **preview** mode, fall back to
339
+ * `insertAtCursor` which emits the markdown snippet.
340
+ *
341
+ * Without this, upload-via-MediaBin and upload-via-drop both
342
+ * added the file to the bin and nowhere else — the composer sent
343
+ * an empty body and the downstream gezel reported "nothing came
344
+ * through."
345
+ */
346
+ const insertMediaRef = useCallback(
347
+ (relativePath: string, name: string, mimeType: string) => {
348
+ const alt = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
349
+ const isImage = mimeType.startsWith('image/');
350
+ const snippet = isImage ? `![${alt}](${relativePath})` : `[${alt}](${relativePath})`;
351
+
352
+ if (activeView === 'wysiwyg' && tiptapEditor) {
353
+ if (isImage) {
354
+ tiptapEditor.chain().focus().setImage({ src: relativePath, alt }).run();
355
+ } else {
356
+ tiptapEditor
357
+ .chain()
358
+ .focus()
359
+ .insertContent([
360
+ {
361
+ type: 'text',
362
+ marks: [{ type: 'link', attrs: { href: relativePath } }],
363
+ text: alt,
364
+ },
365
+ ])
366
+ .run();
367
+ }
368
+ return;
369
+ }
370
+ if (activeView === 'raw' && monacoEditor) {
371
+ insertAtCursor(snippet);
372
+ return;
373
+ }
374
+ // Preview mode — no interactive editor to insert into. Append
375
+ // to markdown source so the ref is still in the buffer.
376
+ setMarkdownSource(markdownSource ? `${markdownSource}\n\n${snippet}` : snippet);
377
+ },
378
+ [activeView, tiptapEditor, monacoEditor, insertAtCursor, markdownSource, setMarkdownSource],
379
+ );
380
+
148
381
  const handleFileDrop = useCallback(
149
382
  async (files: File[], target: DropTarget) => {
150
383
  try {
@@ -152,10 +385,21 @@ function EditorShellInner({
152
385
 
153
386
  // Process media files
154
387
  if (media.length > 0 && mediaProvider) {
155
- await processMediaFiles(media, mediaProvider);
388
+ const paths = await processMediaFiles(media, mediaProvider);
156
389
  setMediaRefreshKey((k) => k + 1);
157
390
  // Auto-open the media bin so the user sees the new files
158
391
  if (!showFiles) setShowFiles(true);
392
+ // Insert each uploaded file as a markdown ref at the cursor so
393
+ // the body actually contains the attachment. Without this the
394
+ // bin holds the file but the serialized markdown stays empty,
395
+ // and anything downstream (chat send, document save) sees no
396
+ // reference to the upload.
397
+ for (let i = 0; i < media.length; i++) {
398
+ const file = media[i];
399
+ const path = paths[i];
400
+ if (!file || !path) continue;
401
+ insertMediaRef(path, file.name, file.type || 'application/octet-stream');
402
+ }
159
403
  }
160
404
 
161
405
  // Process text files
@@ -174,7 +418,7 @@ function EditorShellInner({
174
418
  console.error('Failed to process dropped files:', err instanceof Error ? err.message : err);
175
419
  }
176
420
  },
177
- [mediaProvider, showFiles, replaceAll, insertAtCursor],
421
+ [mediaProvider, showFiles, replaceAll, insertAtCursor, insertMediaRef],
178
422
  );
179
423
 
180
424
  const { isDragging, dragContentType, containerProps, zoneProps } = useFileDrop({
@@ -200,6 +444,7 @@ function EditorShellInner({
200
444
  document.querySelector<HTMLButtonElement>('[data-view="raw"]')?.click();
201
445
  break;
202
446
  case '3':
447
+ if (!showPlayTab) return;
203
448
  e.preventDefault();
204
449
  document.querySelector<HTMLButtonElement>('[data-view="preview"]')?.click();
205
450
  break;
@@ -208,17 +453,26 @@ function EditorShellInner({
208
453
  };
209
454
  window.addEventListener('keydown', handler);
210
455
  return () => window.removeEventListener('keydown', handler);
211
- }, []);
456
+ }, [showPlayTab]);
457
+
458
+ const autoGrow = minHeight !== undefined || maxHeight !== undefined;
212
459
 
213
460
  return (
214
461
  <div
215
462
  className={`squisq-editor-shell ${className || ''}`}
216
463
  data-theme={theme}
464
+ data-full-width={fullWidth ? 'true' : undefined}
465
+ data-thin-margins={thinMargins ? 'true' : undefined}
217
466
  style={{
218
467
  display: 'flex',
219
468
  flexDirection: 'column',
220
- height,
221
469
  overflow: 'hidden',
470
+ ...(autoGrow ? { minHeight, maxHeight } : { height }),
471
+ // When a consumer supplies a UX font stack, expose it to the
472
+ // editor CSS via this custom property. Chrome elements (toolbar,
473
+ // tabs, status bar) consume `--squisq-ux-font` as their
474
+ // `font-family`, falling back to the system stack when unset.
475
+ ...(uxFont ? ({ '--squisq-ux-font': uxFont } as CSSProperties) : {}),
222
476
  }}
223
477
  {...containerProps}
224
478
  >
@@ -227,35 +481,70 @@ function EditorShellInner({
227
481
  <div className="squisq-editor-header">
228
482
  <Toolbar
229
483
  showFiles={showFiles}
230
- onToggleFiles={filesToggleEnabled ? handleToggleFiles : undefined}
484
+ onToggleFiles={!isCodeMode && filesToggleEnabled ? handleToggleFiles : undefined}
231
485
  slotLeft={toolbarSlotLeft}
232
486
  slotAfterActions={
233
487
  <>
234
488
  {toolbarSlotAfterActions}
235
- {isPreview && <PreviewToolbarControls />}
489
+ {!isCodeMode && isPreview && <PreviewToolbarControls />}
236
490
  </>
237
491
  }
238
492
  slotRight={toolbarSlotRight}
493
+ showPlayTab={showPlayTab}
239
494
  />
240
495
  </div>
241
496
 
242
497
  {/* Main content area */}
243
498
  <div
244
499
  className="squisq-editor-content"
245
- style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex' }}
500
+ style={{
501
+ flex: autoGrow ? '1 1 auto' : 1,
502
+ overflowY: autoGrow ? 'auto' : 'hidden',
503
+ overflowX: 'hidden',
504
+ minHeight: 0,
505
+ position: 'relative',
506
+ display: 'flex',
507
+ }}
246
508
  >
247
- <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} />}
509
+ <div
510
+ style={{
511
+ flex: autoGrow ? '1 1 auto' : 1,
512
+ overflow: autoGrow ? 'visible' : 'hidden',
513
+ minHeight: 0,
514
+ position: 'relative',
515
+ }}
516
+ >
517
+ {activeView === 'raw' && (
518
+ <RawEditor
519
+ theme={theme === 'dark' ? 'vs-dark' : 'vs'}
520
+ submitOnEnter={submitOnEnter}
521
+ readOnly={readOnly}
522
+ />
523
+ )}
524
+ {/* WYSIWYG + Preview are markdown-only surfaces — skip them
525
+ entirely in code mode so Tiptap never initializes and the
526
+ preview pipeline stays idle. */}
527
+ {!isCodeMode && activeView === 'wysiwyg' && (
528
+ <WysiwygEditor
529
+ submitOnEnter={submitOnEnter}
530
+ placeholder={placeholder}
531
+ readOnly={readOnly}
532
+ />
533
+ )}
534
+ {!isCodeMode && isPreview && <PreviewPanel basePath={basePath} container={container} />}
251
535
  </div>
252
536
 
253
- {showFiles && (
254
- <MediaBin mediaProvider={mediaProvider} isDark={isDark} refreshKey={mediaRefreshKey} />
537
+ {!isCodeMode && showFiles && (
538
+ <MediaBin
539
+ mediaProvider={mediaProvider}
540
+ isDark={isDark}
541
+ refreshKey={mediaRefreshKey}
542
+ onMediaUploaded={insertMediaRef}
543
+ />
255
544
  )}
256
545
 
257
- {/* Drop zone overlay */}
258
- {isDragging && (
546
+ {/* Drop zone overlay — image / text drop UX is markdown-specific. */}
547
+ {!isCodeMode && isDragging && (
259
548
  <DropZoneOverlay
260
549
  dragContentType={dragContentType}
261
550
  zoneProps={zoneProps}
@@ -264,9 +553,12 @@ function EditorShellInner({
264
553
  )}
265
554
  </div>
266
555
 
267
- {/* Status bar */}
268
- <StatusBar />
556
+ {/* Status bar — word / char / line / block counts. Host can
557
+ suppress via `showStatusBar={false}` for embedded chat-style
558
+ composers where the stats are noise. */}
559
+ {showStatusBar && <StatusBar />}
269
560
  </PreviewSettingsProvider>
561
+ <TooltipLayer />
270
562
  </div>
271
563
  );
272
564
  }
@@ -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
@@ -20,6 +21,17 @@ export interface MediaBinProps {
20
21
  isDark: boolean;
21
22
  /** Incremented externally to signal a re-scan of the media list */
22
23
  refreshKey?: number;
24
+ /**
25
+ * Fired after a successful upload via the MediaBin's own "+ Upload"
26
+ * button. `relativePath` is what the provider returned (the same
27
+ * value embedded in markdown refs, e.g. `attachments/xyz.png`);
28
+ * `name` is the uploader-chosen filename before storage renamed
29
+ * it. Consumers typically use this to insert a markdown image ref
30
+ * at the editor's cursor so the file actually participates in the
31
+ * outgoing message — previously files went to the bin and nowhere
32
+ * else, leaving the message body empty when the user hit Send.
33
+ */
34
+ onMediaUploaded?: (relativePath: string, name: string, mimeType: string) => void | Promise<void>;
23
35
  }
24
36
 
25
37
  // ============================================
@@ -49,7 +61,7 @@ function isImageMime(mimeType: string): boolean {
49
61
  // Component
50
62
  // ============================================
51
63
 
52
- export function MediaBin({ mediaProvider, isDark, refreshKey }: MediaBinProps) {
64
+ export function MediaBin({ mediaProvider, isDark, refreshKey, onMediaUploaded }: MediaBinProps) {
53
65
  const [entries, setEntries] = useState<MediaEntry[]>([]);
54
66
  const [thumbUrls, setThumbUrls] = useState<Record<string, string>>({});
55
67
  const [loading, setLoading] = useState(false);
@@ -116,9 +128,17 @@ export function MediaBin({ mediaProvider, isDark, refreshKey }: MediaBinProps) {
116
128
  try {
117
129
  for (let i = 0; i < files.length; i++) {
118
130
  const file = files[i];
131
+ if (!file) continue;
119
132
  const buffer = await file.arrayBuffer();
120
133
  const mimeType = file.type || 'application/octet-stream';
121
- await mediaProvider.addMedia(file.name, buffer, mimeType);
134
+ const relativePath = await mediaProvider.addMedia(file.name, buffer, mimeType);
135
+ if (onMediaUploaded) {
136
+ try {
137
+ await onMediaUploaded(relativePath, file.name, mimeType);
138
+ } catch {
139
+ /* callback is a nice-to-have; don't abort the upload batch */
140
+ }
141
+ }
122
142
  }
123
143
  // Re-scan
124
144
  const list = await mediaProvider.listMedia();
@@ -146,7 +166,7 @@ export function MediaBin({ mediaProvider, isDark, refreshKey }: MediaBinProps) {
146
166
  if (fileInputRef.current) fileInputRef.current.value = '';
147
167
  }
148
168
  },
149
- [mediaProvider],
169
+ [mediaProvider, onMediaUploaded],
150
170
  );
151
171
 
152
172
  return (
@@ -186,16 +206,37 @@ export function MediaBin({ mediaProvider, isDark, refreshKey }: MediaBinProps) {
186
206
  {entries.map((entry) => {
187
207
  const thumb = thumbUrls[entry.name];
188
208
  const basename = entry.name.includes('/') ? entry.name.split('/').pop()! : entry.name;
209
+ const isImage = isImageMime(entry.mimeType);
210
+ const altText = basename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
211
+
212
+ const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
213
+ if (!isImage) return;
214
+ const payload = JSON.stringify({
215
+ name: entry.name,
216
+ mimeType: entry.mimeType,
217
+ alt: altText,
218
+ });
219
+ e.dataTransfer.setData(SQUISQ_MEDIA_MIME, payload);
220
+ e.dataTransfer.setData('text/plain', `![${altText}](${entry.name})`);
221
+ e.dataTransfer.effectAllowed = 'copy';
222
+ };
189
223
 
190
224
  return (
191
225
  <div
192
226
  key={entry.name}
193
227
  className="squisq-media-bin-item"
194
228
  title={`${entry.name}\n${entry.mimeType}\n${formatSize(entry.size)}`}
229
+ draggable={isImage}
230
+ onDragStart={handleDragStart}
195
231
  >
196
232
  {/* Thumbnail or icon */}
197
233
  {thumb ? (
198
- <img src={thumb} alt={basename} className="squisq-media-bin-thumb" />
234
+ <img
235
+ src={thumb}
236
+ alt={basename}
237
+ className="squisq-media-bin-thumb"
238
+ draggable={false}
239
+ />
199
240
  ) : (
200
241
  <span className="squisq-media-bin-icon">{iconForMime(entry.mimeType)}</span>
201
242
  )}