diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 51f0b63..7f11713 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,69 @@ 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, Backspace on an empty +block deletes it, `/` opens the block menu, Esc cancels. + +**Enter always splits the block at the caret** — it is never a save (⌘Enter is), +and nothing is allowed to swallow it. It is one `splitAt` op: the caret's text +node is CUT at a byte offset, both halves keep their bytes exactly, and the +inline elements the caret sits inside are closed before the block ends and +reopened inside the new one. Because nothing is re-rendered, this works in a +block holding markup the run model could never describe — a ``, a `` — +which the earlier describe-both-halves approach silently declined to split. At +either end of a block the same op leaves an empty half, so there are no special +cases. When the block has uncommitted typing, or markdown before the caret, the +op instead carries the two halves' new content, which is how a split saves what +is on screen and resolves markdown in the same write. The split is applied to the +DOM immediately and the reload lands on top of it, so Enter doesn't wait on a +round trip. +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..ad45b81 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,33 @@ 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>( + () => {} + ); + // Mirrored so the message handler can tell which load a jh:ready came from + // without re-subscribing on every reload. + const reloadNonceRef = useRef(0); + const reloadDoc = useCallback(() => { + setOverlayReady(false); + setReloadNonce((n) => { + reloadNonceRef.current = n + 1; + return 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 +451,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 +500,20 @@ 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. + // + // Only once the NEW document is the one answering: a reload leaves the + // outgoing document alive until the new one commits, and it replies to + // the readiness ping first. Restoring into it would put the caret in a + // page about to be discarded — which reads as the caret being lost on + // every Enter. + if (pendingFocus.current && (d.r || "0") === String(reloadNonceRef.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 +586,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 +797,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 +812,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 +827,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 +866,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 +1125,13 @@ export default function CommentsShell(props: Props) {
- - {title} - + {canRename && editing ? ( + + ) : ( + + {title} + + )} {signedIn ? ( @@ -952,6 +1161,14 @@ export default function CommentsShell(props: Props) { ) : null} {canEdit ? ( <> + {editing && words !== null ? ( + {words} words + ) : null} + {dirty ? ( + + • + + ) : null} {editStatus ? ( {editStatus} @@ -1006,7 +1223,7 @@ export default function CommentsShell(props: Props) {