rem-notepad / src /components /Editor /HoverToolbar.tsx
algorembrant's picture
Upload 31 files
4af09f9 verified
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useCallback, useEffect, useState } from 'react';
import {
FORMAT_TEXT_COMMAND,
$getSelection,
$isRangeSelection,
TextFormatType
} from 'lexical';
import {
TextBoldIcon,
TextItalicIcon,
TextStrikethroughIcon,
MagicWand01Icon,
PaintBucketIcon
} from 'hugeicons-react';
import { $patchStyleText, $getSelectionStyleValueForProperty } from '@lexical/selection';
export default function HoverToolbar() {
const [editor] = useLexicalComposerContext();
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [position, setPosition] = useState({ top: -10000, left: -10000, visible: false });
const [showColorPicker, setShowColorPicker] = useState(false);
const colors = [
'#000000', '#FF0000', '#00FF00', '#0000FF',
'#FFA500', '#800080', '#008080', '#FF69B4'
];
const updateToolbar = useCallback(() => {
editor.getEditorState().read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
const nativeSelection = window.getSelection();
if (nativeSelection && nativeSelection.rangeCount > 0) {
const domRange = nativeSelection.getRangeAt(0);
const rect = domRange.getBoundingClientRect();
setPosition({
top: rect.top - 40,
left: rect.left + (rect.width / 2) - 80,
visible: true
});
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
}
} else {
setPosition(p => ({ ...p, visible: false }));
}
});
}, [editor]);
useEffect(() => {
const unregister = editor.registerUpdateListener(() => {
updateToolbar();
});
document.addEventListener('selectionchange', updateToolbar);
return () => {
unregister();
document.removeEventListener('selectionchange', updateToolbar);
};
}, [editor, updateToolbar]);
const applyFormat = (format: TextFormatType) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
};
const applyPixelize = () => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const currentFilter = $getSelectionStyleValueForProperty(selection, 'filter');
if (currentFilter === 'blur(3px)') {
$patchStyleText(selection, { 'filter': '' });
} else {
$patchStyleText(selection, { 'filter': 'blur(3px)' });
}
}
});
};
const applyColor = (color: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { 'color': color });
}
});
setShowColorPicker(false);
};
if (!position.visible) return null;
return (
<div
style={{
position: 'fixed',
top: position.top,
left: position.left,
backgroundColor: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '4px',
display: 'flex',
gap: '4px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 1000,
alignItems: 'center'
}}
>
<button
style={{ padding: 4, background: isBold ? '#eee' : 'transparent', borderRadius: 4 }}
onClick={() => applyFormat('bold')}
>
<TextBoldIcon size={16} />
</button>
<button
style={{ padding: 4, background: isItalic ? '#eee' : 'transparent', borderRadius: 4 }}
onClick={() => applyFormat('italic')}
>
<TextItalicIcon size={16} />
</button>
<button
style={{ padding: 4, background: isStrikethrough ? '#eee' : 'transparent', borderRadius: 4 }}
onClick={() => applyFormat('strikethrough')}
>
<TextStrikethroughIcon size={16} />
</button>
<div style={{ width: 1, height: 16, backgroundColor: '#ddd', margin: '0 4px' }} />
<button
style={{ padding: 4, background: 'transparent', borderRadius: 4, display: 'flex', alignItems: 'center' }}
onClick={applyPixelize}
title="Pixelize / Blur"
>
<MagicWand01Icon size={16} />
</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<button
onClick={() => setShowColorPicker(!showColorPicker)}
style={{ padding: 4, background: 'transparent', borderRadius: 4, display: 'flex', alignItems: 'center' }}
title="Text Color"
>
<PaintBucketIcon size={16} />
</button>
{showColorPicker && (
<div style={{
position: 'absolute', top: 32, left: 0, zIndex: 100,
background: 'var(--bg-panel)', border: '1px solid var(--border-color)',
borderRadius: '6px', padding: '12px', display: 'flex', flexDirection: 'column', gap: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)', minWidth: 160
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4px' }}>
{colors.map(c => (
<div
key={c}
onClick={() => applyColor(c)}
style={{ width: 24, height: 24, background: c, borderRadius: '2px', cursor: 'pointer', border: '1px solid rgba(0,0,0,0.05)' }}
/>
))}
</div>
<div style={{ height: 1, backgroundColor: 'var(--border-color)', margin: '4px 0' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
onChange={(e) => applyColor(e.target.value)}
style={{ width: 32, height: 32, border: 'none', background: 'none', padding: 0, cursor: 'pointer' }}
/>
<input
type="text"
placeholder="#000000"
style={{ flex: 1, fontSize: '11px', padding: '4px 8px', border: '1px solid var(--border-color)', borderRadius: '4px', outline: 'none' }}
onKeyDown={(e: any) => { if (e.key === 'Enter') applyColor(e.target.value); }}
/>
</div>
<div style={{ fontSize: '9px', opacity: 0.5, textAlign: 'center' }}>Enter to apply Hex</div>
</div>
)}
</div>
</div>
);
}