This commit is contained in:
Oli Passey
2026-02-21 17:33:52 +00:00
parent b63d094c70
commit 22f7b3c4a9
4 changed files with 123 additions and 9 deletions

View File

@@ -8,6 +8,8 @@ A lightweight, browser-based tool to build a tile library and paint reusable pat
- Support rectangular tiles (multi-cell) - Support rectangular tiles (multi-cell)
- Pin a floor tile row at the bottom - Pin a floor tile row at the bottom
- Fill any row with a chosen tile - Fill any row with a chosen tile
- Auto-save tile library in the browser
- Import/export the tile library as JSON
- Export JSON for handing off to a tiler - Export JSON for handing off to a tiler
- Export PNG snapshots - Export PNG snapshots
- Import JSON to continue a saved pattern - Import JSON to continue a saved pattern
@@ -57,4 +59,5 @@ Open `index.html` directly in your browser, or use the optional local server scr
- Shift-click or right click to erase a tile. - Shift-click or right click to erase a tile.
- Enable “Floor row” to lock the bottom row to a chosen tile. - Enable “Floor row” to lock the bottom row to a chosen tile.
- Use “Fill row” to quickly paint a full row with a tile. - Use “Fill row” to quickly paint a full row with a tile.
- Tile library changes auto-save to your browser storage.
- If you use image tiles, keep images square for best results. - If you use image tiles, keep images square for best results.

117
app.js
View File

@@ -7,6 +7,9 @@ const tileImageInput = document.getElementById("tile-image");
const tileList = document.getElementById("tile-list"); const tileList = document.getElementById("tile-list");
const activeTileName = document.getElementById("active-tile-name"); const activeTileName = document.getElementById("active-tile-name");
const activeTileSwatch = document.getElementById("active-tile-swatch"); 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 gridRowsInput = document.getElementById("grid-rows");
const gridColsInput = document.getElementById("grid-cols"); const gridColsInput = document.getElementById("grid-cols");
@@ -36,6 +39,7 @@ let floorEnabled = false;
let floorTileId = null; let floorTileId = null;
const placements = new Map(); const placements = new Map();
const imageCache = new Map(); const imageCache = new Map();
const STORAGE_KEY = "tile-library-v1";
const config = { const config = {
rows: Number(gridRowsInput.value), rows: Number(gridRowsInput.value),
@@ -59,6 +63,16 @@ const createPlacementId = () =>
? crypto.randomUUID() ? crypto.randomUUID()
: `placement-${Date.now()}-${Math.random().toString(16).slice(2)}`; : `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 = () => { const applyFloorRow = () => {
if (!floorEnabled || !floorTileId) { if (!floorEnabled || !floorTileId) {
return; return;
@@ -194,7 +208,13 @@ const renderTiles = () => {
deleteButton.addEventListener("click", () => { deleteButton.addEventListener("click", () => {
tiles = tiles.filter((item) => item.id !== tile.id); tiles = tiles.filter((item) => item.id !== tile.id);
imageCache.delete(tile.id); imageCache.delete(tile.id);
grid = grid.map((row) => row.map((cell) => (cell === tile.id ? null : cell))); const placementsToRemove = [];
placements.forEach((placement, placementId) => {
if (placement.tileId === tile.id) {
placementsToRemove.push(placementId);
}
});
placementsToRemove.forEach((placementId) => clearPlacement(placementId));
if (floorTileId === tile.id) { if (floorTileId === tile.id) {
floorTileId = null; floorTileId = null;
} }
@@ -204,6 +224,7 @@ const renderTiles = () => {
renderTiles(); renderTiles();
renderCanvas(); renderCanvas();
syncPatternPreview(); syncPatternPreview();
saveLibraryToStorage();
}); });
actions.append(selectButton, deleteButton); actions.append(selectButton, deleteButton);
@@ -268,6 +289,55 @@ const drawGridLines = () => {
} }
}; };
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 = () => { const renderCanvas = () => {
resizeCanvas(); resizeCanvas();
drawGridBackground(); drawGridBackground();
@@ -522,6 +592,7 @@ tileForm.addEventListener("submit", async (event) => {
renderTiles(); renderTiles();
renderCanvas(); renderCanvas();
syncPatternPreview(); syncPatternPreview();
saveLibraryToStorage();
}); });
const buildPattern = () => ({ const buildPattern = () => ({
@@ -674,14 +745,7 @@ importJsonButton.addEventListener("click", () => {
floorEnabledInput.checked = floorEnabled; floorEnabledInput.checked = floorEnabled;
fillRowInput.value = "1"; fillRowInput.value = "1";
tiles = payload.tiles.map((tile) => ({ tiles = normalizeTiles(payload.tiles);
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(); imageCache.clear();
tiles.forEach((tile) => buildTileImage(tile)); tiles.forEach((tile) => buildTileImage(tile));
@@ -716,12 +780,47 @@ importJsonButton.addEventListener("click", () => {
renderFloorOptions(); renderFloorOptions();
renderCanvas(); renderCanvas();
syncPatternPreview(); syncPatternPreview();
saveLibraryToStorage();
} catch (error) { } catch (error) {
alert("Could not import JSON. Please check the format."); 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(); initGrid();
loadLibraryFromStorage();
renderTiles(); renderTiles();
renderCanvas(); renderCanvas();
syncPatternPreview(); syncPatternPreview();

View File

@@ -22,6 +22,11 @@
<main class="app-layout"> <main class="app-layout">
<section class="panel"> <section class="panel">
<h2>Tile Library</h2> <h2>Tile Library</h2>
<div class="library-actions">
<button id="export-library" class="primary">Export library</button>
<button id="import-library">Import library</button>
<input id="import-library-file" type="file" accept="application/json" hidden />
</div>
<form id="tile-form" class="tile-form"> <form id="tile-form" class="tile-form">
<label> <label>
Tile name Tile name

View File

@@ -86,6 +86,13 @@ button:hover {
margin-top: 0; margin-top: 0;
} }
.library-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.tile-form { .tile-form {
display: grid; display: grid;
gap: 12px; gap: 12px;