Divider Designer
I needed to cut some custom dividers for a storage box and found this divider designer by greylightmay. It generates interlocking SVG pieces you can feed to a laser cutter -- horizontal pieces get slots from the top, vertical pieces from the bottom, and they slide together at crossings.
The original works well for uniform grids, but I wanted compartments of different sizes: one large section spanning the full width for long items, and smaller cells below. That means merging cells like in a table -- something the original doesn't support.
So I built this version. Click cells in the preview to select them, merge them into larger compartments, and the tool figures out how to segment the divider pieces around the merged regions. It also handles edge joints -- when a piece only partially spans the grid due to a merge, you can extend it past the interlocking slot by a configurable amount so the joint stays rigid instead of wobbly.
// === Types ===
type Region = {
id: string;
startRow: number; startCol: number;
endRow: number; endCol: number;
};
type GridState = {
cols: number;
rows: number;
regions: Region[];
colWidths: (number | null)[];
rowDepths: (number | null)[];
};
type AppState = {
grid: GridState;
boxWidth: number;
boxDepth: number;
boxHeight: number;
thickness: number;
units: 'mm' | 'in';
selectedCells: Set<string>;
bedWidth: number;
bedHeight: number;
edgeExtension: number; // 0 = no edge slots, >0 = extend pieces by this many mm
};
// === State ===
let state: AppState = {
grid: { cols: 3, rows: 3, regions: [], colWidths: [null, null, null], rowDepths: [null, null, null] },
boxWidth: 200, boxDepth: 150, boxHeight: 50,
thickness: 3, units: 'mm',
selectedCells: new Set(),
bedWidth: 500, bedHeight: 300,
edgeExtension: 3,
};
function cellKey(row: number, col: number): string {
return `${row},${col}`;
}
function getRegionForCell(row: number, col: number): Region | null {
return state.grid.regions.find(r =>
row >= r.startRow && row <= r.endRow &&
col >= r.startCol && col <= r.endCol
) ?? null;
}
function hasHorizontalWall(row: number, col: number): boolean {
if (row <= 0 || row >= state.grid.rows) return false;
const above = getRegionForCell(row - 1, col);
const below = getRegionForCell(row, col);
if (above && below && above.id === below.id) return false;
return true;
}
function hasVerticalWall(row: number, col: number): boolean {
if (col <= 0 || col >= state.grid.cols) return false;
const left = getRegionForCell(row, col - 1);
const right = getRegionForCell(row, col);
if (left && right && left.id === right.id) return false;
return true;
}
// === Merge logic ===
function canMergeSelection(): boolean {
if (state.selectedCells.size < 2) return false;
const cells = [...state.selectedCells].map(k => {
const [r, c] = k.split(',').map(Number);
return { row: r, col: c };
});
const minR = Math.min(...cells.map(c => c.row));
const maxR = Math.max(...cells.map(c => c.row));
const minC = Math.min(...cells.map(c => c.col));
const maxC = Math.max(...cells.map(c => c.col));
const expectedCount = (maxR - minR + 1) * (maxC - minC + 1);
if (cells.length !== expectedCount) return false;
for (const region of state.grid.regions) {
const overlapRows = Math.max(0, Math.min(maxR, region.endRow) - Math.max(minR, region.startRow) + 1);
const overlapCols = Math.max(0, Math.min(maxC, region.endCol) - Math.max(minC, region.startCol) + 1);
if (overlapRows > 0 && overlapCols > 0) {
const regionRows = region.endRow - region.startRow + 1;
const regionCols = region.endCol - region.startCol + 1;
if (overlapRows * overlapCols !== regionRows * regionCols) return false;
}
}
return true;
}
function mergeSelection(): void {
if (!canMergeSelection()) return;
const cells = [...state.selectedCells].map(k => {
const [r, c] = k.split(',').map(Number);
return { row: r, col: c };
});
const minR = Math.min(...cells.map(c => c.row));
const maxR = Math.max(...cells.map(c => c.row));
const minC = Math.min(...cells.map(c => c.col));
const maxC = Math.max(...cells.map(c => c.col));
state.grid.regions = state.grid.regions.filter(r =>
!(r.startRow >= minR && r.endRow <= maxR && r.startCol >= minC && r.endCol <= maxC)
);
state.grid.regions.push({
id: `r${Date.now()}`,
startRow: minR, startCol: minC,
endRow: maxR, endCol: maxC,
});
state.selectedCells.clear();
}
function unmergeCell(row: number, col: number): void {
state.grid.regions = state.grid.regions.filter(r =>
!(row >= r.startRow && row <= r.endRow && col >= r.startCol && col <= r.endCol)
);
}
// === Dimension computation ===
function computeWidths(): number[] {
const { cols, colWidths } = state.grid;
const totalThickness = (cols - 1) * state.thickness;
const available = state.boxWidth - totalThickness;
const fixedSum = colWidths.reduce((s, w) => s + (w ?? 0), 0);
const flexCount = colWidths.filter(w => w === null).length;
const flexWidth = flexCount > 0 ? (available - fixedSum) / flexCount : 0;
return colWidths.map(w => w ?? flexWidth);
}
function computeDepths(): number[] {
const { rows, rowDepths } = state.grid;
const totalThickness = (rows - 1) * state.thickness;
const available = state.boxDepth - totalThickness;
const fixedSum = rowDepths.reduce((s, d) => s + (d ?? 0), 0);
const flexCount = rowDepths.filter(d => d === null).length;
const flexDepth = flexCount > 0 ? (available - fixedSum) / flexCount : 0;
return rowDepths.map(d => d ?? flexDepth);
}
// === SVG Generation ===
type DividerPiece = {
orientation: 'horizontal' | 'vertical';
length: number;
height: number;
slots: number[];
slotDepth: number;
slotWidth: number;
label: string;
quantity: number;
};
function computePieces(): DividerPiece[] {
const widths = computeWidths();
const depths = computeDepths();
const { cols, rows } = state.grid;
const thickness = state.thickness;
const pieces: DividerPiece[] = [];
// Horizontal pieces (wall between row r-1 and row r)
for (let r = 1; r < rows; r++) {
let segStart = -1;
for (let c = 0; c <= cols; c++) {
const wall = c < cols && hasHorizontalWall(r, c);
if (wall) {
if (segStart === -1) segStart = c;
} else {
if (segStart !== -1) {
emitHorizontal(r, segStart, c - 1);
segStart = -1;
}
}
}
}
// Vertical pieces (wall between col c-1 and col c)
for (let c = 1; c < cols; c++) {
let segStart = -1;
for (let r = 0; r <= rows; r++) {
const wall = r < rows && hasVerticalWall(r, c);
if (wall) {
if (segStart === -1) segStart = r;
} else {
if (segStart !== -1) {
emitVertical(c, segStart, r - 1);
segStart = -1;
}
}
}
}
function emitHorizontal(wallRow: number, startCol: number, endCol: number) {
// Piece length = sum of cell widths + material thickness between cells
let length = 0;
for (let c = startCol; c <= endCol; c++) {
length += widths[c];
if (c < endCol) length += thickness;
}
// Find slots where vertical walls cross this horizontal piece
const ext = state.edgeExtension;
const hasLeftEdge = ext > 0 && (hasVerticalWall(wallRow - 1, startCol) || hasVerticalWall(wallRow, startCol));
const hasRightEdge = ext > 0 && (hasVerticalWall(wallRow - 1, endCol + 1) || hasVerticalWall(wallRow, endCol + 1));
const startExt = hasLeftEdge ? ext : 0;
const endExt = hasRightEdge ? ext : 0;
const slots: number[] = [];
// Edge slot at left (only when extending — without extension, no material to hold it)
if (hasLeftEdge) {
slots.push(startExt);
}
// Internal slots (shifted by startExt)
for (let cb = startCol + 1; cb <= endCol; cb++) {
if (hasVerticalWall(wallRow - 1, cb) || hasVerticalWall(wallRow, cb)) {
let pos = 0;
for (let c = startCol; c < cb; c++) {
pos += widths[c] + thickness;
}
pos -= thickness / 2;
slots.push(pos + startExt);
}
}
// Edge slot at right (only when extending)
if (hasRightEdge) {
slots.push(length + startExt);
}
const totalLength = length + startExt + endExt;
// Label: H{wallRow}-{segment letter}
const existingH = pieces.filter(p => p.label.startsWith(`H${wallRow}`));
const letter = String.fromCharCode(97 + existingH.length); // a, b, c...
pieces.push({
orientation: 'horizontal',
length: totalLength,
height: state.boxHeight,
slots,
slotDepth: state.boxHeight / 2,
slotWidth: thickness,
label: `H${wallRow}-${letter}`,
quantity: 1,
});
}
function emitVertical(wallCol: number, startRow: number, endRow: number) {
let length = 0;
for (let r = startRow; r <= endRow; r++) {
length += depths[r];
if (r < endRow) length += thickness;
}
const ext = state.edgeExtension;
const hasTopEdge = ext > 0 && (hasHorizontalWall(startRow, wallCol - 1) || hasHorizontalWall(startRow, wallCol));
const hasBottomEdge = ext > 0 && (hasHorizontalWall(endRow + 1, wallCol - 1) || hasHorizontalWall(endRow + 1, wallCol));
const startExt = hasTopEdge ? ext : 0;
const endExt = hasBottomEdge ? ext : 0;
const slots: number[] = [];
// Edge slot at top (only when extending — without extension, no material to hold it)
if (hasTopEdge) {
slots.push(startExt);
}
// Internal slots (shifted by startExt)
for (let rb = startRow + 1; rb <= endRow; rb++) {
if (hasHorizontalWall(rb, wallCol - 1) || hasHorizontalWall(rb, wallCol)) {
let pos = 0;
for (let r = startRow; r < rb; r++) {
pos += depths[r] + thickness;
}
pos -= thickness / 2;
slots.push(pos + startExt);
}
}
// Edge slot at bottom (only when extending)
if (hasBottomEdge) {
slots.push(length + startExt);
}
const totalLength = length + startExt + endExt;
const existingV = pieces.filter(p => p.label.startsWith(`V${wallCol}`));
const letter = String.fromCharCode(97 + existingV.length);
pieces.push({
orientation: 'vertical',
length: totalLength,
height: state.boxHeight,
slots,
slotDepth: state.boxHeight / 2,
slotWidth: thickness,
label: `V${wallCol}-${letter}`,
quantity: 1,
});
}
// Deduplicate identical pieces (same orientation, length, height, slot pattern)
const deduped: DividerPiece[] = [];
for (const piece of pieces) {
const match = deduped.find(d =>
d.orientation === piece.orientation &&
Math.abs(d.length - piece.length) < 0.001 &&
Math.abs(d.height - piece.height) < 0.001 &&
d.slots.length === piece.slots.length &&
d.slots.every((s, i) => Math.abs(s - piece.slots[i]) < 0.001)
);
if (match) {
match.quantity++;
} else {
deduped.push({ ...piece });
}
}
return deduped;
}
function generateSVG(pieces: DividerPiece[]): string {
const toMm = state.units === 'in' ? 25.4 : 1;
const spacing = 10; // mm between pieces
const labelHeight = 8; // mm for label text below piece
const margin = 5; // mm around all content
// Layout pieces in rows
const bedW = state.bedWidth * toMm;
let curX = margin;
let curY = margin;
let rowMaxH = 0;
type Placed = { piece: DividerPiece; x: number; y: number; w: number; h: number };
const placed: Placed[] = [];
for (const piece of pieces) {
const w = piece.length * toMm;
const h = piece.height * toMm;
// For each copy of this piece
for (let q = 0; q < piece.quantity; q++) {
if (curX + w > bedW && curX > margin) {
// New row
curX = margin;
curY += rowMaxH + labelHeight + spacing;
rowMaxH = 0;
}
placed.push({ piece, x: curX, y: curY, w, h });
rowMaxH = Math.max(rowMaxH, h);
curX += w + spacing;
}
}
const totalW = Math.max(bedW, ...placed.map(p => p.x + p.w + margin));
const totalH = curY + rowMaxH + labelHeight + margin;
let paths = '';
let labels = '';
for (const { piece, x, y, w, h } of placed) {
const slotD = piece.slotDepth * toMm;
const slotW = piece.slotWidth * toMm;
const slotsInMm = piece.slots.map(s => s * toMm);
let d: string;
if (piece.orientation === 'horizontal') {
// Slots from top
d = piecePath(w, h, slotsInMm, slotW, slotD, 'top');
} else {
// Slots from bottom
d = piecePath(w, h, slotsInMm, slotW, slotD, 'bottom');
}
paths += ` <path d="${d}" transform="translate(${fmt(x)},${fmt(y)})" fill="none" stroke="red" stroke-width="0.1"/>\n`;
const qtyText = piece.quantity > 1 ? ` (x${piece.quantity})` : '';
labels += ` <text x="${fmt(x + w / 2)}" y="${fmt(y + h + labelHeight - 1)}" text-anchor="middle" font-family="sans-serif" font-size="4" fill="green">${piece.label}${qtyText}</text>\n`;
}
function fmt(v: number): string {
return (Math.round(v * 1000) / 1000).toString();
}
function piecePath(w: number, h: number, slots: number[], slotW: number, slotD: number, side: 'top' | 'bottom'): string {
// Sort slots by position
const sorted = [...slots].sort((a, b) => a - b);
const parts: string[] = [];
if (side === 'top') {
// Start top-left, go right along top edge with notches from top
parts.push(`M 0 0`);
let cx = 0;
for (const s of sorted) {
const slotLeft = Math.max(0, s - slotW / 2);
const slotRight = Math.min(w, s + slotW / 2);
if (slotLeft > cx) parts.push(`L ${fmt(slotLeft)} 0`);
parts.push(`L ${fmt(slotLeft)} ${fmt(slotD)}`);
parts.push(`L ${fmt(slotRight)} ${fmt(slotD)}`);
parts.push(`L ${fmt(slotRight)} 0`);
cx = slotRight;
}
if (cx < w) parts.push(`L ${fmt(w)} 0`);
parts.push(`L ${fmt(w)} ${fmt(h)}`);
parts.push(`L 0 ${fmt(h)}`);
parts.push(`Z`);
} else {
// Slots from bottom
parts.push(`M 0 0`);
parts.push(`L ${fmt(w)} 0`);
parts.push(`L ${fmt(w)} ${fmt(h)}`);
// Go left along bottom edge with notches from bottom
let cx = w;
for (const s of [...sorted].reverse()) {
const slotRight = Math.min(w, s + slotW / 2);
const slotLeft = Math.max(0, s - slotW / 2);
if (slotRight < cx) parts.push(`L ${fmt(slotRight)} ${fmt(h)}`);
parts.push(`L ${fmt(slotRight)} ${fmt(h - slotD)}`);
parts.push(`L ${fmt(slotLeft)} ${fmt(h - slotD)}`);
parts.push(`L ${fmt(slotLeft)} ${fmt(h)}`);
cx = slotLeft;
}
if (cx > 0) parts.push(`L 0 ${fmt(h)}`);
parts.push(`Z`);
}
return parts.join(' ');
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${fmt(totalW)}mm" height="${fmt(totalH)}mm" viewBox="0 0 ${fmt(totalW)} ${fmt(totalH)}">
${paths}${labels}</svg>
`;
}
// === UI ===
const container = document.createElement("div");
container.style.cssText = "font-family: system-ui, -apple-system, sans-serif; max-width: 1200px; margin: 0 auto;";
// Two-panel layout
const layout = document.createElement("div");
layout.style.cssText = "display: grid; grid-template-columns: 280px 1fr; gap: 16px; align-items: stretch;";
container.appendChild(layout);
// Responsive: stack on narrow screens
const mediaQuery = window.matchMedia("(max-width: 700px)");
function applyLayout() {
if (mediaQuery.matches) {
layout.style.gridTemplateColumns = "1fr";
layout.style.gridTemplateRows = "auto 1fr";
} else {
layout.style.gridTemplateColumns = "280px 1fr";
layout.style.gridTemplateRows = "";
}
}
mediaQuery.addEventListener("change", applyLayout);
applyLayout();
// === Controls Panel ===
const controlsWrap = document.createElement("div");
controlsWrap.style.cssText = "display: flex; flex-direction: column; max-height: 80vh; overflow: hidden;";
layout.appendChild(controlsWrap);
// Control tabs
const ctrlTabBar = document.createElement("div");
ctrlTabBar.style.cssText = "display: flex; border-bottom: 1px solid #e0e0e0; margin-bottom: 4px; flex-shrink: 0;";
controlsWrap.appendChild(ctrlTabBar);
let activeCtrlTab = "setup";
const ctrlTabs: { id: string; btn: HTMLButtonElement; panel: HTMLDivElement }[] = [];
function makeCtrlTab(label: string, id: string): { btn: HTMLButtonElement; panel: HTMLDivElement } {
const btn = document.createElement("button");
btn.textContent = label;
btn.style.cssText = "flex: 1; padding: 6px 8px; border: none; background: none; font-size: 12px; cursor: pointer; border-bottom: 2px solid transparent; color: #666;";
btn.addEventListener("click", () => { activeCtrlTab = id; updateCtrlTabs(); });
ctrlTabBar.appendChild(btn);
const panel = document.createElement("div");
panel.style.cssText = "display: none; flex-direction: column; gap: 4px; overflow-y: auto; padding-right: 8px; flex: 1;";
controlsWrap.appendChild(panel);
const entry = { id, btn, panel };
ctrlTabs.push(entry);
return entry;
}
function updateCtrlTabs() {
for (const t of ctrlTabs) {
t.btn.style.borderBottomColor = t.id === activeCtrlTab ? "#4285f4" : "transparent";
t.btn.style.color = t.id === activeCtrlTab ? "#4285f4" : "#666";
t.panel.style.display = t.id === activeCtrlTab ? "flex" : "none";
}
}
const setupTab = makeCtrlTab("Setup", "setup");
const customTab = makeCtrlTab("Layout", "layout");
const exportTab = makeCtrlTab("Export", "export");
updateCtrlTabs();
const controls = setupTab.panel;
const layoutControls = customTab.panel;
const exportControls = exportTab.panel;
function makeSection(title: string): HTMLDivElement {
const section = document.createElement("div");
section.style.cssText = "margin-top: 12px;";
const h = document.createElement("div");
h.textContent = title;
h.style.cssText = "font-weight: 700; font-size: 14px; margin-bottom: 6px; color: #333;";
section.appendChild(h);
return section;
}
function makeLabel(text: string, input: HTMLElement): HTMLDivElement {
const row = document.createElement("div");
row.style.cssText = "display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;";
const lbl = document.createElement("label");
lbl.textContent = text;
lbl.style.cssText = "font-size: 13px; color: #555;";
row.appendChild(lbl);
row.appendChild(input);
return row;
}
function makeNumberInput(value: number, onChange: (v: number) => void, width = "80px"): HTMLInputElement {
const inp = document.createElement("input");
inp.type = "number";
inp.value = String(Math.round(value * 1000) / 1000);
inp.style.cssText = `width: ${width}; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;`;
inp.addEventListener("change", () => {
const v = parseFloat(inp.value);
if (!isNaN(v)) onChange(v);
});
return inp;
}
// --- Units ---
const unitsSection = makeSection("Units");
const unitsRow = document.createElement("div");
unitsRow.style.cssText = "display: flex; gap: 16px;";
function makeRadio(name: string, value: string, label: string, checked: boolean): HTMLLabelElement {
const lbl = document.createElement("label");
lbl.style.cssText = "font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 4px;";
const radio = document.createElement("input");
radio.type = "radio";
radio.name = name;
radio.value = value;
radio.checked = checked;
radio.addEventListener("change", () => {
if (radio.checked) {
const oldUnits = state.units;
const newUnits = value as 'mm' | 'in';
if (oldUnits !== newUnits) {
const factor = newUnits === 'in' ? 1 / 25.4 : 25.4;
state.units = newUnits;
state.boxWidth *= factor;
state.boxDepth *= factor;
state.boxHeight *= factor;
state.thickness *= factor;
state.bedWidth *= factor;
state.bedHeight *= factor;
state.grid.colWidths = state.grid.colWidths.map(w => w !== null ? w * factor : null);
state.grid.rowDepths = state.grid.rowDepths.map(d => d !== null ? d * factor : null);
rebuildControls();
drawCanvas();
}
}
});
lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(label));
return lbl;
}
unitsRow.appendChild(makeRadio("units", "mm", "mm", state.units === "mm"));
unitsRow.appendChild(makeRadio("units", "in", "inches", state.units === "in"));
unitsSection.appendChild(unitsRow);
controls.appendChild(unitsSection);
// --- Box Dimensions ---
const boxSection = makeSection("Box Dimensions");
const boxInputsContainer = document.createElement("div");
boxSection.appendChild(boxInputsContainer);
controls.appendChild(boxSection);
// --- Grid Size ---
const gridSection = makeSection("Grid Size");
const gridInputsContainer = document.createElement("div");
gridSection.appendChild(gridInputsContainer);
controls.appendChild(gridSection);
// --- Material Thickness ---
const thicknessSection = makeSection("Material Thickness");
const thicknessContainer = document.createElement("div");
thicknessSection.appendChild(thicknessContainer);
controls.appendChild(thicknessSection);
// --- Edge Extension ---
const edgeSection = makeSection("Edge Extension");
const edgeRow = document.createElement("div");
edgeRow.style.cssText = "display: flex; align-items: center; gap: 8px;";
const edgeInput = document.createElement("input");
edgeInput.type = "number";
edgeInput.min = "0";
edgeInput.step = "0.5";
edgeInput.value = String(state.edgeExtension);
edgeInput.style.cssText = "width: 60px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;";
edgeInput.addEventListener("change", () => {
state.edgeExtension = Math.max(0, parseFloat(edgeInput.value) || 0);
edgeInput.value = String(state.edgeExtension);
rebuildControls();
drawCanvas();
});
const edgeUnitLabel = document.createElement("span");
edgeUnitLabel.style.cssText = "font-size: 13px; color: #666;";
edgeUnitLabel.textContent = "mm (0 = off)";
edgeRow.appendChild(edgeInput);
edgeRow.appendChild(edgeUnitLabel);
const edgeHint = document.createElement("div");
edgeHint.style.cssText = "font-size: 11px; color: #888; margin-top: 2px;";
edgeHint.textContent = "Tab length past edge slots for stability at merged boundaries";
edgeSection.appendChild(edgeRow);
edgeSection.appendChild(edgeHint);
controls.appendChild(edgeSection);
// --- Column Widths (Layout tab) ---
const colWidthsSection = makeSection("Column Widths");
const colWidthsContainer = document.createElement("div");
colWidthsSection.appendChild(colWidthsContainer);
layoutControls.appendChild(colWidthsSection);
// --- Row Depths (Layout tab) ---
const rowDepthsSection = makeSection("Row Depths");
const rowDepthsContainer = document.createElement("div");
rowDepthsSection.appendChild(rowDepthsContainer);
layoutControls.appendChild(rowDepthsSection);
// --- Bed Size (Export tab) ---
const bedSection = makeSection("Bed Size");
const bedContainer = document.createElement("div");
bedSection.appendChild(bedContainer);
exportControls.appendChild(bedSection);
// === Right Panel (tabs + toolbar) ===
const rightPanel = document.createElement("div");
rightPanel.style.cssText = "display: flex; flex-direction: column; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; background: #fff; min-height: 500px;";
layout.appendChild(rightPanel);
// Tab bar
const tabBar = document.createElement("div");
tabBar.style.cssText = "display: flex; align-items: center; border-bottom: 1px solid #e0e0e0; background: #fafafa;";
let activeTab = "preview";
function makeTab(label: string, id: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.textContent = label;
btn.style.cssText = "padding: 8px 16px; border: none; background: none; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; color: #666;";
btn.addEventListener("click", () => {
activeTab = id;
updateTabs();
});
return btn;
}
const previewTab = makeTab("Preview", "preview");
const svgTab = makeTab("SVG", "svg");
const spacer = document.createElement("div");
spacer.style.cssText = "flex: 1;";
const fullscreenBtn = document.createElement("button");
fullscreenBtn.textContent = "\u26F6";
fullscreenBtn.title = "Fullscreen";
fullscreenBtn.style.cssText = "padding: 6px 10px; border: none; background: none; font-size: 16px; cursor: pointer; color: #666;";
fullscreenBtn.addEventListener("click", () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
// Fullscreen the outer .org-tabs wrapper if it exists, otherwise the container
const fsTarget = container.closest(".org-tabs") || container;
fsTarget.requestFullscreen();
}
});
tabBar.appendChild(previewTab);
tabBar.appendChild(svgTab);
tabBar.appendChild(spacer);
tabBar.appendChild(fullscreenBtn);
rightPanel.appendChild(tabBar);
// Content area
const contentArea = document.createElement("div");
contentArea.style.cssText = "flex: 1; position: relative; overflow: hidden;";
rightPanel.appendChild(contentArea);
// Canvas (preview tab)
const canvasWrap = document.createElement("div");
canvasWrap.style.cssText = "width: 100%; height: 100%; position: absolute; inset: 0;";
contentArea.appendChild(canvasWrap);
const canvas = document.createElement("canvas");
canvas.style.cssText = "width: 100%; height: 100%; cursor: crosshair; display: block;";
canvasWrap.appendChild(canvas);
// SVG output (svg tab)
const svgWrap = document.createElement("div");
svgWrap.style.cssText = "width: 100%; height: 100%; position: absolute; inset: 0; overflow: auto; padding: 12px; box-sizing: border-box; display: none;";
const svgPre = document.createElement("pre");
svgPre.style.cssText = "margin: 0; font-size: 12px; font-family: 'SF Mono', Monaco, Consolas, monospace; white-space: pre-wrap; word-break: break-all; color: #333;";
svgWrap.appendChild(svgPre);
contentArea.appendChild(svgWrap);
function updateTabs() {
previewTab.style.borderBottomColor = activeTab === "preview" ? "#4285f4" : "transparent";
previewTab.style.color = activeTab === "preview" ? "#4285f4" : "#666";
svgTab.style.borderBottomColor = activeTab === "svg" ? "#4285f4" : "transparent";
svgTab.style.color = activeTab === "svg" ? "#4285f4" : "#666";
canvasWrap.style.display = activeTab === "preview" ? "" : "none";
svgWrap.style.display = activeTab === "svg" ? "" : "none";
if (activeTab === "svg") updateSvgOutput();
}
function updateSvgOutput() {
const pieces = computePieces();
svgPre.textContent = pieces.length > 0 ? generateSVG(pieces) : "No divider pieces. Merge creates compartments — dividers exist at remaining walls.";
}
updateTabs();
// Bottom toolbar (always visible: merge/unmerge + download)
const toolbar = document.createElement("div");
toolbar.style.cssText = "border-top: 1px solid #e0e0e0; padding: 8px 12px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; background: #fafafa;";
const mergeBtn = document.createElement("button");
mergeBtn.textContent = "Merge";
mergeBtn.style.cssText = "padding: 5px 10px; border: 1px solid #ccc; border-radius: 4px; background: #f5f5f5; cursor: pointer; font-size: 12px;";
mergeBtn.addEventListener("click", () => {
mergeSelection();
rebuildControls();
drawCanvas();
});
const unmergeBtn = document.createElement("button");
unmergeBtn.textContent = "Unmerge";
unmergeBtn.style.cssText = "padding: 5px 10px; border: 1px solid #ccc; border-radius: 4px; background: #f5f5f5; cursor: pointer; font-size: 12px;";
unmergeBtn.addEventListener("click", () => {
if (state.selectedCells.size > 0) {
const first = [...state.selectedCells][0];
const [r, c] = first.split(",").map(Number);
unmergeCell(r, c);
state.selectedCells.clear();
rebuildControls();
drawCanvas();
}
});
const toolbarSpacer = document.createElement("div");
toolbarSpacer.style.cssText = "flex: 1;";
const filenameInput = document.createElement("input");
filenameInput.type = "text";
filenameInput.value = "divider";
filenameInput.style.cssText = "width: 80px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 12px;";
const dlBtn = document.createElement("button");
dlBtn.textContent = "Download SVG";
dlBtn.style.cssText = "padding: 5px 10px; border: 1px solid #4285f4; border-radius: 4px; background: #4285f4; color: white; cursor: pointer; font-size: 12px;";
dlBtn.addEventListener("click", () => {
const pieces = computePieces();
if (pieces.length === 0) {
alert("No divider pieces to generate. Add some internal walls by using a grid with 2+ rows/columns.");
return;
}
const svg = generateSVG(pieces);
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filenameInput.value + ".svg";
a.click();
URL.revokeObjectURL(url);
});
toolbar.appendChild(mergeBtn);
toolbar.appendChild(unmergeBtn);
toolbar.appendChild(toolbarSpacer);
toolbar.appendChild(filenameInput);
toolbar.appendChild(dlBtn);
rightPanel.appendChild(toolbar);
// Fullscreen style adjustments
document.addEventListener("fullscreenchange", () => {
const fsTarget = container.closest(".org-tabs") || container;
const isFs = document.fullscreenElement === fsTarget;
if (isFs) {
fsTarget.style.background = "#fff";
fsTarget.style.padding = "0";
container.style.maxWidth = "100%";
container.style.height = "100vh";
container.style.padding = "16px";
container.style.boxSizing = "border-box";
layout.style.height = "calc(100% - 8px)";
rightPanel.style.minHeight = "0";
} else {
fsTarget.style.background = "";
fsTarget.style.padding = "";
container.style.maxWidth = "1200px";
container.style.height = "";
container.style.padding = "";
container.style.boxSizing = "";
layout.style.height = "";
rightPanel.style.minHeight = "500px";
}
requestAnimationFrame(() => drawCanvas());
});
// Track computed layout for click detection
let renderMeta = { offsetX: 0, offsetY: 0, scale: 1, cellXs: [] as number[], cellYs: [] as number[], widths: [] as number[], depths: [] as number[] };
function drawCanvas() {
if (activeTab !== "preview") return;
const dpr = window.devicePixelRatio || 1;
const rect = canvasWrap.getBoundingClientRect();
const w = rect.width;
const h = Math.max(300, rect.height || w * 0.7);
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext("2d")!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const widths = computeWidths();
const depths = computeDepths();
const { cols, rows } = state.grid;
const totalW = widths.reduce((s, v) => s + v, 0) + (cols - 1) * state.thickness;
const totalD = depths.reduce((s, v) => s + v, 0) + (rows - 1) * state.thickness;
const pad = 40;
const scaleX = (w - pad * 2) / totalW;
const scaleY = (h - pad * 2) / totalD;
const scale = Math.min(scaleX, scaleY);
const ox = (w - totalW * scale) / 2;
const oy = (h - totalD * scale) / 2;
// Precompute cell positions
const cellXs: number[] = [];
let cx = 0;
for (let c = 0; c < cols; c++) {
cellXs.push(cx);
cx += widths[c] + (c < cols - 1 ? state.thickness : 0);
}
const cellYs: number[] = [];
let cy = 0;
for (let r = 0; r < rows; r++) {
cellYs.push(cy);
cy += depths[r] + (r < rows - 1 ? state.thickness : 0);
}
renderMeta = { offsetX: ox, offsetY: oy, scale, cellXs, cellYs, widths, depths };
// Draw merged regions (green fill)
for (const region of state.grid.regions) {
const rx = ox + cellXs[region.startCol] * scale;
const ry = oy + cellYs[region.startRow] * scale;
let rw = 0;
for (let c = region.startCol; c <= region.endCol; c++) {
rw += widths[c];
if (c < region.endCol) rw += state.thickness;
}
let rh = 0;
for (let r = region.startRow; r <= region.endRow; r++) {
rh += depths[r];
if (r < region.endRow) rh += state.thickness;
}
ctx.fillStyle = "rgba(76, 175, 80, 0.2)";
ctx.fillRect(rx, ry, rw * scale, rh * scale);
}
// Draw selected cells (blue fill)
for (const key of state.selectedCells) {
const [r, c] = key.split(",").map(Number);
if (r >= rows || c >= cols) continue;
const region = getRegionForCell(r, c);
if (region) {
const rx = ox + cellXs[region.startCol] * scale;
const ry = oy + cellYs[region.startRow] * scale;
let rw = 0;
for (let cc = region.startCol; cc <= region.endCol; cc++) {
rw += widths[cc];
if (cc < region.endCol) rw += state.thickness;
}
let rh = 0;
for (let rr = region.startRow; rr <= region.endRow; rr++) {
rh += depths[rr];
if (rr < region.endRow) rh += state.thickness;
}
ctx.fillStyle = "rgba(66, 133, 244, 0.3)";
ctx.fillRect(rx, ry, rw * scale, rh * scale);
} else {
const sx = ox + cellXs[c] * scale;
const sy = oy + cellYs[r] * scale;
ctx.fillStyle = "rgba(66, 133, 244, 0.3)";
ctx.fillRect(sx, sy, widths[c] * scale, depths[r] * scale);
}
}
// Draw internal walls
ctx.strokeStyle = "#888";
ctx.lineWidth = 1;
// Horizontal walls
for (let r = 1; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (hasHorizontalWall(r, c)) {
const x1 = ox + cellXs[c] * scale;
const x2 = x1 + widths[c] * scale;
const wallY = oy + (cellYs[r] - state.thickness / 2) * scale;
ctx.beginPath();
ctx.moveTo(x1, wallY);
ctx.lineTo(x2, wallY);
ctx.stroke();
}
}
}
// Vertical walls
for (let r = 0; r < rows; r++) {
for (let c = 1; c < cols; c++) {
if (hasVerticalWall(r, c)) {
const y1 = oy + cellYs[r] * scale;
const y2 = y1 + depths[r] * scale;
const wallX = ox + (cellXs[c] - state.thickness / 2) * scale;
ctx.beginPath();
ctx.moveTo(wallX, y1);
ctx.lineTo(wallX, y2);
ctx.stroke();
}
}
}
// Draw outer border
ctx.strokeStyle = "#000";
ctx.lineWidth = 2;
ctx.strokeRect(ox, oy, totalW * scale, totalD * scale);
// Dimension labels
ctx.fillStyle = "#333";
ctx.font = "11px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const unitLabel = state.units;
const fmt = (v: number) => v < 10 ? v.toFixed(2) : v < 100 ? v.toFixed(1) : Math.round(v).toString();
// Label each effective compartment
const labeled = new Set<string>();
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const region = getRegionForCell(r, c);
const rKey = region ? region.id : cellKey(r, c);
if (labeled.has(rKey)) continue;
labeled.add(rKey);
let cw: number, cd: number, lx: number, ly: number;
if (region) {
cw = 0;
for (let cc = region.startCol; cc <= region.endCol; cc++) {
cw += widths[cc];
if (cc < region.endCol) cw += state.thickness;
}
cd = 0;
for (let rr = region.startRow; rr <= region.endRow; rr++) {
cd += depths[rr];
if (rr < region.endRow) cd += state.thickness;
}
lx = ox + (cellXs[region.startCol] + cw / 2) * scale;
ly = oy + (cellYs[region.startRow] + cd / 2) * scale;
} else {
cw = widths[c];
cd = depths[r];
lx = ox + (cellXs[c] + cw / 2) * scale;
ly = oy + (cellYs[r] + cd / 2) * scale;
}
const txt = `${fmt(cw)}×${fmt(cd)}`;
// Only draw if text fits
if (cw * scale > 40 && cd * scale > 16) {
ctx.fillStyle = "rgba(255,255,255,0.8)";
const tw = ctx.measureText(txt).width + 6;
ctx.fillRect(lx - tw / 2, ly - 8, tw, 16);
ctx.fillStyle = "#333";
ctx.fillText(txt, lx, ly);
}
}
}
}
// === Canvas click ===
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const { offsetX: ox, offsetY: oy, scale, cellXs, cellYs, widths, depths } = renderMeta;
const { cols, rows } = state.grid;
// Find which cell was clicked
let clickedRow = -1, clickedCol = -1;
for (let r = 0; r < rows; r++) {
const y = oy + cellYs[r] * scale;
const h = depths[r] * scale;
if (my >= y && my < y + h) { clickedRow = r; break; }
}
for (let c = 0; c < cols; c++) {
const x = ox + cellXs[c] * scale;
const w = widths[c] * scale;
if (mx >= x && mx < x + w) { clickedCol = c; break; }
}
if (clickedRow < 0 || clickedCol < 0) return;
const region = getRegionForCell(clickedRow, clickedCol);
if (region) {
// Toggle all cells of the region
const regionCells: string[] = [];
for (let r = region.startRow; r <= region.endRow; r++) {
for (let c = region.startCol; c <= region.endCol; c++) {
regionCells.push(cellKey(r, c));
}
}
const allSelected = regionCells.every(k => state.selectedCells.has(k));
if (allSelected) {
regionCells.forEach(k => state.selectedCells.delete(k));
} else {
regionCells.forEach(k => state.selectedCells.add(k));
}
} else {
const key = cellKey(clickedRow, clickedCol);
if (state.selectedCells.has(key)) {
state.selectedCells.delete(key);
} else {
state.selectedCells.add(key);
}
}
rebuildControls();
drawCanvas();
});
// === Rebuild Controls ===
function rebuildControls() {
// Box dimensions
boxInputsContainer.innerHTML = "";
boxInputsContainer.appendChild(makeLabel("Width", makeNumberInput(state.boxWidth, v => { state.boxWidth = v; rebuildControls(); drawCanvas(); })));
boxInputsContainer.appendChild(makeLabel("Depth", makeNumberInput(state.boxDepth, v => { state.boxDepth = v; rebuildControls(); drawCanvas(); })));
boxInputsContainer.appendChild(makeLabel("Height", makeNumberInput(state.boxHeight, v => { state.boxHeight = v; })));
// Grid size
gridInputsContainer.innerHTML = "";
const colInput = makeNumberInput(state.grid.cols, v => {
const newCols = Math.max(2, Math.min(12, Math.round(v)));
const old = state.grid.colWidths;
state.grid.colWidths = Array.from({ length: newCols }, (_, i) => i < old.length ? old[i] : null);
state.grid.cols = newCols;
state.grid.regions = state.grid.regions.filter(r => r.endCol < newCols);
state.selectedCells.clear();
rebuildControls();
drawCanvas();
}, "60px");
colInput.min = "2"; colInput.max = "12";
gridInputsContainer.appendChild(makeLabel("Columns", colInput));
const rowInput = makeNumberInput(state.grid.rows, v => {
const newRows = Math.max(2, Math.min(12, Math.round(v)));
const old = state.grid.rowDepths;
state.grid.rowDepths = Array.from({ length: newRows }, (_, i) => i < old.length ? old[i] : null);
state.grid.rows = newRows;
state.grid.regions = state.grid.regions.filter(r => r.endRow < newRows);
state.selectedCells.clear();
rebuildControls();
drawCanvas();
}, "60px");
rowInput.min = "2"; rowInput.max = "12";
gridInputsContainer.appendChild(makeLabel("Rows", rowInput));
// Thickness
thicknessContainer.innerHTML = "";
const thicknessPresets = [
{ label: "3mm", value: 3, inValue: 3 / 25.4 },
{ label: '1/8" (3.175mm)', value: 3.175, inValue: 0.125 },
{ label: "6mm", value: 6, inValue: 6 / 25.4 },
{ label: "Custom", value: -1, inValue: -1 },
];
const thickSelect = document.createElement("select");
thickSelect.style.cssText = "width: 160px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;";
const currentThickMm = state.units === "in" ? state.thickness * 25.4 : state.thickness;
let matchedPreset = -1;
thicknessPresets.forEach((p, i) => {
const opt = document.createElement("option");
opt.value = String(i);
opt.textContent = p.label;
thickSelect.appendChild(opt);
if (p.value > 0 && Math.abs(currentThickMm - p.value) < 0.01) matchedPreset = i;
});
thickSelect.value = matchedPreset >= 0 ? String(matchedPreset) : "3";
const customThickRow = document.createElement("div");
customThickRow.style.cssText = "margin-top: 4px;";
function updateThicknessCustom() {
const idx = parseInt(thickSelect.value);
if (idx === 3) {
customThickRow.innerHTML = "";
customThickRow.appendChild(makeNumberInput(state.thickness, v => { state.thickness = v; drawCanvas(); }));
customThickRow.style.display = "";
} else {
customThickRow.style.display = "none";
const preset = thicknessPresets[idx];
state.thickness = state.units === "in" ? preset.inValue : preset.value;
drawCanvas();
}
}
thickSelect.addEventListener("change", updateThicknessCustom);
thicknessContainer.appendChild(thickSelect);
thicknessContainer.appendChild(customThickRow);
updateThicknessCustom();
// Column widths
colWidthsContainer.innerHTML = "";
for (let c = 0; c < state.grid.cols; c++) {
const inp = document.createElement("input");
inp.type = "number";
inp.placeholder = "flex";
inp.value = state.grid.colWidths[c] !== null ? String(Math.round(state.grid.colWidths[c]! * 1000) / 1000) : "";
inp.style.cssText = "width: 60px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;";
const idx = c;
inp.addEventListener("change", () => {
state.grid.colWidths[idx] = inp.value === "" ? null : parseFloat(inp.value);
drawCanvas();
});
const row = makeLabel(`Col ${c + 1}`, inp);
colWidthsContainer.appendChild(row);
}
// Row depths
rowDepthsContainer.innerHTML = "";
for (let r = 0; r < state.grid.rows; r++) {
const inp = document.createElement("input");
inp.type = "number";
inp.placeholder = "flex";
inp.value = state.grid.rowDepths[r] !== null ? String(Math.round(state.grid.rowDepths[r]! * 1000) / 1000) : "";
inp.style.cssText = "width: 60px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;";
const idx = r;
inp.addEventListener("change", () => {
state.grid.rowDepths[idx] = inp.value === "" ? null : parseFloat(inp.value);
drawCanvas();
});
const row = makeLabel(`Row ${r + 1}`, inp);
rowDepthsContainer.appendChild(row);
}
// Bed size
bedContainer.innerHTML = "";
const bedPresets = [
{ label: "300×200mm", w: 300, h: 200 },
{ label: "500×300mm", w: 500, h: 300 },
{ label: '12×20"', w: 304.8, h: 508 },
{ label: "Custom", w: -1, h: -1 },
];
const bedSelect = document.createElement("select");
bedSelect.style.cssText = "width: 160px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;";
const bedWmm = state.units === "in" ? state.bedWidth * 25.4 : state.bedWidth;
const bedHmm = state.units === "in" ? state.bedHeight * 25.4 : state.bedHeight;
let bedMatch = -1;
bedPresets.forEach((p, i) => {
const opt = document.createElement("option");
opt.value = String(i);
opt.textContent = p.label;
bedSelect.appendChild(opt);
if (p.w > 0 && Math.abs(bedWmm - p.w) < 1 && Math.abs(bedHmm - p.h) < 1) bedMatch = i;
});
bedSelect.value = bedMatch >= 0 ? String(bedMatch) : "3";
const bedCustom = document.createElement("div");
bedCustom.style.cssText = "margin-top: 4px;";
function updateBedCustom() {
const idx = parseInt(bedSelect.value);
if (idx === 3) {
bedCustom.innerHTML = "";
bedCustom.appendChild(makeLabel("Width", makeNumberInput(state.bedWidth, v => { state.bedWidth = v; rebuildControls(); })));
bedCustom.appendChild(makeLabel("Height", makeNumberInput(state.bedHeight, v => { state.bedHeight = v; rebuildControls(); })));
bedCustom.style.display = "";
} else {
bedCustom.style.display = "none";
const preset = bedPresets[idx];
const factor = state.units === "in" ? 1 / 25.4 : 1;
state.bedWidth = preset.w * factor;
state.bedHeight = preset.h * factor;
}
}
bedSelect.addEventListener("change", updateBedCustom);
bedContainer.appendChild(bedSelect);
bedContainer.appendChild(bedCustom);
updateBedCustom();
// Bed fit indicator — use actual computed pieces
const bedPieces = computePieces();
const maxPieceW = bedPieces.length > 0 ? Math.max(...bedPieces.map(p => p.length)) : 0;
const maxPieceH = bedPieces.length > 0 ? Math.max(...bedPieces.map(p => p.height)) : 0;
const fits = maxPieceW <= state.bedWidth && maxPieceH <= state.bedHeight;
const fitIndicator = document.createElement("div");
fitIndicator.style.cssText = `margin-top: 6px; font-size: 13px; color: ${fits ? "#2e7d32" : "#c62828"};`;
fitIndicator.textContent = fits ? "\u2713 Pieces fit on bed" : "\u2717 Largest piece exceeds bed";
bedContainer.appendChild(fitIndicator);
// Merge button states
mergeBtn.disabled = !canMergeSelection();
mergeBtn.style.opacity = mergeBtn.disabled ? "0.4" : "1";
mergeBtn.style.cursor = mergeBtn.disabled ? "default" : "pointer";
const hasSelectedRegion = [...state.selectedCells].some(k => {
const [r, c] = k.split(",").map(Number);
return getRegionForCell(r, c) !== null;
});
unmergeBtn.disabled = !hasSelectedRegion;
unmergeBtn.style.opacity = unmergeBtn.disabled ? "0.4" : "1";
unmergeBtn.style.cursor = unmergeBtn.disabled ? "default" : "pointer";
if (activeTab === "svg") updateSvgOutput();
}
// Initial build
rebuildControls();
requestAnimationFrame(() => drawCanvas());
// Resize observer for canvas
const resizeObs = new ResizeObserver(() => drawCanvas());
resizeObs.observe(contentArea);
export default container;