From 01bf8c663203268e92b2c80915ca1569d0790ba8 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:32:54 +0000 Subject: [PATCH 1/2] Add formatting, blocks and markdown input to inline editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline editing could change text and nothing else. This adds the rest of a small editor on top of it: marks and links, block types, new blocks, deletes, drag reordering, list nesting, tables, code blocks, and markdown as an input method — plus the history and recovery those writes need. Two write paths, chosen by what changed. Typing stays a text patch to /edits: nothing reloads, the caret never moves, comment anchors ride the patch's offset map. Formatting and structure go to a new /ops, where each op names an element by the id written into the start tags of the overlay copy of the document and says what it should become. The server turns that into one splice of that element's byte range, so everything outside it is untouched down to the byte — the same guarantee the text path gives, and the reason /d/:slug/raw can serve the author's bytes. Ops carry intent, never markup: a block is {tag, runs}, every tag comes from a literal in block-render.ts and author text is always escaped, so there is no html to sanitize. Element ids are indexes into the bytes they were served with, so base_version is effectively required; a stale one is a 409 and the shell replays once, which is safe because text ops carry the text they expect to replace and a real collision comes back as a 422. After an ops write the iframe reloads against the stored bytes and the caret and scroll are restored, rather than the overlay re-rendering locally. A second markup renderer in the sandbox would have to agree with the server's forever, and the moment it drifted the rendered document would stop being the stored one. Two things fall out of writing this often. Consecutive patches by one author inside a five-minute window now replace the previous snapshot instead of adding one, so a session of writing no longer exhausts the 100-version cap; the document's version still increments on every write, so stale-write detection is unaffected. And POST /versions/:n/restore (with a button on the history page) writes an old version's content forward as a new one, so undoing a bad edit is no longer database surgery. Editing a phrase that appears twice also works now: when the text engine reports it can't place a match uniquely, the shell retries positionally as a setRuns op, which names the exact text node instead of searching for its content. htmlparser2 was already in the tree; it is now a direct dependency, used for its source offsets. Its non-spec tree building doesn't matter here because the browser learns an element's id from an attribute, not from tree position, and text ops are verified against the text they expect before anything is written. Verified against a real Chromium over CDP and a local Postgres: 17 browser checks (typing, ⌘B, markdown inline and pasted, block shortcuts, Enter, the block menu, entity and script preservation, escaping, the toolbar, ⌘K, word count, rename) and 21 API checks (list nesting, move, tables, delete, refusals, the access boundary from anonymous through commenter to editor, coalescing, restore). 231 unit tests, tsc, spec:check and next build pass; npm run lint is not configured in this repo. Co-Authored-By: Claude Opus 5 --- DEVELOPMENT.md | 70 +- app/api/v1/docs/[slug]/ops/route.ts | 113 ++ .../docs/[slug]/versions/[n]/restore/route.ts | 89 ++ app/d/[slug]/CommentsShell.tsx | 415 ++++- app/d/[slug]/history/HistoryClient.tsx | 42 +- app/d/[slug]/history/page.tsx | 11 + app/d/[slug]/page.tsx | 3 + app/d/[slug]/raw/route.ts | 8 + lib/docs/block-render.ts | 158 ++ lib/docs/config.ts | 8 + lib/docs/doc-ops.test.ts | 346 +++++ lib/docs/doc-ops.ts | 362 +++++ lib/docs/html-source.test.ts | 127 ++ lib/docs/html-source.ts | 253 +++ lib/docs/markdown-input.test.ts | 111 ++ lib/docs/markdown-input.ts | 166 ++ lib/docs/overlay.test.ts | 27 + lib/docs/overlay.ts | 799 +++++++++- lib/docs/paths.ts | 62 + lib/docs/schemas.ts | 157 +- lib/docs/store.ts | 175 ++- lib/openapi/generated-spec.ts | 2 +- lib/openapi/generated.json | 1384 ++++++++++++++++- lib/openapi/generated.yaml | 938 ++++++++++- lib/skill-content.ts | 33 +- package-lock.json | 1 + package.json | 1 + skills/just-html/SKILL.md | 33 +- 28 files changed, 5785 insertions(+), 109 deletions(-) create mode 100644 app/api/v1/docs/[slug]/ops/route.ts create mode 100644 app/api/v1/docs/[slug]/versions/[n]/restore/route.ts create mode 100644 lib/docs/block-render.ts create mode 100644 lib/docs/doc-ops.test.ts create mode 100644 lib/docs/doc-ops.ts create mode 100644 lib/docs/html-source.test.ts create mode 100644 lib/docs/html-source.ts create mode 100644 lib/docs/markdown-input.test.ts create mode 100644 lib/docs/markdown-input.ts create mode 100644 lib/docs/overlay.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 51f0b63..c030aae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -191,9 +191,11 @@ Auth: - API 401s carry `WWW-Authenticate: Bearer resource_metadata="…"`. 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. +`/edits` (deterministic text patches), `/ops` (structural edits), +`/rotate-token`, `/versions`, `/versions/:n/restore`, `/grants`. `/edits`, +`/ops` and `/versions/:n/restore` additionally accept a signed-in session — they +back the viewer's inline edit mode and the history page's restore button (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 @@ -221,19 +223,55 @@ owner / editor or commenter grant / view-token holder with identity / any identi 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. +mode; clicking a block makes THAT BLOCK contentEditable. There are two write +paths, chosen by what changed. + +**Typing** diffs the block's TEXT NODES on blur / ⌘-Enter and posts +`{oldText,newText}` to `/edits` — the overlay never serializes the DOM back to +HTML, because a round-trip would rewrite the whole document (attribute order, +entity forms, script-rendered subtrees) and byte-exact `/raw` is the product. +Nothing reloads and comment anchors ride the patch's offset map. A text node is +verbatim in the stored HTML except where the author used entities, hence two +payloads (literal, then entity-escaped on `not_found`); `newText` is always +escaped so typing `` lands as text. If the text still can't be placed — or +appears more than once — the shell retries POSITIONALLY as a `setRuns` op, which +names the exact text node instead of searching for its content. + +**Formatting and structure** posts to `/ops`. Each op names an element by the +`data-jh-src` id written into the start tags of the overlay copy only +(`lib/docs/html-source.ts`; direct `/raw` stays byte-pristine) and describes what +it should become; `lib/docs/doc-ops.ts` turns that into ONE SPLICE of that +element's byte range, so everything outside it is untouched down to the byte, +exactly as with a text patch. Ops carry INTENT, never markup — every tag comes +from a literal in `lib/docs/block-render.ts` and author text is always escaped — +so there is no html to sanitize. Ids are indexes into the bytes they were served +with, so `base_version` is effectively mandatory; a mismatch is a 409 and the +shell replays once (safe because text ops carry the text they expect to replace). + +After an ops write the iframe RELOADS against the stored bytes and the caret and +scroll are restored, rather than the overlay re-rendering the change locally: a +second markup renderer in the sandbox would have to agree with the server's +forever, and the moment it drifted the rendered document would stop being the +stored one. Typing does not reload. + +Markdown is an INPUT METHOD, not a storage format (`lib/docs/markdown-input.ts`, +injected into the overlay): `**bold**`, `` `code` ``, `[t](u)`, bare URLs, `## ` +/ `- ` / `> ` / ``` ``` ``` / `---` at a block start, and a multi-line paste all +parse to runs/blocks that the server renders. Nothing round-trips back to +asterisks. Keys: ⌘B / ⌘I / ⌘E / ⌘⇧X / ⌘K, ⌘⌥1-3 headings, ⌘⇧7 / ⌘⇧8 lists, +⌘⇧. quote, Tab / ⇧Tab list indent and table-cell movement, Enter splits a block, +Backspace on an empty block deletes it, `/` opens the block menu, Esc cancels. +Blocks drag to reorder by the gutter grip. A block holding markup the run model +can't describe (an unknown inline element) is REFUSED rather than reformatted. + +Because inline editing writes on every blur and every formatting command, +consecutive patches by one author inside `VERSION_COALESCE_MS` replace the +previous snapshot instead of adding one — the document's version still increments +on every write so stale-write detection is unaffected, but a session of writing yields +roughly one restorable point per five minutes instead of exhausting +`MAX_VERSIONS_PER_DOC`. `POST /versions/:n/restore` (and the button on +`/d/:slug/history`) writes an old version's content forward as a new one, so a +bad edit no longer needs database surgery to undo. Viewing: `/d/:slug` (shell + sandboxed iframe; the google-docs-style comment rail appears once a doc has comments/reactions or the viewer can interact — diff --git a/app/api/v1/docs/[slug]/ops/route.ts b/app/api/v1/docs/[slug]/ops/route.ts new file mode 100644 index 0000000..476b93b --- /dev/null +++ b/app/api/v1/docs/[slug]/ops/route.ts @@ -0,0 +1,113 @@ +import { + forbiddenScope, + hasScope, + json, + notFoundDoc, + parseJsonObject, + payloadTooLarge, + quotaExceeded, + 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 { OpsBody, opsBadRequest } from "@/lib/docs/schemas"; +import { MAX_HTML_BYTES, RL_WRITES_PER_MIN } from "@/lib/docs/config"; +import { applyDocOps, findBySlug, granteeView, ownerView } from "@/lib/docs/store"; +import { OpApplyError, type Op } from "@/lib/docs/doc-ops"; +import { accessRoleLabel, canEdit, resolveAccess } from "@/lib/docs/grants"; + +export const dynamic = "force-dynamic"; + +type Ctx = { params: Promise<{ slug: string }> }; + +// POST /api/v1/docs/:slug/ops — apply structural edits (lib/docs/doc-ops.ts). +// Body: { ops: [{ op, src, … }, …], base_version }. +// +// The sibling of /edits. /edits changes TEXT by matching it; this changes MARKUP +// by naming an element, which is what the viewer's formatting, block-type, +// insert/delete/move and list commands need. Auth, rate limiting, quota, size +// caps and the 409/422 contract are deliberately identical to /edits — the only +// additions are the op-specific 422 reasons and `focus`, the id of content this +// request created, which the viewer uses to put the caret in the right place +// once it has reloaded against the new bytes. +// +// base_version is effectively mandatory here even though the schema allows it to +// be absent: an element id only means something against the bytes it was served +// with, so an id dereferenced against a document that has moved is a silent +// mis-edit. Sending it turns that into a 409. +export async function POST(req: Request, ctx: Ctx): Promise { + const apiPrincipal = await authenticate(req); + if (apiPrincipal && !hasScope(apiPrincipal, "docs.write")) return forbiddenScope("docs.write"); + const session = apiPrincipal ? null : await getSession(req); + 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) } + ); + } + } + + const contentLength = Number(req.headers.get("content-length") ?? ""); + if (Number.isFinite(contentLength) && contentLength > MAX_HTML_BYTES) { + return payloadTooLarge(MAX_HTML_BYTES, contentLength); + } + + const { slug } = await ctx.params; + const doc = await findBySlug(slug); + if (!doc) return notFoundDoc(); + const access = await resolveAccess(doc, principal.email, principal.userId); + // No existence oracle: no edit access is the same 404 as no document. + if (!canEdit(access)) return notFoundDoc(); + + const parsed = await parseJsonObject(req); + if ("response" in parsed) return parsed.response; + + const v = OpsBody.safeParse(parsed.obj); + if (!v.success) return opsBadRequest(v.error); + + let result; + try { + result = await applyDocOps({ + doc, + ops: v.data.ops as Op[], + baseVersion: v.data.base_version, + authorUserId: principal.userId, + }); + } catch (e) { + if (e instanceof OpApplyError) return unprocessableEdit(e.reason, e.opIndex, e.message); + throw e; + } + + if ("stale" in result) return staleVersion(result.stale.currentVersion); + if ("tooLarge" in result) return payloadTooLarge(MAX_HTML_BYTES, result.tooLarge.gotBytes); + if ("quota" in result) { + return quotaExceeded(result.quota.kind, result.quota.limit, result.quota.current); + } + + const view = + access.kind === "owner" + ? ownerView(result.doc, true) + : granteeView(result.doc, true, accessRoleLabel(access)); + return json(result.focus === undefined ? view : { ...view, focus: result.focus }); +} diff --git a/app/api/v1/docs/[slug]/versions/[n]/restore/route.ts b/app/api/v1/docs/[slug]/versions/[n]/restore/route.ts new file mode 100644 index 0000000..bc3c585 --- /dev/null +++ b/app/api/v1/docs/[slug]/versions/[n]/restore/route.ts @@ -0,0 +1,89 @@ +import { + apiError, + forbiddenScope, + hasScope, + json, + notFoundDoc, + parsePositiveIntParam, + quotaExceeded, + rateLimit, +} 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 { RL_WRITES_PER_MIN } from "@/lib/docs/config"; +import { findBySlug, findVersion, granteeView, ownerView, rewriteDoc } from "@/lib/docs/store"; +import { accessRoleLabel, canEdit, resolveAccess } from "@/lib/docs/grants"; + +export const dynamic = "force-dynamic"; + +type Ctx = { params: Promise<{ slug: string; n: string }> }; + +// POST /api/v1/docs/:slug/versions/:n/restore — put an earlier version's content +// back as the current one. Owner or editor grant; API key OR signed-in session, +// so the history page's Restore button works from the browser. +// +// Restoring is a normal forward write, not a rewind: it takes version n's html +// and stores it as a NEW version with edit_kind 'rewrite'. Nothing is deleted, so +// restoring the wrong version is itself undoable, and the intervening versions +// stay in the history (subject to the usual retention cap). +// +// This is the recovery path for inline editing. Every other route can only move +// a document forward, which meant undoing a bad write required operating on the +// database directly. +export async function POST(req: Request, ctx: Ctx): Promise { + const apiPrincipal = await authenticate(req); + if (apiPrincipal && !hasScope(apiPrincipal, "docs.write")) return forbiddenScope("docs.write"); + const session = apiPrincipal ? null : await getSession(req); + const principal = await resolveCommentPrincipal(apiPrincipal, session); + if (!principal) return authFail(req); + + 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) } + ); + } + } + + const { slug, n } = await ctx.params; + const versionResult = parsePositiveIntParam("Version", n); + if ("response" in versionResult) return versionResult.response; + + const doc = await findBySlug(slug); + if (!doc) return notFoundDoc(); + const access = await resolveAccess(doc, principal.email, principal.userId); + if (!canEdit(access)) return notFoundDoc(); + + const version = await findVersion(doc.id, versionResult.value); + if (!version) { + return apiError(404, "not_found", "No such version (it may have been pruned past the retention cap)."); + } + if (version.html === doc.html) { + return apiError(422, "no_change", "That version's content is already what the document holds."); + } + + const result = await rewriteDoc({ doc, html: version.html, authorUserId: principal.userId }); + if ("quota" in result) { + return quotaExceeded(result.quota.kind, result.quota.limit, result.quota.current); + } + + const view = + access.kind === "owner" + ? ownerView(result.doc, true) + : granteeView(result.doc, true, accessRoleLabel(access)); + return json({ ...view, restored_from: version.version }); +} diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 76f2634..c6a80d8 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -83,6 +83,9 @@ type Props = { // 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; + // Title and visibility are owner-only on the API, so only an owner is offered + // the inline title field. An editor grantee edits the body, not the metadata. + canRename: boolean; signedIn: boolean; docId: number; bookmarked: boolean; @@ -154,6 +157,38 @@ function editErrorMessage(status: number, body: { reason?: string; message?: str return body?.message || "save failed"; } +/** + * Turn an /ops failure into something a person can act on. These reasons are + * about MARKUP, not text: an element the request named is gone, the document + * moved underneath, or the target isn't something inline editing rewrites. + */ +function opsErrorMessage(status: number, body: { reason?: string; message?: string } | null): string { + if (status === 409) return "edited elsewhere — reloading"; + if (status === 429) return "too many edits — wait a moment"; + if (status === 401 || status === 403 || status === 404) return "no edit access"; + if (status === 413) return "document is at its 2 MB limit"; + if (status === 422 && body?.reason === "anchor_mismatch") return "the document moved — reloading"; + if (status === 422 && body?.reason === "not_editable") return "that part of the document isn't editable here"; + if (status === 422 && body?.reason === "no_change") return "nothing changed"; + return body?.message || "save failed"; +} + +/** + * One text node's change, as the overlay reports it: the text before and after + * (what /edits matches on) plus the element id and child index that name the same + * node positionally (what /ops falls back to when the text isn't unique). + */ +type InlineChange = TextChange & { src: number | null; child: number }; + +/** What the overlay reports about the caret's surroundings, for the toolbar. */ +type EditSel = { + marks: string[]; + href: string | null; + tag: string; + code: boolean; + rect: { top: number; left: number; right: number; viewTop: number } | null; +}; + export default function CommentsShell(props: Props) { const { slug, @@ -163,6 +198,7 @@ export default function CommentsShell(props: Props) { canComment, canReact, canEdit, + canRename, signedIn, docId, me, @@ -261,7 +297,7 @@ export default function CommentsShell(props: Props) { const [editing, setEditing] = useState(false); const [editStatus, setEditStatus] = useState(null); const versionRef = useRef(props.version); - const saveInlineEditRef = useRef<(changes: TextChange[]) => void>(() => {}); + const saveInlineEditRef = useRef<(changes: InlineChange[]) => void>(() => {}); const statusTimer = useRef(null); const showEditStatus = useCallback((msg: string | null, clearAfterMs?: number) => { setEditStatus(msg); @@ -269,6 +305,27 @@ export default function CommentsShell(props: Props) { if (msg && clearAfterMs) statusTimer.current = window.setTimeout(() => setEditStatus(null), clearAfterMs); }, []); + // Formatting state. editSel drives the floating format toolbar; dirty is an + // open block with unsaved keystrokes (also the beforeunload guard); words is + // the live count the bar shows while editing. + const [editSel, setEditSel] = useState(null); + const [dirty, setDirty] = useState(false); + const [words, setWords] = useState(null); + const [linkDraft, setLinkDraft] = useState<{ href: string } | null>(null); + // A structural write reloads the iframe against the bytes that were actually + // stored rather than replaying the change locally, so the rendered document can + // never drift from the document. The nonce busts the iframe's cache; the + // pending focus is replayed to the overlay on the next jh:ready. + const [reloadNonce, setReloadNonce] = useState(0); + const pendingFocus = useRef<{ src: number | null; offset: number; scrollY: number } | null>(null); + const applyOpsRef = useRef<(ops: unknown[], focus: { src?: number; offset?: number } | null, scrollY: number) => void>( + () => {} + ); + const reloadDoc = useCallback(() => { + setOverlayReady(false); + setReloadNonce((n) => n + 1); + }, []); + // 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); @@ -388,8 +445,8 @@ export default function CommentsShell(props: Props) { // 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]); + if (overlayReady) postToOverlay({ type: "jh:editMode", on: editing, allowed: canEdit }); + }, [overlayReady, overlayReadyNonce, editing, canEdit, 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. @@ -437,6 +494,14 @@ export default function CommentsShell(props: Props) { // when they change while mounted. Sent before any hash-driven scroll so // the target heading has its id by the time we ask the overlay to scroll. postToOverlay({ type: "jh:sections", sections: initialSections }); + // A structural write reloaded the iframe out from under the viewer. + // Edit mode is re-sent by its own effect (keyed on the ready nonce); + // this puts the scroll and the caret back where they were. + if (pendingFocus.current) { + const f = pendingFocus.current; + pendingFocus.current = null; + postToOverlay({ type: "jh:focusBlock", src: f.src, offset: f.offset, scrollY: f.scrollY }); + } break; case "jh:positions": setPositions(d.positions || {}); @@ -509,8 +574,37 @@ export default function CommentsShell(props: Props) { // server-side on every patch; this is only the UI gate. if (canEdit && Array.isArray(d.changes)) saveInlineEditRef.current(d.changes); break; + case "jh:ops": + // Formatting or structure. canEdit is re-checked server-side on every + // write; this is only the UI gate. + if (canEdit && Array.isArray(d.ops)) applyOpsRef.current(d.ops, d.focus, d.scrollY ?? 0); + break; case "jh:editRejected": - showEditStatus("structure changed — use your agent for that", 6000); + showEditStatus( + d.reason === "markup" + ? "this block has markup the editor can't rewrite — use your agent" + : d.reason === "list" + ? "take the item out of the list first" + : "structure changed — use your agent for that", + 6000 + ); + break; + case "jh:editSel": + setEditSel( + d.active ? { marks: d.marks || [], href: d.href ?? null, tag: d.tag, code: !!d.code, rect: d.rect ?? null } : null + ); + break; + case "jh:dirty": + setDirty(!!d.on); + break; + case "jh:words": + setWords(typeof d.words === "number" ? d.words : null); + break; + case "jh:linkPrompt": + setLinkDraft({ href: typeof d.href === "string" ? d.href : "" }); + break; + case "jh:requestEditMode": + if (canEdit) setEditing(true); break; case "jh:copyLink": // The section link icon (inside the iframe) was clicked. Clipboard is @@ -691,7 +785,7 @@ export default function CommentsShell(props: Props) { // {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[]) => { + async (changes: InlineChange[]) => { const payloads = buildInlineEdits(changes); if (!payloads) return; showEditStatus("saving…"); @@ -706,6 +800,14 @@ export default function CommentsShell(props: Props) { let r = await post(payloads.edits); let body = await r.json().catch(() => null); + // Someone else wrote while this block was open. Replaying against the new + // version is safe rather than hopeful: the engine still has to find this + // exact text, unambiguously, so a real collision comes back as a 422. + if (r.status === 409 && typeof body?.current_version === "number") { + versionRef.current = body.current_version; + r = await post(payloads.edits); + 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. @@ -713,6 +815,28 @@ export default function CommentsShell(props: Props) { r = await post(payloads.escaped); body = await r.json().catch(() => null); } + // Still unplaceable, or placeable in more than one spot — "the" appears in + // a document a hundred times. Retry POSITIONALLY: the overlay also told us + // which text node it edited, so /ops can rewrite exactly that one without + // searching for its content. This is why editing a repeated phrase no + // longer has to be handed to an agent. + if (r.status === 422 && (body?.reason === "not_found" || body?.reason === "multiple_matches")) { + const positional = changes.filter((c) => typeof c.src === "number" && c.child >= 0); + if (positional.length === changes.length) { + applyOpsRef.current( + positional.map((c) => ({ + op: "setRuns", + src: c.src, + child: c.child, + before: c.before, + runs: [{ kind: "text", text: c.after }], + })), + null, + 0 + ); + return; + } + } // Tell the overlay first: on a rejection it puts the author's text back, so // the rendered document never disagrees with the stored bytes. @@ -730,6 +854,75 @@ export default function CommentsShell(props: Props) { ); saveInlineEditRef.current = (changes) => void saveInlineEdit(changes); + // Apply structural edits (lib/docs/doc-ops.ts) and re-read the document. + // + // Unlike a text patch, this always reloads the iframe. Element ids are indexes + // into the stored bytes, so every id the viewer is holding shifts the moment the + // document's length changes — and re-rendering the change locally instead would + // mean a second markup renderer here that has to agree with the server's. The + // reload costs a fetch; drift would cost correctness. + const applyOps = useCallback( + async (ops: unknown[], focus: { src?: number; offset?: number } | null, scrollY: number) => { + showEditStatus("saving…"); + const post = (base: number) => + fetch(`${apiBase}/ops${tokenQuery}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ ops, base_version: base }), + }); + + let r = await post(versionRef.current); + let body = await r.json().catch(() => null); + // Someone else wrote while this edit was being composed. Replaying is safe + // rather than hopeful: a text op carries the text it expects to replace and + // the server refuses on a mismatch, so a genuine collision 422s instead of + // silently landing on the wrong content. + if (r.status === 409 && typeof body?.current_version === "number") { + versionRef.current = body.current_version; + r = await post(versionRef.current); + body = await r.json().catch(() => null); + } + if (r.ok && typeof body?.version === "number") versionRef.current = body.version; + + pendingFocus.current = { + src: r.ok && typeof body?.focus === "number" ? body.focus : (focus?.src ?? null), + offset: focus?.offset ?? 0, + scrollY, + }; + showEditStatus(r.ok ? "saved" : opsErrorMessage(r.status, body), r.ok ? 2000 : 6000); + reloadDoc(); + // The write re-anchored comments in the same transaction; pull the result. + await reload(); + }, + [apiBase, tokenQuery, reload, reloadDoc, showEditStatus] + ); + applyOpsRef.current = (ops, focus, scrollY) => void applyOps(ops, focus, scrollY); + + // Rename the document. Owner-only on the API, and metadata rather than + // content, so it is a PATCH and not part of the edit/ops machinery. + const saveTitle = useCallback( + async (next: string) => { + const r = await fetch(`${apiBase}${tokenQuery}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ title: next.trim() || null }), + }); + showEditStatus(r.ok ? "saved" : "couldn't rename", r.ok ? 2000 : 6000); + }, + [apiBase, tokenQuery, showEditStatus] + ); + + // Warn before leaving with keystrokes that never reached the server. The block + // commits on blur, so this only fires on a hard close mid-sentence. + useEffect(() => { + if (!dirty) return; + const warn = (e: BeforeUnloadEvent) => e.preventDefault(); + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [dirty]); + // Sync the active highlight to the overlay. useEffect(() => { if (overlayReady) postToOverlay({ type: "jh:active", id: activeId }); @@ -920,9 +1113,13 @@ export default function CommentsShell(props: Props) {
- - {title} - + {canRename && editing ? ( + + ) : ( + + {title} + + )} {signedIn ? ( @@ -952,6 +1149,14 @@ export default function CommentsShell(props: Props) { ) : null} {canEdit ? ( <> + {editing && words !== null ? ( + {words} words + ) : null} + {dirty ? ( + + • + + ) : null} {editStatus ? ( {editStatus} @@ -1006,7 +1211,7 @@ export default function CommentsShell(props: Props) {