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();
|
).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 }) => {
|
test('Use This Layout commits a custom layout to the lobby', async ({ page }) => {
|
||||||
// Build minimal valid layout.
|
// Build minimal valid layout.
|
||||||
await page.getByTestId('palette-white-king').click();
|
await page.getByTestId('palette-white-king').click();
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
* incrementally — simpler and avoids ambiguity about which source
|
* incrementally — simpler and avoids ambiguity about which source
|
||||||
* of truth wins).
|
* 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 { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
fromFen,
|
fromFen,
|
||||||
|
|
@ -92,6 +92,17 @@ export function LayoutEditor({
|
||||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||||
const [libraryVersion, setLibraryVersion] = useState(0); // bump to force reload
|
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
|
// Global Esc listener: close the editor regardless of where focus
|
||||||
// currently is. We can't rely on the modal div's onKeyDown because
|
// currently is. We can't rely on the modal div's onKeyDown because
|
||||||
// the user might have focused a textarea / button inside a nested
|
// the user might have focused a textarea / button inside a nested
|
||||||
|
|
@ -105,11 +116,18 @@ export function LayoutEditor({
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Derived FEN whenever placements change — keeps the readonly view
|
// Derived FEN whenever placements change. The textarea mirrors
|
||||||
// in sync. The user's in-flight fenDraft is a SEPARATE state so
|
// this so placing pieces on the board updates the FEN field live
|
||||||
// typing doesn't get clobbered by a re-render.
|
// — 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]);
|
const liveFen = useMemo(() => toFen(placements), [placements]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fenDirtyRef.current) return;
|
||||||
|
setFenDraft(liveFen);
|
||||||
|
}, [liveFen]);
|
||||||
|
|
||||||
const validation = useMemo(
|
const validation = useMemo(
|
||||||
() =>
|
() =>
|
||||||
validateLayout({
|
validateLayout({
|
||||||
|
|
@ -149,13 +167,16 @@ export function LayoutEditor({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPlacements(pieces);
|
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');
|
toast.success('FEN loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncFenDraft() {
|
function syncFenDraft() {
|
||||||
// Allow a manual "Copy current board to FEN field" action if a
|
// Discard in-progress edits, restore the textarea to the current
|
||||||
// user edited the field and then changed their mind — restores
|
// board state.
|
||||||
// the draft to the live FEN.
|
fenDirtyRef.current = false;
|
||||||
setFenDraft(liveFen);
|
setFenDraft(liveFen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,7 +221,10 @@ export function LayoutEditor({
|
||||||
function handleLoadFromLibrary(entry: SavedLayout) {
|
function handleLoadFromLibrary(entry: SavedLayout) {
|
||||||
setPlacements([...entry.pieces]);
|
setPlacements([...entry.pieces]);
|
||||||
setName(entry.name);
|
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);
|
setLibraryOpen(false);
|
||||||
toast.success(`Loaded "${entry.name}"`);
|
toast.success(`Loaded "${entry.name}"`);
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +327,13 @@ export function LayoutEditor({
|
||||||
<ActionsPanel
|
<ActionsPanel
|
||||||
fenDraft={fenDraft}
|
fenDraft={fenDraft}
|
||||||
liveFen={liveFen}
|
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}
|
onLoadFen={loadFen}
|
||||||
onSyncFen={syncFenDraft}
|
onSyncFen={syncFenDraft}
|
||||||
validation={validation}
|
validation={validation}
|
||||||
|
|
@ -528,9 +558,19 @@ function ActionsPanel({
|
||||||
return (
|
return (
|
||||||
<aside className="w-80 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
|
<aside className="w-80 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
FEN
|
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
|
||||||
</h3>
|
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
|
<textarea
|
||||||
data-testid="editor-fen"
|
data-testid="editor-fen"
|
||||||
value={fenDraft}
|
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"
|
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…"
|
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">
|
<div className="flex gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
data-action="load-fen"
|
data-action="load-fen"
|
||||||
onClick={onLoadFen}
|
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
|
Load
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
data-action="sync-fen"
|
data-action="sync-fen"
|
||||||
onClick={onSyncFen}
|
onClick={onSyncFen}
|
||||||
title="Reset FEN field to current board"
|
title="Discard unsaved FEN edits and re-sync to the 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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[10px] text-neutral-400 font-mono break-all">
|
|
||||||
Current: {liveFen}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ValidationPanel validation={validation} />
|
<ValidationPanel validation={validation} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue