Skip to content

add canvases#33

Merged
ntietje1 merged 37 commits intodevfrom
xy-flow
Apr 6, 2026
Merged

add canvases#33
ntietje1 merged 37 commits intodevfrom
xy-flow

Conversation

@ntietje1
Copy link
Copy Markdown
Owner

@ntietje1 ntietje1 commented Apr 6, 2026

Summary by CodeRabbit

  • New Features

    • Canvas: collaborative whiteboard added — viewer, toolbar, drawing/erase/lasso/rectangle tools, nodes (text, sticky, stroke, rectangle, embeds), minimap, color/opacity/size controls, and canvas cards.
    • Previews: claimable preview generation, preview image assignment, PDF preview generation, and thumbnail previews for notes/files/maps.
  • Bug Fixes

    • Safer preview cleanup and replacement to avoid orphaned storage after deletion.
  • Collaboration

    • Real-time Yjs sync, remote cursors and selections, and canvas-aware persistence/awareness.
  • Tests

    • Added and updated test suites for canvases, previews, and persistence.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds a collaborative Canvas sidebar item type with Convex schema, create/update mutations, Yjs document support (notes+canvases as Yjs documents), preview claiming/uploading and metadata fields on sidebar items, a full React Flow-based canvas UI (nodes, hooks, stores, utilities), and test/factory updates; removes the old note block update API in favor of Yjs-driven persistence.

Changes

