npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

hyper-undo

v0.4.0

Published

DOM-state undo/redo via MutationObserver inverse-op replay. Standalone or paired with hyperclayjs.

Downloads

505

Readme

hyper-undo

DOM-state undo/redo for self-editing HTML pages. A single MutationObserver records primitive DOM mutations with computable inverses, batches them into labelled commits, and replays them backward (undo) or forward (redo). Removed subtrees are kept by reference, so undo restores the same live nodes: event listeners, focus, scroll position, and custom-attribute wiring all survive.

Works standalone, or auto-wired into hyperclayjs as window.hyperclay.undo.

Mental model

The DOM is the state. Undo navigates between DOM states. Each undoable operation is a transition recorded as inverse-able primitives, not a full snapshot. Snapshot-based undo re-clones from serialized HTML and loses live node identity; mutation-based replay keeps it.

Install

npm install hyper-undo
import { undo } from 'hyper-undo'

undo.start({ scope: document.body, maxHistory: 100, bindKeys: true })

undo.commit('User edited title', () => {
  document.querySelector('h1').textContent = 'New title'
})

undo.undo()   // restores prior state
undo.redo()   // re-applies the undone state

Or load the IIFE bundle directly (auto-attaches to window.hyperclay.undo):

<script src="https://cdn.jsdelivr.net/npm/hyper-undo/dist/hyper-undo.min.js"></script>

Via hyperclayjs

The smooth-sailing preset includes hyper-undo and auto-starts the singleton on document.body (in edit mode only, with bindKeys: true). Cmd+Z works out of the box.

<script type="module">
  await import('https://cdn.jsdelivr.net/npm/hyperclayjs@1/src/hyperclay.js?preset=smooth-sailing')
  hyperclay.undo.commit('Add product', () => addProduct())
</script>

API

| Call | Effect | |---|---| | undo.start(opts) | start the singleton on opts.scope (default document.body) | | undo.stop() | disconnect observer, remove key bindings, clear stacks | | undo.commit(label, fn) | run a synchronous fn, push its mutations as one labelled commit | | undo.commitCaptured(label) | drain observer.takeRecords() and push as one commit (pause-before / commit-on-success pattern) | | undo.discardCaptured() | drain and throw away the captured records (failure path companion) | | undo.flush() | force-close the current idle batch as its own commit | | undo.undo() / undo.redo() | navigate history | | undo.clear() | clear both stacks | | undo.pause() / undo.resume() | recorder skips while paused | | undo.on('undo'\|'redo'\|'commit'\|'clear', fn) / undo.off(...) | subscribe to lifecycle events; undo/redo fire after a navigate, commit after a new commit, clear after a reset. (No generic change event.) | | undo.canUndo / undo.canRedo | booleans (getters) | | undo.history | [{ label, timestamp }, ...], oldest first; timestamp is Date.now() millis | | undo.isPaused | boolean (getter) | | undo.defaults | { shadowKeydownIn: [...] } | | undo.create(opts) | a separate scope for advanced multi-scope use |

Options

| Option | Default | Notes | |---|---|---| | scope | document.body | element to observe | | maxHistory | 100 | older commits drop off the back; dropped commits release their removed-node references | | idleWindowMs | 500 | how long to wait before auto-closing a batch | | idleLabel | 'Edit' | label for auto-closed batches | | bindKeys | true (singleton), false (create) | install the global Cmd+Z handler | | shadowKeydownIn | code-editor selectors (see below) | when event.target.closest(selector) matches, the global handler bails without preventDefault | | ignoreAttribute | null | predicate (attrName, element) => boolean; return true to skip recording that attribute mutation | | debug | false | console.log internal state transitions |

Batching

Raw mutation records are too fine-grained (typing "hello" is five characterData records). Two batching modes share one collector:

  • Explicit commitundo.commit(label, fn) wraps a synchronous chunk into one labelled commit. Throws if fn() returns a Promise (mutations after the first await would silently land in a different commit).
  • Idle auto-batch — mutations made outside an explicit commit collect until the scope is idle for idleWindowMs, then close into one Edit commit.

Keyboard shortcuts

bindKeys: true installs a window keydown capture-phase listener:

| Combo | Action | |---|---| | Cmd+Z / Ctrl+Z | undo | | Cmd+Shift+Z / Ctrl+Shift+Z | redo | | Cmd+Y / Ctrl+Y | redo (Windows convention) |

In-page editors

The handler short-circuits (without preventDefault) when event.target is inside any selector in shadowKeydownIn, so an embedded editor's own keymap handles the key. The default list covers CodeMirror v5/v6, Monaco, Ace, Quill, Tiptap, and ProseMirror:

['.CodeMirror', '.cm-editor', '.monaco-editor', '.ace_editor', '.ql-editor', '.tiptap', '.ProseMirror']

Extend it for your own editor:

undo.start({ shadowKeydownIn: [...undo.defaults.shadowKeydownIn, '.my-editor'] })

Outside the shadow list, the global Cmd+Z intercepts even inside plain <input>/<textarea>, so native char-level input-undo no longer fires there. That's intentional: page-state undo is what users expect for Cmd+Z on a self-editing page. Pass bindKeys: false to opt out entirely and bind your own handler.

Filter attributes

A mutation is excluded from recording when its target's ancestor chain contains any of: mutations-ignore, save-remove, save-ignore, save-freeze. These mirror hyperclayjs's _shouldIgnore semantics; no new attribute is introduced.

Multi-scope (advanced)

const pageUndo = undo.start()                                  // singleton on document.body, owns Cmd+Z
const editorUndo = undo.create({ scope: editorRoot, bindKeys: false })
editorUndo.start()
// editorUndo.undo() / .redo() called manually; Cmd+Z still routes to pageUndo

Only one scope can own the global Cmd+Z binding at a time. Calling start() again with a different scope throws (use create for additional scopes); calling it again with the same scope warns and keeps the original config.

Form input typing (known gap)

Pure-property <input>/<textarea> value changes are not MutationRecords, so raw typing into a field isn't directly observable. Coverage:

  • CMS form fields flow through the engine, which mutates the page DOM — the recorder sees the page mutation. ✓
  • [persist] inputs on a Hyperclay page mirror el.value to the value attribute; [persist] textareas mirror to data-value. The recorder sees the attribute mutation. ✓
  • Plain <input>/<textarea> without [persist] do NOT mirror; their typing is invisible to the recorder. ✗

For an unmirrored field, do one of: add [persist], wrap the input handler in undo.commit(label, fn), or accept that raw typing isn't undoable for that field.

What this does NOT do (v1)

No persistence across reloads, no cross-tab sync, no semantic diffs (it records DOM ops, the label is the only semantic), no collaborative/OT undo, no tracking of non-DOM state, no "revert to saved" checkpoint.

License

MIT