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)
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
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 buildtype-checks with tsc and bundles with esbuild,bun run lintruns the officialeslint-plugin-obsidianmdrecommended config,bun run smokeruns a headless behavior contract for the trail engine, andbun run loadcheckloads the built bundle in Node with a stubbedobsidianmodule and callsonload(). - 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 attachmain.js,manifest.jsonandstyles.cssto a GitHub release. - CI caught one bug my machine never showed:
bun install --frozen-lockfilefailed on the runner because the committedbun.lockstill described the repo under its old package name from before the rename, and one dependency pin had drifted frompackage.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
- How to Develop an Obsidian Plugin - Getting Started Guide by dss99911: the template and esbuild setup this repo started from.
- I Built an Obsidian Plugin for Myself. Someone Actually Used It. by vladbevs: the built-it-for-my-own-workflow path this post follows too.
- Map pointer coordinates into the canvas drawing buffer by morphio: the coordinate mapping math, the same problem as keeping a trail above a scrolling note.
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)
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.