Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

*Note: some changes may be missing prior to September 2026 due to my lack of documentation.*

## August 15, 2026 — Crisper, pixel-perfect preview
The preview panel got a big clarity upgrade:
- The pattern now **repeats right to the edges** of the panel, at any size or zoom level — resizing the panel just reveals more of the pattern instead of stretching it.
- **Zoom controls the pattern size** 50% up to 500%
- **Fixed Monitor Interpolation** by adding a formula to calculate how to display pattern **sharp and pixel-perfect** at every level and every screen size — no more mis-sized, blurry or smudged edges (avoids half pixel rounding).
- The preview area now takes up about **two-thirds of the panel's height**, giving your pattern more room to shine.
- Fixed an edge case where the pattern could stop just short of the panel's borders at certain zoom levels.

## August 14, 2026 — Favorite Colours
- Added a **★ star button** next to the Color Code button so you can **favorite your current color palette** (preset or custom) with one click — tap again to remove it.
- Favorited colors appear in a new **Favorites** list above the Color Presets, so you can quickly jump back to the palettes you use most. Custom palettes are listed as **"custom"**, and your favorites are remembered between sessions.
Expand Down
82 changes: 63 additions & 19 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,36 +304,80 @@ document.addEventListener("DOMContentLoaded", () => {
context: previewCtx,
primaryColorInput: previewPrimaryColor,
secondaryColorInput: previewSecondaryColor,
canvasWrap: previewCanvasWrap,
getZoom: () => previewZoom,
});

// ── Preview zoom ──────────────────────────────────────────────────────────
let previewZoom = 1;
const PREVIEW_ZOOM_MIN = 0.5;
const PREVIEW_ZOOM_MAX = 4;
const PREVIEW_ZOOM_STEP = 0.5;

