Skip to content

feat: add 2D canvas rendering for shape indicators#7708

Merged
steveruizok merged 9 commits intomainfrom
feat/2d-canvas-indicators
Jan 27, 2026
Merged

feat: add 2D canvas rendering for shape indicators#7708
steveruizok merged 9 commits intomainfrom
feat/2d-canvas-indicators

Conversation

@kostyafarber
Copy link
Copy Markdown
Contributor

@kostyafarber kostyafarber commented Jan 19, 2026

Implements canvas-based rendering for shape indicators with indicator2d methods across all shape utilities.

  • I've also added some throwaway logging (enabled with the debug menu) for those reviewing to see the per improvements. I'm seeing around 25x increases as well as the time we save mounting/unmounting on page load.
  • I've added a new method to the ShapeUtil and a new type to allow for more complex shape drawing (arrows require clip paths for the label etc)

I've also appended a snippet that you can run in the console which will create a bunch of shapes to test out the new feature too.

pr-7708-walkthrough.mp4
create shapes snippet
// Shape Test Generator for CanvasShapeIndicators.tsx
// Run this in the Chrome DevTools console when tldraw is loaded

(function generateTestShapes() {
  // Get the editor instance
  const editor = window.editor || document.querySelector('[data-testid="tldraw"]')?.__tldraw_editor__;
  
  if (!editor) {
    console.error('Could not find editor instance. Make sure tldraw is loaded.');
    console.log('Tip: The editor is usually available as window.editor in development mode');
    return;
  }

  // Style options (no blue colors to avoid confusion with indicators)
  const colors = ['black', 'grey', 'light-violet', 'violet', 'yellow', 'orange', 'green', 'light-green', 'light-red', 'red'];
  const geoTypes = ['rectangle', 'ellipse', 'triangle', 'diamond', 'pentagon', 'hexagon', 'octagon', 'star', 'rhombus', 'oval', 'trapezoid', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'x-box', 'check-box', 'cloud', 'heart'];
  const fills = ['none', 'semi', 'solid', 'pattern', 'fill'];
  const dashes = ['draw', 'solid', 'dashed', 'dotted'];
  const sizes = ['s', 'm', 'l', 'xl'];
  
  const shapes = [];
  let x = 0;
  let y = 0;
  const spacing = 150;
  const rowHeight = 150;
  let shapeIndex = 0;

  // Helper to create unique shape IDs
  const createId = () => `shape:test_${Date.now()}_${shapeIndex++}`;

  // 1. GEO SHAPES - All types with different styles
  console.log('Creating geo shapes...');
  geoTypes.forEach((geo, i) => {
    const col = i % 6;
    const row = Math.floor(i / 6);
    shapes.push({
      id: createId(),
      type: 'geo',
      x: col * spacing,
      y: row * rowHeight,
      props: {
        geo,
        w: 100,
        h: 100,
        color: colors[i % colors.length],
        fill: fills[i % fills.length],
        dash: dashes[i % dashes.length],
        size: sizes[i % sizes.length],
      }
    });
  });

  // Offset for next section
  let sectionY = Math.ceil(geoTypes.length / 6) * rowHeight + 100;

  // 2. ARROWS with different styles
  console.log('Creating arrow shapes...');
  const arrowheadTypes = ['arrow', 'triangle', 'square', 'dot', 'pipe', 'diamond', 'inverted', 'bar', 'none'];
  arrowheadTypes.forEach((arrowhead, i) => {
    shapes.push({
      id: createId(),
      type: 'arrow',
      x: (i % 4) * 200,
      y: sectionY + Math.floor(i / 4) * 120,
      props: {
        color: colors[i % colors.length],
        fill: fills[i % fills.length],
        dash: dashes[i % dashes.length],
        size: sizes[i % sizes.length],
        arrowheadStart: arrowhead,
        arrowheadEnd: arrowheadTypes[(i + 1) % arrowheadTypes.length],
        start: { x: 0, y: 0 },
        end: { x: 150, y: 80 },
      }
    });
  });

  sectionY += Math.ceil(arrowheadTypes.length / 4) * 120 + 100;

  // 3. NOTE shapes with different colors and sizes
  console.log('Creating note shapes...');
  colors.slice(0, 8).forEach((color, i) => {
    shapes.push({
      id: createId(),
      type: 'note',
      x: (i % 4) * 250,
      y: sectionY + Math.floor(i / 4) * 200,
      props: {
        color,
        size: sizes[i % sizes.length],
        scale: 1,
      }
    });
  });

  sectionY += Math.ceil(8 / 4) * 200 + 100;

  // 4. TEXT shapes with different fonts and colors
  console.log('Creating text shapes...');
  const fonts = ['draw', 'sans', 'serif', 'mono'];
  const textAligns = ['start', 'middle', 'end'];
  fonts.forEach((font, i) => {
    shapes.push({
      id: createId(),
      type: 'text',
      x: (i % 4) * 200,
      y: sectionY,
      props: {
        color: colors[i % colors.length],
        size: sizes[i % sizes.length],
        font,
        textAlign: textAligns[i % textAligns.length],
        w: 150,
        autoSize: true,
        scale: 1,
      }
    });
  });

  sectionY += 150;

  // 5. FRAME shapes
  console.log('Creating frame shapes...');
  colors.slice(0, 4).forEach((color, i) => {
    shapes.push({
      id: createId(),
      type: 'frame',
      x: i * 300,
      y: sectionY,
      props: {
        w: 250,
        h: 200,
        name: `Frame ${i + 1}`,
        color,
      }
    });
  });

  sectionY += 300;

  // 6. LINE shapes with different styles
  console.log('Creating line shapes...');
  dashes.forEach((dash, i) => {
    shapes.push({
      id: createId(),
      type: 'line',
      x: i * 200,
      y: sectionY,
      props: {
        color: colors[i % colors.length],
        dash,
        size: sizes[i % sizes.length],
        points: {
          a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
          a2: { id: 'a2', index: 'a2', x: 100, y: 50 },
          a3: { id: 'a3', index: 'a3', x: 150, y: 0 },
        }
      }
    });
  });

  sectionY += 150;

  // 7. Shapes at various OPACITIES
  console.log('Creating shapes with varying opacity...');
  [1, 0.75, 0.5, 0.25].forEach((opacity, i) => {
    shapes.push({
      id: createId(),
      type: 'geo',
      x: i * spacing,
      y: sectionY,
      opacity,
      props: {
        geo: 'rectangle',
        w: 100,
        h: 100,
        color: 'red',
        fill: 'solid',
      }
    });
  });

  sectionY += rowHeight + 50;

  // 8. Rotated shapes
  console.log('Creating rotated shapes...');
  [0, Math.PI / 6, Math.PI / 4, Math.PI / 3, Math.PI / 2].forEach((rotation, i) => {
    shapes.push({
      id: createId(),
      type: 'geo',
      x: i * spacing + 50,
      y: sectionY + 50,
      rotation,
      props: {
        geo: 'star',
        w: 80,
        h: 80,
        color: colors[i % colors.length],
        fill: 'solid',
      }
    });
  });

  sectionY += rowHeight + 100;

  // 9. Different sizes
  console.log('Creating shapes with different sizes...');
  [50, 100, 150, 200].forEach((size, i) => {
    shapes.push({
      id: createId(),
      type: 'geo',
      x: i * 250,
      y: sectionY,
      props: {
        geo: 'ellipse',
        w: size,
        h: size,
        color: colors[i % colors.length],
        fill: fills[i % fills.length],
        dash: dashes[i % dashes.length],
      }
    });
  });

  // Create all shapes
  console.log(`Creating ${shapes.length} shapes...`);
  editor.createShapes(shapes);

  // Select all created shapes
  const createdIds = shapes.map(s => s.id);
  editor.select(...createdIds);

  // Zoom to fit all shapes
  editor.zoomToSelection();

  console.log(`✅ Created ${shapes.length} test shapes!`);
  console.log('Shape types created: geo (all variants), arrow, note, text, frame, line');
  console.log('Variations include: colors (no blues), fills, dashes, sizes, opacities, rotations');

  return shapes;
})();

