mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-20 16:47:40 +02:00
376 lines
12 KiB
TypeScript
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;
|