Cohort / File(s) Summary
Canvas schema & API
convex/canvases/baseSchema.ts, convex/canvases/types.ts, convex/canvases/schema.ts, convex/canvases/mutations.ts, convex/canvases/functions/...
Add canvases table, validators, types, create/update functions and mutations, slug/indexes, enhancers that initialize/persist Yjs state.
Sidebar schema/types & preview fields
convex/sidebarItems/schema/baseFields.ts, convex/sidebarItems/schema/baseValidators.ts, convex/sidebarItems/schema/schema.ts, convex/sidebarItems/schema/contentSchema.ts, convex/sidebarItems/types/..., convex/_test/factories.helper.ts, src/test/factories/sidebar-item-factory.ts
Add canvases to validators/unions; add preview DB fields (previewStorageId, previewLockedUntil, previewUpdatedAt) and previewUrl validator/typed field; update test factories to include preview defaults.
Yjs document model & plumbing
convex/yjsSync/schema.ts, convex/yjsSync/functions/types.ts, convex/yjsSync/functions/*, convex/yjsSync/mutations.ts, convex/yjsSync/queries.ts, convex/yjsSync/internalMutations.ts
Generalize documentId to YjsDocumentId (union notes
Preview backend + client
convex/sidebarItems/functions/claimPreviewGeneration.ts, convex/sidebarItems/functions/setPreviewImage.ts, convex/sidebarItems/mutations.ts, src/features/previews/hooks/*, src/features/previews/utils/*, src/features/previews/utils/__tests__/*
Add claim/set preview mutations (locks, cooldown), client hooks/utilities to capture element/PDF previews, upload blobs and set preview IDs; tests for upload and cleanup.
Sidebar handling & hard-delete
convex/sidebarItems/functions/applyToDependents.ts, convex/sidebarItems/functions/hardDeleteItem.ts, convex/sidebarItems/functions/enhanceSidebarItem.ts, convex/sidebarItems/functions/fetchCampaignSidebarItems.ts, convex/sidebarItems/functions/getSidebarItemById.ts, convex/sidebarItems/functions/getSidebarItemBySlug.ts, convex/sidebarItems/functions/defaultItemName.ts
Integrate canvases across fetch/enhance/get flows; compute previewUrl from previewStorageId; hard delete removes preview blobs (with map dedupe logic); add default canvas name.
Canvas UI, nodes, hooks & utils
src/features/canvas/components/..., src/features/canvas/hooks/..., src/features/canvas/stores/..., src/features/canvas/utils/...
Large client additions: CanvasViewer, React Flow node types (text/sticky/rectangle/stroke/embed), embedding components, drawing/eraser/lasso/rectangle/history/awareness hooks, selection/resize wrappers, file upload/drop integration, stroke geometry utilities, preview generation hook.
Editor & note persistence refactor
src/features/editor/components/note-content.tsx, src/features/editor/components/note-view.tsx, src/features/editor/hooks/useNoteYjsCollaboration.ts, src/features/editor/hooks/useConvexYjsCollaboration.ts
Refactor note editor into NoteContent and move periodic persistence into note-specific hook (uses persistNoteBlocks); update Yjs collaboration to use YjsDocumentId.
Embedding, cards & viewer updates
src/features/editor/components/viewer/folder/canvas-card.tsx, src/features/editor/components/viewer/folder/item-card.tsx, other viewer/card files, src/features/editor/components/viewer/note/note-editor.tsx, src/features/editor/components/viewer/sidebar-item-editor.tsx
Add CanvasCard and render path for canvases; viewer cards show previewUrl thumbnails; note viewer/editor now uses NoteContent.
Files/maps preview propagation
convex/files/functions/*, convex/gameMaps/functions/*, convex/gameMaps/__tests__/*
Files and maps now derive and persist previewStorageId/timestamps on create/update; tests added/updated to assert preview propagation and preservation.
DND, context menu & sidebar hooks
src/features/dnd/utils/dnd-registry.ts, src/features/dnd/hooks/*, src/features/context-menu/*, src/features/sidebar/hooks/*, src/features/sidebar/utils/sidebar-item-utils.ts
Canvas drop zone and embed action; canvas creation action in context menu; create/edit hooks extended to call canvas Convex mutations; utilities updated to recognize canvases.
Tests, factories & removed note block API
convex/notes/functions/updateNoteContent.ts (removed), convex/notes/mutations.ts (persistNoteBlocks added), various test deletions/updates (convex/notes/__tests__/*, yjs/provider tests, editor hook tests)
Removed old updateNoteContent API in favor of Yjs-driven persistNoteBlocks; tests adjusted/removed accordingly; factories updated.
Build/config & deps & styles
convex/schema.ts, package.json, eslint.config.js, src/styles/app.css, e2e/editor-stress.spec.ts
Register canvases table in schema; add deps (@xyflow/react, html-to-image, perfect-freehand, @convex-dev/eslint-plugin); ESLint plugin configured; CSS theme/React Flow tweaks; minor test loop refactor.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as Canvas UI
    participant Convex as Convex Backend
    participant Yjs as Yjs storage
    participant Storage as Blob Storage

    User->>UI: createCanvas(name,parent)
    UI->>Convex: api.canvases.mutations.createCanvas(args)
    Convex->>Convex: validate, generate slug, insert canvases row
    Convex->>Yjs: createYjsDocument({ documentId, initialState })
    Yjs-->>Convex: persist snapshot row
    Convex-->>UI: { canvasId, slug }
    UI->>Yjs: start Yjs collaboration (pushUpdate/pushAwareness)
    UI->>Convex: claimPreviewGeneration(canvasId)
    Convex->>Convex: check locks/cooldown → patch previewLockedUntil if granted
    UI->>Storage: upload preview blob (via upload URL)
    UI->>Convex: setPreviewImage(canvasId, previewStorageId)
    Convex->>Convex: patch canvas preview fields
Loading
sequenceDiagram
    participant Local as Local client
    participant Provider as ConvexYjsProvider
    participant Convex as Backend (yjsUpdates/yjsAwareness)
    participant Remote as Remote client

    Local->>Provider: local Yjs update
    Provider->>Convex: pushUpdate(documentId, update)
    Convex->>Convex: store update row
    Remote->>Convex: getUpdates(documentId, afterSeq)
    Convex-->>Remote: return updates
    Remote->>Remote: apply updates to local Y.Doc
    Local->>Provider: awareness change
    Provider->>Convex: pushAwareness(documentId, state)
    Convex->>Convex: store awareness row
    Remote->>Convex: getAwareness(documentId)
    Convex-->>Remote: return awareness states
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Note yjs #30 — Continues/refactors Yjs/document model and schema to include canvases and update provider/document flows; overlaps YjsDocumentId and related backend changes.
  • Testing #26 — Touches test factory helpers and sidebar item factories; directly related to preview-field additions in test helpers.

Poem

🐇 I hopped in with a brush so bright,
New canvases woke in shared daylight.
Strokes and previews, locks set right,
Blobs uploaded, slugs take flight—
Hop, create, collaborate tonight! 🎨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xy-flow

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 6, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
wizard-archive b8dbdb9 Commit Preview URL

Branch Preview URL
Apr 06 2026, 10:01 PM

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 54

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
convex/sidebarItems/functions/hardDeleteItem.ts (1)

41-43: ⚠️ Potential issue | 🟠 Major

Canvas Yjs documents are not cleaned up on hard delete.
With canvases now using Yjs, restricting cleanup to notes leaves orphaned yjsUpdates/yjsAwareness rows after canvas deletion.

Proposed fix
-  if (item.type === SIDEBAR_ITEM_TYPES.notes) {
+  if (
+    item.type === SIDEBAR_ITEM_TYPES.notes ||
+    item.type === SIDEBAR_ITEM_TYPES.canvases
+  ) {
     await deleteYjsDocument(ctx, item._id)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/sidebarItems/functions/hardDeleteItem.ts` around lines 41 - 43, The
current hardDeleteItem logic only calls deleteYjsDocument for
SIDEBAR_ITEM_TYPES.notes, leaving Yjs rows orphaned for canvases; update the
conditional in hardDeleteItem (the block that currently checks item.type ===
SIDEBAR_ITEM_TYPES.notes) to also call deleteYjsDocument for canvas items (e.g.,
item.type === SIDEBAR_ITEM_TYPES.canvas) — either expand the condition to
include canvas or use a set of types (notes | canvas) and invoke
deleteYjsDocument(ctx, item._id) for those types so yjsUpdates/yjsAwareness rows
are removed on canvas hard-delete.
src/features/editor/providers/convex-yjs-provider.ts (2)

201-231: ⚠️ Potential issue | 🟠 Major

Pending updates can be lost during destroy() if a push is in-flight.

When pushInFlight is true, flushUpdates() returns immediately without waiting for the current push to complete. In destroy(), this means:

  1. destroyed = true is set
  2. await this.flushUpdates() returns immediately (line 203)
  3. The in-flight push eventually completes
  4. finally block (line 229) calls scheduleFlush() if there are pending updates
  5. scheduleFlush() (line 234) returns early because this.destroyed is true

Any updates accumulated in pendingUpdates that weren't merged into the in-flight batch will be lost.

Proposed fix: track and await the in-flight promise
   private pushInFlight = false
+  private inflightPromise: Promise<void> | null = null
   private awarenessTimer: ReturnType<typeof setTimeout> | null = null
   private flushUpdates(): Promise<void> {
     this.clearUpdateTimers()
-    if (this.pushInFlight) return Promise.resolve()
+    if (this.pushInFlight) return this.inflightPromise ?? Promise.resolve()
     if (this.pendingUpdates.length === 0) return Promise.resolve()

     const merged =
       this.pendingUpdates.length === 1
         ? this.pendingUpdates[0]
         : Y.mergeUpdates(this.pendingUpdates)
     this.pendingUpdates = []

     this.pushInFlight = true
-    return this.config
+    this.inflightPromise = this.config
       .pushUpdate({
         documentId: this.documentId,
         update: uint8ToArrayBuffer(merged),
       })
       .then(({ seq }) => {
         if (seq > this.lastAppliedSeq) this.lastAppliedSeq = seq
       })
       .catch((err: unknown) => {
         console.error('[YJS] push failed for', this.documentId, err)
         if (this._writable && !this.destroyed) {
           this.pendingUpdates.unshift(merged)
         }
       })
       .finally(() => {
         this.pushInFlight = false
+        this.inflightPromise = null
         if (this.pendingUpdates.length > 0) this.scheduleFlush()
       })
+    return this.inflightPromise
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/providers/convex-yjs-provider.ts` around lines 201 - 231,
flushUpdates currently returns immediately when this.pushInFlight is true,
causing in-flight pushes to complete after destroy() sets destroyed=true and
then drop any pendingUpdates; change flushUpdates to track the active push
promise (e.g., add a private pushPromise: Promise<void> field), set pushPromise
when starting the config.pushUpdate() chain and clear it in finally, and when
pushInFlight is true return await this.pushPromise instead of Promise.resolve()
so callers (like destroy which calls await flushUpdates()) will wait for the
in-flight push to finish; keep existing behavior of re-queuing merged in catch
and scheduling flush in finally, and ensure pushInFlight is still set/cleared
around the pushPromise lifecycle.

261-261: ⚠️ Potential issue | 🟡 Minor

Comment says "debouncing" but implementation uses throttling.

The awareness handling was changed to a throttle pattern (immediate flush, then rate-limited subsequent flushes). Update the comment to match.

-  // -- Awareness debouncing --
+  // -- Awareness throttling --
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/providers/convex-yjs-provider.ts` at line 261, The
comment "// -- Awareness debouncing --" is incorrect because the code uses a
throttle pattern (immediate flush then rate-limited subsequent flushes); update
that comment to reflect throttling (e.g., "// -- Awareness throttling --") and,
if there are any nearby explanatory comments around the awareness update
handler, clarify that the implementation performs an immediate flush followed by
rate-limited flushes to match the actual behavior of the awareness update logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/files/functions/updateFile.ts`:
- Around line 50-58: In updateFile, when a new storageId exists but the fetched
metadata is not an image the code currently leaves updates.previewStorageId
unchanged, causing stale previews to persist; modify the branch that runs when
storageId is truthy (where ctx.db.system.get(storageId) is called and metadata
is inspected) so that if metadata?.contentType?.startsWith('image/') is false
you explicitly set updates.previewStorageId = null and clear
updates.previewUpdatedAt (e.g., set to null or undefined) to remove any old
preview; keep the existing behavior for image types.

In `@convex/folders/functions/getFolderContentsForDownload.ts`:
- Around line 119-121: The switch in getFolderContentsForDownload currently
drops SIDEBAR_ITEM_TYPES.canvases with an empty case, which returns success but
omits canvases; update the SIDEBAR_ITEM_TYPES.canvases branch in
getFolderContentsForDownload to either (A) implement a minimal canvas export
path (call the canvas export helper or serialize canvas data into the download
bundle) or (B) explicitly surface unsupported items by adding canvases to an
unsupportedItems array or throwing a clear error (e.g., return { success: false,
unsupportedItems: [...] } or throw a meaningful Error) so callers can handle
incomplete exports; modify the switch case for SIDEBAR_ITEM_TYPES.canvases
accordingly and ensure the function's response shape (success/error or
unsupportedItems) is adjusted wherever getFolderContentsForDownload is consumed.

In `@convex/notes/functions/createNote.ts`:
- Around line 78-95: The code is using the internal _headless option and
accessing editor._tiptapEditor.destroy(); change BlockNoteEditor.create usage to
only use documented options (remove _headless) and perform cleanup via the
documented event-listener cleanup functions: capture the cleanup functions
returned by editor.onMount / editor.onUnmount / editor.onChange (or whatever
mount/unmount listener APIs BlockNoteEditor exposes) and call those to teardown
the editor instead of touching editor._tiptapEditor; keep the existing flow of
converting content with blocksToYDoc and encoding via Y.encodeStateAsUpdate
before calling createYjsDocument, but ensure you call the documented listener
cleanup(s) and editor.destroy() (if a public destroy/unmount API exists) after
initialState is produced and before doc.destroy().

In `@convex/notes/mutations.ts`:
- Around line 77-89: The code uses BlockNoteEditor.create and directly touches
internal fields _headless and _tiptapEditor; replace this with BlockNote's
public server API by constructing the editor via ServerBlockNoteEditor from
`@blocknote/server-util` (or a small adapter wrapping ServerBlockNoteEditor) so
yDocToBlocks and saveTopLevelBlocksForNote consume a supported editor instance,
and ensure proper destruction via the public API; if using internals is
unavoidable, add a concise comment explaining why and encapsulate access to
_headless and _tiptapEditor behind an adapter function (with tests) so only that
adapter touches internals.

In `@convex/sidebarItems/__tests__/previewGeneration.test.ts`:
- Around line 433-446: The test assumes the new note is the first item in
results which is brittle; instead have createNote return the created note's id
(capture the noteId from createNote or its response) and then after calling
api.sidebarItems.queries.getSidebarItemsByLocation filter the returned items for
the one matching that noteId (e.g., compare item.entityId or equivalent) and
assert that that matched item's previewUrl is null; update the test to use
createNote, capture noteId, filter items by that id, and assert on the matched
item's previewUrl rather than items[0].

In `@convex/sidebarItems/functions/applyToDependents.ts`:
- Around line 55-68: The current code calls operation(ctx, row) for every item
in yjsUpdates and yjsAwareness via a single Promise.all, which can overload the
system for large canvases; change this to process rows in bounded batches (e.g.,
batchSize = 50): combine or iterate the arrays (yjsUpdates and yjsAwareness),
split them into chunks, and for each chunk run Promise.all on that chunk and
await it before continuing; ensure you still call operation(ctx, row) for each
row and preserve the original ordering/semantics while awaiting between batches
to limit concurrent mutation execution.

In `@convex/sidebarItems/functions/claimPreviewGeneration.ts`:
- Around line 9-10: The COOLDOWN_MS constant uses an unnecessary multiplication
("1 * 60_000") which is redundant and inconsistent with LEASE_DURATION_MS;
change COOLDOWN_MS to directly use the numeric literal "60_000" (i.e., set
COOLDOWN_MS = 60_000) so both constants follow the same style and remove the
redundant multiplication.

In `@convex/yjsSync/__tests__/yjsSyncHelpers.test.ts`:
- Around line 94-99: Add a parallel test that mirrors the existing
duplicate-creation checks but uses a canvas documentId to exercise the Yjs ID
union path: duplicate the two await createYjsDocument(dbCtx, { documentId:
noteId }) calls and replace noteId with a canvasId (e.g., a different constant
like canvasId) so the test suite validates duplication behavior for canvas
documents as well; ensure the new canvasId is initialized similarly to noteId
and the test names/descriptions indicate it's for the canvas documentId path.

In `@src/features/canvas/components/canvas-color-panel.tsx`:
- Around line 86-92: The effect is re-running because colorRelevantNodes gets a
new array reference each render; stabilize it by memoizing colorRelevantNodes
(e.g., with useMemo) so it only changes when its true inputs change (likely
selectedNodes or whatever source drives it). Update the component to compute
colorRelevantNodes via useMemo and keep the existing useEffect that reads
hasColorSelection, getSelectionColor, getSelectionOpacity, setStrokeColor, and
setStrokeOpacity so the effect only fires when the actual selection changes.

In `@src/features/canvas/components/canvas-remote-cursors.tsx`:
- Around line 131-151: NameLabel creates a new inline style object on every
render which can cause unnecessary re-renders; fix by extracting the static
style properties into a shared constant (e.g., BASE_NAME_LABEL_STYLE) and
composing it with the dynamic properties (backgroundColor and color computed via
getContrastColor(color)) inside NameLabel, and/or wrap the component export with
React.memo(NameLabel) to prevent re-renders when props haven't changed; update
any uses in canvas-remote-selections.tsx to import the memoized/optimized
NameLabel.
- Around line 59-71: The code currently mutates refs and reads DOM style during
render (pinnedRef.current, wasDraggingRef.current,
elementRef.current.style.transform, lerpRef.current) which must be moved into a
useLayoutEffect; create a useLayoutEffect hook that depends on [isDragging,
pinnedPosition] (and elementRef if needed), set pinnedRef.current =
pinnedPosition and perform the drag-start detection: if (isDragging &&
!wasDraggingRef.current && elementRef.current) parse
elementRef.current.style.transform to compute lerpRef.current with from and
startTime, then update wasDraggingRef.current = isDragging at the end of the
effect so all DOM reads/writes happen after render but before paint.

In `@src/features/canvas/components/canvas-toolbar.tsx`:
- Around line 164-180: The stroke size button elements lack an explicit type and
thus default to type="submit"; update the button rendered in the strokes map
(the element that reads onClick={() => setStrokeSize(size)} and uses strokeSize
to determine background) to include type="button" so clicking a size won't
trigger form submission if the toolbar is placed inside a form.

In `@src/features/canvas/components/canvas-viewer.tsx`:
- Around line 246-251: The useEffect that queries wrapperRef.current for
'.react-flow' runs on every render because it lacks a dependency array; update
the effect attached to useEffect to only run when needed (e.g., add an empty
dependency array [] if the element is stable) or replace this approach with a
callback ref pattern that sets canvasContainerRef.current when the element
mounts/changes, or install a MutationObserver inside the effect (with proper
cleanup) to track dynamic additions; target the useEffect, wrapperRef,
canvasContainerRef, and the '.react-flow' query when making the change.

In `@src/features/canvas/components/nodes/embed-content/embed-file-content.tsx`:
- Around line 21-24: The image preview uses alt="" which is inaccessible; update
the img in the EmbedFileContent component (the img with src={downloadUrl}) to
provide meaningful alt text by using the file's descriptive property (e.g.,
attachment.name, fileName, node.label or a passed-in title prop) and fall back
to a generic phrase like "Embedded image" if no name is available; ensure the
chosen property is used consistently so screen readers receive useful context.

In `@src/features/canvas/components/nodes/embed-content/embed-folder-content.tsx`:
- Around line 22-28: The flex container div in the EmbedFolderContent rendering
has a redundant "truncate" class which does nothing for text truncation; remove
the "truncate" class from the outer div (the element rendered with
key={child._id as string} and className that includes "flex items-center gap-1.5
px-1.5 py-1 rounded text-xs") and keep the "truncate" on the inner <span>
({child.name}) so only the span handles overflow truncation.

In `@src/features/canvas/components/nodes/embed-content/embed-map-content.tsx`:
- Line 3: The image currently uses an empty alt which hides map content from
assistive tech; update EmbedMapContent to accept a descriptive label (e.g., add
a prop mapTitle?: string or mapName) and set the <img> alt attribute to that
value with a sensible fallback like "Embedded map" (not an empty string), and
apply the same change to the other embedded map <img> instances referenced
around the block (the images mentioned in lines 14-17) so all map images use
meaningful alt text; also update any callers of EmbedMapContent to pass the map
title where available.

In `@src/features/canvas/components/nodes/embed-content/embed-note-content.tsx`:
- Around line 91-104: Replace the manual string concatenation used for the
wrapper div's className in embed-note-content.tsx with a conditional class
utility (e.g., clsx or a local cn). Specifically, update the className
expression on the top-level div (the one using `h-full${editable ? ' nodrag
nopan' : ''}${selected ? ' nowheel' : ''}`) to call the utility with keys like
'h-full', { 'nodrag nopan': editable, 'nowheel': selected }, and add the
appropriate import for the utility at the top of the file (e.g., import cn from
'clsx' or your project's cn helper). Ensure you remove the string concatenation
and use the utility consistently so spacing and class toggles are correct.

In `@src/features/canvas/components/nodes/rectangle-node.tsx`:
- Around line 20-23: Clamp the opacity prop before converting to CSS by bounding
it to the 0–100 range and then dividing by 100; update the style block in
rectangle-node.tsx (the object where backgroundColor: color and opacity are set)
to compute something like const safeOpacity = Math.max(0, Math.min(100, opacity
?? 100)) / 100 and use safeOpacity for the CSS opacity to ensure values are
always within 0–1; you can place the clamp inline or extract a small helper
clamp function if preferred.

In `@src/features/canvas/components/nodes/sticky-node.tsx`:
- Around line 86-88: The sticky editor's textarea in StickyNode (the <textarea>
element in sticky-node.tsx) is missing the "nowheel" opt-out class so wheel
events over an overflowing note propagate to the canvas; add the "nowheel" CSS
class to that textarea's className (alongside the existing classes like
"bg-transparent outline-none ...") so useCanvasWheel() will skip this descendant
and scrolling the note with the mouse wheel will not pan/zoom the canvas.
- Around line 92-96: The Escape handler in the onKeyDown for the sticky node
needs to stop the event from bubbling to canvas-level shortcuts; update the
onKeyDown callback (in sticky-node.tsx) to call e.stopPropagation() and
e.preventDefault() before invoking handleBlur(label) so Escape only cancels the
sticky edit and does not trigger outer canvas handlers.

In `@src/features/canvas/components/nodes/stroke-node.tsx`:
- Around line 82-92: The selection overlay redundantly recalculates the SVG path
by calling pointsToPathD twice for the same points/size inputs; wrap those
calculations in a memoized value (e.g., useMemo) keyed on points and size so you
compute the pathD once and reuse it for both the main path and the overlay;
update the JSX in stroke-node.tsx to reference the memoized path (from
pointsToPathD(points, size * 0.3) and the other call) and ensure the dependency
array includes points and size so the cache invalidates correctly.

In `@src/features/canvas/hooks/useCanvasAwareness.ts`:
- Around line 36-41: The effect in useCanvasAwareness leaves stale remoteUsers
when provider becomes null; update the effect to explicitly clear
awarenessRef.current and reset remote users when provider is falsy (e.g., call
setRemoteUsers([]) and set awarenessRef.current = null) so old
cursors/selections are removed; also ensure the cleanup path that unregisters
awareness event listeners (the logic around provider.awareness and any on/ off
handlers) clears remote state when the provider is disposed or changed to avoid
lingering presence.

In `@src/features/canvas/hooks/useCanvasDrawing.ts`:
- Around line 68-94: onPointerUp currently takes no event and never explicitly
releases pointer capture; change its signature to accept the PointerEvent (e.g.,
onPointerUp = useCallback((event: PointerEvent) => { ... }), then call
event.currentTarget?.releasePointerCapture(event.pointerId) (or fallback to
(event.target as Element).releasePointerCapture(event.pointerId)) before
clearing points and calling setLocalDrawing/setAwarenessDrawing; also update the
useCallback dependency array if you add any new references. This ensures pointer
capture set in onPointerDown is always explicitly released.

In `@src/features/canvas/hooks/useCanvasDropTarget.ts`:
- Around line 121-139: The code awaits processDataTransferItems and each
uploadRef.current call while mutating nodesMapRef.current, which can write to
the wrong Yjs document if the user switches canvases; to fix, capture the target
canvas/document id upfront (e.g., read currentCanvasId or similar), then
freeze/lock the target by collecting all created node objects (using
crypto.randomUUID, basePosition and result.id) into a local array during the
async work (inside the loop that calls processDataTransferItems and
uploadRef.current) and, after all uploads complete, verify the current
canvas/document id still matches the captured id and perform a single batched
write to nodesMapRef.current to insert all nodes at once; if the id changed,
discard or re-target the nodes appropriately.

In `@src/features/canvas/hooks/useCanvasEraser.ts`:
- Around line 71-82: onPointerMove currently calls testIntersections() on every
pointer event; wrap that call with RAF throttling: create a rafId ref (e.g.,
rafIdRef) and in onPointerMove schedule testIntersections() via
requestAnimationFrame only if no raf is pending, storing the id; ensure
testIntersections reads trailRef/current as before. Also cancel/clear the
pending RAF in onPointerUp (and reset rafIdRef) to avoid running stale work when
erasingRef or pointer interaction ends. This preserves existing logic around
erasingRef/current, trailRef.current.push(pos) and dependencies (reactFlow,
testIntersections).
- Around line 84-94: The onPointerUp callback uses a non-null assertion on
nodesMap.doc (nodesMap.doc!) which can throw if the Yjs document is not
initialized; update onPointerUp to guard early by returning if nodesMap.doc is
null/undefined (or if nodesMap is not ready) before calling
nodesMap.doc.transact, then proceed to delete IDs from nodesMap, clear
markedRef.current and trailRef.current, and call
useCanvasToolStore.getState().setErasingStrokeIds(new Set()) as before;
reference the onPointerUp function and nodesMap.delete to locate where to add
the null/ready check.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx`:
- Around line 75-107: The code can leave a committed storage blob orphaned if
commitUpload.mutateAsync succeeds but createItem(...) throws; wrap the
createItem + toast logic in a try/catch and on failure perform a compensating
deletion of the committed storage using the existing storage APIs (e.g., call a
deleteCommittedUpload/deleteFileStorage/deleteFile mutation with the storageId
or enqueue a cleanup job) and ensure toast.dismiss(toastId) is run in finally;
reference generateUploadUrl.mutateAsync, uploadFile(...),
trackUpload.mutateAsync, commitUpload.mutateAsync and createItem to locate where
to add the try/catch and compensating delete or cleanup scheduling.

In `@src/features/canvas/hooks/useCanvasHistory.ts`:
- Around line 29-37: syncStore currently captures stale closures because it has
an empty dependency array but passes undo and redo into setHistory while
undo/redo reference syncStore; to fix, stop relying on captured functions—either
store stable function refs (e.g., create undoRef.current and redoRef.current and
pass those to useCanvasToolStore.getState().setHistory) and update those refs
whenever undo/redo are redefined, or remove syncStore entirely and call
useCanvasToolStore.getState().setHistory directly from within the undo and redo
implementations (using undoStackRef.current and redoStackRef.current for
canUndo/canRedo) so setHistory always gets the current behavior; update
references to undo, redo, syncStore, undoStackRef, redoStackRef accordingly.

In `@src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts`:
- Around line 20-27: In useCanvasKeyboardShortcuts, normalize the keyboard event
key before comparisons: read e.key into a local normalized variable (e.g., key =
e.key.toLowerCase()) and use that variable when checking for 'z' and 'y' so
uppercase 'Z' from Shift+Z still matches; update the checks that call undo() and
redo() (and the 'y' branch) to use the normalized key while preserving existing
modifier checks and e.preventDefault() calls.

In `@src/features/canvas/hooks/useCanvasRectangleDraw.ts`:
- Around line 73-74: The size-check uses && so it only rejects rectangles when
both width and height are below MIN_RECT_SIZE, which allows very thin/tall
rectangles; decide intended behavior and either (a) change the condition in
useCanvasRectangleDraw from `rect.width < MIN_RECT_SIZE && rect.height <
MIN_RECT_SIZE` to `rect.width < MIN_RECT_SIZE || rect.height < MIN_RECT_SIZE` to
reject any rectangle with a small dimension, or (b) keep the && and add a
clarifying comment next to the `rectFromPoints`/validation line explaining that
thin/tall shapes are intentionally allowed.
- Around line 22-35: The onPointerDown handler (onPointerDown) should ignore
non-left clicks like onPointerMove does; add a check at the top of onPointerDown
to return early when e.button !== 0 so only left-button presses proceed, then
perform pointer capture, set activeRef.current, compute pos via
reactFlow.screenToFlowPosition, set startRef.current and lastClientPos.current,
and call useCanvasToolStore.getState().setSelectionRect(null); this mirrors the
left-button guard used in useCanvasEraser and ensures right/middle clicks don't
start drawing.

In `@src/features/canvas/hooks/useCanvasSelectionRect.ts`:
- Around line 59-80: Duplicate logic computes adjusted stroke points inside the
reactFlow.setNodes mapping (used when filtering selected 'stroke' nodes) and
again in the RAF callback; extract that into a helper named
getAdjustedStrokePoints(n: Node, strokeData: StrokeNodeData): Array<[number,
number, number]> that computes offsetX/offsetY from n.position and
strokeData.bounds and returns strokeData.points mapped to [x + offsetX, y +
offsetY, p]; then replace the inline offset/points.map blocks in the
selection-end reactFlow.setNodes map and in the RAF callback with calls to
getAdjustedStrokePoints and pass the returned adjustedPoints into
strokePathIntersectsRect and any other consumers.

In `@src/features/canvas/hooks/useCanvasSelectionSync.ts`:
- Around line 36-43: The code in useCanvasSelectionSync
(reactFlowInstance.setNodes) forces draggable to match selection for all nodes
(using selectedSet.has(n.id)), which prevents dragging unselected nodes; either
add a concise inline comment near that block explaining this is an intentional
UX choice (only selected nodes are draggable) or change the update logic so only
selected nodes are set draggable=true and all other nodes are left unchanged
(i.e., update nodes where selectedSet.has(n.id) is true to {...n, draggable:
true} and return n for others) to preserve existing draggability for unselected
nodes; reference reactFlowInstance.setNodes and selectedSet to locate the code.

In `@src/features/canvas/hooks/useNodeEditing.ts`:
- Around line 15-19: startEditing doesn't reset the commit flag, so a previous
Escape-cancelled session (shouldCommitRef.current = false) can leak into the
next session; update startEditing (and the other start-edit handler around the
43-45 region) to set shouldCommitRef.current = true when beginning an edit
session so blurs behave correctly and commits aren't incorrectly dropped. Ensure
you modify the startEditing callback and the analogous handler to reinitialize
shouldCommitRef.current to true right before calling setIsEditing(true).

In `@src/features/canvas/utils/canvas-stroke-utils.ts`:
- Around line 87-103: segmentsIntersect currently rejects all near-parallel
cases by returning false when denom ≈ 0, which misses collinear overlapping
segments; update segmentsIntersect to handle collinear/parallel cases by
computing orientations and using an on-segment check (e.g., orientation(A,B,C),
orientation(A,B,D), orientation(C,D,A), orientation(C,D,B)) and if orientations
indicate collinearity, test whether any endpoint lies on the other segment
(onSegment check using bounding-box comparisons of ax/bx/… and ay/by/…); keep
the existing t/u intersection logic for non-parallel cases (denom not ≈ 0) but
replace the early return with the orientation + on-segment handling to detect
overlapping/edge-aligned intersections.

In
`@src/features/editor/components/extensions/selection-toolbar/selection-toolbar.tsx`:
- Line 16: The inline comment "// TODO: fix this" in selection-toolbar.tsx is
ambiguous—replace it with a clear, actionable note that either links to the
tracking ticket or describes the missing/incorrect behavior and the verification
steps; for example, in the SelectionToolbar component (or near any related
function such as renderToolbar / handleSelectionChange) state what specifically
is broken, how to reproduce it, the expected outcome, and acceptance criteria
(e.g., "See ISSUE-1234: toolbar fails to position on multi-line selection —
reproduce by selecting across lines A/B, expect toolbar to appear above
selection"); keep the comment concise and stable so future reviewers can find
the ticket or tests to validate the fix.

In `@src/features/editor/components/forms/file-form/file-form.tsx`:
- Around line 182-187: The create path currently awaits
generatePdfPreviewIfNeeded(fileUpload.file, newFileId) which, if it throws, will
block navigation and success UI even though the file was created; wrap that call
in a try/catch inside the create flow in the FileForm component (the same place
that handles newFileId after creation), log or report the error (e.g., via
processLogger or UI toast) and swallow it so navigation and success feedback
still occur when the file creation succeeded.
- Around line 164-166: The await on generatePdfPreviewIfNeeded(fileUpload.file,
fileId) blocks the success flow if preview generation fails; make preview
generation non-blocking by either wrapping the call in a try-catch that logs
errors (so onSuccess and the success toast still run) or fire-and-forget it
(call generatePdfPreviewIfNeeded(fileUpload.file, fileId) without await and
attach .catch to log failures). Update the code around the fileUpload/fileId
handling and ensure onSuccess and the success toast are executed regardless of
preview errors.

In `@src/features/editor/components/note-content.tsx`:
- Around line 191-205: Add an explanatory comment above the RAF retry loop (the
MAX_RETRIES / tryPatch logic) describing that BlockNote initializes Tiptap
plugins asynchronously and we need to wait for
instance._tiptapEditor.view.state.plugins to be non-empty before calling
patchYUndoPluginDestroy and patchYSyncAfterTypeChanged; note the chosen
MAX_RETRIES (30) as a safety cap to avoid infinite RAF looping and mention that
retries use requestAnimationFrame to poll until plugins are ready or the cap is
reached.
- Around line 142-146: The useEffect currently skips calling
editor.replaceBlocks when content.length === 0, so clearing content doesn't
propagate; update the effect to run whenever editor or content changes (keep
useEffect dependencies) and call editor.replaceBlocks(editor.document, content)
even when content is empty — or normalize empty content to the expected
empty-block payload (e.g., [] or "") before calling; modify the effect around
useEffect/replaceBlocks to remove the content.length > 0 guard and ensure empty
content is converted to the editor's empty value so the editor is cleared.

In `@src/features/editor/components/viewer/folder/canvas-card.tsx`:
- Around line 98-108: The img rendering branch using canvas.previewUrl should
enable browser lazy-loading to improve folder view performance; update the <img>
element that references canvas.previewUrl (in the canvas card component) to
include loading="lazy" (and consider preserving existing attributes like src,
alt, className) so previews load only as they scroll into view.

In `@src/features/editor/components/viewer/folder/file-card.tsx`:
- Around line 143-151: The image preview can break if previewUrl is stale; add a
local state (e.g., hasPreviewError via useState) in the FileCard component and
use it to conditionally render the <img> only when file.previewUrl is present
and hasPreviewError is false, and attach an onError handler to the img to set
hasPreviewError(true) so the component falls back to rendering the FileIcon;
update any initial state if you want to optimistically show the preview when
previewUrl exists and ensure the fallback logic uses the same FileIcon className
so appearance stays consistent.

In `@src/features/editor/hooks/useYjsReactFlowSync.ts`:
- Around line 95-160: The RAF loop in the useEffect's animate function never
stops; change animate to only call requestAnimationFrame when there are active
targets or springs (check remoteDragRef.current keys length,
springStates.current.size, or springActiveIds.current.size) and
cancelAnimationFrame/clear rafId when none remain so the loop goes idle; also
ensure the effect restarts the loop when remote drag state changes by triggering
the effect (e.g., add the remote-drag state variable that backs remoteDragRef to
the useEffect dependencies or call a restart helper when remoteDragRef is
updated) so requestAnimationFrame is re-queued when new targets appear
(referencing animate, rafId, requestAnimationFrame, cancelAnimationFrame,
remoteDragRef, springStates, springActiveIds).

In `@src/features/previews/hooks/use-claim-and-upload-preview.ts`:
- Around line 42-44: In the catch block inside useClaimAndUploadPreview (the
function in use-claim-and-upload-preview.ts) enhance the logger.error call to
include the itemId (and optional contextual info like preview type or upload
target) along with the caught error so logs show which item failed; update the
catch to log a descriptive message such as "Failed to claim/upload preview for
item" plus itemId and the error object (and keep returning false) to aid
debugging.

In `@src/features/previews/hooks/use-pdf-preview-upload.ts`:
- Around line 6-8: The isPdfFile function currently checks
file.name.endsWith('.pdf') which misses uppercase extensions; change the
extension check to be case-insensitive (for example, compare
file.name.toLowerCase().endsWith('.pdf') or use a case-insensitive regex) so
that isPdfFile(File) returns true for names ending in .pdf, .PDF, or mixed-case
variants while keeping the existing MIME-type check (file.type ===
'application/pdf').

In `@src/features/previews/utils/__tests__/upload-preview.test.ts`:
- Around line 54-61: The test suite is stubbing the global fetch with
vi.stubGlobal('fetch') inside a test which overrides the beforeEach stub and
isn't cleaned up; add an afterEach cleanup to restore globals so later tests
aren't affected — e.g. add afterEach(() => { vi.unstubAllGlobals();
vi.restoreAllMocks(); }) to this test file so any vi.stubGlobal('fetch') or mock
changes made in the test are undone.
- Around line 17-30: The test setup stubs the global fetch via vi.stubGlobal but
never cleans it up, risking test pollution; add an afterEach that calls
vi.unstubAllGlobals() to remove the global fetch stub (and also call
vi.restoreAllMocks() or vi.resetAllMocks() to reset mockGenerateUploadUrl and
mockSetPreviewImage) so the stubbed global and mocks are restored between tests;
locate the setup using mockGenerateUploadUrl, mockSetPreviewImage, and the
vi.stubGlobal('fetch') call and add the cleanup there.

In `@src/features/previews/utils/generate-preview.ts`:
- Around line 44-49: The canvas height is being capped against
PDF_PREVIEW_HEIGHT multiplied by scale, which double-scales the limit; update
the logic in generate-preview.ts around the scale/viewport calculation
(variables: scale, viewport, PDF_PREVIEW_WIDTH, PDF_PREVIEW_HEIGHT,
page.getViewport) to either (A) cap canvas.height against the unscaled pixel
limit by using Math.min(viewport.height, PDF_PREVIEW_HEIGHT) so the preview
height is in device pixels, or (B) compute a new scale that respects both width
and height constraints (e.g., heightScale = PDF_PREVIEW_HEIGHT /
page.getViewport({ scale: 1 }).height; scale = Math.min(widthScale,
heightScale); then call page.getViewport({ scale }) and set canvas.width/height
to viewport.width/height) so the aspect ratio is preserved and the preview fits
both max width and max height.
- Around line 33-37: The current dynamic worker path using new URL(...) is
inconsistent with the project's Vite pattern; import the worker as a URL using
the Vite idiom (e.g., add an import like pdfjsWorkerUrl from
'pdfjs-dist/build/pdf.worker.min.mjs?url' at the top of generate-preview.ts) and
then set pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl instead of
building the URL via import.meta.url; update references in the file (pdfjsLib
and pdfjsLib.GlobalWorkerOptions.workerSrc) to use that imported pdfjsWorkerUrl
for consistency with pdf-file-viewer.tsx.

In `@src/features/previews/utils/upload-preview.ts`:
- Around line 25-27: The code currently force-casts the JSON body to {
storageId: Id<'_storage'> } which can mask malformed responses; replace the
direct assertion by parsing the JSON into a temporary value (e.g., const body =
await response.json()), validate that body is an object and has a valid
storageId (e.g., typeof body.storageId === 'string' or whatever Id<'_storage'>
expects), and only then assign storageId from the validated body; if validation
fails, throw or handle an explicit error so callers don’t operate on undefined
storageId (refer to the storageId variable and the response.json() usage in
upload-preview.ts).

In `@src/features/sidebar/components/sidebar-layout.tsx`:
- Around line 149-159: The width-calculation logic (computing displayWidth,
contentWidth, totalDisplay, totalContent and applying them to
outerRef.innerRef.handleRef) is duplicated in the mousemove and mouseup paths;
extract this into a single helper function (e.g., computeAndApplyWidths or
updateSidebarWidths) that accepts finalWidth and startWidth (or derives them
like the existing code) and performs the displayWidth/contentWidth/total* math
and DOM style updates, then call that helper from both handleMouseMove and
handleMouseUp to avoid drift and maintenance issues.

In `@src/shared/hooks/useSpringPosition.ts`:
- Around line 69-109: The RAF loop currently always schedules another frame in
animate, causing perpetual 60fps even when targetRef.current is null or the
spring has settled; update animate (and the surrounding effect) so it stops
calling requestAnimationFrame when targetRef.current is null or when the spring
is idle (detect idle by comparing state.pos to the target and checking vel is
near zero / within a small epsilon after calling stepSpring), canceling/clearing
rafId and resetting prevTimeRef/posRef/velRef as needed; only re-start the RAF
when a new non-null target appears (e.g., trigger requestAnimationFrame again
when targetRef transitions from null to a value). Ensure you reference animate,
requestAnimationFrame/cancelAnimationFrame, stepSpring, targetRef, posRef,
velRef, prevTimeRef, elementRef, optionsRef, and SPRING_DEFAULTS.maxDt when
making the change.

In `@src/shared/utils/color.ts`:
- Around line 1-7: The getContrastColor function must validate and normalize the
hexColor before parsing: in getContrastColor strip the leading '#', expand
3-character hex to 6-character form, ensure the resulting string is exactly 6
hex digits (0-9a-f/A-F), and if validation fails use a safe fallback (e.g.,
return '#000000' or a configured default) instead of proceeding; only then parse
r/g/b and compute luminance to choose '#000000' or '#ffffff'.

In `@src/styles/app.css`:
- Around line 182-184: The reduced-motion override selector currently uses
:not(.allow-motion *) (and ::before/::after variants) which does not exclude the
.allow-motion element itself; update the selector list to also exclude the
element by adding :not(.allow-motion), :not(.allow-motion)::before, and
:not(.allow-motion)::after alongside the existing :not(.allow-motion *) variants
so elements with class ".allow-motion" are not subjected to the
animation-duration override.

---

Outside diff comments:
In `@convex/sidebarItems/functions/hardDeleteItem.ts`:
- Around line 41-43: The current hardDeleteItem logic only calls
deleteYjsDocument for SIDEBAR_ITEM_TYPES.notes, leaving Yjs rows orphaned for
canvases; update the conditional in hardDeleteItem (the block that currently
checks item.type === SIDEBAR_ITEM_TYPES.notes) to also call deleteYjsDocument
for canvas items (e.g., item.type === SIDEBAR_ITEM_TYPES.canvas) — either expand
the condition to include canvas or use a set of types (notes | canvas) and
invoke deleteYjsDocument(ctx, item._id) for those types so
yjsUpdates/yjsAwareness rows are removed on canvas hard-delete.

In `@src/features/editor/providers/convex-yjs-provider.ts`:
- Around line 201-231: flushUpdates currently returns immediately when
this.pushInFlight is true, causing in-flight pushes to complete after destroy()
sets destroyed=true and then drop any pendingUpdates; change flushUpdates to
track the active push promise (e.g., add a private pushPromise: Promise<void>
field), set pushPromise when starting the config.pushUpdate() chain and clear it
in finally, and when pushInFlight is true return await this.pushPromise instead
of Promise.resolve() so callers (like destroy which calls await flushUpdates())
will wait for the in-flight push to finish; keep existing behavior of re-queuing
merged in catch and scheduling flush in finally, and ensure pushInFlight is
still set/cleared around the pushPromise lifecycle.
- Line 261: The comment "// -- Awareness debouncing --" is incorrect because the
code uses a throttle pattern (immediate flush then rate-limited subsequent
flushes); update that comment to reflect throttling (e.g., "// -- Awareness
throttling --") and, if there are any nearby explanatory comments around the
awareness update handler, clarify that the implementation performs an immediate
flush followed by rate-limited flushes to match the actual behavior of the
awareness update logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 585ae647-b5e4-4b67-a273-a773d5312398

📥 Commits

Reviewing files that changed from the base of the PR and between d35b5d0 and 547ae4f.

⛔ Files ignored due to path filters (2)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (137)
  • convex/_test/factories.helper.ts
  • convex/canvases/baseSchema.ts
  • convex/canvases/functions/createCanvas.ts
  • convex/canvases/functions/enhanceCanvas.ts
  • convex/canvases/functions/updateCanvas.ts
  • convex/canvases/mutations.ts
  • convex/canvases/schema.ts
  • convex/canvases/types.ts
  • convex/files/functions/createFile.ts
  • convex/files/functions/updateFile.ts
  • convex/folders/functions/createFolder.ts
  • convex/folders/functions/getFolderContentsForDownload.ts
  • convex/gameMaps/__tests__/gameMaps.test.ts
  • convex/gameMaps/functions/createMap.ts
  • convex/gameMaps/functions/updateMap.ts
  • convex/notes/__tests__/noteContentBlockShareConsistency.test.ts
  • convex/notes/__tests__/noteWorkflows.test.ts
  • convex/notes/__tests__/notes.test.ts
  • convex/notes/functions/createNote.ts
  • convex/notes/functions/updateNoteContent.ts
  • convex/notes/mutations.ts
  • convex/schema.ts
  • convex/sidebarItems/__tests__/previewCleanup.test.ts
  • convex/sidebarItems/__tests__/previewGeneration.test.ts
  • convex/sidebarItems/functions/applyToDependents.ts
  • convex/sidebarItems/functions/claimPreviewGeneration.ts
  • convex/sidebarItems/functions/defaultItemName.ts
  • convex/sidebarItems/functions/enhanceSidebarItem.ts
  • convex/sidebarItems/functions/fetchCampaignSidebarItems.ts
  • convex/sidebarItems/functions/getSidebarItemById.ts
  • convex/sidebarItems/functions/getSidebarItemBySlug.ts
  • convex/sidebarItems/functions/hardDeleteItem.ts
  • convex/sidebarItems/functions/setPreviewImage.ts
  • convex/sidebarItems/mutations.ts
  • convex/sidebarItems/schema/baseFields.ts
  • convex/sidebarItems/schema/baseValidators.ts
  • convex/sidebarItems/schema/contentSchema.ts
  • convex/sidebarItems/schema/schema.ts
  • convex/sidebarItems/types/baseTypes.ts
  • convex/sidebarItems/types/types.ts
  • convex/yjsSync/__tests__/yjsSyncHelpers.test.ts
  • convex/yjsSync/__tests__/yjsSyncMutations.test.ts
  • convex/yjsSync/constants.ts
  • convex/yjsSync/functions/checkYjsAccess.ts
  • convex/yjsSync/functions/compactUpdates.ts
  • convex/yjsSync/functions/createYjsDocument.ts
  • convex/yjsSync/functions/deleteYjsDocument.ts
  • convex/yjsSync/functions/reconstructYDoc.ts
  • convex/yjsSync/functions/types.ts
  • convex/yjsSync/internalMutations.ts
  • convex/yjsSync/mutations.ts
  • convex/yjsSync/queries.ts
  • convex/yjsSync/schema.ts
  • e2e/editor-stress.spec.ts
  • eslint.config.js
  • package.json
  • src/features/canvas/components/canvas-color-panel.tsx
  • src/features/canvas/components/canvas-minimap-node.tsx
  • src/features/canvas/components/canvas-remote-cursors.tsx
  • src/features/canvas/components/canvas-remote-selections.tsx
  • src/features/canvas/components/canvas-strokes.tsx
  • src/features/canvas/components/canvas-toolbar.tsx
  • src/features/canvas/components/canvas-viewer.tsx
  • src/features/canvas/components/nodes/canvas-node-types.ts
  • src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx
  • src/features/canvas/components/nodes/embed-content/embed-file-content.tsx
  • src/features/canvas/components/nodes/embed-content/embed-folder-content.tsx
  • src/features/canvas/components/nodes/embed-content/embed-map-content.tsx
  • src/features/canvas/components/nodes/embed-content/embed-note-content.tsx
  • src/features/canvas/components/nodes/embed-node.tsx
  • src/features/canvas/components/nodes/rectangle-node.tsx
  • src/features/canvas/components/nodes/resizable-node-wrapper.tsx
  • src/features/canvas/components/nodes/sticky-node-constants.ts
  • src/features/canvas/components/nodes/sticky-node.tsx
  • src/features/canvas/components/nodes/stroke-node.tsx
  • src/features/canvas/components/nodes/text-node.tsx
  • src/features/canvas/hooks/useCanvasAwareness.ts
  • src/features/canvas/hooks/useCanvasDrawing.ts
  • src/features/canvas/hooks/useCanvasDropTarget.ts
  • src/features/canvas/hooks/useCanvasEraser.ts
  • src/features/canvas/hooks/useCanvasFileUpload.tsx
  • src/features/canvas/hooks/useCanvasHistory.ts
  • src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts
  • src/features/canvas/hooks/useCanvasLassoSelection.ts
  • src/features/canvas/hooks/useCanvasOverlayHandlers.ts
  • src/features/canvas/hooks/useCanvasRectangleDraw.ts
  • src/features/canvas/hooks/useCanvasSelectionRect.ts
  • src/features/canvas/hooks/useCanvasSelectionSync.ts
  • src/features/canvas/hooks/useCanvasWheel.ts
  • src/features/canvas/hooks/useEmbedItemContent.ts
  • src/features/canvas/hooks/useNodeEditing.ts
  • src/features/canvas/index.ts
  • src/features/canvas/stores/canvas-tool-store.ts
  • src/features/canvas/utils/canvas-awareness-types.ts
  • src/features/canvas/utils/canvas-context.ts
  • src/features/canvas/utils/canvas-stroke-utils.ts
  • src/features/context-menu/actions.tsx
  • src/features/context-menu/menu-registry.ts
  • src/features/dnd/hooks/useFileDropHandler.tsx
  • src/features/dnd/hooks/useNoteEditorDropTarget.ts
  • src/features/dnd/utils/dnd-registry.ts
  • src/features/editor/components/create-new-dashboard.tsx
  • src/features/editor/components/extensions/selection-toolbar/selection-toolbar.tsx
  • src/features/editor/components/forms/file-form/file-form.tsx
  • src/features/editor/components/note-content.tsx
  • src/features/editor/components/note-view.tsx
  • src/features/editor/components/viewer/folder/canvas-card.tsx
  • src/features/editor/components/viewer/folder/file-card.tsx
  • src/features/editor/components/viewer/folder/item-card.tsx
  • src/features/editor/components/viewer/folder/map-card.tsx
  • src/features/editor/components/viewer/folder/note-card.tsx
  • src/features/editor/components/viewer/note/note-editor.tsx
  • src/features/editor/components/viewer/sidebar-item-editor.tsx
  • src/features/editor/hooks/__tests__/useConvexYjsCollaboration.test.ts
  • src/features/editor/hooks/__tests__/useNoteYjsCollaboration.test.ts
  • src/features/editor/hooks/useConvexYjsCollaboration.ts
  • src/features/editor/hooks/useNoteYjsCollaboration.ts
  • src/features/editor/hooks/useYjsReactFlowSync.ts
  • src/features/editor/providers/__tests__/convex-yjs-provider.test.ts
  • src/features/editor/providers/convex-yjs-provider.ts
  • src/features/previews/hooks/use-canvas-preview.ts
  • src/features/previews/hooks/use-claim-and-upload-preview.ts
  • src/features/previews/hooks/use-note-preview.ts
  • src/features/previews/hooks/use-pdf-preview-upload.ts
  • src/features/previews/utils/__tests__/upload-preview.test.ts
  • src/features/previews/utils/generate-preview.ts
  • src/features/previews/utils/upload-preview.ts
  • src/features/sharing/hooks/useSidebarItemsShare.ts
  • src/features/sidebar/components/sidebar-layout.tsx
  • src/features/sidebar/hooks/useCreateSidebarItem.ts
  • src/features/sidebar/hooks/useEditSidebarItem.ts
  • src/features/sidebar/utils/sidebar-item-utils.ts
  • src/shared/components/color-picker-popover.tsx
  • src/shared/hooks/useSpringPosition.ts
  • src/shared/utils/color.ts
  • src/styles/app.css
  • src/test/factories/sidebar-item-factory.ts
💤 Files with no reviewable changes (6)
  • convex/notes/tests/notes.test.ts
  • convex/notes/functions/updateNoteContent.ts
  • convex/yjsSync/constants.ts
  • src/features/editor/hooks/tests/useConvexYjsCollaboration.test.ts
  • convex/notes/tests/noteContentBlockShareConsistency.test.ts
  • src/features/editor/providers/tests/convex-yjs-provider.test.ts

Comment thread convex/files/functions/updateFile.ts
Comment thread convex/folders/functions/getFolderContentsForDownload.ts
Comment thread convex/notes/functions/createNote.ts
Comment thread convex/sidebarItems/functions/applyToDependents.ts
Comment thread convex/sidebarItems/functions/claimPreviewGeneration.ts Outdated
Comment thread src/features/previews/utils/upload-preview.ts Outdated
Comment thread src/features/sidebar/components/sidebar-layout.tsx Outdated
Comment thread src/shared/hooks/useSpringPosition.ts Outdated
Comment thread src/shared/utils/color.ts Outdated
Comment thread src/styles/app.css Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment thread convex/notes/mutations.ts
Comment thread convex/sidebarItems/__tests__/previewGeneration.test.ts
Comment thread src/features/canvas/components/canvas-viewer.tsx Outdated
Comment thread src/features/canvas/components/nodes/stroke-node.tsx Outdated
Comment thread src/features/canvas/hooks/useCanvasAwareness.ts Outdated
Comment thread src/features/canvas/hooks/useCanvasFileUpload.tsx Outdated
Comment thread src/features/canvas/hooks/useCanvasHistory.ts
Comment thread src/features/canvas/hooks/useNodeEditing.ts
Comment thread src/features/editor/components/note-content.tsx
Comment thread src/features/editor/components/note-content.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/features/editor/providers/convex-yjs-provider.ts (1)

146-165: ⚠️ Potential issue | 🟠 Major

Potential data loss when destroy() is called during an in-flight push.

If pushInFlight is true when destroy() calls flushUpdates(), the method returns the in-flight promise (line 204). When that push completes, its finally block calls scheduleFlush() (line 230), which returns immediately because destroyed is true (line 236). Any updates that accumulated during the in-flight push are lost.

🔧 Suggested fix: loop until pending updates are drained
     const teardown = async () => {
       if (this._writable) {
-        await this.flushUpdates()
+        // Loop until all pending updates are flushed
+        while (this.pendingUpdates.length > 0 || this.pushInFlight) {
+          await this.flushUpdates()
+        }
       }
 
       await this.flushAwareness()

Alternatively, modify flushUpdates() to handle the destroyed state differently when called directly vs. from a timer, but the loop approach is simpler and more explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/providers/convex-yjs-provider.ts` around lines 146 - 165,
The destroy() path can lose updates if a push is in flight because
flushUpdates() returns the in-flight promise and scheduleFlush() no-ops when
destroyed; fix by looping in destroy() until all pending updates are drained:
instead of a single await this.flushUpdates() in the teardown, repeatedly
call/await this.flushUpdates() (or check this.pushInFlight and any
pending-update buffer/state used by flushUpdates()) until flushUpdates() returns
no promise/indicates no work or both pushInFlight is false and pending buffer is
empty; keep the rest of teardown (flushAwareness(), removeAwareness) unchanged.
Ensure you reference the existing members pushInFlight, destroyed,
flushUpdates(), scheduleFlush(), clearAwarenessTimer(), flushAwareness(), and
config.removeAwareness when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/features/editor/providers/convex-yjs-provider.ts`:
- Around line 26-40: Update the ConvexYjsProviderConfig callback signatures to
accept YjsDocumentId instead of string: change the documentId parameter types in
pushUpdate, pushAwareness, and removeAwareness to YjsDocumentId so they match
the class usage; then update any related call sites that pass YjsDocumentId
(e.g., where the provider stores/passes Id<'notes'> | Id<'canvases'>) to satisfy
the new types and ensure imports/exports reference the YjsDocumentId type where
ConvexYjsProviderConfig is defined.

---

Outside diff comments:
In `@src/features/editor/providers/convex-yjs-provider.ts`:
- Around line 146-165: The destroy() path can lose updates if a push is in
flight because flushUpdates() returns the in-flight promise and scheduleFlush()
no-ops when destroyed; fix by looping in destroy() until all pending updates are
drained: instead of a single await this.flushUpdates() in the teardown,
repeatedly call/await this.flushUpdates() (or check this.pushInFlight and any
pending-update buffer/state used by flushUpdates()) until flushUpdates() returns
no promise/indicates no work or both pushInFlight is false and pending buffer is
empty; keep the rest of teardown (flushAwareness(), removeAwareness) unchanged.
Ensure you reference the existing members pushInFlight, destroyed,
flushUpdates(), scheduleFlush(), clearAwarenessTimer(), flushAwareness(), and
config.removeAwareness when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 86e5bbfb-4c0f-4d91-baff-daf45c3d9889

📥 Commits

Reviewing files that changed from the base of the PR and between a559a18 and a31a514.

📒 Files selected for processing (1)
  • src/features/editor/providers/convex-yjs-provider.ts

Comment thread src/features/editor/providers/convex-yjs-provider.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (2)
src/features/canvas/components/canvas-color-panel.tsx (1)

58-64: ⚠️ Potential issue | 🟠 Major

Batch target can still drift across rapid selection changes.

Line [63] keeps the previous pending nodeIds whenever a RAF batch already exists. If selection changes and another color/opacity change is queued before flush, the new change is still applied to the old selection.

💡 Proposed fix
   const scheduleNodeUpdate = useCallback(
-    (data: Record<string, unknown>) => {
+    (nodeIds: Array<string>, data: Record<string, unknown>) => {
+      if (!nodeIds.length) return
+      const prev = pendingUpdate.current
+      const sameTarget =
+        !!prev &&
+        prev.nodeIds.length === nodeIds.length &&
+        prev.nodeIds.every((id, i) => id === nodeIds[i])
+
       pendingUpdate.current = {
-        data: { ...pendingUpdate.current?.data, ...data },
-        nodeIds:
-          pendingUpdate.current?.nodeIds ?? colorRelevantNodes.map((n) => n.id),
+        nodeIds,
+        data: sameTarget ? { ...prev!.data, ...data } : data,
       }
       if (!rafId.current) {
         rafId.current = requestAnimationFrame(() => {
           rafId.current = 0
           flushNodeUpdates()
         })
       }
     },
-    [flushNodeUpdates, colorRelevantNodes],
+    [flushNodeUpdates],
   )
@@
   const handleColorChange = useCallback(
     (color: string) => {
       setStrokeColor(color)
-      if (hasColorSelection) scheduleNodeUpdate({ color })
+      if (hasColorSelection) {
+        scheduleNodeUpdate(
+          colorRelevantNodes.map((node) => node.id),
+          { color },
+        )
+      }
     },
-    [setStrokeColor, scheduleNodeUpdate, hasColorSelection],
+    [setStrokeColor, scheduleNodeUpdate, hasColorSelection, colorRelevantNodes],
   )
@@
   const handleOpacityChange = useCallback(
     (opacity: number) => {
       setStrokeOpacity(opacity)
-      if (hasColorSelection) scheduleNodeUpdate({ opacity })
+      if (hasColorSelection) {
+        scheduleNodeUpdate(
+          colorRelevantNodes.map((node) => node.id),
+          { opacity },
+        )
+      }
     },
-    [setStrokeOpacity, scheduleNodeUpdate, hasColorSelection],
+    [setStrokeOpacity, scheduleNodeUpdate, hasColorSelection, colorRelevantNodes],
   )

Also applies to: 83-97

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-color-panel.tsx` around lines 58 - 64,
scheduleNodeUpdate currently preserves pendingUpdate.current.nodeIds when a RAF
batch exists, causing queued changes to apply to an outdated selection; change
the assignment inside scheduleNodeUpdate so nodeIds is always set to the current
selection (e.g., colorRelevantNodes.map(n => n.id)) instead of using
pendingUpdate.current?.nodeIds, and make the same change in the other batching
callback at lines 83-97 so every new queued change overwrites nodeIds with the
latest selection before the RAF flush.
src/features/canvas/hooks/useCanvasFileUpload.tsx (1)

92-117: ⚠️ Potential issue | 🟠 Major

Committed storage can still be orphaned when sidebar item creation fails.

Line 92 commits the upload before createItem, and the catch at Lines 106-117 only logs/shows an error. This leaves committed blobs without a reachable sidebar reference. Please add compensating cleanup (or deferred commit orchestration) so failed createItem does not leak storage records.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx` around lines 92 - 117, In
useCanvasFileUpload, when commitUpload.mutateAsync({ storageId }) succeeds but
createItem(...) fails, the committed blob can be orphaned; add compensating
cleanup by invoking the storage deletion/rollback API (e.g., a
deleteStorage/deleteBlob or commitUpload.undo/delete method) with the same
storageId inside the createItem catch block before returning null, and
handle/delete errors (log and optionally toast) so the failure path doesn’t leak
storage records; ensure you reference commitUpload, createItem and storageId and
preserve the existing error toast flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/auth/component.ts`:
- Around line 49-55: The module currently throws during createAuth module
initialization if process.env.BETTER_AUTH_SECRET is missing, which breaks
preview/analyze; remove the eager check that throws on BETTER_AUTH_SECRET at
top-level and instead defer the validation to runtime by passing a secret getter
or validating inside the auth runtime path (e.g., inside the function that
handles requests or inside the betterAuth consumer) so that createAuth /
betterAuth(...) can be imported during analysis without throwing, but still
raise an error when the secret is actually required at request runtime;
reference the BETTER_AUTH_SECRET env var, the createAuth entry point, and the
betterAuth(...) call to locate where to move the validation.

In `@src/features/canvas/components/canvas-viewer.tsx`:
- Around line 391-394: The code sets nodesDraggable = false while still
supplying onNodeDragStart/onNodeDrag/onNodeDragStop handlers, which is
confusing; either make node dragging explicit or document the intent — add a
concise comment above the nodesDraggable declaration explaining that default
React Flow dragging is disabled intentionally (e.g., because dragging is handled
via selection-based drag, resize controls, or custom handlers) and reference the
handlers onNodeDragStart, onNodeDrag, and onNodeDragStop so future readers
understand why handlers remain while nodesDraggable is false.
- Around line 222-230: The canvas tool hooks are initialized unconditionally
allowing read-only users to trigger mutating handlers; update the code to
respect the canEdit flag by passing canEdit into useCanvasDrawing,
useCanvasEraser, useCanvasLassoSelection, useCanvasRectangleDraw and
useCanvasKeyboardShortcuts (or alternatively gate overlay binding inside
useCanvasOverlayHandlers), and change each hook (e.g., the
onPointerUp/onPointerDown handlers in useCanvasDrawing, useCanvasEraser,
useCanvasRectangleDraw and the undo/redo in useCanvasKeyboardShortcuts) to be
no-ops when canEdit is false; ensure any persisted tool in useCanvasToolStore is
either reset to a non-mutating tool (e.g., 'hand') when canEdit turns false or
that the hooks ignore it so no nodesMap.set/delete calls occur for read-only
users.

In `@src/features/canvas/components/nodes/stroke-node.tsx`:
- Line 43: The opacity handling is inconsistent: opacityOverride is treated as a
0–1 value while data.opacity is treated as 0–100 and divided by 100; update
StrokeNode rendering (the opacity prop set where opacity={opacityOverride ??
(data.opacity ?? 100) / 100}) to use a single 0–1 convention by removing the
0–100 division and treating data.opacity as 0–1, and ensure callers/creators of
stroke data convert/normalize opacity to 0–1 (e.g., when creating/updating
strokes set data.opacity to a 0–1 value) so both opacityOverride and
data.opacity share the same scale.

In `@src/features/canvas/hooks/useCanvasOverlayHandlers.ts`:
- Around line 4-9: The PointerHandlers type declares onPointerCancel but the
effect in useCanvasOverlayHandlers only registers
pointerdown/pointermove/pointerup; add handling for pointercancel by registering
window.addEventListener('pointercancel', handlers.onPointerCancel) (or a wrapper
that calls handlers.onPointerCancel if defined) alongside the other listeners,
and ensure you remove the 'pointercancel' listener in the same cleanup to
prevent stale state. Locate the effect that registers pointer event listeners in
useCanvasOverlayHandlers and mirror the pattern used for
onPointerUp/onPointerMove to wire up and tear down onPointerCancel.

In `@src/features/canvas/hooks/useCanvasWheel.ts`:
- Around line 12-54: The effect captures ref.current but doesn't depend on it,
which can leave listeners on an old element; update the useEffect dependencies
to include ref.current (or ref) alongside reactFlowInstance and ensure the
current element is captured into a local const (already done as const el =
ref.current) so the cleanup removes the listener from that specific element
(keep handler as defined). In short: add ref.current to the dependency array of
the useEffect that defines handler and uses reactFlowInstance, so the effect
re-runs and cleans up when the ref target changes.

---

Duplicate comments:
In `@src/features/canvas/components/canvas-color-panel.tsx`:
- Around line 58-64: scheduleNodeUpdate currently preserves
pendingUpdate.current.nodeIds when a RAF batch exists, causing queued changes to
apply to an outdated selection; change the assignment inside scheduleNodeUpdate
so nodeIds is always set to the current selection (e.g.,
colorRelevantNodes.map(n => n.id)) instead of using
pendingUpdate.current?.nodeIds, and make the same change in the other batching
callback at lines 83-97 so every new queued change overwrites nodeIds with the
latest selection before the RAF flush.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx`:
- Around line 92-117: In useCanvasFileUpload, when commitUpload.mutateAsync({
storageId }) succeeds but createItem(...) fails, the committed blob can be
orphaned; add compensating cleanup by invoking the storage deletion/rollback API
(e.g., a deleteStorage/deleteBlob or commitUpload.undo/delete method) with the
same storageId inside the createItem catch block before returning null, and
handle/delete errors (log and optionally toast) so the failure path doesn’t leak
storage records; ensure you reference commitUpload, createItem and storageId and
preserve the existing error toast flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5620ce80-bdb6-49e4-8d79-0e84675f90d7

📥 Commits

Reviewing files that changed from the base of the PR and between a31a514 and 854de07.

📒 Files selected for processing (19)
  • convex/auth/component.ts
  • src/features/canvas/components/canvas-color-panel.tsx
  • src/features/canvas/components/canvas-remote-cursors.tsx
  • src/features/canvas/components/canvas-viewer.tsx
  • src/features/canvas/components/nodes/resizable-node-wrapper.tsx
  • src/features/canvas/components/nodes/stroke-node.tsx
  • src/features/canvas/hooks/useCanvasEraser.ts
  • src/features/canvas/hooks/useCanvasFileUpload.tsx
  • src/features/canvas/hooks/useCanvasOverlayHandlers.ts
  • src/features/canvas/hooks/useCanvasSelectionRect.ts
  • src/features/canvas/hooks/useCanvasStrokeClick.ts
  • src/features/canvas/hooks/useCanvasWheel.ts
  • src/features/canvas/hooks/useNodeEditing.ts
  • src/features/canvas/utils/canvas-stroke-utils.ts
  • src/features/editor/components/forms/file-form/file-form.tsx
  • src/features/editor/components/viewer/folder/note-card.tsx
  • src/features/editor/hooks/useYjsReactFlowSync.ts
  • src/features/previews/utils/upload-preview.ts
  • src/styles/app.css

Comment thread convex/auth/component.ts Outdated
Comment thread src/features/canvas/components/canvas-viewer.tsx
Comment thread src/features/canvas/components/canvas-viewer.tsx
Comment thread src/features/canvas/components/nodes/stroke-node.tsx Outdated
Comment thread src/features/canvas/hooks/useCanvasWheel.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/features/sidebar/utils/sidebar-item-utils.ts (1)

59-60: ⚠️ Potential issue | 🟡 Minor

Malformed JSDoc comment: duplicate opening markers.

Lines 59-60 have two /** comment opening markers in a row, which breaks the JSDoc block and leaves an orphaned comment line.

Proposed fix
-/**
 /**
  * Type guard to check if a sidebar item is a SidebarFile.
  */

