Replace single-click rename with double-click, pencil icon, and long-press (#6)

Single-clicking a combatant name now opens the stat block panel instead of
entering edit mode. Renaming is triggered by double-click, a hover pencil
icon, or long-press on touch. Also fixes condition picker positioning when
near viewport edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-11 22:22:21 +01:00
parent 613bb70065
commit 2d8e823eff
5 changed files with 118 additions and 20 deletions

View File

@@ -3,7 +3,7 @@ import {
type ConditionId,
deriveHpStatus,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { Brain, Pencil, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield";
@@ -44,14 +44,19 @@ function EditableName({
name,
combatantId,
onRename,
onShowStatBlock,
}: {
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => {
const trimmed = draft.trim();
@@ -67,6 +72,46 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select());
}, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) {
return (
<Input
@@ -85,16 +130,30 @@ function EditableName({
}
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
>
{name}
</button>
<>
<button
type="button"
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
>
{name}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
aria-label="Edit name"
className="hidden shrink-0 items-center text-muted-foreground hover:text-hover-neutral group-hover:inline-flex"
>
<Pencil size={14} />
</button>
</>
);
}
@@ -487,9 +546,12 @@ export function CombatantRow({
dimmed && "opacity-50",
)}
>
<span className="min-w-0 truncate">
<EditableName name={name} combatantId={id} onRename={onRename} />
</span>
<EditableName
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
/>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}