diff --git a/.gitignore b/.gitignore index a547bf3..bce5ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +Puzzlink_Assistance.js \ No newline at end of file diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index e80a98b..c8939cb 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -43,7 +43,7 @@ src/ plugins/ # plugin contracts and registry exporters/ # export adapters difficulty/ # difficulty snapshot and rule usage aggregation - features/ # board, controls, replay, explanation, stats + features/ # solver controls, board rendering, editor tools, explanation, stats test/ # test setup/runtime helpers ``` @@ -51,16 +51,19 @@ Design rule: - UI should render and orchestrate. - Domain should decide logic. +- The solver workspace and puzzle editor are separate product surfaces that exchange normalized `PuzzleIR`. --- ## 4. End-to-End Data Flow 1. Parser converts URL/input into IR (`PuzzleIR`). -2. Rule engine runs ordered rules and returns one step at a time. -3. Each step stores rule metadata + explicit diffs. -4. Timeline store replays diffs forward/backward. -5. Board and explanation panel render current state + reasoning history. +2. Optional editor tooling can create or modify initial puzzle IR before solving. +3. The solver store loads the initial IR and resets replay state. +4. Rule engine runs ordered rules and returns one step at a time. +5. Each step stores rule metadata + explicit diffs. +6. Timeline store replays diffs forward/backward. +7. Board and explanation panel render current state + reasoning history. This guarantees the same inference chain can be replayed and inspected later. @@ -137,7 +140,11 @@ If these two paths diverge, timeline replay and solver state will drift. Implemented: -- Slitherlink puzz.link parse/encode baseline (URL input currently targets puzz.link; penpa-style URL support is planned) +- Dedicated solver workspace for import, solving, replay, explanation, stats, and export +- Dedicated editor workspace for puzzle construction before loading into the solver +- Slitherlink puzz.link parse/encode baseline +- Slitherlink Penpa import baseline +- Slitherlink editor tools for clues, pre-drawn line edges, crossed/blank edges, erasing, custom grid sizes, and built-in presets - Ordered rule execution with step metadata - Step replay (`Next`, `Previous`, `Solve to End`) - Explanation-oriented deduction trace @@ -146,9 +153,10 @@ Implemented: Partially implemented / planned: -- Penpa adapter completeness - More puzzle families (e.g. Masyu/Nonogram) -- Richer puzzle-specific interaction tools +- Puzzle-specific editor support for each puzzle family +- Canvas interaction and rendering optimization for larger boards and richer editor states +- Penpa adapter/export completeness - Better calibrated difficulty modeling Important expectation: difficult puzzles may stop at a stable but incomplete state if no rule applies. @@ -181,4 +189,3 @@ When editing: - `npm run test:run` - unit/component tests - `npm run build` - production build - `npm run test:e2e` - Playwright end-to-end tests - diff --git a/playwright.config.ts b/playwright.config.ts index 212f475..e38d3e6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ trace: 'on-first-retry', }, webServer: { - command: 'npm run dev -- --host 127.0.0.1 --port 4173', + command: `"${process.execPath}" ./node_modules/vite/bin/vite.js --host 127.0.0.1 --port 4173`, port: 4173, reuseExistingServer: true, }, diff --git a/src/App.tsx b/src/App.tsx index 3c06e29..13b5330 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ import { Navigate, Route, Routes } from 'react-router-dom' +import { EditorPage } from './app/EditorPage' import { WorkspacePage } from './app/WorkspacePage' function App() { return ( } /> + } /> } /> ) diff --git a/src/app/EditorPage.test.tsx b/src/app/EditorPage.test.tsx new file mode 100644 index 0000000..6771072 --- /dev/null +++ b/src/app/EditorPage.test.tsx @@ -0,0 +1,447 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import App from '../App' +import { cellKey, edgeKey } from '../domain/ir/keys' +import { createSlitherPuzzle } from '../domain/ir/slither' +import { useEditorStore } from '../features/editor/editorStore' +import { useSolverStore } from '../features/solver/solverStore' + +const mockCanvasRect = (canvas: HTMLCanvasElement) => { + Object.defineProperty(canvas, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: canvas.width, + bottom: canvas.height, + width: canvas.width, + height: canvas.height, + toJSON: () => ({}), + }), + }) +} + +const clickCanvas = (canvas: HTMLCanvasElement, x: number, y: number) => { + fireEvent.mouseDown(canvas, { clientX: x, clientY: y }) + fireEvent.mouseUp(canvas, { clientX: x, clientY: y }) +} + +const rightClickCanvas = (canvas: HTMLCanvasElement, x: number, y: number) => { + fireEvent.mouseDown(canvas, { button: 2, buttons: 2, clientX: x, clientY: y }) + fireEvent.mouseUp(canvas, { button: 2, clientX: x, clientY: y }) +} + +const dragCanvas = ( + canvas: HTMLCanvasElement, + points: Array<[number, number]>, + button = 0, +) => { + const [startX, startY] = points[0] + fireEvent.mouseDown(canvas, { button, buttons: button === 2 ? 2 : 1, clientX: startX, clientY: startY }) + for (const [x, y] of points.slice(1)) { + fireEvent.mouseMove(canvas, { button, buttons: button === 2 ? 2 : 1, clientX: x, clientY: y }) + } + const [endX, endY] = points[points.length - 1] + fireEvent.mouseUp(canvas, { button, clientX: endX, clientY: endY }) +} + +describe('EditorPage', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + useEditorStore.getState().loadEditorPuzzle(createSlitherPuzzle(5, 5)) + }) + + it('edits clues and edge marks with direct board input, then hands the puzzle to the solver', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + + expect(screen.getByLabelText(/editor board scroll area/i)).toHaveClass('board-scroll-shell') + const zoom = screen.getByLabelText(/board zoom/i) + expect(zoom).toHaveValue('100') + expect(zoom).toHaveAttribute('min', '20') + expect(zoom).toHaveAttribute('max', '200') + expect(zoom).toHaveAttribute('step', '5') + expect(canvas).toHaveClass('editor-board-canvas') + + clickCanvas(canvas, 74, 74) + fireEvent.keyDown(canvas, { key: '2' }) + fireEvent.keyDown(canvas, { key: '3' }) + expect(useEditorStore.getState().puzzle.cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'number', + value: 3, + }) + fireEvent.keyDown(canvas, { key: '?' }) + expect(useEditorStore.getState().puzzle.cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'number', + value: '?', + }) + fireEvent.keyDown(canvas, { key: 'Backspace' }) + expect(useEditorStore.getState().puzzle.cells[cellKey(0, 0)]?.clue).toBeUndefined() + fireEvent.keyDown(canvas, { key: '3' }) + + dragCanvas(canvas, [ + [74, 48], + [126, 48], + ]) + const topEdge = edgeKey([0, 0], [0, 1]) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('line') + + dragCanvas(canvas, [ + [74, 48], + [126, 48], + ]) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('unknown') + + dragCanvas(canvas, [ + [74, 48], + [126, 48], + ]) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('line') + + dragCanvas( + canvas, + [ + [74, 48], + [126, 48], + ], + 2, + ) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('blank') + + dragCanvas( + canvas, + [ + [48, 74], + [48, 126], + ], + 2, + ) + const leftEdge = edgeKey([0, 0], [1, 0]) + expect(useEditorStore.getState().puzzle.edges[leftEdge]?.mark).toBe('blank') + + fireEvent.click(screen.getByRole('button', { name: /solve it/i })) + + expect(screen.getByRole('heading', { name: /puzzlekit web/i })).toBeInTheDocument() + expect(useSolverStore.getState().initialPuzzle.cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'number', + value: 3, + }) + expect(useSolverStore.getState().initialPuzzle.edges[leftEdge]?.mark).toBe('blank') + expect(useSolverStore.getState().pointer).toBe(0) + }) + + it('changes each edge at most once during a single drag stroke', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + const topEdge = edgeKey([0, 0], [0, 1]) + + dragCanvas(canvas, [ + [74, 48], + [96, 48], + [74, 48], + [126, 48], + ]) + + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('line') + }) + + it('marks crosses with a strict right-click edge target', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + const topEdge = edgeKey([0, 0], [0, 1]) + + rightClickCanvas(canvas, 74, 53) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('unknown') + + rightClickCanvas(canvas, 74, 48) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('blank') + }) + + it('does not draw edges from cell clicks or drags that start inside cells', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + const topEdge = edgeKey([0, 0], [0, 1]) + const rightEdge = edgeKey([0, 1], [1, 1]) + + clickCanvas(canvas, 74, 52) + fireEvent.keyDown(canvas, { key: '1' }) + + expect(useEditorStore.getState().puzzle.cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'number', + value: 1, + }) + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('unknown') + + dragCanvas(canvas, [ + [74, 74], + [126, 48], + [152, 48], + ]) + + expect(useEditorStore.getState().puzzle.edges[topEdge]?.mark).toBe('unknown') + expect(useEditorStore.getState().puzzle.edges[rightEdge]?.mark).toBe('unknown') + }) + + it('follows U-shaped edge drags across horizontal and vertical edges', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + const leftEdge = edgeKey([0, 0], [1, 0]) + const bottomEdge = edgeKey([1, 0], [1, 1]) + const rightEdge = edgeKey([0, 1], [1, 1]) + + dragCanvas(canvas, [ + [48, 74], + [48, 100], + [74, 100], + [100, 100], + [100, 74], + ]) + + expect(useEditorStore.getState().puzzle.edges[leftEdge]?.mark).toBe('line') + expect(useEditorStore.getState().puzzle.edges[bottomEdge]?.mark).toBe('line') + expect(useEditorStore.getState().puzzle.edges[rightEdge]?.mark).toBe('line') + }) + + it('keeps vertical drags from falling back to crossed horizontal edges', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + mockCanvasRect(canvas) + const firstVertical = edgeKey([0, 1], [1, 1]) + const secondVertical = edgeKey([1, 1], [2, 1]) + const crossedHorizontal = edgeKey([1, 0], [1, 1]) + + dragCanvas(canvas, [ + [100, 74], + [100, 126], + ]) + + expect(useEditorStore.getState().puzzle.edges[firstVertical]?.mark).toBe('line') + expect(useEditorStore.getState().puzzle.edges[secondVertical]?.mark).toBe('line') + expect(useEditorStore.getState().puzzle.edges[crossedHorizontal]?.mark).toBe('unknown') + }) + + it('opens the preset library and filters presets by search and tag', () => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /load preset/i })) + + const dialog = screen.getByRole('dialog', { name: /load preset/i }) + expect(dialog.querySelector('.preset-grid-scroll')).not.toBeNull() + expect(within(dialog).getByText(/default slitherlink 1/i)).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: /default/i })).toBeInTheDocument() + expect(within(dialog).getAllByLabelText(/preset preview/i).length).toBeGreaterThan(0) + + fireEvent.click(within(dialog).getByRole('button', { name: /puzz\.link/i })) + expect(within(dialog).getByText(/default slitherlink 2/i)).toBeInTheDocument() + + fireEvent.change(within(dialog).getByLabelText(/search presets/i), { + // Unique fragment from default-slitherlink-2 sourceUrl (search is substring match over URL/name/etc.) + target: { value: '82232382' }, + }) + expect(within(dialog).getByText(/default slitherlink 2/i)).toBeInTheDocument() + expect(within(dialog).queryByText(/default slitherlink 1/i)).not.toBeInTheDocument() + }) + + it('uses the shared workspace grid columns on the editor page', () => { + render( + + + , + ) + + expect(document.querySelector('.workspace-grid.editor-workspace-grid')).not.toBeNull() + }) + + it('loads a preset into the editor from the preset library', () => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /load preset/i })) + const card = screen.getByText(/default slitherlink 1/i).closest('article') + expect(card).not.toBeNull() + fireEvent.click(within(card as HTMLElement).getByRole('button', { name: /to edit/i })) + + expect(screen.queryByRole('dialog', { name: /load preset/i })).not.toBeInTheDocument() + expect(useEditorStore.getState().selectedPresetId).toBe('default-slitherlink-1') + expect(useEditorStore.getState().puzzle.rows).toBe(10) + expect(useEditorStore.getState().puzzle.cols).toBe(10) + }) + + it('loads a preset into the solver from the preset library', () => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /load preset/i })) + const card = screen.getByText(/default slitherlink 1/i).closest('article') + expect(card).not.toBeNull() + fireEvent.click(within(card as HTMLElement).getByRole('button', { name: /to solve/i })) + + expect(screen.getByRole('heading', { name: /puzzlekit web/i })).toBeInTheDocument() + expect(useSolverStore.getState().initialPuzzle.rows).toBe(10) + expect(useSolverStore.getState().initialPuzzle.cols).toBe(10) + }) + + it('opens preset URLs in a new tab', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /load preset/i })) + const card = screen.getByText(/default slitherlink 1/i).closest('article') + expect(card).not.toBeNull() + fireEvent.click(within(card as HTMLElement).getByRole('button', { name: 'URL' })) + + expect(open).toHaveBeenCalledWith( + 'https://puzz.link/p?slither/10/10/gdk8dh2ah738cgd60djagbdgcj25bdg817ah0dh8dk5', + '_blank', + 'noopener,noreferrer', + ) + }) + + it('closes the preset library with close controls and Escape', () => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /load preset/i })) + fireEvent.click(screen.getByRole('button', { name: /close preset library/i })) + expect(screen.queryByRole('dialog', { name: /load preset/i })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /load preset/i })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: /load preset/i })).not.toBeInTheDocument() + }) + + it('keeps wheel scrolling separate from editor board zoom', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + const zoom = screen.getByLabelText(/board zoom/i) + + expect(fireEvent.wheel(canvas, { deltaY: -120 })).toBe(true) + expect(zoom).toHaveValue('100') + }) + + it('zooms the editor board with the slider while preserving hit targets', () => { + render( + + + , + ) + + const canvas = screen.getByLabelText(/slitherlink editor canvas/i) as HTMLCanvasElement + const zoom = screen.getByLabelText(/board zoom/i) + + fireEvent.change(zoom, { target: { value: '150' } }) + expect(zoom).toHaveValue('150') + mockCanvasRect(canvas) + + clickCanvas(canvas, 111, 111) + fireEvent.keyDown(canvas, { key: '2' }) + expect(useEditorStore.getState().puzzle.cells[cellKey(0, 0)]?.clue).toEqual({ + kind: 'number', + value: 2, + }) + + dragCanvas(canvas, [ + [111, 72], + [189, 72], + ]) + expect(useEditorStore.getState().puzzle.edges[edgeKey([0, 0], [0, 1])]?.mark).toBe('line') + }) + + it('draws row and column labels around the editor grid', () => { + const fillText = vi.fn() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + ({ + clearRect: () => {}, + save: () => {}, + restore: () => {}, + scale: () => {}, + fillRect: () => {}, + beginPath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + strokeRect: () => {}, + fillText, + arc: () => {}, + fill: () => {}, + setLineDash: () => {}, + }) as unknown as CanvasRenderingContext2D, + ) + + useEditorStore.getState().loadEditorPuzzle(createSlitherPuzzle(3, 4)) + + render( + + + , + ) + + const labels = fillText.mock.calls.map(([text]) => text) + expect(labels).toContain('R1') + expect(labels).toContain('R3') + expect(labels).toContain('C1') + expect(labels).toContain('C4') + }) +}) diff --git a/src/app/EditorPage.tsx b/src/app/EditorPage.tsx new file mode 100644 index 0000000..e22f7ac --- /dev/null +++ b/src/app/EditorPage.tsx @@ -0,0 +1,506 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { parseCellKey, parseEdgeKey } from '../domain/ir/keys' +import { + SLITHER_CUSTOM_GRID_MAX, + SLITHER_CUSTOM_GRID_MIN, +} from '../domain/ir/slither' +import type { PuzzleIR } from '../domain/ir/types' +import { puzzleRegistry } from '../domain/plugins/registry' +import { SlitherlinkEditorBoard } from '../features/editor/SlitherlinkEditorBoard' +import { useEditorStore } from '../features/editor/editorStore' +import { puzzlePresets, type PuzzlePreset } from '../features/editor/presets' +import { useSolverStore } from '../features/solver/solverStore' +import './workspace.css' + +const PRESET_PREVIEW_WIDTH = 320 +const PRESET_PREVIEW_HEIGHT = 180 +const PRESET_PREVIEW_PADDING = 18 + +const parsePresetPuzzle = (preset: PuzzlePreset): PuzzleIR | null => { + if (preset.puzzle) { + return preset.puzzle + } + if (!preset.sourceUrl) { + return null + } + const plugin = puzzleRegistry.get(preset.puzzleType) + if (!plugin) { + return null + } + try { + return plugin.parse(preset.sourceUrl) + } catch { + return null + } +} + +const drawPresetPreview = (ctx: CanvasRenderingContext2D, puzzle: PuzzleIR): void => { + const boardWidth = PRESET_PREVIEW_WIDTH - PRESET_PREVIEW_PADDING * 2 + const boardHeight = PRESET_PREVIEW_HEIGHT - PRESET_PREVIEW_PADDING * 2 + const cellSize = Math.min(boardWidth / puzzle.cols, boardHeight / puzzle.rows) + const gridWidth = cellSize * puzzle.cols + const gridHeight = cellSize * puzzle.rows + const offsetX = (PRESET_PREVIEW_WIDTH - gridWidth) / 2 + const offsetY = (PRESET_PREVIEW_HEIGHT - gridHeight) / 2 + + ctx.clearRect(0, 0, PRESET_PREVIEW_WIDTH, PRESET_PREVIEW_HEIGHT) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, PRESET_PREVIEW_WIDTH, PRESET_PREVIEW_HEIGHT) + + ctx.strokeStyle = '#cbd5e1' + ctx.lineWidth = 1 + for (let row = 0; row <= puzzle.rows; row += 1) { + const y = offsetY + row * cellSize + ctx.beginPath() + ctx.moveTo(offsetX, y) + ctx.lineTo(offsetX + gridWidth, y) + ctx.stroke() + } + for (let col = 0; col <= puzzle.cols; col += 1) { + const x = offsetX + col * cellSize + ctx.beginPath() + ctx.moveTo(x, offsetY) + ctx.lineTo(x, offsetY + gridHeight) + ctx.stroke() + } + + ctx.fillStyle = '#111827' + ctx.font = `700 ${Math.max(12, Math.min(22, cellSize * 0.5))}px Inter, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + for (const [key, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind !== 'number') { + continue + } + const [row, col] = parseCellKey(key) + ctx.fillText( + String(cell.clue.value), + offsetX + col * cellSize + cellSize / 2, + offsetY + row * cellSize + cellSize / 2, + ) + } + + for (const [edge, state] of Object.entries(puzzle.edges)) { + const [v1, v2] = parseEdgeKey(edge) + const x1 = offsetX + v1[1] * cellSize + const y1 = offsetY + v1[0] * cellSize + const x2 = offsetX + v2[1] * cellSize + const y2 = offsetY + v2[0] * cellSize + + if (state.mark === 'line') { + ctx.strokeStyle = '#0284c7' + ctx.lineWidth = Math.max(2, cellSize * 0.08) + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + } else if (state.mark === 'blank') { + const midX = (x1 + x2) / 2 + const midY = (y1 + y2) / 2 + const crossSize = Math.max(3, cellSize * 0.18) + ctx.strokeStyle = '#94a3b8' + ctx.lineWidth = Math.max(1.5, cellSize * 0.05) + ctx.beginPath() + ctx.moveTo(midX - crossSize, midY - crossSize) + ctx.lineTo(midX + crossSize, midY + crossSize) + ctx.moveTo(midX + crossSize, midY - crossSize) + ctx.lineTo(midX - crossSize, midY + crossSize) + ctx.stroke() + } + } + + ctx.fillStyle = '#111827' + const vertexRadius = Math.max(1.3, Math.min(2.2, cellSize * 0.08)) + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + ctx.beginPath() + ctx.arc(offsetX + col * cellSize, offsetY + row * cellSize, vertexRadius, 0, Math.PI * 2) + ctx.fill() + } + } +} + +const PresetPreviewBoard = ({ preset }: { preset: PuzzlePreset }) => { + const canvasRef = useRef(null) + const puzzle = useMemo(() => parsePresetPuzzle(preset), [preset]) + + useEffect(() => { + if (preset.previewImageUrl || !puzzle) { + return + } + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) { + return + } + canvas.width = PRESET_PREVIEW_WIDTH + canvas.height = PRESET_PREVIEW_HEIGHT + drawPresetPreview(ctx, puzzle) + }, [preset.previewImageUrl, puzzle]) + + if (preset.previewImageUrl) { + return + } + + if (!puzzle) { + return {preset.rows} × {preset.cols} + } + + return ( + + ) +} + +type PresetLibraryDialogProps = { + presets: PuzzlePreset[] + selectedPresetId: string | null + onClose: () => void + onOpenUrl: (preset: PuzzlePreset) => void + onLoadToEdit: (preset: PuzzlePreset) => void + onLoadToSolve: (preset: PuzzlePreset) => void + actionError: string +} + +const PresetLibraryDialog = ({ + presets, + selectedPresetId, + onClose, + onOpenUrl, + onLoadToEdit, + onLoadToSolve, + actionError, +}: PresetLibraryDialogProps) => { + const [query, setQuery] = useState('') + const [activeTag, setActiveTag] = useState(null) + const tags = useMemo( + () => Array.from(new Set(presets.flatMap((preset) => preset.tags))).sort(), + [presets], + ) + const filteredPresets = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase() + return presets.filter((preset) => { + const searchableText = [ + preset.name, + preset.description, + preset.sourceUrl, + preset.puzzleType, + preset.rows, + preset.cols, + ...preset.tags, + ] + .filter((value) => value !== undefined) + .join(' ') + .toLowerCase() + const matchesQuery = normalizedQuery.length === 0 || searchableText.includes(normalizedQuery) + const matchesTag = activeTag === null || preset.tags.includes(activeTag) + return matchesQuery && matchesTag + }) + }, [activeTag, presets, query]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + return ( +
+
event.stopPropagation()} + > +
+
+

