@bendyline/squisq-editor-react 1.2.2 → 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/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/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 +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/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/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 +66 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-editor-react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "React editor shell with raw/WYSIWYG/preview modes for Squisq documents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bendyline",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
],
|
|
32
32
|
"exports": {
|
|
33
33
|
".": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js"
|
|
36
36
|
},
|
|
37
37
|
"./styles": "./src/styles/editor.css"
|
|
38
38
|
},
|
|
@@ -46,11 +46,12 @@
|
|
|
46
46
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@bendyline/squisq": "1.
|
|
50
|
-
"@bendyline/squisq-formats": "1.2.
|
|
51
|
-
"@bendyline/squisq-react": "1.
|
|
49
|
+
"@bendyline/squisq": "1.3.0",
|
|
50
|
+
"@bendyline/squisq-formats": "1.2.3",
|
|
51
|
+
"@bendyline/squisq-react": "1.2.0",
|
|
52
52
|
"@monaco-editor/react": "4.7.0",
|
|
53
53
|
"@tiptap/extension-image": "2.27.2",
|
|
54
|
+
"@tiptap/extension-mention": "2.27.2",
|
|
54
55
|
"@tiptap/extension-placeholder": "2.27.2",
|
|
55
56
|
"@tiptap/extension-table": "2.27.2",
|
|
56
57
|
"@tiptap/extension-table-cell": "2.27.2",
|
|
@@ -60,7 +61,8 @@
|
|
|
60
61
|
"@tiptap/extension-task-list": "2.27.2",
|
|
61
62
|
"@tiptap/pm": "2.27.2",
|
|
62
63
|
"@tiptap/react": "2.27.2",
|
|
63
|
-
"@tiptap/starter-kit": "2.27.2"
|
|
64
|
+
"@tiptap/starter-kit": "2.27.2",
|
|
65
|
+
"@tiptap/suggestion": "2.27.2"
|
|
64
66
|
},
|
|
65
67
|
"devDependencies": {
|
|
66
68
|
"@types/react": "18.3.28",
|
package/src/EditorContext.tsx
CHANGED
|
@@ -24,14 +24,47 @@ import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
|
24
24
|
import type { Editor as TiptapEditor } from '@tiptap/core';
|
|
25
25
|
import type { editor as MonacoEditorNs } from 'monaco-editor';
|
|
26
26
|
import { markdownToTiptap } from './tiptapBridge';
|
|
27
|
+
import { resolveFileKind } from './fileKind';
|
|
27
28
|
|
|
28
29
|
/** Monaco standalone code editor instance type */
|
|
29
30
|
type MonacoEditor = MonacoEditorNs.IStandaloneCodeEditor;
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* One candidate returned by a {@link MentionProvider}. Shown in the editor's
|
|
34
|
+
* `@` popover. `id` is the stable identifier (serialized into the mention
|
|
35
|
+
* wire format); `label` is what the reader sees; `scheme` is the namespace
|
|
36
|
+
* (e.g. `'user'`, `'issue'`) written into the markdown as `@[label](scheme:id)`;
|
|
37
|
+
* `description` and `group` are optional hints for richer suggestion UIs.
|
|
38
|
+
*
|
|
39
|
+
* Different candidates in the same result set may carry different schemes —
|
|
40
|
+
* a provider that returns both users and issues, for example, tags each
|
|
41
|
+
* candidate with its own namespace and the editor emits mentions accordingly.
|
|
42
|
+
*/
|
|
43
|
+
export interface MentionCandidate {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
scheme: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
group?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Looks up mention candidates matching a query. Called as the user types
|
|
53
|
+
* after `@`. The provider is free to do server-side or client-side filtering;
|
|
54
|
+
* the editor only cares that candidates come back in relevance order.
|
|
55
|
+
*/
|
|
56
|
+
export type MentionProvider = (query: string) => Promise<MentionCandidate[]>;
|
|
57
|
+
|
|
31
58
|
// ─── Types ───────────────────────────────────────────────
|
|
32
59
|
|
|
33
60
|
export type EditorView = 'raw' | 'wysiwyg' | 'preview';
|
|
34
61
|
export type EditorTheme = 'light' | 'dark';
|
|
62
|
+
/**
|
|
63
|
+
* Editor operating mode. `markdown` is the full experience (WYSIWYG +
|
|
64
|
+
* Preview tabs, formatting toolbar). `code` is a Monaco-only view used
|
|
65
|
+
* when the content represents a non-markdown file like `foo.ts`.
|
|
66
|
+
*/
|
|
67
|
+
export type EditorMode = 'markdown' | 'code';
|
|
35
68
|
|
|
36
69
|
export interface EditorState {
|
|
37
70
|
/** Raw markdown source string */
|
|
@@ -48,6 +81,10 @@ export interface EditorState {
|
|
|
48
81
|
isParsing: boolean;
|
|
49
82
|
/** Current color theme */
|
|
50
83
|
theme: EditorTheme;
|
|
84
|
+
/** Operating mode — 'markdown' for the full shell, 'code' for Monaco-only. */
|
|
85
|
+
editorMode: EditorMode;
|
|
86
|
+
/** Monaco language ID for the Raw editor. */
|
|
87
|
+
language: string;
|
|
51
88
|
}
|
|
52
89
|
|
|
53
90
|
export interface EditorActions {
|
|
@@ -76,8 +113,25 @@ export interface EditorContextValue extends EditorState, EditorActions {
|
|
|
76
113
|
monacoEditor: MonacoEditor | null;
|
|
77
114
|
/** MediaProvider for resolving image URLs in the WYSIWYG editor */
|
|
78
115
|
mediaProvider: MediaProvider | null;
|
|
116
|
+
/**
|
|
117
|
+
* How pasted/inserted images should be displayed in the WYSIWYG view.
|
|
118
|
+
* `'inline'` (default) lets them flow at natural size up to the editor
|
|
119
|
+
* width; `'thumbnail'` constrains them to a 100×100 box so chat
|
|
120
|
+
* composers and other dense surfaces don't get dominated by a single
|
|
121
|
+
* pasted screenshot. The stored image bytes are unchanged — this is a
|
|
122
|
+
* pure render-time decision.
|
|
123
|
+
*/
|
|
124
|
+
imageDisplayMode: ImageDisplayMode;
|
|
125
|
+
/**
|
|
126
|
+
* Optional provider for `@`-mention suggestions. When set, both the
|
|
127
|
+
* WYSIWYG (Tiptap) and Raw (Monaco) editors show a mention popover as
|
|
128
|
+
* the user types `@<query>`. When unset, `@` is just a literal character.
|
|
129
|
+
*/
|
|
130
|
+
mentionProvider: MentionProvider | null;
|
|
79
131
|
}
|
|
80
132
|
|
|
133
|
+
export type ImageDisplayMode = 'inline' | 'thumbnail';
|
|
134
|
+
|
|
81
135
|
// ─── Context ─────────────────────────────────────────────
|
|
82
136
|
|
|
83
137
|
const EditorContext = createContext<EditorContextValue | null>(null);
|
|
@@ -107,6 +161,20 @@ export interface EditorProviderProps {
|
|
|
107
161
|
theme?: EditorTheme;
|
|
108
162
|
/** MediaProvider for resolving image URLs */
|
|
109
163
|
mediaProvider?: MediaProvider | null;
|
|
164
|
+
/** Display mode for images in the WYSIWYG view. Defaults to `'inline'`. */
|
|
165
|
+
imageDisplayMode?: ImageDisplayMode;
|
|
166
|
+
/**
|
|
167
|
+
* Async provider for `@`-mention suggestions. Omit to disable mentions
|
|
168
|
+
* entirely — typing `@` becomes just a literal character again.
|
|
169
|
+
*/
|
|
170
|
+
mentionProvider?: MentionProvider | null;
|
|
171
|
+
/**
|
|
172
|
+
* File name (e.g. `foo.ts`) or bare extension — used to pick a Monaco
|
|
173
|
+
* language and decide between markdown vs. code mode.
|
|
174
|
+
*/
|
|
175
|
+
fileName?: string;
|
|
176
|
+
/** Explicit Monaco language ID — wins over the fileName-derived one. */
|
|
177
|
+
language?: string;
|
|
110
178
|
children: ReactNode;
|
|
111
179
|
}
|
|
112
180
|
|
|
@@ -120,12 +188,34 @@ export function EditorProvider({
|
|
|
120
188
|
articleId = 'untitled',
|
|
121
189
|
theme: initialTheme = 'light',
|
|
122
190
|
mediaProvider = null,
|
|
191
|
+
imageDisplayMode = 'inline',
|
|
192
|
+
mentionProvider = null,
|
|
193
|
+
fileName,
|
|
194
|
+
language,
|
|
123
195
|
children,
|
|
124
196
|
}: EditorProviderProps) {
|
|
197
|
+
// Resolve once per provider mount. Changing fileName/language after mount
|
|
198
|
+
// would require recreating the Monaco model anyway, so treat it as static.
|
|
199
|
+
const { mode: editorMode, language: resolvedLanguage } = useMemo(
|
|
200
|
+
() => resolveFileKind(fileName, language),
|
|
201
|
+
[fileName, language],
|
|
202
|
+
);
|
|
203
|
+
// In code mode, WYSIWYG and Preview aren't rendered — force the starting
|
|
204
|
+
// view to 'raw' so we don't boot into an unmounted surface.
|
|
125
205
|
const [markdownSource, setMarkdownSourceRaw] = useState(initialMarkdown);
|
|
126
206
|
const [markdownDoc, setMarkdownDocState] = useState<MarkdownDocument | null>(null);
|
|
127
207
|
const [doc, setDoc] = useState<Doc | null>(null);
|
|
128
|
-
const [activeView,
|
|
208
|
+
const [activeView, setActiveViewRaw] = useState<EditorView>(
|
|
209
|
+
editorMode === 'code' ? 'raw' : initialView,
|
|
210
|
+
);
|
|
211
|
+
const setActiveView = useCallback(
|
|
212
|
+
(view: EditorView) => {
|
|
213
|
+
// In code mode only the raw view is valid; ignore any other requests.
|
|
214
|
+
if (editorMode === 'code' && view !== 'raw') return;
|
|
215
|
+
setActiveViewRaw(view);
|
|
216
|
+
},
|
|
217
|
+
[editorMode],
|
|
218
|
+
);
|
|
129
219
|
const [parseError, setParseError] = useState<string | null>(null);
|
|
130
220
|
const [isParsing, setIsParsing] = useState(false);
|
|
131
221
|
const [theme, setTheme] = useState<EditorTheme>(initialTheme);
|
|
@@ -170,8 +260,12 @@ export function EditorProvider({
|
|
|
170
260
|
}
|
|
171
261
|
}, []);
|
|
172
262
|
|
|
173
|
-
// Parse on source changes with debounce
|
|
263
|
+
// Parse on source changes with debounce. Skipped in code mode — the
|
|
264
|
+
// WYSIWYG/Preview surfaces that consume markdownDoc/doc aren't mounted,
|
|
265
|
+
// so there's nothing to feed and no reason to run the markdown parser on
|
|
266
|
+
// TypeScript / JSON / etc.
|
|
174
267
|
useEffect(() => {
|
|
268
|
+
if (editorMode === 'code') return;
|
|
175
269
|
if (parseTimeoutRef.current) {
|
|
176
270
|
clearTimeout(parseTimeoutRef.current);
|
|
177
271
|
}
|
|
@@ -183,10 +277,11 @@ export function EditorProvider({
|
|
|
183
277
|
clearTimeout(parseTimeoutRef.current);
|
|
184
278
|
}
|
|
185
279
|
};
|
|
186
|
-
}, [markdownSource, doParse]);
|
|
280
|
+
}, [markdownSource, doParse, editorMode]);
|
|
187
281
|
|
|
188
282
|
// Initial parse
|
|
189
283
|
useEffect(() => {
|
|
284
|
+
if (editorMode === 'code') return;
|
|
190
285
|
if (initialMarkdown) {
|
|
191
286
|
doParse(initialMarkdown);
|
|
192
287
|
}
|
|
@@ -276,9 +371,13 @@ export function EditorProvider({
|
|
|
276
371
|
parseError,
|
|
277
372
|
isParsing,
|
|
278
373
|
theme,
|
|
374
|
+
editorMode,
|
|
375
|
+
language: resolvedLanguage,
|
|
279
376
|
tiptapEditor,
|
|
280
377
|
monacoEditor,
|
|
281
378
|
mediaProvider,
|
|
379
|
+
imageDisplayMode,
|
|
380
|
+
mentionProvider,
|
|
282
381
|
setMarkdownSource,
|
|
283
382
|
setMarkdownDoc,
|
|
284
383
|
setActiveView,
|
|
@@ -296,9 +395,13 @@ export function EditorProvider({
|
|
|
296
395
|
parseError,
|
|
297
396
|
isParsing,
|
|
298
397
|
theme,
|
|
398
|
+
editorMode,
|
|
399
|
+
resolvedLanguage,
|
|
299
400
|
tiptapEditor,
|
|
300
401
|
monacoEditor,
|
|
301
402
|
mediaProvider,
|
|
403
|
+
imageDisplayMode,
|
|
404
|
+
mentionProvider,
|
|
302
405
|
setMarkdownSource,
|
|
303
406
|
setMarkdownDoc,
|
|
304
407
|
setActiveView,
|
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
|
)}
|