947 lines
28 KiB
JavaScript
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);
|