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 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 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 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 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 = ""; const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = "Select tile"; floorTileSelect.append(placeholder); fillRowTileSelect.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)); }); if (floorTileId && !tiles.some((tile) => tile.id === floorTileId)) { floorTileId = null; } floorTileSelect.value = floorTileId ?? ""; fillRowTileSelect.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); grid = grid.map((row) => row.map((cell) => (cell === tile.id ? null : cell))); if (floorTileId === tile.id) { floorTileId = null; } if (activeTileId === tile.id) { setActiveTile(tiles[0]?.id ?? null); } renderTiles(); renderCanvas(); syncPatternPreview(); }); 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; for (let row = 0; row <= config.rows; row += 1) { patternContext.beginPath(); patternContext.moveTo(0, 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) { patternContext.beginPath(); patternContext.moveTo(col * config.tileSize + 0.5, 0); patternContext.lineTo(col * config.tileSize + 0.5, patternCanvas.height); patternContext.stroke(); } }; 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 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; 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); }); 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(); }); 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"; tiles = payload.tiles.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, })); 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(); } catch (error) { alert("Could not import JSON. Please check the format."); } }); initGrid(); renderTiles(); renderCanvas(); syncPatternPreview();