Files
tiles/app.js
Oli Passey 68cf0fcdc3 brickblock
2026-02-21 18:12:10 +00:00

947 lines
28 KiB
JavaScript

const tileForm = document.getElementById("tile-form");
const tileNameInput = document.getElementById("tile-name");
const tileColorInput = document.getElementById("tile-color");
const tileWidthInput = document.getElementById("tile-width");
const tileHeightInput = document.getElementById("tile-height");
const tileImageInput = document.getElementById("tile-image");
const tileList = document.getElementById("tile-list");
const activeTileName = document.getElementById("active-tile-name");
const activeTileSwatch = document.getElementById("active-tile-swatch");
const exportLibraryButton = document.getElementById("export-library");
const importLibraryButton = document.getElementById("import-library");
const importLibraryFile = document.getElementById("import-library-file");
const gridRowsInput = document.getElementById("grid-rows");
const gridColsInput = document.getElementById("grid-cols");
const gridSizeInput = document.getElementById("grid-size");
const floorEnabledInput = document.getElementById("floor-enabled");
const floorTileSelect = document.getElementById("floor-tile");
const fillRowInput = document.getElementById("fill-row");
const fillRowTileSelect = document.getElementById("fill-row-tile");
const fillRowButton = document.getElementById("fill-row-button");
const brickStartInput = document.getElementById("brick-start");
const brickEndInput = document.getElementById("brick-end");
const brickTileSelect = document.getElementById("brick-tile");
const brickStaggerInput = document.getElementById("brick-stagger");
const brickApplyButton = document.getElementById("brick-apply");
const applyGridButton = document.getElementById("apply-grid");
const clearGridButton = document.getElementById("clear-grid");
const patternCanvas = document.getElementById("pattern-canvas");
const patternContext = patternCanvas.getContext("2d");
const exportJsonButton = document.getElementById("export-json");
const copyJsonButton = document.getElementById("copy-json");
const exportPngButton = document.getElementById("export-png");
const importJsonButton = document.getElementById("import-json");
const refreshJsonButton = document.getElementById("refresh-json");
const patternJson = document.getElementById("pattern-json");
let tiles = [];
let grid = [];
let activeTileId = null;
let floorEnabled = false;
let floorTileId = null;
const placements = new Map();
const imageCache = new Map();
const STORAGE_KEY = "tile-library-v1";
const config = {
rows: Number(gridRowsInput.value),
cols: Number(gridColsInput.value),
tileSize: Number(gridSizeInput.value),
};
const gridLineColor = "#e4e8f2";
const gridBackground = "#ffffff";
const createId = () =>
(crypto && crypto.randomUUID)
? crypto.randomUUID()
: `tile-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max);
const getTileById = (tileId) => tiles.find((item) => item.id === tileId);
const createPlacementId = () =>
(crypto && crypto.randomUUID)
? crypto.randomUUID()
: `placement-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const normalizeTiles = (items) =>
items.map((tile) => ({
id: tile.id || createId(),
name: tile.name || "Untitled",
color: tile.color || "#999999",
width: clampNumber(Number(tile.width ?? 1), 1, 12),
height: clampNumber(Number(tile.height ?? 1), 1, 12),
imageData: tile.imageData || null,
}));
const applyFloorRow = () => {
if (!floorEnabled || !floorTileId) {
return;
}
const lastRow = config.rows - 1;
const tile = getTileById(floorTileId);
if (!tile) {
return;
}
for (let col = 0; col < config.cols; col += tile.width) {
const width = clampNumber(tile.width, 1, config.cols - col);
const x = col * config.tileSize;
const y = lastRow * config.tileSize;
const tileWidth = width * config.tileSize;
if (tile.imageData) {
const image = buildTileImage(tile);
if (image.complete) {
patternContext.drawImage(image, x, y, tileWidth, config.tileSize);
} else {
image.onload = () => {
renderCanvas();
};
}
} else {
patternContext.fillStyle = tile.color;
patternContext.fillRect(x, y, tileWidth, config.tileSize);
}
}
};
const initGrid = () => {
grid = Array.from({ length: config.rows }, () =>
Array.from({ length: config.cols }, () => null)
);
placements.clear();
};
const buildTileImage = (tile) => {
if (!tile.imageData) {
return null;
}
if (imageCache.has(tile.id)) {
return imageCache.get(tile.id);
}
const image = new Image();
image.src = tile.imageData;
imageCache.set(tile.id, image);
return image;
};
const setActiveTile = (tileId) => {
activeTileId = tileId;
const tile = getTileById(tileId);
if (!tile) {
activeTileName.textContent = "None selected";
activeTileSwatch.style.background = "repeating-linear-gradient(45deg, #f1f2f7, #f1f2f7 10px, #e8eaf2 10px, #e8eaf2 20px)";
activeTileSwatch.style.backgroundImage = "";
fillRowTileSelect.value = "";
return;
}
activeTileName.textContent = tile.name;
fillRowTileSelect.value = tile.id;
if (tile.imageData) {
activeTileSwatch.style.backgroundImage = `url(${tile.imageData})`;
activeTileSwatch.style.backgroundSize = "cover";
activeTileSwatch.style.backgroundColor = "transparent";
} else {
activeTileSwatch.style.backgroundImage = "";
activeTileSwatch.style.backgroundColor = tile.color;
}
};
const renderFloorOptions = () => {
floorTileSelect.innerHTML = "";
fillRowTileSelect.innerHTML = "";
brickTileSelect.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select tile";
floorTileSelect.append(placeholder);
fillRowTileSelect.append(placeholder.cloneNode(true));
brickTileSelect.append(placeholder.cloneNode(true));
tiles.forEach((tile) => {
const option = document.createElement("option");
option.value = tile.id;
option.textContent = tile.name;
floorTileSelect.append(option);
fillRowTileSelect.append(option.cloneNode(true));
brickTileSelect.append(option.cloneNode(true));
});
if (floorTileId && !tiles.some((tile) => tile.id === floorTileId)) {
floorTileId = null;
}
floorTileSelect.value = floorTileId ?? "";
fillRowTileSelect.value = activeTileId ?? "";
brickTileSelect.value = activeTileId ?? "";
};
const renderTiles = () => {
tileList.innerHTML = "";
tiles.forEach((tile) => {
const card = document.createElement("div");
card.className = "tile-card";
const swatch = document.createElement("div");
swatch.className = "tile-swatch";
if (tile.imageData) {
swatch.style.backgroundImage = `url(${tile.imageData})`;
} else {
swatch.style.backgroundColor = tile.color;
}
const meta = document.createElement("div");
meta.className = "tile-meta";
const title = document.createElement("strong");
title.textContent = tile.name;
const subtitle = document.createElement("span");
const sizeLabel = `${tile.width}x${tile.height}`;
subtitle.textContent = tile.imageData
? `Image tile · ${sizeLabel}`
: `${tile.color.toUpperCase()} · ${sizeLabel}`;
meta.append(title, subtitle);
const actions = document.createElement("div");
actions.className = "tile-actions";
const selectButton = document.createElement("button");
selectButton.textContent = "Use";
selectButton.addEventListener("click", () => {
setActiveTile(tile.id);
});
const deleteButton = document.createElement("button");
deleteButton.textContent = "Delete";
deleteButton.addEventListener("click", () => {
tiles = tiles.filter((item) => item.id !== tile.id);
imageCache.delete(tile.id);
const placementsToRemove = [];
placements.forEach((placement, placementId) => {
if (placement.tileId === tile.id) {
placementsToRemove.push(placementId);
}
});
placementsToRemove.forEach((placementId) => clearPlacement(placementId));
if (floorTileId === tile.id) {
floorTileId = null;
}
if (activeTileId === tile.id) {
setActiveTile(tiles[0]?.id ?? null);
}
renderTiles();
renderCanvas();
syncPatternPreview();
saveLibraryToStorage();
});
actions.append(selectButton, deleteButton);
card.append(swatch, meta, actions);
tileList.append(card);
});
renderFloorOptions();
};
const resizeCanvas = () => {
patternCanvas.width = config.cols * config.tileSize;
patternCanvas.height = config.rows * config.tileSize;
};
const drawGridBackground = () => {
patternContext.fillStyle = gridBackground;
patternContext.fillRect(0, 0, patternCanvas.width, patternCanvas.height);
};
const drawTiles = () => {
placements.forEach((placement) => {
const tile = getTileById(placement.tileId);
if (!tile) {
return;
}
const x = placement.col * config.tileSize;
const y = placement.row * config.tileSize;
const width = placement.width * config.tileSize;
const height = placement.height * config.tileSize;
if (tile.imageData) {
const image = buildTileImage(tile);
if (image.complete) {
patternContext.drawImage(image, x, y, width, height);
} else {
image.onload = () => {
renderCanvas();
};
}
} else {
patternContext.fillStyle = tile.color;
patternContext.fillRect(x, y, width, height);
}
});
};
const drawGridLines = () => {
patternContext.strokeStyle = gridLineColor;
patternContext.lineWidth = 1;
const lastRowIndex = config.rows - 1;
const floorTile = floorTileId ? getTileById(floorTileId) : null;
for (let row = 0; row <= config.rows; row += 1) {
if (row === 0 || row === config.rows) {
patternContext.beginPath();
patternContext.moveTo(0, row * config.tileSize + 0.5);
patternContext.lineTo(patternCanvas.width, row * config.tileSize + 0.5);
patternContext.stroke();
continue;
}
let segmentStart = 0;
for (let col = 0; col < config.cols; col += 1) {
const topId = grid[row - 1][col];
const bottomId = grid[row][col];
const shouldSkip = topId && bottomId && topId === bottomId;
if (shouldSkip) {
if (segmentStart < col) {
patternContext.beginPath();
patternContext.moveTo(segmentStart * config.tileSize, row * config.tileSize + 0.5);
patternContext.lineTo(col * config.tileSize, row * config.tileSize + 0.5);
patternContext.stroke();
}
segmentStart = col + 1;
}
}
if (segmentStart < config.cols) {
patternContext.beginPath();
patternContext.moveTo(segmentStart * config.tileSize, row * config.tileSize + 0.5);
patternContext.lineTo(patternCanvas.width, row * config.tileSize + 0.5);
patternContext.stroke();
}
}
for (let col = 0; col <= config.cols; col += 1) {
if (col === 0 || col === config.cols) {
patternContext.beginPath();
patternContext.moveTo(col * config.tileSize + 0.5, 0);
patternContext.lineTo(col * config.tileSize + 0.5, patternCanvas.height);
patternContext.stroke();
continue;
}
let segmentStart = 0;
for (let row = 0; row < config.rows; row += 1) {
const leftId = grid[row][col - 1];
const rightId = grid[row][col];
const isFloorRow = floorEnabled && row === lastRowIndex;
const floorSkip =
isFloorRow && floorTile && floorTile.width > 1 && col % floorTile.width !== 0;
const shouldSkip = (leftId && rightId && leftId === rightId) || floorSkip;
if (shouldSkip) {
if (segmentStart < row) {
patternContext.beginPath();
patternContext.moveTo(col * config.tileSize + 0.5, segmentStart * config.tileSize);
patternContext.lineTo(col * config.tileSize + 0.5, row * config.tileSize);
patternContext.stroke();
}
segmentStart = row + 1;
}
}
if (segmentStart < config.rows) {
patternContext.beginPath();
patternContext.moveTo(col * config.tileSize + 0.5, segmentStart * config.tileSize);
patternContext.lineTo(col * config.tileSize + 0.5, patternCanvas.height);
patternContext.stroke();
}
}
};
const saveLibraryToStorage = () => {
const payload = {
version: 1,
tiles,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
}
};
const loadLibraryFromPayload = (payload) => {
if (!payload || !Array.isArray(payload.tiles)) {
return false;
}
tiles = normalizeTiles(payload.tiles);
imageCache.clear();
tiles.forEach((tile) => buildTileImage(tile));
const validIds = new Set(tiles.map((tile) => tile.id));
const placementsToRemove = [];
placements.forEach((placement, placementId) => {
if (!validIds.has(placement.tileId)) {
placementsToRemove.push(placementId);
}
});
placementsToRemove.forEach((placementId) => clearPlacement(placementId));
if (floorTileId && !validIds.has(floorTileId)) {
floorTileId = null;
}
setActiveTile(tiles[0]?.id ?? null);
renderTiles();
renderFloorOptions();
renderCanvas();
syncPatternPreview();
return true;
};
const loadLibraryFromStorage = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
const payload = JSON.parse(raw);
loadLibraryFromPayload(payload);
} catch (error) {
}
};
const renderCanvas = () => {
resizeCanvas();
drawGridBackground();
drawTiles();
applyFloorRow();
drawGridLines();
};
const getCellFromPointer = (event) => {
const rect = patternCanvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / config.tileSize);
const row = Math.floor(y / config.tileSize);
if (row < 0 || row >= config.rows || col < 0 || col >= config.cols) {
return null;
}
return { row, col };
};
const clearPlacement = (placementId) => {
const placement = placements.get(placementId);
if (!placement) {
return;
}
for (let r = placement.row; r < placement.row + placement.height; r += 1) {
for (let c = placement.col; c < placement.col + placement.width; c += 1) {
if (grid[r]?.[c] === placementId) {
grid[r][c] = null;
}
}
}
placements.delete(placementId);
};
const removePlacementsInArea = (row, col, width, height) => {
const affected = new Set();
for (let r = row; r < row + height; r += 1) {
for (let c = col; c < col + width; c += 1) {
const placementId = grid[r]?.[c];
if (placementId) {
affected.add(placementId);
}
}
}
affected.forEach((placementId) => clearPlacement(placementId));
};
const addPlacement = (row, col, tileId, width, height) => {
const placementId = createPlacementId();
const placement = {
id: placementId,
tileId,
row,
col,
width,
height,
};
placements.set(placementId, placement);
for (let r = row; r < row + height; r += 1) {
for (let c = col; c < col + width; c += 1) {
grid[r][c] = placementId;
}
}
};
const fillRowWithTile = (rowIndex, tileId) => {
const tile = getTileById(tileId);
if (!tile) {
return;
}
if (floorEnabled && rowIndex === config.rows - 1) {
return;
}
const toRemove = new Set();
for (let col = 0; col < config.cols; col += 1) {
const placementId = grid[rowIndex][col];
if (placementId) {
toRemove.add(placementId);
}
}
toRemove.forEach((placementId) => clearPlacement(placementId));
const width = clampNumber(tile.width, 1, config.cols);
for (let col = 0; col < config.cols; col += width) {
const remaining = config.cols - col;
addPlacement(rowIndex, col, tile.id, clampNumber(width, 1, remaining), 1);
}
renderCanvas();
syncPatternPreview();
};
const fillBrickRows = (startRow, endRow, tileId, stagger) => {
const tile = getTileById(tileId);
if (!tile) {
return;
}
const maxRow = floorEnabled ? config.rows - 2 : config.rows - 1;
const start = clampNumber(startRow, 0, maxRow);
const end = clampNumber(endRow, start, maxRow);
const tileWidth = clampNumber(tile.width, 1, config.cols);
for (let row = start; row <= end; row += 1) {
const offset = stagger && (row - start) % 2 === 1 ? Math.floor(tileWidth / 2) : 0;
const toRemove = new Set();
for (let col = 0; col < config.cols; col += 1) {
const placementId = grid[row][col];
if (placementId) {
toRemove.add(placementId);
}
}
toRemove.forEach((placementId) => clearPlacement(placementId));
let col = 0;
if (offset > 0) {
addPlacement(row, 0, tile.id, clampNumber(offset, 1, config.cols), 1);
col = offset;
}
for (; col < config.cols; col += tileWidth) {
const remaining = config.cols - col;
addPlacement(row, col, tile.id, clampNumber(tileWidth, 1, remaining), 1);
}
}
renderCanvas();
syncPatternPreview();
};
const paintArea = (row, col, tileId, width = 1, height = 1) => {
const maxRow = floorEnabled ? config.rows - 2 : config.rows - 1;
if (row > maxRow || col >= config.cols) {
return;
}
const clampedWidth = clampNumber(width, 1, config.cols - col);
const clampedHeight = clampNumber(height, 1, maxRow - row + 1);
removePlacementsInArea(row, col, clampedWidth, clampedHeight);
if (tileId) {
addPlacement(row, col, tileId, clampedWidth, clampedHeight);
}
renderCanvas();
syncPatternPreview();
};
let isPainting = false;
let paintMode = "paint";
patternCanvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
patternCanvas.addEventListener("pointerdown", (event) => {
isPainting = true;
paintMode = event.shiftKey || event.button === 2 ? "erase" : "paint";
const cell = getCellFromPointer(event);
if (!cell) {
return;
}
if (paintMode === "erase") {
if (!(floorEnabled && cell.row === config.rows - 1)) {
const placementId = grid[cell.row][cell.col];
if (placementId) {
clearPlacement(placementId);
renderCanvas();
syncPatternPreview();
}
}
} else if (activeTileId) {
const tile = getTileById(activeTileId);
if (tile) {
paintArea(cell.row, cell.col, activeTileId, tile.width, tile.height);
}
}
});
patternCanvas.addEventListener("pointermove", (event) => {
if (!isPainting) {
return;
}
const cell = getCellFromPointer(event);
if (!cell) {
return;
}
if (paintMode === "erase") {
if (!(floorEnabled && cell.row === config.rows - 1)) {
const placementId = grid[cell.row][cell.col];
if (placementId) {
clearPlacement(placementId);
renderCanvas();
syncPatternPreview();
}
}
} else if (activeTileId) {
const tile = getTileById(activeTileId);
if (tile) {
paintArea(cell.row, cell.col, activeTileId, tile.width, tile.height);
}
}
});
patternCanvas.addEventListener("pointerup", () => {
isPainting = false;
});
patternCanvas.addEventListener("pointerleave", () => {
isPainting = false;
});
applyGridButton.addEventListener("click", () => {
config.rows = clampNumber(Number(gridRowsInput.value), 1, 100);
config.cols = clampNumber(Number(gridColsInput.value), 1, 100);
config.tileSize = clampNumber(Number(gridSizeInput.value), 12, 120);
gridRowsInput.value = config.rows;
gridColsInput.value = config.cols;
gridSizeInput.value = config.tileSize;
fillRowInput.max = String(config.rows);
brickStartInput.max = String(config.rows);
brickEndInput.max = String(config.rows);
initGrid();
renderCanvas();
syncPatternPreview();
});
clearGridButton.addEventListener("click", () => {
initGrid();
renderCanvas();
syncPatternPreview();
});
floorEnabledInput.addEventListener("change", () => {
floorEnabled = floorEnabledInput.checked;
applyFloorRow();
renderCanvas();
syncPatternPreview();
});
floorTileSelect.addEventListener("change", () => {
floorTileId = floorTileSelect.value || null;
applyFloorRow();
renderCanvas();
syncPatternPreview();
});
fillRowButton.addEventListener("click", () => {
const rowIndex = clampNumber(Number(fillRowInput.value) - 1, 0, config.rows - 1);
const tileId = fillRowTileSelect.value || activeTileId;
if (!tileId) {
return;
}
fillRowWithTile(rowIndex, tileId);
});
brickApplyButton.addEventListener("click", () => {
const startRow = Number(brickStartInput.value) - 1;
const endRow = Number(brickEndInput.value) - 1;
const tileId = brickTileSelect.value || activeTileId;
if (!tileId) {
return;
}
fillBrickRows(startRow, endRow, tileId, brickStaggerInput.checked);
});
const readFileAsDataUrl = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error("Could not read file"));
reader.readAsDataURL(file);
});
tileForm.addEventListener("submit", async (event) => {
event.preventDefault();
const name = tileNameInput.value.trim();
if (!name) {
return;
}
const color = tileColorInput.value;
const width = clampNumber(Number(tileWidthInput.value), 1, 12);
const height = clampNumber(Number(tileHeightInput.value), 1, 12);
const file = tileImageInput.files?.[0] ?? null;
const tile = {
id: createId(),
name,
color,
width,
height,
imageData: null,
};
if (file) {
tile.imageData = await readFileAsDataUrl(file);
}
tiles.push(tile);
tileNameInput.value = "";
tileImageInput.value = "";
tileWidthInput.value = "1";
tileHeightInput.value = "1";
if (!activeTileId) {
setActiveTile(tile.id);
}
renderTiles();
renderCanvas();
syncPatternPreview();
saveLibraryToStorage();
});
const buildPattern = () => ({
version: 1,
config: {
rows: config.rows,
cols: config.cols,
tileSize: config.tileSize,
floorEnabled,
floorTileId,
},
tiles: tiles.map((tile) => ({
id: tile.id,
name: tile.name,
color: tile.color,
width: tile.width,
height: tile.height,
imageData: tile.imageData,
})),
placements: Array.from(placements.values()),
grid: grid.map((row) => row.map((cell) => {
if (!cell) {
return null;
}
const placement = placements.get(cell);
return placement ? placement.tileId : null;
})),
});
const syncPatternPreview = () => {
const payload = buildPattern();
patternJson.value = JSON.stringify(payload, null, 2);
};
const downloadFile = (content, filename, type) => {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
exportJsonButton.addEventListener("click", () => {
applyFloorRow();
const payload = buildPattern();
downloadFile(JSON.stringify(payload, null, 2), "tile-pattern.json", "application/json");
});
copyJsonButton.addEventListener("click", async () => {
const payload = buildPattern();
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
} catch (error) {
patternJson.focus();
patternJson.select();
}
});
exportPngButton.addEventListener("click", () => {
const exportCanvas = document.createElement("canvas");
exportCanvas.width = config.cols * config.tileSize;
exportCanvas.height = config.rows * config.tileSize;
const exportContext = exportCanvas.getContext("2d");
exportContext.fillStyle = gridBackground;
exportContext.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
placements.forEach((placement) => {
const tile = getTileById(placement.tileId);
if (!tile) {
return;
}
const x = placement.col * config.tileSize;
const y = placement.row * config.tileSize;
const width = placement.width * config.tileSize;
const height = placement.height * config.tileSize;
if (tile.imageData) {
const image = buildTileImage(tile);
if (image.complete) {
exportContext.drawImage(image, x, y, width, height);
} else {
image.onload = () => {
exportPngButton.click();
};
}
} else {
exportContext.fillStyle = tile.color;
exportContext.fillRect(x, y, width, height);
}
});
if (floorEnabled && floorTileId) {
const tile = getTileById(floorTileId);
if (tile) {
const lastRow = config.rows - 1;
for (let col = 0; col < config.cols; col += tile.width) {
const width = clampNumber(tile.width, 1, config.cols - col);
const x = col * config.tileSize;
const y = lastRow * config.tileSize;
const tileWidth = width * config.tileSize;
if (tile.imageData) {
const image = buildTileImage(tile);
if (image.complete) {
exportContext.drawImage(image, x, y, tileWidth, config.tileSize);
} else {
image.onload = () => {
exportPngButton.click();
};
}
} else {
exportContext.fillStyle = tile.color;
exportContext.fillRect(x, y, tileWidth, config.tileSize);
}
}
}
}
exportCanvas.toBlob((blob) => {
if (!blob) {
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "tile-pattern.png";
link.click();
URL.revokeObjectURL(url);
});
});
refreshJsonButton.addEventListener("click", () => {
syncPatternPreview();
});
importJsonButton.addEventListener("click", () => {
try {
const payload = JSON.parse(patternJson.value);
if (!payload.config || !payload.grid || !payload.tiles) {
throw new Error("Invalid pattern data");
}
config.rows = clampNumber(Number(payload.config.rows), 1, 100);
config.cols = clampNumber(Number(payload.config.cols), 1, 100);
config.tileSize = clampNumber(Number(payload.config.tileSize), 12, 120);
floorEnabled = Boolean(payload.config.floorEnabled);
floorTileId = payload.config.floorTileId ?? null;
gridRowsInput.value = config.rows;
gridColsInput.value = config.cols;
gridSizeInput.value = config.tileSize;
floorEnabledInput.checked = floorEnabled;
fillRowInput.value = "1";
fillRowInput.max = String(config.rows);
brickStartInput.max = String(config.rows);
brickEndInput.max = String(config.rows);
tiles = normalizeTiles(payload.tiles);
imageCache.clear();
tiles.forEach((tile) => buildTileImage(tile));
initGrid();
if (Array.isArray(payload.placements)) {
payload.placements.forEach((placement) => {
const tile = getTileById(placement.tileId);
if (!tile) {
return;
}
const row = clampNumber(Number(placement.row), 0, config.rows - 1);
const col = clampNumber(Number(placement.col), 0, config.cols - 1);
const width = clampNumber(Number(placement.width ?? tile.width), 1, config.cols - col);
const height = clampNumber(Number(placement.height ?? tile.height), 1, config.rows - row);
addPlacement(row, col, tile.id, width, height);
});
} else if (Array.isArray(payload.grid)) {
payload.grid.forEach((row, rowIndex) => {
row.forEach((cell, colIndex) => {
if (cell) {
const tile = getTileById(cell);
if (tile) {
addPlacement(rowIndex, colIndex, tile.id, 1, 1);
}
}
});
});
}
setActiveTile(tiles[0]?.id ?? null);
renderTiles();
renderFloorOptions();
renderCanvas();
syncPatternPreview();
saveLibraryToStorage();
} catch (error) {
alert("Could not import JSON. Please check the format.");
}
});
exportLibraryButton.addEventListener("click", () => {
const payload = {
version: 1,
tiles,
};
downloadFile(JSON.stringify(payload, null, 2), "tile-library.json", "application/json");
});
importLibraryButton.addEventListener("click", () => {
importLibraryFile.click();
});
importLibraryFile.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
const text = await file.text();
const payload = JSON.parse(text);
const loaded = loadLibraryFromPayload(payload);
if (loaded) {
saveLibraryToStorage();
} else {
alert("Invalid library JSON.");
}
} catch (error) {
alert("Could not import library JSON.");
} finally {
importLibraryFile.value = "";
}
});
initGrid();
loadLibraryFromStorage();
renderTiles();
renderCanvas();
syncPatternPreview();
fillRowInput.max = String(config.rows);
brickStartInput.max = String(config.rows);
brickEndInput.max = String(config.rows);