Skip to content

Arrows between two objects, and the one object with no box - #40

Merged
boubou666 merged 2 commits into
mainfrom
connectors
Aug 9, 2026
Merged

boubou666 merged 2 commits into
mainfrom
connectors

Conversation

@boubou666

@boubou666 boubou666 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Select two objects and press Connect: an arrow joins them, pointing from the first one selected to the second. The same control says Disconnect once they are joined, because with one already there that is the only thing anybody wants from a button about it. A press on the line selects it, Delete removes it, and deleting either object takes its arrows with it — in the same op, so one undo puts the whole picture back rather than the cards first and the lines after.

A connector is a relation, not a rectangle

Its geometry is worked out from wherever its ends are at the moment it is drawn. That is what makes dragging a card free: the arrows follow without a single op being written for them, and the document holds nothing that could disagree with where the cards are.

It is also the one object with no box, which is the invariant every part of this app that assumes an x had to be taught. The list is exactly as long as it sounds — bounds, snapping, the marquee, nudging, dragging, the frame a PNG covers, and the map — and isPlaced is how each of them asks. Missing one writes NaN into the document and broadcasts it to everyone else on the board, so each has a test.

What copies, copies whole

A connector comes along when both the things it joins do — selected or not, the rule an envelope's contents already follow — and is left behind when they do not, since an arrow with one end in the payload has nothing to point at when it lands. Duplicating, pasting and exporting a selection all ask that same question.

The PNG asks it too. The export is a second renderer and its comment says what that costs; an arrow on screen that is missing from the file is exactly that drift, so it draws them — from the same geometry, with the stroke probed off the stylesheet rather than restated.

Drawn in one SVG, behind everything

At the back of the world layer, in world coordinates, with the thickness in world units: a connector scales with the board like a card's text and unlike a selection ring, because it is content rather than an affordance. The visible line is two and a half units thick, which nobody can point at, so an invisible sixteen-unit stroke underneath takes the press.

Two things found by building it

  • An element of no size with overflow: visible is one Chrome lays out, hit-tests and paints none of. That is the obvious shape for drawing at unbounded, negative coordinates, and it is what this started as — the lines were provably on screen, hittable, and invisible. The element is moved and sized to hold what it draws now, with a viewBox that keeps its contents world coordinates.
  • "Is there room between these two boxes" cannot be answered by the distance between their borders. Two objects that overlap have their far borders in the wrong order, so the distance between those points says nothing; measured along the direction the two lie in, an overlap is negative and the answer is "draw nothing". Without it an arrow is drawn backwards through both objects.

Not in this change

Straight lines only: no routing round what is in the way, no elbows, no choosing which side an arrow leaves by, and no label on the line — which is the one most likely to be missed, since half of what an arrow says on a diagram is written along it. There is also no drag-to-connect gesture: handles on an object's edge would land exactly where the resize handles already are, which is what made the two-object selection the right first move. All written into the README's limits.

Checks

986 tests, all passing, 96.9% line coverage. core/connectors.js and platform/connectors.js are both at 100%. 29 node tests for the geometry and the board rules, 11 browser tests over the real gestures — connect, disconnect, follow, overlap, select the line, the delete cascade and its undo, drag and nudge with one in the selection, the marquee, duplicate — and 3 export tests on the painted pixels.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PTizw7U35rC2MPquwr4Fv5

Summary by CodeRabbit

  • New Features
    • Added connectors between two selected objects, displayed as arrows that follow endpoint movement.
    • Connectors can be selected, disconnected, deleted, copied, duplicated, and restored with undo.
    • Added connector controls to the formatting bar.
    • Connectors are included in PNG exports when applicable and render beneath objects.
  • Documentation
    • Documented connector behavior, interactions, rendering, and current limitations, including straight lines without routing, labels, or drag-to-connect interaction.

Select two objects and press "Connect": an arrow joins them, pointing from the
first one selected to the second. The same control says "Disconnect" once they
are joined, because with one already there that is the only thing anybody wants
from a button about it. A press on the line selects it, Delete removes it, and
deleting either object takes its arrows with it — in the same op, so one undo
puts the whole picture back rather than the cards first and the lines after.

**A connector is a relation, not a rectangle.** Its geometry is worked out from
wherever its ends are at the moment it is drawn, which is what makes dragging a
card free: the arrows follow it without a single op being written for them, and
the document holds nothing that could disagree with where the cards are.

That is also the invariant every part of this app that assumes an `x` had to be
taught, and the list is exactly as long as it sounds: bounds, snapping, the
marquee, nudging, dragging, the frame a PNG covers and the map. `isPlaced` is
how each of them asks. Missing one writes `NaN` into the document and broadcasts
it to everyone else on the board, so each has a test that fails without the
guard.