Change type

  • bugfix
  • improvement
  • feature
  • api
  • other

API changes

  • Added getIndicatorPath() for canvas indicators
  • Added a TLIndicatorPath for the return canvas 2d paths

Note

Introduces 2D canvas rendering for shape indicators and migrates built-in shapes to it, reducing DOM overhead.

  • New CanvasShapeIndicators canvas overlay hooked into DefaultCanvas; legacy SVG indicators are now rendered only when ShapeUtil.useLegacyIndicator() returns true
  • API: adds ShapeUtil.getIndicatorPath(shape) and useLegacyIndicator(), plus TLIndicatorPath type and PathBuilder.toPath2D for converting PathBuilder to Path2D
  • Implements canvas indicators across shapes (arrow with clip support, bookmark, draw, embed, frame, geo, highlight, image, line, note, text, video); each overrides useLegacyIndicator() to false
  • Styling: adds .tl-canvas-indicators layer; tests updated to assert its presence instead of per-shape SVGs
  • Test infra: adds Path2D.roundRect polyfill in Vitest setup
  • Public exports/docs updated (api-report changes, index.ts exports TLIndicatorPath)

Written by Cursor Bugbot for commit 0eabd14. This will update automatically on new commits. Configure here.

Implements canvas-based rendering for shape indicators with indicator2d methods across all shape utilities.
@vercel
Copy link
Copy Markdown

