A developer tool for inspecting the Content Model of RoosterJS editors on a page, inspired by React DevTools.
This repo doesn't contain RoosterJS itself - it's a separate tool that talks to any RoosterJS
editor on a page through a small public hook the editor core publishes (see below). It was
originally developed inside the roosterjs monorepo's devtools/ folder and later extracted here
once it had no remaining dependency on that repo's source or build tooling.
There are two ways to use it:
extension/- a real Chrome DevTools extension (a "Content Model" panel). Recommended.roosterjs-devtools-agent.js- a single console script for quick, no-install use.
The editor core publishes a small global hook. The contract is public and versioned: the
RoosterJsDevToolsHook interface is exported from roosterjs-content-model-types, and the current
contract version from RoosterJsDevToolsHookVersion in roosterjs-content-model-core.
- On creation, every
Editorpushes itself ontowindow.__ROOSTERJS_DEVTOOLS_EDITORS__, stampswindow.__ROOSTERJS_DEVTOOLS_HOOK__.version, and callsonEditorCreated(editor). - For every plugin event it calls
onPluginEvent(editor, event). - On disposal it removes itself and calls
onEditorDisposed(editor).
A tool installs the hook to receive notifications and discover existing editors. This mirrors React
DevTools' __REACT_DEVTOOLS_GLOBAL_HOOK__ pattern - the library only detects and calls the hook;
all tool logic lives in the tool. Because the contract ships in the published roosterjs packages,
any site built with a recent enough RoosterJS is inspectable, not just a specific demo.
- Open a page built with a RoosterJS version that includes the devtools hook. If you're working
against the roosterjs repo itself, its demo app
(
yarn startthere) is the easiest target; any other RoosterJS-based app works too. - In Chrome go to
chrome://extensions, enable Developer mode, click Load unpacked, and select this repo'sextensionfolder. - Open the RoosterJS page, open DevTools (F12), and pick the Content Model tab.
- The panel has six tabs: Model, Events, Selection, Format State, HTML, and Snapshots. Use the dropdown to switch editor instances; all tabs follow whichever editor is selected.
Shows the model of the selected editor as a collapsible <details> tree. Refreshes live as you
type, change selection, or apply formatting.
Interactions:
- Hover a node → highlights the matching DOM element in the editor.
- Click a node → selects it in the editor (reverse of inspect).
- Inspect button (◎) → click it, then hover/click an element in the editor to reveal and flash its node in the tree (like React DevTools' inspector). Press Esc to cancel.
- Selection sync → moving the caret/selection in the editor highlights the corresponding node in the panel and keeps it scrolled into view.
- Edit a format value → each entry under a node's format is an input. Change it and press Enter
(or blur) to write it back to the editor via
formatContentModel(with an undo snapshot). Clear the input to remove that format. Only a node's ownformatis editable today;segmentFormat, listlevels, and adding new keys are not yet supported. { }button → toggles the Model tab between the interactive tree and a read-only dump of the raw Content Model as JSON (real field names -blockGroupType,segments, etc. - unlike the tree's display-renamed labels), for pasting into a bug repro. Fetched fresh each time you toggle it on, or when you hit Refresh while it's open.
A live log of PluginEvents dispatched by the selected editor (the same feed the hook forwards via
onPluginEvent), newest first. Each entry expands to type-specific details (key pressed, mouse
button, paste content types, entity operation, etc.). The "Show item count" dropdown controls how
much of the buffered log is displayed (the agent keeps the last 100 events per editor regardless, so
switching editors or reopening the panel doesn't lose recent history); "Clear all" empties that
editor's buffer.
Shows the editor's current DOMSelection: for a text selection, the collapsed/reverted state,
selected text preview, and start/end points (node kind, offset, text preview); for an image
selection, the image's tag and src; for a table selection, the first/last row and column. Updates
live on selectionChanged and after content changes.
Two sections:
- Editor State - dark mode (with a checkbox to toggle it live via
setDarkModeState()), a Focus button (editor.focus()),hasFocus,isInShadowEdit,canUndo/canRedo, environment flags (isMac/isAndroid/isIOS/isSafari/isMobileOrTablet/isTouchSupported), and enabled experimental features. - Format at Cursor - the merged format at the current selection (bold/italic/underline/ strikethrough/sub/superscript, font name/size, colors, line height/letter spacing, heading level, list/blockquote/table-cell context, link/image presence). A key is omitted when the selection spans content with conflicting values for it (e.g. bold text and non-bold text both selected).
This can't call the real getFormatState() - that API lives in roosterjs-content-model-api,
a package layer above the one the hook lives in, unreachable from a content script. Instead the
agent ports the same merge algorithm (retrieveModelFormatState in roosterjs-content-model-dom)
client-side, computed from data already public on IEditor (see the "Format state" comment block
at the top of injected-agent.js for exactly which edge cases this simplified port skips - none of
them affect the common "what's the format at my cursor" case this tab exists for).
A read-only, live view of the editor content div's innerHTML, via DOMHelper.getClonedRoot() (a
deep clone into a detached document, so reading it never touches the live editor DOM). Updates on
the same cadence as the Model tab.
The full undo/redo stack, similar to the roosterjs demo's SnapshotPane. Fetched on demand (opening
the tab, or the Refresh button) rather than kept continuously live, since building the list walks
the whole stack - see below.
- The list - every snapshot in the stack (past and "redo" future), newest first, with the
current one marked. Each shows a truncated HTML preview, its character count, and flags for
isDarkMode/selection/hasEntityStates/hasAdditionalState/logicalRoot. Click Apply on any non-current one to jump straight to it (editor.focus()+editor.restoreSnapshot()at that position - the same pair the demo'sSnapshotPlugin.onMoveuses for its double-click-to-restore). Click Copy on any row (including the current one) to copy its HTML plus a trailing<!--{...}-->comment holding its selection/dark mode/logical root - the exact same format the demo'sSnapshotPane"Copy snapshot with metadata" button produces. - Undo/Redo/Take Snapshot buttons.
- Apply custom HTML as new content - paste or type HTML and click Apply to set it as the
editor's live content directly. Paste text produced by the Copy button above (HTML + a trailing
metadata comment) and the selection/dark mode/logical root come back too, not just the HTML -
editor.restoreSnapshot({ html, selection, isDarkMode, logicalRootPath }), same call the demo's "Restore snapshot" button makes for a pasted snapshot. Plain HTML with no comment still works, applying just the content with the editor's current dark mode state. Either way this does not itself add an undo checkpoint - it's a content replacement, not a new stack entry - so click Take Snapshot afterward if you want the pasted state to become one.
How the list is even possible. The public SnapshotsManager interface only exposes relative
movement (canMove/move), not the underlying array - by design, there's no getSnapshots().
But move(step) turns out to only move an internal index and return the snapshot there; it never
touches editor content (only editor.restoreSnapshot() does that - move() and restoreSnapshot()
are deliberately separate calls, which is also how the library's own undo/redo commands use
them). So the agent walks the whole stack - back to the start, forward to the end, collecting every
snapshot along the way - then walks back to the original position, all without ever calling
restoreSnapshot(). The editor's real content is never touched during the walk; the only
observable side effect on the host page is its own snapshot-changed listeners (if any) firing a few
extra times as the internal index moves back and forth, settling back to the correct state by the
end.
Requires Chrome 111+ (the extension injects a MAIN-world content script).
injected-agent.js (MAIN world) --window.postMessage--> content-script.js (ISOLATED)
installs hook, serializes, | chrome.runtime
draws highlight overlay v
background.js (routes by tabId)
| port
v
panel.js (Model / Events / Selection / Format State / HTML / Snapshots tabs)
Only JSON-safe data crosses the boundary; live DOM references stay in the agent, which is why the highlight overlay is drawn in the page rather than the panel.
test/run-e2e.js loads the real extension into a
Chrome for Testing instance (regular Chrome
137+ disables the --load-extension switch, but Chrome for Testing still honors it) and drives the
whole pipeline over the DevTools Protocol:
library hook -> MAIN-world agent -> ISOLATED content script -> background -> panel
It asserts: content scripts inject, the editor is discovered and serialized to a Document tree,
the panel renders it through the real messaging path, typing live-updates the Model tab via the
onPluginEvent feed, that feed also populates the Events tab (and "Clear all" empties it), the
Selection tab reflects the resulting caret, the Format State tab derives a cursor format from it,
the dark mode toggle round-trips through the real editor.setDarkModeState(), the HTML tab shows
live source, the { } toggle shows raw Content Model JSON, the Snapshots tab lists the stack with
the current entry marked, Undo reverts an edit and Apply jumps to a specific snapshot (both through
the real snapshot/restore path), pasting HTML applies it as new content, Take Snapshot then adds
that pasted content as a checkpoint, Copy includes selection metadata in its trailing comment and
pasting that copy back in restores the exact selection (not just the HTML), and Focus calls
editor.focus(). (The panel runs in standalone mode for the test, so the only line not exercised is
chrome.devtools.panels.create.)
npm install
npm test
This needs a RoosterJS page to test against - it doesn't bundle one. By default it looks for
one already running at http://localhost:3000/ (a natural fit if you happen to have the roosterjs
repo's demo running via yarn start there); if nothing answers, it prints a clear error rather than
guessing. Point it elsewhere, or have it start your own dev server, with:
DEMO_URL=<url>- the page to test against (defaulthttp://localhost:3000/).DEMO_READY_PATH=<path>- URL path appended toDEMO_URLto probe readiness (defaultscripts/demo.js, matching the roosterjs demo's build output - override if testing a different app's fixture page).DEMO_START_CMD=<cmd>/DEMO_START_CWD=<dir>- command (and working directory) to auto-start a dev server ifDEMO_URLisn't already reachable, e.g.DEMO_START_CMD="yarn start" DEMO_START_CWD=/path/to/roosterjs npm test.CFT_PATH=<chrome.exe>- override the Chrome for Testing binary. If none is found, runnpx @puppeteer/browsers install chrome@stableonce and set this to the printed path.HEADLESS=1- run without a visible window.
See the header comment in test/run-e2e.js for the full details.
- Open any page built with a RoosterJS version that includes the devtools hook.
- Open DevTools, and paste the entire contents of
roosterjs-devtools-agent.jsinto the console. - A floating panel appears (top-right) showing the Content Model as a collapsible
<details>tree. Use the dropdown to switch between editor instances; hover a node to highlight the matching DOM element in the editor.
The panel auto-refreshes on selection/typing in the focused editor. Helpers:
window.__roosterjsDevtools.refresh()- force a refreshwindow.__roosterjsDevtools.editors()- list discovered editorswindow.__roosterjsDevtools.detach()- remove the panel and hook
- Editing scope. Two-way editing covers a node's own
format;segmentFormat, listlevels, decorators, and adding brand-new format keys are not editable yet. - Single document / top frame. Editors in a popped-out window or a cross-origin iframe register on that frame's globals; the panel currently follows the inspected top window.
- Element mapping (hover/inspect/click-to-select) needs the editor's element cache. It maps
Content Model nodes to DOM via the editor's
cachedElementreferences, which are present during normal editor-driven interaction. Mutating the editor's DOM or selection from outside the editor can drop those references until the next editor-driven update; the tree still renders, but highlight/inspect/select may be unavailable for those nodes. - The console script (
roosterjs-devtools-agent.js) still uses DOM-based change detection (selectionchange+input); the extension uses the richer plugin-event feed.
- Zero dependency on RoosterJS source or build tooling. The extension and the console script
have no
import/requireof anything - they only talk to a page through the public, versionedRoosterJsDevToolsHookglobal contract (window.__ROOSTERJS_DEVTOOLS_HOOK__/window.__ROOSTERJS_DEVTOOLS_EDITORS__). That's what makes this repo standalone: it works against any page built with a recent enough RoosterJS, and needs nothing from the roosterjs repo itself except a page to point at. test/run-e2e.js'swsdependency is declared in this repo's ownpackage.jsonrather than assumed to be available some other way -npm installhere is self-contained.- See
PLAN.mdfor the feature history and the design reasoning behind each tab, including a couple of spots where a feature that first looked infeasible (a full snapshot list; deriving format state without the internal API for it) turned out to be possible after digging into how the underlying public APIs actually behave.