@bendyline/squisq-editor-react 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) 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 +112 -2
  6. package/dist/EditorShell.d.ts.map +1 -1
  7. package/dist/EditorShell.js +95 -11
  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 +12 -1
  13. package/dist/MediaBin.d.ts.map +1 -1
  14. package/dist/MediaBin.js +29 -4
  15. package/dist/MediaBin.js.map +1 -1
  16. package/dist/MentionExtension.d.ts +22 -0
  17. package/dist/MentionExtension.d.ts.map +1 -0
  18. package/dist/MentionExtension.js +242 -0
  19. package/dist/MentionExtension.js.map +1 -0
  20. package/dist/RawEditor.d.ts +8 -1
  21. package/dist/RawEditor.d.ts.map +1 -1
  22. package/dist/RawEditor.js +167 -30
  23. package/dist/RawEditor.js.map +1 -1
  24. package/dist/TemplateAnnotation.d.ts.map +1 -1
  25. package/dist/TemplateAnnotation.js +4 -2
  26. package/dist/TemplateAnnotation.js.map +1 -1
  27. package/dist/Toolbar.d.ts +7 -1
  28. package/dist/Toolbar.d.ts.map +1 -1
  29. package/dist/Toolbar.js +57 -18
  30. package/dist/Toolbar.js.map +1 -1
  31. package/dist/Tooltip.d.ts +10 -0
  32. package/dist/Tooltip.d.ts.map +1 -0
  33. package/dist/Tooltip.js +104 -0
  34. package/dist/Tooltip.js.map +1 -0
  35. package/dist/ViewSwitcher.d.ts +1 -1
  36. package/dist/ViewSwitcher.d.ts.map +1 -1
  37. package/dist/ViewSwitcher.js +10 -4
  38. package/dist/ViewSwitcher.js.map +1 -1
  39. package/dist/WysiwygEditor.d.ts +13 -2
  40. package/dist/WysiwygEditor.d.ts.map +1 -1
  41. package/dist/WysiwygEditor.js +239 -4
  42. package/dist/WysiwygEditor.js.map +1 -1
  43. package/dist/__tests__/detectMarkdown.test.d.ts +2 -0
  44. package/dist/__tests__/detectMarkdown.test.d.ts.map +1 -0
  45. package/dist/__tests__/detectMarkdown.test.js +69 -0
  46. package/dist/__tests__/detectMarkdown.test.js.map +1 -0
  47. package/dist/__tests__/fileKind.test.d.ts +2 -0
  48. package/dist/__tests__/fileKind.test.d.ts.map +1 -0
  49. package/dist/__tests__/fileKind.test.js +81 -0
  50. package/dist/__tests__/fileKind.test.js.map +1 -0
  51. package/dist/__tests__/mediaAttachmentFlow.test.d.ts +2 -0
  52. package/dist/__tests__/mediaAttachmentFlow.test.d.ts.map +1 -0
  53. package/dist/__tests__/mediaAttachmentFlow.test.js +99 -0
  54. package/dist/__tests__/mediaAttachmentFlow.test.js.map +1 -0
  55. package/dist/__tests__/tiptapBridge.test.js +49 -0
  56. package/dist/__tests__/tiptapBridge.test.js.map +1 -1
  57. package/dist/__tests__/tiptapImageRoundTrip.test.d.ts +2 -0
  58. package/dist/__tests__/tiptapImageRoundTrip.test.d.ts.map +1 -0
  59. package/dist/__tests__/tiptapImageRoundTrip.test.js +68 -0
  60. package/dist/__tests__/tiptapImageRoundTrip.test.js.map +1 -0
  61. package/dist/detectMarkdown.d.ts +20 -0
  62. package/dist/detectMarkdown.d.ts.map +1 -0
  63. package/dist/detectMarkdown.js +61 -0
  64. package/dist/detectMarkdown.js.map +1 -0
  65. package/dist/fileKind.d.ts +30 -0
  66. package/dist/fileKind.d.ts.map +1 -0
  67. package/dist/fileKind.js +123 -0
  68. package/dist/fileKind.js.map +1 -0
  69. package/dist/hooks/useFileDrop.d.ts.map +1 -1
  70. package/dist/hooks/useFileDrop.js +9 -7
  71. package/dist/hooks/useFileDrop.js.map +1 -1
  72. package/dist/index.d.ts +4 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +4 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/mediaDragMime.d.ts +17 -0
  77. package/dist/mediaDragMime.d.ts.map +1 -0
  78. package/dist/mediaDragMime.js +22 -0
  79. package/dist/mediaDragMime.js.map +1 -0
  80. package/dist/tiptapBridge.d.ts.map +1 -1
  81. package/dist/tiptapBridge.js +99 -6
  82. package/dist/tiptapBridge.js.map +1 -1
  83. package/package.json +9 -7
  84. package/src/EditorContext.tsx +106 -3
  85. package/src/EditorShell.tsx +313 -21
  86. package/src/ImageNodeView.tsx +15 -2
  87. package/src/MediaBin.tsx +45 -4
  88. package/src/MentionExtension.tsx +258 -0
  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__/mediaAttachmentFlow.test.ts +110 -0
  98. package/src/__tests__/tiptapBridge.test.ts +58 -0
  99. package/src/__tests__/tiptapImageRoundTrip.test.ts +73 -0
  100. package/src/detectMarkdown.ts +62 -0
  101. package/src/fileKind.ts +134 -0
  102. package/src/hooks/useFileDrop.ts +10 -6
  103. package/src/index.ts +11 -0
  104. package/src/mediaDragMime.ts +32 -0
  105. package/src/styles/editor.css +214 -8
  106. package/src/tiptapBridge.ts +107 -6
