diff --git a/AGENTS.md b/AGENTS.md index ab7e71da0..724a3cfc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ The project structure is: * packages/workshop-backend: The Gadgets Workshop server. * Runs on Cloudflare Workers. * This is the **kernel**: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read *every line* of `workshop-backend` and of API changes in `workshop-shared`, so keep diffs here small and elegant. Concretely: doc-comment **every** exported member of the `workshop-shared` public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes "ambient" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits so `workshop-backend`/`workshop-shared` can be reviewed apart from UI), since fewer kernel lines = easier review. - * `format-blueprints/` holds the **output format** blueprints the deployment ships with, committed as data: a `.gadget` archive plus a `.json` sidecar giving its `blueprintId`, prose, and `output` presentation. `scripts/build-format-blueprints.mjs` globs that directory (override with `FORMAT_BLUEPRINTS_DIR`, which lets a fork ship its own set without touching this submodule) into the gitignored `src/generated/format-blueprints.ts`, so `build` and `test` both run the generator first (it rewrites the module only when the content changes, so the repeat invocations don't invalidate the downstream task cache). Replace one with `pnpm import:format-blueprint `, or add one with `pnpm import:format-blueprint --new `; never edit a `blueprintId` after deploy, since the install and promotion are keyed on it and a rename orphans the old entry. See `format-blueprints/README.md`. + * `format-blueprints/` holds the **output format** blueprints the deployment ships with, committed as reviewable source: each `/` contains `blueprint.json` plus the gadget code under `files/`. `scripts/build-format-blueprints.ts` reconstructs their archive representation (override the source directory with `FORMAT_BLUEPRINTS_DIR`) into the gitignored `src/generated/format-blueprints.ts`, so `build` and `test` both run the generator first. Replace one with `pnpm import:format-blueprint `, or add one with `pnpm import:format-blueprint --new `; never edit a `blueprintId` after deploy, since the install and promotion are keyed on it and a rename orphans the old entry. See `format-blueprints/README.md`. * packages/workshop-shared: Shared API definitions between client and server. * This defines the application's RPC interface. * The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details. diff --git a/docs/blueprints.md b/docs/blueprints.md index c2d2893ae..117863f40 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -120,11 +120,11 @@ A **format** is an ordinary blueprint the deployment has promoted, so that "New What a blueprint may declare is `BlueprintMetadata.output`: a grouping `id`, a `noun` and `plural` ("Doc"/"Docs"), and an `icon` from the closed `OUTPUT_ICONS` set. A gadget instantiated from the blueprint inherits it, and that is what the workspace tab, chat cards and the Outputs page draw. Declaring it is presentation only and grants nothing -- any user can publish a blueprint calling itself a Document. Being *offered* as one of the deployment's standard formats is the separate, admin-curated decision. An admin can override any of these fields (`FormatCuration.overrides`), and the override is applied on every instantiation path, so a rename reaches gadgets the agent builds as well as ones made from the menu. -A deployment can also ship blueprints as data. `packages/workshop-backend/format-blueprints/` holds a `.gadget` archive plus a `.json` sidecar for each, and `scripts/build-format-blueprints.mjs` bundles that directory (overridable with `FORMAT_BLUEPRINTS_DIR`, so a fork can ship its own set) into a generated module. These differ from published blueprints in three ways: +A deployment can also ship blueprints as data. `packages/workshop-backend/format-blueprints/` holds a directory for each blueprint with a `blueprint.json` manifest and reviewable files under `files/`. `scripts/build-format-blueprints.ts` reconstructs their ordinary archive representation and bundles it into a generated module (overridable with `FORMAT_BLUEPRINTS_DIR`, so a fork can ship its own set). These differ from published blueprints in three ways: - Their IDs are **stable and readable** (`format.document`, not a random hex ID), because both installation and promotion are keyed on them. Renaming one after deploy orphans the old entry rather than moving it. - They have **no owning User DO**. `AdminSettings` writes them straight into the featured mirror, because there is no publishing user whose `featured` bit could be authoritative. -- Their `output` lives in the sidecar rather than the archive, so the deployment's presentation has a single source of truth. +- Their `output` lives in `blueprint.json`, so the deployment's presentation has a single source of truth. The first `/api` request a deployment serves installs any whose manifest fingerprint has changed. The fingerprint covers its title, description, author, revision, and output presentation; `revision` represents changes to the archive bytes. Each bundled blueprint is promoted only once ever -- an upgrade never undoes an admin's later removal or overrides. diff --git a/packages/workshop-backend/__tests__/format-blueprint-files.test.ts b/packages/workshop-backend/__tests__/format-blueprint-files.test.ts new file mode 100644 index 000000000..8174cc82a --- /dev/null +++ b/packages/workshop-backend/__tests__/format-blueprint-files.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildContent, + extractFiles, + parseArchive, + readSourceFiles, + serializeArchive, +} from "../scripts/format-blueprint-files.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(path => + rm(path, {recursive: true, force: true}))); +}); + +describe("format blueprint source", () => { + it("reconstructs files deterministically", () => { + let files = new Map([ + ["server.js", "export default {};\n"], + ["empty.txt", ""], + ["client.js", "console.log('hello');\n"], + ]); + let metadata = { + title: "Example", + description: "Example blueprint", + author: {type: "user", name: "Test", id: "test@example.com"}, + created: "2026-01-01T00:00:00.000Z", + version: 1, + lastUpdated: "2026-01-01T00:00:00.000Z", + bindings: {}, + }; + + let first = serializeArchive(metadata, buildContent(files, "example"), "example"); + let second = serializeArchive(metadata, buildContent(files, "example"), "example"); + + expect(second).toEqual(first); + let parsed = parseArchive(first, "example"); + expect(parsed.metadata).toEqual(metadata); + expect(extractFiles(parsed.content, "example")).toEqual(files); + }); + + it("rejects non-UTF-8 source", async () => { + let directory = await mkdtemp(join(tmpdir(), "format-blueprint-")); + temporaryDirectories.push(directory); + await writeFile(join(directory, "client.js"), Uint8Array.of(0xff)); + + await expect(readSourceFiles(directory, "example/files")) + .rejects.toThrow("client.js is not valid UTF-8"); + }); + + it("rejects symlinks", async () => { + let directory = await mkdtemp(join(tmpdir(), "format-blueprint-")); + temporaryDirectories.push(directory); + await writeFile(join(directory, "source.js"), "source"); + await symlink(join(directory, "source.js"), join(directory, "client.js")); + + await expect(readSourceFiles(directory, "example/files")) + .rejects.toThrow("client.js must not be a symlink"); + }); +}); diff --git a/packages/workshop-backend/__tests__/format-blueprints.test.ts b/packages/workshop-backend/__tests__/format-blueprints.test.ts index 6ff332554..adf7d0e0a 100644 --- a/packages/workshop-backend/__tests__/format-blueprints.test.ts +++ b/packages/workshop-backend/__tests__/format-blueprints.test.ts @@ -62,12 +62,12 @@ describe("bundled format blueprints", () => { // No owning user: these belong to the deployment, so the owner-anchored featured toggle // must not apply to them. expect(record.ownerId).toBeUndefined(); - // Presentation comes from the sidecar, not from whatever the archive was called in the + // Presentation comes from the source manifest, not from whatever the archive was called in the // workspace it was exported from. expect(record.metadata.title).toBe(entry.title); expect(record.metadata.description).toBe(entry.description); expect(record.metadata.author).toEqual(entry.author); - // The sidecar's declaration is written into the installed blueprint, so from here on the + // The manifest's declaration is written into the installed blueprint, so from here on the // blueprint declares its own format like any other. expect(record.metadata.output).toEqual(entry.output); // ...and it survives the same validation an uploaded archive's would. @@ -140,6 +140,19 @@ describe("bundled format blueprints", () => { } }); + it.skipIf(FORMAT_BLUEPRINTS.length === 0)( + "changes the manifest version when bundled source changes", () => { + let entry = FORMAT_BLUEPRINTS[0]; + let before = formatBlueprintsManifestVersion(); + let original = entry.contentHash; + try { + entry.contentHash = `${original}-changed`; + expect(formatBlueprintsManifestVersion()).not.toBe(before); + } finally { + entry.contentHash = original; + } + }); + // Curated text is the input most likely to be edited -- it is the whole point of keeping it in a // text file -- and an edit that doesn't reach deployments which already installed would be // invisible: the build succeeds and the old wording stays put. diff --git a/packages/workshop-backend/format-blueprints/README.md b/packages/workshop-backend/format-blueprints/README.md index 6f2d14b44..c44dbac00 100644 --- a/packages/workshop-backend/format-blueprints/README.md +++ b/packages/workshop-backend/format-blueprints/README.md @@ -1,111 +1,70 @@ # Bundled format blueprints -This directory contains the output-format blueprints that ship with this repo, so a fresh deployment -can write a doc or build a deck without anyone having to build one first. A *format* is an ordinary -blueprint the deployment has promoted (see `AdminConfig.formats`); these are the ones it promotes out -of the box. +This directory contains the output-format blueprints that ship with this repo. A fresh deployment +installs them into BLUEPRINTS KV and BLUEPRINT_CONTENT R2 on its first `/api` request, after which +they are ordinary blueprints. -Each `.gadget` file here is committed **as data**. The first `/api` request a deployment serves -installs them into the BLUEPRINTS KV namespace and BLUEPRINT_CONTENT R2 bucket (see -`src/format-blueprints.ts`), after which they are ordinary blueprints. Nothing wakes on deploy, so a -fresh deployment is provisioned by its first visitor. +## Layout -## Who owns what +Each blueprint is committed as reviewable source: -A blueprint is two files with the same stem: +``` +workspace-docs/ + blueprint.json + files/ + README.md + client.js + server.js +``` -| | lives in | why | -| --- | --- | --- | -| the code, and the `bindings` it needs | `.gadget` | what the blueprint *does* | -| `blueprintId`, title, description, `output` (noun/plural/icon), author, `revision` | `.json` | what a human *curates*, so it's text in a reviewable file rather than fields inside a binary | +`files/` is the gadget's code. `blueprint.json` contains its install ID, presentation, provenance, +bindings, blueprint `version`, and bundled `revision`. The build converts these files into the same +gzip-compressed Yjs `.gadget` representation used by uploaded blueprints and embeds it in the +generated Worker module. No binary archive is committed. -The installer writes the sidecar's values over whatever the archive happens to carry, -so an archive's own title and author are inert. The import script normalizes them anyway, -so the committed bytes don't contradict the sidecar. +`blueprintId` is the install key. Never change it after deployment: the new ID would install a +second format while the old one remained. `version` is the blueprint's published content version +and R2 key. The build fingerprints the generated archive, so direct edits under `files/` reinstall +automatically. `revision` remains an explicit reinstall trigger and is bumped by the importer. -## Changing a title, description or author +## Editing presentation -Edit the sidecar and rebuild. That's all — no archive rewrite, no `revision` bump: everything that -ends up in the installed metadata is part of the installed-version fingerprint (see -`formatBlueprintsManifestVersion`), so a reinstall follows on the next deploy. +Edit `blueprint.json` and rebuild. Changes to title, description, output, or author are included in +the install fingerprint and do not need a `revision` bump. -## Updating a blueprint's code +## Updating code -Build it in a real Workshop, export it, and import the export: +Build the blueprint in a Workshop, export it, then import the export: ``` pnpm import:format-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document ``` -That rewrites the archive, bumps `revision` in the sidecar, rebuilds -`src/generated/format-blueprints.ts`, and reports what it did: - -``` -Updated workspace-docs.gadget (format.document) - code 23668 -> 24489 bytes (7c5413e5a482) - bindings (none) - version 3 -> 4 - revision 2 -> 3 (workspace-docs.json) - - presented as "Workspace Docs" by Cloudflare, from workspace-docs.json - [export called it "Gadgets Doc"] -``` - -The line worth reading is **`bindings`**, flagged `[CHANGED]` when the export needs something the old -copy didn't — an instantiating user will now be asked for it. `revision` is the reinstall trigger for -the one input the fingerprint can't see, the archive bytes; it's automated because forgetting it is -invisible (everything builds and deploys, and the old blueprint quietly stays put). - -The script also round-trips the bytes it just wrote and checks the metadata and a content hash, -because these are committed as data and a corrupt archive would otherwise first surface when a -deployment tried to install it. +The importer replaces `files/`, updates archive-owned metadata (`created`, `version`, `lastUpdated`, +and `bindings`), bumps `revision`, rebuilds `src/generated/format-blueprints.ts`, and reports changed +files and bindings. Review the resulting source diff normally. -## Adding a new format - -`--new` writes the sidecar for you, filling in what it can from the export: +## Adding a format ``` pnpm import:format-blueprint ~/Downloads/Brief.gadget --new acme-brief ``` -It then prints the handful of fields worth editing before you deploy — chiefly `output`, the noun, -plural and icon the sidecar owns rather than the archive, and `output.id`, the grouping key on the -Outputs page. -Make that one **generic** (`document`, not `acme-brief`): the Outputs page groups by it, so a -"Contract" blueprint declaring `document` is listed with the rest of the documents instead of -adding a filter chip of its own. - -`blueprintId` defaults to the name you passed. It is the install key: reimporting the same id -updates that blueprint in place, and **changing it after a deployment has installed it** promotes -the new id as a *second* format while the old one stays in the New menu, updated by nothing. -Rename files freely; the id is the load-bearing part. - +This extracts the files and writes a valid scaffolded `blueprint.json`. Before deploying, replace +the scaffold description and review `output`. Prefer a generic `output.id` such as `document`; the +Outputs page uses it to group related formats. ## Shipping your own formats -This directory is only the **default**. `FORMAT_BLUEPRINTS_DIR` points the build somewhere else: +`FORMAT_BLUEPRINTS_DIR` points the build at another directory in this same extracted layout: ``` FORMAT_BLUEPRINTS_DIR=../../acme-formats pnpm exec vp run build ``` -`vp run`, not `pnpm build`: this package's `build` is a Vite+ task rather than a package.json -script, so pnpm cannot see it. `../vite.config.ts` explains why it has to be one. - -Whatever directory it names *is* the deployment's format set — it replaces this one rather than -adding to it. Keep it in your own tree, in the same `.gadget` + `.json` layout; nothing -in it refers back to this repo. If you want one of ours too, copy the pair across once and own it -from then on. - -This matters because this repo is usually a submodule: adding or deleting files *here* would -conflict on every update. Pointing the build at your own directory touches nothing, so the submodule -stays pristine forever. The import script honours the same variable, so you get the same workflow. - -Two lighter options need no build change at all: +The named directory replaces this set rather than extending it. It can be empty to ship no bundled +formats. The import command honors the same variable. Keeping deployment-owned formats outside this +repo avoids modifying it when it is consumed as a submodule. -1. **Promote your own blueprints.** These are ordinary blueprints, and the standard set is admin - curation (`AdminConfig.formats`). Publish a blueprint in your deployment and promote it in the - admin Formats panel; disable the bundled ones you don't want. Nothing needs rebuilding, and this - is the mechanism the bundled set is a convenience on top of, not a special case beside. -2. **Ship no formats.** Point `FORMAT_BLUEPRINTS_DIR` at an empty directory, and the deployment - simply has none until an admin promotes something. +Administrators can also publish and promote ordinary blueprints at runtime instead of rebuilding a +deployment. diff --git a/packages/workshop-backend/format-blueprints/workspace-docs.gadget b/packages/workshop-backend/format-blueprints/workspace-docs.gadget deleted file mode 100644 index 1e202760a..000000000 Binary files a/packages/workshop-backend/format-blueprints/workspace-docs.gadget and /dev/null differ diff --git a/packages/workshop-backend/format-blueprints/workspace-docs.json b/packages/workshop-backend/format-blueprints/workspace-docs/blueprint.json similarity index 68% rename from packages/workshop-backend/format-blueprints/workspace-docs.json rename to packages/workshop-backend/format-blueprints/workspace-docs/blueprint.json index 6200bc96b..b347fe049 100644 --- a/packages/workshop-backend/format-blueprints/workspace-docs.json +++ b/packages/workshop-backend/format-blueprints/workspace-docs/blueprint.json @@ -9,6 +9,14 @@ "plural": "Docs", "icon": "fileText" }, - "author": { "type": "user", "name": "Cloudflare", "id": "agent@cloudflare.com" }, - "revision": 8 + "author": { + "type": "user", + "name": "Cloudflare", + "id": "agent@cloudflare.com" + }, + "revision": 9, + "created": "2026-06-24T04:19:41.881Z", + "version": 6, + "lastUpdated": "2026-08-12T18:09:36.250Z", + "bindings": {} } diff --git a/packages/workshop-backend/format-blueprints/workspace-docs/files/README.md b/packages/workshop-backend/format-blueprints/workspace-docs/files/README.md new file mode 100644 index 000000000..dbcef3b9e --- /dev/null +++ b/packages/workshop-backend/format-blueprints/workspace-docs/files/README.md @@ -0,0 +1,78 @@ +# Docs — real-time collaborative rich-text Gadget + +A single-document, Google-Docs-style rich-text editor with Durable Object persistence, live block updates, collaborator presence, and conflict-safe concurrent editing. + +## Architecture + +- **server.js** is the authoritative collaboration coordinator. It stores one atomic `document:v2` snapshot containing the title, global revision, ordered blocks, per-block versions, and modification time. +- **client.js** builds the entire UI around a `contenteditable` surface. Every top-level document element has a stable `data-block-id`. Local typing is optimistic and changed blocks are sent after a short ~220 ms debounce. +- Clients subscribe with a Cap'n Web `RpcTarget`. The server broadcasts accepted block operations and ephemeral presence events to every connected client. + +## Real-time data model + +The persisted document resembles: + +```js +{ + revision: 42, + title: "Project plan", + blocks: [ + { id: "b_…", html: "

Project plan

", version: 3 }, + { id: "b_…", html: "

Draft…

", version: 8 } + ], + lastModified: 1700000000000 +} +``` + +Clients send compact operation batches containing only changed/upserted blocks, version-checked deletions, the current block order, and title. The Durable Object: + +1. checks each changed block's `baseVersion`; +2. accepts non-conflicting changes; +3. increments accepted block versions and the document revision; +4. persists the resulting authoritative snapshot; and +5. broadcasts only the accepted operation. + +A remote operation patches only affected DOM blocks. The whole editor is not replaced, so typing and selection in unrelated paragraphs remain stable. Ordering is last-writer-wins, while content and deletion changes use per-block optimistic concurrency. + +### Same-block concurrency + +A remote update to the block currently being edited is queued rather than replacing the user's caret and draft. If the local block is clean on blur, the remote version is applied. If it is locally dirty, the draft is rebased onto the latest server block version and saved as the next version. Server conflicts return the authoritative block/version, and the client automatically retries its visible local draft—no local text is silently discarded. + +This is block-granularity collaboration rather than a character-level CRDT. It provides smooth simultaneous editing across paragraphs with much less complexity. A CRDT/OT layer would still be required for merged character-by-character edits by multiple people inside the exact same paragraph. + +## Presence + +Selection changes are throttled to ~70 ms and sent as ephemeral presence messages containing collaborator ID, name, color, and anchor/focus block-and-text offsets. Presence is never persisted. A collaborator appears as a slim named caret; selected text is shown with a subtle translucent highlight, including selections spanning multiple blocks. Both are rendered in a fixed overlay without modifying document HTML. There are no header avatars or active-block outlines. Page close uses a best-effort leave message, RPC disconnects broadcast leave events, and client heartbeats expire stale cursors when browsers do not complete unload networking. + +Remote carets and selection highlights live in a separate overlay, so presence decoration never enters the editable DOM or persisted document HTML. Serialization also strips transient local image-selection decoration. + +## Programmatic population and backward compatibility + +Agents and importers should call `setDocument({ title, blocks, senderId })` to populate or replace the document atomically. It works whether the editor has already been opened or not, persists the full snapshot, increments the revision, and broadcasts the result to connected clients. This avoids requiring callers to choose between initialization and incremental operations. + +`initializeBlocks()` remains the idempotent bootstrap/migration API. It also handles the startup race where a newly opened browser creates an empty revision-1 shell just before generated content arrives: that exact untouched shell can be replaced by a non-empty initialization payload. It will not overwrite later empty documents, which may represent an intentional user edit. + +If only the former `content`, `title`, and `lastModified` KV keys exist, the first v2 client loads that HTML, normalizes its top-level nodes, assigns stable block IDs, and calls `initializeBlocks()`. Existing documents therefore migrate without data loss. + +## Formatting and media + +The toolbar supports paragraph styles, font family/size, bold, italic, underline, strikethrough, text/highlight color, alignment, lists, indentation, links, images, horizontal rules, clear formatting, and undo/redo. + +Pasted Google Docs/Word HTML is rebuilt into clean semantic markup. Images from file selection, drag/drop, and clipboard are downscaled and embedded as data URLs. Because collaboration transmits changed blocks, an embedded image is no longer resent when unrelated paragraphs change. A future asset store could replace data URLs with image IDs if very large media-heavy documents are needed. + +## Google Doc sync + +`syncToGoogleDoc()` remains an explicit full-document integration because the current binding API exposes content replacement rather than range operations. It is separate from the efficient local real-time collaboration path. + +## Implementation notes + +- Durable Object storage is the source of truth; in-memory subscriber and presence maps are intentionally ephemeral. +- Structural DOM normalization handles root text and `
` nodes and repairs duplicate/missing block IDs. +- Remote ordering moves only blocks that are out of place, avoiding unnecessary selection disruption. +- `execCommand` is deprecated but remains the compact, broadly compatible formatting mechanism for this sandboxed editor. + +## Export formats + +- **Markdown** exports the persisted rich-text blocks as Markdown, preserving headings, emphasis, + links, images, lists, block quotes, code, and horizontal rules. +- **HTML** and **PDF** use the editor's print layout and omit editing chrome and presence UI. diff --git a/packages/workshop-backend/format-blueprints/workspace-docs/files/client.js b/packages/workshop-backend/format-blueprints/workspace-docs/files/client.js new file mode 100644 index 000000000..d6693481e --- /dev/null +++ b/packages/workshop-backend/format-blueprints/workspace-docs/files/client.js @@ -0,0 +1,1778 @@ +// --------------------------------------------------------------------------- +// Docs — a Google-Docs-style editor. Builds the entire UI in JS. +// --------------------------------------------------------------------------- + +const clientId = Math.random().toString(36).slice(2); +const isDocumentExport = ["html", "pdf"].includes(globalThis.gadgetExportFormatId); + +// --- Styles ---------------------------------------------------------------- +const style = document.createElement("style"); +style.textContent = ` +:root { + color-scheme: light; + --bg: #f6f6f4; + --surface: #ffffff; + --surface-2: #efefec; + --line: rgba(20,20,25,0.10); + --line-strong: rgba(20,20,25,0.18); + --text: #1d1d20; + --muted: #6b6b73; + --faint: #9a9aa2; + --accent: #e1632e; + --ok:#1f9d77; --warn:#b9842f; --bad:#c4566a; + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; height: 100%; + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, sans-serif; + font-size: 13.5px; + -webkit-font-smoothing: antialiased; +} + +::selection { background: rgba(225,99,46,0.22); } + +/* Thin scrollbars */ +* { scrollbar-width: thin; scrollbar-color: rgba(20,20,25,0.22) transparent; } +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-thumb { + background: rgba(20,20,25,0.22); border-radius: 10px; + border: 3px solid transparent; background-clip: content-box; +} +*::-webkit-scrollbar-track { background: transparent; } + +.app { display: flex; flex-direction: column; height: 100vh; } + +/* --- Top bar --------------------------------------------------------------*/ +.topbar { + display: flex; align-items: center; gap: 12px; + padding: 8px 16px; + background: var(--surface); + border-bottom: 1px solid var(--line); + flex: 0 0 auto; + contain: layout style; /* isolate from editor reflows */ +} +.title-wrap { display: flex; flex-direction: column; min-width: 0; } +.title-input { + appearance: none; background: transparent; border: 1px solid transparent; + color: var(--text); font-size: 15px; font-weight: 600; letter-spacing: -0.01em; + padding: 3px 7px; border-radius: 6px; width: min(46vw, 420px); + transition: border-color .14s var(--ease-out), background .14s var(--ease-out); +} +.title-input:hover { border-color: var(--line); } +.title-input:focus { outline: none; border-color: var(--line-strong); background: var(--bg); } +.status { + display: flex; align-items: center; gap: 6px; + font-size: 11px; letter-spacing: .03em; + color: var(--faint); flex: 0 0 auto; + opacity: .75; transition: opacity .2s var(--ease-out); +} +.status:hover { opacity: 1; } +.dot { width: 5px; height: 5px; border-radius: 50%; background: var(--faint); flex: 0 0 auto; } +.dot.saving { background: var(--warn); animation: pulse 1s infinite var(--ease-in-out); } +.dot.saved { background: var(--ok); } +.dot.bad { background: var(--bad); } +.dot.synced { background: var(--accent); animation: pulse .6s 2 var(--ease-in-out); } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } } + +.spacer { flex: 1 1 auto; } + +/* --- Toolbar --------------------------------------------------------------*/ +.toolbar { + display: flex; align-items: center; flex-wrap: nowrap; gap: 0; + padding: 6px 16px; + background: var(--surface); + border-bottom: 1px solid var(--line); + flex: 0 0 auto; + overflow-x: auto; /* last-resort scroll on very tiny widths */ + scrollbar-width: none; + contain: layout style; /* isolate from editor reflows */ +} +.toolbar::-webkit-scrollbar { display: none; } +.tgroup { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; } +.tdiv { width: 1px; height: 20px; background: var(--line); margin: 0 7px; flex: 0 0 auto; } + +/* Progressive collapse: drop lower-priority groups as width shrinks, so the + toolbar always stays on a single line. */ +@media (max-width: 1180px) { .toolbar .p3 { display: none; } } +@media (max-width: 980px) { .toolbar .p2 { display: none; } } +@media (max-width: 780px) { .toolbar .p1 { display: none; } } + +.icon-btn { + width: 28px; height: 28px; flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: 1px solid transparent; border-radius: 6px; + color: var(--muted); cursor: pointer; + transition: all .14s var(--ease-out); +} +.icon-btn:hover { background: var(--surface-2); border-color: var(--line); color: var(--text); } +.icon-btn:active { transform: scale(0.94); } +.icon-btn.active { background: rgba(225,99,46,0.12); border-color: rgba(225,99,46,0.35); color: var(--accent); } +.icon-btn svg { width: 16px; height: 16px; } +.icon-btn[disabled] { opacity: .4; pointer-events: none; } + +.cselect { + appearance: none; background: var(--surface); color: var(--text); + border: 1px solid var(--line); border-radius: 6px; + font-size: 12.5px; height: 28px; padding: 0 8px 0 10px; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; + transition: border-color .14s var(--ease-out), background .14s var(--ease-out); +} +.cselect:hover { border-color: var(--line-strong); background: var(--surface-2); } +.cselect:active { transform: scale(0.98); } +.cselect.open { border-color: var(--accent); background: var(--surface); } +.cs-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1 1 auto; text-align: left; } +.cs-chev { display: inline-flex; color: var(--muted); flex: 0 0 auto; transition: transform .14s var(--ease-out); } +.cs-chev svg { width: 12px; height: 12px; } +.cselect.open .cs-chev { transform: rotate(180deg); } +.cselect.style-sel { width: 116px; } +.cselect.font-sel { width: 132px; } +.cselect.size-sel { width: 66px; } + +.cmenu { + position: fixed; z-index: 1000; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: 8px; padding: 4px; + box-shadow: 0 10px 30px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.08); + max-height: 340px; overflow-y: auto; + animation: cmenu-in .12s var(--ease-out); +} +@keyframes cmenu-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } +.cmenu-item { + padding: 6px 10px; border-radius: 5px; font-size: 13px; color: var(--text); + cursor: pointer; white-space: nowrap; display: flex; align-items: center; + justify-content: space-between; gap: 18px; + transition: background .1s var(--ease-out); +} +.cmenu-item:hover { background: var(--surface-2); } +.cmenu-item.sel { color: var(--accent); } +.cmenu-item.sel::after { + content: ""; width: 13px; height: 13px; flex: 0 0 auto; + background: no-repeat center/contain url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e1632e' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); +} + +/* Color buttons */ +.color-btn { + position: relative; width: 28px; height: 28px; flex: 0 0 auto; + display: inline-flex; flex-direction: column; align-items: center; justify-content: center; + background: transparent; border: 1px solid transparent; border-radius: 6px; + color: var(--muted); cursor: pointer; transition: all .14s var(--ease-out); +} +.color-btn:hover { background: var(--surface-2); border-color: var(--line); color: var(--text); } +.color-btn:active { transform: scale(0.94); } +.color-btn svg { width: 15px; height: 15px; margin-top: -1px; } +.color-btn .bar { width: 16px; height: 3px; border-radius: 2px; margin-top: 1px; } +.color-btn input[type=color] { + position: absolute; inset: 0; opacity: 0; cursor: pointer; border: none; padding: 0; +} + +/* Segmented control for alignment */ +.segment { + display: inline-flex; gap: 2px; padding: 2px; + background: var(--surface-2); border: 1px solid var(--line); border-radius: 7px; +} +.segment .seg-btn { + width: 24px; height: 22px; display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: none; border-radius: 5px; color: var(--muted); + cursor: pointer; transition: all .14s var(--ease-out); +} +.segment .seg-btn svg { width: 15px; height: 15px; } +.segment .seg-btn.active { + background: var(--surface); color: var(--accent); + box-shadow: 0 1px 2px rgba(0,0,0,0.08); +} + +/* --- Canvas / page (pageless mode) ----------------------------------------*/ +/* One continuous bright-white writing surface that fills the available space, + Google-Docs "pageless" style. No floating page card or page breaks. */ +.canvas { + flex: 1 1 auto; overflow-y: auto; + background: var(--surface); +} + +.doc-page { + background: var(--surface); + color: var(--text); + width: 100%; + max-width: 900px; /* cap line length for comfortable reading */ + margin: 0 auto; + /* Generous side gutters that shrink gracefully on narrow screens. */ + padding: clamp(28px, 4vw, 56px) clamp(20px, 6vw, 80px) 200px; + min-height: 100%; + outline: none; + font-size: 16px; line-height: 1.6; +} + +/* Document typography */ +.doc-page > :first-child { margin-top: 0; } +.doc-page h1.doc-title { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 4px; } +.doc-page h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.01em; margin: 22px 0 8px; } +.doc-page h2 { font-size: 21px; font-weight: 600; letter-spacing: -0.01em; margin: 18px 0 6px; } +.doc-page h3 { font-size: 17px; font-weight: 600; margin: 16px 0 6px; } +.doc-page p { margin: 0 0 12px; } +.doc-page a { color: var(--accent); } +.doc-page ul, .doc-page ol { margin: 0 0 12px; padding-left: 28px; } +.doc-page li { margin: 2px 0; } +.doc-page blockquote { + margin: 0 0 12px; padding: 4px 16px; border-left: 3px solid var(--accent); + color: var(--muted); +} +.doc-page pre { + margin: 0 0 12px; padding: 14px 16px; background: #f3f3f1; + border: 1px solid var(--line); border-radius: 8px; overflow-x: auto; + font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13.5px; line-height: 1.5; +} +.doc-page hr { border: none; border-top: 1px solid var(--line); margin: 22px 0; } +.doc-page img.doc-image { + display: block; + max-width: 100%; + height: auto; + margin: 12px 0; + border-radius: 6px; + cursor: pointer; +} +.doc-page img.doc-image.image-selected { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.image-controls { + position: fixed; + z-index: 999; + pointer-events: none; + border: 2px solid var(--accent); + border-radius: 7px; + display: none; +} +.image-controls .resize-handle { + position: absolute; + right: -7px; + bottom: -7px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--surface); + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + cursor: nwse-resize; + pointer-events: auto; +} +.drop-target { + box-shadow: inset 0 0 0 3px rgba(225,99,46,0.28); +} +.doc-page:empty:before { + content: "Start writing…"; color: var(--faint); +} + +/* --- Link popover ---------------------------------------------------------*/ +.link-pop { + position: fixed; z-index: 1000; + display: flex; align-items: center; gap: 6px; + background: var(--surface); border: 1px solid var(--line-strong); + border-radius: 8px; padding: 5px 6px 5px 11px; + box-shadow: 0 10px 30px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.08); + font-size: 12.5px; max-width: 380px; + animation: cmenu-in .12s var(--ease-out); +} +.link-pop .lp-url { + color: var(--accent); text-decoration: none; + max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.link-pop .lp-url:hover { text-decoration: underline; } +.link-pop .lp-div { width: 1px; height: 18px; background: var(--line); margin: 0 2px; flex: 0 0 auto; } +.link-pop .lp-btn { + appearance: none; background: transparent; border: 1px solid transparent; + border-radius: 6px; color: var(--muted); cursor: pointer; + font-size: 12px; padding: 4px 9px; white-space: nowrap; + transition: all .14s var(--ease-out); +} +.link-pop .lp-btn:hover { background: var(--surface-2); border-color: var(--line); color: var(--text); } +.link-pop .lp-btn.lp-danger:hover { background: rgba(196,86,106,0.12); border-color: rgba(196,86,106,0.35); color: var(--bad); } + +@media (max-width: 720px) { + .title-input { width: 42vw; } + .topbar { padding: 8px 12px; } + .toolbar { padding: 6px 12px; } +} + +html.document-export, html.document-export body { + height: auto; background: #fff; overflow: visible; +} +html.document-export .app { display: block; height: auto; } +html.document-export .canvas { overflow: visible; } +html.document-export .doc-page { + max-width: none; + min-height: 0; + padding: 0; + font-size: 11pt; + line-height: 1.5; +} +html.document-export .doc-page h1, +html.document-export .doc-page h2, +html.document-export .doc-page h3 { break-after: avoid-page; } +html.document-export .doc-page img, +html.document-export .doc-page pre, +html.document-export .doc-page blockquote { break-inside: avoid-page; } +html.document-export .doc-page:empty::before { content: none; } +html.document-export .doc-page img.doc-image { cursor: default; } +html.document-export .doc-page img.doc-image.image-selected { outline: none; } +@media screen { + html.document-export body { padding: 0.65in; } + html.document-export .app { max-width: 7.2in; margin: 0 auto; } +} + +@page { margin: 0.65in; } +@media print { + html, body { height: auto; background: #fff; overflow: visible; } + .app { display: block; height: auto; } + .topbar, .toolbar, .image-controls, .link-pop, .cmenu { display: none !important; } + .canvas { overflow: visible; } + .doc-page { + max-width: none; + min-height: 0; + padding: 0; + font-size: 11pt; + line-height: 1.5; + } + .doc-page h1, .doc-page h2, .doc-page h3 { break-after: avoid-page; } + .doc-page img, .doc-page pre, .doc-page blockquote { break-inside: avoid-page; } + .doc-page:empty::before { content: none; } + .doc-page img.doc-image { cursor: default; } + .doc-page img.doc-image.image-selected { outline: none; } +} +`; +document.head.appendChild(style); + +// --- Icon helper ----------------------------------------------------------- +function icon(paths) { + return `${paths}`; +} +const ICONS = { + undo: '', + redo: '', + bold: '', + italic: '', + underline: '', + strike: '', + textcolor: '', + highlight: '', + alignLeft: '', + alignCenter: '', + alignRight: '', + alignJustify: '', + ul: '', + ol: '', + outdent: '', + indent: '', + link: '', + hr: '', + clear: '', + image: '', +}; + +// --- DOM helpers ----------------------------------------------------------- +function el(tag, props = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (k === "class") node.className = v; + else if (k === "html") node.innerHTML = v; + else if (k.startsWith("on")) node.addEventListener(k.slice(2).toLowerCase(), v); + else if (v !== null && v !== undefined) node.setAttribute(k, v); + } + for (const c of [].concat(children)) { + if (c) node.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + } + return node; +} + +// --- Build UI -------------------------------------------------------------- +const editor = el("div", { class: "doc-page", contenteditable: "true", spellcheck: "true" }); + +const titleInput = el("input", { class: "title-input", value: "Untitled document", "aria-label": "Document title" }); +const statusDot = el("span", { class: "dot saved" }); +const statusText = el("span", {}, "Saved"); + +const topbar = el("div", { class: "topbar" }, [ + el("div", { class: "title-wrap" }, [titleInput]), + el("div", { class: "spacer" }), + el("div", { class: "status", title: "Save status" }, [statusDot, statusText]), +]); + +// Toolbar +function iconBtn(name, title, onClick) { + const b = el("button", { class: "icon-btn", title, html: icon(ICONS[name]) }); + b.addEventListener("mousedown", (e) => e.preventDefault()); + b.addEventListener("click", onClick); + return b; +} + +// A toolbar group. `prio` (p1/p2/p3 or null) controls when it collapses on +// narrow screens — null groups always stay visible. The leading divider is +// part of the group so it hides together with the group. +function group(prio, items, first = false) { + const children = first ? [] : [el("div", { class: "tdiv" })]; + children.push(...items); + return el("div", { class: "tgroup" + (prio ? " " + prio : "") }, children); +} + +// Custom dropdown matching the app's design. `options` is [{value,label,style?}]. +const chevSvg = icon(''); +function customSelect({ className, title, options, value, onChange }) { + let current; + const labelSpan = el("span", { class: "cs-label" }); + const btn = el("button", { type: "button", class: "cselect " + (className || ""), title }, [ + labelSpan, el("span", { class: "cs-chev", html: chevSvg }), + ]); + const menu = el("div", { class: "cmenu" }); + const items = options.map((o) => { + const item = el("div", { class: "cmenu-item", "data-value": String(o.value) }, o.label); + if (o.style) item.style.cssText += o.style; + item.addEventListener("mousedown", (e) => e.preventDefault()); + item.addEventListener("click", () => { closeMenu(); setValue(o.value); onChange(o.value); }); + menu.appendChild(item); + return item; + }); + let open = false; + + function setValue(v) { + if (v === current) return; // skip redundant DOM work on hot path + current = v; + const opt = options.find((o) => o.value === v) || options[0]; + labelSpan.textContent = opt ? opt.label : ""; + items.forEach((it) => it.classList.toggle("sel", it.dataset.value === String(v))); + } + function openMenu() { + const r = btn.getBoundingClientRect(); + menu.style.left = Math.round(r.left) + "px"; + menu.style.top = Math.round(r.bottom + 4) + "px"; + menu.style.minWidth = Math.round(r.width) + "px"; + document.body.appendChild(menu); + open = true; btn.classList.add("open"); + } + function closeMenu() { + if (menu.parentNode) menu.parentNode.removeChild(menu); + open = false; btn.classList.remove("open"); + } + btn.addEventListener("mousedown", (e) => { e.preventDefault(); savedRange = getRange(); }); + btn.addEventListener("click", () => { open ? closeMenu() : openMenu(); }); + document.addEventListener("mousedown", (e) => { + if (open && !menu.contains(e.target) && !btn.contains(e.target)) closeMenu(); + }); + window.addEventListener("scroll", () => { if (open) closeMenu(); }, true); + window.addEventListener("resize", () => { if (open) closeMenu(); }); + + setValue(value); + return { el: btn, setValue, getValue: () => current }; +} + +// Style selector +const styleSel = customSelect({ + className: "style-sel", title: "Paragraph style", + options: [ + { value: "P", label: "Normal text" }, + { value: "TITLE", label: "Title" }, + { value: "H1", label: "Heading 1" }, + { value: "H2", label: "Heading 2" }, + { value: "H3", label: "Heading 3" }, + { value: "BLOCKQUOTE", label: "Quote" }, + { value: "PRE", label: "Code block" }, + ], + value: "P", + onChange: (value) => { + restoreRange(); + if (value === "TITLE") { + document.execCommand("formatBlock", false, "H1"); + const block = currentBlock(); + if (block && block.tagName === "H1") block.classList.add("doc-title"); + } else { + const block = currentBlock(); + if (block) block.classList.remove("doc-title"); + document.execCommand("formatBlock", false, value); + } + editor.focus(); + scheduleSave(); + }, +}); + +// Font family +const fontSel = customSelect({ + className: "font-sel", title: "Font", + options: [ + ["Sans serif", "ui-sans-serif, system-ui, Inter, sans-serif"], + ["Serif", "Georgia, 'Times New Roman', serif"], + ["Mono", "ui-monospace, 'SF Mono', Menlo, monospace"], + ["Inter", "Inter, system-ui, sans-serif"], + ["Georgia", "Georgia, serif"], + ["Courier", "'Courier New', monospace"], + ].map(([label, val]) => ({ value: val, label, style: "font-family:" + val + ";" })), + value: "ui-sans-serif, system-ui, Inter, sans-serif", + onChange: (value) => { + restoreRange(); + document.execCommand("fontName", false, value); + editor.focus(); + scheduleSave(); + }, +}); + +// Font size +const sizeSel = customSelect({ + className: "size-sel", title: "Font size", + options: [11, 12, 13, 14, 16, 18, 20, 24, 28, 32, 40, 48].map((s) => ({ value: s, label: String(s) })), + value: 16, + onChange: (value) => { + restoreRange(); + applyFontSize(parseInt(value, 10)); + editor.focus(); + scheduleSave(); + }, +}); + +function applyFontSize(px) { + document.execCommand("fontSize", false, "7"); + editor.querySelectorAll('font[size="7"]').forEach((f) => { + f.removeAttribute("size"); + f.style.fontSize = px + "px"; + }); +} + +// Simple command buttons +function cmdBtn(name, title, command, value = null) { + return iconBtn(name, title, () => { + document.execCommand(command, false, value); + editor.focus(); + refreshToolbarState(); + scheduleSave(); + }); +} + +const boldBtn = cmdBtn("bold", "Bold (Ctrl+B)", "bold"); +const italicBtn = cmdBtn("italic", "Italic (Ctrl+I)", "italic"); +const underlineBtn = cmdBtn("underline", "Underline (Ctrl+U)", "underline"); +const strikeBtn = cmdBtn("strike", "Strikethrough", "strikeThrough"); + +// Color buttons (text + highlight) +function colorBtn(name, title, command, defaultColor) { + const bar = el("span", { class: "bar" }); + bar.style.background = defaultColor; + const input = el("input", { type: "color", value: defaultColor }); + const btn = el("div", { class: "color-btn", title }, [ + el("span", { html: icon(ICONS[name]) }), + bar, + input, + ]); + btn.addEventListener("mousedown", (e) => { e.preventDefault(); savedRange = getRange(); }); + input.addEventListener("input", () => { + bar.style.background = input.value; + restoreRange(); + document.execCommand(command, false, input.value); + editor.focus(); + scheduleSave(); + }); + return btn; +} +const textColorBtn = colorBtn("textcolor", "Text color", "foreColor", "#1d1d20"); +const highlightBtn = colorBtn("highlight", "Highlight color", "hiliteColor", "#fff3a3"); + +// Alignment segmented control +const alignBtns = {}; +function segBtn(name, title, command) { + const b = el("button", { class: "seg-btn", title, html: icon(ICONS[name]) }); + b.addEventListener("mousedown", (e) => e.preventDefault()); + b.addEventListener("click", () => { + document.execCommand(command, false, null); + editor.focus(); + refreshToolbarState(); + scheduleSave(); + }); + return b; +} +alignBtns.left = segBtn("alignLeft", "Align left", "justifyLeft"); +alignBtns.center = segBtn("alignCenter", "Align center", "justifyCenter"); +alignBtns.right = segBtn("alignRight", "Align right", "justifyRight"); +alignBtns.justify = segBtn("alignJustify", "Justify", "justifyFull"); +const alignSegment = el("div", { class: "segment" }, [alignBtns.left, alignBtns.center, alignBtns.right, alignBtns.justify]); + +const ulBtn = cmdBtn("ul", "Bulleted list", "insertUnorderedList"); +const olBtn = cmdBtn("ol", "Numbered list", "insertOrderedList"); +const outdentBtn = cmdBtn("outdent", "Decrease indent", "outdent"); +const indentBtn = cmdBtn("indent", "Increase indent", "indent"); + +const linkBtn = iconBtn("link", "Insert link", () => insertLink()); +const imageInput = el("input", { type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", multiple: "true" }); +imageInput.style.display = "none"; +document.body.appendChild(imageInput); +const imageBtn = iconBtn("image", "Insert image", () => { + savedRange = getRange(); + imageInput.value = ""; + imageInput.click(); +}); +imageInput.addEventListener("change", () => insertImageFiles(Array.from(imageInput.files || []))); +const hrBtn = cmdBtn("hr", "Horizontal line", "insertHorizontalRule"); +const clearBtn = iconBtn("clear", "Clear formatting", () => { + document.execCommand("removeFormat", false, null); + const block = currentBlock(); + if (block) block.classList.remove("doc-title"); + document.execCommand("formatBlock", false, "P"); + editor.focus(); + refreshToolbarState(); + scheduleSave(); +}); + +const undoBtn = iconBtn("undo", "Undo (Ctrl+Z)", () => { document.execCommand("undo"); editor.focus(); scheduleSave(); }); +const redoBtn = iconBtn("redo", "Redo (Ctrl+Y)", () => { document.execCommand("redo"); editor.focus(); scheduleSave(); }); + +const toolbar = el("div", { class: "toolbar" }, [ + group(null, [undoBtn, redoBtn], true), // always + group(null, [styleSel.el]), // always + group("p2", [fontSel.el, sizeSel.el]), + group(null, [boldBtn, italicBtn, underlineBtn, strikeBtn]), // always + group("p2", [textColorBtn, highlightBtn]), + group("p1", [alignSegment]), + group("p1", [ulBtn, olBtn]), + group("p3", [outdentBtn, indentBtn]), + group("p2", [linkBtn, imageBtn]), + group("p3", [hrBtn, clearBtn]), +]); + +const canvas = el("div", { class: "canvas" }, [editor]); +const app = el("div", { class: "app" }, [topbar, toolbar, canvas]); +document.body.appendChild(app); + +// --- Selection helpers ----------------------------------------------------- +let savedRange = null; +function getRange() { + const sel = window.getSelection(); + if (sel && sel.rangeCount && editor.contains(sel.anchorNode)) return sel.getRangeAt(0).cloneRange(); + return null; +} +function restoreRange() { + if (!savedRange) return; + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(savedRange); +} +function currentBlock() { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return null; + let node = sel.anchorNode; + while (node && node !== editor) { + if (node.nodeType === 1 && /^(P|H1|H2|H3|H4|BLOCKQUOTE|PRE|LI|DIV)$/.test(node.tagName)) return node; + node = node.parentNode; + } + return null; +} + +// The element containing the current selection, if any. +function currentLink() { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return null; + let node = sel.anchorNode; + while (node && node !== editor) { + if (node.nodeType === 1 && node.tagName === "A") return node; + node = node.parentNode; + } + return null; +} + +function selectNode(node) { + const range = document.createRange(); + range.selectNode(node); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); +} + +function normalizeHref(url) { + let href = (url || "").trim(); + if (href && !/^(https?:|mailto:|tel:|#|\/)/i.test(href)) href = "https://" + href; + return href; +} + +function escapeAttr(s) { + return String(s || "").replace(/&/g, "&").replace(/"/g, """).replace(/ { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("Could not load image")); + img.src = src; + }); +} + +function readFileAsDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error || new Error("Could not read image")); + reader.readAsDataURL(file); + }); +} + +async function fileToImageDataUrl(file) { + const original = await readFileAsDataURL(file); + const img = await loadImage(original); + let width = img.naturalWidth || img.width; + let height = img.naturalHeight || img.height; + const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(width, height)); + width = Math.max(1, Math.round(width * scale)); + height = Math.max(1, Math.round(height * scale)); + + // GIFs may be animated; canvas would flatten them. Preserve reasonably-sized + // GIFs, otherwise make a static optimized preview so storage stays sane. + if (file.type === "image/gif" && scale === 1 && file.size < 2_000_000) { + return { src: original, width, height, alt: file.name || "Image" }; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width, height); + let src; + try { + src = canvas.toDataURL("image/webp", IMAGE_QUALITY); + if (!src.startsWith("data:image/webp")) throw new Error("WebP unsupported"); + } catch (e) { + src = canvas.toDataURL("image/jpeg", IMAGE_QUALITY); + } + return { src, width, height, alt: file.name || "Image" }; +} + +function insertImageDataUrl({ src, width, alt }) { + if (!isSafeImageDataUrl(src)) return; + restoreRange(); + const displayWidth = Math.min(width || 520, Math.max(240, editor.clientWidth - 40)); + const html = `${escapeAttr(alt || `; + document.execCommand("insertHTML", false, html); + savedRange = getRange(); +} + +async function insertImageFiles(files) { + const imageFiles = files.filter(isImageFile); + if (!imageFiles.length) return; + hideLinkPopover(); + hideImageControls(); + setStatus("saving", "Processing image…"); + try { + for (const file of imageFiles) { + const data = await fileToImageDataUrl(file); + insertImageDataUrl(data); + } + editor.focus(); + refreshToolbarState(); + scheduleSave(); + } catch (e) { + setStatus("bad", "Image failed"); + } +} + +function imageFilesFromDataTransfer(dt) { + if (!dt) return []; + const files = Array.from(dt.files || []).filter(isImageFile); + if (files.length) return files; + return Array.from(dt.items || []) + .filter((item) => item.kind === "file" && IMAGE_TYPES.has(item.type)) + .map((item) => item.getAsFile()) + .filter(isImageFile); +} + +function setCaretFromPoint(x, y) { + let range = null; + if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(x, y); + } else if (document.caretPositionFromPoint) { + const pos = document.caretPositionFromPoint(x, y); + if (pos) { + range = document.createRange(); + range.setStart(pos.offsetNode, pos.offset); + range.collapse(true); + } + } + if (!range || !editor.contains(range.startContainer)) { + range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + } + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + savedRange = range.cloneRange(); +} + +let selectedImage = null; +let resizingImage = false; +let draggedImage = null; +const imageControls = el("div", { class: "image-controls" }, [el("div", { class: "resize-handle" })]); +document.body.appendChild(imageControls); +const resizeHandle = imageControls.querySelector(".resize-handle"); + +function positionImageControls() { + if (!selectedImage || !editor.contains(selectedImage) || resizingImage) return; + const r = selectedImage.getBoundingClientRect(); + imageControls.style.left = Math.round(r.left) + "px"; + imageControls.style.top = Math.round(r.top) + "px"; + imageControls.style.width = Math.round(r.width) + "px"; + imageControls.style.height = Math.round(r.height) + "px"; + imageControls.style.display = "block"; +} + +function selectImage(img) { + if (selectedImage === img) { + positionImageControls(); + return; + } + hideLinkPopover(); + if (selectedImage) selectedImage.classList.remove("image-selected"); + selectedImage = img; + selectedImage.classList.add("image-selected"); + positionImageControls(); +} + +function hideImageControls() { + if (selectedImage) selectedImage.classList.remove("image-selected"); + selectedImage = null; + imageControls.style.display = "none"; +} + +resizeHandle.addEventListener("mousedown", (e) => { + if (!selectedImage) return; + e.preventDefault(); + e.stopPropagation(); + resizingImage = true; + const img = selectedImage; + const startX = e.clientX; + const startWidth = img.getBoundingClientRect().width; + const editorWidth = editor.getBoundingClientRect().width; + imageControls.style.display = "none"; + + const move = (ev) => { + const next = Math.max(80, Math.min(editorWidth, startWidth + ev.clientX - startX)); + img.style.width = Math.round(next) + "px"; + img.style.height = "auto"; + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + resizingImage = false; + positionImageControls(); + scheduleSave(); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); +}); + +editor.addEventListener("click", (e) => { + const img = e.target.closest && e.target.closest("img.doc-image"); + if (img && editor.contains(img)) selectImage(img); + else hideImageControls(); +}); + +editor.addEventListener("dragstart", (e) => { + const img = e.target.closest && e.target.closest("img.doc-image"); + if (!img || !editor.contains(img)) return; + draggedImage = img; + selectImage(img); + hideImageControls(); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + // Mark this as an internal move. Without this, contenteditable/browser + // defaults expose the image as HTML and our drop sanitizer inserts a copy. + e.dataTransfer.setData("application/x-doc-image-move", "1"); + e.dataTransfer.setData("text/plain", ""); + try { e.dataTransfer.setDragImage(img, Math.min(20, img.width / 2), Math.min(20, img.height / 2)); } catch (err) {} + } +}); + +editor.addEventListener("dragend", () => { + draggedImage = null; + editor.classList.remove("drop-target"); + if (selectedImage) positionImageControls(); +}); + +editor.addEventListener("dragover", (e) => { + const types = Array.from((e.dataTransfer && e.dataTransfer.types) || []); + if (draggedImage || imageFilesFromDataTransfer(e.dataTransfer).length || types.includes("Files") || types.includes("text/html") || types.includes("text/plain")) { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = draggedImage ? "move" : "copy"; + editor.classList.add("drop-target"); + } +}); +editor.addEventListener("dragleave", () => editor.classList.remove("drop-target")); +editor.addEventListener("drop", (e) => { + editor.classList.remove("drop-target"); + + // Internal image moves must be handled before file/html drops. Otherwise the + // browser's contenteditable drag payload looks like pasted HTML and creates a + // duplicate image instead of moving the original. + if (draggedImage && editor.contains(draggedImage)) { + e.preventDefault(); + const img = draggedImage; + draggedImage = null; + setCaretFromPoint(e.clientX, e.clientY); + const range = getRange(); + if (range) { + range.insertNode(img); // insertNode moves an existing node; it doesn't clone. + const after = document.createRange(); + after.setStartAfter(img); + after.collapse(true); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(after); + savedRange = after.cloneRange(); + } + selectImage(img); + scheduleSave(); + return; + } + + const files = imageFilesFromDataTransfer(e.dataTransfer); + if (files.length) { + e.preventDefault(); + setCaretFromPoint(e.clientX, e.clientY); + insertImageFiles(files); + return; + } + // Sanitize HTML/text drops too, so dragging content from another document or + // chat app doesn't bypass the paste sanitizer. + const html = e.dataTransfer && e.dataTransfer.getData("text/html"); + const text = e.dataTransfer && e.dataTransfer.getData("text/plain"); + if ((html && html.trim()) || text) { + e.preventDefault(); + setCaretFromPoint(e.clientX, e.clientY); + if (html && html.trim()) document.execCommand("insertHTML", false, sanitizePastedHtml(html)); + else document.execCommand("insertHTML", false, escapeText(text).replace(/\r?\n/g, "
")); + refreshToolbarState(); + scheduleSave(); + } +}); + +window.addEventListener("scroll", () => { if (selectedImage) positionImageControls(); }, true); +window.addEventListener("resize", () => { if (selectedImage) positionImageControls(); }); + +function sanitizeImageElement(srcImg) { + const src = srcImg.getAttribute("src") || ""; + // Persist only embedded images. External/blob URLs are not reliable after + // reload in the Gadget sandbox, and blob: URLs vanish immediately. + if (!isSafeImageDataUrl(src)) return null; + const img = document.createElement("img"); + img.className = "doc-image"; + img.draggable = true; + img.src = src; + img.alt = srcImg.getAttribute("alt") || "Image"; + const styleWidth = (srcImg.getAttribute("style") || "").match(/width\s*:\s*([0-9.]+)px/i); + const attrWidth = parseInt(srcImg.getAttribute("width") || "", 10); + const width = styleWidth ? parseFloat(styleWidth[1]) : attrWidth; + if (width && width > 0) img.style.width = Math.min(900, Math.max(40, Math.round(width))) + "px"; + img.style.height = "auto"; + return img; +} + +// The link button: edit the link under the cursor if there is one, else create. +async function insertLink() { + const existing = currentLink(); + if (existing) return editLink(existing); + savedRange = getRange(); + const url = await promptInline("Enter URL:"); + if (!url) return; + restoreRange(); + document.execCommand("createLink", false, normalizeHref(url)); + editor.focus(); + scheduleSave(); +} + +async function editLink(anchor) { + const url = await promptInline("Edit URL:", anchor.getAttribute("href") || ""); + if (url === null) return; + selectNode(anchor); + if (url.trim() === "") { + document.execCommand("unlink", false, null); + } else { + document.execCommand("createLink", false, normalizeHref(url)); + } + hideLinkPopover(); + editor.focus(); + scheduleSave(); +} + +// Strip the link, keeping its text. +function removeLink(anchor) { + selectNode(anchor); + document.execCommand("unlink", false, null); + hideLinkPopover(); + editor.focus(); + scheduleSave(); +} + +// --- Link popover (Google-Docs style) -------------------------------------- +let activeLink = null; +const linkPopUrl = el("a", { class: "lp-url", target: "_blank", rel: "noopener noreferrer" }); +const linkPopEdit = el("button", { class: "lp-btn" }, "Edit"); +const linkPopRemove = el("button", { class: "lp-btn lp-danger" }, "Remove link"); +const linkPop = el("div", { class: "link-pop" }, [ + linkPopUrl, el("span", { class: "lp-div" }), linkPopEdit, linkPopRemove, +]); +linkPop.style.display = "none"; +// Don't let clicks inside the popover collapse the editor selection. +linkPop.addEventListener("mousedown", (e) => { if (e.target !== linkPopUrl) e.preventDefault(); }); +linkPopEdit.addEventListener("click", () => { if (activeLink) editLink(activeLink); }); +linkPopRemove.addEventListener("click", () => { if (activeLink) removeLink(activeLink); }); +document.body.appendChild(linkPop); + +function positionLinkPopover(anchor) { + const r = anchor.getBoundingClientRect(); + linkPop.style.visibility = "hidden"; + linkPop.style.display = "flex"; + const pw = linkPop.offsetWidth, ph = linkPop.offsetHeight; + let left = Math.round(r.left); + left = Math.max(8, Math.min(left, window.innerWidth - pw - 8)); + let top = Math.round(r.bottom + 6); + if (top + ph > window.innerHeight - 8) top = Math.round(r.top - ph - 6); + linkPop.style.left = left + "px"; + linkPop.style.top = top + "px"; + linkPop.style.visibility = "visible"; +} + +function showLinkPopover(anchor) { + activeLink = anchor; + const href = anchor.getAttribute("href") || ""; + linkPopUrl.textContent = href.replace(/^mailto:/i, ""); + linkPopUrl.setAttribute("href", href); + positionLinkPopover(anchor); +} + +function hideLinkPopover() { + activeLink = null; + linkPop.style.display = "none"; +} + +function updateLinkPopover() { + const a = currentLink(); + if (a && editor.contains(a)) showLinkPopover(a); + else hideLinkPopover(); +} + +window.addEventListener("scroll", () => { if (activeLink) positionLinkPopover(activeLink); }, true); +window.addEventListener("resize", () => { if (activeLink) positionLinkPopover(activeLink); }); + +// Inline prompt (alert/prompt are blocked in the sandbox iframe) +function promptInline(message, def = "") { + return new Promise((resolve) => { + const overlay = el("div", {}, []); + Object.assign(overlay.style, { + position: "fixed", inset: "0", display: "flex", alignItems: "center", + justifyContent: "center", background: "rgba(20,20,25,0.35)", + backdropFilter: "blur(5px)", zIndex: "1000", + }); + const input = el("input", { value: def, placeholder: "https://" }); + Object.assign(input.style, { + width: "100%", padding: "8px 10px", fontSize: "13.5px", + border: "1px solid var(--line-strong)", borderRadius: "6px", + background: "var(--bg)", color: "var(--text)", outline: "none", + }); + const ok = el("button", {}, "Insert"); + const cancel = el("button", {}, "Cancel"); + for (const b of [ok, cancel]) Object.assign(b.style, { + padding: "6px 12px", fontSize: "13px", borderRadius: "6px", + border: "1px solid var(--line)", background: "var(--surface)", + color: "var(--text)", cursor: "pointer", + }); + Object.assign(ok.style, { background: "var(--accent)", color: "#fff", borderColor: "var(--accent)" }); + const card = el("div", {}, [ + el("div", { html: message }), input, + el("div", {}, [cancel, ok]), + ]); + Object.assign(card.style, { + background: "var(--surface)", border: "1px solid var(--line)", borderRadius: "10px", + padding: "16px", width: "min(420px, 90vw)", display: "flex", flexDirection: "column", + gap: "12px", boxShadow: "0 12px 40px rgba(0,0,0,0.25)", + }); + card.lastChild.style.display = "flex"; + card.lastChild.style.justifyContent = "flex-end"; + card.lastChild.style.gap = "8px"; + card.firstChild.style.fontSize = "13px"; + card.firstChild.style.color = "var(--muted)"; + overlay.appendChild(card); + document.body.appendChild(overlay); + input.focus(); + const done = (val) => { overlay.remove(); resolve(val); }; + ok.addEventListener("click", () => done(input.value)); + cancel.addEventListener("click", () => done(null)); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) done(null); }); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") done(input.value); + if (e.key === "Escape") done(null); + }); + }); +} + +// --- Paste sanitizer ------------------------------------------------------- +// Pasted content (especially from Google Docs / Word) carries formatting in +// inline `style` attributes and non-semantic / wrappers — which +// the toolbar commands (bold/underline/link…) can't toggle or remove. We +// rebuild pasted HTML into clean semantic markup so it behaves like text the +// editor created itself. +const INLINE_ONLY = ["code", "s", "u", "i", "b"]; +function wrapEl(tag, child) { const e = document.createElement(tag); e.appendChild(child); return e; } + +function fmtOf(elem, ctx) { + const cs = ((elem.getAttribute && elem.getAttribute("style")) || "").toLowerCase(); + const tag = elem.tagName; + const n = Object.assign({}, ctx); + if (tag === "B" || tag === "STRONG") n.bold = true; + if (tag === "I" || tag === "EM") n.italic = true; + if (tag === "U" || tag === "INS") n.underline = true; + if (tag === "S" || tag === "STRIKE" || tag === "DEL") n.strike = true; + if (tag === "CODE" || tag === "TT") n.code = true; + if (tag === "A") n.link = elem.getAttribute("href") || ctx.link; + // Inline styles override tag defaults (Google wraps everything in + // , so we must honor the style). + if (/font-weight:\s*(bold|[6-9]00)/.test(cs)) n.bold = true; + else if (/font-weight:\s*(normal|[1-4]00)/.test(cs)) n.bold = false; + if (/font-style:\s*italic/.test(cs)) n.italic = true; + else if (/font-style:\s*normal/.test(cs)) n.italic = false; + if (/text-decoration[^;]*underline/.test(cs)) n.underline = true; + if (/text-decoration[^;]*line-through/.test(cs)) n.strike = true; + return n; +} + +function wrapInline(text, ctx) { + let node = document.createTextNode(text); + if (ctx.code) node = wrapEl("code", node); + if (ctx.strike) node = wrapEl("s", node); + if (ctx.underline) node = wrapEl("u", node); + if (ctx.italic) node = wrapEl("i", node); + if (ctx.bold) node = wrapEl("b", node); + if (ctx.link) { + let href = ctx.link.trim(); + if (href && !/^(https?:|mailto:|tel:|#|\/)/i.test(href)) href = "https://" + href; + const a = document.createElement("a"); + a.setAttribute("href", href); + a.appendChild(node); + node = a; + } + return node; +} + +const BLOCK_TAGS = ["P", "H1", "H2", "H3", "H4", "H5", "H6", "BLOCKQUOTE", "PRE", "UL", "OL", "LI", "DIV"]; + +function sanitizePastedHtml(html) { + const parsed = new DOMParser().parseFromString(html, "text/html"); + const result = document.createElement("div"); + + function appendInline(src, target, ctx) { + src.childNodes.forEach((child) => { + if (child.nodeType === 3) { + if (child.textContent) target.appendChild(wrapInline(child.textContent, ctx)); + } else if (child.nodeType === 1) { + const tag = child.tagName; + if (tag === "BR") target.appendChild(document.createElement("br")); + else if (tag === "IMG") { + const img = sanitizeImageElement(child); + if (img) target.appendChild(img); + } + else if (tag === "HR" || tag === "STYLE" || tag === "SCRIPT") return; + else appendInline(child, target, fmtOf(child, ctx)); + } + }); + } + + function processList(src, ctx) { + const list = document.createElement(src.tagName === "OL" ? "ol" : "ul"); + src.childNodes.forEach((child) => { + if (child.nodeType !== 1) return; + if (child.tagName === "LI") { + const li = document.createElement("li"); + const nested = []; + child.childNodes.forEach((g) => { + if (g.nodeType === 1 && (g.tagName === "UL" || g.tagName === "OL")) nested.push(g); + else if (g.nodeType === 3) { if (g.textContent) li.appendChild(wrapInline(g.textContent, ctx)); } + else if (g.nodeType === 1) appendInline(g, li, fmtOf(g, ctx)); + }); + nested.forEach((n) => li.appendChild(processList(n, ctx))); + list.appendChild(li); + } else if (child.tagName === "UL" || child.tagName === "OL") { + list.appendChild(processList(child, ctx)); + } + }); + return list; + } + + function processNodes(nodes, ctx) { + nodes.forEach((node) => { + if (node.nodeType === 3) { + if (node.textContent && node.textContent.trim()) { + const p = document.createElement("p"); + p.appendChild(wrapInline(node.textContent, ctx)); + result.appendChild(p); + } + return; + } + if (node.nodeType !== 1) return; + const tag = node.tagName; + if (tag === "STYLE" || tag === "SCRIPT" || tag === "META" || tag === "BR") return; + if (tag === "HR") { result.appendChild(document.createElement("hr")); return; } + if (tag === "IMG") { + const img = sanitizeImageElement(node); + if (img) { + const p = document.createElement("p"); + p.appendChild(img); + result.appendChild(p); + } + return; + } + if (tag === "UL" || tag === "OL") { result.appendChild(processList(node, ctx)); return; } + if (["P", "H1", "H2", "H3", "H4", "H5", "H6", "BLOCKQUOTE", "PRE"].includes(tag)) { + let out = tag.toLowerCase(); + if (out === "h4" || out === "h5" || out === "h6") out = "h3"; + const block = document.createElement(out); + appendInline(node, block, ctx); + if (block.textContent.trim() || block.querySelector("br")) result.appendChild(block); + return; + } + // Wrapper (DIV/SPAN/FONT/B-wrapper…): descend if it holds blocks, + // otherwise treat its inline content as a paragraph. + const newCtx = fmtOf(node, ctx); + const hasBlockChild = Array.from(node.childNodes).some( + (c) => c.nodeType === 1 && BLOCK_TAGS.includes(c.tagName)); + if (hasBlockChild) { + processNodes(Array.from(node.childNodes), newCtx); + } else { + const p = document.createElement("p"); + appendInline(node, p, newCtx); + if (p.textContent.trim() || p.querySelector("br")) result.appendChild(p); + } + }); + } + + processNodes(Array.from(parsed.body.childNodes), {}); + return result.innerHTML; +} + +function escapeText(t) { + return t.replace(/&/g, "&").replace(//g, ">"); +} + + +editor.addEventListener("paste", (e) => { + const cb = e.clipboardData; + if (!cb) return; + const imageFiles = imageFilesFromDataTransfer(cb); + if (imageFiles.length) { + e.preventDefault(); + savedRange = getRange(); + insertImageFiles(imageFiles); + return; + } + e.preventDefault(); + const html = cb.getData("text/html"); + if (html && html.trim()) { + document.execCommand("insertHTML", false, sanitizePastedHtml(html)); + } else { + const text = cb.getData("text/plain") || ""; + document.execCommand("insertHTML", false, escapeText(text).replace(/\r?\n/g, "
")); + } + refreshToolbarState(); + scheduleSave(); +}); + +// --- Toolbar live state ---------------------------------------------------- +function refreshToolbarState() { + const set = (btn, on) => btn.classList.toggle("active", on); + try { + set(boldBtn, document.queryCommandState("bold")); + set(italicBtn, document.queryCommandState("italic")); + set(underlineBtn, document.queryCommandState("underline")); + set(strikeBtn, document.queryCommandState("strikeThrough")); + set(ulBtn, document.queryCommandState("insertUnorderedList")); + set(olBtn, document.queryCommandState("insertOrderedList")); + alignBtns.left.classList.toggle("active", document.queryCommandState("justifyLeft")); + alignBtns.center.classList.toggle("active", document.queryCommandState("justifyCenter")); + alignBtns.right.classList.toggle("active", document.queryCommandState("justifyRight")); + alignBtns.justify.classList.toggle("active", document.queryCommandState("justifyFull")); + } catch (e) {} + // Style selector + const block = currentBlock(); + if (block) { + let tag = block.tagName; + if (tag === "LI" || tag === "DIV") tag = "P"; + if (tag === "H1" && block.classList.contains("doc-title")) tag = "TITLE"; + styleSel.setValue(["P", "TITLE", "H1", "H2", "H3", "BLOCKQUOTE", "PRE"].includes(tag) ? tag : "P"); + } +} + +// selectionchange fires on every keystroke and cursor move. Coalesce the work +// (layout-forcing queryCommandState calls, DOM walks, popover positioning) into +// a single rAF so a burst collapses to at most one refresh per frame. +let selUpdateQueued = false; +function scheduleSelectionUpdate() { + if (selUpdateQueued) return; + selUpdateQueued = true; + requestAnimationFrame(() => { + selUpdateQueued = false; + refreshToolbarState(); + updateLinkPopover(); + }); +} +document.addEventListener("selectionchange", () => { + if (editor.contains(window.getSelection().anchorNode)) scheduleSelectionUpdate(); +}); + +// --- Real-time block collaboration ---------------------------------------- +// The document is persisted as versioned top-level blocks. Typing remains +// optimistic in the local contenteditable; only changed blocks cross RPC. +const realtimeCss = ` +.remote-caret-layer { position:fixed; inset:0; z-index:998; pointer-events:none; } +.remote-selection { position:fixed; border-radius:2px; opacity:.22; } +.remote-caret { position:fixed; width:2px; min-height:18px; border-radius:2px; } +.remote-caret-label { position:absolute; left:0; bottom:100%; padding:2px 5px; border-radius:4px 4px 4px 0; + color:white; font-size:10px; font-weight:650; white-space:nowrap; transform:translateY(-2px); } +`; +style.textContent += realtimeCss; +const remoteCaretLayer = el("div", { class: "remote-caret-layer", "aria-hidden": "true" }); +document.body.appendChild(remoteCaretLayer); + +const collaboratorName = "Guest " + clientId.slice(0, 4).toUpperCase(); +const collaboratorColor = `hsl(${parseInt(clientId.slice(0, 6), 36) % 360} 62% 48%)`; +let saveTimer = null; +let saveInFlight = false; +let saveAgain = false; +let applyingRemote = false; +let revision = 0; +let acknowledgedTitle = "Untitled document"; +const acknowledged = new Map(); // id -> {html, version} +const pendingByBlock = new Map(); +const collaborators = new Map(); + +let curStatusKind = null, curStatusText = null; +function setStatus(kind, text) { + if (kind === curStatusKind && text === curStatusText) return; + curStatusKind = kind; curStatusText = text; + statusDot.className = "dot " + kind; + statusText.textContent = text; +} + +function newBlockId() { + if (globalThis.crypto?.randomUUID) return "b_" + crypto.randomUUID(); + return "b_" + Date.now().toString(36) + Math.random().toString(36).slice(2); +} + +function blockId(node) { return node?.nodeType === 1 ? node.getAttribute("data-block-id") : null; } +function findBlock(id) { + return Array.from(editor.children).find((node) => blockId(node) === id) || null; +} +function activeBlockId() { + const sel = window.getSelection(); + let node = sel?.anchorNode; + if (!node || !editor.contains(node)) return null; + if (node.nodeType !== 1) node = node.parentElement; + while (node && node.parentElement !== editor) node = node.parentElement; + return node && node.parentElement === editor ? blockId(node) : null; +} + +// contenteditable can create bare text/BR nodes at the root. Convert those to +// paragraphs and guarantee unique stable IDs before serialization. +function normalizeBlocks() { + const selectionBlock = activeBlockId(); + for (const node of Array.from(editor.childNodes)) { + if (node.nodeType === 3 || (node.nodeType === 1 && node.tagName === "BR")) { + const p = document.createElement("p"); + if (node.nodeType === 3) p.textContent = node.textContent; + else p.appendChild(document.createElement("br")); + editor.replaceChild(p, node); + } + } + const seen = new Set(); + for (const node of Array.from(editor.children)) { + let id = blockId(node); + if (!id || seen.has(id)) { + id = newBlockId(); + node.setAttribute("data-block-id", id); + } + seen.add(id); + } + // Merely adding attributes preserves the selection; wrapping a rare root text + // node may not. Put the caret back at the end of its old block when possible. + if (selectionBlock && !activeBlockId()) { + const node = findBlock(selectionBlock); + if (node) { + const range = document.createRange(); range.selectNodeContents(node); range.collapse(false); + const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); + } + } +} + +function canonicalBlockHtml(node) { + // Presence decoration is ephemeral UI and must never enter persisted HTML. + const clone = node.cloneNode(true); + clone.classList.remove("remote-editing"); + clone.style.removeProperty("--remote-color"); + clone.querySelectorAll(".remote-editing").forEach((child) => { + child.classList.remove("remote-editing"); child.style.removeProperty("--remote-color"); + }); + clone.querySelectorAll(".image-selected").forEach((image) => image.classList.remove("image-selected")); + return clone.outerHTML; +} +function serializeBlocks() { + normalizeBlocks(); + return Array.from(editor.children).map((node) => ({ id: blockId(node), html: canonicalBlockHtml(node) })); +} +function parseBlock(block) { + const tpl = document.createElement("template"); + tpl.innerHTML = block.html; + const node = tpl.content.firstElementChild || document.createElement("p"); + node.setAttribute("data-block-id", block.id); + return node; +} + +function scheduleSave(delay = 220) { + if (applyingRemote) return; + setStatus("saving", "Saving…"); + clearTimeout(saveTimer); + saveTimer = setTimeout(doSave, delay); +} + +async function doSave() { + clearTimeout(saveTimer); + if (saveInFlight) { saveAgain = true; return; } + const blocks = serializeBlocks(); + const currentIds = new Set(blocks.map((b) => b.id)); + const upserts = blocks + .filter((block) => acknowledged.get(block.id)?.html !== block.html) + .map((block) => ({ ...block, baseVersion: acknowledged.get(block.id)?.version || 0 })); + const deletes = Array.from(acknowledged.entries()) + .filter(([id]) => !currentIds.has(id)) + .map(([id, block]) => ({ id, baseVersion: block.version })); + const title = titleInput.value.trim() || "Untitled document"; + if (!upserts.length && !deletes.length && title === acknowledgedTitle) { + setStatus("saved", "Saved"); + return; + } + + saveInFlight = true; + try { + const result = await gadget.applyOperation({ + senderId: clientId, baseRevision: revision, upserts, deletes, + order: blocks.map((b) => b.id), title, + }); + revision = Math.max(revision, result.revision || 0); + for (const block of result.upserts || []) acknowledged.set(block.id, { html: block.html, version: block.version }); + for (const id of result.deletedIds || []) acknowledged.delete(id); + acknowledgedTitle = result.title || title; + + if (result.conflicts?.length) { + // Keep the local draft visible, but rebase its next operation on the latest + // authoritative block version. The next save intentionally preserves mine. + for (const block of result.conflicts) acknowledged.set(block.id, { html: block.html, version: block.version }); + setStatus("synced", "Resolving concurrent edit…"); + saveAgain = true; + } else { + setStatus("saved", "Saved"); + } + } catch (e) { + setStatus("bad", "Save failed"); + } finally { + saveInFlight = false; + const latest = serializeBlocks(); + const stillDirty = latest.some((b) => acknowledged.get(b.id)?.html !== b.html) || + latest.length !== acknowledged.size || + (titleInput.value.trim() || "Untitled document") !== acknowledgedTitle; + if (saveAgain || stillDirty) { + saveAgain = false; + scheduleSave(40); + } + } +} + +editor.addEventListener("input", () => { + if (selectedImage && !editor.contains(selectedImage)) hideImageControls(); + else if (selectedImage) positionImageControls(); + scheduleSave(); + schedulePresence(); +}); +titleInput.addEventListener("input", scheduleSave); + +try { document.execCommand("styleWithCSS", false, true); } catch (e) {} +try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {} + +function applyOrder(order) { + // Move only nodes that are actually out of place. Re-appending every block + // would unnecessarily disturb a live Selection in the active block. + let position = 0; + for (const id of order || []) { + const node = findBlock(id); + if (!node) continue; + const atPosition = editor.children[position]; + if (atPosition !== node) editor.insertBefore(node, atPosition || null); + position++; + } +} + +function applyRemoteOperation(event) { + if (!event || event.senderId === clientId) return; + applyingRemote = true; + revision = Math.max(revision, event.revision || 0); + const activeId = activeBlockId(); + + for (const block of event.upserts || []) { + acknowledged.set(block.id, { html: block.html, version: block.version }); + if (block.id === activeId) { + pendingByBlock.set(block.id, { type: "upsert", block }); + continue; + } + const old = findBlock(block.id); + const next = parseBlock(block); + if (old) old.replaceWith(next); else editor.appendChild(next); + } + for (const id of event.deletedIds || []) { + acknowledged.delete(id); + if (id === activeId) pendingByBlock.set(id, { type: "delete" }); + else findBlock(id)?.remove(); + } + applyOrder(event.order); + if (document.activeElement !== titleInput) titleInput.value = event.title || acknowledgedTitle; + acknowledgedTitle = event.title || acknowledgedTitle; + applyingRemote = false; + setStatus("synced", activeId && pendingByBlock.has(activeId) ? "Concurrent edit pending" : "Live update"); + setTimeout(() => { if (!saveInFlight && !saveTimer) setStatus("saved", "Saved"); }, 900); +} + +function applySnapshot(doc) { + applyingRemote = true; + hideLinkPopover(); hideImageControls(); + revision = doc.revision || 0; + acknowledged.clear(); + // Server snapshots store IDs alongside HTML; imported/generated HTML is not + // required to repeat data-block-id inside the markup. Rebuild through + // parseBlock so the DOM always receives the authoritative IDs. + editor.replaceChildren(...(doc.blocks || []).map(parseBlock)); + normalizeBlocks(); + for (const block of doc.blocks || []) { + const node = findBlock(block.id); + acknowledged.set(block.id, { html: node ? canonicalBlockHtml(node) : block.html, version: block.version }); + } + titleInput.value = doc.title || "Untitled document"; + acknowledgedTitle = titleInput.value; + applyingRemote = false; +} + +// If a remote update arrived for the block being typed in, don't clobber the +// caret. On blur, apply it only when the local block is clean; otherwise the +// local draft is rebased and sent as the next version. +function settlePendingBlock(id) { + const pending = pendingByBlock.get(id); + if (!pending) return; + pendingByBlock.delete(id); + const local = findBlock(id); + const base = acknowledged.get(id); + const dirty = local && (!base || canonicalBlockHtml(local) !== base.html); + if (dirty) { scheduleSave(20); return; } + applyingRemote = true; + if (pending.type === "delete") local?.remove(); + else if (pending.block.version >= (base?.version || 0)) { + const next = parseBlock(pending.block); + if (local) local.replaceWith(next); else editor.appendChild(next); + } + applyingRemote = false; +} + +editor.addEventListener("focusout", () => { + const id = activeBlockId(); + setTimeout(() => { + if (!linkPop.contains(document.activeElement)) hideLinkPopover(); + if (id) settlePendingBlock(id); + sendPresence(); + }, 0); +}); + +// --- Ephemeral presence ---------------------------------------------------- +let presenceTimer = null; +function containingBlock(node) { + if (!node || !editor.contains(node)) return null; + if (node.nodeType !== 1) node = node.parentElement; + if (node === editor) return null; + while (node && node.parentElement !== editor) node = node.parentElement; + return node?.parentElement === editor ? node : null; +} +function textOffsetForPoint(block, node, offset) { + if (!block || !node) return 0; + try { + const range = document.createRange(); + range.selectNodeContents(block); + range.setEnd(node, offset); + return range.toString().length; + } catch (e) { return 0; } +} +function schedulePresence() { + clearTimeout(presenceTimer); + presenceTimer = setTimeout(sendPresence, 70); +} +function sendPresence() { + const sel = window.getSelection(); + const anchorBlock = containingBlock(sel?.anchorNode); + const focusBlock = containingBlock(sel?.focusNode); + gadget.updatePresence({ + clientId, name: collaboratorName, color: collaboratorColor, + anchorBlockId: blockId(anchorBlock), + anchorOffset: textOffsetForPoint(anchorBlock, sel?.anchorNode, sel?.anchorOffset || 0), + focusBlockId: blockId(focusBlock), + focusOffset: textOffsetForPoint(focusBlock, sel?.focusNode, sel?.focusOffset || 0), + }).catch(() => {}); +} +function domPointAtTextOffset(block, requestedOffset) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + let remaining = Math.max(0, requestedOffset || 0), text = null, last = null; + while ((text = walker.nextNode())) { + last = text; + if (remaining <= text.data.length) return { node: text, offset: remaining }; + remaining -= text.data.length; + } + if (last) return { node: last, offset: last.data.length }; + return { node: block, offset: 0 }; +} +function caretRectAtPoint(block, point) { + try { + const range = document.createRange(); + range.setStart(point.node, point.offset); range.collapse(true); + const rect = range.getClientRects()[0]; + if (rect) return rect; + } catch (e) {} + const r = block.getBoundingClientRect(); + return { left: r.left, top: r.top + 3, height: Math.min(22, Math.max(18, r.height - 6)) }; +} +function orderedSelectionRange(person) { + const anchorBlock = person.anchorBlockId && findBlock(person.anchorBlockId); + const focusBlock = person.focusBlockId && findBlock(person.focusBlockId); + if (!anchorBlock || !focusBlock) return null; + const anchor = domPointAtTextOffset(anchorBlock, person.anchorOffset); + const focus = domPointAtTextOffset(focusBlock, person.focusOffset); + const blockOrder = Array.from(editor.children); + const ai = blockOrder.indexOf(anchorBlock), fi = blockOrder.indexOf(focusBlock); + const anchorFirst = ai < fi || (ai === fi && person.anchorOffset <= person.focusOffset); + const start = anchorFirst ? anchor : focus; + const end = anchorFirst ? focus : anchor; + const range = document.createRange(); + try { range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); } + catch (e) { return null; } + return { range, focusBlock, focus }; +} +function renderPresence() { + remoteCaretLayer.replaceChildren(); + for (const person of collaborators.values()) { + const selection = orderedSelectionRange(person); + if (!selection) continue; + + if (!selection.range.collapsed) { + for (const rect of selection.range.getClientRects()) { + if (!rect.width || !rect.height) continue; + const highlight = el("span", { class: "remote-selection" }); + highlight.style.cssText = `left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px;background:${person.color}`; + remoteCaretLayer.appendChild(highlight); + } + } + + // The caret follows the focus end, matching the collaborator's actual cursor + // even when they selected backwards from right to left. + const r = caretRectAtPoint(selection.focusBlock, selection.focus); + const caret = el("span", { class: "remote-caret" }, [ + el("span", { class: "remote-caret-label" }, person.name || "Guest"), + ]); + caret.style.cssText = `left:${Math.round(r.left)}px;top:${Math.round(r.top)}px;height:${Math.max(18, Math.round(r.height || 18))}px;background:${person.color}`; + caret.firstChild.style.background = person.color; + remoteCaretLayer.appendChild(caret); + } +} +function applyPresence(event) { + if (!event?.clientId || event.clientId === clientId) return; + if (event.type === "leave") collaborators.delete(event.clientId); + else collaborators.set(event.clientId, { ...event, seenAt: Date.now() }); + renderPresence(); +} +document.addEventListener("selectionchange", () => { + if (editor.contains(window.getSelection()?.anchorNode)) schedulePresence(); +}); +window.addEventListener("scroll", () => { if (collaborators.size) renderPresence(); }, true); +window.addEventListener("resize", () => { if (collaborators.size) renderPresence(); }); + +// onRpcBroken and unload delivery can both be delayed by the browser. A small +// heartbeat makes stationary cursors live, while stale collaborators disappear +// predictably even when a tab/process is killed without a clean disconnect. +const PRESENCE_HEARTBEAT_MS = 4000; +const PRESENCE_STALE_MS = 12000; +setInterval(() => { + sendPresence(); + const cutoff = Date.now() - PRESENCE_STALE_MS; + let changed = false; + for (const [id, person] of collaborators) { + if ((person.seenAt || 0) < cutoff) { + collaborators.delete(id); + changed = true; + } + } + if (changed) renderPresence(); +}, PRESENCE_HEARTBEAT_MS); + +window.addEventListener("pagehide", () => { + // This is best-effort only; stale expiry above is the guaranteed fallback. + gadget.leavePresence(clientId).catch(() => {}); +}); + +class DocCallbacks extends RpcTarget { + operation(event) { + if (event.type === "snapshot") applySnapshot(event.document); + else applyRemoteOperation(event); + } + presence(event) { applyPresence(event); } +} + +if (isDocumentExport) { + document.documentElement.classList.add("document-export"); + editor.removeAttribute("contenteditable"); + app.replaceChildren(canvas); + document.body.replaceChildren(app); +} + +// --- Init ------------------------------------------------------------------ + + try { + let doc = await gadget.subscribe(new DocCallbacks(), { + clientId, name: collaboratorName, color: collaboratorColor, + }); + if (!doc.blocks) { + // One-time, backwards-compatible conversion of the former HTML snapshot. + editor.innerHTML = doc.legacyContent || ""; + normalizeBlocks(); + doc = await gadget.initializeBlocks({ + blocks: serializeBlocks(), title: doc.title, senderId: clientId, + }); + } + applySnapshot(doc); + setStatus("saved", "Saved"); + sendPresence(); + } catch (e) { + console.error(e); + setStatus("bad", "Offline"); + } + refreshToolbarState(); diff --git a/packages/workshop-backend/format-blueprints/workspace-docs/files/server.js b/packages/workshop-backend/format-blueprints/workspace-docs/files/server.js new file mode 100644 index 000000000..34dbcc84f --- /dev/null +++ b/packages/workshop-backend/format-blueprints/workspace-docs/files/server.js @@ -0,0 +1,427 @@ +import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; + +const DEFAULT_TITLE = "Untitled document"; + +export class Gadget extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + this.ctx = ctx; + this.subscribers = new Map(); + // RPC calls may overlap at await points. Chain mutations so each operation + // observes and commits one authoritative document state in strict order. + this.mutationQueue = Promise.resolve(); + } + + enqueueMutation(fn) { + const result = this.mutationQueue.then(fn); + this.mutationQueue = result.catch(() => {}); + return result; + } + + async loadDocument() { + let doc = await this.ctx.storage.get("document:v2"); + if (doc) return doc; + + // Keep old documents readable. The first v2 client converts the legacy HTML + // into stable top-level blocks and calls initializeBlocks(). + const [content, title, lastModified] = await Promise.all([ + this.ctx.storage.get("content"), + this.ctx.storage.get("title"), + this.ctx.storage.get("lastModified"), + ]); + return { + revision: 0, + title: title ?? DEFAULT_TITLE, + blocks: null, + legacyContent: content ?? "", + lastModified: lastModified ?? null, + }; + } + + async getDocument() { + return this.loadDocument(); + } + + initializeBlocks(args) { + return this.enqueueMutation(() => this.initializeBlocksLocked(args)); + } + + async initializeBlocksLocked({ blocks, title, senderId }) { + let current = await this.ctx.storage.get("document:v2"); + const cleanBlocks = sanitizeBlocks(blocks); + const cleanTitle = String(title || DEFAULT_TITLE); + + if (current) { + // A newly opened client may win the initialization race by creating the + // blank revision-1 shell before an agent seeds generated content. Treat + // only that exact shell as replaceable; later empty documents may be an + // intentional user edit and are never overwritten by initialization. + const isBlankBootstrap = current.revision === 1 && + current.title === DEFAULT_TITLE && current.blocks.length === 0; + const hasSeedContent = cleanBlocks.length > 0 || cleanTitle !== DEFAULT_TITLE; + if (!isBlankBootstrap || !hasSeedContent) return current; + } + + const now = Date.now(); + current = { + revision: current ? current.revision + 1 : 1, + title: cleanTitle, + blocks: cleanBlocks.map((block) => ({ id: block.id, html: block.html, version: 1 })), + lastModified: now, + }; + await this.ctx.storage.put("document:v2", current); + await this.broadcast({ + type: "snapshot", + senderId, + document: current, + }); + return current; + } + + // Explicit full-document writer for agents/importers. Unlike initializeBlocks, + // this always applies, so callers never need to inspect revision state or + // construct per-block applyOperation payloads just to populate a document. + setDocument(args) { + return this.enqueueMutation(() => this.setDocumentLocked(args)); + } + + async setDocumentLocked({ blocks, title, senderId }) { + const previous = await this.ctx.storage.get("document:v2"); + const previousById = new Map((previous?.blocks || []).map((block) => [block.id, block])); + const cleanBlocks = sanitizeBlocks(blocks); + const document = { + revision: (previous?.revision || 0) + 1, + title: String(title || DEFAULT_TITLE), + blocks: cleanBlocks.map((block) => ({ + id: block.id, + html: block.html, + version: (previousById.get(block.id)?.version || 0) + 1, + })), + lastModified: Date.now(), + }; + await this.ctx.storage.put("document:v2", document); + await this.broadcast({ type: "snapshot", senderId, document }); + return document; + } + + // Apply a compact batch of block changes. The mutation queue is the single + // authoritative order for all collaborators. + applyOperation(operation) { + return this.enqueueMutation(() => this.applyOperationLocked(operation)); + } + + async applyOperationLocked(operation) { + let doc = await this.ctx.storage.get("document:v2"); + if (!doc) throw new Error("Document must be initialized first."); + + const byId = new Map(doc.blocks.map((block) => [block.id, block])); + const accepted = []; + const conflicts = []; + + for (const incoming of sanitizeBlocks(operation.upserts || [])) { + const current = byId.get(incoming.id); + const expected = Number(incoming.baseVersion || 0); + if (current && expected !== current.version) { + conflicts.push(current); + continue; + } + if (!current && expected !== 0) continue; + const next = { id: incoming.id, html: incoming.html, version: (current?.version || 0) + 1 }; + byId.set(next.id, next); + accepted.push(next); + } + + const deletedIds = []; + for (const deletion of operation.deletes || []) { + const id = String(deletion?.id || ""); + const current = byId.get(id); + if (!current) continue; + if (Number(deletion.baseVersion || 0) !== current.version) { + conflicts.push(current); + continue; + } + byId.delete(id); + deletedIds.push(id); + } + + // Ordering is intentionally last-writer-wins. Text/content remains guarded + // by per-block versions, while inserts, moves and list restructuring stay + // responsive and deterministic. + const requestedOrder = Array.isArray(operation.order) ? operation.order.map(String) : []; + const order = []; + const seen = new Set(); + for (const id of requestedOrder) { + if (byId.has(id) && !seen.has(id)) { order.push(id); seen.add(id); } + } + for (const block of doc.blocks) { + if (byId.has(block.id) && !seen.has(block.id)) { order.push(block.id); seen.add(block.id); } + } + for (const id of byId.keys()) { + if (!seen.has(id)) order.push(id); + } + + const titleChanged = typeof operation.title === "string" && operation.title !== doc.title; + const changed = accepted.length || deletedIds.length || titleChanged || + order.join("\n") !== doc.blocks.map((b) => b.id).join("\n"); + + if (!changed) { + return { status: conflicts.length ? "conflict" : "unchanged", revision: doc.revision, conflicts }; + } + + doc = { + revision: doc.revision + 1, + title: typeof operation.title === "string" ? operation.title : doc.title, + blocks: order.map((id) => byId.get(id)), + lastModified: Date.now(), + }; + await this.ctx.storage.put("document:v2", doc); + + const event = { + type: "operation", + senderId: operation.senderId, + revision: doc.revision, + title: doc.title, + upserts: accepted, + deletedIds, + order, + lastModified: doc.lastModified, + }; + await this.broadcast(event); + return { + status: conflicts.length ? "conflict" : "applied", + ...event, + conflicts, + }; + } + + async subscribe(callback, client = {}) { + const dup = callback.dup(); + const existing = Array.from(this.subscribers.values()); + const info = { + callback: dup, + clientId: String(client.clientId || ""), + name: String(client.name || "Guest").slice(0, 40), + color: String(client.color || "#e1632e"), + }; + this.subscribers.set(dup, info); + dup.onRpcBroken(() => { + this.subscribers.delete(dup); + this.broadcastPresence({ type: "leave", clientId: info.clientId }); + }); + queueMicrotask(async () => { + // Seed the newcomer with collaborators who were already connected. + for (const person of existing) { + try { + await dup.presence({ type: "join", clientId: person.clientId, name: person.name, color: person.color, blockId: null }); + } catch (e) { break; } + } + await this.broadcastPresence({ + type: "join", clientId: info.clientId, name: info.name, color: info.color, blockId: null, + }); + }); + return this.loadDocument(); + } + + async updatePresence(presence) { + const event = { + type: "cursor", + clientId: String(presence.clientId || ""), + name: String(presence.name || "Guest").slice(0, 40), + color: String(presence.color || "#e1632e"), + // Anchor/focus endpoints let clients render both a caret and highlighted + // selections, including selections spanning multiple top-level blocks. + anchorBlockId: presence.anchorBlockId ? String(presence.anchorBlockId) : null, + anchorOffset: Math.max(0, Number(presence.anchorOffset || 0)), + focusBlockId: presence.focusBlockId ? String(presence.focusBlockId) : null, + focusOffset: Math.max(0, Number(presence.focusOffset || 0)), + at: Date.now(), + }; + await this.broadcastPresence(event); + } + + // Best-effort fast path for pagehide. Clients also expire stale presence via + // heartbeats because browsers cannot guarantee that unload RPC completes. + async leavePresence(clientId) { + await this.broadcastPresence({ + type: "leave", + clientId: String(clientId || ""), + at: Date.now(), + }); + } + + async broadcast(event) { + const calls = []; + for (const [stub] of this.subscribers) { + calls.push(Promise.resolve(stub.operation(event)).catch(() => this.subscribers.delete(stub))); + } + await Promise.all(calls); + } + + async broadcastPresence(event) { + const calls = []; + for (const [stub] of this.subscribers) { + calls.push(Promise.resolve(stub.presence(event)).catch(() => this.subscribers.delete(stub))); + } + await Promise.all(calls); + } + + async getGoogleDocInfo() { + if (!this.env.GOOGLE_DOC) return null; + const metadata = await this.env.GOOGLE_DOC.getMetadata(); + return { title: metadata.title, lastModified: metadata.lastModified }; + } + + async syncToGoogleDoc({ markdown }) { + if (!this.env.GOOGLE_DOC) throw new Error("GOOGLE_DOC binding is not configured."); + const next = String(markdown || "").trim() || " "; + const current = await this.env.GOOGLE_DOC.getContent(); + if ((current || "").trim() === next.trim()) return { status: "unchanged" }; + if (!(current || "").trim()) await this.env.GOOGLE_DOC.appendText(next); + else await this.env.GOOGLE_DOC.replaceText(current, next); + return { status: "synced", metadata: await this.env.GOOGLE_DOC.getMetadata() }; + } +} + +function sanitizeBlocks(blocks) { + if (!Array.isArray(blocks)) return []; + const result = []; + const seen = new Set(); + for (const value of blocks) { + const id = String(value?.id || "").slice(0, 100); + const html = String(value?.html || ""); + if (!id || seen.has(id) || html.length > 10_000_000) continue; + seen.add(id); + result.push({ id, html, baseVersion: Number(value?.baseVersion || 0) }); + } + return result; +} + + +const DOC_EXPORT_FORMATS = [ + { id: "markdown", label: "Markdown", mode: "server", contentType: "text/markdown", fileExtension: ".md" }, + { id: "html", label: "HTML", mode: "browser", contentType: "text/html", fileExtension: ".html" }, + { id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf", fileExtension: ".pdf" }, +]; + +export class ExportHandler extends WorkerEntrypoint { + async getExportFormats() { + return DOC_EXPORT_FORMATS; + } + + async export(gadget, id) { + if (id !== "markdown") throw new Error("Unsupported document export format: " + id); + const document = await gadget.getDocument(); + const html = document.blocks + ? document.blocks.map((block) => block.html).join("") + : document.legacyContent || ""; + return new Response(htmlToMarkdown(html)).body; + } +} + +function htmlToMarkdown(html) { + const tokens = String(html).match(/|]*>|<[^>]+>|[^<]+/g) || []; + const lists = []; + const links = []; + const blockquotes = []; + let markdown = ""; + let inPre = false; + + for (const token of tokens) { + if (!token.startsWith("<")) { + let text = decodeHtml(token); + if (!inPre) { + text = text.replace(/\s+/g, " ").replace(/([\\*_[\]])/g, "\\$1"); + } + markdown += text; + continue; + } + if (token.startsWith("