@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.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@elixpo/lixeditor",
3
+ "version": "2.1.6",
4
+ "description": "A rich WYSIWYG block editor and renderer built on BlockNote — equations, mermaid diagrams, code highlighting, and more.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "require": "./dist/index.cjs"
11
+ },
12
+ "./styles": "./dist/styles/index.css"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "scripts": {
19
+ "build": "node build.mjs",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "peerDependencies": {
23
+ "react": ">=18.0.0",
24
+ "react-dom": ">=18.0.0",
25
+ "@blocknote/core": ">=0.30.0",
26
+ "@blocknote/react": ">=0.30.0",
27
+ "@blocknote/mantine": ">=0.30.0"
28
+ },
29
+ "dependencies": {
30
+ "katex": "^0.16.0",
31
+ "mermaid": "^11.0.0",
32
+ "shiki": "^3.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "esbuild": "^0.24.0"
36
+ },
37
+ "keywords": [
38
+ "editor",
39
+ "wysiwyg",
40
+ "blocknote",
41
+ "rich-text",
42
+ "markdown",
43
+ "latex",
44
+ "mermaid",
45
+ "code-highlighting",
46
+ "react"
47
+ ],
48
+ "license": "MIT",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/Elixpo-Gaming/lixeditor"
52
+ }
53
+ }
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ import { createReactBlockSpec } from '@blocknote/react';
4
+ import { useState, useEffect, useRef, useCallback } from 'react';
5
+ import katex from 'katex';
6
+
7
+ // Strip \[...\], $$...$$, \(...\), $...$ delimiters — KaTeX expects the inner expression only
8
+ function stripDelimiters(raw) {
9
+ let s = raw.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('\\)')) return s.slice(2, -2).trim();
13
+ if (s.startsWith('$') && s.endsWith('$') && s.length > 2) return s.slice(1, -1).trim();
14
+ return s;
15
+ }
16
+
17
+ function renderKaTeX(latex, displayMode = true) {
18
+ try {
19
+ return katex.renderToString(stripDelimiters(latex), { displayMode, throwOnError: false });
20
+ } catch {
21
+ return `<span style="color:#f87171">${latex}</span>`;
22
+ }
23
+ }
24
+
25
+ export const BlockEquation = createReactBlockSpec(
26
+ {
27
+ type: 'blockEquation',
28
+ propSchema: {
29
+ latex: { default: '' },
30
+ },
31
+ content: 'none',
32
+ },
33
+ {
34
+ render: ({ block, editor }) => {
35
+ const [editing, setEditing] = useState(!block.props.latex);
36
+ const [value, setValue] = useState(block.props.latex || '');
37
+ const [livePreview, setLivePreview] = useState(block.props.latex || '');
38
+ const inputRef = useRef(null);
39
+ const debounceRef = useRef(null);
40
+
41
+ useEffect(() => {
42
+ if (editing) inputRef.current?.focus();
43
+ }, [editing]);
44
+
45
+ const handleCodeChange = useCallback((e) => {
46
+ const v = e.target.value;
47
+ setValue(v);
48
+ clearTimeout(debounceRef.current);
49
+ debounceRef.current = setTimeout(() => setLivePreview(v), 200);
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ return () => clearTimeout(debounceRef.current);
54
+ }, []);
55
+
56
+ const save = () => {
57
+ editor.updateBlock(block, { props: { latex: value } });
58
+ setEditing(false);
59
+ };
60
+
61
+ if (editing) {
62
+ return (
63
+ <div className="mermaid-block mermaid-block--editing">
64
+ <div className="mermaid-block-header">
65
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c4b5fd" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
66
+ <path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"/>
67
+ </svg>
68
+ <span>LaTeX Equation</span>
69
+ <span style={{ marginLeft: 'auto', fontSize: '10px', color: 'var(--text-faint)' }}>Shift+Enter to save</span>
70
+ </div>
71
+ <textarea
72
+ ref={inputRef}
73
+ value={value}
74
+ onChange={handleCodeChange}
75
+ onKeyDown={(e) => {
76
+ if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); save(); }
77
+ if (e.key === 'Escape') { setEditing(false); setValue(block.props.latex || ''); setLivePreview(block.props.latex || ''); }
78
+ }}
79
+ placeholder="E = mc^2"
80
+ rows={4}
81
+ className="mermaid-block-textarea"
82
+ />
83
+ {/* Live KaTeX preview */}
84
+ {livePreview.trim() && (
85
+ <div className="latex-live-preview">
86
+ <div className="latex-live-preview-label">Preview</div>
87
+ <div dangerouslySetInnerHTML={{ __html: renderKaTeX(livePreview) }} />
88
+ </div>
89
+ )}
90
+ <div className="mermaid-block-actions">
91
+ <button onClick={() => { setEditing(false); setValue(block.props.latex || ''); setLivePreview(block.props.latex || ''); }} className="mermaid-btn-cancel">Cancel</button>
92
+ <button onClick={save} className="mermaid-btn-save" disabled={!value.trim()}>Done</button>
93
+ </div>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ const latex = block.props.latex;
99
+ if (!latex) {
100
+ return (
101
+ <div
102
+ onClick={() => setEditing(true)}
103
+ className="mermaid-block mermaid-block--empty"
104
+ >
105
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
106
+ <path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"/>
107
+ </svg>
108
+ <span>Click to add a block equation</span>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div
115
+ onClick={() => setEditing(true)}
116
+ className="editor-block-equation"
117
+ dangerouslySetInnerHTML={{ __html: renderKaTeX(latex) }}
118
+ />
119
+ );
120
+ },
121
+ }
122
+ );
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { createReactBlockSpec } from '@blocknote/react';
4
+ import { useState, useEffect, useRef } from 'react';
5
+
6
+ const BUTTON_ACTIONS = [
7
+ { value: 'link', label: 'Open Link' },
8
+ { value: 'copy', label: 'Copy Text' },
9
+ { value: 'scroll-top', label: 'Scroll to Top' },
10
+ { value: 'share', label: 'Share Page' },
11
+ ];
12
+
13
+ const BUTTON_VARIANTS = [
14
+ { value: 'primary', label: 'Primary', cls: 'bg-[#9b7bf7] text-[var(--text-primary)] hover:bg-[#b69aff]' },
15
+ { value: 'secondary', label: 'Secondary', cls: 'bg-[var(--bg-surface)] border border-[var(--border-default)] text-[var(--text-primary)] hover:border-[var(--border-hover)]' },
16
+ { value: 'accent', label: 'Accent', cls: 'bg-[#9b7bf7] text-[var(--text-primary)] hover:bg-[#b69aff]' },
17
+ ];
18
+
19
+ export const ButtonBlock = createReactBlockSpec(
20
+ {
21
+ type: 'buttonBlock',
22
+ propSchema: {
23
+ label: { default: 'Button' },
24
+ action: { default: 'link' },
25
+ actionValue: { default: '' },
26
+ variant: { default: 'primary' },
27
+ },
28
+ content: 'none',
29
+ },
30
+ {
31
+ render: ({ block, editor }) => {
32
+ const [editing, setEditing] = useState(!block.props.label || block.props.label === 'Button');
33
+ const [label, setLabel] = useState(block.props.label);
34
+ const [action, setAction] = useState(block.props.action);
35
+ const [actionValue, setActionValue] = useState(block.props.actionValue);
36
+ const [variant, setVariant] = useState(block.props.variant);
37
+
38
+ const save = () => {
39
+ editor.updateBlock(block, { props: { label, action, actionValue, variant } });
40
+ setEditing(false);
41
+ };
42
+
43
+ if (editing) {
44
+ return (
45
+ <div className="border border-[var(--border-default)] rounded-xl bg-[var(--bg-surface)] p-4 my-2 space-y-3">
46
+ <p className="text-[11px] text-[var(--text-muted)] font-medium">Button Block</p>
47
+ <input
48
+ type="text"
49
+ value={label}
50
+ onChange={(e) => setLabel(e.target.value)}
51
+ placeholder="Button label"
52
+ className="w-full bg-[var(--bg-app)] border border-[var(--border-default)] rounded-lg px-3 py-2 text-[13px] text-[var(--text-primary)] outline-none focus:border-[var(--border-hover)] placeholder-[#6b7a8d]"
53
+ />
54
+ <div className="flex gap-2">
55
+ <select value={action} onChange={(e) => setAction(e.target.value)} className="bg-[var(--bg-app)] border border-[var(--border-default)] rounded-lg px-3 py-2 text-[13px] text-[var(--text-primary)] outline-none flex-1">
56
+ {BUTTON_ACTIONS.map((a) => <option key={a.value} value={a.value}>{a.label}</option>)}
57
+ </select>
58
+ <select value={variant} onChange={(e) => setVariant(e.target.value)} className="bg-[var(--bg-app)] border border-[var(--border-default)] rounded-lg px-3 py-2 text-[13px] text-[var(--text-primary)] outline-none">
59
+ {BUTTON_VARIANTS.map((v) => <option key={v.value} value={v.value}>{v.label}</option>)}
60
+ </select>
61
+ </div>
62
+ {(action === 'link' || action === 'copy') && (
63
+ <input
64
+ type="text"
65
+ value={actionValue}
66
+ onChange={(e) => setActionValue(e.target.value)}
67
+ placeholder={action === 'link' ? 'https://...' : 'Text to copy'}
68
+ className="w-full bg-[var(--bg-app)] border border-[var(--border-default)] rounded-lg px-3 py-2 text-[13px] text-[var(--text-primary)] outline-none focus:border-[var(--border-hover)] placeholder-[#6b7a8d]"
69
+ />
70
+ )}
71
+ <div className="flex justify-end gap-2">
72
+ <button onClick={() => setEditing(false)} className="px-3 py-1 text-[12px] text-[#888] hover:text-[var(--text-primary)] transition-colors">Cancel</button>
73
+ <button onClick={save} className="px-3 py-1 text-[12px] bg-[#9b7bf7] text-[var(--text-primary)] rounded-md font-medium hover:bg-[#b69aff] transition-colors">Done</button>
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ const variantCls = BUTTON_VARIANTS.find((v) => v.value === variant)?.cls || BUTTON_VARIANTS[0].cls;
80
+
81
+ return (
82
+ <div className="my-2" onDoubleClick={() => setEditing(true)}>
83
+ <button className={`px-5 py-2 rounded-lg text-[13px] font-medium transition-colors ${variantCls}`}>
84
+ {label}
85
+ </button>
86
+ </div>
87
+ );
88
+ },
89
+ }
90
+ );
@@ -0,0 +1,170 @@
1
+ 'use client';
2
+
3
+ import { createReactInlineContentSpec } from '@blocknote/react';
4
+ import { useState, useRef, useEffect, useCallback } from 'react';
5
+
6
+ function MiniCalendar({ selectedDate, onSelect, onClose, anchorEl }) {
7
+ const ref = useRef(null);
8
+ const [viewDate, setViewDate] = useState(() => {
9
+ const d = selectedDate ? new Date(selectedDate) : new Date();
10
+ return { year: d.getFullYear(), month: d.getMonth() };
11
+ });
12
+ const [pos, setPos] = useState(null);
13
+
14
+ useEffect(() => {
15
+ function handleClick(e) {
16
+ if (ref.current && !ref.current.contains(e.target)) onClose();
17
+ }
18
+ document.addEventListener('mousedown', handleClick);
19
+ return () => document.removeEventListener('mousedown', handleClick);
20
+ }, [onClose]);
21
+
22
+ // Position below the anchor, clamped to viewport
23
+ useEffect(() => {
24
+ if (!anchorEl) return;
25
+ const rect = anchorEl.getBoundingClientRect();
26
+ const calWidth = 240;
27
+ let left = rect.left;
28
+ left = Math.max(8, Math.min(left, window.innerWidth - calWidth - 8));
29
+ setPos({ top: rect.bottom + 4, left });
30
+ }, [anchorEl]);
31
+
32
+ const { year, month } = viewDate;
33
+ const firstDay = new Date(year, month, 1).getDay();
34
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
35
+ const today = new Date();
36
+ const todayStr = today.toISOString().split('T')[0];
37
+ const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
38
+
39
+ const days = [];
40
+ for (let i = 0; i < firstDay; i++) days.push(null);
41
+ for (let d = 1; d <= daysInMonth; d++) days.push(d);
42
+
43
+ const prev = () => setViewDate(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { ...v, month: v.month - 1 });
44
+ const next = () => setViewDate(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { ...v, month: v.month + 1 });
45
+
46
+ const toDateStr = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
47
+
48
+ if (!pos) return null;
49
+
50
+ return (
51
+ <div
52
+ ref={ref}
53
+ className="fixed z-[100] rounded-xl shadow-2xl overflow-hidden"
54
+ style={{ backgroundColor: 'var(--bg-app)', border: '1px solid var(--border-default)', width: '240px', top: pos.top, left: pos.left }}
55
+ onMouseDown={e => e.stopPropagation()}
56
+ >
57
+ {/* Header */}
58
+ <div className="flex items-center justify-between px-3 py-2" style={{ borderBottom: '1px solid var(--divider)' }}>
59
+ <button onClick={prev} className="w-6 h-6 flex items-center justify-center rounded hover:bg-[var(--bg-hover)] transition-colors" style={{ color: 'var(--text-muted)' }}>
60
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6"/></svg>
61
+ </button>
62
+ <span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>{monthName}</span>
63
+ <button onClick={next} className="w-6 h-6 flex items-center justify-center rounded hover:bg-[var(--bg-hover)] transition-colors" style={{ color: 'var(--text-muted)' }}>
64
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="9 18 15 12 9 6"/></svg>
65
+ </button>
66
+ </div>
67
+
68
+ {/* Day labels */}
69
+ <div className="grid grid-cols-7 px-2 pt-2">
70
+ {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => (
71
+ <div key={d} className="text-center text-[10px] font-medium py-1" style={{ color: 'var(--text-faint)' }}>{d}</div>
72
+ ))}
73
+ </div>
74
+
75
+ {/* Days grid */}
76
+ <div className="grid grid-cols-7 px-2 pb-2 gap-0.5">
77
+ {days.map((d, i) => {
78
+ if (!d) return <div key={`e${i}`} />;
79
+ const dateStr = toDateStr(d);
80
+ const isSelected = dateStr === selectedDate;
81
+ const isToday = dateStr === todayStr;
82
+ return (
83
+ <button
84
+ key={d}
85
+ onClick={() => { onSelect(dateStr); onClose(); }}
86
+ className="w-7 h-7 rounded-lg text-[11px] font-medium flex items-center justify-center transition-all"
87
+ style={{
88
+ backgroundColor: isSelected ? '#9b7bf7' : 'transparent',
89
+ color: isSelected ? 'white' : isToday ? '#9b7bf7' : 'var(--text-body)',
90
+ border: isToday && !isSelected ? '1px solid #9b7bf7' : '1px solid transparent',
91
+ }}
92
+ onMouseEnter={e => { if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--bg-hover)'; }}
93
+ onMouseLeave={e => { if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent'; }}
94
+ >
95
+ {d}
96
+ </button>
97
+ );
98
+ })}
99
+ </div>
100
+
101
+ {/* Footer */}
102
+ <div className="flex items-center justify-between px-3 py-1.5" style={{ borderTop: '1px solid var(--divider)' }}>
103
+ <button
104
+ onClick={() => { onSelect(''); onClose(); }}
105
+ className="text-[10px] font-medium transition-colors" style={{ color: 'var(--text-faint)' }}
106
+ >Clear</button>
107
+ <button
108
+ onClick={() => { onSelect(todayStr); onClose(); }}
109
+ className="text-[10px] font-medium transition-colors" style={{ color: '#9b7bf7' }}
110
+ >Today</button>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function DateChip({ inlineContent }) {
117
+ const [showPicker, setShowPicker] = useState(false);
118
+ const chipRef = useRef(null);
119
+ const d = inlineContent.props.date;
120
+
121
+ let formatted;
122
+ try {
123
+ formatted = new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
124
+ } catch {
125
+ formatted = d;
126
+ }
127
+
128
+ const handleSelect = useCallback((newDate) => {
129
+ if (newDate) {
130
+ inlineContent.props.date = newDate;
131
+ }
132
+ setShowPicker(false);
133
+ }, [inlineContent]);
134
+
135
+ return (
136
+ <span className="relative inline-flex items-center">
137
+ <span
138
+ ref={chipRef}
139
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); setShowPicker(!showPicker); }}
140
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[13px] font-medium mx-0.5 cursor-pointer transition-all hover:ring-2 hover:ring-[#9b7bf7]/30"
141
+ style={{ color: '#9b7bf7', backgroundColor: 'rgba(155,123,247,0.06)', border: '1px solid rgba(155,123,247,0.15)' }}
142
+ title="Click to change date (Ctrl+D to insert)"
143
+ >
144
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" strokeWidth={2} /><line x1="16" y1="2" x2="16" y2="6" strokeWidth={2} /><line x1="8" y1="2" x2="8" y2="6" strokeWidth={2} /><line x1="3" y1="10" x2="21" y2="10" strokeWidth={2} /></svg>
145
+ {formatted}
146
+ </span>
147
+ {showPicker && (
148
+ <MiniCalendar
149
+ selectedDate={d}
150
+ onSelect={handleSelect}
151
+ onClose={() => setShowPicker(false)}
152
+ anchorEl={chipRef.current}
153
+ />
154
+ )}
155
+ </span>
156
+ );
157
+ }
158
+
159
+ export const DateInline = createReactInlineContentSpec(
160
+ {
161
+ type: 'dateInline',
162
+ propSchema: {
163
+ date: { default: new Date().toISOString().split('T')[0] },
164
+ },
165
+ content: 'none',
166
+ },
167
+ {
168
+ render: (props) => <DateChip {...props} />,
169
+ }
170
+ );