@@ -0,0 +1,258 @@
1
+ /**
2
+ * MentionExtension
3
+ *
4
+ * Tiptap mention configuration paired with a small absolutely-positioned
5
+ * suggestion popover. Shares a caller-supplied async provider (see
6
+ * `MentionProvider` in EditorContext) with the Monaco `@` completion
7
+ * provider in `RawEditor`, so both editing modes surface the same roster.
8
+ *
9
+ * The mention chip renders as `<span data-mention data-kind data-id
10
+ * data-label class="mention">@Label</span>`, matching the wire format that
11
+ * `tiptapBridge` emits when converting markdown → Tiptap HTML. On serialize
12
+ * back to markdown, the bridge emits `@[Label](kind:id)`.
13
+ */
14
+
15
+ import Mention from '@tiptap/extension-mention';
16
+ import { PluginKey } from '@tiptap/pm/state';
17
+ import type { Editor, Range } from '@tiptap/core';
18
+ import type { MentionCandidate, MentionProvider } from './EditorContext';
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ type SuggestionProps = any;
22
+
23
+ /**
24
+ * Fallback namespace for defensive code paths — used when a mention node
25
+ * somehow lacks a `kind` attribute (e.g. legacy HTML parsed without one).
26
+ * Inserts from the suggestion popover always carry the candidate's own
27
+ * `scheme`, so this only surfaces for malformed/legacy content.
28
+ */
29
+ const FALLBACK_KIND = 'mention';
30
+
31
+ type SuggestionState = {
32
+ items: MentionCandidate[];
33
+ selected: number;
34
+ };
35
+
36
+ /**
37
+ * Build the Tiptap mention extension for an editor. The returned extension
38
+ * captures a reference to `getProvider` at configure-time and calls it on
39
+ * every keystroke — keep the reference stable so we don't recreate the
40
+ * editor just to change who answers the `@` query.
41
+ */
42
+ export function buildMentionExtension(getProvider: () => MentionProvider | null) {
43
+ return Mention.configure({
44
+ HTMLAttributes: {
45
+ class: 'mention',
46
+ 'data-mention': 'true',
47
+ },
48
+ renderHTML({ options, node }) {
49
+ const label =
50
+ (node.attrs.label as string | undefined) ?? (node.attrs.id as string | undefined) ?? '';
51
+ const id = (node.attrs.id as string | undefined) ?? '';
52
+ const kind = (node.attrs.kind as string | undefined) ?? FALLBACK_KIND;
53
+ return [
54
+ 'span',
55
+ {
56
+ ...options.HTMLAttributes,
57
+ 'data-kind': kind,
58
+ 'data-id': id,
59
+ 'data-label': label,
60
+ },
61
+ `@${label}`,
62
+ ];
63
+ },
64
+ renderText({ node }) {
65
+ const label =
66
+ (node.attrs.label as string | undefined) ?? (node.attrs.id as string | undefined) ?? '';
67
+ const id = (node.attrs.id as string | undefined) ?? '';
68
+ const kind = (node.attrs.kind as string | undefined) ?? FALLBACK_KIND;
69
+ return `@[${label}](${kind}:${id})`;
70
+ },
71
+ }).extend({
72
+ addAttributes() {
73
+ return {
74
+ id: {
75
+ default: null,
76
+ parseHTML: (el) => el.getAttribute('data-id'),
77
+ renderHTML: (attrs) => (attrs.id ? { 'data-id': attrs.id } : {}),
78
+ },
79
+ label: {
80
+ default: null,
81
+ parseHTML: (el) => el.getAttribute('data-label'),
82
+ renderHTML: (attrs) => (attrs.label ? { 'data-label': attrs.label } : {}),
83
+ },
84
+ kind: {
85
+ default: FALLBACK_KIND,
86
+ parseHTML: (el) => el.getAttribute('data-kind') ?? FALLBACK_KIND,
87
+ renderHTML: (attrs) => ({ 'data-kind': attrs.kind ?? FALLBACK_KIND }),
88
+ },
89
+ };
90
+ },
91
+ addOptions() {
92
+ return {
93
+ ...(this.parent?.() ?? {}),
94
+ suggestion: {
95
+ char: '@',
96
+ // Custom plugin key so the mention suggestion doesn't collide
97
+ // with any future `:` or `/` popovers.
98
+ pluginKey: new PluginKey('mentionSuggestion'),
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
101
+ const id = (props?.id as string | null) ?? '';
102
+ const label = (props?.label as string | null) ?? id;
103
+ const kind = (props?.kind as string | undefined) ?? FALLBACK_KIND;
104
+ editor
105
+ .chain()
106
+ .focus()
107
+ .insertContentAt(range, [
108
+ {
109
+ type: 'mention',
110
+ attrs: { id, label, kind },
111
+ },
112
+ { type: 'text', text: ' ' },
113
+ ])
114
+ .run();
115
+ },
116
+ items: async ({ query }: { query: string }) => {
117
+ const provider = getProvider();
118
+ if (!provider) return [];
119
+ try {
120
+ return await provider(query);
121
+ } catch {
122
+ return [];
123
+ }
124
+ },
125
+ render: renderSuggestionFactory(),
126
+ },
127
+ };
128
+ },
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Lightweight suggestion popover. Uses a plain absolutely-positioned div
134
+ * anchored to the caret rect — no tippy.js needed. Keyboard nav handled via
135
+ * the `onKeyDown` hook Tiptap wires up.
136
+ */
137
+ function renderSuggestionFactory() {
138
+ return () => {
139
+ let container: HTMLDivElement | null = null;
140
+ let state: SuggestionState = { items: [], selected: 0 };
141
+ let currentProps: SuggestionProps | null = null;
142
+
143
+ const update = () => {
144
+ if (!container || !currentProps) return;
145
+ container.innerHTML = '';
146
+ if (state.items.length === 0) {
147
+ container.style.display = 'none';
148
+ return;
149
+ }
150
+ container.style.display = 'block';
151
+
152
+ for (let i = 0; i < state.items.length; i++) {
153
+ const item = state.items[i];
154
+ const btn = document.createElement('button');
155
+ btn.type = 'button';
156
+ btn.className = 'squisq-mention-item' + (i === state.selected ? ' is-selected' : '');
157
+ btn.dataset.index = String(i);
158
+ btn.innerHTML = '';
159
+ const label = document.createElement('span');
160
+ label.className = 'squisq-mention-label';
161
+ label.textContent = item.label;
162
+ btn.appendChild(label);
163
+ if (item.description) {
164
+ const desc = document.createElement('span');
165
+ desc.className = 'squisq-mention-desc';
166
+ desc.textContent = item.description;
167
+ btn.appendChild(desc);
168
+ }
169
+ btn.addEventListener('mousedown', (ev) => {
170
+ ev.preventDefault();
171
+ selectAt(i);
172
+ });
173
+ container.appendChild(btn);
174
+ }
175
+
176
+ positionTo(container, currentProps.clientRect);
177
+ };
178
+
179
+ const selectAt = (index: number) => {
180
+ const item = state.items[index];
181
+ if (!item || !currentProps) return;
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
+ const command = (currentProps as any).command;
184
+ if (typeof command === 'function') {
185
+ command({ id: item.id, label: item.label, kind: item.scheme });
186
+ }
187
+ };
188
+
189
+ return {
190
+ onStart: (props: SuggestionProps) => {
191
+ currentProps = props;
192
+ state = { items: props.items ?? [], selected: 0 };
193
+ if (!container) {
194
+ container = document.createElement('div');
195
+ container.className = 'squisq-mention-popover';
196
+ container.style.position = 'absolute';
197
+ container.style.zIndex = '10000';
198
+ document.body.appendChild(container);
199
+ }
200
+ update();
201
+ },
202
+ onUpdate: (props: SuggestionProps) => {
203
+ currentProps = props;
204
+ if (Array.isArray(props.items)) {
205
+ state = { items: props.items, selected: 0 };
206
+ }
207
+ update();
208
+ },
209
+ onKeyDown: ({ event }: { event: KeyboardEvent }) => {
210
+ if (!state.items.length) return false;
211
+ if (event.key === 'ArrowDown') {
212
+ state.selected = (state.selected + 1) % state.items.length;
213
+ update();
214
+ return true;
215
+ }
216
+ if (event.key === 'ArrowUp') {
217
+ state.selected = (state.selected - 1 + state.items.length) % state.items.length;
218
+ update();
219
+ return true;
220
+ }
221
+ if (event.key === 'Enter' || event.key === 'Tab') {
222
+ selectAt(state.selected);
223
+ return true;
224
+ }
225
+ if (event.key === 'Escape') {
226
+ state = { items: [], selected: 0 };
227
+ update();
228
+ return true;
229
+ }
230
+ return false;
231
+ },
232
+ onExit: () => {
233
+ if (container?.parentNode) container.parentNode.removeChild(container);
234
+ container = null;
235
+ currentProps = null;
236
+ },
237
+ };
238
+ };
239
+ }
240
+
241
+ function positionTo(
242
+ el: HTMLDivElement,
243
+ clientRect: (() => DOMRect | null) | null | undefined,
244
+ ): void {
245
+ const rect = clientRect?.();
246
+ if (!rect) return;
247
+ // Anchor just below the caret; fall back to above when there's no room.
248
+ const viewportH = window.innerHeight;
249
+ const below = rect.bottom + 4;
250
+ const estH = Math.min(240, el.offsetHeight || 200);
251
+ const fitsBelow = below + estH < viewportH;
252
+ el.style.left = `${rect.left + window.scrollX}px`;
253
+ if (fitsBelow) {
254
+ el.style.top = `${below + window.scrollY}px`;
255
+ } else {
256
+ el.style.top = `${rect.top + window.scrollY - estH - 4}px`;
257
+ }
258
+ }
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
  }