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