vercel bot commented Jan 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
examples Ready Ready Preview Jan 27, 2026 8:17pm
5 Skipped Deployments
Project Deployment Review Updated (UTC)
analytics Ignored Ignored Preview Jan 27, 2026 8:17pm
chat-template Ignored Ignored Preview Jan 27, 2026 8:17pm
tldraw-docs Ignored Ignored Preview Jan 27, 2026 8:17pm
tldraw-shader Ignored Ignored Preview Jan 27, 2026 8:17pm
workflow-template Ignored Ignored Preview Jan 27, 2026 8:17pm

Request Review

@huppy-bot
Copy link
Copy Markdown
Contributor

huppy-bot bot commented Jan 19, 2026

API Changes Check Passed

Great! The PR description now includes the required "### API changes" section. This helps reviewers and SDK users understand the impact of your changes.

Copy link
Copy Markdown
Collaborator

@ds300 ds300 left a comment

Choose a reason for hiding this comment

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

looking great!

@kostyafarber
Copy link
Copy Markdown
Contributor Author

@ds300 added the computed cache, computed properties and a new method in the ShapeUtil for cheaper checks to see whether to use canvas/react rendering for indicators.

I've also added better perf logging using the chrome perf api. So you can now run a trace and look in the "Timings" tab and you'll see our traces there! pretty cool I think.

2026-01-21.at.17.52.52.-.Amber.Aardvark_3x.mp4

all the logging and debug flags will be removed when we merge.

Copy link
Copy Markdown
Collaborator

@ds300 ds300 left a comment

Choose a reason for hiding this comment

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

excellent

@kostyafarber kostyafarber marked this pull request as ready for review January 22, 2026 12:17
@huppy-bot huppy-bot bot added api API change feature New feature labels Jan 22, 2026
cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@vercel vercel bot temporarily deployed to Preview – tldraw-shader January 22, 2026 17:55 Inactive
@vercel vercel bot temporarily deployed to Preview – chat-template January 22, 2026 17:55 Inactive
@vercel vercel bot temporarily deployed to Preview – workflow-template January 22, 2026 17:55 Inactive
Corrects the filter condition to exclude shapes that do not exist, ensuring only valid shapes are processed for legacy indicators.
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

rSelectedColor.current = null
}, 0)
return () => clearTimeout(timer)
}, [isDarkMode, editor])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Canvas indicators don't repaint after dark mode toggle

Medium Severity

When dark mode toggles, the useEffect clears the cached color on the next tick, but the useQuickReactor that repaints the canvas only runs when its dependencies (editor, $renderData) change. Since neither changes on dark mode toggle, the canvas indicators continue displaying the old theme's color until some other interaction (like selection or camera movement) triggers a repaint.

Fix in Cursor Fix in Web

const hovered = editor.getHoveredShapeId()
if (hovered) idsToDisplay.add(hovered)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicated indicator display logic between components

Medium Severity