Load Preset

+ {/*

Select a puzzle to open, solve, or continue editing.

*/} +
+ +
+
+ +
+ + {tags.map((tag) => ( + + ))} +
+
+ {actionError ?

{actionError}

: null} +
+
+ {filteredPresets.map((preset) => ( +
+
+ +
+
+

{preset.name}

+ + {preset.rows} × {preset.cols} · {preset.puzzleType} + +
+ {preset.tags.map((tag) => ( + {tag} + ))} +
+ {preset.description ? ( +

{preset.description}

+ ) : null} +
+
+ + + +
+
+ ))} +
+ {filteredPresets.length === 0 ?

No presets match the current filters.

: null} +
+
+
+ ) +} + +export const EditorPage = () => { + const navigate = useNavigate() + const { + pluginId, + puzzle, + sourceUrl, + importError, + selectedPresetId, + setPluginId, + createBlankSlither, + importFromUrl, + loadPreset, + setSlitherCellClue, + setSlitherEdgeMark, + } = useEditorStore() + const loadPuzzle = useSolverStore((state) => state.loadPuzzle) + const [localUrl, setLocalUrl] = useState(sourceUrl) + const [rows, setRows] = useState(String(puzzle.rows)) + const [cols, setCols] = useState(String(puzzle.cols)) + const [showPresetLibrary, setShowPresetLibrary] = useState(false) + const [presetActionError, setPresetActionError] = useState('') + + useEffect(() => { + setRows(String(puzzle.rows)) + setCols(String(puzzle.cols)) + }, [puzzle.rows, puzzle.cols]) + + useEffect(() => { + setLocalUrl(sourceUrl) + }, [sourceUrl]) + + const solveCurrentPuzzle = () => { + loadPuzzle(puzzle, { + pluginId: puzzle.puzzleType, + sourceUrl, + }) + navigate('/') + } + + const openPresetUrl = (preset: PuzzlePreset) => { + if (!preset.sourceUrl) { + return + } + window.open(preset.sourceUrl, '_blank', 'noopener,noreferrer') + } + + const loadPresetToEdit = (preset: PuzzlePreset) => { + loadPreset(preset) + setRows(String(preset.rows)) + setCols(String(preset.cols)) + setLocalUrl(preset.sourceUrl ?? '') + setPresetActionError('') + setShowPresetLibrary(false) + navigate('/editor') + } + + const loadPresetToSolve = (preset: PuzzlePreset) => { + try { + if (preset.puzzle) { + loadPuzzle(preset.puzzle, { + pluginId: preset.puzzleType, + sourceUrl: preset.sourceUrl ?? '', + }) + } else if (preset.sourceUrl) { + const plugin = puzzleRegistry.get(preset.puzzleType) + if (!plugin) { + throw new Error(`Plugin "${preset.puzzleType}" not found.`) + } + const parsed = plugin.parse(preset.sourceUrl) + loadPuzzle(parsed, { + pluginId: preset.puzzleType, + sourceUrl: preset.sourceUrl, + }) + } else { + throw new Error(`Preset "${preset.name}" does not include puzzle data.`) + } + setPresetActionError('') + setShowPresetLibrary(false) + navigate('/') + } catch (error) { + setPresetActionError(error instanceof Error ? error.message : String(error)) + } + } + + return ( +
+
+
+
+
+

PuzzleKit Editor

+

Create a puzzle, then hand it to the explainable solver.

+
+ +
+ +
+
+
+
+

Puzzle Builder

+
+ +
+ Grid +
+ + + +
+
+