feat(chess): live-sync FEN textarea with board edits in the editor
Previously the FEN textarea held an independent draft that only synced to the board when the user clicked Reset. Placing a piece left the FEN showing stale content, which was confusing — the field looked like a proxy for the board but wasn't. Now the textarea mirrors the live board FEN by default: - Placing, erasing, or clearing pieces updates the textarea immediately. - Starting to type into the textarea marks it 'dirty' (via a ref so we don't rerender), pausing the auto-sync until the user Loads (accept the draft) or Discards (restore the live FEN). - The 'Unsaved edit' indicator appears while dirty. - Load / Discard buttons disable when draft equals live FEN — nothing to apply. - Library load also resets the dirty flag, as that's a clean state reset. - Removed the redundant 'Current: <fen>' helper line below the textarea; the textarea itself is the live FEN now. E2E: 2 new tests - FEN field updates live as pieces are placed. - Typing into FEN field pauses live sync until Load or Discard. 25/25 Playwright tests passing. 1025 unit tests green.
This commit is contained in:
parent
c6c79c678b
commit
8828908a59
2 changed files with 110 additions and 19 deletions
|
|
@ -168,6 +168,54 @@ test.describe('LayoutEditor modal', () => {
|
|||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('FEN field updates live as pieces are placed on the board', async ({
|
||||
page,
|
||||
}) => {
|
||||
const fenField = page.getByTestId('editor-fen');
|
||||
|
||||
// Empty board → all-eights FEN.
|
||||
await expect(fenField).toHaveValue('8/8/8/8/8/8/8/8');
|
||||
|
||||
// Place a white king on e1.
|
||||
await page.getByTestId('palette-white-king').click();
|
||||
await page.getByTestId('editor-square-4').click();
|
||||
|
||||
// Textarea now reflects the board without any Load/Reset step.
|
||||
await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3');
|
||||
|
||||
// Place a black king on e8.
|
||||
await page.getByTestId('palette-black-king').click();
|
||||
await page.getByTestId('editor-square-60').click();
|
||||
await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3');
|
||||
});
|
||||
|
||||
test('typing into FEN field stops live sync until Load or Discard', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Place a king so the live FEN has something meaningful.
|
||||
await page.getByTestId('palette-white-king').click();
|
||||
await page.getByTestId('editor-square-4').click();
|
||||
|
||||
const fenField = page.getByTestId('editor-fen');
|
||||
await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3');
|
||||
|
||||
// Start editing the textarea — live sync should now pause.
|
||||
await fenField.fill('garbage in progress');
|
||||
|
||||
// "Unsaved edit" indicator appears; the Load button is enabled.
|
||||
await expect(page.getByTestId('editor-fen-dirty')).toBeVisible();
|
||||
|
||||
// Adding more pieces to the board does NOT clobber the draft.
|
||||
await page.getByTestId('palette-black-king').click();
|
||||
await page.getByTestId('editor-square-60').click();
|
||||
await expect(fenField).toHaveValue('garbage in progress');
|
||||
|
||||
// Discard restores the live FEN and clears the dirty indicator.
|
||||
await page.locator('[data-action="sync-fen"]').click();
|
||||
await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3');
|
||||
await expect(page.getByTestId('editor-fen-dirty')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Use This Layout commits a custom layout to the lobby', async ({ page }) => {
|
||||
// Build minimal valid layout.
|
||||
await page.getByTestId('palette-white-king').click();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
* incrementally — simpler and avoids ambiguity about which source
|
||||
* of truth wins).
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
fromFen,
|
||||
|
|
@ -92,6 +92,17 @@ export function LayoutEditor({
|
|||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
const [libraryVersion, setLibraryVersion] = useState(0); // bump to force reload
|
||||
|
||||
/**
|
||||
* Whether the FEN textarea is currently being edited by the user.
|
||||
* While true, we DON'T overwrite its contents with the derived
|
||||
* liveFen — that would clobber typing mid-edit. It flips off when
|
||||
* the user clicks Load (accept) or Reset (discard) or blurs out.
|
||||
*
|
||||
* A ref rather than state because we only need to gate an effect;
|
||||
* we don't render based on it.
|
||||
*/
|
||||
const fenDirtyRef = useRef(false);
|
||||
|
||||
// Global Esc listener: close the editor regardless of where focus
|
||||
// currently is. We can't rely on the modal div's onKeyDown because
|
||||
// the user might have focused a textarea / button inside a nested
|
||||
|
|
@ -105,11 +116,18 @@ export function LayoutEditor({
|
|||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
// Derived FEN whenever placements change — keeps the readonly view
|
||||
// in sync. The user's in-flight fenDraft is a SEPARATE state so
|
||||
// typing doesn't get clobbered by a re-render.
|
||||
// Derived FEN whenever placements change. The textarea mirrors
|
||||
// this so placing pieces on the board updates the FEN field live
|
||||
// — except when the user is actively editing the field (see
|
||||
// fenDirtyRef), in which case we leave their draft alone until
|
||||
// they explicitly Load or Reset.
|
||||
const liveFen = useMemo(() => toFen(placements), [placements]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fenDirtyRef.current) return;
|
||||
setFenDraft(liveFen);
|
||||
}, [liveFen]);
|
||||
|
||||
const validation = useMemo(
|
||||
() =>
|
||||
validateLayout({
|
||||
|
|
@ -149,13 +167,16 @@ export function LayoutEditor({
|
|||
return;
|
||||
}
|
||||
setPlacements(pieces);
|
||||
// Successful load re-syncs the two sources of truth — the
|
||||
// textarea is no longer "dirty" vs. the board.
|
||||
fenDirtyRef.current = false;
|
||||
toast.success('FEN loaded');
|
||||
}
|
||||
|
||||
function syncFenDraft() {
|
||||
// Allow a manual "Copy current board to FEN field" action if a
|
||||
// user edited the field and then changed their mind — restores
|
||||
// the draft to the live FEN.
|
||||
// Discard in-progress edits, restore the textarea to the current
|
||||
// board state.
|
||||
fenDirtyRef.current = false;
|
||||
setFenDraft(liveFen);
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +221,10 @@ export function LayoutEditor({
|
|||
function handleLoadFromLibrary(entry: SavedLayout) {
|
||||
setPlacements([...entry.pieces]);
|
||||
setName(entry.name);
|
||||
setFenDraft(toFen(entry.pieces));
|
||||
// Library load is a clean state reset — clear any in-progress
|
||||
// FEN edits. The useEffect that mirrors liveFen → fenDraft will
|
||||
// sync the textarea on the next render.
|
||||
fenDirtyRef.current = false;
|
||||
setLibraryOpen(false);
|
||||
toast.success(`Loaded "${entry.name}"`);
|
||||
}
|
||||
|
|
@ -303,7 +327,13 @@ export function LayoutEditor({
|
|||
<ActionsPanel
|
||||
fenDraft={fenDraft}
|
||||
liveFen={liveFen}
|
||||
onFenDraftChange={setFenDraft}
|
||||
onFenDraftChange={(v) => {
|
||||
// User is typing: mark the textarea as dirty so
|
||||
// the board→FEN auto-sync effect doesn't clobber
|
||||
// their edit until they Load or Reset.
|
||||
fenDirtyRef.current = true;
|
||||
setFenDraft(v);
|
||||
}}
|
||||
onLoadFen={loadFen}
|
||||
onSyncFen={syncFenDraft}
|
||||
validation={validation}
|
||||
|
|
@ -528,9 +558,19 @@ function ActionsPanel({
|
|||
return (
|
||||
<aside className="w-80 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
|
||||
<section>
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
|
||||
FEN
|
||||
</h3>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
|
||||
FEN
|
||||
</h3>
|
||||
{fenDraft !== liveFen && (
|
||||
<span
|
||||
data-testid="editor-fen-dirty"
|
||||
className="text-[10px] font-semibold text-amber-600 uppercase tracking-wide"
|
||||
>
|
||||
Unsaved edit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
data-testid="editor-fen"
|
||||
value={fenDraft}
|
||||
|
|
@ -539,26 +579,29 @@ function ActionsPanel({
|
|||
className="w-full px-3 py-2 text-xs font-mono border border-neutral-300 rounded resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Paste FEN here…"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-neutral-400 leading-snug">
|
||||
Updates live as you place pieces. Paste a FEN and hit Load
|
||||
to replace the board.
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
data-action="load-fen"
|
||||
onClick={onLoadFen}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100"
|
||||
disabled={fenDraft === liveFen}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
data-action="sync-fen"
|
||||
onClick={onSyncFen}
|
||||
title="Reset FEN field to current board"
|
||||
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100"
|
||||
title="Discard unsaved FEN edits and re-sync to the current board"
|
||||
disabled={fenDraft === liveFen}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reset to board
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-neutral-400 font-mono break-all">
|
||||
Current: {liveFen}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ValidationPanel validation={validation} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue