DEV Community

Cover image for Laser pointer for Obsidian: how my messy tldraw canvas during midsems became a plugin
Akash Pattnaik
Akash Pattnaik

Posted on AI-assisted

Laser pointer for Obsidian: how my messy tldraw canvas during midsems became a plugin

For the past few weeks I've been taking notes and reading in Obsidian for my midsems, and it worked fine until I had to explain a topic to a friend. I wanted the shared-whiteboard experience: copy the question somewhere, draw around it, point at the pieces. My tool for that part was tldraw, mostly because of its laser pointer.

Two problems showed up fast. The tldraw canvas just kept growing, one topic stacked under another, until finding "that thing I explained earlier" meant scrolling archaeology. And switching apps in the middle of an explanation broke the flow every time.

So the obvious idea finally landed: the laser should live inside Obsidian, where the notes already are. I searched a lot for an existing plugin and found nothing, so I wrote one. Building it turned into a longer story than the feature itself, with a licensing detour in the middle.

What the plugin does

It's tldraw-laser-obsidian, a plain community plugin. Turn it on, drag the pointer anywhere over an open note, and a red trail follows you. Lift the pointer and the trail holds, then fades. Esc or k exits back to typing. It never writes to the file or the undo history; the trail is overlay ink only.

It works in source mode, Live Preview and reading mode. Reading mode is where most of my midsem studying happens, and reading mode is also why this needs a plugin at all instead of clever CSS: it has no CodeMirror editor, so there is nothing for the usual in-editor ink approaches to attach to (more on that below).

There is also a floating color toolbar with tldraw's default palette as circular swatches, light and dark variants included, and a top-right panel mode that slides out of the way behind a half-circle tab at the screen edge. Default trail: 4px wide, 0.7 opacity, 1200ms hold, 500ms fade. Those are tldraw's exact laser values; matching the feel was the whole point.

How the trail actually works

If you ever want whiteboard ink over a plain scrolling web page, this is the part that transfers. perfect-freehand turns the raw pointer list into a filled outline, which is the visual half. The interesting work was around it: where points live, how input gets throttled, and how the fade drains.

Points are recorded in the note's coordinate space

The canvas covers the viewport, but the note scrolls under it. Every painted frame is translated by the content element's current offset, so strokes stay glued to the text and you can scroll mid-explanation without the trail smearing away:

ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0)
ctx.clearRect(0, 0, this.viewWidth, this.viewHeight)
ctx.translate(contentRect.left - layerRect.left, contentRect.top - layerRect.top)
Enter fullscreen mode Exit fullscreen mode

Input is staged, one commit per tick

pointermove fires much faster than the screen can repaint. Points arriving between frames are staged, one per stroke, and each animation frame commits the newest staged point. Anything under one pixel from the newest committed point is rejected outright, so a resting pointer never twitches the trail:

const point: TrailPoint = { x, y, z: 0.5 }
const gate = stroke.gate
if (gate && Math.hypot(point.x - gate.x, point.y - gate.y) < 1) return
stroke.pending = point
Enter fullscreen mode Exit fullscreen mode

The fade is a drain, not a CSS transition

A drag opens a session that strokes live in until its hold timer fires (1200ms idle by default). Then everything in the session drains together, oldest point first, with an ease-in curve over 500ms. That grouped drain is what makes it read as one laser gesture instead of a pile of scribbles vanishing separately.

Reading mode, where CodeMirror ends

Edit and Live Preview were the easy half, both are CodeMirror 6: one ViewPlugin per editor places a canvas over the editor. Reading view has no editor object at all, so the same overlay class is installed from workspace events (layout changes, active leaf changes) instead, through a second host context for that surface. Roughly half the repo exists only because of that split.

One more fight while the tool is on: the canvas overlays sit outside the scroll container, so native wheel scrolling dead-ends. The plugin forwards wheel events to the note's actual scroller while leaving ctrlKey events native so pinch zoom still works. Small thing, but a laser you cannot scroll with was unusable during real studying.

The tldraw license detour (why the code is really mine)

Honest version: v1 was straight-up copying. I ported tldraw's scribble manager and chunks of its overlay code because that reaches the exact feel fastest. The mistake bit when I went to publish: tldraw's actual code ships under a bespoke "tldraw license", source-available, and it conflicts with what I needed. Their code cannot be relicensed under MIT, and a public plugin installed by end users falls outside the free-use grant as far as I could tell.

So the shipped version stops copying. The engine was rewritten from the documented behavior instead: same numbers, same feel (one commit per tick, grouped ease-in fade), different internals entirely. The repo carries no tldraw source, the README says so, and tldraw's license sits in the repo verbatim as reference. MIT covers only my code.

If you take one practical thing from this post: read the license before the first commit, not on release day. It ended up costing me a rewrite. A week earlier it would have cost me a coffee.

Building it with Bun, esbuild and GitHub Actions

For anyone writing an Obsidian plugin next:

  • The whole repo runs on Bun (npm is never touched): bun run build type-checks with tsc and bundles with esbuild, bun run lint runs the official eslint-plugin-obsidianmd recommended config, bun run smoke runs a headless behavior contract for the trail engine, and bun run loadcheck loads the built bundle in Node with a stubbed obsidian module and calls onload().
  • The Obsidian lint rules are a dress rehearsal for the community-plugin review, and the repo keeps passing them on every commit.
  • Obsidian loads plugins from module.exports.default. My first bundle exported the class as a named export only, so the app reported "failed to load plugin". The loadcheck harness now guards that shape before every release.
  • Releases are tags: pushing a tag matching manifest.json's version makes Actions lint, smoke-test, build, verify the tag against the manifest, and attach main.js, manifest.json and styles.css to a GitHub release.
  • CI caught one bug my machine never showed: bun install --frozen-lockfile failed on the runner because the committed bun.lock still described the repo under its old package name from before the rename, and one dependency pin had drifted from package.json. Nothing noticed locally. Regenerated the lockfile, moved the tag, done. If you rename a Bun project, regenerate the lockfile.

Try it

Repo: tldraw-laser-obsidian
Latest release (1.2.0): releases

Manual install: copy main.js, manifest.json and styles.css from the release into <vault>/.obsidian/plugins/tldraw-laser-obsidian/, restart Obsidian and enable "Laser Pointer". BRAT works too: point it at iAkashPattnaik/tldraw-laser-obsidian. It is not in the community plugin store yet.

Posts I read along the way

If an Obsidian laser plugin already exists and my searching was just bad, tell me. I'd happily delete mine instead of duplicating a good one.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

Keeping pointer coordinates in the note’s content space is the detail that makes the overlay feel native while scrolling. A useful regression test would replay a scroll plus pointer sequence at different device-pixel ratios and compare the stroke anchor points, since that is where a visual tool can quietly become disorienting.