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
11 changes: 11 additions & 0 deletions .changeset/add-snap-to-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@floormap-tools/core": minor
"@floormap-tools/svg": minor
"@floormap-tools/react": minor
---

Add snap-to-grid support for entity drag.

- `@floormap-tools/core` exports a new `snapToGrid(value, gridSize)` utility that rounds a world-space coordinate to the nearest grid multiple
- `@floormap-tools/svg` accepts a `snapToGrid?: number` option on `mountSvgRenderer`; when set, entity positions are snapped on every drag frame
- `@floormap-tools/react` accepts a `snapToGrid?: number` prop on `FloormapCanvas` with the same behavior
169 changes: 169 additions & 0 deletions .claude/skills/issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
name: issue
description: Interactive creator and editor of GitHub issues for the floormap-tools/floormap project. Asks structured questions to build a complete, consistent issue body matching the project's template.
---

You are a GitHub issue creator and editor for the **floormap-tools/floormap** repository. Your job is to collect structured information and produce a well-formatted issue that matches the project's template.

---

## Project reference

**Repo:** `floormap-tools/floormap`
**Owner:** `floormap-tools`
**Packages:** `@floormap-tools/core` · `@floormap-tools/svg` · `@floormap-tools/react` · cross-cutting
**Milestones:**
| # | Title | Scope |
|---|-------|-------|
| 1 | Functional editor | snap, resize, rubber band |
| 2 | Undo/Redo & Persistence | undo/redo, serialization, event sourcing |
| 3 | Extend & Optimize | UI components, incremental renderer, batch ops, rotation |
| 4 | Ecosystem | v0.1.0 release, canvas adapter, vue adapter |

**Issue types:** Feature · Bug · Chore
**Priority:** High · Medium · Low
**Size:** XS (<30 min) · S (<2 h) · M (~4 h) · L (1–2 days) · XL (>2 days)
**Labels:** `enhancement` (features) · `bug` · `documentation` · `chore`

---

## How to use this skill

The user will invoke `/issue` with optional args:
- `/issue` or `/issue create` → create a new issue
- `/issue edit <number>` → edit an existing issue (fetch it first, then update)

---

## Step-by-step flow

### 1 — Determine mode

- If args contain a number → **edit mode**: call `mcp__github__get_issue` to fetch the current issue before asking questions. Pre-fill answers from existing data.
- Otherwise → **create mode**: start fresh.

### 2 — Ask questions (use `AskUserQuestion` tool)

Group questions into at most two rounds. **Round 1** — core identity:

```
Questions to ask in round 1:
1. "What is the issue title?" (header: "Title", no options — open text, so use Other)
Options: pick a sensible default from context if available, otherwise list none (user will type)
2. "What type of issue is this?" (header: "Type")
Options: Feature | Bug | Chore
3. "Which package does this affect?" (header: "Package")
Options: @floormap-tools/core | @floormap-tools/svg | @floormap-tools/react | cross-cutting
4. "Which milestone does this belong to?" (header: "Milestone")
Options: M1 — Functional editor | M2 — Undo/Redo & Persistence | M3 — Extend & Optimize | M4 — Ecosystem | None
```

**Round 2** — sizing and description:

```
Questions to ask in round 2:
1. "What is the priority?" (header: "Priority")
Options: High | Medium | Low
2. "What is the estimated size?" (header: "Size")
Options: XS (<30 min) | S (<2 h) | M (~4 h) | L (1–2 days) | XL (>2 days)
3. "Briefly describe what this issue is about and what needs to change."
(header: "Context", let user type freely via Other)
```

If in **edit mode**, only ask about fields the user seems to want to change. Use judgment based on any hint in the user's request.

### 3 — Generate the issue body

Use the answers to generate a complete body in this template:

```markdown
## Context

[1–2 paragraphs: what this is, why it matters, which use case it serves. For bugs, include reproduction steps.]

---

## What changes

[For Features/Chores: describe the implementation approach, affected module, proposed API if any. Include code snippets when relevant.]

[For Bugs: describe root cause, expected vs actual behavior.]

---

## Affected files

| File | Change |
|------|--------|
| `path/to/file.ts` | Brief description of the change |

---

## Invariants to preserve

- [Key constraint or behavior that must not regress]
- [...]

---

## Implementation checklist

- [ ] [Concrete task]
- [ ] [...]
- [ ] Run `pnpm typecheck && pnpm test && pnpm lint`
```

**Rules for body generation:**
- For **Bugs**: replace "What changes" with "Root cause" and "Reproduction steps". Remove "Invariants" if not applicable.
- For **Chores**: simplify — Context + What changes + Checklist is usually enough.
- Use only facts derived from the answers + project context. Do not invent implementation details.
- All text in English.

### 4 — Create or update the issue

**Create:**
```
mcp__github__create_issue(
owner: "floormap-tools",
repo: "floormap",
title: <title from answers>,
body: <generated body>,
labels: [<label matching type>],
milestone: <milestone number if chosen, else omit>
)
```

**Edit:**
```
mcp__github__update_issue(
owner: "floormap-tools",
repo: "floormap",
issue_number: <number>,
title: <updated title if changed>,
body: <new body>
)
```

### 5 — Post-creation note

After creating or updating the issue, always output this reminder:

