@elixpo/lixeditor 2.1.6

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,274 @@
1
+ 'use client';
2
+
3
+ import { createReactBlockSpec } from '@blocknote/react';
4
+ import { useState, useRef, useCallback, useEffect } from 'react';
5
+
6
+ /**
7
+ * Image block for @elixpo/lixeditor.
8
+ * Supports: Upload (base64), Embed URL, Paste, Drag & Drop, Captions.
9
+ * Same class names as LixBlogs for CSS compatibility.
10
+ *
11
+ * For custom upload (e.g. cloud storage), consumers should use the LixBlogs
12
+ * BlogImageBlock or override via extraBlockSpecs.
13
+ */
14
+ export const BlogImageBlock = createReactBlockSpec(
15
+ {
16
+ type: 'image',
17
+ propSchema: {
18
+ url: { default: '' },
19
+ caption: { default: '' },
20
+ previewWidth: { default: 740 },
21
+ name: { default: '' },
22
+ showPreview: { default: true },
23
+ },
24
+ content: 'none',
25
+ },
26
+ {
27
+ render: (props) => <ImageRenderer {...props} />,
28
+ }
29
+ );
30
+
31
+ function ImageRenderer({ block, editor }) {
32
+ const { url, caption } = block.props;
33
+ const [mode, setMode] = useState('idle'); // idle | embed | uploading
34
+ const [embedUrl, setEmbedUrl] = useState('');
35
+ const [embedError, setEmbedError] = useState('');
36
+ const [isDragOver, setIsDragOver] = useState(false);
37
+ const [uploadStatus, setUploadStatus] = useState('');
38
+ const [editingCaption, setEditingCaption] = useState(false);
39
+ const [captionText, setCaptionText] = useState(caption || '');
40
+ const fileInputRef = useRef(null);
41
+ const blockRef = useRef(null);
42
+ const embedInputRef = useRef(null);
43
+
44
+ // Focus input when mode changes
45
+ useEffect(() => {
46
+ if (mode === 'embed') setTimeout(() => embedInputRef.current?.focus(), 50);
47
+ }, [mode]);
48
+
49
+ // Keyboard: backspace to delete when focused
50
+ useEffect(() => {
51
+ const el = blockRef.current;
52
+ if (!el) return;
53
+ function handleKey(e) {
54
+ if ((e.key === 'Backspace' || e.key === 'Delete') && mode === 'idle' && !url) {
55
+ e.preventDefault();
56
+ try { editor.removeBlocks([block.id]); } catch {}
57
+ }
58
+ }
59
+ el.addEventListener('keydown', handleKey);
60
+ return () => el.removeEventListener('keydown', handleKey);
61
+ }, [editor, block.id, mode, url]);
62
+
63
+ // Upload — reads file as base64 data URL
64
+ const uploadFile = useCallback(async (file) => {
65
+ if (!file || !file.type.startsWith('image/')) return;
66
+ setMode('uploading');
67
+ setUploadStatus('Processing...');
68
+ try {
69
+ const reader = new FileReader();
70
+ reader.onload = () => {
71
+ editor.updateBlock(block.id, { props: { url: reader.result, name: file.name } });
72
+ setMode('idle');
73
+ };
74
+ reader.onerror = () => {
75
+ showFailToast('Failed to read image');
76
+ setMode('idle');
77
+ };
78
+ reader.readAsDataURL(file);
79
+ } catch {
80
+ setMode('idle');
81
+ }
82
+ }, [editor, block.id]);
83
+
84
+ // Paste handler
85
+ const handlePaste = useCallback((e) => {
86
+ const items = e.clipboardData?.items;
87
+ if (!items) return;
88
+ for (const item of items) {
89
+ if (item.type.startsWith('image/')) {
90
+ e.preventDefault();
91
+ uploadFile(item.getAsFile());
92
+ return;
93
+ }
94
+ }
95
+ }, [uploadFile]);
96
+
97
+ // Drag and drop
98
+ const handleDrop = useCallback((e) => {
99
+ e.preventDefault();
100
+ setIsDragOver(false);
101
+ const file = e.dataTransfer?.files?.[0];
102
+ if (file?.type.startsWith('image/')) uploadFile(file);
103
+ }, [uploadFile]);
104
+
105
+ // Embed URL submit
106
+ const handleEmbed = useCallback(() => {
107
+ const trimmed = embedUrl.trim();
108
+ if (!trimmed) return;
109
+ if (!trimmed.startsWith('http')) {
110
+ setEmbedError('URL must start with http:// or https://');
111
+ return;
112
+ }
113
+ editor.updateBlock(block.id, { props: { url: trimmed } });
114
+ setMode('idle');
115
+ setEmbedUrl('');
116
+ setEmbedError('');
117
+ }, [embedUrl, editor, block.id]);
118
+
119
+ // Toast on failure
120
+ const showFailToast = useCallback((msg) => {
121
+ const toast = document.createElement('div');
122
+ toast.className = 'blog-img-fail-toast';
123
+ toast.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink:0"><circle cx="8" cy="8" r="7" stroke="#f87171" stroke-width="1.5"/><path d="M8 4.5v4" stroke="#f87171" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11" r=".75" fill="#f87171"/></svg><span>${msg}</span>`;
124
+ document.body.appendChild(toast);
125
+ setTimeout(() => { toast.classList.add('blog-img-fail-toast--out'); }, 3200);
126
+ setTimeout(() => { toast.remove(); }, 3600);
127
+ }, []);
128
+
129
+ const handleDelete = useCallback(() => {
130
+ try { editor.removeBlocks([block.id]); } catch {}
131
+ }, [editor, block.id]);
132
+
133
+ const handleReplace = useCallback(() => {
134
+ editor.updateBlock(block.id, { props: { url: '' } });
135
+ setMode('idle');
136
+ }, [editor, block.id]);
137
+
138
+ const handleCaptionSave = useCallback(() => {
139
+ editor.updateBlock(block.id, { props: { caption: captionText } });
140
+ setEditingCaption(false);
141
+ }, [editor, block.id, captionText]);
142
+
143
+ // ─── No image yet ───
144
+ if (!url) {
145
+ return (
146
+ <div
147
+ ref={blockRef}
148
+ className="blog-img-empty"
149
+ tabIndex={0}
150
+ onPaste={handlePaste}
151
+ onDrop={handleDrop}
152
+ onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
153
+ onDragLeave={() => setIsDragOver(false)}
154
+ data-drag-over={isDragOver}
155
+ >
156
+ <input
157
+ ref={fileInputRef}
158
+ type="file"
159
+ accept="image/*"
160
+ onChange={(e) => { if (e.target.files?.[0]) uploadFile(e.target.files[0]); e.target.value = ''; }}
161
+ style={{ display: 'none' }}
162
+ />
163
+
164
+ {/* Uploading state */}
165
+ {mode === 'uploading' && (
166
+ <div className="blog-img-status">
167
+ <div className="blog-img-spinner" />
168
+ <span>{uploadStatus}</span>
169
+ </div>
170
+ )}
171
+
172
+ {/* Idle — 2 action buttons (no AI) */}
173
+ {mode === 'idle' && (
174
+ <>
175
+ <div className="blog-img-actions-row">
176
+ <button className="blog-img-action" onClick={() => fileInputRef.current?.click()}>
177
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
178
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
179
+ <polyline points="17 8 12 3 7 8" />
180
+ <line x1="12" y1="3" x2="12" y2="15" />
181
+ </svg>
182
+ Upload
183
+ </button>
184
+ <button className="blog-img-action" onClick={() => setMode('embed')}>
185
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
186
+ <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
187
+ <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
188
+ </svg>
189
+ Embed URL
190
+ </button>
191
+ </div>
192
+ <p className="blog-img-hint">or drag & drop / paste an image</p>
193
+ </>
194
+ )}
195
+
196
+ {/* Embed URL input */}
197
+ {mode === 'embed' && (
198
+ <div className="blog-img-input-row">
199
+ <input
200
+ ref={embedInputRef}
201
+ type="url"
202
+ value={embedUrl}
203
+ onChange={(e) => { setEmbedUrl(e.target.value); setEmbedError(''); }}
204
+ onKeyDown={(e) => {
205
+ if (e.key === 'Enter') handleEmbed();
206
+ if (e.key === 'Escape') { setMode('idle'); setEmbedUrl(''); setEmbedError(''); }
207
+ }}
208
+ placeholder="https://example.com/image.jpg"
209
+ className="blog-img-url-input"
210
+ />
211
+ <button className="blog-img-submit-btn" onClick={handleEmbed} disabled={!embedUrl.trim()}>
212
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
213
+ <polyline points="20 6 9 17 4 12" />
214
+ </svg>
215
+ </button>
216
+ <button className="blog-img-cancel-btn" onClick={() => { setMode('idle'); setEmbedUrl(''); setEmbedError(''); }}>
217
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
218
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
219
+ </svg>
220
+ </button>
221
+ {embedError && <span className="blog-img-error">{embedError}</span>}
222
+ </div>
223
+ )}
224
+ </div>
225
+ );
226
+ }
227
+
228
+ // ─── Image loaded ───
229
+ return (
230
+ <div ref={blockRef} className="blog-img-loaded" tabIndex={0} onPaste={handlePaste}>
231
+ <div className="blog-img-wrapper">
232
+ <img src={url} alt={caption || 'Image'} className="blog-img-main" draggable={false} />
233
+ <div className="blog-img-hover-overlay">
234
+ <div className="blog-img-hover-actions">
235
+ <button className="blog-img-hover-btn" onClick={handleReplace}>
236
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
237
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
238
+ </svg>
239
+ </button>
240
+ <button className="blog-img-hover-btn" onClick={() => setEditingCaption(true)}>
241
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
242
+ <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
243
+ </svg>
244
+ </button>
245
+ <button className="blog-img-hover-btn blog-img-hover-delete" onClick={handleDelete}>
246
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
247
+ <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
248
+ </svg>
249
+ </button>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ {editingCaption ? (
254
+ <input
255
+ type="text"
256
+ value={captionText}
257
+ onChange={(e) => setCaptionText(e.target.value)}
258
+ onKeyDown={(e) => { if (e.key === 'Enter') handleCaptionSave(); if (e.key === 'Escape') { setEditingCaption(false); setCaptionText(caption || ''); } }}
259
+ onBlur={handleCaptionSave}
260
+ placeholder="Add a caption..."
261
+ className="blog-img-caption-input"
262
+ autoFocus
263
+ />
264
+ ) : (
265
+ <p
266
+ className={`blog-img-caption ${caption ? '' : 'blog-img-caption--empty'}`}
267
+ onClick={() => { setCaptionText(caption || ''); setEditingCaption(true); }}
268
+ >
269
+ {caption || 'Add a caption...'}
270
+ </p>
271
+ )}
272
+ </div>
273
+ );
274
+ }
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import { createReactInlineContentSpec } from '@blocknote/react';
4
+ import { useState, useRef, useEffect, useCallback } from 'react';
5
+ import katex from 'katex';
6
+
7
+ function stripDelimiters(raw) {
8
+ let s = raw.trim();
9
+ if (s.startsWith('\\(') && s.endsWith('\\)')) return s.slice(2, -2).trim();
10
+ if (s.startsWith('\\[') && s.endsWith('\\]')) return s.slice(2, -2).trim();
11
+ if (s.startsWith('$$') && s.endsWith('$$')) return s.slice(2, -2).trim();
12
+ if (s.startsWith('$') && s.endsWith('$') && s.length > 2) return s.slice(1, -1).trim();
13
+ return s;
14
+ }
15
+
16
+ function renderKaTeXInline(latex) {
17
+ try {
18
+ return katex.renderToString(stripDelimiters(latex), { displayMode: false, throwOnError: false });
19
+ } catch {
20
+ return `<span style="color:#f87171">${latex}</span>`;
21
+ }
22
+ }
23
+
24
+ function InlineEquationChip({ inlineContent }) {
25
+ const [editing, setEditing] = useState(false);
26
+ const [value, setValue] = useState(inlineContent.props.latex || '');
27
+ const inputRef = useRef(null);
28
+ const popupRef = useRef(null);
29
+
30
+ useEffect(() => {
31
+ if (editing && inputRef.current) inputRef.current.focus();
32
+ }, [editing]);
33
+
34
+ // Close on click outside
35
+ useEffect(() => {
36
+ if (!editing) return;
37
+ function handleClick(e) {
38
+ if (popupRef.current && !popupRef.current.contains(e.target)) {
39
+ setEditing(false);
40
+ }
41
+ }
42
+ document.addEventListener('mousedown', handleClick);
43
+ return () => document.removeEventListener('mousedown', handleClick);
44
+ }, [editing]);
45
+
46
+ const save = useCallback(() => {
47
+ if (value.trim()) {
48
+ inlineContent.props.latex = value.trim();
49
+ }
50
+ setEditing(false);
51
+ }, [value, inlineContent]);
52
+
53
+ const html = renderKaTeXInline(inlineContent.props.latex);
54
+
55
+ // Live preview while editing
56
+ const previewHtml = value.trim() ? renderKaTeXInline(value) : '';
57
+
58
+ return (
59
+ <span className="relative inline-flex items-center">
60
+ <span
61
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); setValue(inlineContent.props.latex || ''); setEditing(!editing); }}
62
+ className="inline-equation-chip"
63
+ dangerouslySetInnerHTML={{ __html: html }}
64
+ title={inlineContent.props.latex}
65
+ />
66
+ {editing && (
67
+ <div
68
+ ref={popupRef}
69
+ className="inline-equation-editor"
70
+ onMouseDown={(e) => e.stopPropagation()}
71
+ >
72
+ <input
73
+ ref={inputRef}
74
+ type="text"
75
+ className="inline-equation-editor-input"
76
+ value={value}
77
+ onChange={(e) => setValue(e.target.value)}
78
+ onKeyDown={(e) => {
79
+ if (e.key === 'Enter') { e.preventDefault(); save(); }
80
+ if (e.key === 'Escape') { setEditing(false); }
81
+ }}
82
+ placeholder="E = mc^2"
83
+ />
84
+ {previewHtml && (
85
+ <div className="inline-equation-editor-preview" dangerouslySetInnerHTML={{ __html: previewHtml }} />
86
+ )}
87
+ <div className="inline-equation-editor-actions">
88
+ <button className="mermaid-btn-cancel" onClick={() => setEditing(false)}>Cancel</button>
89
+ <button className="mermaid-btn-save" disabled={!value.trim()} onClick={save}>Save</button>
90
+ </div>
91
+ </div>
92
+ )}
93
+ </span>
94
+ );
95
+ }
96
+
97
+ export const InlineEquation = createReactInlineContentSpec(
98
+ {
99
+ type: 'inlineEquation',
100
+ propSchema: {
101
+ latex: { default: 'x^2' },
102
+ },
103
+ content: 'none',
104
+ },
105
+ {
106
+ render: (props) => <InlineEquationChip {...props} />,
107
+ }
108
+ );