feat: handle non-letter characters in actor names with separator rows

Display spaces, hyphens and other non-letter characters as static cells
instead of input fields, and add separator rows in the grid for
non-alphabetic characters in the main actor's name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere
2026-03-30 21:59:11 +02:00
parent ba9a3fba5d
commit 335a55562f
5 changed files with 99 additions and 19 deletions

View File

@@ -8,7 +8,7 @@ export default function ActorPopover({ actorName }) {
open: isOpen, open: isOpen,
onOpenChange: setIsOpen, onOpenChange: setIsOpen,
middleware: [offset(8), flip(), shift()], middleware: [offset(8), flip(), shift()],
placement: 'top', placement: 'left',
}); });
const click = useClick(context); const click = useClick(context);

View File

@@ -5,15 +5,34 @@ export default function GameGrid({ grid, width, middle }) {
return ( return (
<table id="actors"> <table id="actors">
<tbody> <tbody>
{grid.map((row, rowIndex) => ( {grid.map((row, rowIndex) => {
<GameRow if (row.separator !== undefined) {
key={rowIndex} return (
actorName={row.actorName} <tr key={rowIndex} className="separator-row">
pos={row.pos} <td />
colStart={middle - row.pos} {Array.from({ length: middle }, (_, i) => (
totalWidth={width} <td key={i} />
/> ))}
))} <td className="letter-static separator-char">
{row.separator === ' ' ? '' : row.separator}
</td>
{Array.from({ length: width - middle }, (_, i) => (
<td key={middle + 1 + i} />
))}
</tr>
);
}
return (
<GameRow
key={rowIndex}
actorName={row.actorName}
pos={row.pos}
colStart={middle - row.pos}
totalWidth={width}
/>
);
})}
</tbody> </tbody>
</table> </table>
); );

View File

@@ -1,19 +1,31 @@
import React, { useRef, useCallback } from 'react'; import React, { useRef, useCallback, useMemo } from 'react';
import LetterInput from './LetterInput'; import LetterInput from './LetterInput';
import ActorPopover from './ActorPopover'; import ActorPopover from './ActorPopover';
function isLetter(ch) {
return /[a-zA-Z]/.test(ch);
}
export default function GameRow({ actorName, pos, colStart, totalWidth }) { export default function GameRow({ actorName, pos, colStart, totalWidth }) {
const inputRefs = useRef([]); const inputRefs = useRef([]);
const letters = actorName.split('');
const letterIndices = useMemo(
() => letters.reduce((acc, ch, i) => { if (isLetter(ch)) acc.push(i); return acc; }, []),
[actorName]
);
const setInputRef = useCallback((index) => (el) => { const setInputRef = useCallback((index) => (el) => {
inputRefs.current[index] = el; inputRefs.current[index] = el;
}, []); }, []);
const focusInput = useCallback((index) => { const focusNextInput = useCallback((charIndex, direction) => {
inputRefs.current[index]?.focus(); const currentPos = letterIndices.indexOf(charIndex);
}, []); const nextPos = currentPos + direction;
if (nextPos >= 0 && nextPos < letterIndices.length) {
const letters = actorName.split(''); inputRefs.current[letterIndices[nextPos]]?.focus();
}
}, [letterIndices]);
return ( return (
<tr> <tr>
@@ -28,13 +40,23 @@ export default function GameRow({ actorName, pos, colStart, totalWidth }) {
return <td key={colIndex} />; return <td key={colIndex} />;
} }
const ch = letters[charIndex];
if (!isLetter(ch)) {
return (
<td key={colIndex} className="letter-static">
{ch}
</td>
);
}
return ( return (
<LetterInput <LetterInput
key={colIndex} key={colIndex}
highlighted={charIndex === pos} highlighted={charIndex === pos}
inputRef={setInputRef(charIndex)} inputRef={setInputRef(charIndex)}
onNext={() => focusInput(charIndex + 1)} onNext={() => focusNextInput(charIndex, 1)}
onPrev={() => focusInput(charIndex - 1)} onPrev={() => focusNextInput(charIndex, -1)}
/> />
); );
})} })}

View File

@@ -75,6 +75,27 @@ body {
color: var(--orange); color: var(--orange);
} }
.letter-static {
width: 38px;
height: 38px;
text-align: center;
font-family: 'Inter', sans-serif;
font-size: 15px;
font-weight: 700;
text-transform: uppercase;
color: var(--text);
vertical-align: middle;
}
.separator-row td {
height: 12px;
padding: 0;
}
.separator-char {
height: 12px;
}
/* ── Popover ── */ /* ── Popover ── */
.popover-trigger { .popover-trigger {

View File

@@ -71,7 +71,25 @@ class GameGridGenerator
$rightSize = 0; $rightSize = 0;
$grid = []; $grid = [];
foreach ($game->getRows() as $row) { $mainActorChars = str_split($game->getMainActor()->getName());
$rows = $game->getRows()->toArray();
$rowIndex = 0;
foreach ($mainActorChars as $char) {
if (!preg_match('/[a-zA-Z]/', $char)) {
$grid[] = [
'separator' => $char,
];
continue;
}
$row = $rows[$rowIndex] ?? null;
++$rowIndex;
if ($row === null) {
continue;
}
$actor = $row->getActor(); $actor = $row->getActor();
$pos = $row->getPosition(); $pos = $row->getPosition();