/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import styles from './InlineEdit.module.css'; interface InlineEditProps { value: string; onSave: (value: string) => Promise | void; prefix?: string; suffix?: string; placeholder?: string; maxLength?: number; validate?: (value: string) => boolean; className?: string; inputClassName?: string; buttonClassName?: string; width?: number | string; allowEmpty?: boolean; } type Mode = 'idle' | 'editing' | 'saving'; function sanitizeDraft(draft: string): string { return draft.replace(/[\r\n\t]/g, ''); } export const InlineEdit: React.FC = observer((props) => { const { className = '', inputClassName = '', buttonClassName = '', placeholder, width, onSave, value, prefix = '', suffix = '', maxLength, validate, allowEmpty = false, } = props; const [mode, setMode] = React.useState('idle'); const [draft, setDraft] = React.useState(value); const [error, setError] = React.useState(null); const editableRef = React.useRef(null); React.useEffect(() => { if (mode === 'idle') { setDraft(value); } }, [value, mode]); const fieldStyle = React.useMemo(() => { if (!width) return undefined; return { minWidth: typeof width === 'number' ? `${width}px` : width, }; }, [width]); const canSave = (raw: string): boolean => { const trimmed = raw.trim(); if (trimmed === value.trim()) return true; if (!trimmed.length) return !!allowEmpty; if (validate && !validate(trimmed)) return false; return true; }; const startEdit = () => { setError(null); setDraft(value); setMode('editing'); }; React.useEffect(() => { if (mode !== 'editing') return; const el = editableRef.current; if (!el) return; el.textContent = draft; requestAnimationFrame(() => { el.focus(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(range); } }); }, [mode]); const cancelEdit = () => { setError(null); setDraft(value); setMode('idle'); }; const doSave = async () => { const next = draft.trim(); if (!canSave(next)) { setError('VALIDATION_FAILED'); return; } if (next === value.trim()) { setMode('idle'); return; } setMode('saving'); setError(null); try { await Promise.resolve(onSave(next)); setMode('idle'); } catch (e: any) { setError(e?.message || 'SAVE_FAILED'); setMode('editing'); } }; const handleEditableInput: React.FormEventHandler = (e) => { const el = e.currentTarget; const raw = el.textContent ?? ''; let next = sanitizeDraft(raw); if (typeof maxLength === 'number' && maxLength > 0 && next.length > maxLength) { next = next.slice(0, maxLength); } if (next !== raw) { el.textContent = next; const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(range); } } setError(null); setDraft(next); }; const handleEditableKeyDown: React.KeyboardEventHandler = (e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); void doSave(); return; } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); editableRef.current?.blur(); return; } }; const handleEditableBlur: React.FocusEventHandler = () => { if (!canSave(draft)) { cancelEdit(); } else { void doSave(); } }; const isEditing = mode === 'editing' || mode === 'saving'; const hasValue = value.trim().length > 0; const showPlaceholder = !hasValue && !isEditing; if (!isEditing) { return (
{error && {error}}
); } return (
{prefix && {prefix}} {/* biome-ignore lint/a11y/useFocusableInteractive: contentEditable makes this focusable */}
{suffix && {suffix}}
{error && {error}}
); });