Back to gallery

Point and shoot: capture anything on a page.

Introducing point-and-shoot, a small open source library with no dependencies for pointing at something on a web page and keeping it. A spotlight follows the pointer from block to block, a click shoots whatever it’s on, and a drag draws an area. What a shot does is up to you: save it, send it, turn it into Markdown, or collect every icon in a rectangle.

It comes out of Glea, a Mac browser of mine with a journal built in, where holding ⌥ lights up the page and a click puts what’s under the pointer in today’s entry. And it is the third time I have built point and shoot: first at beam, the browser I worked on, where pressing ⌥ to capture anything on the web first felt like magic, then at Kosmik, and now in Glea. Three times is enough to know which parts are the same every time and which parts belong to the app, so this time I pulled the parts that are the same out into a library, and open sourced it, so there won’t be a fourth.

This page is the library’s demo and its manual, and everything on it is capturable: hold ⌥, or press the button, and click anything.

Settings

ColorSelectionAfter a shot

Take it with you

Everything above runs on @daformat/point-and-shoot: the pointing, the spotlight, the press, the drag, the flash and the shake. What the demo adds on top is what Glea adds on top: the ⌥ key, the Markdown, the journal entry, and the picture of an area. Glea itself, its browser extensions and glea.app all run on the same package, so the thing you are holding here is the thing that ships.

Install

Open the repo on Github (and drop a star if you like it!)

npm install @daformat/point-and-shoot

Usage

import { createPointAndShoot } from "@daformat/point-and-shoot";
import { toMarkdown } from "@daformat/point-and-shoot/markdown";

const pns = createPointAndShoot({
  // Resolve to keep it, throw to shake. The node is an Element or a Range.
  onShoot: async (target) => save(toMarkdown(target.node)),
});

button.addEventListener("click", () => pns.toggle());

// The confirmation is yours to draw: the library flashes, and that's all
pns.on("shot", (result) => result.ok && toast("Saved"));

No hotkeys

The library never decides when it’s on. You do.

Glea turns the mode on while ⌥ is held, the extensions do the same from a content script, and this page does it from a key handler and a button. Those are three different answers to when, so the library has none of its own: it has activate, deactivate and toggle, and you call them from wherever your answer lives. The only key it listens to is Escape, which leaves the mode and cancels a shot still waiting on your handler, because that’s what Escape means everywhere, and even that one can be turned off.

// Hold Option alone for a moment and it's on, let go and it's off
let timer = 0;
addEventListener("keydown", (e) => {
  if (e.key !== "Alt" || e.repeat || e.metaKey || e.ctrlKey) return;
  timer = setTimeout(() => pns.activate({ at: pointer }), 120);
});
addEventListener("keyup", (e) => {
  if (e.key !== "Alt") return;
  clearTimeout(timer);
  pns.deactivate();
});

// A release can go unseen when focus goes elsewhere: the real key state
// decides, and returning false lets the event through and leaves the mode
pns.configure({ guard: (e) => e.altKey, after: "stay" });

Two details in there came from getting it wrong. The 120 milliseconds are there because ⌥ is also the first key of a dozen shortcuts, and a spotlight flashing up every time you switch tabs with ⌥⌘→ is exactly as annoying as it sounds. The guard is there because a key release is not guaranteed: switch apps with ⌥ held and the page never hears it go up. Every pointer event while the mode is on asks the guard first, and the event’s own altKey is the truth.

What’s worth pointing at

The rules that tell a block from the box around it.

Point at a page and the browser will tell you every element under the pointer, innermost first, and almost none of them are what you meant. There is the span around a word, the paragraph around the span, three wrappers with no content of their own, a grid, the main column and the body. The default targeter walks that stack and keeps an element only if it is visible, has a box at all, and has something of its own to show:

meaningful = visible ∧ area ≤ 1.5 viewports ∧ height ≤ 4 viewports ∧ (text ∨ media)

