feat(ui): modifier editor undo/redo + conflict resolution panel + copy/paste modifiers
This commit is contained in:
parent
3557aa7cb4
commit
37e485537d
7 changed files with 382 additions and 39 deletions
9
.sisyphus/boulder.json
Normal file
9
.sisyphus/boulder.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"active_plan": "/home/joey/Projects/rules/.sisyphus/plans/modifier-profiles-t2.md",
|
||||||
|
"started_at": "2026-04-19T14:42:06.329Z",
|
||||||
|
"session_ids": [
|
||||||
|
"ses_267b9d7a2ffeFkGcPFn1iv223J"
|
||||||
|
],
|
||||||
|
"plan_name": "modifier-profiles-t2",
|
||||||
|
"agent": "atlas"
|
||||||
|
}
|
||||||
|
|
@ -203,7 +203,7 @@ Wave FINAL (4 parallel reviewers):
|
||||||
|
|
||||||
## TODOs
|
## TODOs
|
||||||
|
|
||||||
- [ ] 1. **T2-ADR documentation**
|
- [x] 1. **T2-ADR documentation**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
|
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,21 @@ export interface ModifierProfileUpdatedPayload {
|
||||||
appliedAt: "turn-boundary";
|
appliedAt: "turn-boundary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client ack: a submitted `modifier-profile.update` passed
|
||||||
|
* receipt-time validation and is queued for the next turn boundary
|
||||||
|
* (T2-ADR-1). Sent only to the submitting client — the opponent
|
||||||
|
* learns about the pending swap only when it actually lands via
|
||||||
|
* `modifier-profile.updated`. `pendingVersion` is the version the
|
||||||
|
* server expects to emit when the queue drains; a subsequent
|
||||||
|
* `modifier-profile.updated` with a higher version means another
|
||||||
|
* swap beat this one on the last-write-wins queue.
|
||||||
|
*/
|
||||||
|
export interface ModifierProfileQueuedPayload {
|
||||||
|
roomCode: string;
|
||||||
|
pendingVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ErrorPayload {
|
export interface ErrorPayload {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -265,6 +280,7 @@ export type ServerMessage =
|
||||||
| MessageEnvelope<"room.created", RoomCreatedPayload>
|
| MessageEnvelope<"room.created", RoomCreatedPayload>
|
||||||
| MessageEnvelope<"room.joined", RoomJoinedPayload>
|
| MessageEnvelope<"room.joined", RoomJoinedPayload>
|
||||||
| MessageEnvelope<"modifier-profile.updated", ModifierProfileUpdatedPayload>
|
| MessageEnvelope<"modifier-profile.updated", ModifierProfileUpdatedPayload>
|
||||||
|
| MessageEnvelope<"modifier-profile.queued", ModifierProfileQueuedPayload>
|
||||||
| MessageEnvelope<"error", ErrorPayload>;
|
| MessageEnvelope<"error", ErrorPayload>;
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
|
|
|
||||||
104
packages/chess/src/ui/ConflictResolutionPanel.tsx
Normal file
104
packages/chess/src/ui/ConflictResolutionPanel.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { StartingLayout } from '../layouts/types.js';
|
||||||
|
import type { ModifierProfile } from '../modifiers/types.js';
|
||||||
|
import {
|
||||||
|
validateProfile,
|
||||||
|
type ValidationError,
|
||||||
|
type ValidationWarning,
|
||||||
|
} from '../modifiers/validate.js';
|
||||||
|
import { CaptureFlag } from '../schema.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: ModifierProfile;
|
||||||
|
layout: StartingLayout | null;
|
||||||
|
onResolve: (nextProfile: ModifierProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFix(
|
||||||
|
profile: ModifierProfile,
|
||||||
|
issue: ValidationError | ValidationWarning,
|
||||||
|
): ModifierProfile {
|
||||||
|
switch (issue.code) {
|
||||||
|
case 'E_PROFILE_INVULN_KING':
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
perType: profile.perType.map((tm) =>
|
||||||
|
tm.kind === 'capture-flags' && tm.pieceType === 'king'
|
||||||
|
? { ...tm, value: (tm.value as number) & ~CaptureFlag.CANNOT_BE_CAPTURED }
|
||||||
|
: tm,
|
||||||
|
),
|
||||||
|
perInstance: profile.perInstance.filter((im) => {
|
||||||
|
if (issue.code !== 'E_PROFILE_INVULN_KING') return true;
|
||||||
|
if ('square' in issue && im.square === issue.square && im.kind === 'capture-flags') {
|
||||||
|
const v = im.value as number;
|
||||||
|
return (v & CaptureFlag.CANNOT_BE_CAPTURED) === 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
case 'E_PROFILE_ORPHAN_INSTANCE':
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
perInstance: profile.perInstance.filter((im) => im.square !== issue.square),
|
||||||
|
};
|
||||||
|
case 'E_PROFILE_NO_KING':
|
||||||
|
return profile;
|
||||||
|
case 'E_PROFILE_ATTR_LIMIT':
|
||||||
|
return profile;
|
||||||
|
case 'E_PROFILE_DEADLOCK':
|
||||||
|
return profile;
|
||||||
|
default:
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConflictResolutionPanel({ profile, layout, onResolve }: Props) {
|
||||||
|
const result = useMemo(() => {
|
||||||
|
if (layout === null) return { errors: [], warnings: [], valid: true };
|
||||||
|
return validateProfile(profile, layout);
|
||||||
|
}, [profile, layout]);
|
||||||
|
|
||||||
|
if (result.valid && result.warnings.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="conflict-panel"
|
||||||
|
className="m-4 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{result.errors.map((err) => (
|
||||||
|
<div
|
||||||
|
key={`${err.code}-${err.square ?? ''}`}
|
||||||
|
className="flex items-center justify-between px-3 py-2 text-sm text-red-800 bg-red-50 border border-red-200 rounded"
|
||||||
|
>
|
||||||
|
<div>⚠ {err.message}</div>
|
||||||
|
{err.code !== 'E_PROFILE_NO_KING' && err.code !== 'E_PROFILE_ATTR_LIMIT' && err.code !== 'E_PROFILE_DEADLOCK' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`conflict-fix-${err.code}`}
|
||||||
|
onClick={() => onResolve(applyFix(profile, err))}
|
||||||
|
className="px-2 py-1 text-xs font-semibold bg-red-100 hover:bg-red-200 text-red-900 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Fix
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{result.warnings.map((warn) => (
|
||||||
|
<div
|
||||||
|
key={`${warn.code}-${warn.square}`}
|
||||||
|
className="flex items-center justify-between px-3 py-2 text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded"
|
||||||
|
>
|
||||||
|
<div>⚠ {warn.message}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`conflict-fix-${warn.code}`}
|
||||||
|
onClick={() => onResolve(applyFix(profile, warn))}
|
||||||
|
className="px-2 py-1 text-xs font-semibold bg-amber-100 hover:bg-amber-200 text-amber-900 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Fix
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*
|
*
|
||||||
* Follows the same overlay/close pattern as LayoutEditor.tsx.
|
* Follows the same overlay/close pattern as LayoutEditor.tsx.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { LAYOUT_REGISTRY, type StartingLayout } from '@paratype/chess';
|
import { LAYOUT_REGISTRY, type StartingLayout } from '@paratype/chess';
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,6 +23,12 @@ import {
|
||||||
import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types';
|
import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types';
|
||||||
import { PerInstancePanel } from './PerInstancePanel';
|
import { PerInstancePanel } from './PerInstancePanel';
|
||||||
import { PerTypePanel } from './PerTypePanel.js';
|
import { PerTypePanel } from './PerTypePanel.js';
|
||||||
|
import { ConflictResolutionPanel } from './ConflictResolutionPanel.js';
|
||||||
|
|
||||||
|
export type ModifierClipboard =
|
||||||
|
| { kind: 'empty' }
|
||||||
|
| { kind: 'type-modifiers'; entries: readonly TypeModifier[] }
|
||||||
|
| { kind: 'instance-modifiers'; entries: readonly Omit<InstanceModifier, 'square'>[] };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -42,17 +48,47 @@ function makeBlankProfile(): ModifierProfile {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
const [profile, setProfile] = useState<ModifierProfile>(makeBlankProfile);
|
const [history, setHistory] = useState<ModifierProfile[]>(() => [
|
||||||
|
makeBlankProfile(),
|
||||||
|
]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(0);
|
||||||
|
const profile = history[historyIndex]!;
|
||||||
|
const canUndo = historyIndex > 0;
|
||||||
|
const canRedo = historyIndex < history.length - 1;
|
||||||
|
|
||||||
|
const pushSnapshot = useCallback(
|
||||||
|
(next: ModifierProfile | ((prev: ModifierProfile) => ModifierProfile)) => {
|
||||||
|
setHistory((h) => {
|
||||||
|
const current = h[historyIndex]!;
|
||||||
|
const nextProfile = typeof next === 'function' ? next(current) : next;
|
||||||
|
const truncated = h.slice(0, historyIndex + 1);
|
||||||
|
truncated.push(nextProfile);
|
||||||
|
while (truncated.length > 50) truncated.shift();
|
||||||
|
return truncated;
|
||||||
|
});
|
||||||
|
setHistoryIndex((i) => Math.min(i + 1, 49));
|
||||||
|
},
|
||||||
|
[historyIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
setHistoryIndex((i) => Math.max(0, i - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
setHistoryIndex((i) => Math.min(history.length - 1, i + 1));
|
||||||
|
}, [history.length]);
|
||||||
|
|
||||||
const [libraryVersion, setLibraryVersion] = useState(0);
|
const [libraryVersion, setLibraryVersion] = useState(0);
|
||||||
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
|
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
|
||||||
const [perInstance, setPerInstance] = useState<InstanceModifier[]>([]);
|
const [clipboard, setClipboard] = useState<ModifierClipboard>({ kind: 'empty' });
|
||||||
const [perType, setPerType] = useState<TypeModifier[]>([]);
|
|
||||||
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
|
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
|
||||||
|
|
||||||
// Fresh profile whenever the modal opens.
|
// Fresh profile whenever the modal opens.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setProfile(makeBlankProfile());
|
setHistory([makeBlankProfile()]);
|
||||||
|
setHistoryIndex(0);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
|
@ -69,11 +105,23 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
onClose();
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mod = e.metaKey || e.ctrlKey;
|
||||||
|
if (!mod) return;
|
||||||
|
if (e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
undo();
|
||||||
|
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'Z') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
redo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown, true);
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose, undo, redo]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
|
@ -94,6 +142,8 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
}
|
}
|
||||||
toast.success(`Saved "${entry.name}" to library`);
|
toast.success(`Saved "${entry.name}" to library`);
|
||||||
setLibraryVersion((n) => n + 1);
|
setLibraryVersion((n) => n + 1);
|
||||||
|
setHistory([profile]);
|
||||||
|
setHistoryIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShareProfile() {
|
function handleShareProfile() {
|
||||||
|
|
@ -120,7 +170,8 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadFromLibrary(entry: SavedModifierProfile) {
|
function handleLoadFromLibrary(entry: SavedModifierProfile) {
|
||||||
setProfile({ ...entry.profile });
|
setHistory([{ ...entry.profile }]);
|
||||||
|
setHistoryIndex(0);
|
||||||
toast.success(`Loaded "${entry.name}"`);
|
toast.success(`Loaded "${entry.name}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +209,7 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
type="text"
|
type="text"
|
||||||
value={profile.name}
|
value={profile.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setProfile((p) => ({ ...p, name: e.target.value }))
|
pushSnapshot((p) => ({ ...p, name: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="Profile name"
|
placeholder="Profile name"
|
||||||
className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
|
@ -178,7 +229,11 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
value={boundLayout?.id ?? ''}
|
value={boundLayout?.id ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const id = e.target.value;
|
const id = e.target.value;
|
||||||
setBoundLayout(id ? (LAYOUT_REGISTRY.get(id) ?? null) : null);
|
const newLayout = id ? (LAYOUT_REGISTRY.get(id) ?? null) : null;
|
||||||
|
setBoundLayout(newLayout);
|
||||||
|
// Any layout change is tracked as an action because per-instance
|
||||||
|
// rules are inherently bound to the layout context.
|
||||||
|
pushSnapshot((p) => ({ ...p }));
|
||||||
}}
|
}}
|
||||||
className="text-sm border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[160px]"
|
className="text-sm border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[160px]"
|
||||||
>
|
>
|
||||||
|
|
@ -192,6 +247,33 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
data-testid="undo-button"
|
||||||
|
onClick={undo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
aria-label="Undo"
|
||||||
|
title="Undo (Cmd/Ctrl+Z)"
|
||||||
|
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
|
||||||
|
>
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="redo-button"
|
||||||
|
onClick={redo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
aria-label="Redo"
|
||||||
|
title="Redo (Cmd/Ctrl+Shift+Z)"
|
||||||
|
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
|
||||||
|
>
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||||||
|
<div data-testid="clipboard-status" className="px-2 text-xs font-medium text-neutral-500">
|
||||||
|
{clipboard.kind === 'empty'
|
||||||
|
? 'Clipboard: empty'
|
||||||
|
: `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||||||
<button
|
<button
|
||||||
data-testid="save-profile"
|
data-testid="save-profile"
|
||||||
onClick={handleSaveToLibrary}
|
onClick={handleSaveToLibrary}
|
||||||
|
|
@ -216,24 +298,59 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Conflict Resolution Panel */}
|
||||||
|
<ConflictResolutionPanel
|
||||||
|
profile={profile}
|
||||||
|
layout={boundLayout}
|
||||||
|
onResolve={(next) => pushSnapshot(next)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Body — three panels */}
|
{/* Body — three panels */}
|
||||||
<div className="flex-1 overflow-hidden flex">
|
<div className="flex-1 overflow-hidden flex border-t border-neutral-200">
|
||||||
{/* LEFT — per-type modifier panel (T21) */}
|
{/* LEFT — per-type modifier panel (T21) */}
|
||||||
<aside className="w-64 border-r border-neutral-200 overflow-hidden">
|
<aside className="w-64 border-r border-neutral-200 overflow-hidden">
|
||||||
<PerTypePanel
|
<PerTypePanel
|
||||||
modifiers={perType}
|
modifiers={profile.perType}
|
||||||
onAdd={(m) => setPerType((prev) => [...prev, m])}
|
clipboard={clipboard}
|
||||||
onDelete={(i) =>
|
onClipboardChange={setClipboard}
|
||||||
setPerType((prev) => prev.filter((_, idx) => idx !== i))
|
onAdd={(m) =>
|
||||||
|
pushSnapshot((p) => ({ ...p, perType: [...p.perType, m] }))
|
||||||
}
|
}
|
||||||
|
onDelete={(i) =>
|
||||||
|
pushSnapshot((p) => ({
|
||||||
|
...p,
|
||||||
|
perType: p.perType.filter((_, idx) => idx !== i),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onPaste={(entries: readonly TypeModifier[]) => {
|
||||||
|
pushSnapshot((p) => {
|
||||||
|
const newPerType = [...p.perType];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const existingIdx = newPerType.findIndex(
|
||||||
|
(m) =>
|
||||||
|
m.kind === entry.kind &&
|
||||||
|
m.pieceType === entry.pieceType &&
|
||||||
|
m.color === entry.color
|
||||||
|
);
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
newPerType[existingIdx] = entry;
|
||||||
|
} else {
|
||||||
|
newPerType.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...p, perType: newPerType };
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* CENTER — per-instance board preview (T22) */}
|
{/* CENTER — per-instance board preview (T22) */}
|
||||||
<main className="flex-1 flex overflow-hidden">
|
<main className="flex-1 flex overflow-hidden">
|
||||||
<PerInstancePanel
|
<PerInstancePanel
|
||||||
modifiers={perInstance}
|
modifiers={profile.perInstance}
|
||||||
boundLayout={boundLayout}
|
boundLayout={boundLayout}
|
||||||
|
clipboard={clipboard}
|
||||||
|
onClipboardChange={setClipboard}
|
||||||
onLayoutSelect={() => {
|
onLayoutSelect={() => {
|
||||||
document
|
document
|
||||||
.querySelector<HTMLSelectElement>(
|
.querySelector<HTMLSelectElement>(
|
||||||
|
|
@ -241,10 +358,36 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||||
)
|
)
|
||||||
?.focus();
|
?.focus();
|
||||||
}}
|
}}
|
||||||
onAdd={(m) => setPerInstance((prev) => [...prev, m])}
|
onAdd={(m) =>
|
||||||
onDelete={(i) =>
|
pushSnapshot((p) => ({
|
||||||
setPerInstance((prev) => prev.filter((_, idx) => idx !== i))
|
...p,
|
||||||
|
perInstance: [...p.perInstance, m],
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
onDelete={(i) =>
|
||||||
|
pushSnapshot((p) => ({
|
||||||
|
...p,
|
||||||
|
perInstance: p.perInstance.filter((_, idx) => idx !== i),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onPaste={(entries: readonly InstanceModifier[]) => {
|
||||||
|
pushSnapshot((p) => {
|
||||||
|
const newPerInstance = [...p.perInstance];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const existingIdx = newPerInstance.findIndex(
|
||||||
|
(m) =>
|
||||||
|
m.kind === entry.kind &&
|
||||||
|
m.square === entry.square
|
||||||
|
);
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
newPerInstance[existingIdx] = entry;
|
||||||
|
} else {
|
||||||
|
newPerInstance.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...p, perInstance: newPerInstance };
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,16 @@ import type { InstanceModifier, ModifierKindId } from '../modifiers/types';
|
||||||
import type { StartingLayout } from '../layouts/types';
|
import type { StartingLayout } from '../layouts/types';
|
||||||
import { squareToAlgebraic } from '../coord';
|
import { squareToAlgebraic } from '../coord';
|
||||||
import { LayoutBoardView } from './LayoutEditor';
|
import { LayoutBoardView } from './LayoutEditor';
|
||||||
|
import type { ModifierClipboard } from './ModifierProfileEditor.js';
|
||||||
|
|
||||||
export interface PerInstancePanelProps {
|
export interface PerInstancePanelProps {
|
||||||
modifiers: readonly InstanceModifier[];
|
modifiers: readonly InstanceModifier[];
|
||||||
boundLayout: StartingLayout | null;
|
boundLayout: StartingLayout | null;
|
||||||
|
clipboard: ModifierClipboard;
|
||||||
|
onClipboardChange: (clipboard: ModifierClipboard) => void;
|
||||||
/** Called when the user requests to open the layout picker. */
|
/** Called when the user requests to open the layout picker. */
|
||||||
onLayoutSelect: () => void;
|
onLayoutSelect: () => void;
|
||||||
|
onPaste: (modifiers: readonly InstanceModifier[]) => void;
|
||||||
onAdd: (modifier: InstanceModifier) => void;
|
onAdd: (modifier: InstanceModifier) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +42,10 @@ const MODIFIER_KINDS: ReadonlyArray<{ id: ModifierKindId; label: string }> = [
|
||||||
export function PerInstancePanel({
|
export function PerInstancePanel({
|
||||||
modifiers,
|
modifiers,
|
||||||
boundLayout,
|
boundLayout,
|
||||||
|
clipboard,
|
||||||
|
onClipboardChange,
|
||||||
onLayoutSelect,
|
onLayoutSelect,
|
||||||
|
onPaste,
|
||||||
onAdd,
|
onAdd,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: PerInstancePanelProps) {
|
}: PerInstancePanelProps) {
|
||||||
|
|
@ -104,6 +111,7 @@ export function PerInstancePanel({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Selected square header */}
|
{/* Selected square header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-1">
|
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-1">
|
||||||
Square {selectedSquare}
|
Square {selectedSquare}
|
||||||
|
|
@ -114,6 +122,41 @@ export function PerInstancePanel({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{squareModifiers.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="copy-instance-modifiers"
|
||||||
|
onClick={() => {
|
||||||
|
onClipboardChange({
|
||||||
|
kind: 'instance-modifiers',
|
||||||
|
entries: squareModifiers.map(({ modifier }) => {
|
||||||
|
const { square: _sq, ...rest } = modifier;
|
||||||
|
return rest;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
Copy modifiers
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{clipboard.kind === 'instance-modifiers' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="paste-instance-modifiers"
|
||||||
|
onClick={() => {
|
||||||
|
if (clipboard.kind === 'instance-modifiers') {
|
||||||
|
onPaste(clipboard.entries.map(entry => ({ ...entry, square: selectedSquare })));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-green-600 bg-green-50 border border-green-200 rounded hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
Paste ({clipboard.entries.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Existing modifiers list */}
|
{/* Existing modifiers list */}
|
||||||
{squareModifiers.length > 0 && (
|
{squareModifiers.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useState } from 'react';
|
||||||
import { MODIFIER_REGISTRY } from '../modifiers/index.js';
|
import { MODIFIER_REGISTRY } from '../modifiers/index.js';
|
||||||
import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js';
|
import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js';
|
||||||
import type { PieceType, PieceColor } from '../schema.js';
|
import type { PieceType, PieceColor } from '../schema.js';
|
||||||
|
import type { ModifierClipboard } from './ModifierProfileEditor.js';
|
||||||
|
|
||||||
const PIECE_TYPES: PieceType[] = [
|
const PIECE_TYPES: PieceType[] = [
|
||||||
'pawn', 'knight', 'bishop', 'rook', 'queen', 'king',
|
'pawn', 'knight', 'bishop', 'rook', 'queen', 'king',
|
||||||
|
|
@ -23,6 +24,9 @@ const COLORS: (PieceColor | 'both')[] = ['white', 'black', 'both'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modifiers: readonly TypeModifier[];
|
modifiers: readonly TypeModifier[];
|
||||||
|
clipboard: ModifierClipboard;
|
||||||
|
onClipboardChange: (clipboard: ModifierClipboard) => void;
|
||||||
|
onPaste: (modifiers: readonly TypeModifier[]) => void;
|
||||||
onAdd: (modifier: TypeModifier) => void;
|
onAdd: (modifier: TypeModifier) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +57,7 @@ function parseFormValue(descriptor: ModifierDescriptor, rawValue: string): unkno
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) {
|
export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste, onAdd, onDelete }: Props) {
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [pieceType, setPieceType] = useState<PieceType>('pawn');
|
const [pieceType, setPieceType] = useState<PieceType>('pawn');
|
||||||
const [color, setColor] = useState<PieceColor | 'both'>('both');
|
const [color, setColor] = useState<PieceColor | 'both'>('both');
|
||||||
|
|
@ -91,10 +95,23 @@ export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* Panel header */}
|
{/* Panel header */}
|
||||||
<div className="px-4 pt-4 pb-2 border-b border-neutral-200 bg-neutral-50">
|
<div className="px-4 pt-4 pb-2 border-b border-neutral-200 bg-neutral-50 flex items-center justify-between">
|
||||||
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||||
Per-Type Modifiers
|
Per-Type Modifiers
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="paste-type-modifier"
|
||||||
|
disabled={clipboard.kind !== 'type-modifiers'}
|
||||||
|
onClick={() => {
|
||||||
|
if (clipboard.kind === 'type-modifiers') {
|
||||||
|
onPaste(clipboard.entries);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-xs font-semibold text-neutral-600 bg-white border border-neutral-300 rounded hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Paste {clipboard.kind === 'type-modifiers' ? `(${clipboard.entries.length})` : ''}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable body */}
|
{/* Scrollable body */}
|
||||||
|
|
@ -111,7 +128,7 @@ export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) {
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
data-testid="type-modifier-row"
|
data-testid="type-modifier-row"
|
||||||
className="flex items-center justify-between bg-white border border-neutral-200 rounded-lg px-3 py-2 text-sm"
|
className="flex items-center justify-between bg-white border border-neutral-200 rounded-lg px-3 py-2 text-sm group"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-semibold capitalize">{mod.pieceType}</span>
|
<span className="font-semibold capitalize">{mod.pieceType}</span>
|
||||||
|
|
@ -122,15 +139,26 @@ export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) {
|
||||||
{desc ? desc.describe(mod.value) : String(mod.value)}
|
{desc ? desc.describe(mod.value) : String(mod.value)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`copy-type-modifier-${i}`}
|
||||||
|
onClick={() => onClipboardChange({ kind: 'type-modifiers', entries: [mod] })}
|
||||||
|
aria-label="Copy modifier"
|
||||||
|
className="px-2 text-neutral-400 hover:text-blue-600 transition-colors text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDelete(i)}
|
onClick={() => onDelete(i)}
|
||||||
aria-label="Delete modifier"
|
aria-label="Delete modifier"
|
||||||
className="ml-2 text-neutral-400 hover:text-red-600 transition-colors"
|
className="ml-1 text-neutral-400 hover:text-red-600 transition-colors text-lg leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue