@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,180 @@
1
+ 'use client';
2
+
3
+ import { createReactBlockSpec } from '@blocknote/react';
4
+ import { useState, useRef, useEffect, useCallback } from 'react';
5
+
6
+ export const TabsBlock = createReactBlockSpec(
7
+ {
8
+ type: 'tabsBlock',
9
+ propSchema: {
10
+ tabs: { default: '[]' },
11
+ },
12
+ content: 'none',
13
+ },
14
+ {
15
+ render: ({ block, editor }) => {
16
+ let tabs = [];
17
+ try { tabs = JSON.parse(block.props.tabs); } catch {}
18
+
19
+ const [adding, setAdding] = useState(tabs.length === 0);
20
+ const [newPageName, setNewPageName] = useState('');
21
+ const [creating, setCreating] = useState(false);
22
+ const inputRef = useRef(null);
23
+ const wrapperRef = useRef(null);
24
+
25
+ useEffect(() => {
26
+ if (adding && inputRef.current) inputRef.current.focus();
27
+ if (!adding && tabs.length === 0 && wrapperRef.current) wrapperRef.current.focus();
28
+ }, [adding, tabs.length]);
29
+
30
+ // Sync subpage titles from DB on mount
31
+ useEffect(() => {
32
+ if (tabs.length === 0) return;
33
+ const blogId = getBlogId();
34
+ if (!blogId) return;
35
+ fetch(`/api/subpages?blogId=${blogId}`)
36
+ .then(r => r.ok ? r.json() : null)
37
+ .then(data => {
38
+ if (!data?.subpages) return;
39
+ const titleMap = {};
40
+ data.subpages.forEach(sp => { titleMap[sp.id] = sp.title; });
41
+ let changed = false;
42
+ const updated = tabs.map(t => {
43
+ if (t.subpageId && titleMap[t.subpageId] && titleMap[t.subpageId] !== t.title) {
44
+ changed = true;
45
+ return { ...t, title: titleMap[t.subpageId] };
46
+ }
47
+ return t;
48
+ });
49
+ if (changed) editor.updateBlock(block, { props: { tabs: JSON.stringify(updated) } });
50
+ })
51
+ .catch(() => {});
52
+ }, []);
53
+
54
+ const getBlogId = () => {
55
+ const m = window.location.pathname.match(/\/edit\/([^/]+)/);
56
+ return m?.[1] || '';
57
+ };
58
+
59
+ const addPage = useCallback(async () => {
60
+ const name = newPageName.trim() || 'Untitled Page';
61
+ setCreating(true);
62
+ try {
63
+ const blogId = getBlogId();
64
+ const res = await fetch('/api/subpages', {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify({ blogId, title: name }),
68
+ });
69
+ if (res.ok) {
70
+ const data = await res.json();
71
+ const updated = [...tabs, { title: name, subpageId: data.id }];
72
+ editor.updateBlock(block, { props: { tabs: JSON.stringify(updated) } });
73
+ setNewPageName('');
74
+ setAdding(false);
75
+ }
76
+ } catch {}
77
+ setCreating(false);
78
+ }, [newPageName, tabs, editor, block]);
79
+
80
+ const removePage = useCallback(async (idx) => {
81
+ const tab = tabs[idx];
82
+ if (tab?.subpageId) {
83
+ try { await fetch(`/api/subpages?id=${tab.subpageId}`, { method: 'DELETE' }); } catch {}
84
+ }
85
+ const updated = tabs.filter((_, i) => i !== idx);
86
+ editor.updateBlock(block, { props: { tabs: JSON.stringify(updated) } });
87
+ }, [tabs, editor, block]);
88
+
89
+ const openSubpage = useCallback((tab) => {
90
+ if (!tab.subpageId) return;
91
+ const blogId = getBlogId();
92
+ window.open(`/edit/${blogId}/${tab.subpageId}`, '_blank');
93
+ }, []);
94
+
95
+ const handleBlockKeyDown = (e) => {
96
+ if ((e.key === 'Backspace' || e.key === 'Delete') && tabs.length === 0 && !adding) {
97
+ e.preventDefault();
98
+ e.stopPropagation();
99
+ try { editor.removeBlocks([block.id]); } catch {}
100
+ }
101
+ };
102
+
103
+ // --- Adding state: name input ---
104
+ if (adding) {
105
+ return (
106
+ <div ref={wrapperRef} className="subpage-block" contentEditable={false} tabIndex={0} onKeyDown={handleBlockKeyDown} style={{ outline: 'none' }}>
107
+ <div className="subpage-block-inner subpage-block-adding">
108
+ <div className="subpage-icon subpage-icon--add">
109
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9b7bf7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
110
+ </div>
111
+ <input
112
+ ref={inputRef}
113
+ value={newPageName}
114
+ onChange={(e) => setNewPageName(e.target.value)}
115
+ onKeyDown={(e) => {
116
+ e.stopPropagation();
117
+ if (e.key === 'Enter' && newPageName.trim() && !creating) { e.preventDefault(); addPage(); }
118
+ if (e.key === 'Backspace' && !newPageName) {
119
+ e.preventDefault();
120
+ if (tabs.length === 0) { try { editor.removeBlocks([block.id]); } catch {} }
121
+ else setAdding(false);
122
+ }
123
+ if (e.key === 'Escape') {
124
+ if (tabs.length === 0) { try { editor.removeBlocks([block.id]); } catch {} }
125
+ else setAdding(false);
126
+ }
127
+ }}
128
+ onKeyUp={(e) => e.stopPropagation()}
129
+ disabled={creating}
130
+ autoFocus
131
+ placeholder="Sub-page name..."
132
+ className="subpage-name-input"
133
+ />
134
+ {creating ? (
135
+ <div className="w-4 h-4 border-2 border-[#9b7bf7] border-t-transparent rounded-full animate-spin" />
136
+ ) : (
137
+ <button onClick={addPage} disabled={!newPageName.trim()} className="subpage-create-btn">Create</button>
138
+ )}
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ // --- Rendered state: list of subpages ---
145
+ return (
146
+ <div ref={wrapperRef} className="subpage-block" contentEditable={false} tabIndex={0} onKeyDown={handleBlockKeyDown} style={{ outline: 'none' }}>
147
+ {tabs.map((tab, i) => (
148
+ <div
149
+ key={tab.subpageId || i}
150
+ className="subpage-block-inner subpage-item group/page"
151
+ onClick={() => openSubpage(tab)}
152
+ >
153
+ <div className="subpage-icon">
154
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
155
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
156
+ <polyline points="14 2 14 8 20 8"/>
157
+ <line x1="16" y1="13" x2="8" y2="13"/>
158
+ <line x1="16" y1="17" x2="8" y2="17"/>
159
+ <line x1="10" y1="9" x2="8" y2="9"/>
160
+ </svg>
161
+ </div>
162
+ <span className="subpage-title">{tab.title}</span>
163
+ {/* Delete — hover only */}
164
+ <button
165
+ onClick={(e) => { e.stopPropagation(); removePage(i); }}
166
+ className="subpage-delete-btn"
167
+ title="Remove sub-page"
168
+ >
169
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
170
+ </button>
171
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="subpage-arrow">
172
+ <polyline points="9 18 15 12 9 6"/>
173
+ </svg>
174
+ </div>
175
+ ))}
176
+ </div>
177
+ );
178
+ },
179
+ }
180
+ );
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ import { createReactBlockSpec } from '@blocknote/react';
4
+
5
+ export const TableOfContents = createReactBlockSpec(
6
+ {
7
+ type: 'tableOfContents',
8
+ propSchema: {},
9
+ content: 'none',
10
+ },
11
+ {
12
+ render: ({ editor }) => {
13
+ const headings = editor.document.filter(
14
+ (b) => b.type === 'heading' && b.content?.length > 0
15
+ );
16
+
17
+ return (
18
+ <div className="toc-block border border-[var(--border-default)] rounded-xl bg-[var(--bg-surface)] px-5 py-4 my-2 select-none">
19
+ <p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] font-bold mb-3">Table of Contents</p>
20
+ {headings.length === 0 ? (
21
+ <p className="text-[13px] text-[var(--text-faint)] italic">Add headings to see the outline here.</p>
22
+ ) : (
23
+ <ul className="space-y-1.5">
24
+ {headings.map((h) => {
25
+ const level = h.props?.level || 1;
26
+ const text = h.content.map((c) => c.text || '').join('');
27
+ return (
28
+ <li
29
+ key={h.id}
30
+ className="text-[13px] text-[#9b7bf7] hover:text-[#b69aff] cursor-pointer transition-colors"
31
+ style={{ paddingLeft: `${(level - 1) * 16}px` }}
32
+ onClick={() => editor.setTextCursorPosition(h.id)}
33
+ >
34
+ {text}
35
+ </li>
36
+ );
37
+ })}
38
+ </ul>
39
+ )}
40
+ </div>
41
+ );
42
+ },
43
+ }
44
+ );
@@ -0,0 +1,8 @@
1
+ export { BlockEquation } from './BlockEquation';
2
+ export { InlineEquation } from './InlineEquation';
3
+ export { DateInline } from './DateInline';
4
+ export { MermaidBlock } from './MermaidBlock';
5
+ export { TableOfContents } from './TableOfContents';
6
+ export { ButtonBlock } from './ButtonBlock';
7
+ export { PDFEmbedBlock } from './PDFEmbedBlock';
8
+ export { BlogImageBlock as ImageBlock } from './ImageBlock';
@@ -0,0 +1,126 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ const SHORTCUT_GROUPS = [
6
+ {
7
+ title: 'General',
8
+ shortcuts: [
9
+ { keys: ['Ctrl', 'S'], desc: 'Save & sync to cloud' },
10
+ { keys: ['Ctrl', 'O'], desc: 'Import markdown file (.md)' },
11
+ { keys: ['Ctrl', 'Shift', 'I'], desc: 'Invite collaborators' },
12
+ { keys: ['Ctrl', 'D'], desc: 'Insert date chip' },
13
+ { keys: ['Ctrl', 'Shift', 'P'], desc: 'Toggle editor / preview' },
14
+ { keys: ['Ctrl', 'Z'], desc: 'Undo' },
15
+ { keys: ['Ctrl', 'Shift', 'Z'], desc: 'Redo' },
16
+ { keys: ['Ctrl', 'A'], desc: 'Select all' },
17
+ { keys: ['?'], desc: 'Show this help' },
18
+ ],
19
+ },
20
+ {
21
+ title: 'Text Formatting',
22
+ shortcuts: [
23
+ { keys: ['Ctrl', 'B'], desc: 'Bold' },
24
+ { keys: ['Ctrl', 'I'], desc: 'Italic' },
25
+ { keys: ['Ctrl', 'U'], desc: 'Underline' },
26
+ { keys: ['Ctrl', 'E'], desc: 'Code (inline)' },
27
+ { keys: ['Ctrl', 'Shift', 'S'], desc: 'Strikethrough' },
28
+ { keys: ['Ctrl', 'K'], desc: 'Add link' },
29
+ ],
30
+ },
31
+ {
32
+ title: 'Blocks',
33
+ shortcuts: [
34
+ { keys: ['/'], desc: 'Slash commands menu' },
35
+ { keys: ['Space'], desc: 'AI assistant (on empty line)' },
36
+ { keys: ['@'], desc: 'Mention user/blog/org' },
37
+ { keys: ['Tab'], desc: 'Indent block' },
38
+ { keys: ['Shift', 'Tab'], desc: 'Outdent block' },
39
+ { keys: ['Enter'], desc: 'New block' },
40
+ { keys: ['Backspace'], desc: 'Delete block (when empty)' },
41
+ ],
42
+ },
43
+ {
44
+ title: 'AI',
45
+ shortcuts: [
46
+ { keys: ['Space'], desc: 'Open AI prompt (empty line)' },
47
+ { keys: ['★', 'click'], desc: 'AI edit selected text' },
48
+ { keys: ['Esc'], desc: 'Cancel AI / close menu' },
49
+ ],
50
+ },
51
+ ];
52
+
53
+ export default function KeyboardShortcutsModal({ onClose }) {
54
+ const ref = useRef(null);
55
+
56
+ useEffect(() => {
57
+ function handleKey(e) {
58
+ if (e.key === 'Escape') onClose();
59
+ }
60
+ function handleClick(e) {
61
+ if (ref.current && !ref.current.contains(e.target)) onClose();
62
+ }
63
+ document.addEventListener('keydown', handleKey);
64
+ document.addEventListener('mousedown', handleClick);
65
+ return () => {
66
+ document.removeEventListener('keydown', handleKey);
67
+ document.removeEventListener('mousedown', handleClick);
68
+ };
69
+ }, [onClose]);
70
+
71
+ return (
72
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/30 backdrop-blur-sm">
73
+ <div
74
+ ref={ref}
75
+ className="w-full max-w-[520px] max-h-[80vh] rounded-2xl shadow-2xl overflow-hidden"
76
+ style={{ backgroundColor: 'var(--card-bg)', border: '1px solid var(--border-default)', boxShadow: 'var(--shadow-lg)' }}
77
+ >
78
+ {/* Header */}
79
+ <div className="flex items-center justify-between px-6 py-4" style={{ borderBottom: '1px solid var(--divider)' }}>
80
+ <div className="flex items-center gap-2.5">
81
+ <ion-icon name="keypad-outline" style={{ fontSize: '18px', color: '#9b7bf7' }} />
82
+ <h2 className="text-[15px] font-bold" style={{ color: 'var(--text-primary)' }}>Keyboard Shortcuts</h2>
83
+ </div>
84
+ <button
85
+ onClick={onClose}
86
+ className="transition-colors p-1"
87
+ style={{ color: 'var(--text-faint)' }}
88
+ >
89
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
90
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
91
+ </svg>
92
+ </button>
93
+ </div>
94
+
95
+ {/* Content */}
96
+ <div className="overflow-y-auto max-h-[calc(80vh-60px)] p-6 space-y-6 scrollbar-thin">
97
+ {SHORTCUT_GROUPS.map((group) => (
98
+ <div key={group.title}>
99
+ <h3 className="text-[11px] font-semibold uppercase tracking-wider mb-3" style={{ color: '#9b7bf7' }}>{group.title}</h3>
100
+ <div className="space-y-1">
101
+ {group.shortcuts.map((s, i) => (
102
+ <div key={i} className="flex items-center justify-between py-1.5">
103
+ <span className="text-[13px]" style={{ color: 'var(--text-body)' }}>{s.desc}</span>
104
+ <div className="flex items-center gap-1">
105
+ {s.keys.map((key, j) => (
106
+ <span key={j}>
107
+ {j > 0 && <span className="text-[10px] mx-0.5" style={{ color: 'var(--text-faint)' }}>+</span>}
108
+ <kbd
109
+ className="inline-block min-w-[24px] text-center px-1.5 py-0.5 text-[11px] font-medium rounded-md"
110
+ style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-surface)', border: '1px solid var(--border-default)' }}
111
+ >
112
+ {key}
113
+ </kbd>
114
+ </span>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ ))}
119
+ </div>
120
+ </div>
121
+ ))}
122
+ </div>
123
+ </div>
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+
5
+ // Client-side cache to avoid refetching the same URL
6
+ const previewCache = new Map();
7
+
8
+ let linkPreviewEndpoint = '/api/link-preview';
9
+ export function setLinkPreviewEndpoint(endpoint) { linkPreviewEndpoint = endpoint; }
10
+
11
+ async function fetchPreview(url) {
12
+ if (previewCache.has(url)) return previewCache.get(url);
13
+ try {
14
+ const res = await fetch(`${linkPreviewEndpoint}?url=${encodeURIComponent(url)}`);
15
+ if (!res.ok) throw new Error('fetch failed');
16
+ const data = await res.json();
17
+ previewCache.set(url, data);
18
+ return data;
19
+ } catch {
20
+ const fallback = { title: new URL(url).hostname, description: '', image: '', favicon: '', domain: new URL(url).hostname };
21
+ previewCache.set(url, fallback);
22
+ return fallback;
23
+ }
24
+ }
25
+
26
+ export default function LinkPreviewTooltip({ anchorEl, url, onClose }) {
27
+ const [data, setData] = useState(null);
28
+ const [loading, setLoading] = useState(true);
29
+ const tooltipRef = useRef(null);
30
+ const hoverRef = useRef(false); // true if mouse is over link OR tooltip
31
+ const hideTimerRef = useRef(null);
32
+
33
+ useEffect(() => {
34
+ if (!url) return;
35
+ setLoading(true);
36
+ fetchPreview(url).then(d => {
37
+ setData(d);
38
+ setLoading(false);
39
+ });
40
+ }, [url]);
41
+
42
+ // Position computed once on mount
43
+ const posRef = useRef(null);
44
+ if (!posRef.current && anchorEl) {
45
+ const rect = anchorEl.getBoundingClientRect();
46
+ const tooltipWidth = 320;
47
+ const tooltipHeight = 300;
48
+ let left = rect.left + rect.width / 2 - tooltipWidth / 2;
49
+ left = Math.max(8, Math.min(left, window.innerWidth - tooltipWidth - 8));
50
+
51
+ const spaceBelow = window.innerHeight - rect.bottom;
52
+ if (spaceBelow >= tooltipHeight || spaceBelow >= rect.top) {
53
+ // Show below
54
+ posRef.current = { top: rect.bottom + 4, left };
55
+ } else {
56
+ // Show above — use bottom anchoring
57
+ posRef.current = { bottom: window.innerHeight - rect.top + 4, left, useBottom: true };
58
+ }
59
+ }
60
+
61
+ // Unified hover tracking: mouse over link OR tooltip = alive
62
+ const scheduleHide = useCallback(() => {
63
+ clearTimeout(hideTimerRef.current);
64
+ hideTimerRef.current = setTimeout(() => {
65
+ if (!hoverRef.current) onClose();
66
+ }, 200);
67
+ }, [onClose]);
68
+
69
+ // Track mouse on the anchor link
70
+ useEffect(() => {
71
+ if (!anchorEl) return;
72
+ const onEnter = () => { hoverRef.current = true; clearTimeout(hideTimerRef.current); };
73
+ const onLeave = () => { hoverRef.current = false; scheduleHide(); };
74
+ anchorEl.addEventListener('mouseenter', onEnter);
75
+ anchorEl.addEventListener('mouseleave', onLeave);
76
+ return () => {
77
+ anchorEl.removeEventListener('mouseenter', onEnter);
78
+ anchorEl.removeEventListener('mouseleave', onLeave);
79
+ };
80
+ }, [anchorEl, scheduleHide]);
81
+
82
+ const onTooltipEnter = useCallback(() => {
83
+ hoverRef.current = true;
84
+ clearTimeout(hideTimerRef.current);
85
+ }, []);
86
+
87
+ const onTooltipLeave = useCallback(() => {
88
+ hoverRef.current = false;
89
+ scheduleHide();
90
+ }, [scheduleHide]);
91
+
92
+ useEffect(() => {
93
+ return () => clearTimeout(hideTimerRef.current);
94
+ }, []);
95
+
96
+ if (!url || !posRef.current) return null;
97
+
98
+ const style = posRef.current.useBottom
99
+ ? { bottom: posRef.current.bottom, left: posRef.current.left }
100
+ : { top: posRef.current.top, left: posRef.current.left };
101
+
102
+ return (
103
+ <div
104
+ ref={tooltipRef}
105
+ className="link-preview-tooltip"
106
+ style={style}
107
+ onMouseEnter={onTooltipEnter}
108
+ onMouseLeave={onTooltipLeave}
109
+ >
110
+ {loading ? (
111
+ <div className="link-preview-loading">
112
+ <div className="link-preview-skeleton" style={{ width: '60%', height: 12 }} />
113
+ <div className="link-preview-skeleton" style={{ width: '90%', height: 10, marginTop: 8 }} />
114
+ <div className="link-preview-skeleton" style={{ width: '40%', height: 10, marginTop: 4 }} />
115
+ </div>
116
+ ) : data ? (
117
+ <a href={url} target="_blank" rel="noopener noreferrer" className="link-preview-card">
118
+ {data.image && (
119
+ <div className="link-preview-image">
120
+ <img src={data.image} alt="" onError={e => { e.target.style.display = 'none'; }} />
121
+ </div>
122
+ )}
123
+ <div className="link-preview-body">
124
+ <div className="link-preview-title">{data.title}</div>
125
+ {data.description && (
126
+ <div className="link-preview-desc">{data.description.length > 120 ? data.description.slice(0, 120) + '...' : data.description}</div>
127
+ )}
128
+ <div className="link-preview-domain">
129
+ {data.favicon && <img src={data.favicon} alt="" className="link-preview-favicon" onError={e => { e.target.style.display = 'none'; }} />}
130
+ <span>{data.domain}</span>
131
+ </div>
132
+ </div>
133
+ </a>
134
+ ) : null}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ // Hook to manage link preview state for any container
140
+ export function useLinkPreview() {
141
+ const [preview, setPreview] = useState(null);
142
+ const showTimerRef = useRef(null);
143
+
144
+ const show = useCallback((anchorEl, url) => {
145
+ clearTimeout(showTimerRef.current);
146
+ showTimerRef.current = setTimeout(() => {
147
+ setPreview({ anchorEl, url });
148
+ }, 400);
149
+ }, []);
150
+
151
+ const hide = useCallback(() => {
152
+ clearTimeout(showTimerRef.current);
153
+ setPreview(null);
154
+ }, []);
155
+
156
+ const cancel = useCallback(() => {
157
+ clearTimeout(showTimerRef.current);
158
+ }, []);
159
+
160
+ useEffect(() => {
161
+ return () => clearTimeout(showTimerRef.current);
162
+ }, []);
163
+
164
+ return { preview, show, hide, cancel };
165
+ }