import { useEffect, useMemo, useRef, useState } from 'react';
import {
BITMAP_HELPERS,
bitmapPrefix,
emitBitmapCode,
processBitmap,
uniqueBitmapPrefix,
} from '@/lib/formats/bitmap.ts';
import type { BitmapPixels, BitmapSettings } from '@/lib/formats/bitmap.ts';
import { THREADS } from '@/data.ts';
import { Button } from '@/components/ui/button.tsx';
import { Checkbox } from '@/components/ui/checkbox.tsx';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { Label } from '@/components/ui/label.tsx';
import { Slider } from '@/components/ui/slider.tsx';
import { Switch } from '@/components/ui/switch.tsx';
import { cn } from '@/utils.ts';
export interface BitmapImportSource extends BitmapPixels {
filename: string;
}
interface Props {
source: BitmapImportSource | null;
programSource: string;
onClose: () => void;
onInsert: (code: string, summary: string) => void;
}
type Section = 'region' | 'resolution' | 'colors' | 'tone' | 'insert';
const IMPORT_TABS: Array<[Section, string]> = [
['region', '1 Region'],
['resolution', '2 Resolution'],
['colors', '3 Colors'],
['tone', '4 Tone'],
['insert', '5 Insert'],
];
function initialSettings(image: BitmapImportSource): BitmapSettings {
const aspect = image.width / image.height;
const width = aspect >= 1 ? image.height : image.width;
const height = width;
const x = Math.round((image.width - width) / 2);
const y = Math.round((image.height - height) / 2);
return {
crop: { x, y, width, height },
columns: 48,
rows: 48,
fabric: '#f5efe4',
threads: ['#2B2B2B'],
invert: false,
steps: 8,
dither: false,
mm: 60,
};
}
function NumberField({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
return (
);
}
function RangeField({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
return (
onChange(Array.isArray(next) ? next[0] : next)}
/>
);
}
function CropPreview({
source,
crop,
}: {
source: BitmapImportSource;
crop: BitmapSettings['crop'];
}) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const longestSide = 560;
const scale = longestSide / Math.max(source.width, source.height);
canvas.width = Math.max(1, Math.round(source.width * scale));
canvas.height = Math.max(1, Math.round(source.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return;
const pixels = new ImageData(new Uint8ClampedArray(source.data), source.width, source.height);
const imageCanvas = document.createElement('canvas');
imageCanvas.width = source.width;
imageCanvas.height = source.height;
const imageCtx = imageCanvas.getContext('2d');
if (!imageCtx) return;
imageCtx.putImageData(pixels, 0, 0);
ctx.drawImage(imageCanvas, 0, 0, canvas.width, canvas.height);
const x = crop.x * scale;
const y = crop.y * scale;
const width = crop.width * scale;
const height = crop.height * scale;
ctx.fillStyle = 'rgba(8, 10, 12, 0.56)';
ctx.fillRect(0, 0, canvas.width, y);
ctx.fillRect(0, y + height, canvas.width, canvas.height - y - height);
ctx.fillRect(0, y, x, height);
ctx.fillRect(x + width, y, canvas.width - x - width, height);
ctx.strokeStyle = '#f6c558';
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, Math.max(0, width - 2), Math.max(0, height - 2));
}, [crop, source]);
return (
Original image · crop highlighted
);
}
function BitmapPreview({
source,
settings,
stitched,
grid,
hoop,
}: {
source: BitmapImportSource;
settings: BitmapSettings;
stitched: boolean;
grid: boolean;
hoop: boolean;
}) {
const canvasRef = useRef(null);
const processed = useMemo(() => processBitmap(source, settings), [source, settings]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const size = 520;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = settings.fabric;
ctx.fillRect(0, 0, size, size);
if (!stitched) {
const scratch = document.createElement('canvas');
scratch.width = source.width;
scratch.height = source.height;
const imageCtx = scratch.getContext('2d');
if (imageCtx) {
imageCtx.putImageData(
new ImageData(new Uint8ClampedArray(source.data), source.width, source.height),
0,
0,
);
const { x, y, width, height } = settings.crop;
ctx.drawImage(scratch, x, y, width, height, 0, 0, size, size);
}
} else {
const cellW = size / settings.columns;
const cellH = size / settings.rows;
for (let row = 0; row < settings.rows; row++) {
for (let col = 0; col < settings.columns; col++) {
for (let plate = 0; plate < processed.plates.length; plate++) {
const intensity = parseInt(processed.plates[plate].rows[row][col], 16) / 15;
if (!intensity) continue;
ctx.globalAlpha = intensity;
ctx.fillStyle = processed.plates[plate].color;
ctx.fillRect(col * cellW, row * cellH, Math.ceil(cellW), Math.ceil(cellH));
}
}
}
ctx.globalAlpha = 1;
if (grid && Math.min(cellW, cellH) >= 4) {
ctx.strokeStyle = 'rgba(20, 20, 20, 0.18)';
ctx.lineWidth = 1;
for (let col = 0; col <= settings.columns; col++) {
ctx.beginPath();
ctx.moveTo(col * cellW, 0);
ctx.lineTo(col * cellW, size);
ctx.stroke();
}
for (let row = 0; row <= settings.rows; row++) {
ctx.beginPath();
ctx.moveTo(0, row * cellH);
ctx.lineTo(size, row * cellH);
ctx.stroke();
}
}
}
if (hoop) {
const diameter = Math.min(size, (47 / settings.mm) * size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, diameter / 2, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.88)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
}, [grid, hoop, processed, settings, source, stitched]);
return (
);
}
export default function BitmapImportDialog({ source, programSource, onClose, onInsert }: Props) {
const initialSource = source ?? {
filename: 'bitmap',
width: 1,
height: 1,
data: new Uint8ClampedArray(4),
};
const [section, setSection] = useState('region');
const [settings, setSettings] = useState(() => initialSettings(initialSource));
const [prefix, setPrefix] = useState(() =>
uniqueBitmapPrefix(initialSource.filename, programSource),
);
const [stitched, setStitched] = useState(true);
const [grid, setGrid] = useState(true);
const [hoop, setHoop] = useState(true);
const [includeHelpers, setIncludeHelpers] = useState(
() => !/\bdef\s+(?:bmpixel|bmsample)\b/.test(programSource),
);
const processed = useMemo(
() => (source && settings ? processBitmap(source, settings) : null),
[settings, source],
);
const helpersPresent = /\bdef\s+(?:bmpixel|bmsample)\b/.test(programSource);
const emitted = useMemo(
() =>
source && settings && processed
? emitBitmapCode(processed, settings, {
filename: source.filename,
prefix: bitmapPrefix(prefix),
source: programSource,
includeHelpers,
})
: '',
[includeHelpers, prefix, processed, programSource, settings, source],
);
if (!source || !processed) return null;
const mmPerCell = settings.mm / Math.max(settings.columns, settings.rows);
const update = (next: Partial) =>
setSettings((current) => ({ ...current!, ...next }));
const setCrop = (next: Partial) =>
update({ crop: { ...settings.crop, ...next } });
const setColumns = (columns: number) => {
const safe = Math.min(96, Math.max(8, columns));
update({
columns: safe,
rows: Math.min(
96,
Math.max(8, Math.round((safe * settings.crop.height) / settings.crop.width)),
),
});
};
const setThreadCount = (count: number) => {
const threads = Array.from(
{ length: count },
(_, index) => settings.threads[index] ?? THREADS[index],
);
update({ threads });
};
const insert = () => {
localStorage.setItem('ns-bitmap-import-pref', JSON.stringify({ ...settings, crop: undefined }));
onInsert(
emitted,
`Inserted bitmap '${source.filename}' — ${settings.columns}×${settings.rows}, ${processed.plates.length} plate${processed.plates.length === 1 ? '' : 's'}, ~${processed.estimatedStitches.toLocaleString()} st (est.)`,
);
};
return (
);
}