@wulperstd/ui-react
v2.0.0
Published
Radix and Tailwind v4 React components for @wulperstd/editor-core: block editor, toolbars, menus, node views and theme presets.
Readme
@wulperstd/ui-react
Not published to npm. This package is private and consumed only through the pnpm workspace (currently by
apps/cms-integration) — do notnpm installorpnpm addit from a registry.
Shadcn-style React components for @wulperstd/editor-core's headless editor
extensions: Radix primitives, cva variants, and Tailwind v4 utilities
compiled by the consumer. The fastest way in is one of the two ready-to-use
editors below — RichTextEditor or BlockEditor — each a complete, working
useEditor + EditorContent wiring you can drop straight into an app.
Everything else in this package (EditorToolbar, SelectionMenu,
BlockGutter, TurnIntoMenu, the slash-command menu, and the individual
ui/ primitives) is what those two editors are built from, and remains
available on its own for building something different.
Two ready-to-use editors
RichTextEditor and BlockEditor are meant to be copied, not just
imported: each is a small, fully-wired file — read it, and take it as the
starting point for your own editor once you need something its props don't
cover. That is a deliberate design choice, not a gap: these components keep
their escape hatches narrow (documented below) rather than growing a prop for
every possible customization, so the file stays short enough to actually
read in one sitting. They are apps/cms-integration's own
RichTextEditor/useNotionEditor + NotionEditorShell ported into this
package, minus that app's CMS-specific output shape, curated embed services,
and per-node placeholder copy — all choices that stay app-owned.
Required peers: react, react-dom, @tiptap/react, @tiptap/pm,
@tiptap/suggestion, and tailwindcss — see package.json's
peerDependencies for exact ranges. The consumer compiles Tailwind; this
package ships its CSS entries (tailwind.css, content.css, themes/*.css)
with @source so the consumer's own Tailwind build picks up this package's
utility classes.
RichTextEditor
The simple editor: an optional fixed toolbar above plain document content — no slash menu, no drag handle, no floating selection menu.
import { RichTextEditor, EditorToolbar } from '@wulperstd/ui-react';
import '@wulperstd/ui-react/tailwind.css'; // compiled by your own Tailwind build
import '@wulperstd/ui-react/content.css'; // document typography, optional
function App() {
return (
<RichTextEditor
content="<p>Hello world</p>"
toolbar={<EditorToolbar />}
onChange={(editor) => console.log(editor.getHTML())}
/>
);
}BlockEditor
The Notion-style editor: a slash-command ("/") menu, a per-block
BlockGutter (drag grip, "add block below", and its turn-into/duplicate/
copy/delete/reset-formatting menu), a floating SelectionMenu, and the
content itself.
import { BlockEditor } from '@wulperstd/ui-react';
import '@wulperstd/ui-react/tailwind.css'; // compiled by your own Tailwind build
import '@wulperstd/ui-react/content.css'; // document typography, optional
function App() {
return (
<BlockEditor
content="<p>Hello world</p>"
onChange={(editor) => console.log(editor.getJSON())}
/>
);
}The extensions escape hatch
Both components accept an extensions prop that, when given, replaces
the built-in bundle outright — it does not merge with it. Each component
also exports that built-in bundle as a plain value (richTextEditorExtensions,
an array; blockEditorExtensions(options), a function, since its slash-menu
catalog is configurable) so you can build your own list explicitly instead of
fighting the default one:
// Add one extra extension:
extensions={[...richTextEditorExtensions, MyExtension]}
// Swap or remove one — the exact case a consumer needs and `useNotionEditor`
// used to hide inside itself, hardcoded, with no way for a caller to reach it:
import { embedExtension } from '@wulperstd/editor-core';
const extensions = blockEditorExtensions().map((extension) =>
extension.name === 'embed'
? embedExtension.configure({ services: MY_EMBED_SERVICES })
: extension,
);Pass the full result through extensions.
The change callback
Both components take onChange?: (editor: Editor) => void, fired on every
document update with the live Editor instance. Neither owns an output
shape — call editor.getHTML(), editor.getJSON(), serializeMdx(editor.getJSON()),
or anything else you need from it. This mirrors apps/cms-integration's own
useNotionEditor, which derives an entire EditorOutput (html/mdx/json/
validation report) because that is what it needs. That choice does not
belong in this package.
onChange also fires once on mount, right after the editor is created from
the initial content — not only on later edits. Without that, the obvious
onChange={(e) => setHtml(e.getHTML())} pattern with an initial content
leaves derived state empty until the first keystroke, which defeats the
point of a drop-in component. Both components follow the same reference:
the callback gets the same live Editor either way, with nothing marking
the mount call as different from an update.
The prose class
Both apply 'editor-prose' to the content element via
editorProps.attributes.class by default (see content.css below). Pass
your own contentClassName to replace it outright — include 'editor-prose'
yourself to add to it (contentClassName="editor-prose my-extra-class"), or
omit it entirely to use a different prose layer, such as Tailwind
Typography's prose class.
The null-editor render
useEditor returns null on the very first render, before the Editor
instance exists. Both components handle this internally (they render
nothing until it does) — you never need your own if (!editor) return null;
guard around them.
Usage
Slash-command menu
import { createSlashCommand } from '@wulperstd/editor-core';
import { createSlashMenuRenderer, DEFAULT_SLASH_MENU_ITEMS } from '@wulperstd/ui-react';
const SlashCommand = createSlashCommand({
items: DEFAULT_SLASH_MENU_ITEMS,
render: createSlashMenuRenderer({ emptyMessage: 'No results' }),
});Pass SlashCommand alongside your other Tiptap extensions. createSlashCommand
handles matching, filtering, and keyboard-event routing (all framework-neutral,
from @wulperstd/editor-core); createSlashMenuRenderer mounts this
package's Radix-based SlashMenu through the Suggestion plugin's own managed
mounting (props.mount) and wires onStart/onUpdate/onKeyDown/onExit
for you. DEFAULT_SLASH_MENU_ITEMS covers generic block-type conversions and
insertions; build your own SlashMenuItem[] (each declaring a group) to
add app-specific entries such as embeds or uploads.
EditorToolbar
The default, ready-to-use formatting toolbar — Bold/Italic/Strikethrough/ Inline code, Heading 1/2, bullet/numbered list, Quote, Code block, a divider insert action, and Undo/Redo:
import { EditorToolbar } from '@wulperstd/ui-react';
function Editor({ editor }: { editor: TiptapEditor }) {
return <EditorToolbar editor={editor} />;
}English labels only (design.md D7: "composition-based localization... the
default compositions are English"). A consumer that needs different copy
composes its own toolbar from the individual controls/* building blocks
(MarkToggle, BlockToggle, HistoryButtons, TextAlignGroup,
ResetFormattingButton) with their own label/tooltip props instead of
configuring this component — see apps/cms-integration's BasicToolbar for
that pattern.
SelectionMenu
A floating bar (via @tiptap/react/menus' BubbleMenu) that appears over
the current text selection, Notion-style. With no children, it renders the
default SelectionMenuContent — mark toggles, LinkPopover, ColorPicker,
script toggles, TextAlignGroup, ResetFormattingButton, and TurnIntoMenu:
import { SelectionMenu } from '@wulperstd/ui-react';
function Editor({ editor }: { editor: TiptapEditor }) {
return <SelectionMenu editor={editor} />;
}children REPLACES the default composition entirely (D7) — a consumer
wanting different copy or extra controls renders its own children built from
MarkToggle/LinkPopover/ResetFormattingButton/etc, the same mechanism
RichTextEditor's toolbar prop uses. See apps/cms-integration's Spanish
NotionSelectionMenu for that pattern.
BlockGutter
A per-block drag grip plus an "add block below" button and a block menu (turn into, reset formatting, duplicate, copy, delete), Notion-style, positioned over the currently hovered or selected block:
import { BlockGutter } from '@wulperstd/ui-react';
function Editor({ editor }: { editor: TiptapEditor }) {
return (
<div style={{ position: 'relative' }}>
<BlockGutter editor={editor} />
{/* <EditorContent editor={editor} /> */}
</div>
);
}BlockGutter is a hand-rolled overlay on native ProseMirror APIs
(posAtCoords, NodeSelection, view.dragging, Selection.near) — it does
not use @tiptap/extension-drag-handle-react or any other drag-handle
library, and requires no extra peer dependency. It renders as an absolutely
positioned sibling, never a body portal (design.md D10), so it MUST be
rendered inside the same position: relative wrapper that also holds
<EditorContent>.
Composition-based localization
Every control in this package takes its own label/tooltip props (or, for
node-view factories that cannot receive props from the React tree, a
labels object captured once) instead of a global classNames/labels
prop on a bundle component (design.md D7). BlockEditor and RichTextEditor
intentionally have NO classNames/labels props: a consumer wanting
different copy composes its own tree from BlockGutter/SelectionMenu/
EditorToolbar/individual controls/*, the same way apps/cms-integration's
NotionEditorShell and BasicToolbar do. Visual restyling goes through
Tailwind utility classes and this package's CSS custom-property theme
contract (tailwind.css's @theme inline tokens, themes/*.css presets),
not per-slot classNames props.
Seams
These integration points are intentionally NOT built-in components — they are documented here so a consumer can add them without this package growing speculative props for features it does not itself implement (design.md D5, "Seams"):
- Collaboration: pass Tiptap's
CollaborationandCollaborationCaretextensions throughBlockEditor's orRichTextEditor's full-replacementextensionsprop (extensions={[...blockEditorExtensions(), Collaboration.configure(...), CollaborationCaret.configure(...)]}). Upload placeholders (node-views/image-upload) are local plugin decorations, not schema nodes, so they never sync over a collaborative session. - A future
<CollaborationCursors/>or<AiMenu/>can mount as a sibling inside the sameEditorScopethis package's own components wrap their tree in —useEditorInstance()/useCurrentEditor()resolves the live editor with noeditorprop threading needed (design.md D5). Compose it directly into your own copy ofBlockEditor/RichTextEditor(orNotionEditorShell), next toBlockGutter/SelectionMenu. - AI slash items:
SlashMenuItem[]is plain data (title,keywords,run,group, an optional icon), so an "Ask AI" entry can simply be appended toDEFAULT_SLASH_MENU_ITEMS(or your own catalog) and passed toblockEditorExtensions({ slashItems }). - AI in the selection menu:
SelectionMenu'schildrenslot can host an "Ask AI" control alongside (or instead of) the defaultSelectionMenuContentcomposition — see the SelectionMenu section above.
