mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 01:24:52 +02:00
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import Typography from '@mui/material/Typography';
|
|
import InputBase from '@mui/material/InputBase';
|
|
import type { SxProps, Theme } from '@mui/material/styles';
|
|
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
|
|
|
interface Props {
|
|
// Current title as shown to the user; seeds the edit field.
|
|
value: string;
|
|
// Called with the trimmed new title only when it actually changed.
|
|
onCommit: (next: string) => void;
|
|
// Layout + text styling shared by the read-only text and the input so the two states line up (pass flex/font/color here).
|
|
sx?: SxProps<Theme>;
|
|
placeholder?: string;
|
|
// Optional custom display node (e.g. the chat card's Typewriter); falls back to a plain Typography of `value` when omitted.
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
// Click-to-rename title. Reads as plain text until clicked, then becomes an inline input that commits on Enter/blur and cancels on Escape. Lives on pointer-drag card headers, so it stops pointer propagation (+ data-no-drag) to avoid starting a card drag while editing.
|
|
export default function InlineEditableTitle({ value, onCommit, sx, placeholder, children }: Props) {
|
|
const c = useClaudeTokens();
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(value);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const measureRef = useRef<HTMLSpanElement>(null);
|
|
const [inputWidth, setInputWidth] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (editing && inputRef.current) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
}
|
|
}, [editing]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!editing) return;
|
|
const measured = measureRef.current?.getBoundingClientRect().width ?? 0;
|
|
setInputWidth(Math.ceil(measured) + 2);
|
|
}, [draft, editing, placeholder]);
|
|
|
|
const begin = useCallback(() => { setDraft(value); setEditing(true); }, [value]);
|
|
|
|
const commit = useCallback(() => {
|
|
const t = draft.trim();
|
|
if (t && t !== value) onCommit(t);
|
|
setEditing(false);
|
|
}, [draft, value, onCommit]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<>
|
|
<Box
|
|
ref={measureRef}
|
|
component="span"
|
|
aria-hidden
|
|
sx={{
|
|
position: 'absolute',
|
|
visibility: 'hidden',
|
|
whiteSpace: 'pre',
|
|
pointerEvents: 'none',
|
|
...sx,
|
|
}}
|
|
>
|
|
{draft || placeholder || ' '}
|
|
</Box>
|
|
<InputBase
|
|
inputRef={inputRef}
|
|
data-no-drag
|
|
value={draft}
|
|
placeholder={placeholder}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
|
else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); }
|
|
}}
|
|
sx={{
|
|
...sx,
|
|
width: inputWidth ? `${inputWidth}px` : 'auto',
|
|
minWidth: 0,
|
|
maxWidth: '100%',
|
|
'& input': { width: '100%', minWidth: 0, p: 0 },
|
|
'& input::placeholder': { color: c.text.muted, opacity: 1 },
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
onClick={begin}
|
|
// Card headers drag via onPointerDown + preventDefault, which otherwise swallows this click. Stop the pointer here so the click reaches us and enters edit mode (same trick the draft-title InputBase uses).
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
title="Click to rename"
|
|
sx={{
|
|
minWidth: 0, cursor: 'text', borderRadius: 0.5, px: 0.25, mx: -0.25,
|
|
// Neutral translucent hover instead of a solid surface token: this title also rides dark
|
|
// glass headers (the chat card) that live outside the theme scope, where bg.elevated resolved
|
|
// to a light near-white pill. A gray alpha darkens on light and lightens on dark, reading right on both.
|
|
'&:hover': { bgcolor: 'rgba(136,136,136,0.18)' },
|
|
...sx,
|
|
}}
|
|
>
|
|
{children ?? (
|
|
<Typography sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...sx }}>
|
|
{value}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|