diff --git a/app/globals.css b/app/globals.css index 0e7f9e3..2278181 100644 --- a/app/globals.css +++ b/app/globals.css @@ -395,6 +395,25 @@ button:disabled { cursor: default; opacity: 0.42; } +.sidebar .secondary-button, +.sidebar .resize-chart-button, +.yoke-repeat-navigation .secondary-button, +.yoke-editing-scope .secondary-button { + transition: + background-color 0.22s ease, + border-color 0.22s ease, + color 0.22s ease; +} +.sidebar .secondary-button:hover:not(:disabled), +.sidebar .resize-chart-button:hover:not(:disabled), +.yoke-repeat-navigation .secondary-button:hover:not(:disabled), +.yoke-editing-scope .secondary-button:hover:not(:disabled) { + background-color: #f4f5f0; + border-color: #c5cabb; + box-shadow: 0 1px 2px rgba(60, 50, 35, 0.04); + color: var(--ink); + transform: none; +} .primary-button { background: var(--accent); border-color: var(--accent); @@ -3027,6 +3046,13 @@ button:disabled { padding: 18px; align-items: start; } +.chart-stack { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; + width: 100%; +} .panel { background: var(--panel); @@ -3354,7 +3380,7 @@ select:focus { .editor { display: flex; flex-direction: column; - min-height: 620px; + min-height: 0; overflow: hidden; } .panel-heading { @@ -3638,6 +3664,9 @@ select:focus { height: 31px; width: 49px; } +.repeat-controls .yoke-preview-view-select { + width: 130px; +} .repeat-controls .repeat-style select { width: 88px; } @@ -3858,6 +3887,56 @@ select:focus { font-size: 10px; line-height: 1.4; } +.knit-section-size { + align-items: center; + color: var(--muted); + display: flex; + font-size: 11px; + gap: 8px; + justify-content: space-between; +} +.knit-section-size select { + flex: 0 0 64px; + font-size: 12px; + height: 28px; + min-height: 28px; + padding: 3px 7px; + width: 64px; +} +.knit-section-location { + align-items: baseline; + color: var(--muted); + display: flex; + flex-wrap: wrap; + font-size: 11px; + gap: 6px 12px; + justify-content: space-between; +} +.knit-jump-control summary { + color: var(--accent); + cursor: pointer; + font-size: 11px; +} +.knit-jump-control[open] { + flex-basis: 100%; +} +.knit-jump-fields { + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + margin-top: 6px; +} +.knit-jump-fields label { + font-size: 11px; + gap: 3px; + min-width: 0; +} +.knit-jump-fields select { + font-size: 12px; + height: 28px; + min-height: 28px; + padding: 3px 7px; +} .knit-mode-settings-grid { display: grid; gap: 8px; @@ -4007,8 +4086,42 @@ select:focus { .knit-row-controls button { min-height: 46px; } +.knit-step-controls button { + font-size: 12px; + line-height: 1.35; + min-height: 34px; + padding: 5px 8px; +} .knit-reset { background: transparent; + border-color: var(--line-soft); + border-radius: 999px; + box-shadow: none; + color: var(--muted); + font-size: 12px; + justify-self: center; + line-height: 1.4; + max-width: 100%; + min-height: 32px; + padding: 6px 14px; +} +.knit-reset:hover:not(:disabled) { + border-color: var(--line); + box-shadow: none; + transform: none; +} +.knit-short-row-prelude { + color: var(--ink); + font-size: 12px; + line-height: 1.5; +} +.knit-short-row-prelude > p { + margin: 0 0 4px; +} +.knit-short-row-prelude summary { + color: var(--muted); + cursor: pointer; + font-size: 11px; } .knit-keyboard-hint { color: var(--muted); @@ -4773,7 +4886,7 @@ select:focus { width: 100%; } .editor { - min-height: 560px; + min-height: 0; order: 1; } .preview { diff --git a/app/yoke-designer/page.dev.tsx b/app/yoke-designer/page.dev.tsx new file mode 100644 index 0000000..1ffdd35 --- /dev/null +++ b/app/yoke-designer/page.dev.tsx @@ -0,0 +1,8 @@ +import { notFound } from "next/navigation"; +import { YokeDesigner } from "@/components/yoke-designer"; +import "./yoke.css"; + +export default function YokeDesignerPage() { + if (process.env.NODE_ENV !== "development") notFound(); + return ; +} diff --git a/app/yoke-designer/yoke.css b/app/yoke-designer/yoke.css new file mode 100644 index 0000000..112f76c --- /dev/null +++ b/app/yoke-designer/yoke.css @@ -0,0 +1,539 @@ +.yoke-designer { + max-width: 1600px; + margin: auto; + padding: 24px; +} +.yoke-plan-dialog { + width: min(1250px, 95vw); + max-width: 1250px; + max-height: 92vh; + overflow: auto; +} +.yoke-plan-comparison { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin: 20px 0; +} +.yoke-plan-comparison figure { + min-width: 0; + margin: 0; +} +.yoke-plan-comparison figcaption { + font-weight: 600; + margin-bottom: 8px; +} +.yoke-transfer-options { + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; +} +.yoke-transfer-options label { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 8px 20px 8px 0; +} +.yoke-gauge-field { + display: grid; + gap: 5px; + margin: 8px 0; +} +.yoke-gauge-field input, +.yoke-gauge-field select { + min-width: 0; + width: 100%; +} +.yoke-gauge-panel .yoke-hint { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + margin: 8px 0 12px; +} +.yoke-gauge-panel .yoke-update-gauge { + font-size: 12px; + min-height: 32px; + width: 100%; +} +.yoke-gauge-panel .text-button { + font-size: 11px; + line-height: 1.5; + margin-top: 8px; + text-align: left; +} +.yoke-editing-scope .secondary-button { + font-size: 12px; + min-height: 32px; +} +.editor > .yoke-view-tabs { + margin: 0; + padding: 16px 20px 6px; +} +.yoke-editor-panel .yoke-view-tabs { + gap: 12px 18px; +} +.yoke-editor-panel .yoke-view-tabs > select { + font-size: 12px; + min-height: 32px; + width: auto; +} +.yoke-editor-panel .yoke-repeat-toggle { + margin-left: 0; + font-size: 12px; + width: auto; +} +.yoke-plan-summary p { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + margin: 6px 0; +} +.yoke-plan-summary p:first-child { + color: var(--ink); + font-size: 13px; + font-weight: 500; +} +.yoke-plan-summary button { + font-size: 12px; + min-height: 32px; + margin-top: 6px; +} +.yoke-zoom-controls select { + background: transparent; + border: 0; + box-shadow: none; + color: var(--muted); + font-size: 12px; + min-height: 32px; + padding: 3px; + width: 65px; +} +.yoke-header, +.yoke-header nav, +.yoke-toolbar, +.yoke-view-tabs, +.yoke-summary { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} +.yoke-header { + justify-content: space-between; + margin-bottom: 24px; +} +.yoke-header h1 { + font-size: 25px; + margin: 0; +} +.yoke-header h1 span { + color: var(--accent-deep); +} +.yoke-header p { + margin: 5px 0; + font-size: 12px; +} +.yoke-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 20px; + align-items: start; +} +.yoke-short-row-inputs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 12px; + margin: 16px 0; +} +.yoke-short-row-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.yoke-short-row-map { + display: block; + max-width: 700px; + width: 100%; + margin: 20px auto; + font: 12px sans-serif; + fill: var(--ink); +} +.yoke-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 20px; + min-width: 0; +} +.yoke-planner-measurements { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + align-items: start; + gap: 24px; +} +.yoke-planner-measurements .yoke-measurement-guide { + position: static; +} +.yoke-panel h2 { + font-size: 22px; + margin: 0 0 8px; +} +.yoke-panel h3 { + font-size: 17px; + margin: 20px 0 8px; +} +.yoke-panel p, +.yoke-panel li { + font-size: 13px; + line-height: 1.5; +} +.yoke-panel .yoke-hint, +.yoke-panel small { + color: #686658; + font-size: 12px; +} +.yoke-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 16px 0; +} +.yoke-panel .yoke-fields label { + display: flex; + flex-direction: column; +} +.yoke-panel label { + display: grid; + gap: 6px; + font-size: 12px; + min-width: 0; +} +.yoke-panel input:not([type="checkbox"]):not([type="color"]), +.yoke-panel select { + width: 100%; + min-width: 0; + padding: 7px; + border: 1px solid var(--line); + border-radius: 6px; + background: white; + font: inherit; +} +.yoke-panel small { + line-height: 1.4; +} +.yoke-measurement-control { + display: grid; + gap: 8px; + margin: 16px 0; + font-size: 12px; +} +.yoke-panel .yoke-measurement-toggle { + display: flex; + justify-content: flex-start; + gap: 10px; + white-space: normal; +} +.yoke-measurement-toggle .knit-toggle-track { + flex-shrink: 0; +} +.yoke-measurement-toggle input:not(:checked) ~ span:last-child, +.yoke-measurement-toggle:has(input:checked) > span:first-child { + color: var(--muted); + font-weight: 400; +} +.yoke-schedule > div { + display: grid; + grid-template-columns: 1fr 1fr 30px; + gap: 8px; + align-items: end; + margin-bottom: 10px; +} +.yoke-summary { + justify-content: space-between; + font-size: 13px; + margin-bottom: 16px; +} +.yoke-toolbar { + margin-top: 16px; +} +.yoke-toolbar label { + display: flex; + align-items: center; +} +.yoke-colour { + display: flex; + align-items: center; + gap: 3px; +} +.yoke-colour button { + border: 2px solid transparent; + border-radius: 50%; + width: 28px; + height: 28px; +} +.yoke-colour button[aria-pressed="true"] { + outline: 2px solid var(--accent-deep); + outline-offset: 2px; +} +.yoke-colour input { + width: 24px; + height: 24px; + padding: 0; + border: 0; +} +.yoke-canvas-scroll { + width: 100%; + overflow: auto; + background: white; + border-radius: 8px; + max-height: 75vh; +} +.yoke-editing-scope { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 18px; + padding: 12px 20px; + font-size: 13px; +} +.yoke-editing-scope label { + display: flex; + align-items: center; + gap: 8px; +} +.yoke-repeat-notice { + font-size: 13px; + margin: 0; + padding: 0 20px 12px; + color: #675b47; +} +.yoke-selection-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + width: 100%; +} +.yoke-selection-tools button { + font-size: 12px; + min-height: 30px; +} +.yoke-canvas-scroll canvas { + display: block; + max-width: none; + height: auto; + touch-action: none; + user-select: none; + cursor: crosshair; +} +.yoke-canvas-navigable { + height: min(70vh, 700px); + overscroll-behavior: contain; +} +.yoke-canvas-navigable canvas { + margin-inline: auto; +} +.yoke-repeat-navigation { + display: flex; + align-items: center; + flex-wrap: wrap; + max-width: 100%; + gap: 12px; + padding: 0; +} +.yoke-repeat-navigation > button { + font-size: 12px; + min-height: 32px; +} +.yoke-drawing details { + margin-top: 14px; + font-size: 13px; +} +.yoke-measurement-help { + margin: 12px 0; + font-size: 12px; +} +.yoke-measurement-help svg { + display: block; + max-width: 260px; + margin: 8px auto; +} +.yoke-schedule-editor fieldset { + border: 1px solid var(--line); + border-radius: 8px; + margin: 12px 0; + padding: 10px; + min-width: 0; +} +.yoke-schedule-editor legend { + font-size: 12px; + font-weight: 600; +} +.yoke-schedule-editor label { + margin-bottom: 8px; +} +.yoke-schedule-editor small { + display: block; + overflow-wrap: anywhere; + margin-bottom: 8px; +} +.yoke-standard-chart { + min-width: 0; +} +.yoke-pending-shaping { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 20px 14px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--accent-soft); + font-size: 12px; +} +.yoke-pending-shaping button { + font-size: 12px; + min-height: 32px; +} +.yoke-measurement-guide { + margin-bottom: 20px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: #faf8f1; +} +.yoke-measurement-guide summary { + cursor: pointer; + font-weight: 600; +} +.yoke-measurement-diagrams { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.yoke-measurement-diagrams figure { + margin: 0 auto; + width: 100%; + max-width: 340px; +} +.yoke-measurement-diagrams figcaption { + font-size: 13px; + text-align: center; + padding: 8px 0; +} +.yoke-measurement-diagrams svg { + width: 100%; + display: block; +} +.measurement-outline { + fill: #ece9df; + stroke: #5b6258; + stroke-width: 1.5; + stroke-linejoin: round; +} +.measurement-lines { + fill: none; + stroke: #bba78c; + stroke-width: 1.8; +} +.measurement-lines.is-active { + stroke: #b45439; + stroke-width: 3; +} +.measurement-point { + cursor: pointer; +} +.measurement-point circle { + fill: #fff; + stroke: #7b8979; + stroke-width: 1.5; +} +.measurement-point text { + fill: #334c44; + font: 600 13px sans-serif; +} +.measurement-point.is-active circle, +.measurement-point:focus circle { + fill: #334c44; + stroke: #334c44; +} +.measurement-point.is-active text, +.measurement-point:focus text { + fill: white; +} +.measurement-point[aria-disabled="true"] { + opacity: 0.45; + cursor: default; +} +.measurement-labels { + fill: #555c50; + font: 11px sans-serif; +} +.measurement-detail { + align-self: start; +} +.measurement-detail p { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.5; +} +.measurement-guide-selection { + border-left: 3px solid #b45439; + padding: 8px 12px; + margin: 12px 0; +} +.measurement-guide-selection p { + margin: 6px 0 0; +} +.measurement-guide-legend { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.measurement-guide-legend button { + font-size: 11px; + padding: 6px 9px; +} +.measurement-guide-legend button[aria-pressed="true"] { + color: white; + background: #334c44; +} +@media (max-width: 500px) { + .yoke-measurement-diagrams { + grid-template-columns: 1fr; + } +} +.yoke-rounding-review { + border: 1px solid #c79d63; + border-radius: 8px; + padding: 12px; + margin: 12px 0; + background: #fff8eb; +} +.yoke-rounding-review button { + display: block; + margin-bottom: 8px; + font-size: 12px; + text-align: left; +} +.yoke-rounding-review label { + display: flex; + align-items: center; + margin-top: 12px; +} +@media (max-width: 900px) { + .yoke-planner-measurements { + grid-template-columns: minmax(0, 1fr); + } + .yoke-planner-measurements .yoke-measurement-guide { + position: static; + grid-row: 1; + } + .yoke-workspace { + grid-template-columns: 1fr; + } + .yoke-designer { + padding: 12px; + } +} diff --git a/components/chart-grid.tsx b/components/chart-grid.tsx index d083a76..fb891ed 100644 --- a/components/chart-grid.tsx +++ b/components/chart-grid.tsx @@ -24,6 +24,8 @@ type Props = { onSelectionChange: (selection: CellSelection | null) => void; onStrokeStart: () => void; onPaint: (row: number, column: number, flood: boolean) => void; + yarnOnly?: boolean; + onStrokeEnd?: () => void; }; export function ChartGrid({ @@ -35,9 +37,12 @@ export function ChartGrid({ onSelectionChange, onStrokeStart, onPaint, + yarnOnly = false, + onStrokeEnd, }: Props) { const svgRef = useRef(null); const drawing = useRef(false); + const strokeActive = useRef(false); const selecting = useRef(false); const selectionStart = useRef<{ row: number; column: number } | null>(null); const previousCell = useRef(""); @@ -87,6 +92,7 @@ export function ChartGrid({ return; } onStrokeStart(); + strokeActive.current = true; drawing.current = tool !== "fill"; previousCell.current = `${cell.row}:${cell.column}`; onPaint(cell.row, cell.column, tool === "fill"); @@ -115,9 +121,12 @@ export function ChartGrid({ } function endPointerAction() { + const finish = strokeActive.current; + strokeActive.current = false; drawing.current = false; selecting.current = false; selectionStart.current = null; + if (finish) onStrokeEnd?.(); } const selectionBounds = selection @@ -311,9 +320,11 @@ export function ChartGrid({ : tool === "fill" ? "Click an area to fill its yarn colour. Stitch symbols stay in place." : tool === "select" - ? "Drag a rectangle to fill, copy, cut, paste, move, or apply a stitch action." + ? 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 + {document.stitchActions && !structureAction && !yarnOnly ? " Use Remove stitch to erase symbols without changing yarn colours." : ""}

diff --git a/components/chart-maker.tsx b/components/chart-maker.tsx index 4809c1a..f8a050c 100644 --- a/components/chart-maker.tsx +++ b/components/chart-maker.tsx @@ -1,14 +1,24 @@ "use client"; +import "@/app/yoke-designer/yoke.css"; import Link from "next/link"; +import { stringifyKnitplot } from "@/lib/yoke-program-wire"; import { useSyncedDraft } from "@/components/use-synced-draft"; import { ColourPalettePanel } from "@/components/colour-palette-panel"; import { StitchPalettePanel } from "@/components/stitch-palette-panel"; import { ChartMeasurementsPanel } from "@/components/chart-measurements-panel"; import { AccountAvatar } from "@/components/account-avatar"; -import { SetStateAction, useEffect, useRef, useState } from "react"; +import { SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { AiChartGenerator } from "@/components/ai-chart-generator"; import { ChartGrid } from "@/components/chart-grid"; +import { YokeEditor } from "@/components/yoke-editor"; +import { YokeKnittedPreview } from "@/components/yoke-knitted-preview"; +import { restoreYokeHistoryDocument } from "@/lib/yoke-history"; +import { + emptyStitchDraft, + type YokeStitchDraft, +} from "@/components/use-yoke-stitches"; +import { YokePlanDialog } from "@/components/yoke-plan-dialog"; import { ExportTools } from "@/components/export-tools"; import { ImageImporter } from "@/components/image-importer"; import { InstructionsView } from "@/components/instructions-view"; @@ -74,6 +84,7 @@ import { remember, travelHistory, type TabHistory } from "@/lib/history"; import { useCloudSync } from "@/components/use-cloud-sync"; import { responseJson } from "@/lib/http"; import { tabSnapshot, workspaceSave } from "@/lib/workspace"; +import { samePreviewFabric } from "@/lib/preview-freshness"; import { useLocalWorkspace } from "@/components/use-local-workspace"; import { useCloudRecovery } from "@/components/use-cloud-recovery"; const colorDefaults = ["#d6a84b", "#6b7da8", "#884d67", "#5f795e", "#29282d"]; @@ -88,12 +99,14 @@ type ChartMakerProps = { viewer: Viewer | null; accountsEnabled: boolean; aiConnected: boolean; + localTestChart?: ChartDocument; }; export function ChartMaker({ viewer, accountsEnabled, aiConnected, + localTestChart, }: ChartMakerProps) { const [tabs, setTabs] = useState([ { @@ -104,6 +117,30 @@ export function ChartMaker({ }, ]); const [activeTabId, setActiveTabId] = useState(INITIAL_TAB_ID); + const [yokeStitchDrafts, setYokeStitchDrafts] = useState< + Record + >({}); + const hasPendingYokeStitches = tabs.some( + (tab) => yokeStitchDrafts[tab.id]?.edits.length, + ); + function confirmLeavingEditor(event: { preventDefault(): void }) { + if ( + hasPendingYokeStitches && + !window.confirm( + "You have unapplied shaping changes. Leave without applying them?", + ) + ) + event.preventDefault(); + } + useEffect(() => { + if (!hasPendingYokeStitches) return; + const warn = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [hasPendingYokeStitches]); const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; const document = activeTab.document; const [tool, setTool] = useState("pencil"); @@ -119,9 +156,7 @@ export function ChartMaker({ }); const [previewDocuments, setPreviewDocuments] = useState< Record - >({ - [INITIAL_TAB_ID]: cloneDocument(defaultDocument), - }); + >(() => ({ [INITIAL_TAB_ID]: tabs[0].document })); const preview = activeTab.preview; const previewDocument = previewDocuments[activeTabId] ?? document; const [chartZoom, setChartZoom] = useState(100); @@ -129,6 +164,7 @@ export function ChartMaker({ const [selectionClipboard, setSelectionClipboard] = useState(null); const [knitModeOpen, setKnitModeOpen] = useState(false); + const [newYokeOpen, setNewYokeOpen] = useState(false); const [patternImporterOpen, setPatternImporterOpen] = useState(false); const [dimensionDraft, setDimensionDraft] = useSyncedDraft( `${activeTabId}:${document.width}:${document.height}`, @@ -183,7 +219,7 @@ export function ChartMaker({ setActiveTabId(next.activeTabId); setPreviewDocuments((current) => ({ ...current, - [incoming.id]: cloneDocument(incoming.document), + [incoming.id]: incoming.document, })); if (next.activeTabId !== activeTabId) prepareTabView(incoming.document); }, @@ -259,7 +295,7 @@ export function ChartMaker({ ); setPreviewDocuments( Object.fromEntries( - workspace.tabs.map((tab) => [tab.id, cloneDocument(tab.document)]), + workspace.tabs.map((tab) => [tab.id, tab.document]), ), ); const active = workspace.tabs.find( @@ -301,14 +337,14 @@ export function ChartMaker({ if (!past.length) return; const next = travelHistory(history, document, "undo"); setHistories((current) => ({ ...current, [activeTabId]: next.history })); - setDocument(next.document); + setDocument(restoreYokeHistoryDocument(document, next.document)); } function redo() { if (!future.length) return; const next = travelHistory(history, document, "redo"); setHistories((current) => ({ ...current, [activeTabId]: next.history })); - setDocument(next.document); + setDocument(restoreYokeHistoryDocument(document, next.document)); } function paint(row: number, column: number, shouldFill: boolean) { @@ -497,7 +533,8 @@ export function ChartMaker({ function refreshPreview() { setPreviewDocuments((current) => ({ ...current, - [activeTabId]: cloneDocument(document), + // Give the renderer a fresh reference so the button can also retry a failure. + [activeTabId]: { ...document }, })); updatePreviewSettings({ renderedRepeatX: preview.repeatX, @@ -748,6 +785,11 @@ export function ChartMaker({ } function openProject(nextDocument: ChartDocument) { + if (nextDocument.yoke || document.yoke) { + if (!addDocumentTab(cloneDocument(nextDocument))) + throw new Error("Close a chart tab before opening another project."); + return; + } commit(() => cloneDocument(nextDocument)); setSelectedColor(nextDocument.palette[1]?.id ?? nextDocument.palette[0].id); setTool("pencil"); @@ -777,10 +819,9 @@ export function ChartMaker({ prepareTabView(nextTab.document); } - function addTab() { - if (tabs.length >= MAX_CHART_TABS) return; + function addDocumentTab(nextDocument: ChartDocument) { + if (tabs.length >= MAX_CHART_TABS) return false; const id = `chart-${crypto.randomUUID()}`; - const nextDocument = cloneDocument(defaultDocument); setTabs((current) => [ ...current, { @@ -792,20 +833,24 @@ export function ChartMaker({ ]); setPreviewDocuments((current) => ({ ...current, - [id]: cloneDocument(nextDocument), + [id]: nextDocument, })); setHistories((current) => ({ ...current, [id]: { past: [], future: [] } })); setActiveTabId(id); prepareTabView(nextDocument); + return true; + } + function addTab() { + addDocumentTab(cloneDocument(defaultDocument)); } function duplicateTab() { if (tabs.length >= MAX_CHART_TABS) return; const id = `chart-${crypto.randomUUID()}`; - const nextDocument = cloneDocument({ + const nextDocument = { ...document, name: `${document.name || "Untitled chart"} copy`, - }); + }; setTabs((current) => [ ...current, { @@ -817,7 +862,7 @@ export function ChartMaker({ ]); setPreviewDocuments((current) => ({ ...current, - [id]: cloneDocument(current[activeTabId] ?? document), + [id]: current[activeTabId] ?? document, })); setHistories((current) => ({ ...current, [id]: { past: [], future: [] } })); setActiveTabId(id); @@ -846,7 +891,7 @@ export function ChartMaker({ ]); setPreviewDocuments((current) => ({ ...current, - [id]: cloneDocument(example.document), + [id]: example.document, })); setHistories((current) => ({ ...current, [id]: { past: [], future: [] } })); setActiveTabId(id); @@ -861,7 +906,7 @@ export function ChartMaker({ const response = await fetch("/api/charts", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + body: stringifyKnitplot({ document, knitProgress: activeTab.knitProgress, preview: activeTab.preview, @@ -946,6 +991,11 @@ export function ChartMaker({ return; } setTabs((current) => current.filter((tab) => tab.id !== id)); + setYokeStitchDrafts((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); setHistories((current) => { const next = { ...current }; delete next[id]; @@ -965,11 +1015,16 @@ export function ChartMaker({ setClosingTabId(null); } + const previewMatchesChart = useMemo( + () => samePreviewFabric(document, previewDocument), + [document, previewDocument], + ); const previewOutdated = - JSON.stringify(document) !== JSON.stringify(previewDocument) || - preview.repeatX !== preview.renderedRepeatX || - preview.repeatY !== preview.renderedRepeatY || - preview.repeatStyle !== preview.renderedRepeatStyle; + !previewMatchesChart || + (!document.yoke && + (preview.repeatX !== preview.renderedRepeatX || + preview.repeatY !== preview.renderedRepeatY || + preview.repeatStyle !== preview.renderedRepeatStyle)); const knitProgress = tabs.find((tab) => tab.id === activeTabId)?.knitProgress ?? defaultKnitProgress; @@ -1060,21 +1115,47 @@ export function ChartMaker({
+ {process.env.NODE_ENV === "development" && localTestChart ? ( + + ) : null} - {viewer && aiConnected ? ( - + {!document.yoke ? ( + <> + {viewer && aiConnected ? ( + + ) : null} + + ) : null} - setPatternImporterOpen(true)} + onNewBlank={addTab} + onNewYoke={() => setNewYokeOpen(true)} + tabsFull={tabs.length >= MAX_CHART_TABS} + onNavigateAway={confirmLeavingEditor} /> = MAX_CHART_TABS} @@ -1107,13 +1192,18 @@ export function ChartMaker({ ) : ( - + Sign in )} @@ -1168,357 +1258,407 @@ export function ChartMaker({
- - -
-
-

Chart editor

-
-
- - - - - {selection && !structureAction ? ( - - ) : null} - {selection && structureAction ? ( - - ) : null} - {selection ? ( - - ) : null} - {selection ? : null} - {selectionClipboard?.tabId === activeTabId ? ( - - ) : null} - {selection ? ( - - - - + + + + {selection && !structureAction ? ( + + ) : null} + {selection && structureAction ? ( + + ) : null} + {selection ? ( + + ) : null} + {selection ? ( + + ) : null} + {selectionClipboard?.tabId === activeTabId ? ( + + ) : null} + {selection ? ( + + + + + + + ) : null} + {selection ? ( + + ) : null} + +
+
+
+ +
+ +
+ +
+

Knitted up

+ approximate fabric at your gauge +
+ + Show / hide + +
+
+
+ Repeats + + + - - ) : null} - {selection ? ( - - ) : null} - -
-
+ {previewOutdated ? ( +

+ Preview needs a refresh. +

+ ) : null} + +
+
- - - - -
- -
-

Knitted up

- approximate fabric at your gauge -
- - Show / hide - -
-
-
- Repeats - - - - -
- {previewOutdated ? ( -

- Preview needs a refresh. -

- ) : null} - -
-
+ + )} + {newYokeOpen ? ( + = MAX_CHART_TABS} + onClose={() => setNewYokeOpen(false)} + onCommit={(next) => addDocumentTab(next)} + /> + ) : null} + {!knitModeOpen ? (