From e772a120299c8d675e611e1677d9ad6604f36fbd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 10 Sep 2026 05:35:11 +0100 Subject: [PATCH 1/2] feat(web): ship the editors and .odm tools behind real routes The last two nav stubs become tools. .odm rendering resolves the master's external chapter files from a second file picker: odmToPdf runs in the worker over the picked map, and OdmUnresolvedSectionError crosses the rpc boundary as data -- the named list of still-missing chapters IS the tool's UX, not an error to hide. The editors tool drives documents.js's live-view editors through worker-held sessions: open holds the editor and answers a paragraph snapshot, each mutation edits the live document in place (set-text is position-preserving -- first run takes the whole text, remaining runs leave) and answers a fresh snapshot, and save re-serialises through the format's own writer. The v1 surface is the paragraph operations every paragraph-family editor exposes identically, so one UI drives docx, odt, doc, and markdown; deeper per-run styling stays out until it has the same genuine cross-format surface. --- packages/web/src/hooks/useEditorSession.ts | 50 +++++ packages/web/src/hooks/useOdmRender.ts | 11 + packages/web/src/routeTree.gen.ts | 42 ++++ packages/web/src/routes/-Sidebar.css.ts | 2 - packages/web/src/routes/-Sidebar.tsx | 21 +- packages/web/src/routes/editors.tsx | 248 +++++++++++++++++++++ packages/web/src/routes/odm.tsx | 132 +++++++++++ packages/web/src/rpc/router.test.ts | 66 +++++- packages/web/src/rpc/router.ts | 236 ++++++++++++++++++++ 9 files changed, 787 insertions(+), 21 deletions(-) create mode 100644 packages/web/src/hooks/useEditorSession.ts create mode 100644 packages/web/src/hooks/useOdmRender.ts create mode 100644 packages/web/src/routes/editors.tsx create mode 100644 packages/web/src/routes/odm.tsx diff --git a/packages/web/src/hooks/useEditorSession.ts b/packages/web/src/hooks/useEditorSession.ts new file mode 100644 index 000000000..24208f3a4 --- /dev/null +++ b/packages/web/src/hooks/useEditorSession.ts @@ -0,0 +1,50 @@ +import { useMutation } from "@tanstack/react-query"; + +import { getRpcClient } from "../rpc/client"; + +// The Editors tool's five mutations, one per rpc procedure. All four snapshot-returning mutations answer the whole fresh paragraph list (the worker re-reads its live accessors per call), so the page state is nothing but the latest snapshot -- there is no client-side per-paragraph state to keep coherent. +export function useOpenEditor() { + return useMutation({ + mutationFn: ( + input: Parameters["editor"]["open"]>[0], + ) => getRpcClient().editor.open(input), + }); +} + +export function useSetParagraphText() { + return useMutation({ + mutationFn: ( + input: Parameters< + ReturnType["editor"]["setParagraphText"] + >[0], + ) => getRpcClient().editor.setParagraphText(input), + }); +} + +export function useAddParagraph() { + return useMutation({ + mutationFn: ( + input: Parameters< + ReturnType["editor"]["addParagraph"] + >[0], + ) => getRpcClient().editor.addParagraph(input), + }); +} + +export function useRemoveParagraph() { + return useMutation({ + mutationFn: ( + input: Parameters< + ReturnType["editor"]["removeParagraph"] + >[0], + ) => getRpcClient().editor.removeParagraph(input), + }); +} + +export function useSaveEditor() { + return useMutation({ + mutationFn: ( + input: Parameters["editor"]["save"]>[0], + ) => getRpcClient().editor.save(input), + }); +} diff --git a/packages/web/src/hooks/useOdmRender.ts b/packages/web/src/hooks/useOdmRender.ts new file mode 100644 index 000000000..e02f92815 --- /dev/null +++ b/packages/web/src/hooks/useOdmRender.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; + +import { getRpcClient } from "../rpc/client"; + +export function useOdmRender() { + return useMutation({ + mutationFn: ( + input: Parameters["odm"]["render"]>[0], + ) => getRpcClient().odm.render(input), + }); +} diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index f54cd4225..56234dfb2 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -11,10 +11,12 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as ConvertRouteImport } from './routes/convert' +import { Route as EditorsRouteImport } from './routes/editors' import { Route as FontsRouteImport } from './routes/fonts' import { Route as InspectRouteImport } from './routes/inspect' import { Route as MetadataRouteImport } from './routes/metadata' import { Route as OdbRouteImport } from './routes/odb' +import { Route as OdmRouteImport } from './routes/odm' import { Route as PackageRouteImport } from './routes/package' import { Route as RecentRouteImport } from './routes/recent' import { Route as ConvertIndexRouteImport } from './routes/convert.index' @@ -30,6 +32,11 @@ const ConvertRoute = ConvertRouteImport.update({ path: '/convert', getParentRoute: () => rootRouteImport, } as any) +const EditorsRoute = EditorsRouteImport.update({ + id: '/editors', + path: '/editors', + getParentRoute: () => rootRouteImport, +} as any) const FontsRoute = FontsRouteImport.update({ id: '/fonts', path: '/fonts', @@ -50,6 +57,11 @@ const OdbRoute = OdbRouteImport.update({ path: '/odb', getParentRoute: () => rootRouteImport, } as any) +const OdmRoute = OdmRouteImport.update({ + id: '/odm', + path: '/odm', + getParentRoute: () => rootRouteImport, +} as any) const PackageRoute = PackageRouteImport.update({ id: '/package', path: '/package', @@ -74,10 +86,12 @@ const ConvertSourceTargetRoute = ConvertSourceTargetRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/convert': typeof ConvertRouteWithChildren + '/editors': typeof EditorsRoute '/fonts': typeof FontsRoute '/inspect': typeof InspectRoute '/metadata': typeof MetadataRoute '/odb': typeof OdbRoute + '/odm': typeof OdmRoute '/package': typeof PackageRoute '/recent': typeof RecentRoute '/convert/': typeof ConvertIndexRoute @@ -85,10 +99,12 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/editors': typeof EditorsRoute '/fonts': typeof FontsRoute '/inspect': typeof InspectRoute '/metadata': typeof MetadataRoute '/odb': typeof OdbRoute + '/odm': typeof OdmRoute '/package': typeof PackageRoute '/recent': typeof RecentRoute '/convert': typeof ConvertIndexRoute @@ -98,10 +114,12 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/convert': typeof ConvertRouteWithChildren + '/editors': typeof EditorsRoute '/fonts': typeof FontsRoute '/inspect': typeof InspectRoute '/metadata': typeof MetadataRoute '/odb': typeof OdbRoute + '/odm': typeof OdmRoute '/package': typeof PackageRoute '/recent': typeof RecentRoute '/convert/': typeof ConvertIndexRoute @@ -112,10 +130,12 @@ export interface FileRouteTypes { fullPaths: | '/' | '/convert' + | '/editors' | '/fonts' | '/inspect' | '/metadata' | '/odb' + | '/odm' | '/package' | '/recent' | '/convert/' @@ -123,10 +143,12 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/editors' | '/fonts' | '/inspect' | '/metadata' | '/odb' + | '/odm' | '/package' | '/recent' | '/convert' @@ -135,10 +157,12 @@ export interface FileRouteTypes { | '__root__' | '/' | '/convert' + | '/editors' | '/fonts' | '/inspect' | '/metadata' | '/odb' + | '/odm' | '/package' | '/recent' | '/convert/' @@ -148,10 +172,12 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ConvertRoute: typeof ConvertRouteWithChildren + EditorsRoute: typeof EditorsRoute FontsRoute: typeof FontsRoute InspectRoute: typeof InspectRoute MetadataRoute: typeof MetadataRoute OdbRoute: typeof OdbRoute + OdmRoute: typeof OdmRoute PackageRoute: typeof PackageRoute RecentRoute: typeof RecentRoute } @@ -172,6 +198,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConvertRouteImport parentRoute: typeof rootRouteImport } + '/editors': { + id: '/editors' + path: '/editors' + fullPath: '/editors' + preLoaderRoute: typeof EditorsRouteImport + parentRoute: typeof rootRouteImport + } '/fonts': { id: '/fonts' path: '/fonts' @@ -200,6 +233,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OdbRouteImport parentRoute: typeof rootRouteImport } + '/odm': { + id: '/odm' + path: '/odm' + fullPath: '/odm' + preLoaderRoute: typeof OdmRouteImport + parentRoute: typeof rootRouteImport + } '/package': { id: '/package' path: '/package' @@ -247,10 +287,12 @@ const ConvertRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ConvertRoute: ConvertRouteWithChildren, + EditorsRoute: EditorsRoute, FontsRoute: FontsRoute, InspectRoute: InspectRoute, MetadataRoute: MetadataRoute, OdbRoute: OdbRoute, + OdmRoute: OdmRoute, PackageRoute: PackageRoute, RecentRoute: RecentRoute, } diff --git a/packages/web/src/routes/-Sidebar.css.ts b/packages/web/src/routes/-Sidebar.css.ts index ac1b929ab..3dc025f1d 100644 --- a/packages/web/src/routes/-Sidebar.css.ts +++ b/packages/web/src/routes/-Sidebar.css.ts @@ -2,8 +2,6 @@ import { style } from "@vanilla-extract/css"; export const navLink = style({ textDecoration: "none", color: "inherit" }); -export const disabledNavItem = style({ cursor: "default" }); - export const versionAnchor = style({ alignItems: "center", gap: 6, diff --git a/packages/web/src/routes/-Sidebar.tsx b/packages/web/src/routes/-Sidebar.tsx index 59092a4ff..145c73ded 100644 --- a/packages/web/src/routes/-Sidebar.tsx +++ b/packages/web/src/routes/-Sidebar.tsx @@ -15,23 +15,19 @@ import { } from "@tabler/icons-react"; import { relativeTime } from "../shared/relativeTime"; -import { disabledNavItem, navLink, versionAnchor } from "./-Sidebar.css"; +import { navLink, versionAnchor } from "./-Sidebar.css"; // Not a route -- the '-' prefix keeps TanStack Router's file-based generator from treating this as one. const NAV_ITEMS = [ { to: "/convert", label: "Convert", icon: IconArrowsExchange }, + { to: "/editors", label: "Editors", icon: IconEdit }, { to: "/metadata", label: "Metadata", icon: IconTags }, { to: "/inspect", label: "Inspect", icon: IconFileSearch }, { to: "/fonts", label: "Fonts", icon: IconTypography }, { to: "/recent", label: "Recent", icon: IconHistory }, { to: "/package", label: "Package / JSON", icon: IconJson }, { to: "/odb", label: ".odb", icon: IconDatabase }, -] as const; - -// Tools already tracked as follow-up work -- headroom in the nav without inventing empty route files ahead of time. -const PLANNED_ITEMS = [ - { label: "Editors", icon: IconEdit }, - { label: ".odm", icon: IconBooks }, + { to: "/odm", label: ".odm", icon: IconBooks }, ] as const; // Build-time git state (see vite.config.ts's `define` block) rather than a dry-run prediction: whenever this build's HEAD is an exact semantic-release tag, CI's own job graph guarantees that tag already exists on disk (the deploy job checks out `ref: main` fresh, strictly after the release job pushed) -- there is nothing to predict, only real state to read. @@ -64,17 +60,6 @@ export function Sidebar() { )} ))} - {PLANNED_ITEMS.map((item) => ( - - } - disabled - className={disabledNavItem} - /> - - ))} (undefined); + const [format, setFormat] = useState(undefined); + const [snapshot, setSnapshot] = useState< + { id: number; paragraphs: string[] } | undefined + >(undefined); + const [newParagraph, setNewParagraph] = useState(""); + + const openEditor = useOpenEditor(); + const setParagraphText = useSetParagraphText(); + const addParagraph = useAddParagraph(); + const removeParagraph = useRemoveParagraph(); + const saveEditor = useSaveEditor(); + const fileAccess = createFileAccess(); + + const handleFile = (opened: OpenedFile) => { + const inferred = inferFormat(opened.name); + if (inferred === undefined) { + notifyError( + "Unsupported format", + new Error("the editors tool opens docx, odt, doc, or markdown files"), + ); + return; + } + setFile(opened); + setFormat(inferred); + setSnapshot(undefined); + openEditor.reset(); + openEditor.mutate( + { format: inferred, bytes: opened.bytes }, + { + onSuccess: setSnapshot, + onError: (error) => { + notifyError("Could not open document", error); + }, + }, + ); + }; + + const applySet = (index: number, text: string) => { + if (snapshot === undefined) return; + setParagraphText.mutate( + { id: snapshot.id, index, text }, + { + onSuccess: setSnapshot, + onError: (error) => { + notifyError("Could not edit paragraph", error); + }, + }, + ); + }; + + const applyAdd = () => { + if (snapshot === undefined || newParagraph === "") return; + addParagraph.mutate( + { id: snapshot.id, text: newParagraph }, + { + onSuccess: (next) => { + setSnapshot(next); + setNewParagraph(""); + }, + onError: (error) => { + notifyError("Could not add paragraph", error); + }, + }, + ); + }; + + const applyRemove = (index: number) => { + if (snapshot === undefined) return; + removeParagraph.mutate( + { id: snapshot.id, index }, + { + onSuccess: setSnapshot, + onError: (error) => { + notifyError("Could not remove paragraph", error); + }, + }, + ); + }; + + const applySave = () => { + if (snapshot === undefined || file === undefined) return; + saveEditor.mutate( + { id: snapshot.id }, + { + onSuccess: (result) => { + notifySuccess("Document saved"); + void fileAccess.saveFile(result.bytes, { + suggestedName: file.name, + mimeType: "application/octet-stream", + }); + }, + onError: (error) => { + notifyError("Could not save document", error); + }, + }, + ); + }; + + return ( + + + Edit a document + + Opens docx, odt, doc, and markdown through live-view editors running + in the browser: edits apply to the document itself, and Save writes + the whole document back through its format's own writer. + + + {snapshot !== undefined && ( + + + + {format?.toUpperCase()} ยท {snapshot.paragraphs.length}{" "} + {snapshot.paragraphs.length === 1 ? "paragraph" : "paragraphs"} + + + + + {snapshot.paragraphs.map((text, index) => ( + +