The logic for computing which shape IDs to display is duplicated between CanvasShapeIndicators and DefaultShapeIndicators. Both components independently check isChangingStyle, isIdleOrEditing (same editor.isInAny call), isInSelectState (same five state paths), and apply identical logic for adding selected and hovered shape IDs. This duplicated logic increases maintenance burden and risks inconsistent behavior if one is updated but not the other.

Additional Locations (1)

Fix in Cursor Fix in Web

: getDot(allPointsFromSegments[0], sw)

return new Path2D(solidStrokePath)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicated path computation in indicator and getIndicatorPath methods

Medium Severity

Multiple shape utilities duplicate their indicator path computation logic between the indicator() method and the new getIndicatorPath() method. For example, DrawShapeUtil has nearly identical code in both methods: computing allPointsFromSegments, forceSolid, sw, strokePoints, and solidStrokePath. The only difference is the return type (<path d={...} /> vs new Path2D(...)). This pattern repeats in HighlightShapeUtil, GeoShapeUtil, and LineShapeUtil.

Additional Locations (1)

Fix in Cursor Fix in Web

@steveruizok
Copy link
Copy Markdown
Collaborator

Good heavens. This is even better than I thought it would be. Fantastic work @kostyafarber!

@steveruizok steveruizok added this pull request to the merge queue Jan 27, 2026
@steveruizok
Copy link
Copy Markdown
Collaborator

I'm seeing huge improvements across basically everything, including just dragging lots of shapes around (35ms -> 25ms in this casual test). I didn't realize just how much was being spent on the signals / hooks around indicators.

Merged via the queue into main with commit 23ea0ac Jan 27, 2026
21 checks passed
@steveruizok steveruizok deleted the feat/2d-canvas-indicators branch January 27, 2026 21:20
ds300 added a commit that referenced this pull request Jan 28, 2026
After the 2D canvas indicator rendering change (#7708), collaborator
shape indicators were no longer being rendered. This was because
CanvasShapeIndicators only rendered the current user's selected shapes,
while collaborator indicators went through the SVG-based
CollaboratorShapeIndicator which now returns null for canvas-enabled
shapes.

This PR fixes the issue by:
- Adding collaborator indicator rendering to CanvasShapeIndicators
- Extracting shared collaborator state logic to collaboratorState.ts
- Updating LiveCollaborators to only render SVG indicators for shapes
  that use legacy indicators (for backwards compatibility with custom
  shapes)
github-merge-queue bot pushed a commit that referenced this pull request Jan 28, 2026
After the 2D canvas indicator rendering change (#7708), collaborator
shape indicators were no longer being rendered. This was because
`CanvasShapeIndicators` only rendered the current user's selected
shapes, while collaborator indicators went through the SVG-based
`CollaboratorShapeIndicator` which now returns null for canvas-enabled
shapes.

This PR fixes the issue by:
- Adding collaborator indicator rendering to `CanvasShapeIndicators`
with per-collaborator colors and 0.5 opacity
- Extracting shared collaborator state logic to `collaboratorState.ts`
to avoid duplication
- Updating `LiveCollaborators` to only render SVG indicators for shapes
that use legacy indicators (for backwards compatibility with custom
shapes)

Relates to #7437

### Change type

- [x] `bugfix`

### Test plan

1. Open a multiplayer session with two users
2. Have one user select shapes
3. Verify the other user sees the collaborator's selection indicators
with the collaborator's color

- [ ] Unit tests
- [ ] End to end tests

### Release notes

- Fixed collaborator shape indicators not rendering after the canvas
indicator change

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Restores collaborator selection indicators after moving shape
indicators to the canvas renderer.
> 
> - Renders collaborator shape indicators in `CanvasShapeIndicators`
with per-collaborator color and 0.7 opacity, drawn beneath local
indicators
> - Adds `useActivePeerIds$` in `usePeerIds` to track which
collaborators should be shown based on timed activity transitions
> - Extracts shared collaborator logic to `utils/collaboratorState.ts`
(`getCollaboratorStateFromElapsedTime`, `shouldShowCollaborator`)
> - Updates `LiveCollaborators` to use `shouldShowCollaborator` and only
render SVG indicators for shapes using legacy indicators (canvas handles
the rest)
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ce0f1f9. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api API change feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants