@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.
- package/dist/EditorContext.d.ts +65 -1
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +31 -4
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +112 -2
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +95 -11
- package/dist/EditorShell.js.map +1 -1
- package/dist/ImageNodeView.d.ts.map +1 -1
- package/dist/ImageNodeView.js +12 -2
- package/dist/ImageNodeView.js.map +1 -1
- package/dist/MediaBin.d.ts +12 -1
- package/dist/MediaBin.d.ts.map +1 -1
- package/dist/MediaBin.js +29 -4
- package/dist/MediaBin.js.map +1 -1
- package/dist/MentionExtension.d.ts +22 -0
- package/dist/MentionExtension.d.ts.map +1 -0
- package/dist/MentionExtension.js +242 -0
- package/dist/MentionExtension.js.map +1 -0
- package/dist/RawEditor.d.ts +8 -1
- package/dist/RawEditor.d.ts.map +1 -1
- package/dist/RawEditor.js +167 -30
- package/dist/RawEditor.js.map +1 -1
- package/dist/TemplateAnnotation.d.ts.map +1 -1
- package/dist/TemplateAnnotation.js +4 -2
- package/dist/TemplateAnnotation.js.map +1 -1
- package/dist/Toolbar.d.ts +7 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +57 -18
- package/dist/Toolbar.js.map +1 -1
- package/dist/Tooltip.d.ts +10 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip.js +104 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/ViewSwitcher.d.ts +1 -1
- package/dist/ViewSwitcher.d.ts.map +1 -1
- package/dist/ViewSwitcher.js +10 -4
- package/dist/ViewSwitcher.js.map +1 -1
- package/dist/WysiwygEditor.d.ts +13 -2
- package/dist/WysiwygEditor.d.ts.map +1 -1
- package/dist/WysiwygEditor.js +239 -4
- package/dist/WysiwygEditor.js.map +1 -1
- package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
- package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
- package/dist/__tests__/detectMarkdown.test.js +69 -0
- package/dist/__tests__/detectMarkdown.test.js.map +1 -0
- package/dist/__tests__/fileKind.test.d.ts +2 -0
- package/dist/__tests__/fileKind.test.d.ts.map +1 -0
- package/dist/__tests__/fileKind.test.js +81 -0
- package/dist/__tests__/fileKind.test.js.map +1 -0
- package/dist/__tests__/mediaAttachmentFlow.test.d.ts +2 -0
- package/dist/__tests__/mediaAttachmentFlow.test.d.ts.map +1 -0
- package/dist/__tests__/mediaAttachmentFlow.test.js +99 -0
- package/dist/__tests__/mediaAttachmentFlow.test.js.map +1 -0
- package/dist/__tests__/tiptapBridge.test.js +49 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -1
- package/dist/__tests__/tiptapImageRoundTrip.test.d.ts +2 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.d.ts.map +1 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.js +68 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.js.map +1 -0
- package/dist/detectMarkdown.d.ts +20 -0
- package/dist/detectMarkdown.d.ts.map +1 -0
- package/dist/detectMarkdown.js +61 -0
- package/dist/detectMarkdown.js.map +1 -0
- package/dist/fileKind.d.ts +30 -0
- package/dist/fileKind.d.ts.map +1 -0
- package/dist/fileKind.js +123 -0
- package/dist/fileKind.js.map +1 -0
- package/dist/hooks/useFileDrop.d.ts.map +1 -1
- package/dist/hooks/useFileDrop.js +9 -7
- package/dist/hooks/useFileDrop.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mediaDragMime.d.ts +17 -0
- package/dist/mediaDragMime.d.ts.map +1 -0
- package/dist/mediaDragMime.js +22 -0
- package/dist/mediaDragMime.js.map +1 -0
- package/dist/tiptapBridge.d.ts.map +1 -1
- package/dist/tiptapBridge.js +99 -6
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +9 -7
- package/src/EditorContext.tsx +106 -3
- package/src/EditorShell.tsx +313 -21
- package/src/ImageNodeView.tsx +15 -2
- package/src/MediaBin.tsx +45 -4
- package/src/MentionExtension.tsx +258 -0
- package/src/RawEditor.tsx +193 -37
- package/src/TemplateAnnotation.ts +4 -2
- package/src/Toolbar.tsx +111 -48
- package/src/Tooltip.tsx +124 -0
- package/src/ViewSwitcher.tsx +15 -5
- package/src/WysiwygEditor.tsx +270 -5
- package/src/__tests__/detectMarkdown.test.ts +88 -0
- package/src/__tests__/fileKind.test.ts +96 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +110 -0
- package/src/__tests__/tiptapBridge.test.ts +58 -0
- package/src/__tests__/tiptapImageRoundTrip.test.ts +73 -0
- package/src/detectMarkdown.ts +62 -0
- package/src/fileKind.ts +134 -0
- package/src/hooks/useFileDrop.ts +10 -6
- package/src/index.ts +11 -0
- package/src/mediaDragMime.ts +32 -0
- package/src/styles/editor.css +214 -8
- package/src/tiptapBridge.ts +107 -6
package/src/EditorShell.tsx
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useEffect, useState, useCallback } from 'react';
|
|
10
|
-
import {
|
|
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={
|
|
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 {
|
|
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})`;
|
|
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={{
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/ImageNodeView.tsx
CHANGED
|
@@ -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
|
-
|
|
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', ``);
|
|
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
|
|
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
|
)}
|