What copies, copies whole. A connector comes along when both the things it joins
do — selected or not, the rule an envelope's contents already follow — and is
left behind when they do not, since an arrow with one end in the payload has
nothing to point at when it lands. Duplicating, pasting and exporting a
selection all ask that same question, and the picture drawn to a PNG asks it
too: the export is a second renderer, and an arrow on screen that is missing
from the file is exactly the drift its comment warns about.

**Drawn in one SVG at the back of the world layer**, in world coordinates, with
the thickness in world units — a connector scales with the board like a card's
text and unlike a selection ring, because it is content rather than an
affordance. The line is two and a half units thick, which nobody can point at,
so an invisible sixteen-unit stroke underneath takes the press.

Two things found by building it. An element of no size with `overflow: visible`
— the obvious way to draw at unbounded coordinates — is one Chrome lays out,
hit-tests and paints none of; the element is moved and sized to hold what it
draws now, with a `viewBox` that keeps its contents world coordinates. And
"is there room between these two boxes" cannot be answered by the distance
between their borders: two objects that overlap have their far borders in the
wrong order, and the answer has to be measured along the direction the two lie
in, or an arrow is drawn backwards through both of them.

986 tests, 96.9% lines. Both new modules at 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTizw7U35rC2MPquwr4Fv5
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a29bf374-de71-4169-80df-f9e3735e9490

📥 Commits

Reviewing files that changed from the base of the PR and between add729a and caeb758.

📒 Files selected for processing (4)
  • src/platform/connectors.js
  • src/styles/canvas.css
  • test/browser/connectors.test.js
  • test/node/connectors.test.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/node/connectors.test.js
  • src/platform/connectors.js
  • src/styles/canvas.css

📝 Walkthrough

Walkthrough

The PR adds connector objects between placed board objects. Connectors support creation, selection, movement with endpoints, deletion, copying, duplication, SVG rendering, minimap filtering, and PNG export.

Changes

Connector feature

Layer / File(s) Summary
Connector model and board behavior
src/core/connectors.js, src/core/board.js, test/node/connectors.test.js
Adds connector geometry, endpoint lookup, board connection operations, connector-aware copy, paste, duplication, deletion, movement, bounds, snapping, and marquee selection.
Connector controls and interactive rendering
src/components/FormatBar.jsx, src/platform/input.js, src/platform/connectors.js, src/platform/renderer.js, src/app.js, src/styles/canvas.css, test/browser/connectors.test.js, README.md
Adds Connect/Disconnect controls, connector selection, a dedicated SVG layer, connector styling, renderer wiring, and browser coverage.
Connector export and map filtering
src/core/export.js, src/platform/export-png.js, src/platform/minimap.js, test/browser/export.test.js
Excludes unplaced objects from frame bounds, renders valid connectors before objects in PNG output, and excludes connectors from minimap rectangles.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the connector feature and the related handling of objects without bounding boxes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch connectors

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/core/connectors.js (2)

159-168: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider exporting the point list so the platform layer can reuse it.

src/platform/connectors.js (lines 96-136) rebuilds the same five zero-size rects from connectorGeometry output that this function builds. Extract a small connectorPoints(drawn) helper here and call it from both places. This keeps one definition of what a connector covers.

