@bendyline/squisq-editor-react 0.1.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.
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Toolbar
3
+ *
4
+ * Formatting toolbar that provides common markdown editing actions.
5
+ * In WYSIWYG mode, uses Tiptap's chain commands to toggle marks / set nodes.
6
+ * In Raw mode, appends markdown syntax at the cursor (or end of source).
7
+ * Hidden in Preview mode.
8
+ */
9
+
10
+ import { useCallback, useEffect, useReducer } from 'react';
11
+ import type { Editor as TiptapEditor } from '@tiptap/core';
12
+ import { useEditorContext, type EditorView } from './EditorContext';
13
+ import { getAvailableTemplates } from '@bendyline/squisq/doc';
14
+
15
+ const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
16
+ { id: 'wysiwyg', label: 'Editor', shortcut: '⌘1' },
17
+ { id: 'raw', label: 'Raw', shortcut: '⌘2' },
18
+ { id: 'preview', label: 'Preview', shortcut: '⌘3' },
19
+ ];
20
+
21
+ export interface ToolbarProps {
22
+ /** Additional class name */
23
+ className?: string;
24
+ }
25
+
26
+ interface ToolbarButton {
27
+ id: string;
28
+ label: string;
29
+ icon: string;
30
+ title: string;
31
+ group: 'format' | 'structure' | 'insert';
32
+ /** CSS font style for the icon (e.g. italic for the I button) */
33
+ iconStyle?: React.CSSProperties;
34
+ }
35
+
36
+ const BUTTONS: ToolbarButton[] = [
37
+ // Format group
38
+ {
39
+ id: 'bold',
40
+ label: 'B',
41
+ icon: 'B',
42
+ title: 'Bold (Ctrl+B)',
43
+ group: 'format',
44
+ iconStyle: { fontWeight: 700 },
45
+ },
46
+ {
47
+ id: 'italic',
48
+ label: 'I',
49
+ icon: 'I',
50
+ title: 'Italic (Ctrl+I)',
51
+ group: 'format',
52
+ iconStyle: { fontStyle: 'italic' },
53
+ },
54
+ {
55
+ id: 'strikethrough',
56
+ label: 'S',
57
+ icon: 'S',
58
+ title: 'Strikethrough',
59
+ group: 'format',
60
+ iconStyle: { textDecoration: 'line-through' },
61
+ },
62
+ { id: 'code', label: '<>', icon: '`', title: 'Inline code', group: 'format' },
63
+
64
+ // Structure group
65
+ { id: 'h1', label: 'H1', icon: 'H1', title: 'Heading 1', group: 'structure' },
66
+ { id: 'h2', label: 'H2', icon: 'H2', title: 'Heading 2', group: 'structure' },
67
+ { id: 'h3', label: 'H3', icon: 'H3', title: 'Heading 3', group: 'structure' },
68
+ { id: 'quote', label: '❝', icon: '❝', title: 'Blockquote', group: 'structure' },
69
+
70
+ // Insert group
71
+ { id: 'ul', label: '•', icon: '•', title: 'Bullet list', group: 'insert' },
72
+ { id: 'ol', label: '1.', icon: '1.', title: 'Numbered list', group: 'insert' },
73
+ { id: 'codeblock', label: '{ }', icon: '{ }', title: 'Code block', group: 'insert' },
74
+ { id: 'hr', label: '—', icon: '—', title: 'Horizontal rule', group: 'insert' },
75
+ { id: 'link', label: '🔗', icon: '🔗', title: 'Insert link', group: 'insert' },
76
+ ];
77
+
78
+ // ─── Tiptap active-state map ────────────────────────────
79
+
80
+ /** Returns true if the given button id is currently active in Tiptap */
81
+ function isTiptapActive(editor: TiptapEditor, id: string): boolean {
82
+ if (!editor) return false;
83
+ switch (id) {
84
+ case 'bold':
85
+ return editor.isActive('bold');
86
+ case 'italic':
87
+ return editor.isActive('italic');
88
+ case 'strikethrough':
89
+ return editor.isActive('strike');
90
+ case 'code':
91
+ return editor.isActive('code');
92
+ case 'h1':
93
+ return editor.isActive('heading', { level: 1 });
94
+ case 'h2':
95
+ return editor.isActive('heading', { level: 2 });
96
+ case 'h3':
97
+ return editor.isActive('heading', { level: 3 });
98
+ case 'quote':
99
+ return editor.isActive('blockquote');
100
+ case 'ul':
101
+ return editor.isActive('bulletList');
102
+ case 'ol':
103
+ return editor.isActive('orderedList');
104
+ case 'codeblock':
105
+ return editor.isActive('codeBlock');
106
+ default:
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Formatting toolbar.
113
+ * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
114
+ * - Raw: appends markdown syntax to the source
115
+ */
116
+ export function Toolbar({ className }: ToolbarProps) {
117
+ const {
118
+ activeView,
119
+ setActiveView,
120
+ markdownSource,
121
+ setMarkdownSource,
122
+ tiptapEditor,
123
+ monacoEditor,
124
+ } = useEditorContext();
125
+
126
+ // Force re-render when Tiptap selection or formatting state changes
127
+ const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
128
+ useEffect(() => {
129
+ if (!tiptapEditor) return;
130
+ tiptapEditor.on('transaction', forceUpdate);
131
+ return () => {
132
+ tiptapEditor.off('transaction', forceUpdate);
133
+ };
134
+ }, [tiptapEditor]);
135
+
136
+ // ── Tiptap handler ─────────────────────────────────────
137
+ const handleTiptap = useCallback(
138
+ (id: string) => {
139
+ if (!tiptapEditor) return;
140
+ const chain = tiptapEditor.chain().focus();
141
+ switch (id) {
142
+ case 'bold':
143
+ chain.toggleBold().run();
144
+ break;
145
+ case 'italic':
146
+ chain.toggleItalic().run();
147
+ break;
148
+ case 'strikethrough':
149
+ chain.toggleStrike().run();
150
+ break;
151
+ case 'code':
152
+ chain.toggleCode().run();
153
+ break;
154
+ case 'h1':
155
+ chain.toggleHeading({ level: 1 }).run();
156
+ break;
157
+ case 'h2':
158
+ chain.toggleHeading({ level: 2 }).run();
159
+ break;
160
+ case 'h3':
161
+ chain.toggleHeading({ level: 3 }).run();
162
+ break;
163
+ case 'quote':
164
+ chain.toggleBlockquote().run();
165
+ break;
166
+ case 'ul':
167
+ chain.toggleBulletList().run();
168
+ break;
169
+ case 'ol':
170
+ chain.toggleOrderedList().run();
171
+ break;
172
+ case 'codeblock':
173
+ chain.toggleCodeBlock().run();
174
+ break;
175
+ case 'hr':
176
+ chain.setHorizontalRule().run();
177
+ break;
178
+ case 'link': {
179
+ const url = window.prompt('URL:');
180
+ if (url) {
181
+ (chain as unknown as Record<string, (opts: { href: string }) => typeof chain>)
182
+ .setLink?.({ href: url })
183
+ .run();
184
+ }
185
+ break;
186
+ }
187
+ }
188
+ },
189
+ [tiptapEditor],
190
+ );
191
+
192
+ // ── Raw markdown handler ───────────────────────────────
193
+ const handleRaw = useCallback(
194
+ (id: string) => {
195
+ if (monacoEditor) {
196
+ // Use Monaco's selection API for proper wrap/insert behavior
197
+ const selection = monacoEditor.getSelection();
198
+ const model = monacoEditor.getModel();
199
+ if (!selection || !model) return;
200
+
201
+ const selectedText = model.getValueInRange(selection);
202
+ const hasSelection = selectedText.length > 0;
203
+
204
+ let replacement = '';
205
+ let newCursorOffset = 0; // offset from start of replacement to place cursor
206
+
207
+ // Inline wrapping: wrap selection or insert placeholder
208
+ const wrapInline = (before: string, after: string, placeholder: string) => {
209
+ if (hasSelection) {
210
+ replacement = before + selectedText + after;
211
+ } else {
212
+ replacement = before + placeholder + after;
213
+ // Select the placeholder text after insertion
214
+ newCursorOffset = before.length;
215
+ }
216
+ };
217
+
218
+ // Block-level: prefix each selected line, or insert a new block
219
+ const prefixLines = (prefix: string, placeholder: string) => {
220
+ if (hasSelection) {
221
+ replacement = selectedText
222
+ .split('\n')
223
+ .map((line: string) => prefix + line)
224
+ .join('\n');
225
+ } else {
226
+ replacement = prefix + placeholder;
227
+ newCursorOffset = prefix.length;
228
+ }
229
+ };
230
+
231
+ switch (id) {
232
+ case 'bold':
233
+ wrapInline('**', '**', 'bold text');
234
+ break;
235
+ case 'italic':
236
+ wrapInline('*', '*', 'italic text');
237
+ break;
238
+ case 'strikethrough':
239
+ wrapInline('~~', '~~', 'strikethrough');
240
+ break;
241
+ case 'code':
242
+ wrapInline('`', '`', 'code');
243
+ break;
244
+ case 'h1':
245
+ prefixLines('# ', 'Heading 1');
246
+ break;
247
+ case 'h2':
248
+ prefixLines('## ', 'Heading 2');
249
+ break;
250
+ case 'h3':
251
+ prefixLines('### ', 'Heading 3');
252
+ break;
253
+ case 'quote':
254
+ prefixLines('> ', 'Quote');
255
+ break;
256
+ case 'ul':
257
+ prefixLines('- ', 'Item');
258
+ break;
259
+ case 'ol':
260
+ prefixLines('1. ', 'Item');
261
+ break;
262
+ case 'codeblock': {
263
+ const inner = hasSelection ? selectedText : 'code';
264
+ replacement = '```\n' + inner + '\n```';
265
+ if (!hasSelection) newCursorOffset = 4; // after ```\n
266
+ break;
267
+ }
268
+ case 'hr': {
269
+ replacement = '\n---\n';
270
+ break;
271
+ }
272
+ case 'link': {
273
+ if (hasSelection) {
274
+ replacement = '[' + selectedText + '](url)';
275
+ } else {
276
+ replacement = '[link text](url)';
277
+ newCursorOffset = 1; // inside the []
278
+ }
279
+ break;
280
+ }
281
+ }
282
+
283
+ // Apply the edit via Monaco's executeEdits for proper undo support
284
+ const range = selection;
285
+ monacoEditor.executeEdits('toolbar', [{ range, text: replacement }]);
286
+
287
+ // If no selection, select the placeholder text so user can type over it
288
+ if (!hasSelection && newCursorOffset > 0) {
289
+ const startPos = model.getPositionAt(
290
+ model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
291
+ );
292
+ const _placeholderLen =
293
+ replacement.length -
294
+ newCursorOffset -
295
+ (replacement.length -
296
+ replacement.lastIndexOf(replacement.charAt(replacement.length - 1)));
297
+ // Just place cursor after the prefix
298
+ monacoEditor.setPosition(startPos);
299
+ }
300
+
301
+ monacoEditor.focus();
302
+ } else {
303
+ // Fallback: no Monaco instance, just append
304
+ let insertion = '';
305
+ switch (id) {
306
+ case 'bold':
307
+ insertion = '**bold text**';
308
+ break;
309
+ case 'italic':
310
+ insertion = '*italic text*';
311
+ break;
312
+ case 'strikethrough':
313
+ insertion = '~~strikethrough~~';
314
+ break;
315
+ case 'code':
316
+ insertion = '`code`';
317
+ break;
318
+ case 'h1':
319
+ insertion = '\n# Heading 1\n';
320
+ break;
321
+ case 'h2':
322
+ insertion = '\n## Heading 2\n';
323
+ break;
324
+ case 'h3':
325
+ insertion = '\n### Heading 3\n';
326
+ break;
327
+ case 'quote':
328
+ insertion = '\n> Quote\n';
329
+ break;
330
+ case 'ul':
331
+ insertion = '\n- Item\n';
332
+ break;
333
+ case 'ol':
334
+ insertion = '\n1. Item\n';
335
+ break;
336
+ case 'codeblock':
337
+ insertion = '\n```\ncode\n```\n';
338
+ break;
339
+ case 'hr':
340
+ insertion = '\n---\n';
341
+ break;
342
+ case 'link':
343
+ insertion = '[link text](url)';
344
+ break;
345
+ }
346
+ if (insertion) {
347
+ setMarkdownSource(markdownSource + insertion);
348
+ }
349
+ }
350
+ },
351
+ [monacoEditor, markdownSource, setMarkdownSource],
352
+ );
353
+
354
+ const handleAction = useCallback(
355
+ (id: string) => {
356
+ if (activeView === 'wysiwyg' && tiptapEditor) {
357
+ handleTiptap(id);
358
+ } else {
359
+ handleRaw(id);
360
+ }
361
+ },
362
+ [activeView, tiptapEditor, handleTiptap, handleRaw],
363
+ );
364
+
365
+ const groups = ['format', 'structure', 'insert'] as const;
366
+ const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
367
+ const isPreview = activeView === 'preview';
368
+
369
+ // Detect current heading template (WYSIWYG mode only)
370
+ const currentTemplate = isWysiwyg
371
+ ? tiptapEditor.isActive('heading')
372
+ ? (tiptapEditor.getAttributes('heading')?.dataTemplate ?? '')
373
+ : null
374
+ : null;
375
+
376
+ const handleTemplatePick = (value: string) => {
377
+ if (!tiptapEditor) return;
378
+ if (value === '') {
379
+ // Clear template
380
+ tiptapEditor
381
+ .chain()
382
+ .focus()
383
+ .updateAttributes('heading', { dataTemplate: null, dataTemplateParams: null })
384
+ .run();
385
+ } else {
386
+ tiptapEditor.chain().focus().updateAttributes('heading', { dataTemplate: value }).run();
387
+ }
388
+ };
389
+
390
+ const templateNames = getAvailableTemplates();
391
+
392
+ return (
393
+ <div
394
+ className={`squisq-toolbar ${className || ''}`}
395
+ role="toolbar"
396
+ aria-label="Formatting toolbar"
397
+ >
398
+ {/* View tabs */}
399
+ <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
400
+ {VIEWS.map((view) => (
401
+ <button
402
+ key={view.id}
403
+ role="tab"
404
+ data-view={view.id}
405
+ aria-selected={activeView === view.id}
406
+ className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
407
+ onClick={() => setActiveView(view.id)}
408
+ title={`${view.label} (${view.shortcut})`}
409
+ >
410
+ {view.label}
411
+ </button>
412
+ ))}
413
+ </div>
414
+
415
+ {/* Formatting buttons — hidden in preview mode */}
416
+ {!isPreview && <div className="squisq-toolbar-separator" />}
417
+ {!isPreview &&
418
+ groups.map((group, gi) => (
419
+ <div key={group} className="squisq-toolbar-group">
420
+ {gi > 0 && <div className="squisq-toolbar-separator" />}
421
+ {BUTTONS.filter((b) => b.group === group).map((btn) => {
422
+ const active = isWysiwyg ? isTiptapActive(tiptapEditor, btn.id) : false;
423
+ return (
424
+ <button
425
+ key={btn.id}
426
+ className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
427
+ title={btn.title}
428
+ onClick={() => handleAction(btn.id)}
429
+ aria-label={btn.title}
430
+ aria-pressed={active}
431
+ style={btn.iconStyle}
432
+ >
433
+ {btn.icon}
434
+ </button>
435
+ );
436
+ })}
437
+ </div>
438
+ ))}
439
+
440
+ {/* Template picker — visible when cursor is in a heading (WYSIWYG) */}
441
+ {!isPreview && currentTemplate !== null && (
442
+ <>
443
+ <div className="squisq-toolbar-separator" />
444
+ <div className="squisq-toolbar-group squisq-template-picker">
445
+ <label className="squisq-template-picker-label" title="Block template for this heading">
446
+ Template:
447
+ <select
448
+ className="squisq-template-picker-select"
449
+ value={currentTemplate}
450
+ onChange={(e) => handleTemplatePick(e.target.value)}
451
+ >
452
+ <option value="">— none —</option>
453
+ {templateNames.map((name) => (
454
+ <option key={name} value={name}>
455
+ {name}
456
+ </option>
457
+ ))}
458
+ </select>
459
+ </label>
460
+ </div>
461
+ </>
462
+ )}
463
+ </div>
464
+ );
465
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ViewSwitcher
3
+ *
4
+ * Tab bar for switching between Raw, WYSIWYG, and Preview editor views.
5
+ */
6
+
7
+ import { useEditorContext, type EditorView } from './EditorContext';
8
+
9
+ const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
10
+ { id: 'raw', label: 'Raw', shortcut: '⌘1' },
11
+ { id: 'wysiwyg', label: 'Editor', shortcut: '⌘2' },
12
+ { id: 'preview', label: 'Preview', shortcut: '⌘3' },
13
+ ];
14
+
15
+ export interface ViewSwitcherProps {
16
+ /** Additional class name */
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * Tab-style view switcher for the three editor modes.
22
+ */
23
+ export function ViewSwitcher({ className }: ViewSwitcherProps) {
24
+ const { activeView, setActiveView } = useEditorContext();
25
+
26
+ return (
27
+ <div
28
+ className={`squisq-view-switcher ${className || ''}`}
29
+ role="tablist"
30
+ aria-label="Editor view"
31
+ >
32
+ {VIEWS.map((view) => (
33
+ <button
34
+ key={view.id}
35
+ role="tab"
36
+ aria-selected={activeView === view.id}
37
+ className={`squisq-view-tab ${activeView === view.id ? 'squisq-view-tab--active' : ''}`}
38
+ onClick={() => setActiveView(view.id)}
39
+ title={`${view.label} (${view.shortcut})`}
40
+ >
41
+ {view.label}
42
+ </button>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * WysiwygEditor
3
+ *
4
+ * Tiptap-based rich text editor that provides a WYSIWYG editing experience
5
+ * for markdown content. Uses squisq's parseMarkdown/stringifyMarkdown for
6
+ * conversion rather than Tiptap's built-in HTML serialization, ensuring
7
+ * perfect fidelity with the markdown format.
8
+ *
9
+ * Includes extensions for GFM features: tables, task lists, strikethrough,
10
+ * and code blocks.
11
+ */
12
+
13
+ import { useEffect, useRef } from 'react';
14
+ import { useEditor, EditorContent } from '@tiptap/react';
15
+ import StarterKit from '@tiptap/starter-kit';
16
+ import Table from '@tiptap/extension-table';
17
+ import TableRow from '@tiptap/extension-table-row';
18
+ import TableCell from '@tiptap/extension-table-cell';
19
+ import TableHeader from '@tiptap/extension-table-header';
20
+ import TaskList from '@tiptap/extension-task-list';
21
+ import TaskItem from '@tiptap/extension-task-item';
22
+ import Placeholder from '@tiptap/extension-placeholder';
23
+ import { HeadingWithTemplate } from './TemplateAnnotation';
24
+ import { useEditorContext } from './EditorContext';
25
+ import { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge';
26
+
27
+ // ── Frontmatter helpers ────────────────────────────────────────────
28
+
29
+ /** Regex matching a YAML frontmatter block at the start of the document. */
30
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
31
+
32
+ /** Strip YAML frontmatter from markdown, returning both parts. */
33
+ function stripFrontmatter(md: string): { body: string; frontmatter: string } {
34
+ const m = md.match(FRONTMATTER_RE);
35
+ if (!m) return { body: md, frontmatter: '' };
36
+ return { body: md.slice(m[0].length), frontmatter: m[0] };
37
+ }
38
+
39
+ export interface WysiwygEditorProps {
40
+ /** Placeholder text when editor is empty */
41
+ placeholder?: string;
42
+ /** Additional class name for the container */
43
+ className?: string;
44
+ }
45
+
46
+ /**
47
+ * Rich WYSIWYG markdown editor built on Tiptap (ProseMirror).
48
+ * Binds to the shared EditorContext for source synchronization.
49
+ */
50
+ export function WysiwygEditor({
51
+ placeholder = 'Start typing your markdown…',
52
+ className,
53
+ }: WysiwygEditorProps) {
54
+ const { markdownSource, setMarkdownSource, setTiptapEditor } = useEditorContext();
55
+ const isExternalUpdate = useRef(false);
56
+ const lastSourceRef = useRef(markdownSource);
57
+ // Preserve frontmatter across edits — hidden from WYSIWYG but prepended on save
58
+ const frontmatterRef = useRef(stripFrontmatter(markdownSource).frontmatter);
59
+
60
+ const editor = useEditor({
61
+ extensions: [
62
+ StarterKit.configure({
63
+ // Disable built-in heading; we use HeadingWithTemplate instead
64
+ heading: false,
65
+ codeBlock: {
66
+ HTMLAttributes: { class: 'squisq-code-block' },
67
+ },
68
+ }),
69
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
70
+ Table.configure({ resizable: true }),
71
+ TableRow,
72
+ TableCell,
73
+ TableHeader,
74
+ TaskList,
75
+ TaskItem.configure({ nested: true }),
76
+ Placeholder.configure({ placeholder }),
77
+ ],
78
+ content: markdownToTiptap(stripFrontmatter(markdownSource).body),
79
+ onUpdate: ({ editor: ed }) => {
80
+ if (isExternalUpdate.current) return;
81
+ const html = ed.getHTML();
82
+ const bodyMd = tiptapToMarkdown(html);
83
+ const newSource = frontmatterRef.current + bodyMd;
84
+ lastSourceRef.current = newSource;
85
+ setMarkdownSource(newSource);
86
+ },
87
+ editorProps: {
88
+ attributes: {
89
+ class: 'squisq-wysiwyg-editor',
90
+ 'data-testid': 'wysiwyg-editor',
91
+ },
92
+ },
93
+ });
94
+
95
+ // Register / unregister the Tiptap editor instance with the shared context
96
+ useEffect(() => {
97
+ if (editor) {
98
+ setTiptapEditor(editor);
99
+ }
100
+ return () => setTiptapEditor(null);
101
+ }, [editor, setTiptapEditor]);
102
+
103
+ // Sync external changes into Tiptap
104
+ useEffect(() => {
105
+ if (!editor) return;
106
+ // Only update if the source changed externally (not from our own onUpdate)
107
+ if (markdownSource !== lastSourceRef.current) {
108
+ isExternalUpdate.current = true;
109
+ const { body, frontmatter } = stripFrontmatter(markdownSource);
110
+ frontmatterRef.current = frontmatter;
111
+ const content = markdownToTiptap(body);
112
+ editor.commands.setContent(content);
113
+ lastSourceRef.current = markdownSource;
114
+ isExternalUpdate.current = false;
115
+ }
116
+ }, [markdownSource, editor]);
117
+
118
+ return (
119
+ <div
120
+ className={className}
121
+ style={{ width: '100%', height: '100%', overflow: 'auto' }}
122
+ data-testid="wysiwyg-container"
123
+ >
124
+ <EditorContent editor={editor} style={{ height: '100%' }} />
125
+ </div>
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Hook to access the Tiptap editor instance for toolbar commands.
131
+ * The WysiwygEditor must be mounted as a sibling or descendant.
132
+ */
133
+ // eslint-disable-next-line react-refresh/only-export-components
134
+ export { useEditor as useTiptapEditor } from '@tiptap/react';
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @bendyline/squisq-editor-react
3
+ *
4
+ * React component library for editing markdown content with three views:
5
+ * - Raw (Monaco) — Full markdown source editing
6
+ * - WYSIWYG (Tiptap) — Rich text editing
7
+ * - Preview — Rendered block preview
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * import { EditorShell } from '@bendyline/squisq-editor-react';
12
+ * import '@bendyline/squisq-editor-react/styles';
13
+ *
14
+ * function App() {
15
+ * return <EditorShell initialMarkdown="# Hello World" />;
16
+ * }
17
+ * ```
18
+ */
19
+
20
+ // Shell (top-level component)
21
+ export { EditorShell } from './EditorShell.js';
22
+ export type { EditorShellProps, EditorTheme } from './EditorShell.js';
23
+
24
+ // Context
25
+ export { EditorProvider, useEditorContext } from './EditorContext.js';
26
+ export type {
27
+ EditorView,
28
+ EditorState,
29
+ EditorActions,
30
+ EditorContextValue,
31
+ EditorProviderProps,
32
+ } from './EditorContext.js';
33
+
34
+ // Individual editors (for custom layouts)
35
+ export { RawEditor } from './RawEditor.js';
36
+ export type { RawEditorProps } from './RawEditor.js';
37
+
38
+ export { WysiwygEditor } from './WysiwygEditor.js';
39
+ export type { WysiwygEditorProps } from './WysiwygEditor.js';
40
+
41
+ export { PreviewPanel } from './PreviewPanel.js';
42
+ export type { PreviewPanelProps } from './PreviewPanel.js';
43
+
44
+ // Chrome (for custom layouts)
45
+ export { ViewSwitcher } from './ViewSwitcher.js';
46
+ export type { ViewSwitcherProps } from './ViewSwitcher.js';
47
+
48
+ export { Toolbar } from './Toolbar.js';
49
+ export type { ToolbarProps } from './Toolbar.js';
50
+
51
+ export { StatusBar } from './StatusBar.js';
52
+ export type { StatusBarProps } from './StatusBar.js';
53
+
54
+ // Bridge utilities
55
+ export { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge.js';
56
+
57
+ // Tiptap extension: Heading with template annotation support
58
+ export { HeadingWithTemplate } from './TemplateAnnotation.js';