@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.
Files changed (105) 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/PreviewPanel.d.ts +3 -8
  20. package/dist/PreviewPanel.d.ts.map +1 -1
  21. package/dist/PreviewPanel.js +4 -282
  22. package/dist/PreviewPanel.js.map +1 -1
  23. package/dist/RawEditor.d.ts +8 -1
  24. package/dist/RawEditor.d.ts.map +1 -1
  25. package/dist/RawEditor.js +167 -30
  26. package/dist/RawEditor.js.map +1 -1
  27. package/dist/TemplateAnnotation.d.ts.map +1 -1
  28. package/dist/TemplateAnnotation.js +4 -2
  29. package/dist/TemplateAnnotation.js.map +1 -1
  30. package/dist/Toolbar.d.ts +7 -1
  31. package/dist/Toolbar.d.ts.map +1 -1
  32. package/dist/Toolbar.js +57 -18
  33. package/dist/Toolbar.js.map +1 -1
  34. package/dist/Tooltip.d.ts +10 -0
  35. package/dist/Tooltip.d.ts.map +1 -0
  36. package/dist/Tooltip.js +104 -0
  37. package/dist/Tooltip.js.map +1 -0
  38. package/dist/ViewSwitcher.d.ts +1 -1
  39. package/dist/ViewSwitcher.d.ts.map +1 -1
  40. package/dist/ViewSwitcher.js +10 -4
  41. package/dist/ViewSwitcher.js.map +1 -1
  42. package/dist/WysiwygEditor.d.ts +13 -2
  43. package/dist/WysiwygEditor.d.ts.map +1 -1
  44. package/dist/WysiwygEditor.js +239 -4
  45. package/dist/WysiwygEditor.js.map +1 -1
  46. package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
  47. package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
  48. package/dist/__tests__/detectMarkdown.test.js +69 -0
  49. package/dist/__tests__/detectMarkdown.test.js.map +1 -0
  50. package/dist/__tests__/fileKind.test.d.ts +2 -0
  51. package/dist/__tests__/fileKind.test.d.ts.map +1 -0
  52. package/dist/__tests__/fileKind.test.js +81 -0
  53. package/dist/__tests__/fileKind.test.js.map +1 -0
  54. package/dist/__tests__/tiptapBridge.test.js +36 -0
  55. package/dist/__tests__/tiptapBridge.test.js.map +1 -1
  56. package/dist/buildPreviewDoc.d.ts +22 -0
  57. package/dist/buildPreviewDoc.d.ts.map +1 -0
  58. package/dist/buildPreviewDoc.js +212 -0
  59. package/dist/buildPreviewDoc.js.map +1 -0
  60. package/dist/detectMarkdown.d.ts +20 -0
  61. package/dist/detectMarkdown.d.ts.map +1 -0
  62. package/dist/detectMarkdown.js +61 -0
  63. package/dist/detectMarkdown.js.map +1 -0
  64. package/dist/fileKind.d.ts +30 -0
  65. package/dist/fileKind.d.ts.map +1 -0
  66. package/dist/fileKind.js +123 -0
  67. package/dist/fileKind.js.map +1 -0
  68. package/dist/hooks/useFileDrop.d.ts.map +1 -1
  69. package/dist/hooks/useFileDrop.js +9 -7
  70. package/dist/hooks/useFileDrop.js.map +1 -1
  71. package/dist/index.d.ts +5 -1
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +6 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/mediaDragMime.d.ts +17 -0
  76. package/dist/mediaDragMime.d.ts.map +1 -0
  77. package/dist/mediaDragMime.js +22 -0
  78. package/dist/mediaDragMime.js.map +1 -0
  79. package/dist/tiptapBridge.d.ts.map +1 -1
  80. package/dist/tiptapBridge.js +58 -2
  81. package/dist/tiptapBridge.js.map +1 -1
  82. package/package.json +9 -7
  83. package/src/EditorContext.tsx +106 -3
  84. package/src/EditorShell.tsx +195 -15
  85. package/src/ImageNodeView.tsx +15 -2
  86. package/src/MediaBin.tsx +23 -1
  87. package/src/MentionExtension.tsx +258 -0
  88. package/src/PreviewPanel.tsx +5 -333
  89. package/src/RawEditor.tsx +193 -37
  90. package/src/TemplateAnnotation.ts +4 -2
  91. package/src/Toolbar.tsx +111 -48
  92. package/src/Tooltip.tsx +124 -0
  93. package/src/ViewSwitcher.tsx +15 -5
  94. package/src/WysiwygEditor.tsx +270 -5
  95. package/src/__tests__/detectMarkdown.test.ts +88 -0
  96. package/src/__tests__/fileKind.test.ts +96 -0
  97. package/src/__tests__/tiptapBridge.test.ts +44 -0
  98. package/src/buildPreviewDoc.ts +254 -0
  99. package/src/detectMarkdown.ts +62 -0
  100. package/src/fileKind.ts +134 -0
  101. package/src/hooks/useFileDrop.ts +10 -6
  102. package/src/index.ts +14 -0
  103. package/src/mediaDragMime.ts +32 -0
  104. package/src/styles/editor.css +214 -8
  105. package/src/tiptapBridge.ts +66 -2
