From 9112be3f3573b6be8d527468c133d7d6621bff56 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:38:15 +0000 Subject: [PATCH] Add inline text editing to the document viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners and editor grantees can now click a pencil in the viewer bar and type directly on the page. Clicking a block makes that block contentEditable; blur or Cmd-Enter saves. Saving never re-serializes the DOM. A round-trip through innerHTML would rewrite the whole document on every save (attribute order, entity forms, script-rendered subtrees) and byte-exact /raw is the point of the format. Instead the overlay diffs the block's text nodes and reports before/after pairs, which become the same deterministic {oldText,newText} patch an agent posts to /edits — so versioning, comment re-anchoring, quotas and the 409/422 outcomes are all existing paths, and bytes outside the edited run are untouched. A text node's content is verbatim in the source except where the author used entities, so each save carries two payloads: literal, then entity-escaped on a not_found. newText is always escaped, so typing lands as text rather than markup. Structural edits are not expressible as text patches: Enter is suppressed, paste is flattened, and a node split/merge/removal restores the block and reports it instead of guessing. Those stay an agent job. /edits now accepts a signed-in session in addition to an API key (the same dual identity the comment routes already take), since the browser posts with a cookie. Permissions are unchanged and still enforced by canEdit: owner or editor grant only. A view-token holder or public-doc reader gets the same 404 as before, so read links stay read-only. Co-Authored-By: Claude Opus 5 --- DEVELOPMENT.md | 17 +++ app/api/v1/docs/[slug]/edits/route.ts | 50 ++++++- app/d/[slug]/CommentsShell.tsx | 133 ++++++++++++++++++- app/d/[slug]/page.tsx | 11 +- lib/docs/inline-edit.test.ts | 105 +++++++++++++++ lib/docs/inline-edit.ts | 66 ++++++++++ lib/docs/overlay.ts | 181 ++++++++++++++++++++++++++ lib/docs/paths.ts | 26 ++-- lib/openapi/generated-spec.ts | 2 +- lib/openapi/generated.json | 5 +- lib/openapi/generated.yaml | 4 +- lib/skill-content.ts | 4 + skills/just-html/SKILL.md | 4 + 13 files changed, 581 insertions(+), 27 deletions(-) create mode 100644 lib/docs/inline-edit.test.ts create mode 100644 lib/docs/inline-edit.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4d75967..51f0b63 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -192,6 +192,8 @@ Auth: Documents (`/api/v1`, `Authorization: Bearer jh_live_…`): `docs` CRUD, `/edits` (deterministic patches), `/rotate-token`, `/versions`, `/grants`. +`/edits` additionally accepts a signed-in session — it backs the viewer's inline +edit mode (see below); every other document endpoint stays API-key-only. `GET /api/v1/docs` items carry `access` and `comment_count`. Creating an **email** grant sends the grantee a share-notification email — one single-use, 7-day login link (`kind='share'` on `login_tokens`) with `next=/d/:slug` that @@ -218,6 +220,21 @@ the chip/highlight appear optimistically the instant you react, no reload. Comme owner / editor or commenter grant / view-token holder with identity / any identity on a public doc. React: anyone who can view, with identity. Anonymous never writes. +Inline editing (`/d/:slug`, owner or editor grant): the bar's pencil enters edit +mode; clicking a block makes THAT BLOCK contentEditable, and blur / ⌘-Enter saves. +The overlay never serializes the DOM back to HTML — a round-trip would rewrite the +whole document (attribute order, entity forms, script-rendered subtrees) and +byte-exact `/raw` is the product. Instead it diffs the block's TEXT NODES and +reports `{before, after}` pairs; `lib/docs/inline-edit.ts` turns those into the +same `{oldText,newText}` patch an agent posts to `/edits`, so versioning, +re-anchoring, quotas and the 409/422 outcomes are all the existing paths. A text +node's content is verbatim in the stored HTML, except where the author used +entities — hence two payloads (literal, then entity-escaped on a `not_found`), and +`newText` is always escaped so typing `` lands as text. Structural edits are +not expressible as text patches: Enter is suppressed, paste is flattened, and a +node split/merge/removal restores the block and reports "structure changed" rather +than guessing. Those go through the agent API. + Viewing: `/d/:slug` (shell + sandboxed iframe; the google-docs-style comment rail appears once a doc has comments/reactions or the viewer can interact — zero comments + non-interacting viewer = zero chrome), `/d/:slug/raw` diff --git a/app/api/v1/docs/[slug]/edits/route.ts b/app/api/v1/docs/[slug]/edits/route.ts index 81f64f7..442d3cb 100644 --- a/app/api/v1/docs/[slug]/edits/route.ts +++ b/app/api/v1/docs/[slug]/edits/route.ts @@ -1,15 +1,21 @@ import { + forbiddenScope, + hasScope, json, notFoundDoc, parseJsonObject, payloadTooLarge, quotaExceeded, - requireApiKey, + rateLimit, staleVersion, unprocessableEdit, } from "@/lib/docs/api"; +import { authenticate, authFail } from "@/lib/auth/bearer"; +import { getSession } from "@/lib/auth/session"; +import { resolveCommentPrincipal } from "@/lib/docs/comments"; +import { checkLimits } from "@/lib/auth/ratelimit"; import { EditsBody, editsBadRequest } from "@/lib/docs/schemas"; -import { MAX_HTML_BYTES } from "@/lib/docs/config"; +import { MAX_HTML_BYTES, RL_WRITES_PER_MIN } from "@/lib/docs/config"; import { applyPatch, findBySlug, granteeView, ownerView } from "@/lib/docs/store"; import { EditApplyError, type Edit } from "@/lib/docs/edit-diff"; import { accessRoleLabel, canEdit, resolveAccess } from "@/lib/docs/grants"; @@ -20,11 +26,43 @@ type Ctx = { params: Promise<{ slug: string }> }; // POST /api/v1/docs/:slug/edits — apply deterministic patches (birthday.md // "Editing"). Body: { edits: [{ oldText, newText }, ...], base_version? }. -// Scope: docs.write. Owner OR editor grant (birthday.md "Permissions model"). +// +// Auth: API key (agents, scope docs.write) OR a signed-in browser session — the +// same dual identity the comment routes accept, because the viewer's inline edit +// mode posts here from the shell with a cookie, not a jh_live_ key. What each +// identity may do is UNCHANGED and enforced below by canEdit: owner or editor +// grant, nothing else. A view-token holder or a public-doc reader resolves to no +// access and gets the same 404 they always did — read links stay read-only. export async function POST(req: Request, ctx: Ctx): Promise { - const auth = await requireApiKey(req, "docs.write", "write"); - if ("response" in auth) return auth.response; - const { principal } = auth; + const apiPrincipal = await authenticate(req); + if (apiPrincipal && !hasScope(apiPrincipal, "docs.write")) return forbiddenScope("docs.write"); + const session = apiPrincipal ? null : await getSession(req); + // Neither identity → the Bearer 401 an agent can bootstrap from (a session + // always resolves to a principal, so this is the truly-anonymous case). + const principal = await resolveCommentPrincipal(apiPrincipal, session); + if (!principal) return authFail(req); + + // Rate limit per credential: the existing per-key bucket for agents, a + // per-session bucket at the same ceiling for the browser. + if (apiPrincipal) { + const limited = await rateLimit(req, apiPrincipal, "write"); + if (limited) return limited; + } else { + const tripped = await checkLimits([ + { key: `docs:write:sess:${session!.id}`, limit: RL_WRITES_PER_MIN, window: "minute" }, + ]); + if (tripped) { + return json( + { + error: "rate_limited", + message: `Too many requests. Retry after ${tripped.retryAfter} seconds.`, + retry_after: tripped.retryAfter, + }, + 429, + { "Retry-After": String(tripped.retryAfter) } + ); + } + } // Size cap by Content-Length before parse (the produced html is re-checked // under the write lock inside applyPatch). This precheck runs before the doc diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 4f90df7..76f2634 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -5,6 +5,7 @@ import { buildChromePalette, type ThemeSample, type ChromePalette } from "@/lib/ import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown"; import type { Section } from "@/lib/docs/sections"; import { fragmentFor, parseHash } from "@/lib/docs/deeplink"; +import { buildInlineEdits, type TextChange } from "@/lib/docs/inline-edit"; // CommentsShell — the THIRD React surface (birthday.md "Production // architecture", "CHOSEN: variant B"). The google-docs-style comment rail. The @@ -78,6 +79,10 @@ type Props = { viewtoken: string | null; canComment: boolean; canReact: boolean; + // Inline edit mode is offered only to the owner or an editor grantee (resolved + // server-side by canEdit). Everyone else — including view-token holders who may + // comment — never sees the affordance, and the API would refuse them anyway. + canEdit: boolean; signedIn: boolean; docId: number; bookmarked: boolean; @@ -135,6 +140,20 @@ function legacyCopy(text: string): boolean { } } +/** + * Turn an /edits failure into something a person can act on. The engine's reason + * codes are precise but written for an agent retrying with more context; a human + * needs to know whether to reload, retype, or hand the change to their agent. + */ +function editErrorMessage(status: number, body: { reason?: string; message?: string } | null): string { + if (status === 409) return "edited elsewhere — reload for the latest"; + if (status === 429) return "too many edits — wait a moment"; + if (status === 401 || status === 403 || status === 404) return "no edit access"; + if (status === 422 && body?.reason === "multiple_matches") return "text repeats — edit via your agent"; + if (status === 422 && body?.reason === "not_found") return "couldn’t place that text — edit via your agent"; + return body?.message || "save failed"; +} + export default function CommentsShell(props: Props) { const { slug, @@ -143,6 +162,7 @@ export default function CommentsShell(props: Props) { viewtoken, canComment, canReact, + canEdit, signedIn, docId, me, @@ -235,6 +255,20 @@ export default function CommentsShell(props: Props) { ); const isDark = palette !== null; + // Inline edit mode. versionRef tracks the version every patch is based on, so a + // concurrent write 409s instead of silently overwriting; the server hands back + // the new version on each save. + const [editing, setEditing] = useState(false); + const [editStatus, setEditStatus] = useState(null); + const versionRef = useRef(props.version); + const saveInlineEditRef = useRef<(changes: TextChange[]) => void>(() => {}); + const statusTimer = useRef(null); + const showEditStatus = useCallback((msg: string | null, clearAfterMs?: number) => { + setEditStatus(msg); + if (statusTimer.current) window.clearTimeout(statusTimer.current); + if (msg && clearAfterMs) statusTimer.current = window.setTimeout(() => setEditStatus(null), clearAfterMs); + }, []); + // Selection state (from the overlay) → the floating toolbar + a pending draft. const [selection, setSelection] = useState<{ anchor: NonNullable; top: number; viewTop: number } | null>(null); const [draft, setDraft] = useState<{ anchor: NonNullable; top: number } | null>(null); @@ -350,6 +384,13 @@ export default function CommentsShell(props: Props) { postToOverlay({ type: "jh:reactions", groups: paintReactionGroups, me, avatars }); }, [overlayReady, paintReactionGroups, me, avatars, postToOverlay]); + // Sync edit mode to the overlay. Keyed on overlayReadyNonce as well as + // overlayReady so an iframe reload re-enters edit mode rather than silently + // dropping it (the fresh overlay starts with editing off). + useEffect(() => { + if (overlayReady) postToOverlay({ type: "jh:editMode", on: editing }); + }, [overlayReady, overlayReadyNonce, editing, postToOverlay]); + // Send sections likewise — on change or when the overlay (re)becomes ready — so // heading ids + gutter icons don't go stale if initialSections changes in place. useEffect(() => { @@ -463,6 +504,14 @@ export default function CommentsShell(props: Props) { // A chip was clicked inside the iframe → optimistic toggle (add/remove). if (canReact && d.anchor && d.anchor.exact) reactAnchoredRef.current(d.emoji, d.anchor); break; + case "jh:edit": + // A block left edit mode with changed text. canEdit is re-checked + // server-side on every patch; this is only the UI gate. + if (canEdit && Array.isArray(d.changes)) saveInlineEditRef.current(d.changes); + break; + case "jh:editRejected": + showEditStatus("structure changed — use your agent for that", 6000); + break; case "jh:copyLink": // The section link icon (inside the iframe) was clicked. Clipboard is // unreliable from the opaque-origin sandbox, so the copy happens here on @@ -473,7 +522,7 @@ export default function CommentsShell(props: Props) { } window.addEventListener("message", onMsg); return () => window.removeEventListener("message", onMsg); - }, [paintAnchors, paintReactionGroups, me, avatars, postToOverlay, canComment, canReact, initialSections, copyLink]); + }, [paintAnchors, paintReactionGroups, me, avatars, postToOverlay, canComment, canReact, canEdit, initialSections, copyLink, showEditStatus]); const reload = useCallback(async () => { const r = await fetch(`${apiBase}/comments${tokenQuery}`, { credentials: "same-origin" }); @@ -637,6 +686,50 @@ export default function CommentsShell(props: Props) { ); reactAnchoredRef.current = reactAnchored; + // Save one block's inline edit. The overlay reports TEXT-NODE before/after pairs + // (never HTML — see lib/docs/inline-edit.ts), which become the same deterministic + // {oldText,newText} patch an agent posts, so the stored bytes change only where + // the text actually changed and everything around it stays byte-for-byte. + const saveInlineEdit = useCallback( + async (changes: TextChange[]) => { + const payloads = buildInlineEdits(changes); + if (!payloads) return; + showEditStatus("saving…"); + + const post = (edits: { oldText: string; newText: string }[]) => + fetch(`${apiBase}/edits${tokenQuery}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ edits, base_version: versionRef.current }), + }); + + let r = await post(payloads.edits); + let body = await r.json().catch(() => null); + // A miss here usually means the source spells the text with entities + // (`&`, ` `), so the DOM text isn't a substring of it. Retry once + // with the escaped form before telling the viewer we can't place it. + if (r.status === 422 && body?.reason === "not_found") { + r = await post(payloads.escaped); + body = await r.json().catch(() => null); + } + + // Tell the overlay first: on a rejection it puts the author's text back, so + // the rendered document never disagrees with the stored bytes. + postToOverlay({ type: "jh:editResult", ok: r.ok }); + if (!r.ok) { + showEditStatus(editErrorMessage(r.status, body), 6000); + return; + } + if (typeof body?.version === "number") versionRef.current = body.version; + showEditStatus("saved", 2000); + // The write re-anchored comments in the same transaction; pull the result. + await reload(); + }, + [apiBase, tokenQuery, postToOverlay, reload, showEditStatus] + ); + saveInlineEditRef.current = (changes) => void saveInlineEdit(changes); + // Sync the active highlight to the overlay. useEffect(() => { if (overlayReady) postToOverlay({ type: "jh:active", id: activeId }); @@ -857,6 +950,42 @@ export default function CommentsShell(props: Props) { ) : null} + {canEdit ? ( + <> + {editStatus ? ( + + {editStatus} + + ) : null} + + + ) : null}