@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,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  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: 
|
|
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
|
+
}
|