@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.
- package/dist/index.d.ts +269 -0
- package/dist/index.js +3825 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/EditorContext.tsx +251 -0
- package/src/EditorShell.tsx +139 -0
- package/src/PreviewPanel.tsx +562 -0
- package/src/RawEditor.tsx +151 -0
- package/src/StatusBar.tsx +48 -0
- package/src/TemplateAnnotation.ts +71 -0
- package/src/Toolbar.tsx +465 -0
- package/src/ViewSwitcher.tsx +46 -0
- package/src/WysiwygEditor.tsx +134 -0
- package/src/index.ts +58 -0
- package/src/styles/editor.css +594 -0
- package/src/tiptapBridge.ts +425 -0
|
@@ -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
|
+
});
|