mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
242 lines
6.5 KiB
TypeScript
242 lines
6.5 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
|
|
import styles from '@app/features/ui/components/InlineEdit.module.css';
|
|
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
|
import {clsx} from 'clsx';
|
|
import {observer} from 'mobx-react-lite';
|
|
import type React from 'react';
|
|
import {useEffect, useMemo, useRef, useState} from 'react';
|
|
|
|
interface InlineEditProps {
|
|
value: string;
|
|
onSave: (value: string) => Promise<void> | 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<InlineEditProps> = observer((props) => {
|
|
const {
|
|
className = '',
|
|
inputClassName = '',
|
|
buttonClassName = '',
|
|
placeholder,
|
|
width,
|
|
onSave,
|
|
value,
|
|
prefix = '',
|
|
suffix = '',
|
|
maxLength,
|
|
validate,
|
|
allowEmpty = false,
|
|
} = props;
|
|
const [mode, setMode] = useState<Mode>('idle');
|
|
const [draft, setDraft] = useState<string>(value);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const editableRef = useRef<HTMLDivElement | null>(null);
|
|
useEffect(() => {
|
|
if (mode === 'idle') {
|
|
setDraft(value);
|
|
}
|
|
}, [value, mode]);
|
|
const fieldStyle = useMemo<React.CSSProperties | undefined>(() => {
|
|
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');
|
|
};
|
|
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: unknown) {
|
|
setError(e instanceof Error ? e.message : 'SAVE_FAILED');
|
|
setMode('editing');
|
|
}
|
|
};
|
|
const handleEditableInput: React.FormEventHandler<HTMLDivElement> = (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<HTMLDivElement> = (e) => {
|
|
if (isIMEComposing(e)) {
|
|
return;
|
|
}
|
|
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<HTMLDivElement> = () => {
|
|
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 (
|
|
<div className={clsx(styles.container, className)} data-flx="ui.inline-edit.container">
|
|
<FocusRing offset={-2} data-flx="ui.inline-edit.focus-ring">
|
|
<button
|
|
type="button"
|
|
onClick={startEdit}
|
|
className={clsx(styles.idleButton, {[styles.placeholder]: showPlaceholder})}
|
|
style={fieldStyle}
|
|
data-flx="ui.inline-edit.idle-button.start-edit"
|
|
>
|
|
<span className={clsx(styles.wrapper, buttonClassName)} data-flx="ui.inline-edit.wrapper">
|
|
{prefix && (
|
|
<span className={clsx(styles.inlineTextBase, styles.affix)} data-flx="ui.inline-edit.inline-text-base">
|
|
{prefix}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={clsx(styles.inlineTextBase, styles.text, inputClassName)}
|
|
data-flx="ui.inline-edit.inline-text-base--2"
|
|
>
|
|
{hasValue ? value : placeholder || ''}
|
|
</span>
|
|
{suffix && (
|
|
<span
|
|
className={clsx(styles.inlineTextBase, styles.affix)}
|
|
data-flx="ui.inline-edit.inline-text-base--3"
|
|
>
|
|
{suffix}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
</FocusRing>
|
|
{error && (
|
|
<span className={styles.error} data-flx="ui.inline-edit.error">
|
|
{error}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className={clsx(styles.container, className)} data-flx="ui.inline-edit.container--2">
|
|
<div className={clsx(styles.wrapper, buttonClassName)} style={fieldStyle} data-flx="ui.inline-edit.wrapper--2">
|
|
{prefix && (
|
|
<span className={clsx(styles.inlineTextBase, styles.affix)} data-flx="ui.inline-edit.inline-text-base--4">
|
|
{prefix}
|
|
</span>
|
|
)}
|
|
<div
|
|
ref={editableRef}
|
|
className={clsx(styles.inlineTextBase, styles.text, styles.editable, inputClassName)}
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onInput={handleEditableInput}
|
|
onKeyDown={handleEditableKeyDown}
|
|
onBlur={handleEditableBlur}
|
|
data-placeholder={placeholder ?? ''}
|
|
role="textbox"
|
|
tabIndex={0}
|
|
data-flx="ui.inline-edit.inline-text-base.editable-input"
|
|
/>
|
|
{suffix && (
|
|
<span className={clsx(styles.inlineTextBase, styles.affix)} data-flx="ui.inline-edit.inline-text-base--5">
|
|
{suffix}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<span className={styles.error} data-flx="ui.inline-edit.error--2">
|
|
{error}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|