diff --git a/app/globals.css b/app/globals.css index 92e4bc3..e23a8fb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -747,6 +747,21 @@ button:disabled { .tour-layer-guided { pointer-events: auto; } +.chart-svg.chart-svg-move { + cursor: crosshair; +} +.chart-increase-target { + fill: none; + stroke: #44936b; + stroke-width: 3px; +} +.increase-move-hint { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 4px 16px 10px; +} .yoke-tour-editor { background: var(--panel); inset: 0; diff --git a/components/chart-grid.tsx b/components/chart-grid.tsx index fb891ed..1bbb655 100644 --- a/components/chart-grid.tsx +++ b/components/chart-grid.tsx @@ -1,6 +1,7 @@ "use client"; import { PointerEvent, useRef } from "react"; +import type { ChartPoint } from "@/lib/yoke-move-increase"; import { cellAspectRatio, CellSelection, @@ -26,6 +27,9 @@ type Props = { onPaint: (row: number, column: number, flood: boolean) => void; yarnOnly?: boolean; onStrokeEnd?: () => void; + onCellPick?: (point: ChartPoint) => void; + onCellPoint?: (point: ChartPoint | null) => void; + moveTarget?: ChartPoint; }; export function ChartGrid({ @@ -39,6 +43,9 @@ export function ChartGrid({ onPaint, yarnOnly = false, onStrokeEnd, + onCellPick, + onCellPoint, + moveTarget, }: Props) { const svgRef = useRef(null); const drawing = useRef(false); @@ -79,6 +86,10 @@ export function ChartGrid({ const cell = cellFromPointer(event); if (!cell) return; event.currentTarget.setPointerCapture(event.pointerId); + if (onCellPick) { + onCellPick(cell); + return; + } if (tool === "select") { selecting.current = true; drawing.current = false; @@ -99,6 +110,10 @@ export function ChartGrid({ } function handlePointerMove(event: PointerEvent) { + if (onCellPick) { + if (event.pointerType !== "touch") onCellPoint?.(cellFromPointer(event)); + return; + } if (drawing.current || selecting.current) event.preventDefault(); if (selecting.current && selectionStart.current) { const cell = cellFromPointer(event); @@ -170,7 +185,7 @@ export function ChartGrid({ ) : null} + {moveTarget ? ( + + ) : null} -

- {structureAction === "knit" - ? "Click and drag to remove stitch symbols, keeping yarn colours. With Fill, click a connected group of symbols to remove them." - : structureAction - ? tool === "fill" - ? `Click an area to fill it with ${stitchActionLabel(structureAction)}.` - : `Click and drag to mark cells as ${stitchActionLabel(structureAction)}${document.guides.symmetry !== "none" ? " with live symmetry" : ""}.` - : tool === "fill" - ? "Click an area to fill its yarn colour. Stitch symbols stay in place." - : tool === "select" - ? yarnOnly - ? "Drag a rectangle to fill, copy, cut, paste or move yarn colours." - : "Drag a rectangle to fill, copy, cut, paste, move, or apply a stitch action." - : `Click and drag to ${tool === "eraser" ? "erase the yarn colour" : "paint"}${document.guides.symmetry !== "none" ? " with live symmetry" : ""}.`} - {document.stitchActions && !structureAction && !yarnOnly - ? " Use Remove stitch to erase symbols without changing yarn colours." - : ""} -

+ {!onCellPick ? ( +

+ {structureAction === "knit" + ? "Click and drag to remove stitch symbols, keeping yarn colours. With Fill, click a connected group of symbols to remove them." + : structureAction + ? tool === "fill" + ? `Click an area to fill it with ${stitchActionLabel(structureAction)}.` + : `Click and drag to mark cells as ${stitchActionLabel(structureAction)}${document.guides.symmetry !== "none" ? " with live symmetry" : ""}.` + : tool === "fill" + ? "Click an area to fill its yarn colour. Stitch symbols stay in place." + : tool === "select" + ? yarnOnly + ? "Drag a rectangle to fill, copy, cut, paste or move yarn colours." + : "Drag a rectangle to fill, copy, cut, paste, move, or apply a stitch action." + : `Click and drag to ${tool === "eraser" ? "erase the yarn colour" : "paint"}${document.guides.symmetry !== "none" ? " with live symmetry" : ""}.`} + {document.stitchActions && !structureAction && !yarnOnly + ? " Use Remove stitch to erase symbols without changing yarn colours." + : ""} +

+ ) : null} ); diff --git a/components/stitch-palette-panel.tsx b/components/stitch-palette-panel.tsx index 7961f0f..8bee589 100644 --- a/components/stitch-palette-panel.tsx +++ b/components/stitch-palette-panel.tsx @@ -8,6 +8,7 @@ import { type StitchAction, type StitchPaletteAction, } from "@/lib/chart"; +const yokeStitchOptions = new Set(["purl", "m1l", "m1r"]); type Props = { document: ChartDocument; structureAction: StitchAction | null; @@ -34,6 +35,12 @@ export function StitchPalettePanel({ removeStitchFromPalette, resetStitchColor, }: Props) { + const availableStitches = stitchActionOptions.filter( + (option): option is typeof option & { value: StitchPaletteAction } => + option.value !== "knit" && + (!document.yoke || yokeStitchOptions.has(option.value)) && + !stitchPalette.includes(option.value), + ); return (

Add a stitch type to this chart’s palette.

- {stitchActionOptions - .filter( - ( - option, - ): option is typeof option & { - value: StitchPaletteAction; - } => - option.value !== "knit" && - !stitchPalette.includes( - option.value as StitchPaletteAction, - ), - ) - .map((option) => ( - - ))} - {stitchPalette.length === - stitchActionOptions.filter((option) => option.value !== "knit") - .length ? ( + {availableStitches.map((option) => ( + + ))} + {!availableStitches.length ? ( All stitch types have been added. ) : null}
diff --git a/components/use-yoke-increase-move.ts b/components/use-yoke-increase-move.ts new file mode 100644 index 0000000..6d264a9 --- /dev/null +++ b/components/use-yoke-increase-move.ts @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { yokeChart } from "@/lib/yoke-chart"; +import { + prepareYokeIncreaseMove, + type ChartPoint, +} from "@/lib/yoke-move-increase"; +import type { YokeProject } from "@/lib/yoke-project"; +import type { YokeRepeatScope } from "@/lib/yoke-repeat-scope"; + +const same = (a: ChartPoint | null, b: ChartPoint | null) => + a === b || (!!a && !!b && a.row === b.row && a.column === b.column); + +export function useYokeIncreaseMove( + project: YokeProject, + repeat: number, + scope: YokeRepeatScope, + enabled: boolean, + onCommit: (next: YokeProject) => void, +) { + const context = `${repeat}/${scope}`; + const [picked, setPicked] = useState<{ + project: YokeProject; + context: string; + point: ChartPoint; + prepare: ReturnType; + } | null>(null); + const [target, setTarget] = useState(null); + const [preview, setPreview] = useState<{ + picked: NonNullable; + target: ChartPoint; + project?: YokeProject; + chart?: ReturnType["document"]; + error?: string; + } | null>(null); + const [error, setError] = useState(""); + const active = + enabled && picked?.project === project && picked.context === context + ? picked + : null; + const result = + active && preview?.picked === active && same(preview.target, target) + ? preview + : null; + + useEffect(() => { + if (!active || !target || same(active.point, target)) return; + const timer = window.setTimeout(() => { + try { + const next = active.prepare(target); + setPreview({ + picked: active, + target, + project: next, + chart: yokeChart(next, repeat).document, + }); + } catch (cause) { + setPreview({ + picked: active, + target, + error: + cause instanceof Error + ? cause.message + : "Could not move this increase.", + }); + } + }, 100); + return () => window.clearTimeout(timer); + }, [active, target, repeat]); + + function cancel() { + setPicked(null); + setTarget(null); + setPreview(null); + setError(""); + } + function apply() { + if (!result?.project) return; + onCommit(result.project); + cancel(); + } + function pointAt(point: ChartPoint | null) { + if (active) + setTarget((current) => (same(current, point) ? current : point)); + } + function click(point: ChartPoint) { + if (!enabled) return; + if (active) { + if (same(active.point, point)) cancel(); + else if (result?.project && same(result.target, point)) apply(); + else if (result?.error && same(result.target, point)) + setError(result.error); + else pointAt(point); + return; + } + cancel(); + try { + setPicked({ + project, + context, + point, + prepare: prepareYokeIncreaseMove(project, repeat, scope, point), + }); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Choose an M1L or M1R.", + ); + } + } + return { + click, + pointAt, + cancel, + apply, + source: active?.point, + target: result?.project ? result.target : undefined, + chart: result?.chart, + error: enabled ? error : "", + }; +} diff --git a/components/use-yoke-tools.ts b/components/use-yoke-tools.ts index c5fd1ff..54b6821 100644 --- a/components/use-yoke-tools.ts +++ b/components/use-yoke-tools.ts @@ -25,7 +25,7 @@ import { mirrorYokeRepeat } from "@/lib/yoke-repeat-mirror"; import type { YokeRepeatScope } from "@/lib/yoke-repeat-scope"; import { yokeRepeatNumber } from "@/lib/yoke-repeat-labels"; -export type YokeTool = Tool | "row-fill"; +export type YokeTool = Tool | "row-fill" | "move-increase"; export function useYokeTools( project: YokeProject, view: YokeView, diff --git a/components/yoke-drawing-tools.tsx b/components/yoke-drawing-tools.tsx index cd6e727..b83e374 100644 --- a/components/yoke-drawing-tools.tsx +++ b/components/yoke-drawing-tools.tsx @@ -15,6 +15,7 @@ export function YokeDrawingTools({ onErase, onFillSelection, selectionLabel, + moveIncreaseUnavailable, }: { tools: YokeTools; color: number; @@ -28,6 +29,7 @@ export function YokeDrawingTools({ onErase?: () => void; onFillSelection?: () => void; selectionLabel?: string; + moveIncreaseUnavailable?: string; }) { const { selection } = tools; return ( @@ -55,6 +57,22 @@ export function YokeDrawingTools({ {label} ))} +