@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,151 @@
1
+ /**
2
+ * RawEditor
3
+ *
4
+ * Monaco-based raw markdown editor. Provides full VS Code-like editing
5
+ * experience with syntax highlighting, minimap, and bracket matching.
6
+ * Syncs changes back to EditorContext on every keystroke (debounced).
7
+ */
8
+
9
+ import { useRef, useCallback, useEffect } from 'react';
10
+ import Editor, { loader, type OnMount, type OnChange } from '@monaco-editor/react';
11
+ import * as monaco from 'monaco-editor';
12
+ import { useEditorContext } from './EditorContext';
13
+ import { getAvailableTemplates } from '@bendyline/squisq/doc';
14
+
15
+ // Use locally installed monaco-editor instead of CDN
16
+ loader.config({ monaco });
17
+
18
+ export interface RawEditorProps {
19
+ /** Monaco editor theme (default: 'vs-dark') */
20
+ theme?: string;
21
+ /** Show minimap (default: false) */
22
+ minimap?: boolean;
23
+ /** Font size in pixels (default: 14) */
24
+ fontSize?: number;
25
+ /** Word wrap setting (default: 'on') */
26
+ wordWrap?: 'on' | 'off' | 'wordWrapColumn' | 'bounded';
27
+ /** Additional class name for the container */
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * Raw markdown editor using Monaco Editor.
33
+ * Binds to the shared EditorContext for source synchronization.
34
+ */
35
+ export function RawEditor({
36
+ theme = 'vs',
37
+ minimap = false,
38
+ fontSize = 14,
39
+ wordWrap = 'on',
40
+ className,
41
+ }: RawEditorProps) {
42
+ const { markdownSource, setMarkdownSource, setMonacoEditor } = useEditorContext();
43
+ const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
44
+ const isExternalUpdate = useRef(false);
45
+ const completionDisposable = useRef<monaco.IDisposable | null>(null);
46
+
47
+ const handleMount: OnMount = useCallback(
48
+ (editor, monaco) => {
49
+ editorRef.current = editor;
50
+ setMonacoEditor(editor);
51
+ editor.focus();
52
+
53
+ // Dispose any previous completion provider (from a prior mount)
54
+ completionDisposable.current?.dispose();
55
+
56
+ // Register template annotation completion provider for {[ trigger
57
+ const templates = getAvailableTemplates();
58
+ completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
59
+ triggerCharacters: ['['],
60
+ provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
61
+ const lineContent = model.getLineContent(position.lineNumber);
62
+
63
+ // Only trigger inside a heading line that has {[ before the cursor
64
+ if (!/^#{1,6}\s/.test(lineContent)) return { suggestions: [] };
65
+
66
+ const textBeforeCursor = lineContent.substring(0, position.column - 1);
67
+ const bracketIdx = textBeforeCursor.lastIndexOf('{[');
68
+ if (bracketIdx === -1) return { suggestions: [] };
69
+
70
+ // The range to replace: from after {[ to the cursor
71
+ const startCol = bracketIdx + 3; // after {[
72
+ const range = new monaco.Range(
73
+ position.lineNumber,
74
+ startCol,
75
+ position.lineNumber,
76
+ position.column,
77
+ );
78
+
79
+ const suggestions = templates.map((name) => ({
80
+ label: name,
81
+ kind: monaco.languages.CompletionItemKind.Value,
82
+ insertText: name + ']}',
83
+ range,
84
+ detail: 'Block template',
85
+ sortText: name,
86
+ }));
87
+
88
+ return { suggestions };
89
+ },
90
+ });
91
+ },
92
+ [setMonacoEditor],
93
+ );
94
+
95
+ // Unregister on unmount
96
+ useEffect(() => {
97
+ return () => {
98
+ setMonacoEditor(null);
99
+ completionDisposable.current?.dispose();
100
+ completionDisposable.current = null;
101
+ };
102
+ }, [setMonacoEditor]);
103
+
104
+ const handleChange: OnChange = useCallback(
105
+ (value) => {
106
+ if (isExternalUpdate.current) return;
107
+ if (value !== undefined) {
108
+ setMarkdownSource(value);
109
+ }
110
+ },
111
+ [setMarkdownSource],
112
+ );
113
+
114
+ // When external changes happen (e.g. from WYSIWYG), update Monaco
115
+ useEffect(() => {
116
+ const editor = editorRef.current;
117
+ if (editor) {
118
+ const currentValue = editor.getValue();
119
+ if (currentValue !== markdownSource) {
120
+ isExternalUpdate.current = true;
121
+ editor.setValue(markdownSource);
122
+ isExternalUpdate.current = false;
123
+ }
124
+ }
125
+ }, [markdownSource]);
126
+
127
+ return (
128
+ <div className={className} style={{ width: '100%', height: '100%' }} data-testid="raw-editor">
129
+ <Editor
130
+ defaultLanguage="markdown"
131
+ value={markdownSource}
132
+ theme={theme}
133
+ onMount={handleMount}
134
+ onChange={handleChange}
135
+ options={{
136
+ fontSize,
137
+ wordWrap,
138
+ minimap: { enabled: minimap },
139
+ lineNumbers: 'on',
140
+ scrollBeyondLastLine: false,
141
+ automaticLayout: true,
142
+ tabSize: 2,
143
+ renderWhitespace: 'selection',
144
+ bracketPairColorization: { enabled: true },
145
+ guides: { indentation: true },
146
+ padding: { top: 12, bottom: 12 },
147
+ }}
148
+ />
149
+ </div>
150
+ );
151
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * StatusBar
3
+ *
4
+ * Bottom status bar showing document statistics and parse status.
5
+ */
6
+
7
+ import { useMemo } from 'react';
8
+ import { useEditorContext } from './EditorContext';
9
+
10
+ export interface StatusBarProps {
11
+ /** Additional class name */
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Status bar displaying document statistics: character count, word count,
17
+ * block count, and parse/error status.
18
+ */
19
+ export function StatusBar({ className }: StatusBarProps) {
20
+ const { markdownSource, doc, parseError, isParsing } = useEditorContext();
21
+
22
+ const stats = useMemo(() => {
23
+ const chars = markdownSource.length;
24
+ const words = markdownSource.trim() ? markdownSource.trim().split(/\s+/).length : 0;
25
+ const lines = markdownSource.split('\n').length;
26
+ const blocks = doc?.blocks.length ?? 0;
27
+ return { chars, words, lines, blocks };
28
+ }, [markdownSource, doc]);
29
+
30
+ return (
31
+ <div className={`squisq-status-bar ${className || ''}`}>
32
+ <span className="squisq-status-item">{stats.words} words</span>
33
+ <span className="squisq-status-item">{stats.chars} chars</span>
34
+ <span className="squisq-status-item">{stats.lines} lines</span>
35
+ <span className="squisq-status-item">{stats.blocks} blocks</span>
36
+ <span className="squisq-status-spacer" />
37
+ {isParsing && <span className="squisq-status-item squisq-status-parsing">Parsing…</span>}
38
+ {parseError && (
39
+ <span className="squisq-status-item squisq-status-error" title={parseError}>
40
+ ⚠ Error
41
+ </span>
42
+ )}
43
+ {!isParsing && !parseError && (
44
+ <span className="squisq-status-item squisq-status-ok">✓ OK</span>
45
+ )}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * TemplateAnnotation — Tiptap Heading Extension
3
+ *
4
+ * Extends Tiptap's built-in Heading node to support `data-template` and
5
+ * `data-template-params` HTML attributes. These attributes store which block
6
+ * template should be used for a heading section.
7
+ *
8
+ * When present, the heading renders a visible badge (styled CSS chip)
9
+ * showing the template name, e.g. `[chart]`.
10
+ *
11
+ * The tiptapBridge converts `### Title {[chart]}` markdown into
12
+ * `<h3 data-template="chart">Title</h3>` and back, so this extension
13
+ * ensures Tiptap's schema preserves those attributes through edits.
14
+ */
15
+
16
+ import Heading from '@tiptap/extension-heading';
17
+
18
+ /**
19
+ * HeadingWithTemplate — drop-in replacement for Tiptap's Heading that
20
+ * persists template annotation attributes.
21
+ */
22
+ export const HeadingWithTemplate = Heading.extend({
23
+ addAttributes() {
24
+ return {
25
+ ...this.parent?.(),
26
+ dataTemplate: {
27
+ default: null,
28
+ parseHTML: (element: HTMLElement) => element.getAttribute('data-template') || null,
29
+ renderHTML: (attributes: Record<string, unknown>) => {
30
+ if (!attributes.dataTemplate) return {};
31
+ return { 'data-template': attributes.dataTemplate };
32
+ },
33
+ },
34
+ dataTemplateParams: {
35
+ default: null,
36
+ parseHTML: (element: HTMLElement) => element.getAttribute('data-template-params') || null,
37
+ renderHTML: (attributes: Record<string, unknown>) => {
38
+ if (!attributes.dataTemplateParams) return {};
39
+ return { 'data-template-params': attributes.dataTemplateParams };
40
+ },
41
+ },
42
+ };
43
+ },
44
+
45
+ renderHTML({ node, HTMLAttributes }) {
46
+ const level = node.attrs.level;
47
+ const tag = `h${level}`;
48
+ const templateName = HTMLAttributes['data-template'];
49
+
50
+ if (templateName) {
51
+ // Render heading with a trailing badge span
52
+ return [
53
+ tag,
54
+ HTMLAttributes,
55
+ ['span', { class: 'squisq-heading-content' }, 0],
56
+ [
57
+ 'span',
58
+ {
59
+ class: 'squisq-template-badge',
60
+ contenteditable: 'false',
61
+ 'data-template': templateName,
62
+ },
63
+ templateName,
64
+ ],
65
+ ];
66
+ }
67
+
68
+ // No template — render as normal heading
69
+ return [tag, HTMLAttributes, 0];
70
+ },
71
+ });