@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.
- 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 +101 -2
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +20 -8
- 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.map +1 -1
- package/dist/MediaBin.js +16 -1
- 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/PreviewPanel.d.ts +3 -8
- package/dist/PreviewPanel.d.ts.map +1 -1
- package/dist/PreviewPanel.js +4 -282
- package/dist/PreviewPanel.js.map +1 -1
- 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__/tiptapBridge.test.js +36 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -1
- package/dist/buildPreviewDoc.d.ts +22 -0
- package/dist/buildPreviewDoc.d.ts.map +1 -0
- package/dist/buildPreviewDoc.js +212 -0
- package/dist/buildPreviewDoc.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 +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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 +58 -2
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +9 -7
- package/src/EditorContext.tsx +106 -3
- package/src/EditorShell.tsx +195 -15
- package/src/ImageNodeView.tsx +15 -2
- package/src/MediaBin.tsx +23 -1
- package/src/MentionExtension.tsx +258 -0
- package/src/PreviewPanel.tsx +5 -333
- 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__/tiptapBridge.test.ts +44 -0
- package/src/buildPreviewDoc.ts +254 -0
- package/src/detectMarkdown.ts +62 -0
- package/src/fileKind.ts +134 -0
- package/src/hooks/useFileDrop.ts +10 -6
- package/src/index.ts +14 -0
- package/src/mediaDragMime.ts +32 -0
- package/src/styles/editor.css +214 -8
- package/src/tiptapBridge.ts +66 -2
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
|
|
|
@@ -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={
|
|
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 } =
|
|
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' &&
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
}
|
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
|
|
@@ -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', ``);
|
|
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
|
|
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
|
+
}
|