@@ -6,29 +6,20 @@
6
6
  *
7
7
  * The markdown-derived Doc (from markdownToDoc) contains hierarchical blocks
8
8
  * with template names, heading text, and body content — but no audio or
9
- * visual layers. This component bridges the gap by:
10
- *
11
- * 1. Flattening the block tree into a linear slide sequence
12
- * 2. Converting each block into a TemplateBlock-compatible object
13
- * (mapping heading text → title, templateOverrides → template fields)
14
- * 3. Synthesizing a dummy audio segment so DocPlayer's timing works
15
- * (the player enters fallback-timer mode when audio can't load)
16
- * 4. Passing the prepared Doc to DocPlayer for SVG-based rendering
9
+ * visual layers. This component bridges the gap by using buildPreviewDoc()
10
+ * to flatten blocks, convert them to TemplateBlock slides with interleaved
11
+ * images, and synthesize timing.
17
12
  */
18
13
 
19
14
  import { useState, useEffect } from 'react';
20
15
  import { DocPlayer, LinearDocView } from '@bendyline/squisq-react';
21
- import { flattenBlocks } from '@bendyline/squisq/doc';
22
- import { hasTemplate } from '@bendyline/squisq/doc';
23
- import { extractPlainText } from '@bendyline/squisq/markdown';
24
- import type { Block, Doc } from '@bendyline/squisq/schemas';
25
- import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
26
- import { getChildren } from '@bendyline/squisq/markdown';
16
+ import type { Doc } from '@bendyline/squisq/schemas';
27
17
  import { applyTransform } from '@bendyline/squisq/transform';
28
18
  import { resolveAudioMapping } from '@bendyline/squisq/doc';
29
19
  import type { ContentContainer } from '@bendyline/squisq/storage';
30
20
  import { useEditorContext } from './EditorContext';
31
21
  import { usePreviewSettings } from './PreviewControls';
22
+ import { buildPreviewDoc } from './buildPreviewDoc';
32
23
 
33
24
  export interface PreviewPanelProps {
34
25
  /** Base path for resolving media URLs in DocPlayer */
@@ -39,325 +30,6 @@ export interface PreviewPanelProps {
39
30
  container?: ContentContainer | null;
40
31
  }
41
32
 
42
- // ── Helpers ────────────────────────────────────────────────────────
43
-
44
- /**
45
- * Extract plain text from an array of markdown block nodes.
46
- * Walks paragraphs, blockquotes, and list items to collect all text.
47
- */
48
- function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
49
- if (!contents || contents.length === 0) return '';
50
- const parts: string[] = [];
51
- for (const node of contents) {
52
- parts.push(extractPlainText(node));
53
- }
54
- return parts.join('\n').trim();
55
- }
56
-
57
- /**
58
- * Extract images from a block's markdown contents.
59
- * Walks the node tree recursively to find all MarkdownImage nodes.
60
- */
61
- function extractBlockImages(
62
- contents: MarkdownBlockNode[] | undefined,
63
- ): Array<{ src: string; alt: string }> {
64
- if (!contents || contents.length === 0) return [];
65
- const images: Array<{ src: string; alt: string }> = [];
66
-
67
- function walk(node: MarkdownNode): void {
68
- if ('type' in node && node.type === 'image' && 'url' in node) {
69
- const img = node as { url: string; alt?: string };
70
- if (img.url) {
71
- images.push({ src: img.url, alt: img.alt ?? '' });
72
- }
73
- }
74
- for (const child of getChildren(node)) {
75
- walk(child);
76
- }
77
- }
78
-
79
- for (const node of contents) {
80
- walk(node);
81
- }
82
- return images;
83
- }
84
-
85
- /**
86
- * Collect all unique images from an entire Doc's block tree.
87
- * Walks nested children to find every image across all blocks.
88
- */
89
- function collectAllDocImages(blocks: Block[]): Array<{ src: string; alt: string }> {
90
- const seen = new Set<string>();
91
- const images: Array<{ src: string; alt: string }> = [];
92
-
93
- function walkBlocks(blockList: Block[]): void {
94
- for (const block of blockList) {
95
- for (const img of extractBlockImages(block.contents)) {
96
- if (!seen.has(img.src)) {
97
- seen.add(img.src);
98
- images.push(img);
99
- }
100
- }
101
- if (block.children) {
102
- walkBlocks(block.children);
103
- }
104
- }
105
- }
106
-
107
- walkBlocks(blocks);
108
- return images;
109
- }
110
-
111
- /**
112
- * Extract list items from markdown body content.
113
- * Returns an array of plain text strings for each list item found.
114
- */
115
- function extractListItems(contents: MarkdownBlockNode[] | undefined): string[] {
116
- if (!contents) return [];
117
- const items: string[] = [];
118
- for (const node of contents) {
119
- if (node.type === 'list') {
120
- for (const item of (node as MarkdownList).children) {
121
- const text = extractPlainText(item).trim();
122
- if (text) items.push(text);
123
- }
124
- }
125
- }
126
- return items;
127
- }
128
-
129
- /**
130
- * Provide sensible default fields for templates that require more than
131
- * just a `title`. This prevents crashes from undefined required fields
132
- * when the markdown annotations don't supply all template-specific values.
133
- */
134
- function getTemplateDefaults(
135
- templateName: string,
136
- headingText: string,
137
- block: Block,
138
- ): Record<string, unknown> {
139
- const body = extractBodyText(block.contents);
140
-
141
- switch (templateName) {
142
- case 'statHighlight':
143
- return {
144
- stat: headingText,
145
- description: body || headingText,
146
- };
147
-
148
- case 'quoteBlock':
149
- case 'fullBleedQuote':
150
- case 'pullQuote':
151
- return {
152
- quote: body || headingText,
153
- };
154
-
155
- case 'factCard':
156
- return {
157
- fact: headingText,
158
- explanation: body || headingText,
159
- };
160
-
161
- case 'comparisonBar':
162
- return {
163
- leftLabel: 'A',
164
- leftValue: 60,
165
- rightLabel: 'B',
166
- rightValue: 40,
167
- };
168
-
169
- case 'listBlock':
170
- return {
171
- items: extractListItems(block.contents) || ['Item 1', 'Item 2', 'Item 3'],
172
- };
173
-
174
- case 'definitionCard':
175
- return {
176
- term: headingText,
177
- definition: body || headingText,
178
- };
179
-
180
- case 'dateEvent':
181
- return {
182
- date: headingText,
183
- description: body || headingText,
184
- };
185
-
186
- default:
187
- return {};
188
- }
189
- }
190
-
191
- /**
192
- * Convert a markdown-derived Block into a TemplateBlock-compatible object.
193
- *
194
- * The block's heading text becomes `title` (works for sectionHeader,
195
- * titleBlock, factCard, etc.). Any templateOverrides from annotation
196
- * syntax `{[template key=value]}` are spread on top so template-specific
197
- * fields (stat, quote, description, …) are available.
198
- *
199
- * If the requested template doesn't exist in the registry, falls back
200
- * to `sectionHeader` to avoid "Unknown template" warnings.
201
- */
202
- function blockToSlide(block: Block, index: number): Record<string, unknown> {
203
- const headingText = block.sourceHeading
204
- ? extractPlainText(block.sourceHeading)
205
- : block.title || block.id || `Slide ${index + 1}`;
206
-
207
- // Validate template name — fall back to sectionHeader for unknowns
208
- const requestedTemplate = block.template || 'sectionHeader';
209
- const template = hasTemplate(requestedTemplate) ? requestedTemplate : 'sectionHeader';
210
-
211
- // Get sensible defaults for templates that need more than just `title`
212
- const defaults = getTemplateDefaults(template, headingText, block);
213
-
214
- // Spread the block itself to pick up any template-specific fields
215
- // placed directly on the block by applyTransform (e.g. stat, description,
216
- // quote, colorScheme). These are not in templateOverrides — they live
217
- // on the block object because the transform produces hybrid Block+Template
218
- // objects via the timing allocator.
219
- const {
220
- id: _id,
221
- startTime: _st,
222
- duration: _d,
223
- audioSegment: _as,
224
- layers: _l,
225
- transition: _tr,
226
- template: _t,
227
- title: _ti,
228
- children: _c,
229
- contents: _co,
230
- sourceHeading: _sh,
231
- templateOverrides: _to,
232
- ...extraFields
233
- } = block as unknown as Record<string, unknown>;
234
-
235
- return {
236
- id: block.id,
237
- template,
238
- duration: block.duration,
239
- audioSegment: 0,
240
- transition: index > 0 ? { type: 'fade', duration: 0.5 } : undefined,
241
- // Provide heading text as title — consumed by sectionHeader, titleBlock, etc.
242
- title: headingText,
243
- // Template-specific defaults (safe fallbacks for required fields)
244
- ...defaults,
245
- // Template-specific fields from transform (stat, description, quote, etc.)
246
- ...extraFields,
247
- // Spread annotation overrides last so explicit values win
248
- ...block.templateOverrides,
249
- };
250
- }
251
-
252
- /** Ambient motions to rotate on image slides. */
253
- const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
254
- 'zoomIn',
255
- 'zoomOut',
256
- 'panLeft',
257
- 'panRight',
258
- ];
259
-
260
- /**
261
- * Build a player-ready Doc from the markdown-derived Doc.
262
- *
263
- * Flattens hierarchical blocks, converts each to a TemplateBlock-compatible
264
- * slide, recalculates timing, and adds a synthetic audio segment.
265
- *
266
- * Images found in the markdown are used in two ways:
267
- * 1. Per-block: if a block has images, its first image becomes the background
268
- * (via imageWithCaption template) or an accent image on text templates.
269
- * 2. Global: remaining images are interleaved as standalone image slides
270
- * for visual variety.
271
- */
272
- function buildPreviewDoc(doc: Doc): Doc {
273
- const flat = flattenBlocks(doc.blocks);
274
-
275
- // Collect all images from the doc for global interleaving
276
- const allImages = collectAllDocImages(doc.blocks);
277
-
278
- // Track which images are used per-block so we can interleave the rest
279
- const usedImageSrcs = new Set<string>();
280
-
281
- // First pass: convert blocks to slides, using per-block images
282
- const slides: Record<string, unknown>[] = [];
283
- let motionIndex = 0;
284
-
285
- for (let i = 0; i < flat.length; i++) {
286
- const block = flat[i];
287
- const blockImages = extractBlockImages(block.contents);
288
- const slide = blockToSlide(block, i);
289
-
290
- // If the block has images and is using the default sectionHeader template,
291
- // upgrade it to imageWithCaption so the image becomes the slide background.
292
- if (blockImages.length > 0 && slide.template === 'sectionHeader') {
293
- const img = blockImages[0];
294
- usedImageSrcs.add(img.src);
295
- slide.template = 'imageWithCaption';
296
- slide.imageSrc = img.src;
297
- slide.imageAlt = img.alt;
298
- slide.caption = slide.title as string;
299
- slide.captionPosition = 'bottom';
300
- slide.ambientMotion = IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length];
301
- } else if (blockImages.length > 0) {
302
- // For other templates, add the first image as an accent
303
- const img = blockImages[0];
304
- usedImageSrcs.add(img.src);
305
- if (!slide.accentImage) {
306
- slide.accentImage = {
307
- src: img.src,
308
- alt: img.alt,
309
- position: 'left-strip',
310
- ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
311
- };
312
- }
313
- }
314
-
315
- slides.push(slide);
316
- }
317
-
318
- // Second pass: interleave unused images as standalone imageWithCaption slides.
319
- // Spread them evenly through the sequence for visual variety.
320
- const unusedImages = allImages.filter((img) => !usedImageSrcs.has(img.src));
321
- if (unusedImages.length > 0 && slides.length > 0) {
322
- const interval = Math.max(2, Math.floor(slides.length / (unusedImages.length + 1)));
323
- let insertOffset = 0;
324
- for (let imgIdx = 0; imgIdx < unusedImages.length; imgIdx++) {
325
- const insertAt = Math.min((imgIdx + 1) * interval + insertOffset, slides.length);
326
- const img = unusedImages[imgIdx];
327
- slides.splice(insertAt, 0, {
328
- id: `img-interleave-${imgIdx}`,
329
- template: 'imageWithCaption',
330
- duration: 5,
331
- audioSegment: 0,
332
- imageSrc: img.src,
333
- imageAlt: img.alt,
334
- ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
335
- transition: { type: 'fade', duration: 0.5 },
336
- });
337
- insertOffset++;
338
- }
339
- }
340
-
341
- // Recalculate sequential timing
342
- let t = 0;
343
- for (const slide of slides) {
344
- slide.startTime = t;
345
- t += slide.duration as number;
346
- }
347
-
348
- return {
349
- articleId: doc.articleId,
350
- duration: t,
351
- blocks: slides as unknown as Block[],
352
- audio: {
353
- // Synthetic segment — audio will fail to load and DocPlayer will use
354
- // its fallback timer to advance currentTime via requestAnimationFrame.
355
- segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
356
- },
357
- ...(doc.captions ? { captions: doc.captions } : {}),
358
- };
359
- }
360
-
361
33
  // ── Component ──────────────────────────────────────────────────────
