diff --git a/packages/web/e2e/navigation.spec.ts b/packages/web/e2e/navigation.spec.ts index 1e473c238..5d8b13c70 100644 --- a/packages/web/e2e/navigation.spec.ts +++ b/packages/web/e2e/navigation.spec.ts @@ -9,10 +9,11 @@ const REAL_TOOLS: readonly { label: string; heading: string }[] = [ { label: "Recent", heading: "Recent files" }, { label: "Package / JSON", heading: "Package / JSON" }, { label: ".odb", heading: "Browse an .odb database" }, + { label: "Editors", heading: "Edit a document" }, + { label: ".odm", heading: "Render an .odm master document" }, ]; -// Sidebar.tsx's own PLANNED_ITEMS -- tracked as their own follow-up (ExaDev/documents.js#1096), deliberately still disabled nav stubs here. -const PLANNED_TOOLS: readonly string[] = ["Editors", ".odm"]; +// The former PLANNED_ITEMS (Editors, .odm) are real tools since #1096 -- they sit in REAL_TOOLS above with their routes' own headings. test("the root route redirects straight into the Convert tool, the flagship page (no separate marketing landing)", async ({ page, @@ -33,21 +34,3 @@ for (const { label, heading } of REAL_TOOLS) { await expect(page.getByRole("heading", { name: heading })).toBeVisible(); }); } - -for (const label of PLANNED_TOOLS) { - test(`sidebar item "${label}" is visible but not a working link -- clicking it leaves the URL unchanged`, async ({ - page, - }) => { - await page.goto("/"); - await expect(page).toHaveURL(/\/convert$/); - // PLANNED_ITEMS render as a disabled Mantine NavLink (component="div", disabled), not an -- no link role and no real navigation to query, so the actual user-facing contract this checks is the disabled state plus a click going nowhere, not a transient tooltip's own hover animation. - const item = page.getByText(label, { exact: true }); - await expect(item).toBeVisible(); - await expect(item.locator("..").locator("..")).toHaveAttribute( - "data-disabled", - "true", - ); - await item.click({ force: true }); - await expect(page).toHaveURL(/\/convert$/); - }); -} 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) => ( + +