@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/README.md +139 -0
- package/dist/index.cjs +2364 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.js +2337 -0
- package/dist/index.js.map +7 -0
- package/dist/styles/blocks.css +1154 -0
- package/dist/styles/blog-image.css +468 -0
- package/dist/styles/editor.css +499 -0
- package/dist/styles/index.css +11 -0
- package/dist/styles/menus.css +518 -0
- package/dist/styles/preview.css +620 -0
- package/dist/styles/variables.css +75 -0
- package/package.json +53 -0
- package/src/blocks/BlockEquation.jsx +122 -0
- package/src/blocks/ButtonBlock.jsx +90 -0
- package/src/blocks/DateInline.jsx +170 -0
- package/src/blocks/ImageBlock.jsx +274 -0
- package/src/blocks/InlineEquation.jsx +108 -0
- package/src/blocks/MermaidBlock.jsx +430 -0
- package/src/blocks/PDFEmbedBlock.jsx +200 -0
- package/src/blocks/SubpageBlock.jsx +180 -0
- package/src/blocks/TableOfContents.jsx +44 -0
- package/src/blocks/index.js +8 -0
- package/src/editor/KeyboardShortcutsModal.jsx +126 -0
- package/src/editor/LinkPreviewTooltip.jsx +165 -0
- package/src/editor/LixEditor.jsx +342 -0
- package/src/hooks/useLixTheme.js +55 -0
- package/src/index.js +41 -0
- package/src/preview/LixPreview.jsx +191 -0
- package/src/preview/renderBlocks.js +163 -0
- package/src/styles/blocks.css +1154 -0
- package/src/styles/blog-image.css +468 -0
- package/src/styles/editor.css +499 -0
- package/src/styles/index.css +11 -0
- package/src/styles/menus.css +518 -0
- package/src/styles/preview.css +620 -0
- package/src/styles/variables.css +75 -0
|
@@ -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
|
+
}
|