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 ? (
-
-
-
-
-
-
-
-
-
-
-
Knitted up
- approximate fabric at your gauge
-
-
- Show / hide ⌄
-
-
-
-
- Repeats
-
-
-
-
- Refresh preview
-
-
- {previewOutdated ? (
-
- Preview needs a refresh.
-
- ) : null}
-
-
-
+ >
+ )}
+ {newYokeOpen ? (
+ = MAX_CHART_TABS}
+ onClose={() => setNewYokeOpen(false)}
+ onCommit={(next) => addDocumentTab(next)}
+ />
+ ) : null}
+
{!knitModeOpen ? (