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