@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,342 @@
1
+ 'use client';
2
+
3
+ import { BlockNoteSchema, defaultBlockSpecs, defaultInlineContentSpecs, createCodeBlockSpec } from '@blocknote/core';
4
+ import { useCreateBlockNote, SuggestionMenuController, getDefaultReactSlashMenuItems, TableHandlesController } from '@blocknote/react';
5
+ import { BlockNoteView } from '@blocknote/mantine';
6
+ import { useCallback, useMemo, forwardRef, useImperativeHandle, useState, useRef, useEffect } from 'react';
7
+ import { useLixTheme } from '../hooks/useLixTheme';
8
+
9
+ // Core blocks
10
+ import { BlockEquation } from '../blocks/BlockEquation';
11
+ import { MermaidBlock } from '../blocks/MermaidBlock';
12
+ import { TableOfContents } from '../blocks/TableOfContents';
13
+ import { InlineEquation } from '../blocks/InlineEquation';
14
+ import { DateInline } from '../blocks/DateInline';
15
+
16
+ // Optional blocks — imported but can be disabled via config
17
+ import { BlogImageBlock as ImageBlock } from '../blocks/ImageBlock';
18
+ import { ButtonBlock } from '../blocks/ButtonBlock';
19
+ import { PDFEmbedBlock } from '../blocks/PDFEmbedBlock';
20
+
21
+ // Utilities
22
+ import LinkPreviewTooltip, { useLinkPreview } from './LinkPreviewTooltip';
23
+
24
+ // Default code block languages
25
+ const DEFAULT_LANGUAGES = {
26
+ text: { name: 'Text' },
27
+ javascript: { name: 'JavaScript', aliases: ['js'] },
28
+ typescript: { name: 'TypeScript', aliases: ['ts'] },
29
+ python: { name: 'Python', aliases: ['py'] },
30
+ java: { name: 'Java' },
31
+ c: { name: 'C' },
32
+ cpp: { name: 'C++' },
33
+ csharp: { name: 'C#', aliases: ['cs'] },
34
+ go: { name: 'Go' },
35
+ rust: { name: 'Rust', aliases: ['rs'] },
36
+ ruby: { name: 'Ruby', aliases: ['rb'] },
37
+ php: { name: 'PHP' },
38
+ swift: { name: 'Swift' },
39
+ kotlin: { name: 'Kotlin', aliases: ['kt'] },
40
+ html: { name: 'HTML' },
41
+ css: { name: 'CSS' },
42
+ json: { name: 'JSON' },
43
+ yaml: { name: 'YAML', aliases: ['yml'] },
44
+ markdown: { name: 'Markdown', aliases: ['md'] },
45
+ bash: { name: 'Bash', aliases: ['sh'] },
46
+ shell: { name: 'Shell' },
47
+ sql: { name: 'SQL' },
48
+ graphql: { name: 'GraphQL', aliases: ['gql'] },
49
+ jsx: { name: 'JSX' },
50
+ tsx: { name: 'TSX' },
51
+ vue: { name: 'Vue' },
52
+ svelte: { name: 'Svelte' },
53
+ dart: { name: 'Dart' },
54
+ lua: { name: 'Lua' },
55
+ r: { name: 'R' },
56
+ scala: { name: 'Scala' },
57
+ };
58
+
59
+ /**
60
+ * LixEditor — A rich WYSIWYG block editor.
61
+ *
62
+ * @param {Object} props
63
+ * @param {Array} [props.initialContent] - Initial block content (BlockNote format)
64
+ * @param {Function} [props.onChange] - Called when content changes, receives the editor instance
65
+ * @param {Object} [props.features] - Enable/disable features
66
+ * @param {boolean} [props.features.equations=true] - Block & inline LaTeX equations
67
+ * @param {boolean} [props.features.mermaid=true] - Mermaid diagram blocks
68
+ * @param {boolean} [props.features.codeHighlighting=true] - Shiki syntax highlighting
69
+ * @param {boolean} [props.features.tableOfContents=true] - TOC block
70
+ * @param {boolean} [props.features.images=true] - Image blocks
71
+ * @param {boolean} [props.features.buttons=true] - Button blocks
72
+ * @param {boolean} [props.features.pdf=true] - PDF embed blocks
73
+ * @param {boolean} [props.features.dates=true] - Inline date chips
74
+ * @param {boolean} [props.features.linkPreview=true] - Link hover preview
75
+ * @param {boolean} [props.features.markdownLinks=true] - Auto-convert [text](url) to links
76
+ * @param {Object} [props.codeLanguages] - Custom code block language map (overrides defaults)
77
+ * @param {Array} [props.extraBlockSpecs] - Additional custom block specs to register
78
+ * @param {Array} [props.extraInlineSpecs] - Additional custom inline content specs
79
+ * @param {Array} [props.slashMenuItems] - Additional slash menu items
80
+ * @param {string} [props.placeholder] - Editor placeholder text
81
+ * @param {Object} [props.collaboration] - Yjs collaboration config
82
+ * @param {Function} [props.onReady] - Called when editor is ready
83
+ * @param {React.ReactNode} [props.children] - Additional children rendered inside BlockNoteView
84
+ */
85
+ const LixEditor = forwardRef(function LixEditor({
86
+ initialContent,
87
+ onChange,
88
+ features = {},
89
+ codeLanguages,
90
+ extraBlockSpecs = [],
91
+ extraInlineSpecs = [],
92
+ slashMenuItems: extraSlashItems = [],
93
+ placeholder = "Type '/' for commands...",
94
+ collaboration,
95
+ onReady,
96
+ children,
97
+ }, ref) {
98
+ const { isDark } = useLixTheme();
99
+ const wrapperRef = useRef(null);
100
+ const editorLinkPreview = useLinkPreview();
101
+
102
+ // Merge features with defaults
103
+ const f = {
104
+ equations: true, mermaid: true, codeHighlighting: true,
105
+ tableOfContents: true, images: true, buttons: true, pdf: true,
106
+ dates: true, linkPreview: true, markdownLinks: true,
107
+ ...features,
108
+ };
109
+
110
+ // Build block specs
111
+ const langs = codeLanguages || DEFAULT_LANGUAGES;
112
+ const codeBlock = f.codeHighlighting
113
+ ? createCodeBlockSpec({
114
+ supportedLanguages: langs,
115
+ createHighlighter: async () => {
116
+ const { createHighlighter } = await import('shiki');
117
+ return createHighlighter({
118
+ themes: ['vitesse-dark', 'vitesse-light'],
119
+ langs: Object.keys(langs).filter(k => k !== 'text'),
120
+ });
121
+ },
122
+ })
123
+ : undefined;
124
+
125
+ const schema = useMemo(() => {
126
+ const blockSpecs = { ...defaultBlockSpecs };
127
+ if (codeBlock) blockSpecs.codeBlock = codeBlock;
128
+ if (f.equations) blockSpecs.blockEquation = BlockEquation({});
129
+ if (f.mermaid) blockSpecs.mermaidBlock = MermaidBlock({});
130
+ if (f.tableOfContents) blockSpecs.tableOfContents = TableOfContents({});
131
+ if (f.images) blockSpecs.image = ImageBlock({});
132
+ if (f.buttons) blockSpecs.buttonBlock = ButtonBlock({});
133
+ if (f.pdf) blockSpecs.pdfEmbed = PDFEmbedBlock({});
134
+
135
+ // Register extra block specs
136
+ for (const spec of extraBlockSpecs) {
137
+ if (spec.type && spec.spec) blockSpecs[spec.type] = spec.spec;
138
+ }
139
+
140
+ const inlineContentSpecs = { ...defaultInlineContentSpecs };
141
+ if (f.equations) inlineContentSpecs.inlineEquation = InlineEquation;
142
+ if (f.dates) inlineContentSpecs.dateInline = DateInline;
143
+
144
+ // Register extra inline specs
145
+ for (const spec of extraInlineSpecs) {
146
+ if (spec.type && spec.spec) inlineContentSpecs[spec.type] = spec.spec;
147
+ }
148
+
149
+ return BlockNoteSchema.create({ blockSpecs, inlineContentSpecs });
150
+ }, []);
151
+
152
+ // Sanitize initial content
153
+ const sanitized = useMemo(() => {
154
+ if (!initialContent) return undefined;
155
+ let blocks = initialContent;
156
+ if (typeof blocks === 'string') {
157
+ try { blocks = JSON.parse(blocks); } catch { return undefined; }
158
+ }
159
+ if (!Array.isArray(blocks) || blocks.length === 0) return undefined;
160
+ return blocks;
161
+ }, [initialContent]);
162
+
163
+ const editor = useCreateBlockNote({
164
+ schema,
165
+ ...(collaboration ? { collaboration } : { initialContent: sanitized || undefined }),
166
+ domAttributes: { editor: { class: 'lix-editor' } },
167
+ placeholders: { default: placeholder },
168
+ });
169
+
170
+ useImperativeHandle(ref, () => ({
171
+ getDocument: () => editor.document,
172
+ getEditor: () => editor,
173
+ getBlocks: () => editor.document,
174
+ getHTML: async () => await editor.blocksToHTMLLossy(editor.document),
175
+ getMarkdown: async () => await editor.blocksToMarkdownLossy(editor.document),
176
+ }), [editor]);
177
+
178
+ // Notify parent when ready
179
+ useEffect(() => { if (onReady) onReady(); }, []);
180
+
181
+ // Auto-convert ![alt](url) to image block and [text](url) to link as you type
182
+ useEffect(() => {
183
+ if (!f.markdownLinks || !editor) return;
184
+ const tiptap = editor._tiptapEditor;
185
+ if (!tiptap) return;
186
+
187
+ const handleInput = () => {
188
+ const { state, view } = tiptap;
189
+ const { $from } = state.selection;
190
+ const textBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, '\ufffc');
191
+
192
+ // Image syntax: ![alt](url)
193
+ const imgMatch = textBefore.match(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)$/);
194
+ if (imgMatch) {
195
+ const [fullMatch, alt, imgUrl] = imgMatch;
196
+ const from = $from.pos - fullMatch.length;
197
+ view.dispatch(state.tr.delete(from, $from.pos));
198
+ const cursorBlock = editor.getTextCursorPosition().block;
199
+ editor.insertBlocks(
200
+ [{ type: 'image', props: { url: imgUrl, caption: alt || '' } }],
201
+ cursorBlock, 'after'
202
+ );
203
+ requestAnimationFrame(() => {
204
+ try {
205
+ const block = editor.getTextCursorPosition().block;
206
+ if (block?.type === 'paragraph' && !(block.content || []).some(c => c.text?.trim())) {
207
+ editor.removeBlocks([block.id]);
208
+ }
209
+ } catch {}
210
+ });
211
+ return;
212
+ }
213
+
214
+ // Link syntax: [text](url)
215
+ const match = textBefore.match(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/);
216
+ if (!match) return;
217
+ const [fullMatch, linkText, url] = match;
218
+ const from = $from.pos - fullMatch.length;
219
+ const linkMark = state.schema.marks.link.create({ href: url });
220
+ const tr = state.tr.delete(from, $from.pos).insertText(linkText, from).addMark(from, from + linkText.length, linkMark);
221
+ view.dispatch(tr);
222
+ };
223
+
224
+ tiptap.on('update', handleInput);
225
+ return () => tiptap.off('update', handleInput);
226
+ }, [editor, f.markdownLinks]);
227
+
228
+ // Link preview hover
229
+ useEffect(() => {
230
+ if (!f.linkPreview) return;
231
+ const wrapper = wrapperRef.current;
232
+ if (!wrapper) return;
233
+
234
+ const handleMouseOver = (e) => {
235
+ const link = e.target.closest('a[href]');
236
+ if (!link || link.closest('.bn-link-toolbar') || link.closest('.bn-toolbar')) return;
237
+ const href = link.getAttribute('href');
238
+ if (href && href.startsWith('http')) editorLinkPreview.show(link, href);
239
+ };
240
+ const handleMouseOut = (e) => {
241
+ const link = e.target.closest('a[href]');
242
+ if (!link) return;
243
+ editorLinkPreview.cancel();
244
+ };
245
+ const handleClick = (e) => {
246
+ if (!(e.ctrlKey || e.metaKey)) return;
247
+ const link = e.target.closest('a[href]');
248
+ if (!link || link.closest('.bn-link-toolbar')) return;
249
+ const href = link.getAttribute('href');
250
+ if (href && href.startsWith('http')) {
251
+ e.preventDefault();
252
+ e.stopPropagation();
253
+ window.open(href, '_blank', 'noopener,noreferrer');
254
+ }
255
+ };
256
+ const handleKeyDown = (e) => { if (e.ctrlKey || e.metaKey) wrapper.classList.add('ctrl-held'); };
257
+ const handleKeyUp = () => wrapper.classList.remove('ctrl-held');
258
+
259
+ wrapper.addEventListener('mouseover', handleMouseOver);
260
+ wrapper.addEventListener('mouseout', handleMouseOut);
261
+ wrapper.addEventListener('click', handleClick);
262
+ window.addEventListener('keydown', handleKeyDown);
263
+ window.addEventListener('keyup', handleKeyUp);
264
+ return () => {
265
+ wrapper.removeEventListener('mouseover', handleMouseOver);
266
+ wrapper.removeEventListener('mouseout', handleMouseOut);
267
+ wrapper.removeEventListener('click', handleClick);
268
+ window.removeEventListener('keydown', handleKeyDown);
269
+ window.removeEventListener('keyup', handleKeyUp);
270
+ };
271
+ }, [f.linkPreview]);
272
+
273
+ // Slash menu items
274
+ const getItems = useCallback(async (query) => {
275
+ const defaults = getDefaultReactSlashMenuItems(editor)
276
+ .filter(item => !['video', 'audio', 'file'].includes(item.key));
277
+
278
+ const custom = [];
279
+
280
+ if (f.equations) {
281
+ custom.push({
282
+ title: 'Block Equation',
283
+ subtext: 'LaTeX block equation',
284
+ group: 'Advanced',
285
+ icon: <span style={{ fontSize: 16 }}>∑</span>,
286
+ onItemClick: () => editor.insertBlocks([{ type: 'blockEquation' }], editor.getTextCursorPosition().block, 'after'),
287
+ });
288
+ }
289
+
290
+ if (f.mermaid) {
291
+ custom.push({
292
+ title: 'Diagram',
293
+ subtext: 'Mermaid diagram (flowchart, sequence, etc.)',
294
+ group: 'Advanced',
295
+ icon: <span style={{ fontSize: 14 }}>◇</span>,
296
+ onItemClick: () => editor.insertBlocks([{ type: 'mermaidBlock' }], editor.getTextCursorPosition().block, 'after'),
297
+ });
298
+ }
299
+
300
+ if (f.tableOfContents) {
301
+ custom.push({
302
+ title: 'Table of Contents',
303
+ subtext: 'Auto-generated document outline',
304
+ group: 'Advanced',
305
+ icon: <span style={{ fontSize: 14 }}>☰</span>,
306
+ onItemClick: () => editor.insertBlocks([{ type: 'tableOfContents' }], editor.getTextCursorPosition().block, 'after'),
307
+ });
308
+ }
309
+
310
+ return [...defaults, ...custom, ...extraSlashItems]
311
+ .filter(item => item.title.toLowerCase().includes(query.toLowerCase()));
312
+ }, [editor, f, extraSlashItems]);
313
+
314
+ const handleChange = useCallback(() => {
315
+ if (onChange) onChange(editor);
316
+ }, [editor, onChange]);
317
+
318
+ return (
319
+ <div className={`lix-editor-wrapper${''}`} ref={wrapperRef} style={{ position: 'relative' }}>
320
+ <BlockNoteView
321
+ editor={editor}
322
+ onChange={handleChange}
323
+ theme={isDark ? 'dark' : 'light'}
324
+ slashMenu={false}
325
+ >
326
+ <SuggestionMenuController triggerCharacter="/" getItems={getItems} />
327
+ <TableHandlesController />
328
+ {children}
329
+ </BlockNoteView>
330
+
331
+ {f.linkPreview && editorLinkPreview.preview && (
332
+ <LinkPreviewTooltip
333
+ anchorEl={editorLinkPreview.preview.anchorEl}
334
+ url={editorLinkPreview.preview.url}
335
+ onClose={editorLinkPreview.hide}
336
+ />
337
+ )}
338
+ </div>
339
+ );
340
+ });
341
+
342
+ export default LixEditor;
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useEffect } from 'react';
4
+
5
+ const LixThemeContext = createContext(null);
6
+
7
+ /**
8
+ * Theme provider for the LixEditor package.
9
+ * Manages light/dark theme and applies it to the document.
10
+ *
11
+ * @param {Object} props
12
+ * @param {'light'|'dark'} [props.defaultTheme='light'] - Initial theme
13
+ * @param {string} [props.storageKey='lixeditor_theme'] - localStorage key for persistence
14
+ * @param {React.ReactNode} props.children
15
+ */
16
+ export function LixThemeProvider({ children, defaultTheme = 'light', storageKey = 'lixeditor_theme' }) {
17
+ const [theme, setTheme] = useState(defaultTheme);
18
+ const [mounted, setMounted] = useState(false);
19
+
20
+ useEffect(() => {
21
+ if (storageKey) {
22
+ const saved = localStorage.getItem(storageKey);
23
+ if (saved === 'dark' || saved === 'light') setTheme(saved);
24
+ }
25
+ setMounted(true);
26
+ }, [storageKey]);
27
+
28
+ useEffect(() => {
29
+ if (!mounted) return;
30
+ document.documentElement.setAttribute('data-theme', theme);
31
+ if (storageKey) localStorage.setItem(storageKey, theme);
32
+ }, [theme, mounted, storageKey]);
33
+
34
+ const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
35
+ const isDark = theme === 'dark';
36
+
37
+ return (
38
+ <LixThemeContext.Provider value={{ theme, setTheme, toggleTheme, isDark, mounted }}>
39
+ {children}
40
+ </LixThemeContext.Provider>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Hook to access the current theme.
46
+ * Falls back to detecting data-theme attribute if no provider is present.
47
+ */
48
+ export function useLixTheme() {
49
+ const ctx = useContext(LixThemeContext);
50
+ if (ctx) return ctx;
51
+
52
+ // Fallback: detect theme from DOM
53
+ const isDark = typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') === 'dark';
54
+ return { theme: isDark ? 'dark' : 'light', isDark, toggleTheme: () => {}, setTheme: () => {}, mounted: true };
55
+ }
package/src/index.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @elixpo/lixeditor — A rich WYSIWYG block editor and renderer.
3
+ *
4
+ * Usage:
5
+ * import { LixEditor, LixPreview, LixThemeProvider } from '@elixpo/lixeditor';
6
+ * import '@elixpo/lixeditor/styles';
7
+ *
8
+ * <LixThemeProvider>
9
+ * <LixEditor
10
+ * initialContent={blocks}
11
+ * onChange={(editor) => save(editor.getBlocks())}
12
+ * features={{ equations: true, mermaid: true }}
13
+ * />
14
+ * </LixThemeProvider>
15
+ *
16
+ * <LixPreview blocks={blocks} />
17
+ */
18
+
19
+ // Core components
20
+ export { default as LixEditor } from './editor/LixEditor';
21
+ export { default as LixPreview } from './preview/LixPreview';
22
+
23
+ // Theme
24
+ export { LixThemeProvider, useLixTheme } from './hooks/useLixTheme';
25
+
26
+ // Block specs — for consumers who want to build custom schemas
27
+ export {
28
+ BlockEquation,
29
+ InlineEquation,
30
+ DateInline,
31
+ MermaidBlock,
32
+ TableOfContents,
33
+ ButtonBlock,
34
+ PDFEmbedBlock,
35
+ ImageBlock,
36
+ } from './blocks/index';
37
+
38
+ // Utilities
39
+ export { renderBlocksToHTML } from './preview/renderBlocks';
40
+ export { default as LinkPreviewTooltip, useLinkPreview, setLinkPreviewEndpoint } from './editor/LinkPreviewTooltip';
41
+ export { default as KeyboardShortcutsModal } from './editor/KeyboardShortcutsModal';
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useCallback } from 'react';
4
+ import { useLixTheme } from '../hooks/useLixTheme';
5
+ import LinkPreviewTooltip, { useLinkPreview } from '../editor/LinkPreviewTooltip';
6
+ import { renderBlocksToHTML } from './renderBlocks';
7
+
8
+ /**
9
+ * LixPreview — Renders BlockNote content as styled HTML with post-processing.
10
+ *
11
+ * @param {Object} props
12
+ * @param {Array} props.blocks - BlockNote document blocks
13
+ * @param {string} [props.html] - Fallback raw HTML (used if blocks not provided)
14
+ * @param {Object} [props.features] - Enable/disable features (same keys as LixEditor)
15
+ * @param {string} [props.className] - Additional CSS class
16
+ */
17
+ export default function LixPreview({ blocks, html, features = {}, className = '' }) {
18
+ const { isDark } = useLixTheme();
19
+ const contentRef = useRef(null);
20
+ const linkPreview = useLinkPreview();
21
+ const linkPreviewRef = useRef(linkPreview);
22
+ linkPreviewRef.current = linkPreview;
23
+
24
+ const f = {
25
+ equations: true, mermaid: true, codeHighlighting: true, linkPreview: true,
26
+ ...features,
27
+ };
28
+
29
+ const renderedHTML = blocks && blocks.length > 0 ? renderBlocksToHTML(blocks) : (html || '');
30
+
31
+ // Set innerHTML via ref so React doesn't overwrite post-processed content
32
+ const effectGenRef = useRef(0);
33
+ useEffect(() => {
34
+ const root = contentRef.current;
35
+ if (!root) return;
36
+ const gen = ++effectGenRef.current;
37
+ root.innerHTML = renderedHTML || '';
38
+
39
+ function isStale() { return effectGenRef.current !== gen; }
40
+
41
+ // ── KaTeX rendering ──
42
+ if (f.equations) {
43
+ const eqEls = root.querySelectorAll('.lix-block-equation[data-latex]');
44
+ const inlineEls = root.querySelectorAll('.lix-inline-equation[data-latex]');
45
+ if (eqEls.length || inlineEls.length) {
46
+ import('katex').then((mod) => {
47
+ if (isStale()) return;
48
+ const katex = mod.default || mod;
49
+ const strip = (raw) => {
50
+ let s = raw.trim();
51
+ if (s.startsWith('\\[') && s.endsWith('\\]')) return s.slice(2, -2).trim();
52
+ if (s.startsWith('$$') && s.endsWith('$$')) return s.slice(2, -2).trim();
53
+ if (s.startsWith('\\(') && s.endsWith('\\)')) return s.slice(2, -2).trim();
54
+ if (s.startsWith('$') && s.endsWith('$') && s.length > 2) return s.slice(1, -1).trim();
55
+ return s;
56
+ };
57
+ eqEls.forEach(el => {
58
+ if (!el.isConnected) return;
59
+ try { el.innerHTML = katex.renderToString(strip(decodeURIComponent(el.dataset.latex)), { displayMode: true, throwOnError: false }); }
60
+ catch (err) { el.innerHTML = `<span style="color:#f87171">${err.message}</span>`; }
61
+ });
62
+ inlineEls.forEach(el => {
63
+ if (!el.isConnected) return;
64
+ try { el.innerHTML = katex.renderToString(strip(decodeURIComponent(el.dataset.latex)), { displayMode: false, throwOnError: false }); }
65
+ catch (err) { el.innerHTML = `<span style="color:#f87171">${err.message}</span>`; }
66
+ });
67
+ }).catch(() => {});
68
+ }
69
+ }
70
+
71
+ // ── Mermaid rendering ──
72
+ if (f.mermaid) {
73
+ const mermaidEls = root.querySelectorAll('.lix-mermaid-block[data-diagram]');
74
+ if (mermaidEls.length) {
75
+ import('mermaid').then((mod) => {
76
+ if (isStale()) return;
77
+ const mermaid = mod.default || mod;
78
+ mermaid.initialize({
79
+ startOnLoad: false, securityLevel: 'loose',
80
+ theme: isDark ? 'dark' : 'default',
81
+ flowchart: { useMaxWidth: false, padding: 20 },
82
+ });
83
+ (async () => {
84
+ for (const el of mermaidEls) {
85
+ const id = `lix-mermaid-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
86
+ try {
87
+ const diagram = decodeURIComponent(el.dataset.diagram).trim();
88
+ const tempDiv = document.createElement('div');
89
+ tempDiv.id = 'c-' + id;
90
+ tempDiv.style.cssText = 'position:fixed;top:0;left:0;width:100vw;opacity:0;pointer-events:none;z-index:-9999;';
91
+ document.body.appendChild(tempDiv);
92
+ const { svg } = await mermaid.render(id, diagram, tempDiv);
93
+ tempDiv.remove();
94
+ if (el.isConnected && !isStale()) {
95
+ el.innerHTML = svg;
96
+ const svgEl = el.querySelector('svg');
97
+ if (svgEl) { svgEl.removeAttribute('width'); svgEl.style.width = '100%'; svgEl.style.height = 'auto'; }
98
+ }
99
+ } catch (err) {
100
+ if (el.isConnected) el.innerHTML = `<pre style="color:#f87171;font-size:12px">${err.message || 'Diagram error'}</pre>`;
101
+ try { document.getElementById(id)?.remove(); document.getElementById('c-' + id)?.remove(); } catch {}
102
+ }
103
+ }
104
+ })();
105
+ }).catch(() => {});
106
+ }
107
+ }
108
+
109
+ // ── Shiki code highlighting ──
110
+ if (f.codeHighlighting) {
111
+ const codeEls = root.querySelectorAll('pre > code[class*="language-"]');
112
+ if (codeEls.length) {
113
+ import('shiki').then(({ createHighlighter }) => {
114
+ if (isStale()) return;
115
+ const langs = new Set();
116
+ codeEls.forEach(el => { const m = el.className.match(/language-(\w+)/); if (m?.[1] && m[1] !== 'text') langs.add(m[1]); });
117
+ return createHighlighter({ themes: ['vitesse-dark', 'vitesse-light'], langs: [...langs] }).then(hl => {
118
+ if (isStale()) return;
119
+ const theme = isDark ? 'vitesse-dark' : 'vitesse-light';
120
+ codeEls.forEach(codeEl => {
121
+ const pre = codeEl.parentElement;
122
+ if (!pre || pre.dataset.highlighted) return;
123
+ pre.dataset.highlighted = 'true';
124
+ const m = codeEl.className.match(/language-(\w+)/);
125
+ const lang = m?.[1] || 'text';
126
+ const code = codeEl.textContent || '';
127
+ if (lang !== 'text' && langs.has(lang)) {
128
+ try {
129
+ const html = hl.codeToHtml(code, { lang, theme });
130
+ const tmp = document.createElement('div'); tmp.innerHTML = html;
131
+ const shikiPre = tmp.querySelector('pre');
132
+ if (shikiPre) codeEl.innerHTML = shikiPre.querySelector('code')?.innerHTML || codeEl.innerHTML;
133
+ } catch {}
134
+ }
135
+ // Language label
136
+ pre.style.position = 'relative';
137
+ const label = document.createElement('span');
138
+ label.className = 'lix-code-lang-label';
139
+ label.textContent = lang;
140
+ pre.appendChild(label);
141
+ // Copy button
142
+ const btn = document.createElement('button');
143
+ btn.className = 'lix-code-copy-btn';
144
+ btn.title = 'Copy code';
145
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
146
+ btn.onclick = () => {
147
+ navigator.clipboard.writeText(code);
148
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>';
149
+ btn.style.color = '#86efac';
150
+ setTimeout(() => { btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; btn.style.color = ''; }, 1500);
151
+ };
152
+ pre.appendChild(btn);
153
+ });
154
+ });
155
+ }).catch(() => {});
156
+ }
157
+ }
158
+
159
+ // ── Link preview on hover ──
160
+ if (f.linkPreview) {
161
+ const externalLinks = root.querySelectorAll('a[href^="http"]');
162
+ const handlers = [];
163
+ externalLinks.forEach(link => {
164
+ const href = link.getAttribute('href');
165
+ if (!href) return;
166
+ const onEnter = () => linkPreviewRef.current.show(link, href);
167
+ const onLeave = () => linkPreviewRef.current.cancel();
168
+ link.addEventListener('mouseenter', onEnter);
169
+ link.addEventListener('mouseleave', onLeave);
170
+ handlers.push({ el: link, onEnter, onLeave });
171
+ });
172
+ return () => handlers.forEach(({ el, onEnter, onLeave }) => {
173
+ el.removeEventListener('mouseenter', onEnter);
174
+ el.removeEventListener('mouseleave', onLeave);
175
+ });
176
+ }
177
+ }, [renderedHTML, isDark]);
178
+
179
+ return (
180
+ <div className={`lix-preview ${className}`}>
181
+ <div ref={contentRef} className="lix-preview-content" />
182
+ {f.linkPreview && linkPreview.preview && (
183
+ <LinkPreviewTooltip
184
+ anchorEl={linkPreview.preview.anchorEl}
185
+ url={linkPreview.preview.url}
186
+ onClose={linkPreview.hide}
187
+ />
188
+ )}
189
+ </div>
190
+ );
191
+ }