Remove the extra /** on line 59.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/sidebar/utils/sidebar-item-utils.ts` around lines 59 - 60, There
are duplicate JSDoc opening markers (`/**` twice) that break the comment block;
edit the JSDoc block immediately above the exported symbol (the comment before
the sidebar item helper in sidebar-item-utils.ts) and remove the extra `/**` so
the block starts with a single `/**` and becomes a valid JSDoc comment.
convex/yjsSync/__tests__/yjsSyncMutations.test.ts (1)

212-226: 🧹 Nitpick | 🔵 Trivial

Use COMPACT_INTERVAL - 1 for consistency in the "before interval" test.

The test at Line 179 correctly uses COMPACT_INTERVAL, but this companion test still uses a hardcoded 19. If COMPACT_INTERVAL changes, this test's assumption (and the expected row count of 20 at Line 225) would become incorrect.

♻️ Suggested fix
-    for (let i = 1; i <= 19; i++) {
+    for (let i = 1; i <= COMPACT_INTERVAL - 1; i++) {
       await dmAuth.mutation(api.yjsSync.mutations.pushUpdate, {
         documentId: noteId,
         update: makeEmptyYjsUpdate(),
       })
     }

     await t.run(async (dbCtx) => {
       const rows = await dbCtx.db
         .query('yjsUpdates')
         .withIndex('by_document_seq', (q) => q.eq('documentId', noteId))
         .collect()

-      expect(rows).toHaveLength(20)
+      expect(rows).toHaveLength(COMPACT_INTERVAL)
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/yjsSync/__tests__/yjsSyncMutations.test.ts` around lines 212 - 226,
The test currently hardcodes 19 iterations and expects 20 rows; replace the
magic number with the constant COMPACT_INTERVAL so the loop runs
COMPACT_INTERVAL - 1 times (use COMPACT_INTERVAL - 1 in the for loop bound where
dmAuth.mutation(api.yjsSync.mutations.pushUpdate, { documentId: noteId, update:
makeEmptyYjsUpdate() }) is called) and update the assertion
expect(rows).toHaveLength(20) to expect(rows).toHaveLength(COMPACT_INTERVAL) so
the test stays correct if COMPACT_INTERVAL changes.
♻️ Duplicate comments (3)
src/features/canvas/components/canvas-remote-cursors.tsx (1)

130-150: 🧹 Nitpick | 🔵 Trivial

Consider memoizing NameLabel or extracting static styles.

NameLabel creates a new inline style object on every render. Since it's exported and used per remote user, consider extracting static style properties to a constant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-remote-cursors.tsx` around lines 130 -
150, NameLabel recreates the full inline style object each render; extract the
static style properties into a constant (e.g., NAME_LABEL_BASE_STYLE) and only
merge the dynamic properties (backgroundColor and color from
getContrastColor(color)) inside NameLabel, and optionally wrap/export NameLabel
with React.memo to avoid re-renders when props are unchanged; update references
in the component to use Object.assign or spread to combine NAME_LABEL_BASE_STYLE
with the dynamic fields so only the color-dependent values are recalculated.
src/features/canvas/utils/canvas-stroke-utils.ts (1)

97-103: ⚠️ Potential issue | 🟠 Major

Handle collinear segment overlaps in hit-testing.

segmentsIntersect returns false for every parallel case (when denom ≈ 0), so overlapping or edge-aligned segments are treated as disjoint. This makes eraser and selection miss strokes when the trail runs along a stroke edge or rectangle boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/utils/canvas-stroke-utils.ts` around lines 97 - 103, The
function segmentsIntersect currently bails out on near-zero denom and treats all
parallel cases as non-intersecting; update it to detect collinear (parallel +
on-line) cases and return true when the 1D projections of the two segments
overlap. Specifically, in segmentsIntersect, when Math.abs(denom) < 1e-10
compute whether the segments are collinear by checking the cross product of
(bx-ax,by-ay) with (cx-ax,cy-ay) is near zero; if collinear, choose the dominant
axis (x or y) from (bx-ax,by-ay), build scalar intervals for [a,b] and [c,d] on
that axis (use ax, bx, cx, dx or ay, by, cy, dy accordingly), then return true
if the intervals overlap (including touching at endpoints), otherwise return
false; leave the existing t/u branch unchanged for the non-collinear case.
src/features/editor/hooks/useYjsReactFlowSync.ts (1)

189-209: ⚠️ Potential issue | 🟡 Minor

Clear spring state in drag handlers to prevent snap-back.

When a local drag starts, the RAF loop clears the spring entry (lines 137-140), but only if the loop is running. If the spring loop has stopped (springs.size === 0) and a node retains stale spring state from a prior remote drag, starting a local drag won't immediately clear it. When remoteDragPositions next changes and restarts the loop, the stale spring may cause unexpected behavior.

Consider clearing spring state directly in the drag handlers:

🛠️ Suggested fix
 const onNodeDragStart: OnNodeDrag = useCallback((_event, _node, nodes) => {
-  for (const n of nodes) draggingIds.current.add(n.id)
+  for (const n of nodes) {
+    draggingIds.current.add(n.id)
+    springStates.current.delete(n.id)
+    springActiveIds.current.delete(n.id)
+  }
 }, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/hooks/useYjsReactFlowSync.ts` around lines 189 - 209, The
drag handlers (onNodeDragStart and onNodeDragStop) need to proactively clear any
stale spring state so nodes don't snap back when the RAF spring loop (springs /
remoteDragPositions loop) restarts; inside onNodeDragStart and when ending in
onNodeDragStop, remove the node's entry from the springs map and from any
related remoteDragPositions state (and/or reset its spring data) before mutating
nodesMap, and keep the existing uses of draggingIds.current and
suppressNodeObserver.current; target the functions onNodeDragStart,
onNodeDragStop and the springs / remoteDragPositions data structures referenced
in the module and ensure the clear happens synchronously in those handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/_test/factories.helper.ts`:
- Around line 175-195: The insertSidebarItem helper currently bypasses TS checks
by casting data with "as any"; replace that with a proper typed insert payload
so schema mismatches are caught: introduce or reuse a specific insert type
(e.g., SidebarItemInsert<TTable> or a Convex insert-row mapping) and type the
local variable data to that type, then pass it to ctx.db.insert(table, data)
inside t.run; update the insertSidebarItem signature if needed so the generic
TTable can map to the correct insert row type (referencing insertSidebarItem,
data, table, t.run, and ctx.db.insert).

In `@src/features/canvas/components/canvas-color-panel.tsx`:
- Around line 36-39: The filter that builds colorRelevantNodes currently checks
for n.data?.color !== undefined but getSelectionColor only counts truthy colors,
causing mismatch for empty-string colors; update colorRelevantNodes to use a
truthy check (e.g., filter(n => !!n.data?.color)) so it matches
getSelectionColor's logic (and make the same change at the other occurrence
around getSelectionColor lines 145-153) or alternatively change
getSelectionColor to accept empty-string as a valid color—pick one consistent
approach and apply it to both colorRelevantNodes and the getSelectionColor
logic.

In `@src/features/canvas/components/canvas-minimap-node.tsx`:
- Around line 46-53: The minimap SVG in canvas-minimap-node.tsx uses the default
preserveAspectRatio which causes strokes to scale differently after non-uniform
resizes; to match StrokePreview behavior, set preserveAspectRatio="none" on the
SVG element (the same element that already sets viewBox and overflow) so the
minimap stroke scaling matches the main StrokePreview rendering.

In `@src/features/canvas/components/canvas-viewer.tsx`:
- Around line 381-388: The useMemo return object references setEditingEmbedId
but the dependency array omits it; add setEditingEmbedId to the dependency array
of the useMemo that returns updateNodeData, onResizeEnd, remoteHighlights,
canEdit, canvasUser, and editingEmbedId so the exhaustive-deps lint rule is
satisfied—locate the useMemo where those symbols are returned and append
setEditingEmbedId to the array.

In `@src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx`:
- Around line 103-118: Edge rendering in embed-canvas-content.tsx currently
draws straight center-to-center lines (edges.map, using
nodePositions.get(edge.source) / .get(edge.target)), which reduces visual
fidelity compared to the main canvas' routed edges; to improve this, replace the
simple <line> rendering with a routed path: compute edge endpoints and control
points (or use existing edge routing logic from the main canvas renderer) to
construct an SVG path (e.g., "M x1 y1 C cx1 cy1, cx2 cy2, x2 y2") and render a
<path> instead of <line>, or add a flag to choose simple vs. routed rendering so
the embedded preview can opt into bezier/handle-based routing while still
falling back to center-to-center when routing data is missing.

In `@src/features/canvas/components/nodes/resizable-node-wrapper.tsx`:
- Around line 36-69: The current logic in subscribeShift adds/removes global
keydown/keyup listeners only while shiftSubscribers.size > 0, causing Shift
state (shiftPressed) to go stale when no handles are mounted; change the
behavior so the window listeners for onShiftDown and onShiftUp are registered
once and never removed (or otherwise ensure they remain active independent of
shiftSubscribers.size), keeping shiftPressed updated at all times; update
subscribeShift to only add/remove callbacks from shiftSubscribers without
attaching/removing event listeners, referencing the functions/variables
shiftPressed, onShiftDown, onShiftUp, subscribeShift, shiftSubscribers and
notifyShiftSubscribers to locate the code to change.

In `@src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts`:
- Around line 20-28: The handler in useCanvasKeyboardShortcuts is checking both
e.code and e.key which breaks non-QWERTY layouts; update the keyboard logic to
drop the physical key checks (remove any comparisons against the variable code
or 'KeyZ'/'KeyY') and rely solely on e.key.toLowerCase() (the existing key
variable) when detecting undo/redo (the branches that call undo() and redo());
keep e.preventDefault() and the shiftKey checks as-is so Ctrl/Cmd+z vs
Ctrl/Cmd+Shift+z behave correctly.

In `@src/features/canvas/hooks/useCanvasSelectionRect.ts`:
- Around line 59-65: The mouse-up/deselection path can see a stale
lastFlowRectRef.current because the RAF that writes it may be cancelled; before
canceling rafRef.current (and/or at the start of the mouse-up handler) copy the
previous selection rect into lastFlowRectRef.current (e.g., set
lastFlowRectRef.current = prevRect or wasActive ? prevRect : null) so the
deselection branch that reads lastFlowRectRef.current has the correct previous
value; update the write at the top of the handler (where prevRect and
rafRef.current are handled) and ensure the same fix is applied to the other
affected sections referenced (the blocks around lines 67-69 and 103-132 that
also rely on lastFlowRectRef.current).

In `@src/features/editor/components/forms/file-form/file-form.tsx`:
- Around line 185-192: Replace the raw console.error call in the create flow
with the module's error handler: when calling
generatePdfPreviewIfNeeded(fileUpload.file, newFileId as Id<'files'>) attach a
.catch that calls handleError(err, 'PDF preview generation failed') instead of
console.error so it matches the update flow's handling; locate this in
file-form.tsx around the generatePdfPreviewIfNeeded invocation and reuse the
existing handleError utility.

In `@src/features/editor/components/note-content.tsx`:
- Around line 128-142: The useEffect that creates the BlockNoteEditor uses
`content` but intentionally omits it from the dependency array; add an inline
ESLint suppression comment above the useEffect such as `//
eslint-disable-next-line react-hooks/exhaustive-deps` and a short comment
documenting the intent (e.g., "create editor only once; intentionally omit
content from deps") so the `useEffect` (which calls `BlockNoteEditor.create`
with `editorSchema`, sets state via `setEditor`, and invokes
`onEditorChangeRef.current`) does not trigger exhaustive-deps warnings.

In `@src/features/editor/hooks/useYjsReactFlowSync.ts`:
- Line 197: The code uses non-null assertions nodesMap.doc! and edgesMap.doc!
(in the transact/remove calls and in handlers onNodesDelete and onEdgesDelete)
which will throw if the Yjs docs aren't initialized; fix by adding explicit
null/undefined guards (e.g., if (!nodesMap?.doc) return or if (!edgesMap?.doc)
return) before any use of .doc or calling .transact/remove, and update
onNodesDelete and onEdgesDelete to early-return when their respective .doc is
missing instead of using the ! operator.

In `@src/features/sidebar/components/forms/color-picker.tsx`:
- Line 3: The file imports the same module using two different path styles;
consolidate to the alias import to keep paths consistent: replace the relative
import for DEFAULT_ITEM_COLOR (currently from '../../utils/sidebar-item-utils')
with the alias import used elsewhere
(~/features/sidebar/utils/sidebar-item-utils) and remove the duplicate import so
only one import of DEFAULT_ITEM_COLOR from that alias remains in the
color-picker.tsx module.

---

Outside diff comments:
In `@convex/yjsSync/__tests__/yjsSyncMutations.test.ts`:
- Around line 212-226: The test currently hardcodes 19 iterations and expects 20
rows; replace the magic number with the constant COMPACT_INTERVAL so the loop
runs COMPACT_INTERVAL - 1 times (use COMPACT_INTERVAL - 1 in the for loop bound
where dmAuth.mutation(api.yjsSync.mutations.pushUpdate, { documentId: noteId,
update: makeEmptyYjsUpdate() }) is called) and update the assertion
expect(rows).toHaveLength(20) to expect(rows).toHaveLength(COMPACT_INTERVAL) so
the test stays correct if COMPACT_INTERVAL changes.

In `@src/features/sidebar/utils/sidebar-item-utils.ts`:
- Around line 59-60: There are duplicate JSDoc opening markers (`/**` twice)
that break the comment block; edit the JSDoc block immediately above the
exported symbol (the comment before the sidebar item helper in
sidebar-item-utils.ts) and remove the extra `/**` so the block starts with a
single `/**` and becomes a valid JSDoc comment.

---

Duplicate comments:
In `@src/features/canvas/components/canvas-remote-cursors.tsx`:
- Around line 130-150: NameLabel recreates the full inline style object each
render; extract the static style properties into a constant (e.g.,
NAME_LABEL_BASE_STYLE) and only merge the dynamic properties (backgroundColor
and color from getContrastColor(color)) inside NameLabel, and optionally
wrap/export NameLabel with React.memo to avoid re-renders when props are
unchanged; update references in the component to use Object.assign or spread to
combine NAME_LABEL_BASE_STYLE with the dynamic fields so only the
color-dependent values are recalculated.

In `@src/features/canvas/utils/canvas-stroke-utils.ts`:
- Around line 97-103: The function segmentsIntersect currently bails out on
near-zero denom and treats all parallel cases as non-intersecting; update it to
detect collinear (parallel + on-line) cases and return true when the 1D
projections of the two segments overlap. Specifically, in segmentsIntersect,
when Math.abs(denom) < 1e-10 compute whether the segments are collinear by
checking the cross product of (bx-ax,by-ay) with (cx-ax,cy-ay) is near zero; if
collinear, choose the dominant axis (x or y) from (bx-ax,by-ay), build scalar
intervals for [a,b] and [c,d] on that axis (use ax, bx, cx, dx or ay, by, cy, dy
accordingly), then return true if the intervals overlap (including touching at
endpoints), otherwise return false; leave the existing t/u branch unchanged for
the non-collinear case.

In `@src/features/editor/hooks/useYjsReactFlowSync.ts`:
- Around line 189-209: The drag handlers (onNodeDragStart and onNodeDragStop)
need to proactively clear any stale spring state so nodes don't snap back when
the RAF spring loop (springs / remoteDragPositions loop) restarts; inside
onNodeDragStart and when ending in onNodeDragStop, remove the node's entry from
the springs map and from any related remoteDragPositions state (and/or reset its
spring data) before mutating nodesMap, and keep the existing uses of
draggingIds.current and suppressNodeObserver.current; target the functions
onNodeDragStart, onNodeDragStop and the springs / remoteDragPositions data
structures referenced in the module and ensure the clear happens synchronously
in those handlers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 731ebcae-c1b0-4161-b301-61142b546140

📥 Commits

Reviewing files that changed from the base of the PR and between a31a514 and 6957a5c.

📒 Files selected for processing (49)
  • convex/_test/factories.helper.ts
  • convex/canvases/functions/createCanvas.ts
  • convex/canvases/functions/updateCanvas.ts
  • convex/folders/functions/getFolderContentsForDownload.ts
  • convex/gameMaps/__tests__/gameMaps.test.ts
  • convex/notes/__tests__/notes.test.ts
  • convex/notes/__tests__/persistBlocks.test.ts
  • convex/notes/functions/createNote.ts
  • convex/sidebarItems/__tests__/previewCleanup.test.ts
  • convex/sidebarItems/__tests__/previewGeneration.test.ts
  • convex/sidebarItems/__tests__/sidebarItemValidation.test.ts
  • convex/sidebarItems/functions/claimPreviewGeneration.ts
  • convex/sidebarItems/functions/enhanceSidebarItem.ts
  • convex/sidebarItems/functions/getSidebarItemById.ts
  • convex/sidebarItems/functions/getSidebarItemBySlug.ts
  • convex/sidebarItems/functions/setPreviewImage.ts
  • convex/sidebarItems/schema/baseFields.ts
  • convex/sidebarItems/types/baseTypes.ts
  • convex/sidebarItems/validation.ts
  • convex/yjsSync/__tests__/yjsSyncMutations.test.ts
  • convex/yjsSync/functions/compactUpdates.ts
  • src/features/canvas/components/canvas-color-panel.tsx
  • src/features/canvas/components/canvas-minimap-node.tsx
  • src/features/canvas/components/canvas-remote-cursors.tsx
  • src/features/canvas/components/canvas-viewer.tsx
  • src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx
  • src/features/canvas/components/nodes/resizable-node-wrapper.tsx
  • src/features/canvas/components/nodes/stroke-node.tsx
  • src/features/canvas/hooks/useCanvasEraser.ts
  • src/features/canvas/hooks/useCanvasFileUpload.tsx
  • src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts
  • src/features/canvas/hooks/useCanvasOverlayHandlers.ts
  • src/features/canvas/hooks/useCanvasSelectionRect.ts
  • src/features/canvas/hooks/useCanvasStrokeClick.ts
  • src/features/canvas/hooks/useCanvasWheel.ts
  • src/features/canvas/hooks/useNodeEditing.ts
  • src/features/canvas/utils/canvas-stroke-utils.ts
  • src/features/dnd/utils/__tests__/dnd-registry.test.ts
  • src/features/dnd/utils/dnd-registry.ts
  • src/features/editor/components/forms/file-form/file-form.tsx
  • src/features/editor/components/note-content.tsx
  • src/features/editor/components/viewer/folder/note-card.tsx
  • src/features/editor/components/viewer/map/map-viewer.tsx
  • src/features/editor/hooks/useYjsReactFlowSync.ts
  • src/features/previews/utils/upload-preview.ts
  • src/features/sidebar/components/forms/color-picker.tsx
  • src/features/sidebar/components/sidebar-root/droppable-root.tsx
  • src/features/sidebar/utils/sidebar-item-utils.ts
  • src/styles/app.css

Comment thread convex/_test/factories.helper.ts
Comment thread src/features/canvas/components/canvas-color-panel.tsx
Comment thread src/features/canvas/components/canvas-minimap-node.tsx
Comment thread src/features/canvas/components/canvas-viewer.tsx
Comment thread src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx Outdated
Comment thread src/features/canvas/hooks/useCanvasSelectionRect.ts
Comment thread src/features/editor/components/forms/file-form/file-form.tsx
Comment thread src/features/editor/components/note-content.tsx
Comment thread src/features/editor/hooks/useYjsReactFlowSync.ts Outdated
Comment thread src/features/sidebar/components/forms/color-picker.tsx Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/features/sidebar/utils/sidebar-item-utils.ts (1)

59-60: ⚠️ Potential issue | 🟡 Minor

Duplicate JSDoc comment opener creates malformed documentation.

There's a duplicated /** on lines 59-60. This results in malformed JSDoc and the comment for isFile is broken.

Proposed fix
-/**
 /**
  * Type guard to check if a sidebar item is a SidebarFile.
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/sidebar/utils/sidebar-item-utils.ts` around lines 59 - 60, The
JSDoc opener is duplicated which breaks the comment for isFile; edit the comment
block in sidebar-item-utils.ts (the JSDoc immediately above the isFile function)
and remove the extra `/**` so there is only a single JSDoc opener, ensuring the
comment is well-formed and applies to the isFile declaration.
convex/yjsSync/__tests__/yjsSyncMutations.test.ts (1)

212-226: 🧹 Nitpick | 🔵 Trivial

Inconsistent use of hardcoded values vs COMPACT_INTERVAL constant.

The "triggers compaction at COMPACT_INTERVAL" test correctly uses the imported constant, but this test still hardcodes 19 and 20. If COMPACT_INTERVAL changes, this test will become incorrect.

♻️ Use COMPACT_INTERVAL for consistency
   it('does not trigger compaction before interval', async () => {
     const ctx = await setupCampaignContext(t)
     const dmAuth = asDm(ctx)

     const { noteId } = await dmAuth.mutation(api.notes.mutations.createNote, {
       campaignId: ctx.campaignId,
       name: 'No Compact Note',
       parentId: null,
     })

-    for (let i = 1; i <= 19; i++) {
+    for (let i = 1; i < COMPACT_INTERVAL; i++) {
       await dmAuth.mutation(api.yjsSync.mutations.pushUpdate, {
         documentId: noteId,
         update: makeEmptyYjsUpdate(),
       })
     }

     await t.run(async (dbCtx) => {
       const rows = await dbCtx.db
         .query('yjsUpdates')
         .withIndex('by_document_seq', (q) => q.eq('documentId', noteId))
         .collect()

-      expect(rows).toHaveLength(20)
+      expect(rows).toHaveLength(COMPACT_INTERVAL)
     })
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/yjsSync/__tests__/yjsSyncMutations.test.ts` around lines 212 - 226,
Replace the hardcoded 19/20 with the COMPACT_INTERVAL constant: import/use
COMPACT_INTERVAL in this test and change the loop to run for i = 1; i <=
COMPACT_INTERVAL - 1; i++ when calling
dmAuth.mutation(api.yjsSync.mutations.pushUpdate, ...) and assert
expect(rows).toHaveLength(COMPACT_INTERVAL) after t.run; reference the existing
symbols dmAuth.mutation, api.yjsSync.mutations.pushUpdate, makeEmptyYjsUpdate,
noteId, and t.run to locate where to make the change.
convex/_test/factories.helper.ts (1)

450-499: 🧹 Nitpick | 🔵 Trivial

Consider adding 'canvas' to setupFolderTree leaf types.

The leafType parameter supports 'note' | 'file' | 'gameMap' but not 'canvas'. If tests need to create folder trees with canvas leaves, this helper would need updating.

♻️ Optional: Add canvas support to setupFolderTree
 export async function setupFolderTree(
   t: T,
   campaignId: Id<'campaigns'>,
   creatorProfileId: Id<'userProfiles'>,
   opts: {
     depth: number
     inheritShares?: Array<boolean>
-    leafType?: 'note' | 'file' | 'gameMap'
+    leafType?: 'note' | 'file' | 'gameMap' | 'canvas'
   },
 ) {
   const { depth, inheritShares = [], leafType = 'note' } = opts
   // ... existing code ...
 
     case 'gameMap': {
       const { mapId } = await createGameMap(t, campaignId, creatorProfileId, {
         parentId: leafParent,
       })
       return { folders, leaf: mapId }
     }
+    case 'canvas': {
+      const { canvasId } = await createCanvas(t, campaignId, creatorProfileId, {
+        parentId: leafParent,
+      })
+      return { folders, leaf: canvasId }
+    }
     default: {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/_test/factories.helper.ts` around lines 450 - 499, The setupFolderTree
helper's leafType union omits 'canvas', so tests cannot create folder trees with
canvas leaves; update the setupFolderTree function to include 'canvas' in the
leafType union and add a case that calls createCanvas(t, campaignId,
creatorProfileId, { parentId: leafParent }) and returns { folders, leaf:
canvasId }; also update the default/exhaustive handling if needed and any
callers/types that reference setupFolderTree's opts.leafType to accept 'canvas'.
♻️ Duplicate comments (5)
src/features/editor/components/forms/file-form/file-form.tsx (1)

185-192: ⚠️ Potential issue | 🟡 Minor

Use handleError instead of console.error in create flow.

Line 189-191 still uses raw console.error, while Line 166-167 uses the shared handleError path. Please align create flow with the same error handler for consistency and centralized logging.

♻️ Proposed fix
         if (fileUpload.file) {
           generatePdfPreviewIfNeeded(
             fileUpload.file,
             newFileId as Id<'files'>,
-          ).catch((err: unknown) =>
-            console.error('PDF preview generation failed:', err),
-          )
+          ).catch((err: unknown) =>
+            handleError(err, 'PDF preview generation failed'),
+          )
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/components/forms/file-form/file-form.tsx` around lines
185 - 192, Replace the raw console.error call inside the create flow where
generatePdfPreviewIfNeeded(...).catch(...) is used with the shared error
handler: call handleError with the caught error and a descriptive context (e.g.,
"PDF preview generation failed") so the create flow uses handleError instead of
console.error; locate the promise catch attached to generatePdfPreviewIfNeeded
and swap console.error('PDF preview generation failed:', err) for
handleError(err, 'PDF preview generation failed') while keeping newFileId and
fileUpload.file unchanged.
src/features/canvas/components/nodes/resizable-node-wrapper.tsx (1)

50-69: ⚠️ Potential issue | 🟠 Major

Reset shiftPressed on window blur.

If the tab loses focus while Shift is held, onShiftUp never fires and shiftPressed stays true until another key event. The next resize then starts with keepAspectRatio stuck on. Add a blur handler that clears the flag and notifies subscribers.

Possible fix
 function onShiftUp(e: KeyboardEvent) {
   if (e.key === 'Shift' && shiftPressed) {
     shiftPressed = false
     notifyShiftSubscribers()
   }
 }
+
+function resetShift() {
+  if (!shiftPressed) return
+  shiftPressed = false
+  notifyShiftSubscribers()
+}
 
 function subscribeShift(cb: () => void) {
   if (shiftSubscribers.size === 0) {
     window.addEventListener('keydown', onShiftDown)
     window.addEventListener('keyup', onShiftUp)
+    window.addEventListener('blur', resetShift)
   }
   shiftSubscribers.add(cb)
   return () => {
     shiftSubscribers.delete(cb)
     if (shiftSubscribers.size === 0) {
       window.removeEventListener('keydown', onShiftDown)
       window.removeEventListener('keyup', onShiftUp)
+      window.removeEventListener('blur', resetShift)
     }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/nodes/resizable-node-wrapper.tsx` around lines
50 - 69, The shiftPressed flag can remain true if the tab loses focus while
Shift is held; update subscribeShift/onShiftUp logic to also attach a window
'blur' handler (e.g., onWindowBlur) when the first subscriber is added and
remove it when the last subscriber is removed; implement onWindowBlur to set
shiftPressed = false and call notifyShiftSubscribers(), and ensure the same
cleanup that removes onShiftDown/onShiftUp also removes the blur listener in
subscribeShift's teardown.
src/features/canvas/utils/canvas-stroke-utils.ts (1)

87-103: ⚠️ Potential issue | 🟠 Major

Handle collinear overlaps in segmentsIntersect.

The denom ≈ 0 => false path still treats parallel/collinear segments as disjoint. Eraser, lasso, and rect hit-testing then miss cases where the trail runs exactly along a stroke edge or rectangle boundary. Add orientation/on-segment checks before falling back to the t/u test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/utils/canvas-stroke-utils.ts` around lines 87 - 103, The
segmentsIntersect function currently returns false when denom ≈ 0, missing
collinear overlaps; modify segmentsIntersect to, when Math.abs(denom) < 1e-10,
perform collinearity and on-segment checks: compute orientation (cross products)
for endpoints (e.g., orient(ax,ay,bx,by, cx,cy) etc.) and if collinear, check if
any endpoint of one segment lies on the other segment using a between/projection
test (compare min/max for x and y or use dot product) before returning false;
keep existing t and u intersection logic for non-parallel cases so
segmentsIntersect still returns true for overlapping collinear segments as well
as proper crossings.
src/features/canvas/components/canvas-color-panel.tsx (1)

58-64: ⚠️ Potential issue | 🟠 Major

Don't reuse a pending batch for a different selection.

pendingUpdate.current.nodeIds is kept whenever a RAF batch already exists. If the selection changes before that frame and the user makes another color/opacity edit, the second update is still flushed to the old node set.

Possible fix
   const scheduleNodeUpdate = useCallback(
     (data: Record<string, unknown>) => {
+      const nodeIds = colorRelevantNodes.map((n) => n.id)
+      const pending = pendingUpdate.current
+      const sameTarget =
+        pending?.nodeIds.length === nodeIds.length &&
+        pending.nodeIds.every((id, index) => id === nodeIds[index])
+
       pendingUpdate.current = {
-        data: { ...pendingUpdate.current?.data, ...data },
-        nodeIds:
-          pendingUpdate.current?.nodeIds ?? colorRelevantNodes.map((n) => n.id),
+        data: sameTarget ? { ...pending?.data, ...data } : data,
+        nodeIds,
       }
       if (!rafId.current) {
         rafId.current = requestAnimationFrame(() => {
           rafId.current = 0
           flushNodeUpdates()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-color-panel.tsx` around lines 58 - 64,
scheduleNodeUpdate currently reuses pendingUpdate.current.nodeIds from an
earlier RAF batch, causing edits to flush to the wrong selection; update the
logic in scheduleNodeUpdate so nodeIds are always derived from the current
selection (colorRelevantNodes.map(n => n.id)) instead of reusing
pendingUpdate.current?.nodeIds — i.e., when setting pendingUpdate.current,
always set nodeIds: colorRelevantNodes.map((n) => n.id) (or compute a stable
currentSelectionIds variable and use that) so a new edit within the same frame
targets the active selection rather than the previous one.
src/features/canvas/hooks/useCanvasSelectionRect.ts (1)

59-69: ⚠️ Potential issue | 🟠 Major

Preserve the last rect even when mouse-up beats the RAF.

Lines 62-69 cancel the pending frame and then rely on lastFlowRectRef.current for the deselection pass. A drag that starts and ends within one frame never writes that ref, so strokes outside the finished rect stay selected. Cleanup should also clear lastFlowRectRef.current so the next session can't reuse a stale rect.

Also applies to: 160-169

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasSelectionRect.ts` around lines 59 - 69,
The deselection path in useCanvasSelectionRect cancels a pending RAF and then
relies on lastFlowRectRef.current (and prevRect) which can be stale if a drag
starts and ends within one frame; update the cleanup so when handling
!userSelectionRect you clear lastFlowRectRef.current (and reset prevRect
appropriately) immediately after performing the deselection logic so the next
session cannot reuse a stale rect; apply the same fix to the duplicate block
around the other deselection branch (the block referenced at the second
occurrence) to ensure lastFlowRectRef.current is always cleared whenever the
selection is finalized or cancelled.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/notes/functions/createNote.ts`:
- Around line 75-80: The code repeats the same condition `content &&
content.length > 0`; consolidate by combining the two blocks into one: wrap the
call to saveTopLevelBlocksForNote and the initialization of `initialState`
inside a single `if (content && content.length > 0)` block so
`saveTopLevelBlocksForNote(ctx, { noteId, content })` is awaited and then
`initialState` is set in the same scope (ensure `initialState` remains defined
as `undefined` outside when there is no content and that any later uses of
`initialState` still see the correct value).

In `@convex/sidebarItems/__tests__/previewGeneration.test.ts`:
- Around line 247-254: The test currently allows a 5000ms tolerance when
asserting note!.previewUpdatedAt which is overly generous; update the assertion
in previewGeneration.test.ts (inside the t.run callback where note is fetched
via dbCtx.db.get with noteId/storageId) to use a tighter bound such as 1000ms
(expect(Math.abs(now - note!.previewUpdatedAt!)).toBeLessThan(1000)) or, if
there are known timing reasons, add an inline comment documenting why 5000ms is
required so future readers understand the rationale.

In `@src/features/canvas/components/canvas-viewer.tsx`:
- Around line 470-478: The component CanvasDropOverlay is receiving a prop named
ref which is deprecated; update it to use React.forwardRef by converting
CanvasDropOverlay into a forwardRef component that accepts (props, ref:
React.Ref<HTMLDivElement | null>) so the DOM ref is passed via the second
parameter instead of a prop, remove the ref entry from the props shape ({
isDropTarget, isFileDropTarget }) and update all call sites to pass the ref as a
normal JSX ref (ref={...}) rather than a prop; alternatively, if you prefer not
to forward refs, rename the prop to a non-reserved name like containerRef or
overlayRef across the component and its usages to avoid React reserved ref
semantics.

In `@src/features/canvas/components/nodes/stroke-node.tsx`:
- Around line 63-90: The resize currently only persists width/height/position in
onResizeEnd (nodesMap.set) while StrokePreview and pointsToPathD render scaled
geometry from width/height, causing hit-testing to use stale data.points and
data.bounds; update onResizeEnd (or the resize commit path) to compute
scaledPoints and scaledBounds from the original data.points/bounds using the
width/height scale factors and call updateNodeData(nodeId, { points:
scaledPoints, bounds: scaledBounds }) so the stored geometry matches the
rendered geometry (alternatively, adjust hit-testing to apply the same display
scale, but prefer recomputing via updateNodeData).

In `@src/features/canvas/hooks/useCanvasEraser.ts`:
- Around line 90-107: onPointerUp currently cancels the pending RAF and clears
trail/marked without flushing the final eraser segment, so a fast lift can miss
the last intersections; modify onPointerUp in useCanvasEraser.ts to append the
pointer-up coordinate to trailRef.current (or otherwise ensure the final point
is included) and call testIntersections() synchronously before
cancelling/clearing the RAF/marked state (use eraserRafRef, trailRef, markedRef,
testIntersections, nodesMap.transact and useCanvasToolStore.setErasingStrokeIds
to ensure the final segment is processed and marked deletions are applied) so
the last segment is evaluated even if the RAF hasn't fired yet.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx`:
- Around line 46-58: Extract the repeated call to isTextFile(file.type,
file.name) into a local boolean variable (e.g., const isText =
isTextFile(file.type, file.name)) at the top of useCanvasFileUpload or just
before toast creation, then use that variable in the ToastContent
message/progress and the subsequent try branch; update references to isTextFile
in the toast loading call, the progress prop, and the if (isTextFile(...))
condition to use isText so you avoid redundant evaluations and improve
readability.

In `@src/features/editor/hooks/useYjsReactFlowSync.ts`:
- Around line 193-205: The drag bookkeeping (draggingIds.current.delete(...)) is
inside the persistence guard and can be skipped if nodesMap is unavailable; move
the deletion out so it always runs: in useYjsReactFlowSync's onNodeDragStop
callback, after the early nodesMap check and after suppressNodeObserver.current
= true, iterate over the incoming nodes and call
draggingIds.current.delete(n.id) for each before entering
nodesMap.doc!.transact, then keep the existing transact block to update nodesMap
(get/set) and remove the deletions from inside that loop.

---

Outside diff comments:
In `@convex/_test/factories.helper.ts`:
- Around line 450-499: The setupFolderTree helper's leafType union omits
'canvas', so tests cannot create folder trees with canvas leaves; update the
setupFolderTree function to include 'canvas' in the leafType union and add a
case that calls createCanvas(t, campaignId, creatorProfileId, { parentId:
leafParent }) and returns { folders, leaf: canvasId }; also update the
default/exhaustive handling if needed and any callers/types that reference
setupFolderTree's opts.leafType to accept 'canvas'.

In `@convex/yjsSync/__tests__/yjsSyncMutations.test.ts`:
- Around line 212-226: Replace the hardcoded 19/20 with the COMPACT_INTERVAL
constant: import/use COMPACT_INTERVAL in this test and change the loop to run
for i = 1; i <= COMPACT_INTERVAL - 1; i++ when calling
dmAuth.mutation(api.yjsSync.mutations.pushUpdate, ...) and assert
expect(rows).toHaveLength(COMPACT_INTERVAL) after t.run; reference the existing
symbols dmAuth.mutation, api.yjsSync.mutations.pushUpdate, makeEmptyYjsUpdate,
noteId, and t.run to locate where to make the change.

In `@src/features/sidebar/utils/sidebar-item-utils.ts`:
- Around line 59-60: The JSDoc opener is duplicated which breaks the comment for
isFile; edit the comment block in sidebar-item-utils.ts (the JSDoc immediately
above the isFile function) and remove the extra `/**` so there is only a single
JSDoc opener, ensuring the comment is well-formed and applies to the isFile
declaration.

---

Duplicate comments:
In `@src/features/canvas/components/canvas-color-panel.tsx`:
- Around line 58-64: scheduleNodeUpdate currently reuses
pendingUpdate.current.nodeIds from an earlier RAF batch, causing edits to flush
to the wrong selection; update the logic in scheduleNodeUpdate so nodeIds are
always derived from the current selection (colorRelevantNodes.map(n => n.id))
instead of reusing pendingUpdate.current?.nodeIds — i.e., when setting
pendingUpdate.current, always set nodeIds: colorRelevantNodes.map((n) => n.id)
(or compute a stable currentSelectionIds variable and use that) so a new edit
within the same frame targets the active selection rather than the previous one.

In `@src/features/canvas/components/nodes/resizable-node-wrapper.tsx`:
- Around line 50-69: The shiftPressed flag can remain true if the tab loses
focus while Shift is held; update subscribeShift/onShiftUp logic to also attach
a window 'blur' handler (e.g., onWindowBlur) when the first subscriber is added
and remove it when the last subscriber is removed; implement onWindowBlur to set
shiftPressed = false and call notifyShiftSubscribers(), and ensure the same
cleanup that removes onShiftDown/onShiftUp also removes the blur listener in
subscribeShift's teardown.

In `@src/features/canvas/hooks/useCanvasSelectionRect.ts`:
- Around line 59-69: The deselection path in useCanvasSelectionRect cancels a
pending RAF and then relies on lastFlowRectRef.current (and prevRect) which can
be stale if a drag starts and ends within one frame; update the cleanup so when
handling !userSelectionRect you clear lastFlowRectRef.current (and reset
prevRect appropriately) immediately after performing the deselection logic so
the next session cannot reuse a stale rect; apply the same fix to the duplicate
block around the other deselection branch (the block referenced at the second
occurrence) to ensure lastFlowRectRef.current is always cleared whenever the
selection is finalized or cancelled.

In `@src/features/canvas/utils/canvas-stroke-utils.ts`:
- Around line 87-103: The segmentsIntersect function currently returns false
when denom ≈ 0, missing collinear overlaps; modify segmentsIntersect to, when
Math.abs(denom) < 1e-10, perform collinearity and on-segment checks: compute
orientation (cross products) for endpoints (e.g., orient(ax,ay,bx,by, cx,cy)
etc.) and if collinear, check if any endpoint of one segment lies on the other
segment using a between/projection test (compare min/max for x and y or use dot
product) before returning false; keep existing t and u intersection logic for
non-parallel cases so segmentsIntersect still returns true for overlapping
collinear segments as well as proper crossings.

In `@src/features/editor/components/forms/file-form/file-form.tsx`:
- Around line 185-192: Replace the raw console.error call inside the create flow
where generatePdfPreviewIfNeeded(...).catch(...) is used with the shared error
handler: call handleError with the caught error and a descriptive context (e.g.,
"PDF preview generation failed") so the create flow uses handleError instead of
console.error; locate the promise catch attached to generatePdfPreviewIfNeeded
and swap console.error('PDF preview generation failed:', err) for
handleError(err, 'PDF preview generation failed') while keeping newFileId and
fileUpload.file unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b048339b-9adc-4a78-ba00-1b9623af9fa9

📥 Commits

Reviewing files that changed from the base of the PR and between a31a514 and 6957a5c.

📒 Files selected for processing (49)
  • convex/_test/factories.helper.ts
  • convex/canvases/functions/createCanvas.ts
  • convex/canvases/functions/updateCanvas.ts
  • convex/folders/functions/getFolderContentsForDownload.ts
  • convex/gameMaps/__tests__/gameMaps.test.ts
  • convex/notes/__tests__/notes.test.ts
  • convex/notes/__tests__/persistBlocks.test.ts
  • convex/notes/functions/createNote.ts
  • convex/sidebarItems/__tests__/previewCleanup.test.ts
  • convex/sidebarItems/__tests__/previewGeneration.test.ts
  • convex/sidebarItems/__tests__/sidebarItemValidation.test.ts
  • convex/sidebarItems/functions/claimPreviewGeneration.ts
  • convex/sidebarItems/functions/enhanceSidebarItem.ts
  • convex/sidebarItems/functions/getSidebarItemById.ts
  • convex/sidebarItems/functions/getSidebarItemBySlug.ts
  • convex/sidebarItems/functions/setPreviewImage.ts
  • convex/sidebarItems/schema/baseFields.ts
  • convex/sidebarItems/types/baseTypes.ts
  • convex/sidebarItems/validation.ts
  • convex/yjsSync/__tests__/yjsSyncMutations.test.ts
  • convex/yjsSync/functions/compactUpdates.ts
  • src/features/canvas/components/canvas-color-panel.tsx
  • src/features/canvas/components/canvas-minimap-node.tsx
  • src/features/canvas/components/canvas-remote-cursors.tsx
  • src/features/canvas/components/canvas-viewer.tsx
  • src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx
  • src/features/canvas/components/nodes/resizable-node-wrapper.tsx
  • src/features/canvas/components/nodes/stroke-node.tsx
  • src/features/canvas/hooks/useCanvasEraser.ts
  • src/features/canvas/hooks/useCanvasFileUpload.tsx
  • src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts
  • src/features/canvas/hooks/useCanvasOverlayHandlers.ts
  • src/features/canvas/hooks/useCanvasSelectionRect.ts
  • src/features/canvas/hooks/useCanvasStrokeClick.ts
  • src/features/canvas/hooks/useCanvasWheel.ts
  • src/features/canvas/hooks/useNodeEditing.ts
  • src/features/canvas/utils/canvas-stroke-utils.ts
  • src/features/dnd/utils/__tests__/dnd-registry.test.ts
  • src/features/dnd/utils/dnd-registry.ts
  • src/features/editor/components/forms/file-form/file-form.tsx
  • src/features/editor/components/note-content.tsx
  • src/features/editor/components/viewer/folder/note-card.tsx
  • src/features/editor/components/viewer/map/map-viewer.tsx
  • src/features/editor/hooks/useYjsReactFlowSync.ts
  • src/features/previews/utils/upload-preview.ts
  • src/features/sidebar/components/forms/color-picker.tsx
  • src/features/sidebar/components/sidebar-root/droppable-root.tsx
  • src/features/sidebar/utils/sidebar-item-utils.ts
  • src/styles/app.css

Comment thread convex/notes/functions/createNote.ts Outdated
Comment thread convex/sidebarItems/__tests__/previewGeneration.test.ts
Comment thread src/features/canvas/components/canvas-viewer.tsx
Comment thread src/features/canvas/components/nodes/stroke-node.tsx Outdated
Comment thread src/features/canvas/hooks/useCanvasEraser.ts Outdated
Comment thread src/features/canvas/hooks/useCanvasFileUpload.tsx Outdated
Comment thread src/features/editor/hooks/useYjsReactFlowSync.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/features/sidebar/utils/sidebar-item-utils.ts (1)

59-67: ⚠️ Potential issue | 🟡 Minor

Fix malformed JSDoc comment - duplicate opening marker.

There's a duplicate /** at lines 59-60 creating invalid JSDoc syntax.

📝 Proposed fix
-/**
 /**
  * Type guard to check if a sidebar item is a SidebarFile.
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/sidebar/utils/sidebar-item-utils.ts` around lines 59 - 67, The
JSDoc for the isFile type guard is malformed due to a duplicate opening `/**`;
edit the JSDoc above the exported function isFile to remove the extra `/**` so
it begins with a single valid `/**` and maintains the existing description,
leaving the function signature, isSidebarItemType call, and SIDEBAR_ITEM_TYPES
reference unchanged.
♻️ Duplicate comments (15)
src/features/editor/components/forms/file-form/file-form.tsx (1)

185-192: ⚠️ Potential issue | 🟡 Minor

Use handleError in create flow instead of console.error.

Line 190 still uses raw console.error, which is inconsistent with the update path and bypasses centralized error handling.

♻️ Proposed fix
         if (fileUpload.file) {
           generatePdfPreviewIfNeeded(
             fileUpload.file,
             newFileId as Id<'files'>,
-          ).catch((err: unknown) =>
-            console.error('PDF preview generation failed:', err),
-          )
+          ).catch((err: unknown) =>
+            handleError(err, 'PDF preview generation failed'),
+          )
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/components/forms/file-form/file-form.tsx` around lines
185 - 192, The PDF preview generation catch currently logs with console.error;
replace that with the centralized error handler by calling handleError(...)
inside the catch for generatePdfPreviewIfNeeded(fileUpload.file, newFileId as
Id<'files'>), passing a descriptive message (e.g., "PDF preview generation
failed for new file") and the caught error so the create flow uses the same
error handling as the update path; ensure you import or reference handleError in
this scope and preserve the promise chain (i.e., .catch((err) =>
handleError('PDF preview generation failed for new file', err))).
convex/folders/functions/getFolderContentsForDownload.ts (1)

116-118: ⚠️ Potential issue | 🟠 Major

Don’t silently omit canvases from exports.

Line 117 currently skips canvas items with break, so downloads can succeed while returning incomplete content. This should either explicitly fail or report unsupported items to callers.

Minimal safe fix (explicit failure)
       // TODO: add canvas -> img export
-      case SIDEBAR_ITEM_TYPES.canvases:
-        break
+      case SIDEBAR_ITEM_TYPES.canvases: {
+        throw new Error('Canvas export is not supported yet')
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/folders/functions/getFolderContentsForDownload.ts` around lines 116 -
118, The switch in getFolderContentsForDownload currently ignores canvases (case
SIDEBAR_ITEM_TYPES.canvases: break) which returns incomplete exports; change
this to explicitly fail or report unsupported items instead of breaking — e.g.,
in the getFolderContentsForDownload switch for SIDEBAR_ITEM_TYPES.canvases throw
or return a descriptive error/unsupported-item result (including the item
id/context) so callers know canvases are not handled; ensure the error
type/message is consistent with surrounding error handling in this function.
src/features/editor/components/note-content.tsx (2)

196-214: 🧹 Nitpick | 🔵 Trivial

RAF retry loop would benefit from an explanatory comment.

The retry loop waits for Tiptap plugins to initialize before patching. This is a workaround for BlockNote's async plugin initialization. Consider adding a comment explaining why this is necessary.

📝 Add explanatory comment
     let cancelled = false
     let retries = 0
     const MAX_RETRIES = 30
+    // BlockNote/Tiptap may initialize plugins asynchronously after editor creation.
+    // Wait for plugins to be available before applying Y.js undo patches.
     const tryPatch = () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/components/note-content.tsx` around lines 196 - 214, Add
an inline explanatory comment above the RAF retry loop (the
cancelled/retries/MAX_RETRIES variables and the tryPatch function) that briefly
explains this is a deliberate workaround to wait for BlockNote/Tiptap plugins to
initialize asynchronously before calling patchYUndoPluginDestroy and
patchYSyncAfterTypeChanged on instance._tiptapEditor.view; mention why
requestAnimationFrame is used, the MAX_RETRIES guard, and that the loop avoids
patching until plugins exist to prevent race conditions.

128-142: 🧹 Nitpick | 🔵 Trivial

Add eslint-disable comment for intentionally omitted dependency.

The content variable is used inside this effect but intentionally excluded from the dependency array (editor creation should only happen once). This will trigger an exhaustive-deps lint warning.

📝 Suggested fix
     onEditorChangeRef.current?.(null, null)
     }
+  // eslint-disable-next-line react-hooks/exhaustive-deps -- content used only for initial editor creation
   }, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/components/note-content.tsx` around lines 128 - 142, The
useEffect that creates the editor references content but intentionally omits it
from dependencies, causing an eslint exhaustive-deps warning; to silence this,
add an inline eslint disable comment (e.g., // eslint-disable-next-line
react-hooks/exhaustive-deps) immediately above the useEffect call that contains
BlockNoteEditor.create({ schema: editorSchema, initialContent, }) so the hook
runs only once; keep the rest of the effect (setEditor(instance),
onEditorChangeRef.current?.(instance, null), and cleanup destroying
instance._tiptapEditor and calling onEditorChangeRef.current?.(null, null))
unchanged.
convex/notes/functions/createNote.ts (1)

81-91: ⚠️ Potential issue | 🟠 Major

Use ServerBlockNoteEditor here instead of client internals.

This Convex mutation is server-side, but it instantiates BlockNoteEditor and reaches _headless / _tiptapEditor. BlockNote's server-processing docs route backend Yjs conversion through ServerBlockNoteEditor, so this path is tightly coupled to client internals and can break note creation on library updates. (blocknotejs.org)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/notes/functions/createNote.ts` around lines 81 - 91, The code
currently instantiates client internals via BlockNoteEditor.create({_headless:
true}) and accesses _tiptapEditor, which is fragile for server-side conversion;
replace this with ServerBlockNoteEditor (imported from blocknote) and use
ServerBlockNoteEditor.create(...) when creating the editor used by blocksToYDoc
so you never touch client-only internals. Update the code paths that call
BlockNoteEditor.create, blocksToYDoc(editor, content, 'document'),
editor._tiptapEditor.destroy() and editor.destroy() to instead create/destroy a
ServerBlockNoteEditor instance and pass that to blocksToYDoc, keep the
Y.encodeStateAsUpdate and uint8ToArrayBuffer flow the same, and remove any
references to _headless or _tiptapEditor.
src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts (1)

20-28: ⚠️ Potential issue | 🟠 Major

Drop event.code from undo/redo detection.

These branches still mix physical-key code with layout-aware key. On non-QWERTY layouts, code tracks the physical key while key tracks the produced character, so Ctrl/Cmd+Y and Ctrl/Cmd+Z can dispatch the wrong history action. Match only normalized e.key for editor shortcuts. (developer.mozilla.org)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts` around lines 20 -
28, The undo/redo keyboard handling currently mixes KeyboardEvent.code with
KeyboardEvent.key which breaks non-QWERTY layouts; in
useCanvasKeyboardShortcuts.ts remove the use of the local `code` variable and
change the undo/redo conditionals (the branches that call `undo()` and `redo()`)
to match only the normalized `key` (e.g., `key === 'z'` and `key === 'y'`) while
keeping the `e.shiftKey` checks and preventDefault/dispatch behavior intact so
only character-aware `e.key` drives history actions.
src/features/editor/hooks/useYjsReactFlowSync.ts (1)

193-206: ⚠️ Potential issue | 🟠 Major

Guard detached Yjs docs and clear bookkeeping outside the transaction path.

draggingIds.current.delete(...) only runs inside nodesMap.doc!.transact(...), and these handlers restore suppress*Observer only after the Yjs call succeeds. If a map is temporarily detached, or a transaction throws, drag state can stay stuck and the observer can remain permanently suppressed. Clear drag ids before the guard, use nodesMap?.doc / edgesMap?.doc, and restore the suppression flags in finally.

Also applies to: 211-231

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/editor/hooks/useYjsReactFlowSync.ts` around lines 193 - 206, The
onNodeDragStop handler can leave drag state or observer suppression stuck if
nodesMap.doc is detached or a transaction throws; move
draggingIds.current.delete(n.id) out of the Yjs transaction so deletion happens
before attempting the transaction, use safe optional access like nodesMap?.doc
(and edgesMap?.doc in the corresponding edge handler), and wrap the
suppressNodeObserver.current toggling and the transact call in a try/finally so
suppressNodeObserver.current is always reset; apply the same pattern to the
other handler referenced (lines ~211-231) ensuring you use nodesMap.get /
nodesMap.set inside the transaction but clear bookkeeping and reset suppression
in finally.
src/features/sidebar/components/forms/color-picker.tsx (1)

3-3: 🧹 Nitpick | 🔵 Trivial

Use one import path for the sidebar color helpers.

Line 11 already imports the same module through the alias path. Pull DEFAULT_ITEM_COLOR into that import too so this file does not mix alias and relative paths for one module.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/sidebar/components/forms/color-picker.tsx` at line 3, The file
mixes two import paths for the same module; remove the separate relative import
of DEFAULT_ITEM_COLOR and add DEFAULT_ITEM_COLOR to the existing import that
already brings in the sidebar color helpers (the alias import used elsewhere in
this file), so all symbols (including DEFAULT_ITEM_COLOR) come from the single
alias import for the sidebar-item-utils module.
src/features/canvas/hooks/useCanvasFileUpload.tsx (1)

92-116: ⚠️ Potential issue | 🟠 Major

Clean up committed uploads when createItem fails.

After commitUpload succeeds, this catch still only logs and returns null. That leaves a durable storage blob/file record with no sidebar item pointing at it. Please call the existing storage cleanup path, or enqueue cleanup, before surfacing the error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx` around lines 92 - 116,
When createItem fails after commitUpload.mutateAsync, the code currently only
logs and returns null, leaving the committed storage orphaned; modify the catch
block for createItem (the one handling createError and referencing storageId and
file.name) to call the existing storage cleanup path (or enqueue a cleanup job)
with storageId before showing the toast and returning, e.g., invoke the
project's cleanup API/function that handles durable storage cleanup (the
function used elsewhere for removing or enqueuing cleanup of committed storage)
so the orphaned blob is removed or scheduled for deletion prior to surfacing the
error.
src/features/canvas/components/canvas-minimap-node.tsx (1)

45-56: ⚠️ Potential issue | 🟡 Minor

Add preserveAspectRatio="none" for consistency with main stroke rendering.

If the main StrokePreview component uses preserveAspectRatio="none", this minimap SVG should match to ensure strokes display consistently after non-uniform resizes.

Suggested fix
     <svg
       x={x}
       y={y}
       width={width}
       height={height}
       viewBox={`${data.bounds.x} ${data.bounds.y} ${safeWidth} ${safeHeight}`}
+      preserveAspectRatio="none"
       overflow="visible"
     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-minimap-node.tsx` around lines 45 - 56,
The minimap SVG in the CanvasMinimapNode component is missing
preserveAspectRatio="none", causing inconsistent rendering versus the main
StrokePreview; update the <svg> returned in the CanvasMinimapNode (the JSX that
uses x, y, width, height, viewBox and safeWidth/safeHeight) to include
preserveAspectRatio="none" so non-uniform resizes match the main stroke
rendering.
src/features/canvas/components/nodes/resizable-node-wrapper.tsx (1)

36-70: ⚠️ Potential issue | 🟠 Major

Shift state can become stale when no resize handles are mounted.

When all subscribers unmount (lines 65-68), the keyup listener is removed. If the user releases Shift after this, shiftPressed remains true. The next resize will incorrectly use keepAspectRatio=true.

Additionally, window blur events should reset the shift state to handle alt-tab scenarios.

Suggested fix: keep listeners attached permanently
 let shiftPressed = false
 const shiftSubscribers = new Set<() => void>()
+let listenersAttached = false
+
+function resetShift() {
+  if (!shiftPressed) return
+  shiftPressed = false
+  notifyShiftSubscribers()
+}

 function notifyShiftSubscribers() {
   for (const cb of shiftSubscribers) cb()
 }

 function onShiftDown(e: KeyboardEvent) {
   if (e.key === 'Shift' && !shiftPressed) {
     shiftPressed = true
     notifyShiftSubscribers()
   }
 }

 function onShiftUp(e: KeyboardEvent) {
   if (e.key === 'Shift' && shiftPressed) {
-    shiftPressed = false
-    notifyShiftSubscribers()
+    resetShift()
   }
 }

 function subscribeShift(cb: () => void) {
-  if (shiftSubscribers.size === 0) {
+  if (!listenersAttached) {
     window.addEventListener('keydown', onShiftDown)
     window.addEventListener('keyup', onShiftUp)
+    window.addEventListener('blur', resetShift)
+    listenersAttached = true
   }
   shiftSubscribers.add(cb)
   return () => {
     shiftSubscribers.delete(cb)
-    if (shiftSubscribers.size === 0) {
-      window.removeEventListener('keydown', onShiftDown)
-      window.removeEventListener('keyup', onShiftUp)
-    }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/nodes/resizable-node-wrapper.tsx` around lines
36 - 70, The shift tracking currently removes key listeners when the last
subscriber is removed causing shiftPressed to stay true if Shift is released
while listeners are removed; change subscribeShift/remove logic so the global
listeners (onShiftDown, onShiftUp) are attached once and never removed (i.e.,
don’t remove listeners when shiftSubscribers becomes empty), and add a window
'blur' handler that clears shiftPressed and calls notifyShiftSubscribers (use
onShiftDown/onShiftUp names and the shiftPressed and shiftSubscribers symbols)
so alt-tab/blur resets state; keep subscribeShift returning an unsubscribe that
only removes the callback from shiftSubscribers but does not detach the global
event listeners.
src/features/canvas/components/canvas-color-panel.tsx (1)

58-64: ⚠️ Potential issue | 🟠 Major

Reset the pending batch when the selection target changes.

pendingUpdate.current?.nodeIds ?? ... keeps the first target set until the RAF flushes. If the user changes selection and tweaks color/opacity again within the same frame, the second change is merged into the first batch and applied to the wrong nodes.

Possible fix
   const scheduleNodeUpdate = useCallback(
     (data: Record<string, unknown>) => {
+      const nodeIds = colorRelevantNodes.map((n) => n.id)
+      const sameTarget =
+        !!pendingUpdate.current &&
+        pendingUpdate.current.nodeIds.length === nodeIds.length &&
+        pendingUpdate.current.nodeIds.every((id, index) => id === nodeIds[index])
+
       pendingUpdate.current = {
-        data: { ...pendingUpdate.current?.data, ...data },
-        nodeIds:
-          pendingUpdate.current?.nodeIds ?? colorRelevantNodes.map((n) => n.id),
+        data: sameTarget
+          ? { ...pendingUpdate.current!.data, ...data }
+          : data,
+        nodeIds,
       }
       if (!rafId.current) {
         rafId.current = requestAnimationFrame(() => {
           rafId.current = 0
           flushNodeUpdates()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-color-panel.tsx` around lines 58 - 64,
The current scheduleNodeUpdate merges new edits into pendingUpdate.current but
reuses pendingUpdate.current.nodeIds via the `??` fallback, causing later
changes within the same RAF to be applied to stale nodeIds; update
scheduleNodeUpdate so nodeIds are derived from the current selection instead of
preserving the old pending nodeIds (i.e., compute nodeIds as
colorRelevantNodes.map(n => n.id) rather than using
`pendingUpdate.current?.nodeIds ?? ...`) so a changed selection resets the
pending batch target.
src/features/canvas/hooks/useCanvasEraser.ts (1)

90-107: ⚠️ Potential issue | 🟠 Major

Flush the final eraser segment before clearing the trail.

A quick erase gesture can end before the scheduled frame runs. Pointer-up currently cancels that frame and clears state immediately, so the last segment never goes through testIntersections() and crossed strokes are missed.

Possible fix
-  const onPointerUp = useCallback(() => {
+  const onPointerUp = useCallback((e: React.PointerEvent) => {
     erasingRef.current = false
     if (eraserRafRef.current) {
       cancelAnimationFrame(eraserRafRef.current)
       eraserRafRef.current = 0
     }
+    const pos = reactFlow.screenToFlowPosition({
+      x: e.clientX,
+      y: e.clientY,
+    })
+    trailRef.current.push(pos)
+    testIntersections()
     const marked = markedRef.current
     if (nodesMap.doc && marked.size > 0) {
       nodesMap.doc.transact(() => {
         for (const id of marked) {
           nodesMap.delete(id)
@@
     markedRef.current = new Set()
     trailRef.current = []
     useCanvasToolStore.getState().setErasingStrokeIds(new Set())
-  }, [nodesMap])
+  }, [nodesMap, reactFlow, testIntersections])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasEraser.ts` around lines 90 - 107,
Pointer-up currently cancels the pending eraser RAF and clears state before the
last trail segment is processed, so call the same final processing path
synchronously in onPointerUp to flush the last segment: before cancelling
eraserRafRef and resetting trailRef/markedRef, run the eraser frame handler (the
logic that calls testIntersections and updates markedRef) so markedRef contains
any final hits and nodesMap deletions happen, then proceed to
cancelAnimationFrame(eraserRafRef.current), clear
erasingRef/eraserRafRef/trailRef and call
useCanvasToolStore.getState().setErasingStrokeIds(new Set()); reference
onPointerUp, eraserRafRef, trailRef, markedRef, testIntersections, and
nodesMap.delete for locating where to insert the synchronous flush.
src/features/canvas/hooks/useCanvasSelectionRect.ts (1)

59-69: ⚠️ Potential issue | 🟠 Major

Don't cancel away the only flow-space selection rect.

A drag that starts and ends within one frame cancels the pending RAF before lastFlowRectRef.current is written. The mouse-up branch then has no flow-space rect to replay, so the built-in box selection can leave non-intersecting stroke nodes selected.

Also applies to: 103-132

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasSelectionRect.ts` around lines 59 - 69,
The code cancels the pending RAF unconditionally which can prevent
lastFlowRectRef.current from being populated when a drag starts and ends within
one frame; update the logic in useCanvasSelectionRect so you do not cancel
rafRef.current when there was an active selection (wasActive === true) but
lastFlowRectRef.current is still empty — either move the cancelAnimationFrame
call until after lastFlowRectRef.current is written or guard the cancel with a
check like if (rafRef.current && (lastFlowRectRef.current || !wasActive)) to
ensure the RAF has a chance to compute and set lastFlowRectRef.current before
being canceled (apply the same guard to the mirrored block around lines
103-132).
src/features/canvas/components/canvas-viewer.tsx (1)

341-350: ⚠️ Potential issue | 🔴 Critical

Persist resized stroke geometry, not just the display box.

StrokeNode renders resized strokes by scaling the SVG to width/height, while useCanvasSelectionRect and useCanvasEraser hit-test against data.points and data.bounds. Persisting only width, height, and position makes the rendered path drift away from the interactive path after the first resize.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-viewer.tsx` around lines 341 - 350,
Persisted node update only sets width/height/position causing StrokeNode visual
scaling to diverge from hit-test data; in onResizeEnd compute scaleX = width /
existing.width and scaleY = height / existing.height (guard against zero), then
scale the node's stroke geometry in existing.data by mapping data.points (x *=
scaleX, y *= scaleY) and scaling data.bounds (x, y, width, height) before
calling nodesMap.set; update the stored node to include the new data object plus
the width, height, and position so useCanvasSelectionRect/useCanvasEraser hit
the transformed path correctly (referencing onResizeEnd, nodesMap, StrokeNode,
data.points, data.bounds).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/folders/functions/getFolderContentsForDownload.ts`:
- Around line 71-73: The suffix-check for note names currently uses
child.name.endsWith('.md') which is case-sensitive and will produce names like
Readme.MD.md; change the check in getFolderContentsForDownload (the noteName
assignment) to perform a case-insensitive test (e.g., test
child.name.toLowerCase().endsWith('.md') or use a case-insensitive regex like
/\.md$/i) so existing .MD/.Md variants are recognized and you don't append an
extra .md.

In `@convex/notes/__tests__/notes.test.ts`:
- Around line 39-40: The tests currently assert typeof result!.blockMeta ===
'object' which also passes for null; update the assertions for result!.blockMeta
in notes.test.ts (the checks around result!.blockMeta at the existing expect
lines and the similar pair at line 86) to explicitly reject null — e.g., add an
explicit non-null assertion (expect(result!.blockMeta).not.toBeNull()) before or
instead of the typeof check so null cannot satisfy the test while preserving the
array check (Array.isArray(result!.blockMeta) should remain false).

In `@convex/notes/__tests__/persistBlocks.test.ts`:
- Around line 56-108: The test suite lacks a positive round-trip for
persistNoteBlocks: add a test that creates a note via setupCampaignContext/asDm,
sends a non-empty Yjs update using api.yjsSync.mutations.pushUpdate (use or
extend makeYjsUpdate to include at least one paragraph/text delta), then call
api.notes.mutations.persistNoteBlocks with that documentId and assert it returns
expected data (or non-null) and that the DB 'blocks' rows were inserted by
querying the blocks table (same pattern as existing empty test using
dbCtx.db.query('blocks')). Ensure you reference the same helpers
(setupCampaignContext, asDm, makeYjsUpdate, pushUpdate, persistNoteBlocks) and
assert on block count and expected block fields to validate the conversion.

In `@convex/sidebarItems/__tests__/previewCleanup.test.ts`:
- Around line 33-35: Add a new test case to previewCleanup.test.ts mirroring
existing preview cleanup tests but using the canvas sidebar item type: use the
test context from createTestContext(), create a canvas via the createCanvas
factory (ensure createCanvas is exported from convex/_test/factories.helper.ts),
add it to the sidebar as a preview, perform the hard delete action used by the
other tests, and assert that the preview cleanup behavior matches other item
types; reference and reuse the same helper setup and assertions used in the
existing preview cleanup tests so the new test for canvas follows the same
structure and expectations.

In `@convex/sidebarItems/__tests__/previewGeneration.test.ts`:
- Around line 21-425: Add tests exercising the new "canvas" sidebar item type by
mirroring existing note tests: use createCanvas (or the project helper that
creates a canvas) to create a canvas and then call
api.sidebarItems.mutations.claimPreviewGeneration and
api.sidebarItems.mutations.setPreviewImage to assert claim/upload behavior
(claimed true/false, previewLockedUntil, previewStorageId, previewUpdatedAt, old
blob deletion, permission checks for edit/view/no-share, NOT_FOUND/auth checks).
Also add a case in the enhanceBase previewUrl resolution tests that creates a
canvas, patches previewStorageId, then queries
api.sidebarItems.queries.getSidebarItemsByLocation to assert that the canvas
item._id yields a previewUrl string when storage is set and null when not.

In `@convex/sidebarItems/functions/setPreviewImage.ts`:
- Around line 36-46: Keep the current try/catch in setPreviewImage around
ctx.storage.delete to avoid failing the mutation on delete errors, but add (1) a
short TODO comment next to the ctx.storage.delete call noting the need for a
periodic reconciliation/cleanup job to remove orphaned blobs, and (2) implement
(or create a new task placeholder for) a scheduled cleanup function (e.g.,
reconcilePreviewStorage or cleanupOrphanedPreviews) that scans storage entries
against active previewStorageId references and deletes unreferenced blobs;
reference the ctx.storage.delete call, the oldPreviewStorageId and
previewStorageId variables, and the setPreviewImage function when adding the
TODO and creating the cleanup task placeholder.

In `@convex/sidebarItems/validation.ts`:
- Around line 295-311: The .unique() call on the by_campaign_slug query will
throw if duplicate {campaignId, slug} pairs exist; remove .unique() in
queryTable (the ctx.db.query(...).withIndex('by_campaign_slug', ...) chain) and
instead return all matches, then compute conflict by finding any returned item
whose id !== excludeId (use the existing campaignId and slug inputs and the
variable excludeId) so create/rename operations treat duplicates as conflicts
instead of throwing; update the code that currently awaits
Promise.all([queryTable('notes'), ...]) to handle arrays and set conflict =
firstMatchFromAnyTable.where(item => item.id !== excludeId).

In `@convex/yjsSync/__tests__/yjsSyncMutations.test.ts`:
- Line 15: The test "does not trigger compaction before interval" (and similarly
the test block around lines 167-179) still uses hardcoded loop bounds and
expected counts (looping to 19 and expecting 20), so replace those hardcoded
values with expressions derived from COMPACT_INTERVAL: loop up to
COMPACT_INTERVAL - 1 and assert the expected row count equals COMPACT_INTERVAL
(or use COMPACT_INTERVAL - 1 / COMPACT_INTERVAL as appropriate for each
assertion); update any iterations, expectedCounts, or magic numbers in the test
functions (e.g., the loop bounds and expect(...) calls) to reference
COMPACT_INTERVAL so the tests track the constant instead of a fixed 19/20.

In `@convex/yjsSync/functions/compactUpdates.ts`:
- Line 23: The code sets maxSeq from the last array element (const maxSeq =
updates[updates.length - 1].seq) which wrongly assumes reconstructYDoc returns
updates sorted; change this to compute the maximum seq explicitly from the
updates array (e.g., use a reduce/Math.max over updates.map(u => u.seq)) so
maxSeq is correct regardless of ordering; update the compactUpdates logic that
uses maxSeq and leave a short comment referencing reconstructYDoc to clarify the
ordering contract if you choose to keep it.

In `@src/features/canvas/components/canvas-color-panel.tsx`:
- Around line 120-131: The color swatch buttons (in the canvas-color-panel
component) only indicate selection visually; update the button elements (where
handleColorChange is used and activeColor is compared to color) to expose
selection to assistive tech by adding an aria-pressed attribute set to
(activeColor === color) so the active swatch reads as pressed/selected to screen
readers; ensure the boolean value is passed (true/false) rather than a string.

In `@src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx`:
- Around line 224-264: The query cursor must be keyed to the canvasId so we
don't fetch or apply updates from the previous canvas: change the getUpdates
call so its query key/params include the canvasId (not just documentId and
afterSeq separately) or build a memoized params object that embeds canvasId with
afterSeq, ensuring the query cache is unique per canvas; when resetting in the
canvasId useEffect set afterSeq and lastAppliedSeqRef and then trigger a new
query for the new canvasId (so updatesResult corresponds to the new canvas), and
in the update application effect (which references updatesResult.data, doc,
lastAppliedSeqRef, canvasIdRef) guard by checking the query’s embedded canvasId
matches canvasIdRef.current before applying entries and updating
lastAppliedSeqRef and setAfterSeq. Use the existing symbols: afterSeq,
setAfterSeq, canvasIdRef, lastAppliedSeqRef,
useAuthQuery(api.yjsSync.queries.getUpdates), updatesResult, and the two
useEffect blocks to implement this.

In `@src/features/canvas/hooks/useCanvasStrokeClick.ts`:
- Around line 12-61: The onPaneClick callback is being recreated on every zoom
change which may cause needless re-renders; instead store zoom in a ref and read
from that ref inside onPaneClick so the callback can be stable. Create a zoomRef
updated in an effect when zoom changes, remove zoom from the onPaneClick
dependency array, and have onPaneClick use zoomRef.current when computing
threshold (STROKE_HIT_PADDING_PX / zoomRef.current) before calling
pointNearStrokePath and reactFlow.setNodes so behavior remains correct but the
callback identity stays stable.

In `@src/features/canvas/hooks/useNodeEditing.ts`:
- Around line 41-46: The Enter-key commit in handleKeyDown currently fires even
during IME composition; add a guard that checks e.nativeEvent.isComposing (or
e.isComposing) and only proceed to preventDefault/stopPropagation/commit when
composition is not active; update the Enter branch in handleKeyDown that
manipulates shouldCommitRef.current and calls commitEdit(value) to first return
early if e.nativeEvent.isComposing is true so IME input isn't committed
prematurely.

In `@src/features/editor/components/note-content.tsx`:
- Line 221: Add an ESLint disable comment to suppress the exhaustive-deps
warning for the useEffect that intentionally omits user.name and user.color:
locate the useEffect hook whose dependency array is "}, [doc, provider])" in the
NoteContent component and add a comment like "// eslint-disable-next-line
react-hooks/exhaustive-deps" immediately above that line so the linter knows the
omission of user.name and user.color is intentional while keeping doc and
provider as dependencies.

---

Outside diff comments:
In `@src/features/sidebar/utils/sidebar-item-utils.ts`:
- Around line 59-67: The JSDoc for the isFile type guard is malformed due to a
duplicate opening `/**`; edit the JSDoc above the exported function isFile to
remove the extra `/**` so it begins with a single valid `/**` and maintains the
existing description, leaving the function signature, isSidebarItemType call,
and SIDEBAR_ITEM_TYPES reference unchanged.

---

Duplicate comments:
In `@convex/folders/functions/getFolderContentsForDownload.ts`:
- Around line 116-118: The switch in getFolderContentsForDownload currently
ignores canvases (case SIDEBAR_ITEM_TYPES.canvases: break) which returns
incomplete exports; change this to explicitly fail or report unsupported items
instead of breaking — e.g., in the getFolderContentsForDownload switch for
SIDEBAR_ITEM_TYPES.canvases throw or return a descriptive error/unsupported-item
result (including the item id/context) so callers know canvases are not handled;
ensure the error type/message is consistent with surrounding error handling in
this function.

In `@convex/notes/functions/createNote.ts`:
- Around line 81-91: The code currently instantiates client internals via
BlockNoteEditor.create({_headless: true}) and accesses _tiptapEditor, which is
fragile for server-side conversion; replace this with ServerBlockNoteEditor
(imported from blocknote) and use ServerBlockNoteEditor.create(...) when
creating the editor used by blocksToYDoc so you never touch client-only
internals. Update the code paths that call BlockNoteEditor.create,
blocksToYDoc(editor, content, 'document'), editor._tiptapEditor.destroy() and
editor.destroy() to instead create/destroy a ServerBlockNoteEditor instance and
pass that to blocksToYDoc, keep the Y.encodeStateAsUpdate and uint8ToArrayBuffer
flow the same, and remove any references to _headless or _tiptapEditor.

In `@src/features/canvas/components/canvas-color-panel.tsx`:
- Around line 58-64: The current scheduleNodeUpdate merges new edits into
pendingUpdate.current but reuses pendingUpdate.current.nodeIds via the `??`
fallback, causing later changes within the same RAF to be applied to stale
nodeIds; update scheduleNodeUpdate so nodeIds are derived from the current
selection instead of preserving the old pending nodeIds (i.e., compute nodeIds
as colorRelevantNodes.map(n => n.id) rather than using
`pendingUpdate.current?.nodeIds ?? ...`) so a changed selection resets the
pending batch target.

In `@src/features/canvas/components/canvas-minimap-node.tsx`:
- Around line 45-56: The minimap SVG in the CanvasMinimapNode component is
missing preserveAspectRatio="none", causing inconsistent rendering versus the
main StrokePreview; update the <svg> returned in the CanvasMinimapNode (the JSX
that uses x, y, width, height, viewBox and safeWidth/safeHeight) to include
preserveAspectRatio="none" so non-uniform resizes match the main stroke
rendering.

In `@src/features/canvas/components/canvas-viewer.tsx`:
- Around line 341-350: Persisted node update only sets width/height/position
causing StrokeNode visual scaling to diverge from hit-test data; in onResizeEnd
compute scaleX = width / existing.width and scaleY = height / existing.height
(guard against zero), then scale the node's stroke geometry in existing.data by
mapping data.points (x *= scaleX, y *= scaleY) and scaling data.bounds (x, y,
width, height) before calling nodesMap.set; update the stored node to include
the new data object plus the width, height, and position so
useCanvasSelectionRect/useCanvasEraser hit the transformed path correctly
(referencing onResizeEnd, nodesMap, StrokeNode, data.points, data.bounds).

In `@src/features/canvas/components/nodes/resizable-node-wrapper.tsx`:
- Around line 36-70: The shift tracking currently removes key listeners when the
last subscriber is removed causing shiftPressed to stay true if Shift is
released while listeners are removed; change subscribeShift/remove logic so the
global listeners (onShiftDown, onShiftUp) are attached once and never removed
(i.e., don’t remove listeners when shiftSubscribers becomes empty), and add a
window 'blur' handler that clears shiftPressed and calls notifyShiftSubscribers
(use onShiftDown/onShiftUp names and the shiftPressed and shiftSubscribers
symbols) so alt-tab/blur resets state; keep subscribeShift returning an
unsubscribe that only removes the callback from shiftSubscribers but does not
detach the global event listeners.

In `@src/features/canvas/hooks/useCanvasEraser.ts`:
- Around line 90-107: Pointer-up currently cancels the pending eraser RAF and
clears state before the last trail segment is processed, so call the same final
processing path synchronously in onPointerUp to flush the last segment: before
cancelling eraserRafRef and resetting trailRef/markedRef, run the eraser frame
handler (the logic that calls testIntersections and updates markedRef) so
markedRef contains any final hits and nodesMap deletions happen, then proceed to
cancelAnimationFrame(eraserRafRef.current), clear
erasingRef/eraserRafRef/trailRef and call
useCanvasToolStore.getState().setErasingStrokeIds(new Set()); reference
onPointerUp, eraserRafRef, trailRef, markedRef, testIntersections, and
nodesMap.delete for locating where to insert the synchronous flush.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx`:
- Around line 92-116: When createItem fails after commitUpload.mutateAsync, the
code currently only logs and returns null, leaving the committed storage
orphaned; modify the catch block for createItem (the one handling createError
and referencing storageId and file.name) to call the existing storage cleanup
path (or enqueue a cleanup job) with storageId before showing the toast and
returning, e.g., invoke the project's cleanup API/function that handles durable
storage cleanup (the function used elsewhere for removing or enqueuing cleanup
of committed storage) so the orphaned blob is removed or scheduled for deletion
prior to surfacing the error.

In `@src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts`:
- Around line 20-28: The undo/redo keyboard handling currently mixes
KeyboardEvent.code with KeyboardEvent.key which breaks non-QWERTY layouts; in
useCanvasKeyboardShortcuts.ts remove the use of the local `code` variable and
change the undo/redo conditionals (the branches that call `undo()` and `redo()`)
to match only the normalized `key` (e.g., `key === 'z'` and `key === 'y'`) while
keeping the `e.shiftKey` checks and preventDefault/dispatch behavior intact so
only character-aware `e.key` drives history actions.

In `@src/features/canvas/hooks/useCanvasSelectionRect.ts`:
- Around line 59-69: The code cancels the pending RAF unconditionally which can
prevent lastFlowRectRef.current from being populated when a drag starts and ends
within one frame; update the logic in useCanvasSelectionRect so you do not
cancel rafRef.current when there was an active selection (wasActive === true)
but lastFlowRectRef.current is still empty — either move the
cancelAnimationFrame call until after lastFlowRectRef.current is written or
guard the cancel with a check like if (rafRef.current &&
(lastFlowRectRef.current || !wasActive)) to ensure the RAF has a chance to
compute and set lastFlowRectRef.current before being canceled (apply the same
guard to the mirrored block around lines 103-132).

In `@src/features/editor/components/forms/file-form/file-form.tsx`:
- Around line 185-192: The PDF preview generation catch currently logs with
console.error; replace that with the centralized error handler by calling
handleError(...) inside the catch for
generatePdfPreviewIfNeeded(fileUpload.file, newFileId as Id<'files'>), passing a
descriptive message (e.g., "PDF preview generation failed for new file") and the
caught error so the create flow uses the same error handling as the update path;
ensure you import or reference handleError in this scope and preserve the
promise chain (i.e., .catch((err) => handleError('PDF preview generation failed
for new file', err))).

In `@src/features/editor/components/note-content.tsx`:
- Around line 196-214: Add an inline explanatory comment above the RAF retry
loop (the cancelled/retries/MAX_RETRIES variables and the tryPatch function)
that briefly explains this is a deliberate workaround to wait for
BlockNote/Tiptap plugins to initialize asynchronously before calling
patchYUndoPluginDestroy and patchYSyncAfterTypeChanged on
instance._tiptapEditor.view; mention why requestAnimationFrame is used, the
MAX_RETRIES guard, and that the loop avoids patching until plugins exist to
prevent race conditions.
- Around line 128-142: The useEffect that creates the editor references content
but intentionally omits it from dependencies, causing an eslint exhaustive-deps
warning; to silence this, add an inline eslint disable comment (e.g., //
eslint-disable-next-line react-hooks/exhaustive-deps) immediately above the
useEffect call that contains BlockNoteEditor.create({ schema: editorSchema,
initialContent, }) so the hook runs only once; keep the rest of the effect
(setEditor(instance), onEditorChangeRef.current?.(instance, null), and cleanup
destroying instance._tiptapEditor and calling onEditorChangeRef.current?.(null,
null)) unchanged.

In `@src/features/editor/hooks/useYjsReactFlowSync.ts`:
- Around line 193-206: The onNodeDragStop handler can leave drag state or
observer suppression stuck if nodesMap.doc is detached or a transaction throws;
move draggingIds.current.delete(n.id) out of the Yjs transaction so deletion
happens before attempting the transaction, use safe optional access like
nodesMap?.doc (and edgesMap?.doc in the corresponding edge handler), and wrap
the suppressNodeObserver.current toggling and the transact call in a try/finally
so suppressNodeObserver.current is always reset; apply the same pattern to the
other handler referenced (lines ~211-231) ensuring you use nodesMap.get /
nodesMap.set inside the transaction but clear bookkeeping and reset suppression
in finally.

In `@src/features/sidebar/components/forms/color-picker.tsx`:
- Line 3: The file mixes two import paths for the same module; remove the
separate relative import of DEFAULT_ITEM_COLOR and add DEFAULT_ITEM_COLOR to the
existing import that already brings in the sidebar color helpers (the alias
import used elsewhere in this file), so all symbols (including
DEFAULT_ITEM_COLOR) come from the single alias import for the sidebar-item-utils
module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f78dd3f9-cd83-4934-a8a2-5b60eb2175e5

📥 Commits

Reviewing files that changed from the base of the PR and between a31a514 and 6957a5c.

📒 Files selected for processing (49)
  • convex/_test/factories.helper.ts
  • convex/canvases/functions/createCanvas.ts
  • convex/canvases/functions/updateCanvas.ts
  • convex/folders/functions/getFolderContentsForDownload.ts
  • convex/gameMaps/__tests__/gameMaps.test.ts
  • convex/notes/__tests__/notes.test.ts
  • convex/notes/__tests__/persistBlocks.test.ts
  • convex/notes/functions/createNote.ts
  • convex/sidebarItems/__tests__/previewCleanup.test.ts
  • convex/sidebarItems/__tests__/previewGeneration.test.ts
  • convex/sidebarItems/__tests__/sidebarItemValidation.test.ts
  • convex/sidebarItems/functions/claimPreviewGeneration.ts
  • convex/sidebarItems/functions/enhanceSidebarItem.ts
  • convex/sidebarItems/functions/getSidebarItemById.ts
  • convex/sidebarItems/functions/getSidebarItemBySlug.ts
  • convex/sidebarItems/functions/setPreviewImage.ts
  • convex/sidebarItems/schema/baseFields.ts
  • convex/sidebarItems/types/baseTypes.ts
  • convex/sidebarItems/validation.ts
  • convex/yjsSync/__tests__/yjsSyncMutations.test.ts
  • convex/yjsSync/functions/compactUpdates.ts
  • src/features/canvas/components/canvas-color-panel.tsx
  • src/features/canvas/components/canvas-minimap-node.tsx
  • src/features/canvas/components/canvas-remote-cursors.tsx
  • src/features/canvas/components/canvas-viewer.tsx
  • src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx
  • src/features/canvas/components/nodes/resizable-node-wrapper.tsx
  • src/features/canvas/components/nodes/stroke-node.tsx
  • src/features/canvas/hooks/useCanvasEraser.ts
  • src/features/canvas/hooks/useCanvasFileUpload.tsx
  • src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts
  • src/features/canvas/hooks/useCanvasOverlayHandlers.ts
  • src/features/canvas/hooks/useCanvasSelectionRect.ts
  • src/features/canvas/hooks/useCanvasStrokeClick.ts
  • src/features/canvas/hooks/useCanvasWheel.ts
  • src/features/canvas/hooks/useNodeEditing.ts
  • src/features/canvas/utils/canvas-stroke-utils.ts
  • src/features/dnd/utils/__tests__/dnd-registry.test.ts
  • src/features/dnd/utils/dnd-registry.ts
  • src/features/editor/components/forms/file-form/file-form.tsx
  • src/features/editor/components/note-content.tsx
  • src/features/editor/components/viewer/folder/note-card.tsx
  • src/features/editor/components/viewer/map/map-viewer.tsx
  • src/features/editor/hooks/useYjsReactFlowSync.ts
  • src/features/previews/utils/upload-preview.ts
  • src/features/sidebar/components/forms/color-picker.tsx
  • src/features/sidebar/components/sidebar-root/droppable-root.tsx
  • src/features/sidebar/utils/sidebar-item-utils.ts
  • src/styles/app.css

Comment thread convex/folders/functions/getFolderContentsForDownload.ts
Comment thread convex/notes/__tests__/notes.test.ts
Comment thread convex/notes/__tests__/persistBlocks.test.ts
Comment thread convex/sidebarItems/__tests__/previewCleanup.test.ts
Comment thread convex/sidebarItems/__tests__/previewGeneration.test.ts
Comment thread src/features/canvas/components/canvas-color-panel.tsx Outdated
Comment thread src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx Outdated
Comment thread src/features/canvas/hooks/useCanvasStrokeClick.ts
Comment thread src/features/canvas/hooks/useNodeEditing.ts Outdated
Comment thread src/features/editor/components/note-content.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/features/dnd/utils/__tests__/dnd-registry.test.ts (1)

24-36: ⚠️ Potential issue | 🟡 Minor

Add tests for new canvas drop-zone branches.

This suite was updated for the moved SIDEBAR_ROOT_TYPE import, but it still doesn’t assert the newly added canvas behavior (embed, self_embed, canvas target key/highlight/file acceptance). Please add focused tests for those paths to cover the new DnD logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/dnd/utils/__tests__/dnd-registry.test.ts` around lines 24 - 36,
Add unit tests covering the new canvas drop-zone branches: write focused tests
that call resolveDropTarget and resolveDropOutcome with drag items representing
canvas embeds (use getDragItemId to create/embed ids if needed) to assert the
returned target types include the new 'embed' and 'self_embed' outcomes; also
add assertions for getDropTargetKey and getHighlightId to verify canvas-specific
target keys/highlight ids are produced, and use canDropFilesOnTarget to confirm
file-acceptance behavior for canvas targets. Target the symbols
resolveDropTarget, resolveDropOutcome, getDropTargetKey, getHighlightId,
canDropFilesOnTarget and reuse existing constants like SIDEBAR_ROOT_TYPE,
MAP_DROP_ZONE_TYPE and EMPTY_EDITOR_DROP_TYPE to construct scenarios for
external embed, self-embed, and file-drop acceptance.
src/features/dnd/utils/dnd-registry.ts (1)

210-211: ⚠️ Potential issue | 🟡 Minor

Make trashed_item rejection text target-agnostic.

trashed_item is now used by canvas embedding (Line 352), but the current message says “link,” which is misleading outside note-editor link flows.

💡 Proposed fix
     case 'trashed_item':
-      return 'Cannot link to a trashed item'
+      return 'Cannot use a trashed item here'

Also applies to: 352-353

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/dnd/utils/dnd-registry.ts` around lines 210 - 211, The rejection
message for the 'trashed_item' case is link-specific; change it to a
target-agnostic phrase used by both note-editor link flows and canvas embedding
(refer to the 'trashed_item' case in dnd-registry.ts and the other occurrence
used by canvas embedding). Replace the returned string "Cannot link to a trashed
item" with a generic message such as "The item is trashed and cannot be used"
(or equivalent) in the 'trashed_item' case so all consumers get a neutral,
accurate rejection text.
♻️ Duplicate comments (24)
src/features/canvas/hooks/useCanvasFileUpload.tsx (2)

46-58: 🧹 Nitpick | 🔵 Trivial

Extract isTextFile result to a local variable to avoid repeated calls.

isTextFile(file.type, file.name) is evaluated three times with identical arguments (lines 50, 52, and 58). Extract to a local variable for clarity and to avoid redundant computation.

+    const isText = isTextFile(file.type, file.name)
+
     const toastId = toast.loading(
       <ToastContent
         title={file.name}
-        message={
-          isTextFile(file.type, file.name) ? 'Processing...' : 'Uploading... 0%'
-        }
-        progress={isTextFile(file.type, file.name) ? undefined : 0}
+        message={isText ? 'Processing...' : 'Uploading... 0%'}
+        progress={isText ? undefined : 0}
       />,
       { duration: Infinity, style: TOAST_STYLE },
     )

     try {
-      if (isTextFile(file.type, file.name)) {
+      if (isText) {

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx` around lines 46 - 58, The
call is repeating isTextFile(file.type, file.name) multiple times; capture its
result once (e.g., const isText = isTextFile(file.type, file.name)) before
creating the toast and use that variable in the ToastContent props and the
subsequent try block so ToastContent.title/message/progress and the if
(isTextFile(...)) check all reference the single local variable (look for the
toast.loading(...) call that constructs ToastContent and the following if
block).

93-117: ⚠️ Potential issue | 🟡 Minor

Storage blob remains orphaned if createItem fails after commitUpload succeeds.

The inner try-catch logs the orphaned storage (line 108) but doesn't perform cleanup. The fileStorage record exists in the database without a referencing sidebar item. Consider adding a compensating deletion (e.g., calling a deleteFileStorage mutation with the storageId) or scheduling a background cleanup job for unreferenced storage records.

🛡️ Suggested compensation pattern
         } catch (createError) {
           // Storage is committed but sidebar item creation failed — storage is orphaned
           console.error('Orphaned storage after failed createItem:', storageId)
+          // Attempt to clean up orphaned storage
+          try {
+            await deleteFileStorage.mutateAsync({ storageId })
+          } catch (cleanupError) {
+            console.error('Failed to cleanup orphaned storage:', cleanupError)
+          }
           toast.error(

This requires adding a deleteFileStorage mutation. Alternatively, implement a scheduled job to periodically clean up fileStorage records without corresponding sidebar items.

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasFileUpload.tsx` around lines 93 - 117, The
catch block after createItem in useCanvasFileUpload.tsx currently logs orphaned
storage but doesn't clean it up; update the error handling to invoke a
compensating deletion (e.g., call the deleteFileStorage mutation with storageId)
when createItem fails after commitUpload, await and handle errors from that
deletion (log failures and surface a toast if needed), and if immediate deletion
isn't possible implement scheduling of a background cleanup job for unreferenced
fileStorage records; ensure you reference storageId and the createItem failure
path so orphaned records are removed or tracked for cleanup.
src/features/canvas/hooks/useNodeEditing.ts (1)

41-46: ⚠️ Potential issue | 🟠 Major

Guard Enter commit while IME composition is active.

Enter currently commits without checking composition state, so IME users can save incomplete composed text. Add an early return before the commit branch.

Suggested patch
   const handleKeyDown = useCallback(
     (e: React.KeyboardEvent, value: string) => {
       if (e.key === 'Enter' && !e.shiftKey) {
+        if (e.nativeEvent.isComposing) return
         e.preventDefault()
         e.stopPropagation()
         shouldCommitRef.current = false
         commitEdit(value)
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useNodeEditing.ts` around lines 41 - 46, The Enter
key handler in useNodeEditing commits even when IME composition is active; add
an early guard before the commit branch to skip handling if composition is in
progress (check e.isComposing || a compositionRef/composing flag used in the
hook). In the keydown handler where you currently check if (e.key === 'Enter' &&
!e.shiftKey) and call commitEdit(value), return early when IME composition is
active (e.isComposing === true or compositionRef.current === true) so
shouldCommitRef/commitEdit are not invoked during composition.
convex/notes/__tests__/persistBlocks.test.ts (1)

56-108: ⚠️ Potential issue | 🟡 Minor

Add a non-empty Yjs round-trip test for persistNoteBlocks.

Current cases only prove empty input behavior. Please add one positive path that pushes a non-empty Yjs update, runs persistNoteBlocks, and asserts rows are actually inserted into blocks for that noteId.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/notes/__tests__/persistBlocks.test.ts` around lines 56 - 108, Add a
positive round-trip test that pushes a non-empty Yjs update, calls
persistNoteBlocks, and asserts blocks were inserted: reuse setupCampaignContext
and asDm to create a note (like in existing tests), create a non-empty Yjs
update (e.g., use makeYjsUpdate with a text/paragraph payload or build a Y.Doc
with text and serialize to an update), call
dmAuth.mutation(api.yjsSync.mutations.pushUpdate, { documentId: noteId, update:
<non-empty update> }), then call
dmAuth.mutation(api.notes.mutations.persistNoteBlocks, { documentId: noteId })
and inside t.run query the blocks table (db.query('blocks').filter(q =>
q.eq(q.field('noteId'), noteId)).collect()) and assert blocks.length > 0 and
optionally validate block content.
convex/notes/__tests__/notes.test.ts (1)

39-40: ⚠️ Potential issue | 🟡 Minor

Strengthen blockMeta assertions to explicitly reject null.

typeof ... === 'object' passes for null, so these checks can succeed on invalid data. Add a non-null assertion before the type check in both locations.

Suggested fix
+    expect(result!.blockMeta).not.toBeNull()
     expect(typeof result!.blockMeta).toBe('object')
     expect(Array.isArray(result!.blockMeta)).toBe(false)
@@
+    expect(result!.blockMeta).not.toBeNull()
     expect(typeof result!.blockMeta).toBe('object')
     expect(Array.isArray(result!.blockMeta)).toBe(false)

Also applies to: 86-87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/notes/__tests__/notes.test.ts` around lines 39 - 40, The tests
currently use typeof result!.blockMeta === 'object' which will be true for null;
update the assertions around result!.blockMeta (both where you check typeof and
Array.isArray — currently lines using expect(typeof
result!.blockMeta).toBe('object') and
expect(Array.isArray(result!.blockMeta)).toBe(false)) to first assert
result!.blockMeta is not null (e.g., expect(result!.blockMeta).not.toBeNull())
before the typeof check and the Array.isArray check so null values are
explicitly rejected; apply the same non-null assertion to the duplicate
assertions around lines 86-87 as well.
convex/yjsSync/__tests__/yjsSyncMutations.test.ts (1)

202-226: ⚠️ Potential issue | 🟡 Minor

De-hardcode the companion pre-interval test as well.

Line 212 and Line 225 still use fixed 19/20, so this test can silently become incorrect if COMPACT_INTERVAL changes.

Suggested fix
-    for (let i = 1; i <= 19; i++) {
+    for (let i = 1; i <= COMPACT_INTERVAL - 1; i++) {
       await dmAuth.mutation(api.yjsSync.mutations.pushUpdate, {
         documentId: noteId,
         update: makeEmptyYjsUpdate(),
       })
     }
@@
-      expect(rows).toHaveLength(20)
+      expect(rows).toHaveLength(COMPACT_INTERVAL)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/yjsSync/__tests__/yjsSyncMutations.test.ts` around lines 202 - 226,
The test "does not trigger compaction before interval" hardcodes 19/20; replace
those magic numbers with the shared COMPACT_INTERVAL constant so the test tracks
the real compaction threshold. Import or reference COMPACT_INTERVAL (the
constant used by the yjs compaction logic) in the test, change the loop to
iterate for (let i = 1; i <= COMPACT_INTERVAL - 1; i++) when calling
api.yjsSync.mutations.pushUpdate, and assert
expect(rows).toHaveLength(COMPACT_INTERVAL) instead of 20; keep using noteId,
dmAuth, and makeEmptyYjsUpdate as before.
src/features/canvas/hooks/useCanvasSelectionRect.ts (1)

59-69: ⚠️ Potential issue | 🟠 Major

Stale lastFlowRectRef.current when RAF is cancelled before firing.

When a drag starts and ends within a single frame, the RAF at line 103 is cancelled (lines 62-65) before it can update lastFlowRectRef.current. The deselection branch at line 68 then reads a stale or null value, causing false-positive stroke selections to never get corrected.

Consider computing the flow rect from prevRect directly when the selection ends, rather than relying on the RAF-populated ref:

🐛 Proposed fix
       const wasActive = prevRect !== null
+      const previousDomRect = prevRect
       prevRect = userSelectionRect

       if (rafRef.current) {
         cancelAnimationFrame(rafRef.current)
         rafRef.current = 0
       }

       if (!userSelectionRect) {
-        if (wasActive && lastFlowRectRef.current) {
-          const selRect = lastFlowRectRef.current
+        const state = storeApi.getState()
+        let selRect = lastFlowRectRef.current
+        if (!selRect && wasActive && previousDomRect && state.domNode) {
+          const bounds = state.domNode.getBoundingClientRect()
+          const topLeft = reactFlow.screenToFlowPosition({
+            x: previousDomRect.x + bounds.left,
+            y: previousDomRect.y + bounds.top,
+          })
+          const bottomRight = reactFlow.screenToFlowPosition({
+            x: previousDomRect.x + previousDomRect.width + bounds.left,
+            y: previousDomRect.y + previousDomRect.height + bounds.top,
+          })
+          selRect = {
+            x: topLeft.x,
+            y: topLeft.y,
+            width: bottomRight.x - topLeft.x,
+            height: bottomRight.y - topLeft.y,
+          }
+        }
+        if (wasActive && selRect) {
           reactFlow.setNodes((nodes) =>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasSelectionRect.ts` around lines 59 - 69,
The deselection branch reads lastFlowRectRef.current which can be stale if the
RAF (managed by rafRef) was cancelled before it updated that ref; update the
branch in useCanvasSelectionRect to compute the flow rectangle from the previous
selection (prevRect) when userSelectionRect becomes null (i.e., when wasActive
is true) instead of relying solely on lastFlowRectRef.current—use prevRect to
derive selRect (with lastFlowRectRef.current as a fallback) and proceed with the
existing deselect logic so short-lived drags that never let the RAF fire still
clear selections correctly.
src/features/canvas/components/canvas-minimap-node.tsx (1)

46-56: ⚠️ Potential issue | 🟡 Minor

Missing preserveAspectRatio="none" for minimap stroke consistency.

StrokePreview uses preserveAspectRatio="none" (line 37 in stroke-node.tsx), but this minimap SVG uses the default preserveAspectRatio. After a non-uniform resize, the minimap stroke will no longer match the canvas shape.

🐛 Proposed fix
     <svg
       x={x}
       y={y}
       width={width}
       height={height}
       viewBox={`${data.bounds.x} ${data.bounds.y} ${safeWidth} ${safeHeight}`}
+      preserveAspectRatio="none"
       overflow="visible"
     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-minimap-node.tsx` around lines 46 - 56,
The SVG in canvas-minimap-node.tsx is missing preserveAspectRatio="none",
causing minimap strokes to distort after non-uniform resizes; update the SVG
element (the one using props x, y, width, height and viewBox with data.bounds
and safeWidth/safeHeight) to include preserveAspectRatio="none" to match
StrokePreview and ensure stroke rendering remains consistent with the canvas.
src/features/canvas/hooks/useCanvasEraser.ts (1)

90-107: ⚠️ Potential issue | 🟠 Major

Final eraser segment not flushed on pointer-up.

When the user lifts the pointer, onPointerUp cancels the pending RAF (lines 92-95) but never calls testIntersections() for the accumulated trail. A fast erase gesture that ends before the RAF fires can miss the final crossed strokes.

🐛 Proposed fix
-  const onPointerUp = useCallback(() => {
+  const onPointerUp = useCallback((e: React.PointerEvent) => {
     erasingRef.current = false
     if (eraserRafRef.current) {
       cancelAnimationFrame(eraserRafRef.current)
       eraserRafRef.current = 0
     }
+    // Flush final segment before deleting
+    const pos = reactFlow.screenToFlowPosition({
+      x: e.clientX,
+      y: e.clientY,
+    })
+    trailRef.current.push(pos)
+    testIntersections()
+
     const marked = markedRef.current
     if (nodesMap.doc && marked.size > 0) {
       nodesMap.doc.transact(() => {
         for (const id of marked) {
           nodesMap.delete(id)
         }
       })
     }
     markedRef.current = new Set()
     trailRef.current = []
     useCanvasToolStore.getState().setErasingStrokeIds(new Set())
-  }, [nodesMap])
+  }, [nodesMap, reactFlow, testIntersections])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/hooks/useCanvasEraser.ts` around lines 90 - 107,
onPointerUp currently cancels the pending RAF and clears state without flushing
the final accumulated trail, so ensure the final segment is processed by calling
the same intersection test routine before cancelling/clearing: invoke
testIntersections() (the function used by the RAF) against
trailRef.current/markedRef.current right when pointer lifts (e.g., at the start
of onPointerUp), then proceed to cancelAnimationFrame(eraserRafRef.current) and
clear eraserRafRef, trailRef, and markedRef; reference symbols: onPointerUp,
testIntersections, eraserRafRef, trailRef, markedRef, nodesMap,
useCanvasToolStore.setErasingStrokeIds.
src/features/canvas/utils/canvas-stroke-utils.ts (1)

87-103: ⚠️ Potential issue | 🟠 Major

segmentsIntersect returns false for collinear overlapping segments.

When denom ≈ 0 (parallel segments), the function returns false even if the segments overlap. This causes hit-testing to miss cases where the eraser trail or selection edge runs exactly along a stroke boundary.

🐛 Proposed fix using orientation-based intersection
+function cross(
+  ax: number, ay: number,
+  bx: number, by: number,
+  cx: number, cy: number,
+): number {
+  return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
+}
+
+function onSegment(
+  ax: number, ay: number,
+  bx: number, by: number,
+  px: number, py: number,
+): boolean {
+  return (
+    px >= Math.min(ax, bx) - 1e-10 &&
+    px <= Math.max(ax, bx) + 1e-10 &&
+    py >= Math.min(ay, by) - 1e-10 &&
+    py <= Math.max(ay, by) + 1e-10
+  )
+}
+
 export function segmentsIntersect(
   ax: number, ay: number,
   bx: number, by: number,
   cx: number, cy: number,
   dx: number, dy: number,
 ): boolean {
-  const denom = (bx - ax) * (dy - cy) - (by - ay) * (dx - cx)
-  if (Math.abs(denom) < 1e-10) return false
-
-  const t = ((cx - ax) * (dy - cy) - (cy - ay) * (dx - cx)) / denom
-  const u = ((cx - ax) * (by - ay) - (cy - ay) * (bx - ax)) / denom
-  return t >= 0 && t <= 1 && u >= 0 && u <= 1
+  const o1 = cross(ax, ay, bx, by, cx, cy)
+  const o2 = cross(ax, ay, bx, by, dx, dy)
+  const o3 = cross(cx, cy, dx, dy, ax, ay)
+  const o4 = cross(cx, cy, dx, dy, bx, by)
+
+  // Collinear cases
+  if (Math.abs(o1) < 1e-10 && onSegment(ax, ay, bx, by, cx, cy)) return true
+  if (Math.abs(o2) < 1e-10 && onSegment(ax, ay, bx, by, dx, dy)) return true
+  if (Math.abs(o3) < 1e-10 && onSegment(cx, cy, dx, dy, ax, ay)) return true
+  if (Math.abs(o4) < 1e-10 && onSegment(cx, cy, dx, dy, bx, by)) return true
+
+  // General case
+  return (o1 > 0) !== (o2 > 0) && (o3 > 0) !== (o4 > 0)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/utils/canvas-stroke-utils.ts` around lines 87 - 103,
segmentsIntersect currently bails out when denom ≈ 0 (parallel/collinear) which
misses collinear overlapping segments; update segmentsIntersect to handle the
collinear case by detecting when denom is nearly zero and then checking whether
the four points are collinear (use the same cross-product/orientation test used
for denom) and if so determine interval overlap by comparing projections of the
segments on an axis (e.g., compare min/max of ax/bx with cx/dx or ay/by with
cy/dy depending on dominant axis) to return true for overlapping ranges; keep
the existing t/u logic for the non-parallel case and only run the collinear
overlap check when Math.abs(denom) < 1e-10.
src/features/canvas/components/canvas-viewer.tsx (3)

470-478: 🧹 Nitpick | 🔵 Trivial

ref as a regular prop is a legacy pattern.

In React 19, using ref as a destructured prop in a function component works but may cause confusion. Consider using forwardRef or renaming to a non-reserved name like overlayRef.

♻️ Proposed fix using forwardRef
-function CanvasDropOverlay({
-  ref,
-  isDropTarget,
-  isFileDropTarget,
-}: {
-  ref: React.RefObject<HTMLDivElement | null>
-  isDropTarget: boolean
-  isFileDropTarget: boolean
-}) {
+import { forwardRef } from 'react'
+
+const CanvasDropOverlay = forwardRef<
+  HTMLDivElement,
+  { isDropTarget: boolean; isFileDropTarget: boolean }
+>(function CanvasDropOverlay({ isDropTarget, isFileDropTarget }, ref) {
   // ... component body
-}
+})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-viewer.tsx` around lines 470 - 478, The
component CanvasDropOverlay currently takes a prop named ref which shadows the
reserved React ref pattern; update CanvasDropOverlay to accept a forwarded ref
(use React.forwardRef) or rename the prop to a non-reserved name like overlayRef
throughout the component usage and type signature, and adjust any internal
references and parent callers that pass the prop (preserve isDropTarget and
isFileDropTarget props unchanged); ensure the exported component uses forwardRef
if keeping the external ref behavior so React refs work correctly.

222-230: ⚠️ Potential issue | 🟠 Major

Mutation hooks not gated by canEdit — read-only users may still trigger mutations.

The hooks useCanvasDrawing, useCanvasEraser, useCanvasLassoSelection, and useCanvasRectangleDraw (lines 224-230) are initialized unconditionally without canEdit checks. While some UI interactions are gated (e.g., line 234), useCanvasOverlayHandlers (line 246) may still bind pointer handlers from these hooks regardless of canEdit, allowing mutations via:

  • drawing.onPointerUp()nodesMap.set()
  • eraser.onPointerUp()nodesMap.delete()
  • rectangleDraw.onPointerUp()nodesMap.set()

Pass canEdit to these hooks and have them return no-op handlers when editing is disabled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-viewer.tsx` around lines 222 - 230, The
four canvas mutation hooks (useCanvasDrawing, useCanvasEraser,
useCanvasLassoSelection, useCanvasRectangleDraw) are created unconditionally and
can still expose pointer handlers that call nodesMap.set/delete even when the
user is read-only; update the calls to pass the canEdit flag into each hook and
change their implementations to return no-op handlers (e.g.,
onPointerDown/Move/Up, start/stop, etc.) when canEdit is false so that
useCanvasOverlayHandlers (and any binding code) cannot trigger mutations like
nodesMap.set() or nodesMap.delete() for read-only users.

381-389: 🧹 Nitpick | 🔵 Trivial

Consider adding setEditingEmbedId to useMemo dependency array.

While useState setters are guaranteed stable by React, omitting setEditingEmbedId from the dependency array may trigger ESLint exhaustive-deps warnings. Adding it for completeness has no runtime impact.

♻️ Proposed fix
     [
       updateNodeData,
       onResizeEnd,
       remoteHighlights,
       canEdit,
       canvasUser,
       editingEmbedId,
+      setEditingEmbedId,
     ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/canvas-viewer.tsx` around lines 381 - 389, The
useMemo dependency array that currently lists updateNodeData, onResizeEnd,
remoteHighlights, canEdit, canvasUser, and editingEmbedId should also include
the state setter setEditingEmbedId to satisfy exhaustive-deps; update the
dependency array (where useMemo is declared) to add setEditingEmbedId—this has
no runtime impact but prevents ESLint warnings and ensures completeness.
src/features/canvas/components/nodes/stroke-node.tsx (2)

63-93: ⚠️ Potential issue | 🔴 Critical

Stroke resize does not update underlying geometry — hit-testing will drift from rendered display.

The StrokeNode renders geometry scaled via width/height props (lines 63-64), but onResizeEnd in canvas-viewer.tsx only persists width, height, and position without updating data.points or data.bounds. Hit-testing tools (click, eraser, selection) use the original data.points translated by data.bounds, so after resize the interactive and rendered geometry diverge.

Either update the stroke data during resize (recompute scaled points/bounds) or adjust all hit-testing logic to account for the display scale factor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/nodes/stroke-node.tsx` around lines 63 - 93,
Stroke rendering is being scaled by width/height in StrokeNode but onResizeEnd
(in canvas-viewer.tsx) only saves width/height/position, leaving data.points and
data.bounds unchanged so hit-testing drifts; fix by, in the resize handler
(onResizeEnd), computing scaleX = newWidth / data.bounds.width and scaleY =
newHeight / data.bounds.height (handle zero-safe), map over data.points to
multiply each point.x by scaleX and point.y by scaleY, update data.bounds (x/y
can be set to new position, width/height to newWidth/newHeight) and also scale
any stroke size/pressure field if present (e.g., data.size) before persisting
the updated stroke object; ensure StrokeNode continues to use data.points and
data.bounds so hit-testing matches rendered output.

40-44: ⚠️ Potential issue | 🟡 Minor

Inconsistent opacity scale between opacityOverride and data.opacity.

opacityOverride expects a 0–1 value (e.g., 0.3 for erasing), but data.opacity is treated as 0–100 and divided by 100. If a caller stores data.opacity in the 0–1 range, the stroke will be nearly invisible.

🐛 Proposed fix — normalize to 0–1 convention
         d={d}
         fill={color}
-        opacity={opacityOverride ?? (data.opacity ?? 100) / 100}
+        opacity={opacityOverride ?? data.opacity ?? 1}

Ensure stroke creators store data.opacity as 0–1.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/nodes/stroke-node.tsx` around lines 40 - 44,
The opacity handling is inconsistent: opacityOverride is 0–1 but data.opacity is
treated as 0–100; update the StrokeNode rendering to normalize to a 0–1
convention by computing a single opacity value used in the <path> element (use
opacityOverride if provided, otherwise use data.opacity normalized to 0–1). In
practice, change the logic around opacityOverride and data.opacity in
stroke-node.tsx (the code that currently computes opacity as opacityOverride ??
(data.opacity ?? 100) / 100) to: choose opacityOverride when present; else if
data.opacity is present treat values >1 as legacy 0–100 and divide by 100,
otherwise use the 0–1 value as-is (and fallback to 1 when undefined) so both
sources produce a 0–1 opacity for the path.
convex/notes/functions/createNote.ts (2)

2-3: ⚠️ Potential issue | 🟠 Major

Use ServerBlockNoteEditor for this backend Yjs bootstrap.

This path is doing server-side document conversion, but it still constructs BlockNoteEditor and tears it down through underscore-prefixed internals. BlockNote’s server-side docs recommend ServerBlockNoteEditor from @blocknote/server-util, note that ServerBlockNoteEditor.create accepts the same editor options, and expose blocksToYDoc / blocksToYXmlFragment for Yjs conversion. (blocknotejs.org)

Also applies to: 81-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/notes/functions/createNote.ts` around lines 2 - 3, The code is
constructing a client-side BlockNoteEditor and using underscore internals for
server-side Yjs conversion; replace this with the server utility: import
ServerBlockNoteEditor from '@blocknote/server-util' and use
ServerBlockNoteEditor.create(...) (passing the same editor options) instead of
new BlockNoteEditor(...), then use the exposed conversion helpers (blocksToYDoc
or blocksToYXmlFragment) from the package for Yjs output rather than touching
internal teardown methods; update all occurrences (e.g., where BlockNoteEditor
and blocksToYDoc are used) including the related block between lines ~81-92 to
use the server APIs.

75-80: 🧹 Nitpick | 🔵 Trivial

Collapse the repeated content guard.

content && content.length > 0 is checked twice in the same flow. Keeping the block save and initialState bootstrap under one guard removes the duplication and keeps the happy path together.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/notes/functions/createNote.ts` around lines 75 - 80, The code
duplicates the content guard `content && content.length > 0`; collapse them by
moving the initialState bootstrap into the same conditional that calls
saveTopLevelBlocksForNote so both operations run under a single `if (content &&
content.length > 0)` block; update the block that references
saveTopLevelBlocksForNote(ctx, { noteId, content }) to also initialize/set
`initialState` (and any related variables) there, removing the second redundant
`if` that checks content, while leaving references to `noteId` and `content`
intact.
src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx (1)

219-246: ⚠️ Potential issue | 🟠 Major

Key the local Yjs session state to canvasId.

Because this reset only happens in useEffect, the first render after a canvasId change still reuses the previous doc / nodes / edges / afterSeq. That can briefly paint the old canvas and call getUpdates for the new documentId with the previous canvas’s cursor, which can skip the new canvas’s initial history. Keep the cursor/snapshot keyed by canvasId (or gate rendering/query args until local state belongs to the current canvasId) instead of storing afterSeq as an unscoped state value.

🔧 Suggested direction
-  const [afterSeq, setAfterSeq] = useState<number | undefined>(undefined)
+  const [cursor, setCursor] = useState<{
+    canvasId: Id<'canvases'>
+    afterSeq: number | undefined
+  }>({ canvasId, afterSeq: undefined })
   const lastAppliedSeqRef = useRef(-1)
-  const canvasIdRef = useRef(canvasId)

   useEffect(() => {
     setIsLoading(true)
-    setAfterSeq(undefined)
+    setCursor({ canvasId, afterSeq: undefined })
     lastAppliedSeqRef.current = -1
-    canvasIdRef.current = canvasId

     const d = new Y.Doc()
     setDoc(d)
@@
   const updatesResult = useAuthQuery(api.yjsSync.queries.getUpdates, {
     documentId: canvasId,
-    afterSeq,
+    afterSeq: cursor.canvasId === canvasId ? cursor.afterSeq : undefined,
   })

   useEffect(() => {
     if (!updatesResult.data || !doc) return
-    if (canvasIdRef.current !== canvasId) return

@@
     if (updatesResult.data.length > 0) {
-      setAfterSeq(lastAppliedSeqRef.current)
+      setCursor({ canvasId, afterSeq: lastAppliedSeqRef.current })
     }

You will also want to gate the rendered nodes / edges the same way so a canvas switch cannot flash the previous snapshot for one paint.

Also applies to: 248-264

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx`
around lines 219 - 246, The local Yjs session state in useReadOnlyYjsCanvas
(doc, nodes, edges, afterSeq, lastAppliedSeqRef) is not scoped to the current
canvasId and can cause flashing/incorrect getUpdates queries; fix by keying that
state to canvasId — e.g., replace independent states with a single keyed
structure or include canvasId in their shape (like {canvasId, doc, nodes, edges,
afterSeq}) or keep a Map<canvasId, State> and select the current entry;
additionally gate rendering and the updates query so you only pass afterSeq and
render nodes/edges when the stored state’s canvasId matches the current canvasId
(or when you have initialized a fresh doc for that canvas) to prevent reusing
previous canvas snapshot during the first render after a switch.
convex/folders/functions/getFolderContentsForDownload.ts (2)

71-73: ⚠️ Potential issue | 🟡 Minor

Make the .md suffix check case-insensitive.

Line 71 currently treats Readme.MD as missing the extension and exports Readme.MD.md.

Proposed fix
-        const noteName = child.name.endsWith('.md')
+        const noteName = child.name.toLowerCase().endsWith('.md')
           ? child.name
           : `${child.name}.md`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/folders/functions/getFolderContentsForDownload.ts` around lines 71 -
73, The extension check for noteName is case-sensitive and will append ".md" to
names like "Readme.MD"; update the condition in getFolderContentsForDownload
(where noteName and child.name are used) to perform a case-insensitive check —
e.g., test child.name.toLowerCase().endsWith('.md') or use a case-insensitive
regex like /\.md$/i — so existing .MD/.Md/etc. extensions are recognized and not
duplicated.

116-118: ⚠️ Potential issue | 🟠 Major

Do not silently skip canvases in download output.

Line 117-Line 118 drops canvases without signaling partial export, which can produce incomplete archives with a success response.

Proposed minimal safe behavior (explicit failure until canvas export exists)
-      // TODO: add canvas -> img export
-      case SIDEBAR_ITEM_TYPES.canvases:
-        break
+      case SIDEBAR_ITEM_TYPES.canvases: {
+        throwClientError(
+          ERROR_CODE.VALIDATION_FAILED,
+          'Canvas export is not supported yet',
+        )
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/folders/functions/getFolderContentsForDownload.ts` around lines 116 -
118, The switch case for SIDEBAR_ITEM_TYPES.canvases in
getFolderContentsForDownload silently drops canvases (case in
getFolderContentsForDownload.ts), causing successful-but-partial exports; change
this to explicitly fail instead of break — e.g., detect the canvases case in the
switch and throw/return an error (with a clear message like "canvas export not
implemented") so callers receive a non-success response until a proper
canvas->img export is implemented; ensure the thrown/returned error is
propagated by the surrounding getFolderContentsForDownload function so the
partial archive cannot be produced silently.
src/features/sidebar/components/forms/color-picker.tsx (1)

3-11: 🧹 Nitpick | 🔵 Trivial

Use one import path style for sidebar-item-utils in this file.

Line 3 and Line 11 import from the same module via different path styles. Consolidate to a single alias import for consistency.

Proposed fix
-import { DEFAULT_ITEM_COLOR } from '../../utils/sidebar-item-utils'
+import {
+  DEFAULT_ITEM_COLOR,
+  validateHexColorOrDefault,
+} from '~/features/sidebar/utils/sidebar-item-utils'
@@
-import { validateHexColorOrDefault } from '~/features/sidebar/utils/sidebar-item-utils'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/sidebar/components/forms/color-picker.tsx` around lines 3 - 11,
The file imports DEFAULT_ITEM_COLOR and validateHexColorOrDefault from the same
module using two different path styles; consolidate them to a single import path
style (use the project alias style used elsewhere, e.g., the
'~/features/sidebar/utils/sidebar-item-utils' form) so that DEFAULT_ITEM_COLOR
and validateHexColorOrDefault are imported from the same module path in
color-picker.tsx.
convex/sidebarItems/validation.ts (1)

295-313: ⚠️ Potential issue | 🟠 Major

Handle slug checks against all matches and avoid .unique() hard-fail paths.

Line 301 can throw if duplicate { campaignId, slug } rows exist, and Line 311 only inspects the first non-null hit. That combination can either 500 or miss a real conflict when excludeId matches the first item but another table still conflicts.

Proposed fix
-  const queryTable = (table: SidebarItemTable) =>
+  const queryTable = (table: SidebarItemTable) =>
     ctx.db
       .query(table)
       .withIndex('by_campaign_slug', (q) =>
         q.eq('campaignId', campaignId).eq('slug', slug),
       ) // check deleted items as well since deleted items can be accessed by slug
-      .unique()
+      .collect()

-  const [note, folder, map, file, canvas] = await Promise.all([
+  const [notes, folders, maps, files, canvases] = await Promise.all([
     queryTable('notes'),
     queryTable('folders'),
     queryTable('gameMaps'),
     queryTable('files'),
     queryTable('canvases'),
   ])

-  const conflict = note ?? folder ?? map ?? file ?? canvas
-  if (!conflict) return false
-  return excludeId ? conflict._id !== excludeId : true
+  const matches = [...notes, ...folders, ...maps, ...files, ...canvases]
+  if (matches.length === 0) return false
+  return excludeId ? matches.some((item) => item._id !== excludeId) : true
In Convex, what is the exact behavior of `query(...).withIndex(...).unique()` when multiple documents match, and what pattern is recommended for conflict detection queries that must not throw on duplicates?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/sidebarItems/validation.ts` around lines 295 - 313, queryTable
currently uses ctx.db.query(...).withIndex(...).unique(), which throws if
multiple rows match and later code only inspects the first non-null hit
(variable conflict), risking a 500 or missing conflicts; change queryTable to
not call .unique() (use .collect() to fetch all matches) and then update the
conflict detection to iterate all results from
notes/folders/gameMaps/files/canvases to check if any returned item has an _id
!== excludeId (or any non-excluded match) before returning true/false; reference
queryTable, ctx.db.query(...).withIndex(...).unique(), conflict, and excludeId.
convex/sidebarItems/__tests__/previewCleanup.test.ts (1)

33-35: ⚠️ Potential issue | 🟡 Minor

Add the missing canvas hard-delete cleanup case.

This suite covers note/file/map/folder paths, but the new canvases item type also carries preview storage in this PR. Without a canvas case, regressions in its hard-delete cleanup path will still pass unnoticed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/sidebarItems/__tests__/previewCleanup.test.ts` around lines 33 - 35,
The test suite "preview cleanup on hard delete" is missing a case for the new
canvases item type; add a test that follows the existing pattern (using
createTestContext()) to create a canvases item with preview storage, perform the
hard-delete flow, and assert that the preview blob is removed; mirror the
assertions and setup used for note/file/map/folder tests so the canvases
hard-delete cleanup path is covered.
convex/sidebarItems/__tests__/previewGeneration.test.ts (1)

21-221: 🧹 Nitpick | 🔵 Trivial

Canvas preview paths are still missing from this suite.

This PR introduces canvas as a sidebar item type, but these tests still only exercise notes/folders. Please mirror at least one createCanvas path for claim/upload/previewUrl resolution so the new canvas preview flow is covered.

Also applies to: 223-380, 382-425

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/sidebarItems/__tests__/previewGeneration.test.ts` around lines 21 -
221, Tests currently only exercise notes/folders; add equivalent canvas
scenarios to cover the new canvas preview flow by calling createCanvas (use the
same setupCampaignContext/asDm/asPlayer helpers) and invoking
api.sidebarItems.mutations.claimPreviewGeneration with the returned canvasId;
mirror at least one positive claim path (DM can claim), one denied path (player
with view-only share), and preview state assertions similar to note tests (check
previewLockedUntil, previewUpdatedAt, COOLDOWN_MS behavior and previewUrl
resolution after upload). Reuse existing helpers like createSidebarShare,
expectPermissionDenied, expectNotFound, and t.run/db.patch to simulate
lock/cooldown conditions so the canvas branch in claimPreviewGeneration and
preview URL resolution is exercised.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 941afcad-22a3-4266-99d3-2655e68176c8

📥 Commits

Reviewing files that changed from the base of the PR and between a31a514 and 6957a5c.

📒 Files selected for processing (49)
  • convex/_test/factories.helper.ts
  • convex/canvases/functions/createCanvas.ts
  • convex/canvases/functions/updateCanvas.ts
  • convex/folders/functions/getFolderContentsForDownload.ts
  • convex/gameMaps/__tests__/gameMaps.test.ts
  • convex/notes/__tests__/notes.test.ts
  • convex/notes/__tests__/persistBlocks.test.ts
  • convex/notes/functions/createNote.ts
  • convex/sidebarItems/__tests__/previewCleanup.test.ts
  • convex/sidebarItems/__tests__/previewGeneration.test.ts
  • convex/sidebarItems/__tests__/sidebarItemValidation.test.ts
  • convex/sidebarItems/functions/claimPreviewGeneration.ts
  • convex/sidebarItems/functions/enhanceSidebarItem.ts
  • convex/sidebarItems/functions/getSidebarItemById.ts
  • convex/sidebarItems/functions/getSidebarItemBySlug.ts
  • convex/sidebarItems/functions/setPreviewImage.ts
  • convex/sidebarItems/schema/baseFields.ts
  • convex/sidebarItems/types/baseTypes.ts
  • convex/sidebarItems/validation.ts
  • convex/yjsSync/__tests__/yjsSyncMutations.test.ts
  • convex/yjsSync/functions/compactUpdates.ts
  • src/features/canvas/components/canvas-color-panel.tsx
  • src/features/canvas/components/canvas-minimap-node.tsx
  • src/features/canvas/components/canvas-remote-cursors.tsx
  • src/features/canvas/components/canvas-viewer.tsx
  • src/features/canvas/components/nodes/embed-content/embed-canvas-content.tsx
  • src/features/canvas/components/nodes/resizable-node-wrapper.tsx
  • src/features/canvas/components/nodes/stroke-node.tsx
  • src/features/canvas/hooks/useCanvasEraser.ts
  • src/features/canvas/hooks/useCanvasFileUpload.tsx
  • src/features/canvas/hooks/useCanvasKeyboardShortcuts.ts
  • src/features/canvas/hooks/useCanvasOverlayHandlers.ts
  • src/features/canvas/hooks/useCanvasSelectionRect.ts
  • src/features/canvas/hooks/useCanvasStrokeClick.ts
  • src/features/canvas/hooks/useCanvasWheel.ts
  • src/features/canvas/hooks/useNodeEditing.ts
  • src/features/canvas/utils/canvas-stroke-utils.ts
  • src/features/dnd/utils/__tests__/dnd-registry.test.ts
  • src/features/dnd/utils/dnd-registry.ts
  • src/features/editor/components/forms/file-form/file-form.tsx
  • src/features/editor/components/note-content.tsx
  • src/features/editor/components/viewer/folder/note-card.tsx
  • src/features/editor/components/viewer/map/map-viewer.tsx
  • src/features/editor/hooks/useYjsReactFlowSync.ts
  • src/features/previews/utils/upload-preview.ts
  • src/features/sidebar/components/forms/color-picker.tsx
  • src/features/sidebar/components/sidebar-root/droppable-root.tsx
  • src/features/sidebar/utils/sidebar-item-utils.ts
  • src/styles/app.css

Comment thread convex/_test/factories.helper.ts
Comment thread convex/canvases/functions/updateCanvas.ts
Comment thread convex/sidebarItems/__tests__/previewGeneration.test.ts
Comment thread convex/sidebarItems/functions/claimPreviewGeneration.ts Outdated
Comment thread src/features/canvas/hooks/useCanvasOverlayHandlers.ts
Comment thread src/features/canvas/utils/canvas-stroke-utils.ts
Comment thread src/features/previews/utils/upload-preview.ts Outdated
Comment thread src/features/sidebar/utils/sidebar-item-utils.ts
@ntietje1 ntietje1 merged commit 98fe91a into dev Apr 6, 2026
15 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Apr 8, 2026
ntietje1 added a commit that referenced this pull request Apr 8, 2026
* add knip to CI

* add preview deployments

* fix site url env var

* fix site_url for preview deps, also move e2e to separate job

* attempt fix env var issue

* remove duplicate comment

* add back comment, move tooltip

* add manual dispatch option to e2e test

* improve preview deployments

* prevent duplicate deployments to prod

* fix sub-sub-domain issue

* fix hardcoded url

* fixes

* add github repo env var

* fix order of operations

* attempt env var fix

* fix

* resolve type issue

* Note yjs (#30)

* add yjs live collab for notes

* optimize collab performance

* remove unused dep

* fix missing env var

* add yet another missing env var for preview

* improvements

* fix viewer strict mode issue

* add tests, fix small issues

* address comments

* fixes

* fix strict mode issue

* fixes

* resolve nits

* add redo test

* re-use convex preview

* fix depreciated flag

* add missing env var

* fix env var handling

* fix comments and undo bug

* add canvases (#33)

* add xy-flow

* add live canvas features

* add free-hand drawing

* fix stroke impl, add more modes

* add undo/redo

* add color picker and selection improvements

* add better zoom controls

* refactor

* selection fixes + minimap adjustment

* improve multi select indicator

* fix sidebar layout issue

* improve color picker

* add basic embedding and dnd

* add resizing

* add embedded content

* add better prveiews with client side image generation for notes

* add convex eslint plugin

* fix ssr issue

* add preview generation

* fixes

* improve canvas embeds

* address comments

* fixes

* explicitly pass auth secret

* fixes

* more fixes

* improvements

* improvements

* fix pre-mature throw

* fix test

* add resize awareness and fix canvas external file drop

* address comments

* fix flakey test

* address comments again

* fix claim token in tests

* attempt fix env var issue

* fix

* add resend api key to preview dep
@ntietje1 ntietje1 deleted the xy-flow branch April 9, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant