Snap to grid and flag export

This commit is contained in:
2025-12-05 00:44:08 +01:00
parent 0a3d2eca77
commit 67b68f1c8a
6 changed files with 62 additions and 29 deletions

View File

@@ -53,8 +53,8 @@
if (!files || files.length === 0) return;
const rect = container.getBoundingClientRect();
const dropX = (e.clientX - rect.left - state.viewport.x) / state.viewport.zoom;
const dropY = (e.clientY - rect.top - state.viewport.y) / state.viewport.zoom;
const dropX = state.snap((e.clientX - rect.left - state.viewport.x) / state.viewport.zoom);
const dropY = state.snap((e.clientY - rect.top - state.viewport.y) / state.viewport.zoom);
for (const file of files) {
handleFile(file, dropX, dropY);

View File

@@ -100,8 +100,8 @@
const dy = (e.clientY - dragStart.y) / appState.viewport.zoom;
for (const [id, startPos] of dragStartPositions) {
appState.updateItem(id, {
x: startPos.x + dx,
y: startPos.y + dy
x: appState.snap(startPos.x + dx),
y: appState.snap(startPos.y + dy)
});
}
} else if (isResizing) {
@@ -126,15 +126,17 @@
newHeight = constrained.height;
}
const deltaWidth = newWidth - resizeStart.width;
const deltaHeight = newHeight - resizeStart.height;
const snappedWidth = appState.snap(newWidth);
const snappedHeight = appState.snap(newHeight);
const deltaWidth = snappedWidth - resizeStart.width;
const deltaHeight = snappedHeight - resizeStart.height;
const offset = calculateCenterOffset(resizeStart.corner, deltaWidth, deltaHeight, item.rotation);
appState.updateItem(item.id, {
width: newWidth,
height: newHeight,
x: resizeStart.itemX + offset.x,
y: resizeStart.itemY + offset.y
width: snappedWidth,
height: snappedHeight,
x: appState.snap(resizeStart.itemX + offset.x),
y: appState.snap(resizeStart.itemY + offset.y)
});
} else if (isRotating) {
const el = document.querySelector(`[data-item-id="${item.id}"]`);

View File

@@ -8,14 +8,22 @@
let soundInput: HTMLInputElement;
let videoInput: HTMLInputElement;
function getSpawnPosition() {
return {
x: state.snap(-state.viewport.x / state.viewport.zoom + 400),
y: state.snap(-state.viewport.y / state.viewport.zoom + 300)
};
}
function addTile() {
const id = crypto.randomUUID();
const pos = getSpawnPosition();
state.addItem({
id,
html: '',
css: '',
x: -state.viewport.x / state.viewport.zoom + 400,
y: -state.viewport.y / state.viewport.zoom + 300,
x: pos.x,
y: pos.y,
width: 200,
height: 200,
rotation: 0,
@@ -26,6 +34,7 @@
function addText() {
const id = crypto.randomUUID();
const pos = getSpawnPosition();
state.addItem({
id,
html: '<p>Text</p>',
@@ -34,8 +43,8 @@
font-size: 24px;
font-family: 'Departure Mono', monospace;
}`,
x: -state.viewport.x / state.viewport.zoom + 400,
y: -state.viewport.y / state.viewport.zoom + 300,
x: pos.x,
y: pos.y,
width: 200,
height: 50,
rotation: 0,
@@ -48,6 +57,7 @@
const url = prompt('Enter URL to embed:');
if (!url) return;
const id = crypto.randomUUID();
const pos = getSpawnPosition();
state.addItem({
id,
html: `<iframe src="${url}" frameborder="0" allowfullscreen></iframe>`,
@@ -56,8 +66,8 @@
height: 100%;
border: none;
}`,
x: -state.viewport.x / state.viewport.zoom + 400,
y: -state.viewport.y / state.viewport.zoom + 300,
x: pos.x,
y: pos.y,
width: 640,
height: 480,
rotation: 0,
@@ -91,8 +101,7 @@
const id = crypto.randomUUID();
const assetId = crypto.randomUUID();
const url = URL.createObjectURL(file);
const x = -state.viewport.x / state.viewport.zoom + 400;
const y = -state.viewport.y / state.viewport.zoom + 300;
const pos = getSpawnPosition();
const img = document.createElement('img');
img.onload = () => {
@@ -106,8 +115,8 @@
height: 100%;
object-fit: contain;
}`,
x,
y,
x: pos.x,
y: pos.y,
width: img.naturalWidth,
height: img.naturalHeight,
rotation: 0,
@@ -122,8 +131,7 @@
const id = crypto.randomUUID();
const assetId = crypto.randomUUID();
const url = URL.createObjectURL(file);
const x = -state.viewport.x / state.viewport.zoom + 400;
const y = -state.viewport.y / state.viewport.zoom + 300;
const pos = getSpawnPosition();
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
@@ -133,8 +141,8 @@
css: `audio {
width: 100%;
}`,
x,
y,
x: pos.x,
y: pos.y,
width: 300,
height: 54,
rotation: 0,
@@ -147,8 +155,7 @@
const id = crypto.randomUUID();
const assetId = crypto.randomUUID();
const url = URL.createObjectURL(file);
const x = -state.viewport.x / state.viewport.zoom + 400;
const y = -state.viewport.y / state.viewport.zoom + 300;
const pos = getSpawnPosition();
const video = document.createElement('video');
video.onloadedmetadata = () => {
@@ -161,8 +168,8 @@
width: 100%;
height: 100%;
}`,
x,
y,
x: pos.x,
y: pos.y,
width: video.videoWidth || 640,
height: video.videoHeight || 360,
rotation: 0,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Upload, Download, Paintbrush, Trash2, EyeOff, ZoomIn, Combine, Lock, Unlock } from 'lucide-svelte';
import { Upload, Download, Paintbrush, Trash2, EyeOff, ZoomIn, Combine, Lock, Unlock, Grid3x3 } from 'lucide-svelte';
import { exportBoard, importBoard, mergeBoard } from './io';
import { state } from './state.svelte';
import Palette from './Palette.svelte';
@@ -116,6 +116,13 @@
<button onclick={handleImportClick} title="Import" disabled={state.locked}><Upload size={14} /></button>
<button onclick={handleMergeClick} title="Merge" disabled={state.locked}><Combine size={14} /></button>
<button onclick={handleExport} title="Export"><Download size={14} /></button>
<button
class:active={state.snapToGrid}
onclick={() => state.setSnapToGrid(!state.snapToGrid)}
title={state.snapToGrid ? 'Disable snap to grid' : 'Snap to grid'}
>
<Grid3x3 size={14} />
</button>
<button
class:active={state.locked}
onclick={() => state.setLocked(!state.locked)}

View File

@@ -16,6 +16,7 @@ export async function exportBoard(): Promise<{
items: state.manifest.items.map((item) => ({ ...item })),
sharedCss: state.manifest.sharedCss,
appCss: state.manifest.appCss,
flags: state.manifest.flags ?? {},
};
for (const item of exportManifest.items) {

View File

@@ -80,6 +80,9 @@ function createState() {
let focusedId = $state<string | null>(null);
let clipboard = $state<Omit<Item, 'id'>[]>([]);
let locked = $state<boolean>(false);
let snapToGrid = $state<boolean>(false);
const GRID_SIZE = 20;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let animationId: number | null = null;
@@ -220,6 +223,14 @@ function createState() {
}
}
function setSnapToGrid(value: boolean) {
snapToGrid = value;
}
function snap(value: number): number {
return snapToGrid ? Math.round(value / GRID_SIZE) * GRID_SIZE : value;
}
function focus(id: string | null) {
focusedId = id;
}
@@ -445,6 +456,9 @@ function createState() {
get locked() {
return locked;
},
get snapToGrid() {
return snapToGrid;
},
get maxZIndex() {
return maxZIndex;
},
@@ -461,6 +475,8 @@ function createState() {
edit,
editGlobal,
setLocked,
setSnapToGrid,
snap,
focus,
updateSharedCss,
updateAppCss,