Text counts once it has a letter or a digit in any script, so a row of bullets, pipes and middle dots is decoration and not content. Text that is hidden, or squeezed into a one pixel box for screen readers, doesn’t count either. The size limits are what stop a wrapper from winning: something that covers more than one and a half screens lights up most of the page, which is no help in picking anything, but a long article column still passes, because four screens tall is a lot of article.

Then images go first, an <img> ahead of a background image, then media, then the innermost block. A word inside a link inside a paragraph targets the paragraph, because inline elements are walked up to the block they sit in, and part of a drawing targets the whole <svg>.

The spotlight doesn’t take the element’s box, though. It takes the extent of what is rendered inside it: its text runs, each measured with a range, and its media. A heading in a padded hero section lights up around the words and not around the hero, and the box is cut down to whatever its ancestors’ overflow lets show, so the half of a carousel slide that’s off to the side isn’t lit. All of this walks the tree with a budget, three hundred nodes to decide if something has text and six hundred to measure it, so a pointer move over a huge subtree costs the same as one over a paragraph.

Two places the browser doesn’t look, it has to be told to. Open shadow roots are searched, since elementsFromPoint stops at their door. And frames: an iframe keeps the pointer’s events to its own document, so the page never hears the pointer arrive over a video player. While the mode is on, frames let the pointer through to the page, and they are found by their box instead.

Holding still

Why it doesn’t flicker between small things.

Move the pointer across a row of icons and in between each one it lands on the row itself, for a frame or two. A naive spotlight balloons out to the whole row and snaps back, again and again, and it looks broken. So when the next target is a container of the current one, it only wins once the pointer has rested on it for 180 milliseconds, and leaving a target for nothing at all keeps it lit within 20 pixels. Both are options, dwell and grace.

The spotlight also leans a few pixels toward the pointer, something I carried over from Kosmik, which makes it feel held rather than placed:

lean = sign(d) × min(√|d|, 1) × 4px, d = (pointer − center) / (half size + 5), half that vertically

The square root is what makes it soft: the first few pixels away from the center lean the most, and the edges barely more than that.

Your own markup

Targeters, and what to do when a selector isn’t enough.

Whatever the heuristics think, you know your page better. A targeter is a function from what’s under the pointer to a target, and they are tried in order, so yours go ahead of the defaults. match covers the common case, the closest ancestor matching a selector, and the same targeter collects matches inside an area too.

import { defaultTargets, match } from "@daformat/point-and-shoot";

// Your own markup, ahead of the defaults: anything under the pointer
// inside a card targets the whole card
const cards = match("[data-card-id]", (el) => ({
  kind: "card",
  whole: true,
  data: { id: el.dataset.cardId },
}));

createPointAndShoot({ targets: [cards, ...defaultTargets] });

Glea’s own is a targeter of this kind: pointing anywhere over a post on X or Bluesky, or a YouTube video, lights the whole post and keeps its address, so the note embeds it again. Holding ⌘ as well turns that off and collects the post as text, and that switch is a flag, free-form state the targeters read and that re-resolves the target when it changes.

Shooting

A promise, a pending state, and a way out.

A shot is your handler, and your handler can take its time. While it runs, the spotlight holds on its target and follows it as the page scrolls, and the page’s clicks are swallowed, so a second click doesn’t follow a link the first one was meant to capture. In Glea that pending state lasts as long as the collect panel is open, which can be a while. Resolve and it flashes; throw and it shakes and stays on for another try; Escape aborts the signal it handed you and lets go.

The flash, the shake and the screen reader announcements are the library’s, and each can be changed or turned off. A badge or a toast is not: what “saved” looks like on your page is yours to draw, from the shot event, which is what the hint at the bottom of this page does.

Areas

Drag a rectangle, and decide what it means.

Dragging is off unless you give it a handler, because a drag can mean anything: collect the cards inside it, copy the icons, or take a picture.

import { elementsInRect } from "@daformat/point-and-shoot";

