@noeta-cloud/ui-editor
v0.3.0
Published
Noeta's embeddable React components: the membership panels, the table editor, the document editor, the file editors, the thread chat and the NoetaEditor dispatcher. Composes @noeta-cloud/ui-core.
Readme
@noeta-cloud/ui-editor
Noeta's embeddable React components. Noeta's own screens consume these same components — the embedded surface and the first-party UI are one codebase, never a fork.
Layering
<NoetaProvider api auth inviteLink>— injection context for the membership surfaces. The package never imports app singletons; the host supplies an API client satisfyingMembershipApi, an auth adapter satisfyingNoetaAuthAdapter(the app passes its Better Auth client — it matches structurally), and the invite-link builder.<MembersPanel>/<TeamsPanel>— the individual membership surfaces (people + invites; teams).<MembershipPanel workspaceId>— both, stacked, for embedders who want the whole thing.<TableView resourceId>— the table sub-editor: a live, collaborative grid over a Noetatableresource (also at the./tablesubpath, without the membership panels). It reads and writes the same Yjs row model every other Noeta surface uses (table-ops, exported), so an edit in the grid is the edit an agent or a published page sees.sourcewidens what it shows:{ kind: "file", resourceId }reads a CSV file resource (parsed exactly asimport_csvwould, live to the file's room) and{ kind: "rows", columns, rows }shows rows the host already holds. Only atablesource is editable; readers can still sort by a column and resize columns locally, neither of which is written back.<RealtimeProvider realtime>/useRealtime()— how a component finds its realtime connector. Inside the Noeta app no provider is mounted: the default is a same-origin, cookie-authenticated connector.<NoetaEmbedProvider>mounts a cross-origin one.
Embedding the table editor
import { NoetaEmbedProvider, TableView } from "@noeta-cloud/ui-editor/table";
<NoetaEmbedProvider baseUrl="https://acme.noeta.cloud" token={getEmbedToken} workspaceId={wsId} userId={sub}>
<TableView resourceId={tableId} readOnly={!settings.allowEdits} fillHeight />
</NoetaEmbedProvider>The token must be minted with surfaces: ["table"] (POST /api/embed/token, server-to-server).
The grid is pure realtime — it calls no /api route — so the token rides the WebSocket upgrade
URL as ?embed_token= (a browser can set neither a header nor a cross-site cookie on a
handshake); the getter form is re-read before every reconnect, so re-minting needs no remount.
Write-capability is a setting AND a cap. readOnly renders cells as text and hides the
row/column tools — an affordance. The server write-gates every socket on the reader's own role,
min(their role, the token's role_cap), independently. A host that wants a genuinely read-only
embed sets readOnly and mints with role_cap: "viewer"; a host that wants edits sets neither.
yjs is a peer dependency (one instance — the provider must recognise the Y.Doc it syncs).
Embedding the document editor
import { NoetaEmbedProvider, DocEditor } from "@noeta-cloud/ui-editor/doc";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
<NoetaEmbedProvider baseUrl="https://acme.noeta.cloud" token={getEmbedToken} workspaceId={wsId} userId={sub}>
<DocEditor resourceId={docId} workspaceId={wsId} readOnly={!settings.allowEdits} user={{ name, color }} />
</NoetaEmbedProvider>Mint with surfaces: ["doc"]. The document itself is the same realtime room Noeta opens (the
surface grants it); the editor's chrome — inline media upload, the "@" mention picker, docLink
titles, the workspace doc default and the comment layer — is the short /api allowlist the
surface names, every call still authorized against the user's own role. workspaceId enables
comments and mentions; without it the editor is a plain writing surface. readOnly sets
BlockNote non-editable and hides the toolbar — again an affordance; role_cap: "viewer" is the
cap. Without a conn the editor opens its own connection (useLiveDoc) and waits for the first
sync before mounting, so it never offers an editable surface over a document that has not arrived.
BlockNote and ProseMirror are dependencies of this package but stay external to its build so
your bundle holds one copy; load their two stylesheets yourself as above. A linkedApp block
(a hosted Noeta app) renders as a placeholder unless the host injects a renderer through
<NoetaProvider linkedApp> — the app host is deliberately not part of this package. Inline images
and print are plain browser GETs the embed token cannot ride yet (see the plan's phase 2 notes).
Embedding a file, or "the editor"
import { NoetaEmbedProvider, TextFileEditor, BlobFileView } from "@noeta-cloud/ui-editor/file";
<NoetaEmbedProvider baseUrl="https://acme.noeta.cloud" token={getEmbedToken} workspaceId={wsId} userId={sub}>
<TextFileEditor resourceId={fileId} name="index.html" mediaType="text/html" readOnly={!settings.allowEdits} />
<BlobFileView resourceId={uploadId} name="logo.png" />
</NoetaEmbedProvider>Mint with surfaces: ["file"]. A text file is the same realtime room Noeta opens (the surface
grants it), edited in place by a CodeMirror editor bound to its Y.Text — the very text agents
write over MCP. A binary file is its blob's metadata plus the bytes, which the component fetches
THROUGH the client (bearer) and renders as an object URL, since an <img src> cannot carry a
token; the surface allowlists that one bytes route. An HTML file shows its source only — the
sandboxed preview is a host-injected renderer (<NoetaProvider filePreview>), like linkedApp.
NoetaEditor (root entry) is the dispatcher: give it a resource id and it opens the document
editor, the table grid, the code editor or the blob view, looking the type up through
GET /api/resources/:id (every surface allowlists it) unless you pass resource yourself.
@noeta-cloud/ui-editor/code is the code editor and the viewers as plain components over YOUR
data — CodeEditor (content + onChange, or a collab binding), CodeSearchBar,
VimStatusBadge, ImageViewer, HexViewer — with no Yjs, realtime or provider in its graph.
RemADE mounts this over its in-memory tabs. CodeMirror is a dependency kept external (one copy
in the host; a second @codemirror/state fails with "Unrecognized extension value" — dedupe it
like React if your checkout links this package).
Embedding a thread chat
import { NoetaEmbedProvider, ThreadChat } from "@noeta-cloud/ui-editor/thread";
<NoetaEmbedProvider baseUrl="https://acme.noeta.cloud" token={getEmbedToken} workspaceId={wsId} userId={sub}>
<ThreadChat
threadId={threadId}
nameOf={(id) => people[id]?.name ?? id}
appName={() => "Acme Insights"}
renderData={(m) => <MyAttachment data={m.data} />}
className={styles.chat}
/>
</NoetaEmbedProvider>Mint with surfaces: ["thread"]. ThreadChat is the conversation only — the live timeline
(history paging, new messages, edits and deletes as they land) and a composer — with no header,
since audience, topics and archive belong to your chrome. A message's data (any JSON object
your app attached) renders through renderData, so an attached chart spec can show as your own
live preview. A message with a persona renders as an agent (square avatar, an "Agent" tag,
never grouped with its user's own messages) — only an app credential may set one. Theme it with
--noeta-thread-* custom properties on your className. For your own chrome entirely, use
useThreadTimeline(threadId): the same paging, de-duplication and reconnect, no markup.
Contract (src/contract.ts)
The package owns minimal structural wire types (TeamView, PersonView, …) and its own copy of
the invitable-role vocabulary and the embed-token URL param. The Noeta server's modules stay the
product SSOT, and a drift guard there fails the build if the copies diverge. Richer host types
satisfy these interfaces structurally, without the package depending on any server schema.
Consumption
npm install @noeta-cloud/ui-editor @noeta-cloud/ui-core yjsEntries ship built JS + .d.ts, so no particular bundler is assumed: @noeta-cloud/ui-editor
(everything, plus NoetaEditor), @noeta-cloud/ui-editor/table (the grid and its realtime/model
layer alone), @noeta-cloud/ui-editor/doc (the document editor, its blocks, comments and the doc
model), @noeta-cloud/ui-editor/code (the CodeMirror editor and the image/hex viewers over your
own data) and @noeta-cloud/ui-editor/file (./code plus the live text-file editor and blob view).
Load the stylesheets once, at your app's entry point — the components carry no inline styles
and render unstyled without them:
import "@noeta-cloud/ui-core/fonts.css";
import "@noeta-cloud/ui-core/tokens.css";
import "@noeta-cloud/ui-core/base.css";
import "@noeta-cloud/ui-core/styles.css";
import "@noeta-cloud/ui-editor/styles.css";
// only if you mount <DocEditor>:
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";React, react-dom, lucide-react, yjs, and @noeta-cloud/ui-core are peer dependencies and
stay external to the build. React must resolve to a single instance — two copies null the
hooks dispatcher and the app renders blank with Cannot read properties of null (reading
'useState'). With a linked/monorepo checkout, dedupe it explicitly (in Vite: resolve.dedupe:
["react", "react-dom", "react/jsx-runtime"]).
The ./teams.module.scss, ./members.module.scss and ./table.module.scss subpaths are raw
Sass, exported for hosts composing our styles into their own components; those require a
Sass-capable bundler.
