@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.
- 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 +112 -2
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +95 -11
- 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 +12 -1
- package/dist/MediaBin.d.ts.map +1 -1
- package/dist/MediaBin.js +29 -4
- 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__/mediaAttachmentFlow.test.d.ts +2 -0
- package/dist/__tests__/mediaAttachmentFlow.test.d.ts.map +1 -0
- package/dist/__tests__/mediaAttachmentFlow.test.js +99 -0
- package/dist/__tests__/mediaAttachmentFlow.test.js.map +1 -0
- package/dist/__tests__/tiptapBridge.test.js +49 -0
- package/dist/__tests__/tiptapBridge.test.js.map +1 -1
- package/dist/__tests__/tiptapImageRoundTrip.test.d.ts +2 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.d.ts.map +1 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.js +68 -0
- package/dist/__tests__/tiptapImageRoundTrip.test.js.map +1 -0
- package/dist/detectMarkdown.d.ts +20 -0
- package/dist/detectMarkdown.d.ts.map +1 -0
- package/dist/detectMarkdown.js +61 -0
- package/dist/detectMarkdown.js.map +1 -0
- package/dist/fileKind.d.ts +30 -0
- package/dist/fileKind.d.ts.map +1 -0
- package/dist/fileKind.js +123 -0
- package/dist/fileKind.js.map +1 -0
- package/dist/hooks/useFileDrop.d.ts.map +1 -1
- package/dist/hooks/useFileDrop.js +9 -7
- package/dist/hooks/useFileDrop.js.map +1 -1
- package/dist/index.d.ts +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 +99 -6
- package/dist/tiptapBridge.js.map +1 -1
- package/package.json +9 -7
- package/src/EditorContext.tsx +106 -3
- package/src/EditorShell.tsx +313 -21
- package/src/ImageNodeView.tsx +15 -2
- package/src/MediaBin.tsx +45 -4
- 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__/mediaAttachmentFlow.test.ts +110 -0
- package/src/__tests__/tiptapBridge.test.ts +58 -0
- package/src/__tests__/tiptapImageRoundTrip.test.ts +73 -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 +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 } =
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 = ``;
|
|
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=
|
|
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
|
}
|