createPointAndShoot({
  area: {
    onCapture: async (rect, ctx) => {
      // What's inside, native and custom in one pass, nothing filtered
      const found = elementsInRect(rect.viewport, ["svg", cards]);

      // Or a picture: hide the overlay, take the pixels your way, give it back
      const shown = await ctx.hideOverlay();
      const png = await takeScreenshot(rect);
      shown();
      return { found, png };
    },
  },
});

The picture is the one the library can’t take for you. No web API reads a page’s own pixels, so Glea asks Chromium for a screenshot and the extensions ask the browser, and both hide the overlay for two frames first so it isn’t in the shot. This page has neither, so it draws itself into a canvas with modern-screenshot, loaded on your first drag, and only the smallest element that holds the whole area is drawn, since the page at large has every code sample on it to encode.

Selections

One shape around all of it, rounded, and quick.

Select some text and hold ⌥, and the selection is collected at once, with its lines lit as it goes, the way Glea does it. The bar has the other ways to treat a selection: lit wherever the pointer is and collected on a click, lit only while you point at it, or left alone. When it’s lit, it’s lit line by line.

Asking the browser for a selection’s boxes gives you a box for every element wholly inside it on top of its text, so select a whole page and you get every card and column back, stacked, the overlaps darker. The library walks the selection instead and keeps only text lines and media, merges the pieces of a line that are within a word’s space of each other so two columns stay apart, and draws the union of all of them as one path:

edges → a compressed grid → cells filled from a 2D difference array → the boundary walked into loops

The rects’ edges make a grid with as many columns as there are distinct x positions, every cell is filled or not in one pass, and the border between filled and empty cells is walked into loops, clockwise around shapes and the other way around holes. Where two shapes only touch at a corner, the walk turns right, so they stay two shapes. Every corner is then rounded, by at most half of its shorter edge:

r = min(6px, in / 2, out / 2)

A selection can be huge, so every step has a way out. Gathering the lines stops at four thousand nodes, two thousand boxes or 24 milliseconds, whichever comes first, and the outline at 160 thousand grid cells; past any of them it draws one rounded box around the lot, which on a page that heavy is what you would have wanted anyway. The lines are cached and moved along as the page scrolls, so pointing at a selection costs nothing after the first frame.

While the shape is drawn, the browser’s own highlight is hidden with a ::selection rule, and it comes back the moment the shape goes, with the selection itself never touched. Text selected inside a shadow root works too, which took asking the right question: Chrome and Safari show the page only a collapsed range where the host is, and getComposedRanges has the real one.

Looks

Every number is a custom property.

The spotlight is the dot under the pointer, the highlight it morphs into, and a selection’s shape, drawn in a closed shadow root so the page’s styles can’t reach in and built node by node for pages with Trusted Types. Its color, border, padding, radius, press and lean are options, and each option is also a --pns-* property on the host element, so a stylesheet can theme it without any JavaScript. The color picker in the bar above is doing exactly that.

point-and-shoot {
  --pns-color: rgb(0 0 0 / 0.08);
  --pns-radius: 2px;
  --pns-border: 1px solid currentColor;
}

@media (prefers-color-scheme: dark) {
  point-and-shoot {
    --pns-color: rgb(255 255 255 / 0.12);
  }
}

With reduced motion, the spotlight jumps instead of gliding, the press doesn’t shrink it, the shake becomes a short pulse, and the flash is quicker. And if none of it is what you want, pass a renderer of your own, or none at all, and draw the whole thing from the events.

Glea’s point and shoot, in the app, in Glea Clipper for Chrome, Firefox and Safari, and on glea.app, runs on this package, with Glea’s own posts, embeds and Markdown on top.
Nothing you capture on this page leaves it: the window above is a mock of Glea’s journal, drawn in your browser, and gone when you reload.

Up next

A table of contents component
-->
<--

Right before

Audio Borealis, an audio glow effect for the web

Click anything to capture it, or drag for an area.