const applyPreviewZoom = () => {
previewCanvas.style.transform = `scale(${previewZoom})`;
previewCanvas.style.transformOrigin = "center";
// Zoom is the displayed CSS px per tile cell (× the encoded pattern scale).
// The renderer draws at device-pixel resolution, so every zoom level stays
// pixel-exact — including 50% — on high-DPI displays.
const PREVIEW_ZOOM_LEVELS = [0.5, 1, 2, 3, 4, 5];
let previewZoomIndex = 1;
let previewZoom = PREVIEW_ZOOM_LEVELS[previewZoomIndex];

const updatePreviewZoomLabel = () => {
previewZoomValue.textContent = `${Math.round(previewZoom * 100)}%`;
if (previewCanvasWrap) {
previewCanvasWrap.style.background = previewPrimaryColor.value;
}
};

previewZoomInBtn.addEventListener("click", () => {
previewZoom = Math.min(PREVIEW_ZOOM_MAX, previewZoom + PREVIEW_ZOOM_STEP);
applyPreviewZoom();
});
const setPreviewZoom = (index: number) => {
previewZoomIndex = Math.max(
0,
Math.min(PREVIEW_ZOOM_LEVELS.length - 1, index)
);
previewZoom = PREVIEW_ZOOM_LEVELS[previewZoomIndex];
updatePreviewZoomLabel();
renderPreviewOnly();
};

previewZoomOutBtn.addEventListener("click", () => {
previewZoom = Math.max(PREVIEW_ZOOM_MIN, previewZoom - PREVIEW_ZOOM_STEP);
applyPreviewZoom();
});
previewZoomInBtn.addEventListener("click", () =>
setPreviewZoom(previewZoomIndex + 1)
);
previewZoomOutBtn.addEventListener("click", () =>
setPreviewZoom(previewZoomIndex - 1)
);

// ── Output update ─────────────────────────────────────────────────────────
const clonePattern = (pattern: number[][]) => pattern.map((row) => [...row]);

// Re-renders the preview bitmap only — no history/URL side effects. Safe to
// call on panel resizes and zoom changes without polluting undo history.
const renderPreviewOnly = () => {
const grid = activeGrid();
const pattern = grid.getCurrentPattern();

if (isScrapActive) {
renderPreview(pattern, true);
} else {
const scale = scaleExponent();
try {
const base64 = generatePatternBase64(
pattern,
grid.getTileWidth(),
grid.getTileHeight(),
scale
);
renderPreview(base64, false);
} catch {
return;
}
}

if (previewCanvasWrap) {
previewCanvasWrap.style.background = previewPrimaryColor.value;
}
};

// Re-render whenever the preview panel is resized so the tile layout stays an
// integer multiple of the pattern tile at the current zoom.
if (typeof ResizeObserver !== "undefined" && previewCanvasWrap) {
new ResizeObserver(() => {
if (previewCanvasWrap.clientWidth > 0 && previewCanvasWrap.clientHeight > 0) {
renderPreviewOnly();
}
}).observe(previewCanvasWrap);
}

let updateOutput = () => {};
updateOutput = () => {
const grid = activeGrid();
Expand Down
66 changes: 59 additions & 7 deletions src/app/previewRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,37 @@ type PreviewRendererOptions = {
context: CanvasRenderingContext2D;
primaryColorInput: HTMLInputElement;
secondaryColorInput: HTMLInputElement;
canvasWrap: HTMLElement;
getZoom: () => number;
};

type PatternSource = { isSet: (x: number, y: number) => boolean };
type PatternSource = {
isSetCell: (cx: number, cy: number) => boolean;
tileWidth: number;
tileHeight: number;
scale: number;
};

function patternSourceFromBase64(base64: string): PatternSource {
const decoder = new PatternDecoder(base64);
return { isSet: (x, y) => decoder.isSet(x, y) };
const scale = decoder.getScale();
return {
// Cell-level predicate: tiles the pattern across cell indices.
isSetCell: (cx, cy) => decoder.isSet(cx << scale, cy << scale),
tileWidth: decoder.getTileWidth(),
tileHeight: decoder.getTileHeight(),
scale,
};
}

function patternSourceFromMatrix(pattern: number[][]): PatternSource {
const height = pattern.length;
const width = pattern[0]?.length ?? 0;
return {
isSet: (x, y) => (pattern[y % height]?.[x % width] ?? 0) === 1,
isSetCell: (cx, cy) => (pattern[cy % height]?.[cx % width] ?? 0) === 1,
tileWidth: width,
tileHeight: height,
scale: 0,
};
}

Expand All @@ -32,17 +49,50 @@ function hexToRgb(hex: string): RgbColor {
}

export function createPreviewRenderer(options: PreviewRendererOptions) {
const { canvas, context, primaryColorInput, secondaryColorInput } = options;
const { canvas, context, primaryColorInput, secondaryColorInput, canvasWrap, getZoom } = options;

return function renderPreview(pattern: string | number[][], isScrap = false) {
const source =
typeof pattern === "string"
? patternSourceFromBase64(pattern)
: patternSourceFromMatrix(pattern);
const width = 512;
const height = 512;

const wrapRect = canvasWrap.getBoundingClientRect();
const availW = wrapRect.width;
const availH = wrapRect.height;

// Displayed size of one tile cell in CSS px (zoom × encoded pattern scale).
const zoomScale = Math.max(0.5, getZoom()) * (1 << source.scale);

// Render at device-pixel resolution so the bitmap maps 1:1 to physical
// pixels and the compositor never scales (no half-pixel rounding). Each
// cell becomes a whole number of device px; only sub-device-pixel cells
// (e.g. 50% of a scale-0 pattern on a 1x screen) fall back to
// nearest-neighbour downsampling via image-rendering.
const dpr = window.devicePixelRatio || 1;
const deviceCell = zoomScale * dpr;
const bitmapCell = deviceCell >= 1 ? Math.round(deviceCell) : 1;
const deviceScale = deviceCell >= 1 ? 1 : deviceCell;

// Actual displayed CSS px per cell after rounding to whole device pixels.
// Rounding can shift this from the intended zoomScale (e.g. a fractional
// devicePixelRatio), so the tile count must come from here — not zoomScale —
// or the canvas would fall short of the panel edges.
const cellDisplayPx = (bitmapCell * deviceScale) / dpr;

// Repeat the pattern to the panel edges; partial edge tiles are clipped by
// the overflow-hidden wrap, so the preview always fills regardless of panel
// size or zoom.
const cols = Math.max(1, Math.ceil(availW / cellDisplayPx));
const rows = Math.max(1, Math.ceil(availH / cellDisplayPx));

const width = cols * bitmapCell;
const height = rows * bitmapCell;

canvas.width = width;
canvas.height = height;
canvas.style.width = `${(width * deviceScale) / dpr}px`;
canvas.style.height = `${(height * deviceScale) / dpr}px`;

const primaryRgb = hexToRgb(primaryColorInput.value);
const secondaryRgb = hexToRgb(secondaryColorInput.value);
Expand All @@ -51,8 +101,10 @@ export function createPreviewRenderer(options: PreviewRendererOptions) {
const data = imageData.data;
let i = 0;
for (let y = 0; y < height; y++) {
const cy = Math.floor(y / bitmapCell);
for (let x = 0; x < width; x++) {
if (source.isSet(x, y)) {
const cx = Math.floor(x / bitmapCell);
if (source.isSetCell(cx, cy)) {
data[i++] = secondaryRgb.r;
data[i++] = secondaryRgb.g;
data[i++] = secondaryRgb.b;
Expand Down
7 changes: 1 addition & 6 deletions src/styles/editor-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@
}

.preview-canvas-wrap {
flex: 1;
flex: 2;
min-height: 0;
display: flex;
justify-content: center;
Expand All @@ -806,13 +806,8 @@
}

#preview {
width: 100%;
max-width: 100%;
height: auto;
aspect-ratio: 1;
background: #fff;
image-rendering: pixelated;
transform-origin: center;
}

/* Colors Section */
Expand Down
Loading