Skip to content

Commit be6cf00

Browse files
authored
Fix shapes jumping to center after drawing and cursor drift under zoom/pan (#35)
* fix(board): stop viewport refit when auto-save echoes board state Drawing any shape made it jump to the screen center ~0.5s later: auto-save published a new currentBoard into the store, BoardCanvas re-fed the re-themed elements array into Plait's Wrapper as value, and Wrapper treats a value/children mismatch as an external board replacement and calls fitViewport. Seed Wrapper's value once per board via a keyed inner component so store echoes for the same board never flow back into the live Plait board. Keep board.children in sync when applying remote collab changes so the same refit cannot fire there. * test(e2e): assert shapes stay put after auto-save settles Regression test for the viewport refit: draws a rectangle, waits past the 500ms auto-save debounce, and asserts the rendered bounding box has not moved. * fix(collab): compute cursor coordinates from viewport origination Plait's viewport has no offsetX/offsetY, so remote cursors drifted under zoom and pan. Replace the offset-based math with origination-based screenToDocument/documentToScreen transforms, resolve the viewport container via getViewportContainerElement instead of the scrolling SVG host, and update tests to the new semantics, including a cross-client zoom/pan/window-size case. * docs: expand code conventions in CLAUDE.md Add top-level rules and concrete guidance for zero-comment code, simplicity over cleverness, naming, and atomic commits.
1 parent f0a1b01 commit be6cf00

15 files changed

Lines changed: 387 additions & 189 deletions

File tree

CLAUDE.md

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44

5+
**Top-level rules — these override defaults:**
6+
7+
- **Zero comments** unless explaining a non-obvious *why*. See [Code conventions](#code-conventions).
8+
- **Simple, readable code over clever code.** Prefer 3 obvious lines over 1 dense line.
9+
- **Atomic commits** after each logical unit of work. See [Commits](#commits--atomic-always).
10+
- **Before creating** a new hook, util, or component — grep for an existing one. Follow existing patterns.
11+
512
## Project Overview
613

714
Thinkix is an AI-native infinite canvas whiteboard built with Next.js 16, React 19, and the Plait board library. The AI agent can read the current board, create new structures, and edit existing elements via a Unix-shell-style command set and a custom DSL. The project uses Bun workspaces for shared code and Liveblocks + Yjs for live collaboration.
@@ -195,8 +202,91 @@ NEXT_PUBLIC_POSTHOG_HOST=...
195202

196203
## Code conventions
197204

198-
- Self-documenting code; no ceremonial comments. Only comment non-obvious *why*.
205+
### Comments — default is zero
206+
207+
Write code that does not need comments. Before adding any comment, apply this test: **does it explain WHY something is done this way, in a way the code itself cannot?** If it describes WHAT the code does, delete it and rename variables/functions until the code speaks for itself.
208+
209+
**Never write comments like these:**
210+
211+
```ts
212+
// Loop through elements and filter selected ones
213+
const selected = elements.filter(isSelected);
214+
215+
// Set the active tool to pen
216+
setActiveTool('pen');
217+
218+
// Check if the board exists
219+
if (!board) return null;
220+
221+
/** Handles the click event */
222+
function handleClick() { ... }
223+
224+
// Initialize state
225+
const [count, setCount] = useState(0);
226+
```
227+
228+
**These are acceptable** (non-obvious why, workaround context, gotcha warnings):
229+
230+
```ts
231+
// Plait re-orders jsonb keys on write; serialize canonically or prompt
232+
// caching breaks downstream. See #142.
233+
const payload = canonicalize(element);
234+
235+
// updatePointerType has no native hook — patched in with-tool-sync.ts
236+
BoardTransforms.updatePointerType = patchedUpdatePointerType;
237+
238+
// Liveblocks presence uses screen coords; we convert to canvas/world
239+
// coords here so cursors render correctly across different viewports.
240+
const worldPos = screenToCanvas(screenPos, viewport);
241+
```
242+
243+
JSDoc is allowed only on exported package APIs (`packages/*`), never on internal functions whose name and signature already tell the story.
244+
245+
### Simplicity over cleverness
246+
247+
Optimize for the next reader, not for elegance or impressiveness.
248+
249+
**Do:**
250+
```ts
251+
const activeUsers = users.filter(user => user.isActive);
252+
const names = activeUsers.map(user => user.name);
253+
```
254+
255+
**Don't:**
256+
```ts
257+
const names = users.reduce((acc, u) => (u.isActive ? [...acc, u.name] : acc), [] as string[]);
258+
```
259+
260+
Rules:
261+
- Prefer a plain `for`/`filter`/`map` chain over a dense one-liner with nested ternaries, `reduce`-to-build-objects, or point-free composition.
262+
- Prefer 3 obvious lines over 1 dense line. Always.
263+
- Prefer duplication over the wrong abstraction — do not invent a helper or generic until the third usage.
264+
- Early returns over nested conditionals: `if (!board) return null` at the top, not an `else` branch at the bottom.
265+
- No nested ternaries. Extract to a variable or use `if`/`else`.
266+
- If you feel the urge to add a comment explaining clever logic — that is the signal to rewrite the logic, not add the comment.
267+
268+
### Naming
269+
270+
- Functions: verbs (`createBoard`, `handleClick`, `resolveProvider`).
271+
- Variables/constants: nouns (`activeUsers`, `boardId`, `GRID_SIZE`).
272+
- Booleans: adjectives/state (`isActive`, `hasPermission`, `shouldRender`).
273+
- Names should be specific enough to be grep-able. Avoid single letters except in tight lambda args (`items.map(x => x.id)` is fine; `function f(x, y, z)` is not).
274+
275+
### Commits — atomic, always
276+
277+
Commit after each logical unit of work. Do not batch unrelated changes.
278+
279+
- One logical change per commit. Refactors, behavior changes, and test additions are separate commits even within the same task.
280+
- If a fix requires a preparatory refactor, commit the refactor first (with no behavior change), then commit the fix.
281+
- Conventional commit format: `fix(collab): …`, `feat(agent): …`, `refactor(board): …`, `test: …`, `chore: …`.
282+
- Never mix generated files (`parser.js`) with hand-written changes unless the generation is caused by that change — and say so in the commit body.
283+
- Commit messages: subject says WHAT changed, body (when needed) says WHY.
284+
285+
### General
286+
199287
- Feature modules expose a small `index.ts` barrel. Follow existing patterns instead of introducing new ones.
288+
- Before creating a new hook, util, or component, grep the codebase for an existing one that does the same thing.
200289
- Types flow from `@thinkix/shared` (no JSX) and `shared/constants` (JSX allowed).
201-
- Prefer early returns (`if (!board) return null`).
202-
- Keep PRs focused; if you change the DSL grammar, regenerate the parser before opening the PR (see `CONTRIBUTING.md`).
290+
- `const`-first. Only use `let` when reassignment is genuinely needed.
291+
- Single-responsibility functions. If a function does two things, split it.
292+
- Keep PRs focused; if you change the DSL grammar, regenerate the parser before opening the PR (see `CONTRIBUTING.md`).

features/board/components/BoardCanvas.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,11 @@ function RemoteSyncHandler({
9393

9494
useEffect(() => {
9595
if (!syncBusContext) return;
96-
96+
9797
const unsubscribe = syncBusContext.syncBus.subscribeToRemoteChanges((elements: BoardElement[]) => {
9898
const normalized = normalizeElements(elements);
9999
onElementsChange(normalized);
100+
board.children = normalized;
100101
listRender.update(normalized, {
101102
board: board,
102103
parent: board,
@@ -110,7 +111,11 @@ function RemoteSyncHandler({
110111
return null;
111112
}
112113

113-
export function BoardCanvas({
114+
export function BoardCanvas(props: BoardCanvasProps) {
115+
return <BoardCanvasInner key={props.boardData?.id ?? 'default'} {...props} />;
116+
}
117+
118+
function BoardCanvasInner({
114119
initialValue,
115120
className,
116121
children,
@@ -131,25 +136,19 @@ export function BoardCanvas({
131136
[boardThemeMode],
132137
);
133138

134-
const initialElements = useMemo(() => {
135-
return syncElementsForBoardTheme(
139+
const [value, setValue] = useState<PlaitElement[]>(() =>
140+
syncElementsForBoardTheme(
136141
boardData?.elements ?? resolvedInitialValue,
137142
getBoardThemeMode(boardData?.theme ?? DEFAULT_THEME),
138-
);
139-
}, [boardData?.elements, boardData?.theme, resolvedInitialValue]);
140-
141-
const [value, setValue] = useState<PlaitElement[]>(initialElements);
143+
),
144+
);
142145

143146
useEffect(() => {
144147
if (boardData) {
145148
setCurrentBoardId(boardData.id);
146149
}
147150
}, [boardData, setCurrentBoardId]);
148151

149-
useEffect(() => {
150-
setValue(initialElements);
151-
}, [initialElements]);
152-
153152
useAutoSave({
154153
board: board,
155154
enabled: !!boardData,
@@ -195,7 +194,6 @@ export function BoardCanvas({
195194
return (
196195
<div className={`relative w-full h-full board-wrapper ${className || ''} ${boardCursorClass}`}>
197196
<Wrapper
198-
key={boardData?.id ?? 'default'}
199197
value={value}
200198
options={DEFAULT_BOARD_OPTIONS}
201199
plugins={plugins}

packages/collaboration/src/components/cursor-overlay.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PlaitBoard } from '@plait/core';
55
import type { CursorState } from '../cursor-manager';
66
import { getVisibleCursors, getActiveCursors } from '../cursor-manager';
77
import { useViewport } from '../hooks/use-viewport';
8+
import { documentToScreen, getViewportContainerElement } from '../utils';
89

910
interface CursorOverlayProps {
1011
cursors: Map<string, CursorState>;
@@ -123,14 +124,12 @@ export function CursorOverlay({
123124
const viewport = useViewport(board);
124125
const [containerRect, setContainerRect] = useState<DOMRect | null>(null);
125126

126-
useEffect(() => {
127+
useEffect(() => {
127128
const getContainer = (): Element | null => {
128129
if (containerRef?.current) {
129130
return containerRef.current;
130131
}
131-
// Fallback to DOM query for backward compatibility
132-
const container = document.querySelector('.plait-board-container') as HTMLElement | null;
133-
return container?.querySelector('svg') || container;
132+
return getViewportContainerElement(board);
134133
};
135134

136135
const update = () => {
@@ -153,7 +152,7 @@ export function CursorOverlay({
153152
window.removeEventListener('resize', update);
154153
observer.disconnect();
155154
};
156-
}, [containerRef]);
155+
}, [containerRef, board]);
157156

158157
const renderableCursors = useMemo((): RenderableCursor[] => {
159158
if (!containerRect) return [];
@@ -172,10 +171,11 @@ export function CursorOverlay({
172171
visible.forEach((cursor, id) => {
173172
if (count >= maxCursors) return;
174173

174+
const { x, y } = documentToScreen(cursor.documentX, cursor.documentY, viewport);
175175
result.push({
176176
id,
177-
screenX: cursor.documentX * viewport.zoom + viewport.offsetX + containerRect.left,
178-
screenY: cursor.documentY * viewport.zoom + viewport.offsetY + containerRect.top,
177+
screenX: x + containerRect.left,
178+
screenY: y + containerRect.top,
179179
userName: cursor.userName,
180180
userColor: cursor.userColor,
181181
userAvatar: cursor.userAvatar,

packages/collaboration/src/cursor-manager.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Cursor, CollaborationUser } from './types';
2-
import type { Viewport } from './utils/viewport';
2+
import { documentToScreen, screenToDocument, type Viewport } from './utils/viewport';
33

44
export interface CursorState {
55
userId: string;
@@ -57,9 +57,8 @@ export class CursorManager {
5757
viewport: Viewport,
5858
pointerType: 'mouse' | 'pen' | 'touch' = 'mouse'
5959
): void {
60-
const documentX = (clientX - containerRect.left - viewport.offsetX) / viewport.zoom;
61-
const documentY = (clientY - containerRect.top - viewport.offsetY) / viewport.zoom;
62-
60+
const { x: documentX, y: documentY } = screenToDocument(clientX, clientY, containerRect, viewport);
61+
6362
this.pendingUpdate = { x: documentX, y: documentY };
6463
this.pendingPointer = pointerType;
6564

@@ -150,8 +149,7 @@ export class CursorManager {
150149
}
151150

152151
getCursorScreenState(cursor: CursorState, viewport: Viewport): CursorState & { screenX: number; screenY: number } {
153-
const screenX = cursor.documentX * viewport.zoom + viewport.offsetX;
154-
const screenY = cursor.documentY * viewport.zoom + viewport.offsetY;
152+
const { x: screenX, y: screenY } = documentToScreen(cursor.documentX, cursor.documentY, viewport);
155153
return { ...cursor, screenX, screenY };
156154
}
157155

@@ -242,9 +240,8 @@ export function getVisibleCursors(
242240
const margin = 100;
243241

244242
for (const [id, cursor] of cursors) {
245-
const screenX = cursor.documentX * viewport.zoom + viewport.offsetX;
246-
const screenY = cursor.documentY * viewport.zoom + viewport.offsetY;
247-
243+
const { x: screenX, y: screenY } = documentToScreen(cursor.documentX, cursor.documentY, viewport);
244+
248245
if (screenX < -margin || screenX > screenWidth + margin) continue;
249246
if (screenY < -margin || screenY > screenHeight + margin) continue;
250247

packages/collaboration/src/hooks/use-cursor-tracking.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import {
99
createCursorManager,
1010
} from '../cursor-manager';
1111
import type { Cursor, CollaborationUser } from '../types';
12-
import { getViewport, type Viewport } from '../utils';
12+
import {
13+
getViewport,
14+
getViewportContainerElement,
15+
documentToScreen,
16+
type Viewport,
17+
} from '../utils';
1318

1419
export interface UseCursorTrackingOptions {
1520
board: PlaitBoard | null;
@@ -72,11 +77,13 @@ export function useCursorTracking({
7277
const handlePointerMove = (e: PointerEvent) => {
7378
const target = e.target;
7479
if (!(target instanceof Element)) return;
75-
76-
const svg = target.closest('svg');
77-
if (!svg || !boardRef.current) return;
7880

79-
const rect = svg.getBoundingClientRect();
81+
if (!target.closest('.plait-board-container') || !boardRef.current) return;
82+
83+
const container = getViewportContainerElement(boardRef.current);
84+
if (!container) return;
85+
86+
const rect = container.getBoundingClientRect();
8087
const viewport = getViewport(boardRef.current);
8188

8289
if (rafId) {
@@ -156,9 +163,6 @@ export function useCursorScreenState(
156163
cursor: CursorState,
157164
viewport: Viewport
158165
): CursorState & { screenX: number; screenY: number } {
159-
return {
160-
...cursor,
161-
screenX: cursor.documentX * viewport.zoom + viewport.offsetX,
162-
screenY: cursor.documentY * viewport.zoom + viewport.offsetY,
163-
};
166+
const { x: screenX, y: screenY } = documentToScreen(cursor.documentX, cursor.documentY, viewport);
167+
return { ...cursor, screenX, screenY };
164168
}

packages/collaboration/src/hooks/use-sync.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useRef, useCallback } from 'react';
44
import { useStorage, useMutation, useSelf } from '@liveblocks/react/suspense';
55
import type { PlaitElement, PlaitBoard } from '@plait/core';
66
import { usePresence } from '../providers/liveblocks/hooks';
7+
import { getViewport, getViewportContainerElement, screenToDocument } from '../utils';
78

89
interface UseBoardSyncOptions {
910
board: PlaitBoard | null;
@@ -70,12 +71,17 @@ export function useBoardCursorTracking(board: PlaitBoard | null, enabled: boolea
7071
const handlePointerMove = (e: Event) => {
7172
const pointerEvent = e as PointerEvent;
7273
const target = pointerEvent.target as SVGElement | HTMLElement;
73-
const svg = target.closest('svg');
74-
if (!svg) return;
74+
if (!target.closest('.plait-board-container')) return;
7575

76-
const rect = svg.getBoundingClientRect();
77-
const x = (pointerEvent.clientX - rect.left - board.viewport.offsetX) / board.viewport.zoom;
78-
const y = (pointerEvent.clientY - rect.top - board.viewport.offsetY) / board.viewport.zoom;
76+
const container = getViewportContainerElement(board);
77+
if (!container) return;
78+
79+
const { x, y } = screenToDocument(
80+
pointerEvent.clientX,
81+
pointerEvent.clientY,
82+
container.getBoundingClientRect(),
83+
getViewport(board)
84+
);
7985

8086
updateCursor({ x, y, pointer: pointerEvent.pointerType as 'mouse' | 'pen' | 'touch' });
8187
};

packages/collaboration/src/hooks/use-viewport.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { useEffect, useState, useRef } from 'react';
44
import type { PlaitBoard } from '@plait/core';
55
import { getViewport, type Viewport } from '../utils';
66

7-
const DEFAULT_VIEWPORT: Viewport = { zoom: 1, offsetX: 0, offsetY: 0 };
7+
const DEFAULT_VIEWPORT: Viewport = { zoom: 1, originationX: 0, originationY: 0 };
88

99
function viewportsEqual(a: Viewport, b: Viewport): boolean {
10-
return a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY;
10+
return (
11+
a.zoom === b.zoom &&
12+
a.originationX === b.originationX &&
13+
a.originationY === b.originationY
14+
);
1115
}
1216

1317
export function useViewport(board: PlaitBoard | null): Viewport {
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
1-
export { getViewport, screenToDocument, documentToScreen, type Viewport } from './viewport';
1+
export {
2+
getViewport,
3+
screenToDocument,
4+
documentToScreen,
5+
getViewportContainerElement,
6+
type Viewport,
7+
} from './viewport';
28
export { debounce, throttle } from './timing';

0 commit comments

Comments
 (0)