From 135668bace189183151719ee5292801b4e33f6e4 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 19 Jul 2026 16:51:52 -0500 Subject: [PATCH] fix: history panel colors --- apps/web/src/lib/tests/Canvas.svelte.test.ts | 4 +- apps/web/src/routes/docs/+page.svelte | 33 +++-- packages/core/src/tools.test.ts | 49 +++++++- packages/core/src/tools/select.ts | 17 ++- packages/renderer/src/index.ts | 46 +++---- packages/renderer/tests/index.test.ts | 6 +- .../ui/src/lib/editor/canvas/Canvas.svelte | 5 +- .../lib/editor/canvas/canvas-store.svelte.ts | 18 ++- .../editor/components/HistoryViewer.svelte | 116 ++++++++++++------ 9 files changed, 207 insertions(+), 87 deletions(-) diff --git a/apps/web/src/lib/tests/Canvas.svelte.test.ts b/apps/web/src/lib/tests/Canvas.svelte.test.ts index 40119f4..2183c16 100644 --- a/apps/web/src/lib/tests/Canvas.svelte.test.ts +++ b/apps/web/src/lib/tests/Canvas.svelte.test.ts @@ -170,7 +170,7 @@ describe('Canvas component', () => { const { container } = renderCanvas(); const canvas = container.querySelector('canvas') as HTMLCanvasElement; - expect(window.getComputedStyle(canvas).cursor).toBe('default'); + expect(window.getComputedStyle(canvas).cursor).toContain('data:image/svg+xml'); window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space' })); await vi.waitFor(() => expect(canvas.style.cursor).toBe('grab')); @@ -199,6 +199,6 @@ describe('Canvas component', () => { expect(canvas.style.cursor).toBe('grab'); window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space' })); - expect(canvas.style.cursor).toBe('default'); + expect(canvas.style.cursor).toBe(''); }); }); diff --git a/apps/web/src/routes/docs/+page.svelte b/apps/web/src/routes/docs/+page.svelte index 7944fe6..7ba5ded 100644 --- a/apps/web/src/routes/docs/+page.svelte +++ b/apps/web/src/routes/docs/+page.svelte @@ -79,15 +79,19 @@

Draw or automate.