♻️ Proposed extraction
+/** The points a drawn connector occupies: both line ends and the head. */
+export const connectorPoints = (drawn) => [
+  { x: drawn.line.x1, y: drawn.line.y1, w: 0, h: 0 },
+  { x: drawn.line.x2, y: drawn.line.y2, w: 0, h: 0 },
+  ...drawn.head.map(([x, y]) => ({ x, y, w: 0, h: 0 })),
+];
+
 export function connectorBox(from, to, options) {
   const drawn = connectorGeometry(from, to, options);
   if (!drawn) return null;
-
-  return bbox([
-    { x: drawn.line.x1, y: drawn.line.y1, w: 0, h: 0 },
-    { x: drawn.line.x2, y: drawn.line.y2, w: 0, h: 0 },
-    ...drawn.head.map(([x, y]) => ({ x, y, w: 0, h: 0 })),
-  ]);
+  return bbox(connectorPoints(drawn));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/connectors.js` around lines 159 - 168, Extract and export a
connectorPoints(drawn) helper from connectors.js that builds the line endpoints
and arrowhead zero-size rectangles currently assembled inside connectorBox.
Update connectorBox and the platform connector logic to reuse this helper
instead of rebuilding the point list, preserving the existing connector
coverage.

145-149: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove or cover isHanging

isHanging has no caller or test reference. Remove the unused export, or add the connector validation path and tests for missing endpoints.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/connectors.js` around lines 145 - 149, Remove the unused exported
isHanging helper from the connector module, unless you integrate it into the
connector validation path and add tests covering connectors with missing from or
to endpoints.
src/core/board.js (1)

188-221: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Exclude connectors explicitly from usable.

If OBJECT_DEFAULTS later includes connector, bbox(usable) will read missing dimensions and produce NaN paste coordinates. Use the existing isPlaced predicate.

Proposed guard
-    const usable = objects.filter((obj) => OBJECT_DEFAULTS[obj?.type]);
+    const usable = objects.filter((obj) => isPlaced(obj) && OBJECT_DEFAULTS[obj?.type]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/board.js` around lines 188 - 221, Update the usable-object filtering
in paste so connectors are explicitly excluded by combining the OBJECT_DEFAULTS
check with the existing isPlaced predicate. Keep connector handling in the
separate joins flow, ensuring bbox(usable) only receives placed objects with
valid dimensions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/platform/renderer.js`:
- Around line 113-115: Update the placed-object insertion logic in the renderer
so the connector SVG created by createConnectorLayer() remains the layer prefix
and all placed objects are inserted after it, preserving connectors behind board
objects. Add a browser assertion that verifies the connector layer is the first
child and placed elements follow it.

In `@test/node/connectors.test.js`:
- Around line 235-247: Update the “pasting points the copies at each other” test
to capture the original connector created by the two() fixture, then assert the
pasted connector’s id differs from that original connector’s id instead of
checking only that copied.id is defined. Preserve the existing assertions that
pasted endpoints reference the new placed ids and no node retains an old id.

---

Nitpick comments:
In `@src/core/board.js`:
- Around line 188-221: Update the usable-object filtering in paste so connectors
are explicitly excluded by combining the OBJECT_DEFAULTS check with the existing
isPlaced predicate. Keep connector handling in the separate joins flow, ensuring
bbox(usable) only receives placed objects with valid dimensions.

In `@src/core/connectors.js`:
- Around line 159-168: Extract and export a connectorPoints(drawn) helper from
connectors.js that builds the line endpoints and arrowhead zero-size rectangles
currently assembled inside connectorBox. Update connectorBox and the platform
connector logic to reuse this helper instead of rebuilding the point list,
preserving the existing connector coverage.
- Around line 145-149: Remove the unused exported isHanging helper from the
connector module, unless you integrate it into the connector validation path and
add tests covering connectors with missing from or to endpoints.
🪄 Autofix

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: CHILL

Plan: Pro Plus

Run ID: a3e3dbf6-c4a8-4712-bcde-eeb91f3eb9e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6e70f34 and add729a.

📒 Files selected for processing (15)
  • README.md
  • src/app.js
  • src/components/FormatBar.jsx
  • src/core/board.js
  • src/core/connectors.js
  • src/core/export.js
  • src/platform/connectors.js
  • src/platform/export-png.js
  • src/platform/input.js
  • src/platform/minimap.js
  • src/platform/renderer.js
  • src/styles/canvas.css
  • test/browser/connectors.test.js
  • test/browser/export.test.js
  • test/node/connectors.test.js

Comment thread src/platform/renderer.js
Comment thread test/node/connectors.test.js
- **A connector was drawn on top of the objects it joins.** Being the layer's
  first child is not a place a component can hold: the renderer rewrites the
  order of the children on every sync to match the z-order of the objects *it*
  draws, and it does that by inserting each one before the previous one's next
  sibling — so the SVG parked at the front was pushed to the end of the list and
  painted over everything. It is appended now and put behind by `z-index: -1`,
  which says the depth once instead of relying on a position another component
  owns. `#layer` is transformed and therefore a stacking context, so it cannot
  fall out of the back of the board.

  This was invisible on a first look because a line is cut at both borders and
  usually has no card under it — and the test I wrote for it passed both ways at
  first, because `board.add` selects what it makes and a selected object is
  lifted by `z-index: 1`. Deselected, the broken version really does answer the
  line rather than the card at that point, which is what the test now asserts
  and what it fails without the fix.

- **An assertion that could not fail.** `copied.id !== undefined` is true of
  every object `paste` returns. The original connector is captured now and the
  copy is compared against it, which is the claim that was meant: nothing keeps
  an old id, because two objects sharing one is a document where an op means two
  things.

987 tests, 96.9% lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTizw7U35rC2MPquwr4Fv5
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.

2 participants