362
34
 
363
35
  /**
package/src/RawEditor.tsx CHANGED
@@ -11,6 +11,7 @@ import Editor, { loader, type OnMount, type OnChange } from '@monaco-editor/reac
11
11
  import * as monaco from 'monaco-editor';
12
12
  import { useEditorContext } from './EditorContext';
13
13
  import { getAvailableTemplates } from '@bendyline/squisq/doc';
14
+ import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
14
15
 
15
16
  // Use locally installed monaco-editor instead of CDN.
16
17
  //
@@ -35,6 +36,13 @@ export interface RawEditorProps {
35
36
  wordWrap?: 'on' | 'off' | 'wordWrapColumn' | 'bounded';
36
37
  /** Additional class name for the container */
37
38
  className?: string;
39
+ /**
40
+ * Chat-composer mode: Enter fires this callback (submit) and Cmd/Ctrl+Enter
41
+ * inserts a newline. When undefined, behaves normally.
42
+ */
43
+ submitOnEnter?: () => void;
44
+ /** Make Monaco read-only (no edits, no cursor blink). */
45
+ readOnly?: boolean;
38
46
  }
39
47
 
40
48
  /**
@@ -47,11 +55,28 @@ export function RawEditor({
47
55
  fontSize = 14,
48
56
  wordWrap = 'on',
49
57
  className,
58
+ submitOnEnter,
59
+ readOnly = false,
50
60
  }: RawEditorProps) {
51
- const { markdownSource, setMarkdownSource, setMonacoEditor } = useEditorContext();
61
+ const { markdownSource, setMarkdownSource, setMonacoEditor, language, mentionProvider } =
62
+ useEditorContext();
52
63
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
53
64
  const isExternalUpdate = useRef(false);
54
65
  const completionDisposable = useRef<monaco.IDisposable | null>(null);
66
+ const mentionCompletionDisposable = useRef<monaco.IDisposable | null>(null);
67
+ const dropCleanupRef = useRef<(() => void) | null>(null);
68
+ const keyDisposable = useRef<monaco.IDisposable | null>(null);
69
+ // Ref so the keydown handler always sees the latest callback.
70
+ const submitOnEnterRef = useRef(submitOnEnter);
71
+ useEffect(() => {
72
+ submitOnEnterRef.current = submitOnEnter;
73
+ }, [submitOnEnter]);
74
+ // Ref so the completion provider — registered once at mount — always
75
+ // sees the latest mentionProvider without needing to unregister.
76
+ const mentionProviderRef = useRef(mentionProvider);
77
+ useEffect(() => {
78
+ mentionProviderRef.current = mentionProvider;
79
+ }, [mentionProvider]);
55
80
 
56
81
  const handleMount: OnMount = useCallback(
57
82
  (editor, monaco) => {
@@ -61,44 +86,167 @@ export function RawEditor({
61
86
 
62
87
  // Dispose any previous completion provider (from a prior mount)
63
88
  completionDisposable.current?.dispose();
89
+ completionDisposable.current = null;
90
+ mentionCompletionDisposable.current?.dispose();
91
+ mentionCompletionDisposable.current = null;
92
+
93
+ // Register the `{[template]}` completion provider only for markdown
94
+ // files — it's meaningless for TypeScript, JSON, Python, etc.
95
+ if (language === 'markdown') {
96
+ const templates = getAvailableTemplates();
97
+ completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
98
+ triggerCharacters: ['['],
99
+ provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
100
+ const lineContent = model.getLineContent(position.lineNumber);
101
+
102
+ // Only trigger inside a heading line that has {[ before the cursor
103
+ if (!/^#{1,6}\s/.test(lineContent)) return { suggestions: [] };
104
+
105
+ const textBeforeCursor = lineContent.substring(0, position.column - 1);
106
+ const bracketIdx = textBeforeCursor.lastIndexOf('{[');
107
+ if (bracketIdx === -1) return { suggestions: [] };
108
+
109
+ // The range to replace: from after {[ to the cursor
110
+ const startCol = bracketIdx + 3; // after {[
111
+ const range = new monaco.Range(
112
+ position.lineNumber,
113
+ startCol,
114
+ position.lineNumber,
115
+ position.column,
116
+ );
117
+
118
+ const suggestions = templates.map((name) => ({
119
+ label: name,
120
+ kind: monaco.languages.CompletionItemKind.Value,
121
+ insertText: name + ']}',
122
+ range,
123
+ detail: 'Block template',
124
+ sortText: name,
125
+ }));
126
+
127
+ return { suggestions };
128
+ },
129
+ });
130
+
131
+ // `@mention` completion — queries the shared MentionProvider. Keep
132
+ // this in its own registration so we can dispose it independently
133
+ // of the template provider, and so the trigger character is just
134
+ // `@` (not `[`).
135
+ mentionCompletionDisposable.current = monaco.languages.registerCompletionItemProvider(
136
+ 'markdown',
137
+ {
138
+ triggerCharacters: ['@'],
139
+ async provideCompletionItems(model, position) {
140
+ const provider = mentionProviderRef.current;
141
+ if (!provider) return { suggestions: [] };
142
+ const lineContent = model.getLineContent(position.lineNumber);
143
+ const textBeforeCursor = lineContent.substring(0, position.column - 1);
144
+ const atIdx = textBeforeCursor.lastIndexOf('@');
145
+ if (atIdx === -1) return { suggestions: [] };
146
+ // `@` must be at line start or preceded by whitespace/punct —
147
+ // skip e.g. email addresses like `foo@bar`.
148
+ if (atIdx > 0) {
149
+ const prevChar = textBeforeCursor[atIdx - 1];
150
+ if (!/[\s\p{P}]/u.test(prevChar)) return { suggestions: [] };
151
+ }
152
+ const query = textBeforeCursor.slice(atIdx + 1);
153
+ // Only fire for short queries — once the user has typed
154
+ // a full word, the popover gets noisy.
155
+ if (query.length > 40) return { suggestions: [] };
156
+ if (/\s/.test(query)) return { suggestions: [] };
64
157
 
65
- // Register template annotation completion provider for {[ trigger
66
- const templates = getAvailableTemplates();
67
- completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
68
- triggerCharacters: ['['],
69
- provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
70
- const lineContent = model.getLineContent(position.lineNumber);
71
-
72
- // Only trigger inside a heading line that has {[ before the cursor
73
- if (!/^#{1,6}\s/.test(lineContent)) return { suggestions: [] };
74
-
75
- const textBeforeCursor = lineContent.substring(0, position.column - 1);
76
- const bracketIdx = textBeforeCursor.lastIndexOf('{[');
77
- if (bracketIdx === -1) return { suggestions: [] };
78
-
79
- // The range to replace: from after {[ to the cursor
80
- const startCol = bracketIdx + 3; // after {[
81
- const range = new monaco.Range(
82
- position.lineNumber,
83
- startCol,
84
- position.lineNumber,
85
- position.column,
86
- );
87
-
88
- const suggestions = templates.map((name) => ({
89
- label: name,
90
- kind: monaco.languages.CompletionItemKind.Value,
91
- insertText: name + ']}',
92
- range,
93
- detail: 'Block template',
94
- sortText: name,
95
- }));
96
-
97
- return { suggestions };
98
- },
158
+ let candidates;
159
+ try {
160
+ candidates = await provider(query);
161
+ } catch {
162
+ return { suggestions: [] };
163
+ }
164
+
165
+ const range = new monaco.Range(
166
+ position.lineNumber,
167
+ atIdx + 1,
168
+ position.lineNumber,
169
+ position.column,
170
+ );
171
+
172
+ return {
173
+ suggestions: candidates.map((c) => ({
174
+ label: `@${c.label}`,
175
+ kind: monaco.languages.CompletionItemKind.User,
176
+ insertText: `@[${c.label}](${c.scheme}:${c.id}) `,
177
+ range,
178
+ ...(c.description ? { detail: c.description } : {}),
179
+ sortText: c.label,
180
+ })),
181
+ };
182
+ },
183
+ },
184
+ );
185
+ }
186
+
187
+ // Chat-composer mode: intercept Enter before Monaco inserts a newline.
188
+ // Cmd/Ctrl+Enter falls through so the native newline still works.
189
+ keyDisposable.current?.dispose();
190
+ keyDisposable.current = editor.onKeyDown((e) => {
191
+ if (e.keyCode !== monaco.KeyCode.Enter) return;
192
+ if (!submitOnEnterRef.current) return;
193
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
194
+ e.preventDefault();
195
+ e.stopPropagation();
196
+ submitOnEnterRef.current();
99
197
  });
198
+
199
+ // Attach native drop listeners for in-app MediaBin drags. Monaco's own
200
+ // drop handling doesn't know about our custom MIME type, so we insert
201
+ // markdown image syntax explicitly in the capture phase.
202
+ dropCleanupRef.current?.();
203
+ const domNode = editor.getDomNode();
204
+ if (domNode) {
205
+ const onDragOver = (e: DragEvent) => {
206
+ if (e.dataTransfer?.types.includes(SQUISQ_MEDIA_MIME)) {
207
+ e.preventDefault();
208
+ e.dataTransfer.dropEffect = 'copy';
209
+ }
210
+ };
211
+ const onDrop = (e: DragEvent) => {
212
+ const dt = e.dataTransfer;
213
+ if (!dt) return;
214
+ const raw = dt.getData(SQUISQ_MEDIA_MIME);
215
+ if (!raw) return;
216
+ const payload = parseSquisqMediaPayload(raw);
217
+ if (!payload || !payload.mimeType.startsWith('image/')) return;
218
+
219
+ e.preventDefault();
220
+ e.stopPropagation();
221
+
222
+ const target = editor.getTargetAtClientPoint(e.clientX, e.clientY);
223
+ const position = target?.position ?? editor.getPosition();
224
+ if (!position) return;
225
+
226
+ const markdown = `![${payload.alt}](${payload.name})`;
227
+ editor.executeEdits('squisq-media-drop', [
228
+ {
229
+ range: new monaco.Range(
230
+ position.lineNumber,
231
+ position.column,
232
+ position.lineNumber,
233
+ position.column,
234
+ ),
235
+ text: markdown,
236
+ forceMoveMarkers: true,
237
+ },
238
+ ]);
239
+ editor.focus();
240
+ };
241
+ domNode.addEventListener('dragover', onDragOver, true);
242
+ domNode.addEventListener('drop', onDrop, true);
243
+ dropCleanupRef.current = () => {
244
+ domNode.removeEventListener('dragover', onDragOver, true);
245
+ domNode.removeEventListener('drop', onDrop, true);
246
+ };
247
+ }
100
248
  },
101
- [setMonacoEditor],
249
+ [setMonacoEditor, language],
102
250
  );
103
251
 
104
252
  // Unregister on unmount
@@ -107,6 +255,12 @@ export function RawEditor({
107
255
  setMonacoEditor(null);
108
256
  completionDisposable.current?.dispose();
109
257
  completionDisposable.current = null;
258
+ mentionCompletionDisposable.current?.dispose();
259
+ mentionCompletionDisposable.current = null;
260
+ dropCleanupRef.current?.();
261
+ dropCleanupRef.current = null;
262
+ keyDisposable.current?.dispose();
263
+ keyDisposable.current = null;
110
264
  };
111
265
  }, [setMonacoEditor]);
112
266
 
@@ -136,7 +290,7 @@ export function RawEditor({
136
290
  return (
137
291
  <div className={className} style={{ width: '100%', height: '100%' }} data-testid="raw-editor">
138
292
  <Editor
139
- defaultLanguage="markdown"
293
+ defaultLanguage={language}
140
294
  value={markdownSource}
141
295
  theme={theme}
142
296
  onMount={handleMount}
@@ -153,6 +307,8 @@ export function RawEditor({
153
307
  bracketPairColorization: { enabled: true },
154
308
  guides: { indentation: true },
155
309
  padding: { top: 12, bottom: 12 },
310
+ readOnly,
311
+ domReadOnly: readOnly,
156
312
  }}
157
313
  />
158
314
  </div>
@@ -48,7 +48,10 @@ export const HeadingWithTemplate = Heading.extend({
48
48
  const templateName = HTMLAttributes['data-template'];
49
49
 
50
50
  if (templateName) {
51
- // Render heading with a trailing badge span
51
+ // Render heading with a trailing badge span. The badge has no text
52
+ // content — its label is painted via CSS `content: attr(data-template)`
53
+ // so the template name never becomes part of the serialized heading
54
+ // text (which would leak into markdown on round-trip).
52
55
  return [
53
56
  tag,
54
57
  HTMLAttributes,
@@ -60,7 +63,6 @@ export const HeadingWithTemplate = Heading.extend({
60
63
  contenteditable: 'false',
61
64
  'data-template': templateName,
62
65
  },
63
- templateName,
64
66
  ],
65
67
  ];
66
68
  }