- {#each features as feature (feature.title)} - - + {#each features as feature, index (feature.title)} + +

{feature.title}

{feature.description}

Read the guide
+ {/each}
@@ -379,18 +383,26 @@ 0 12px 32px color-mix(in srgb, var(--ink-shadow-color) 8%, transparent), inset 0 0 0 1px color-mix(in srgb, var(--ink-border) 30%, transparent); text-decoration: none; - transition-property: background-color, box-shadow, translate; - transition-duration: 180ms; + transform: translateY(0) rotate(0deg); + transform-origin: 50% 85%; + transition-property: background-color, box-shadow, color, transform, scale; + transition-duration: 220ms; transition-timing-function: var(--ink-ease-out); } - .feature-card:hover { + .feature-card:hover, + .feature-card:focus-visible { color: var(--ink-text); - background: color-mix(in srgb, var(--ink-canvas) 75%, var(--ink-accent) 7%); + background: color-mix(in srgb, var(--ink-canvas) 88%, var(--ink-accent) 12%); box-shadow: - 0 18px 40px color-mix(in srgb, var(--ink-shadow-color) 14%, transparent), - inset 0 0 0 1px color-mix(in srgb, var(--ink-accent) 45%, transparent); - translate: 0 -5px; + 7px 10px 0 color-mix(in srgb, var(--ink-shadow-color) 88%, transparent), + 0 24px 44px color-mix(in srgb, var(--ink-shadow-color) 18%, transparent), + inset 0 0 0 2px color-mix(in srgb, var(--ink-border-strong) 78%, transparent); + transform: translate(-2px, -8px) rotate(var(--card-tilt)); + } + + .feature-card:active { + scale: 0.96; } .feature-icon { @@ -501,6 +513,7 @@ .secondary-action:hover, .feature-card:hover { translate: 0; + transform: none; } } diff --git a/packages/core/src/tools.test.ts b/packages/core/src/tools.test.ts index a1f073c..585daca 100644 --- a/packages/core/src/tools.test.ts +++ b/packages/core/src/tools.test.ts @@ -261,6 +261,46 @@ describe("SelectTool", () => { expect(movedShape2.y).toBe(100); }); + it("snaps the lead shape origin and preserves selection offsets", () => { + const snappingTool = new SelectTool(undefined, (point) => ({ + x: Math.round(point.x / 25) * 25, + y: Math.round(point.y / 25) * 25, + })); + const offGridShape1 = { ...shape1, x: 7, y: 8 }; + const offGridShape2 = { ...shape2, x: 207, y: 8 }; + const state = { + ...initialState, + doc: { + ...initialState.doc, + shapes: { ...initialState.doc.shapes, [shape1.id]: offGridShape1, [shape2.id]: offGridShape2 }, + }, + ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] }, + }; + + let result = snappingTool.onAction( + state, + Action.pointerDown( + { x: 20, y: 20 }, + { x: 20, y: 20 }, + 0, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + result = snappingTool.onAction( + result, + Action.pointerMove( + { x: 50, y: 50 }, + { x: 50, y: 50 }, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + expect(result.doc.shapes[shape1.id]).toMatchObject({ x: 25, y: 50 }); + expect(result.doc.shapes[shape2.id]).toMatchObject({ x: 225, y: 50 }); + }); + it("should reset drag state on pointer up", () => { const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; @@ -387,11 +427,10 @@ describe("SelectTool", () => { expect(result.ui.selectionIds).toEqual([]); }); - it.each([{ description: "Delete key removes selected shapes", key: "Delete", code: "Delete" }, { - description: "Backspace key removes selected shapes", - key: "Backspace", - code: "Backspace", - }])("should handle $description", ({ key, code }) => { + it.each([ + { description: "Delete key removes selected shapes", key: "Delete", code: "Delete" }, + { description: "Backspace key removes selected shapes", key: "Backspace", code: "Backspace" }, + ])("should handle $description", ({ key, code }) => { const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; const result = tool.onAction(state, Action.keyDown(key, code, Modifiers.create())); diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index 17c3953..5223c31 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -63,9 +63,17 @@ export class SelectTool implements Tool { readonly id: ToolId = 'select'; private toolState: SelectToolState; private readonly marqueeListener?: (bounds: Box2 | null) => void; + private readonly snapPosition?: (point: Vec2) => Vec2; - constructor(onMarqueeChange?: (bounds: Box2 | null) => void) { + /** + * Creates a selection tool. + * + * The optional position snapper aligns the lead shape during a drag. Other + * selected shapes keep their original offset from that shape. + */ + constructor(onMarqueeChange?: (bounds: Box2 | null) => void, snapPosition?: (point: Vec2) => Vec2) { this.marqueeListener = onMarqueeChange; + this.snapPosition = snapPosition; this.toolState = { isDragging: false, dragStartWorld: null, @@ -356,7 +364,12 @@ export class SelectTool implements Tool { private handleDragMove(state: EditorState, action: Action): EditorState { if (action.type !== 'pointer-move' || !this.toolState.dragStartWorld) return state; - const delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld); + let delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld); + const leadPosition = this.toolState.initialShapePositions.values().next().value; + if (leadPosition && this.snapPosition) { + const candidate = Vec2Ops.add(leadPosition, delta); + delta = Vec2Ops.sub(this.snapPosition(candidate), leadPosition); + } const newShapes = { ...state.doc.shapes }; diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index 7529f90..d423923 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -311,18 +311,19 @@ function applyCameraTransform(context: CanvasRenderingContext2D, camera: Camera, const DEFAULT_GRID_SIZE = 25; /** - * Draw grid/graph paper background + * Draw a dot-grid background at the same world-space positions used by snapping. * - * Draws a subtle grid that helps with spatial awareness and alignment. - * The grid adapts to zoom level to maintain visual clarity. + * At distant zoom levels, the renderer skips intermediate dots so the grid + * stays legible without changing the snapping interval. */ function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport, snapSettings?: SnapSettings) { if (snapSettings && !snapSettings.gridEnabled) { return; } const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE; - const minorGridColor = 'rgba(128, 128, 128, 0.1)'; - const majorGridColor = 'rgba(128, 128, 128, 0.2)'; + const minimumScreenSpacing = 10; + const stepMultiplier = Math.max(1, Math.ceil(minimumScreenSpacing / (gridSize * camera.zoom))); + const visibleGridSize = gridSize * stepMultiplier; const topLeft = { x: camera.x - viewport.width / (2 * camera.zoom), @@ -333,30 +334,21 @@ function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: V y: camera.y + viewport.height / (2 * camera.zoom) }; - const startX = Math.floor(topLeft.x / gridSize) * gridSize; - const endX = Math.ceil(bottomRight.x / gridSize) * gridSize; - const startY = Math.floor(topLeft.y / gridSize) * gridSize; - const endY = Math.ceil(bottomRight.y / gridSize) * gridSize; + const startX = Math.floor(topLeft.x / visibleGridSize) * visibleGridSize; + const endX = Math.ceil(bottomRight.x / visibleGridSize) * visibleGridSize; + const startY = Math.floor(topLeft.y / visibleGridSize) * visibleGridSize; + const endY = Math.ceil(bottomRight.y / visibleGridSize) * visibleGridSize; + const dotRadius = 1 / camera.zoom; - context.lineWidth = 1 / camera.zoom; - - for (let x = startX; x <= endX; x += gridSize) { - const isMajor = x % (gridSize * 5) === 0; - context.strokeStyle = isMajor ? majorGridColor : minorGridColor; - context.beginPath(); - context.moveTo(x, startY); - context.lineTo(x, endY); - context.stroke(); - } - - for (let y = startY; y <= endY; y += gridSize) { - const isMajor = y % (gridSize * 5) === 0; - context.strokeStyle = isMajor ? majorGridColor : minorGridColor; - context.beginPath(); - context.moveTo(startX, y); - context.lineTo(endX, y); - context.stroke(); + context.fillStyle = 'rgba(128, 128, 128, 0.24)'; + context.beginPath(); + for (let x = startX; x <= endX; x += visibleGridSize) { + for (let y = startY; y <= endY; y += visibleGridSize) { + context.moveTo(x + dotRadius, y); + context.arc(x, y, dotRadius, 0, Math.PI * 2); + } } + context.fill(); } function drawSnapGuides( diff --git a/packages/renderer/tests/index.test.ts b/packages/renderer/tests/index.test.ts index 460687b..2035e17 100644 --- a/packages/renderer/tests/index.test.ts +++ b/packages/renderer/tests/index.test.ts @@ -179,7 +179,7 @@ describe('Renderer', () => { const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0); expect(alphaWrites).toContain(0.4); - expect(context.fill).toHaveBeenCalledTimes(1); + expect(context.fill).toHaveBeenCalledTimes(2); expect(context.save).toHaveBeenCalledTimes(vi.mocked(context.restore).mock.calls.length); renderer.dispose(); }); @@ -233,7 +233,7 @@ describe('Renderer', () => { expect(alphaWrites).toContain(0.2); expect(alphaWrites).toContain(0.4); expect(strokeAlphas).toContain(0.4); - expect(context.fill).toHaveBeenCalledOnce(); + expect(context.fill).toHaveBeenCalledTimes(2); renderer.dispose(); }); @@ -291,7 +291,7 @@ describe('Renderer', () => { const renderer = createRenderer(canvas, store); scheduledFrames.shift()?.(0); - expect(context.fill).toHaveBeenCalledTimes(1); + expect(context.fill).toHaveBeenCalledTimes(2); expect(context.strokeRect).toHaveBeenCalledWith(0, 0, 50, 50); expect(context.translate).toHaveBeenCalledWith(10_000, 10_000); renderer.dispose(); diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index 21540e4..00f50f9 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -374,7 +374,10 @@ height: 100%; display: block; touch-action: none; - cursor: default; + cursor: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Cpath d='M4 2.75 22.2 16.1l-8.05 1.15 4.2 7.25-4.2 2.4-4.05-7.2-5.35 6.1z' fill='%23171928' stroke='%2388edc4' stroke-width='2.25' stroke-linejoin='round'/%3E%3C/svg%3E") + 4 3, + default; } .proposal-ghost-layer { diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index 7aae204..35c6e94 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -156,7 +156,7 @@ export function createCanvasController( { hover: handleState.hover, active: handleState.active }, runtime.getInteractionState().pointerDown ); - canvas.style.cursor = cursor; + canvas.style.cursor = cursor === 'default' ? '' : cursor; } function setActiveBoardId(boardId: string) { @@ -180,7 +180,21 @@ export function createCanvasController( const handleMarqueeChange = (bounds: Box2 | null) => void updateMarquee(bounds); - const selectTool = new SelectTool(handleMarqueeChange); + const selectTool = new SelectTool(handleMarqueeChange, (point) => { + const snap = snapStore.get(); + if ( + !snap.snapEnabled || + !snap.gridEnabled || + !Number.isFinite(snap.gridSize) || + snap.gridSize <= 0 + ) { + return point; + } + return { + x: Math.round(point.x / snap.gridSize) * snap.gridSize, + y: Math.round(point.y / snap.gridSize) * snap.gridSize + }; + }); const rectTool = new RectTool(); const ellipseTool = new EllipseTool(); const lineTool = new LineTool(); diff --git a/packages/ui/src/lib/editor/components/HistoryViewer.svelte b/packages/ui/src/lib/editor/components/HistoryViewer.svelte index e0566b4..e609e83 100644 --- a/packages/ui/src/lib/editor/components/HistoryViewer.svelte +++ b/packages/ui/src/lib/editor/components/HistoryViewer.svelte @@ -92,6 +92,7 @@ -- 2.51.2