@unknownaiio/editor
v0.2.2
Published
Production-ready Tiptap editor presets and a static React viewer for UnknownAI.
Readme
@unknownaiio/editor
A public React 19/Tiptap package for editing and statically rendering stored JSON documents. It ships compiled ESM, declarations, source maps, and CSS; consumers do not compile package TypeScript.
React 19, react-dom, and the Tiptap core/react/ProseMirror packages are peer
dependencies. Keep them on the compatible versions shown by your package
manager to guarantee one React and one Tiptap/ProseMirror runtime.
pnpm add @unknownaiio/editorThe package is public on npm (publishConfig.access: "public") under an
UNLICENSED license: anyone can install it and read its code, and no one is
granted rights to use it. Store Tiptap JSONContent, not HTML. To release a new version, follow
PUBLISHING.md.
API
Load the stylesheet once and select an explicit capability tier.
import { Editor, RichTextViewer, type JSONContent } from "@unknownaiio/editor";
import "@unknownaiio/editor/styles.css";
function Document({ initial }: { initial: JSONContent }) {
const [value, setValue] = React.useState(initial);
return (
<>
<Editor preset="full" value={value} editable onChange={setValue} />
<RichTextViewer value={value} />
</>
);
}| Preset | Designed for | Included features |
| --- | --- | --- |
| composer | Quickly submitting AI/chat content | Attachment action, send/loading/error controls, mentions |
| minimal | Small general rich-text input | Composer features plus marks, links, code, lists, blockquotes |
| standard | Normal rich-text editing | Minimal features plus headings, tasks, highlight, typography, image/video/attachment nodes |
| full | Everything supported | Standard features plus tables, colour, alignment, sub/superscript, rules, comments |
The root entry only exports Editor, its types, RichTextViewer, and Tiptap
types. Integration helpers live at @unknownaiio/editor/integrations; intentional
low-level composition lives at @unknownaiio/editor/custom.
Next.js and security
The interactive entry points are client components (they emit the "use client"
directive). Import Editor or RichTextViewer from a Next.js Client Component;
keep stored JSON and data loading in your Server Component.
Links are scheme-checked before the editor stores them and again by the viewer. That is defense in depth, not a trust boundary: validate and authorize document JSON and private-media keys on your server before persisting or resolving them.
Bundling note
The package is ESM and unused root exports can be tree-shaken. The selected
preset itself is a runtime prop, however, so <Editor preset="minimal" />
necessarily imports all four preset implementations. Splitting each preset
behind asynchronous imports would change editor mounting behavior, so this
package intentionally does not do that. Use the smallest preset for behavior;
consider a separate, preset-specific entrypoint only if a future performance
requirement outweighs the single-component API.
Composer request state and attachment action
composer and minimal submit through onSubmit. An async handler clears
only after it resolves; throwing preserves the draft. Supply submitButtonStatus to
show the built-in loader or error button.
const [status, setStatus] = React.useState<"idle" | "loader" | "error">("idle");
<Editor
preset="composer"
onAttach={() => picker.current?.click()}
submitButtonStatus={status}
submitErrorTooltip="Request failed — edit and retry"
onSubmit={async (json, text) => {
setStatus("loader");
try {
await api.send({ content: json, preview: text });
setStatus("idle");
} catch (error) {
setStatus("error");
throw error;
}
}}
/>onAttach only signals: open your picker there. composer and minimal
have no file nodes, so the host keeps the chosen files itself (as chips above
the composer, say) and sends them with the message in onSubmit.
handleFileDrop(files) works the same way for files dropped on the composer,
and attachmentIconType sets the button's icon, as in the other presets (see
Images, files and drops).
The composer draws no border or background of its own; put it in the frame your page needs (a bordered box, a chat bar).
The attachment button sits in the left slot of composer, as in a chat
input, and at the end of the formatting toolbar in minimal, as in
standard and full. leadingAction takes the left slot and replaces the
attachment action, and trailingActions adds host controls beside the
state button. Pass users and currentUserId to any
preset for @ mentions.
Images, files and drops
The package owns no picker, dialog, provider or upload API. Every way a file can reach a document goes through the host, which decides what is allowed, uploads it, and inserts it with the integration helpers:
| Way in | Prop | What the editor does |
| --- | --- | --- |
| Attachment button (and mod+shift+i) | handleFileInput() | calls it; nothing else |
| composer/minimal attachment button | onAttach() | calls it; nothing else |
| Dropping files | handleFileDrop(files) | hands it the File objects and puts the caret where they were dropped |
There is one attachment button. attachmentIconType ("file", the default,
"image" or "video") changes only its icon and label ("Add files", "Add
image", "Add video"); it calls the same handler whatever it shows, and the
host decides what its picker accepts. An app that takes images passes
"image" and opens an image/* picker, or its own search dialog or media
library, and inserts what is chosen.
The button appears only when its handler is passed; without one it is not
rendered at all, because pressing it could do nothing the host knows about.
Without handleFileDrop, a file dragged over the editor shows the not-allowed
cursor and a drop is ignored (with a console warning in development), and the
browser is kept from opening the file. Dragging text inside the editor is
unaffected.
A complete upload recipe
One routine serves every way in: the button opens a picker, the picker and a
drop both hand their files to uploadAndInsert. insertLocalFiles shows each
file at once from a local preview, the upload runs, and updateFileNode
swaps the preview for the durable key:
import { Editor, type AttachmentIconType, type TiptapEditor } from "@unknownaiio/editor";
import {
insertLocalFiles,
setFileUploadState,
updateFileNode,
} from "@unknownaiio/editor/integrations";
const ACCEPT: Record<AttachmentIconType, string> = { file: "", image: "image/*", video: "video/*" };
function NoteEditor({ iconType = "file" }: { iconType?: AttachmentIconType }) {
const editor = React.useRef<TiptapEditor | null>(null);
const picker = React.useRef<HTMLInputElement>(null);
async function uploadAndInsert(files: File[]) {
const instance = editor.current;
if (!instance || files.length === 0) return;
// Check type, size and count here: the editor accepts whatever you insert.
const ids = insertLocalFiles(instance, files);
await Promise.all(files.map(async (file, index) => {
const id = ids[index]!;
setFileUploadState(instance, id, { status: "uploading" });
try {
const stored = await api.upload(file);
updateFileNode(instance, id, { src: stored.key, mimeType: file.type });
setFileUploadState(instance, id, { status: "uploaded" });
} catch {
setFileUploadState(instance, id, { status: "error" });
}
}));
}
return (
<>
<input
ref={picker}
type="file"
hidden
multiple
accept={ACCEPT[iconType]}
onChange={(event) => void uploadAndInsert(Array.from(event.target.files ?? []))}
/>
<Editor
ref={editor}
preset="standard"
editable
attachmentIconType={iconType}
handleFileInput={() => picker.current?.click()}
handleFileDrop={(files) => void uploadAndInsert(files)}
/>
</>
);
}Leave out handleFileDrop to refuse drops, or handleFileInput to have no
button. Whether a file becomes an image, a video or a download card follows
its MIME type, not the icon.
Inserting a file that is already stored
A file chosen from a media library or an image search needs no upload and no
preview. Insert it by its durable key with insertFileNodes:
import { insertFileNodes } from "@unknownaiio/editor/integrations";
<Editor
ref={editor}
preset="standard"
editable
attachmentIconType="image"
handleFileInput={() => setLibraryOpen(true)} // your own dialog
/>;
function onLibraryPick(item: LibraryItem) {
insertFileNodes(editor.current!, [
{ src: item.key, mimeType: item.mimeType, title: item.name, alt: item.description, size: item.bytes },
]);
setLibraryOpen(false);
}The node is inserted at the caret. The editor keeps its selection while your dialog has focus, so the image lands where the user was writing.
Clicking a file
handleFileClick(file) receives a file's metadata (id, src, title,
type, size, category) when its name is clicked in a read-only editor
(editable={false}), for example to open a download or a viewer.
A document saved while an upload is in flight still holds the local blob:
URL, which is dead after a reload. Refuse such documents on the server.
Render private media and attachments
Use one resolver for editing and viewing when stored src values are private
keys rather than public URLs. It may be synchronous or asynchronous.
const resolveFile = async (fileId: string, key: string) =>
(await api.files.downloadUrl({ fileId, key })).url;
<Editor preset="full" editable getFile={resolveFile} />
<RichTextViewer value={storedDocument} getFile={resolveFile} />The viewer renders code, tasks, tables, mentions, images, video, and attachment
download cards without mounting an editor. It scheme-checks URL-bearing output:
links and attachments allow http(s):, mailto: and relative URLs; an image or
video also allows a data: URL of its own kind and a blob: URL, as the editor
does. Anything else renders as #.
Styling
The package is styled by its host, not on its own. It needs three things, and a missing one fails silently as unstyled output, never as an error.
- The stylesheet, imported once:
@unknownaiio/editor/styles.css. It styles the document (headings, lists, code, tables, mentions) in both the editor and the viewer. - Tailwind v4 scanning the built package. The toolbar, menus, tooltips and
popovers use Tailwind classes that the package does not compile. Without the
@sourceline they render unstyled while the document still looks right. - The shadcn theme variables, defined by the host, with the usual
@theme inlinemapping to Tailwind's--color-*and--radius-*:--background,--foreground,--card,--card-foreground,--popover,--popover-foreground,--primary,--primary-foreground,--secondary,--secondary-foreground,--muted,--muted-foreground,--accent,--accent-foreground,--destructive,--border,--input,--ringand--radius. Without them borders and colours disappear. Define them on:root(not only on an app wrapper), because menus and popovers are portalled tobody.
@import "tailwindcss";
@import "@unknownaiio/editor/styles.css";
@source "../node_modules/@unknownaiio/editor/dist/**/*.{js,mjs}";A host that is not built on shadcn maps its own palette onto those names.
Optional variables, each with a default: --tt-editor-font-size (1rem),
--comment-highlight-bg, --comment-highlight-bg-active and
--comment-highlight-border.
The document styles are wrapped in @scope (.unknown-editor-surface), so they
never restyle another Tiptap editor in the host. The editor and the viewer
each render that surface with .tiptap.ProseMirror inside it; a selector in
@scope matches only below its root, so the two must stay separate elements.
Check the preview
pnpm playgroundOne tab per preset (the tab is kept in the URL, #full). Each shows the editor
in one card and a RichTextViewer in another, with nothing else inside them,
so both look as they will in a host page. Edit in one and compare with the
other: the viewer should look the same as the editor, including the
placeholder image in the full sample. If headings and code blocks look like
plain text in one place only, the stylesheet's scope does not reach that
element.
Above the cards, a switch passes or withholds the host handlers (onAttach,
handleFileInput, handleFileDrop), so you can see the button appear and
disappear, a select sets attachmentIconType, and a log shows each handler
call. The playground plays the host: a native file picker that accepts what the
icon promises (image/* for "image"), a simulated 1.5 s upload, and the same
routine for dropped files. Turn the switch off and drop a file to see the
refusal.
No preset draws a frame of its own, the composer included: the host puts the editor in the border, card or chat bar its page needs. The playground's cards are that host frame.
Scripts
pnpm build
pnpm check-types
pnpm lint
pnpm test
pnpm playground
pnpm build:playgroundSee features.md for the complete feature matrix and recipes.