```
Issue created/updated: <url>

Project board fields to set manually in GitHub Projects:
• Priority → <answer>
• Size → <answer>
• Package → <answer>

These fields can't be set via the API — open the issue in the project board to apply them.
```

---

## Absolute rules

- Never skip the question rounds — always ask before writing the body.
- Never invent code or file paths not derivable from the project context.
- Never add production dependencies in issues related to `@floormap-tools/core` or `@floormap-tools/svg`.
- If the user provides a partial description in their invocation args, use it to pre-fill context but still confirm via questions.
5 changes: 5 additions & 0 deletions packages/adapters/react/src/FloormapCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface FloormapCanvasProps {
wheelZoomFactor?: number;
pinchZoomFactor?: number;

snapToGrid?: number;

// SVG element
className?: string;
style?: CSSProperties;
Expand All @@ -57,6 +59,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({
clickThresholdPx = 3,
wheelZoomFactor = 0.0015,
pinchZoomFactor = 0.005,
snapToGrid,
className,
style,
}: FloormapCanvasProps) {
Expand Down Expand Up @@ -86,6 +89,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({
clickThresholdPx,
modifierSelect,
pinchZoomFactor,
snapToGrid,
onClickEntity: (id) => onClickEntityRef.current?.(id),
});
}, [
Expand All @@ -99,6 +103,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({
clickThresholdPx,
modifierSelect,
pinchZoomFactor,
snapToGrid,
]);

// Keep viewport.screenSize in sync with the SVG element dimensions
Expand Down
8 changes: 6 additions & 2 deletions packages/adapters/react/src/internal/attachHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { snapToGrid } from '@floormap-tools/core';
import type { FloormapCore, EntityId, Bounds } from '@floormap-tools/core';

export type AttachHandlersOptions = {
Expand All @@ -11,6 +12,7 @@ export type AttachHandlersOptions = {
onClickEntity?: (id: EntityId) => void;
modifierSelect: boolean;
pinchZoomFactor: number;
snapToGrid?: number;
};

export function attachHandlers(
Expand Down Expand Up @@ -123,12 +125,14 @@ export function attachHandlers(
};
const patches = [];
for (const [id, snap] of state.entitySnapshots) {
const rawX = snap.x + worldDelta.x;
const rawY = snap.y + worldDelta.y;
patches.push({
id,
patch: {
bounds: {
x: snap.x + worldDelta.x,
y: snap.y + worldDelta.y,
x: options.snapToGrid ? snapToGrid(rawX, options.snapToGrid) : rawX,
y: options.snapToGrid ? snapToGrid(rawY, options.snapToGrid) : rawY,
width: snap.width,
height: snap.height,
},
Expand Down
29 changes: 28 additions & 1 deletion packages/core/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { clamp, containsPointAABB, expandBounds, unionBounds } from '../utils';
import { clamp, containsPointAABB, expandBounds, unionBounds, snapToGrid } from '../utils';
import { Bounds, Vec2 } from '../types';

describe('clamp', () => {
Expand Down Expand Up @@ -91,4 +91,31 @@ describe('unionBounds', () => {
const result = unionBounds(a, b);
expect(result).toEqual({ x: -10, y: -10, width: 25, height: 25 });
});
});

describe('snapToGrid', () => {
it('snaps value to nearest grid point below', () => {
expect(snapToGrid(42, 40)).toBe(40);
expect(snapToGrid(127, 40)).toBe(120);
});

it('snaps value to nearest grid point above', () => {
expect(snapToGrid(61, 40)).toBe(80);
expect(snapToGrid(140, 40)).toBe(160);
});

it('value already on grid returns unchanged', () => {
expect(snapToGrid(80, 40)).toBe(80);
expect(snapToGrid(0, 40)).toBe(0);
});

it('works with negative values', () => {
expect(snapToGrid(-21, 40)).toBe(-40);
expect(snapToGrid(-85, 40)).toBe(-80);
});

it('works with non-round grid sizes', () => {
expect(snapToGrid(37, 25)).toBe(25);
expect(snapToGrid(38, 25)).toBe(50);
});
});
5 changes: 4 additions & 1 deletion packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ export const unionBounds = (a: Bounds, b: Bounds): Bounds => {
width: Math.max(a.x + a.width, b.x + b.width) - Math.min(a.x, b.x),
height: Math.max(a.y + a.height, b.y + b.height) - Math.min(a.y, b.y)
}
};
};

export const snapToGrid = (value: number, gridSize: number): number =>
Math.round(value / gridSize) * gridSize;
6 changes: 6 additions & 0 deletions packages/examples/js-vanilla/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
<button id="btn-fit" title="Fit all entities into view">Fit to scene</button>
<button id="btn-add-desk" title="Add a new desk to the open space">Add desk</button>
<button id="btn-delete" title="Delete selected entities (Del)">Delete selected</button>
<label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer">
<input type="checkbox" id="snap-toggle" title="Snap entities to grid when dragging" />
Snap to grid
</label>
<input type="number" id="snap-size" value="40" min="5" max="200" step="5"
title="Grid size in world units" style="width:52px;font-size:13px;display:none" />
</div>
<div id="zoom-display">100%</div>
</header>
Expand Down
Loading
Loading