init
This commit is contained in:
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Tile Pattern Creator
|
||||||
|
|
||||||
|
A lightweight, browser-based tool to build a tile library and paint reusable patterns for tiling teams.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Add tiles by color or optional image
|
||||||
|
- Paint patterns on a configurable grid
|
||||||
|
- Support rectangular tiles (multi-cell)
|
||||||
|
- Pin a floor tile row at the bottom
|
||||||
|
- Fill any row with a chosen tile
|
||||||
|
- Export JSON for handing off to a tiler
|
||||||
|
- Export PNG snapshots
|
||||||
|
- Import JSON to continue a saved pattern
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
Open `index.html` directly in your browser, or use the optional local server script below.
|
||||||
|
|
||||||
|
### Optional local server (PowerShell)
|
||||||
|
```powershell
|
||||||
|
./scripts/serve.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke check
|
||||||
|
```powershell
|
||||||
|
./scripts/smoke.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern JSON format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"config": {
|
||||||
|
"rows": 12,
|
||||||
|
"cols": 12,
|
||||||
|
"tileSize": 48,
|
||||||
|
"floorEnabled": true,
|
||||||
|
"floorTileId": "tile-abc"
|
||||||
|
},
|
||||||
|
"tiles": [
|
||||||
|
{
|
||||||
|
"id": "tile-abc",
|
||||||
|
"name": "Sky Blue",
|
||||||
|
"color": "#4c8bf5",
|
||||||
|
"width": 2,
|
||||||
|
"height": 1,
|
||||||
|
"imageData": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"placements": [
|
||||||
|
{ "id": "place-1", "tileId": "tile-abc", "row": 2, "col": 3, "width": 2, "height": 1 }
|
||||||
|
],
|
||||||
|
"grid": [["tile-abc", null]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
- Shift-click or right click to erase a 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.
|
||||||
|
- If you use image tiles, keep images square for best results.
|
||||||
727
app.js
Normal file
727
app.js
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
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();
|
||||||
121
index.html
Normal file
121
index.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Tile Pattern Creator</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<div>
|
||||||
|
<h1>Tile Pattern Creator</h1>
|
||||||
|
<p>Build a tile library, then paint patterns for your tiler.</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="export-json" class="primary">Export JSON</button>
|
||||||
|
<button id="copy-json">Copy JSON</button>
|
||||||
|
<button id="export-png">Export PNG</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-layout">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Tile Library</h2>
|
||||||
|
<form id="tile-form" class="tile-form">
|
||||||
|
<label>
|
||||||
|
Tile name
|
||||||
|
<input id="tile-name" type="text" placeholder="e.g., Sky Blue" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tile color
|
||||||
|
<input id="tile-color" type="color" value="#4c8bf5" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tile width (cells)
|
||||||
|
<input id="tile-width" type="number" min="1" max="12" value="1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tile height (cells)
|
||||||
|
<input id="tile-height" type="number" min="1" max="12" value="1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Optional image
|
||||||
|
<input id="tile-image" type="file" accept="image/*" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="primary">Add tile</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="active-tile">
|
||||||
|
<span>Active tile</span>
|
||||||
|
<div class="active-preview" id="active-tile-swatch"></div>
|
||||||
|
<strong id="active-tile-name">None selected</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tile-list" class="tile-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>
|
||||||
|
Rows
|
||||||
|
<input id="grid-rows" type="number" min="1" max="100" value="12" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Columns
|
||||||
|
<input id="grid-cols" type="number" min="1" max="100" value="12" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tile size
|
||||||
|
<input id="grid-size" type="number" min="12" max="120" value="48" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="floor-enabled" type="checkbox" />
|
||||||
|
Floor row
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Floor tile
|
||||||
|
<select id="floor-tile"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>
|
||||||
|
Fill row #
|
||||||
|
<input id="fill-row" type="number" min="1" max="100" value="1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Fill with tile
|
||||||
|
<select id="fill-row-tile"></select>
|
||||||
|
</label>
|
||||||
|
<button id="fill-row-button">Fill row</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-actions">
|
||||||
|
<button id="apply-grid">Apply grid</button>
|
||||||
|
<button id="clear-grid">Clear grid</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-tip">
|
||||||
|
Tip: click to paint. Shift-click or right click to erase.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="canvas-wrap">
|
||||||
|
<canvas id="pattern-canvas" width="576" height="576"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="json-panel">
|
||||||
|
<div class="json-actions">
|
||||||
|
<h3>Pattern JSON</h3>
|
||||||
|
<button id="import-json">Import JSON</button>
|
||||||
|
<button id="refresh-json">Refresh JSON</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="pattern-json" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "tile-pattern-creator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Tile library + pattern creator for tilers.",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "powershell -ExecutionPolicy Bypass -File ./scripts/serve.ps1",
|
||||||
|
"smoke": "powershell -ExecutionPolicy Bypass -File ./scripts/smoke.ps1"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
scripts/serve.ps1
Normal file
8
scripts/serve.ps1
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
$python = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if (-not $python) {
|
||||||
|
Write-Host "Python not found. Open index.html directly in a browser." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting local server at http://localhost:8000" -ForegroundColor Green
|
||||||
|
python -m http.server 8000
|
||||||
15
scripts/smoke.ps1
Normal file
15
scripts/smoke.ps1
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
$files = @("index.html", "styles.css", "app.js", "README.md")
|
||||||
|
$missing = @()
|
||||||
|
|
||||||
|
foreach ($file in $files) {
|
||||||
|
if (-not (Test-Path $file)) {
|
||||||
|
$missing += $file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing.Count -gt 0) {
|
||||||
|
Write-Host "Missing files: $($missing -join ', ')" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Smoke check passed." -ForegroundColor Green
|
||||||
277
styles.css
Normal file
277
styles.css
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #1c1f2a;
|
||||||
|
--muted: #6a7182;
|
||||||
|
--primary: #2d6cdf;
|
||||||
|
--border: #e1e5ef;
|
||||||
|
--shadow: 0 18px 40px rgba(20, 26, 40, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 40px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px 40px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.workspace {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-form input[type="text"],
|
||||||
|
.tile-form input[type="number"],
|
||||||
|
.tile-form input[type="color"],
|
||||||
|
.tile-form input[type="file"],
|
||||||
|
.tile-form select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-tile {
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-preview {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: repeating-linear-gradient(45deg, #f1f2f7, #f1f2f7 10px, #e8eaf2 10px, #e8eaf2 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-swatch {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-meta span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input {
|
||||||
|
width: 120px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group select {
|
||||||
|
width: 180px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrap {
|
||||||
|
background: #fefefe;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pattern-canvas {
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pattern-json {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f9fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.app-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user