Files
openswarm/frontend/src/app/components/RichPromptEditor.tsx
T

376 lines
12 KiB
TypeScript

import React, { useState, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CommandPicker, { CommandPickerItem } from '@/app/components/CommandPicker';
import {
SKILL_PILL_ATTR,
AttachedSkill,
createSkillPillElement,
serializeEditorContent,
deserializeToEditor,
detectEditorTrigger,
TriggerState,
EMPTY_TRIGGER,
} from '@/app/components/richEditorUtils';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface RichPromptEditorProps {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
minRows?: number;
maxRows?: number;
}
const LINE_HEIGHT = 1.5;
const FONT_SIZE = 0.85;
const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
value,
onChange,
label = '',
placeholder = '',
minRows = 3,
maxRows = 8,
}) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
const [hasContent, setHasContent] = useState(false);
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const attachedSkillsRef = useRef(attachedSkills);
attachedSkillsRef.current = attachedSkills;
const removeSkillPillRef = useRef<(id: string) => void>(() => {});
const [picker, setPicker] = useState<TriggerState>(EMPTY_TRIGGER);
const [pickerRect, setPickerRect] = useState<DOMRect | null>(null);
const skills = useAppSelector((state) => state.skills.items);
useEffect(() => {
if (picker.visible && wrapperRef.current) {
setPickerRect(wrapperRef.current.getBoundingClientRect());
} else {
setPickerRect(null);
}
}, [picker.visible]);
const minHeight = minRows * FONT_SIZE * LINE_HEIGHT;
const maxHeight = maxRows * FONT_SIZE * LINE_HEIGHT;
const isLabelFloating = focused || hasContent;
// Sync external value → editor on mount / when value changes externally
const lastEmittedRef = useRef<string | null>(null);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
if (value === lastEmittedRef.current) return;
lastEmittedRef.current = value;
if (/\{\{skill:.+?\}\}/.test(value)) {
const skillsByName: Record<string, AttachedSkill> = {};
for (const s of Object.values(skills)) {
skillsByName[s.name] = { id: s.id, name: s.name, content: s.content };
}
const restored = deserializeToEditor(
editor,
value,
skillsByName,
(id) => removeSkillPillRef.current(id),
c.font.mono,
c.status.error,
);
setAttachedSkills(restored);
} else {
editor.textContent = value;
}
setHasContent(!!value);
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
const emitChange = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
const serialized = serializeEditorContent(editor, attachedSkillsRef.current);
lastEmittedRef.current = serialized;
onChange(serialized);
}, [onChange]);
const updateHasContent = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
const text = (editor.textContent || '').replace(/\u200B/g, '');
const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null;
setHasContent(text.trim().length > 0 || hasPills);
}, []);
const syncAttachedSkills = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
const pillIds = new Set(
Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`))
.map((el) => el.getAttribute(SKILL_PILL_ATTR))
.filter(Boolean) as string[],
);
setAttachedSkills((prev) => {
const prevKeys = Object.keys(prev);
if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev;
const next: Record<string, AttachedSkill> = {};
for (const [id, skill] of Object.entries(prev)) {
if (pillIds.has(id)) next[id] = skill;
}
return next;
});
}, []);
const removeSkillPill = useCallback((skillId: string) => {
const editor = editorRef.current;
if (!editor) return;
const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`);
if (pill) pill.remove();
setAttachedSkills((prev) => {
const { [skillId]: _, ...rest } = prev;
return rest;
});
updateHasContent();
emitChange();
editor.focus();
}, [updateHasContent, emitChange]);
removeSkillPillRef.current = removeSkillPill;
const detectTrigger = useCallback(() => {
const result = detectEditorTrigger();
if (result) {
setPicker(result);
} else {
// See ChatInput: bail when already hidden to avoid a per-keystroke
// re-render of the whole editor on every keypress.
setPicker((p) => p.visible ? { ...p, visible: false } : p);
}
}, []);
// See ChatInput.handleInput: paste skips the heavy DOM scans that
// paste can't invalidate (never adds skill pills, never starts a
// slash/at trigger). emitChange still runs because Modes settings
// is controlled and the parent needs the new value.
const justPastedRef = useRef(false);
const handleInput = useCallback(() => {
if (justPastedRef.current) {
justPastedRef.current = false;
setHasContent(true);
emitChange();
return;
}
updateHasContent();
detectTrigger();
syncAttachedSkills();
emitChange();
}, [updateHasContent, detectTrigger, syncAttachedSkills, emitChange]);
const handleEditorClick = useCallback(() => {
detectTrigger();
}, [detectTrigger]);
const handlePickerSelect = (item: CommandPickerItem) => {
setPicker((p) => ({ ...p, visible: false }));
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const { triggerNode, triggerOffset, filter } = picker;
if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) {
const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length);
const range = document.createRange();
range.setStart(triggerNode, triggerOffset);
range.setEnd(triggerNode, endOffset);
range.deleteContents();
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
}
if (item.type === 'skill') {
const skill = skills[item.id];
if (!skill) return;
if (editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return;
const pill = createSkillPillElement(
{ id: skill.id, name: skill.name, content: skill.content },
removeSkillPill,
c.font.mono,
c.status.error,
);
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.collapse(false);
range.insertNode(pill);
const spacer = document.createTextNode('\u200B');
pill.after(spacer);
const newRange = document.createRange();
newRange.setStartAfter(spacer);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
setAttachedSkills((prev) => ({
...prev,
[skill.id]: { id: skill.id, name: skill.name, content: skill.content },
}));
} else if (item.type === 'mode') {
document.execCommand('insertText', false, item.name);
} else if (item.type === 'context') {
document.execCommand('insertText', false, `@${item.command} `);
}
updateHasContent();
emitChange();
setTimeout(() => editor.focus(), 0);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) {
e.preventDefault();
return;
}
if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) {
e.preventDefault();
return;
}
};
const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault();
const plain = e.clipboardData.getData('text/plain');
if (plain) {
justPastedRef.current = true;
document.execCommand('insertText', false, plain);
}
}, []);
return (
<Box ref={wrapperRef} sx={{ position: 'relative' }}>
{picker.visible && pickerRect && createPortal(
<div
style={{
position: 'fixed',
top: pickerRect.top,
left: pickerRect.left,
width: pickerRect.width,
height: 0,
zIndex: 1400,
pointerEvents: 'none',
}}
>
<div style={{ position: 'relative', width: '100%', pointerEvents: 'auto' }}>
<CommandPicker
trigger={picker.trigger}
filter={picker.filter}
onSelect={handlePickerSelect}
onClose={() => setPicker((p) => ({ ...p, visible: false }))}
visible={picker.visible}
/>
</div>
</div>,
document.body,
)}
<Box
onClick={() => editorRef.current?.focus()}
sx={{
position: 'relative',
border: `1px solid ${focused ? c.accent.primary : c.border.medium}`,
borderRadius: '4px',
bgcolor: c.bg.page,
transition: 'border-color 0.15s',
'&:hover': {
borderColor: focused ? c.accent.primary : c.text.primary,
},
cursor: 'text',
}}
>
{label && (
<Typography
component="label"
sx={{
position: 'absolute',
left: 12,
top: isLabelFloating ? -1 : '50%',
transform: isLabelFloating ? 'translateY(-50%) scale(0.75)' : 'translateY(-50%)',
transformOrigin: 'top left',
color: focused ? c.accent.primary : c.text.tertiary,
fontSize: '1rem',
lineHeight: 1,
pointerEvents: 'none',
transition: 'all 0.15s ease',
bgcolor: isLabelFloating ? c.bg.page : 'transparent',
px: isLabelFloating ? 0.5 : 0,
zIndex: 1,
}}
>
{label}
</Typography>
)}
<Box sx={{ px: 1.75, pt: label ? 2 : 1.25, pb: 1.25, position: 'relative' }}>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={handleInput}
onClick={handleEditorClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
width: '100%',
minHeight: `${minHeight}rem`,
maxHeight: `${maxHeight}rem`,
overflowY: 'auto',
background: 'transparent',
border: 'none',
outline: 'none',
color: c.text.primary,
fontSize: `${FONT_SIZE}rem`,
lineHeight: `${LINE_HEIGHT}`,
fontFamily: 'inherit',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
/>
{!hasContent && (
<div
style={{
position: 'absolute',
top: label ? 16 : 10,
left: 14,
right: 14,
color: c.text.tertiary,
fontSize: `${FONT_SIZE}rem`,
lineHeight: `${LINE_HEIGHT}`,
fontFamily: 'inherit',
pointerEvents: 'none',
userSelect: 'none',
}}
>
{placeholder}
</div>
)}
</Box>
</Box>
</Box>
);
};
export default RichPromptEditor;