feat: store unsaved changes in browser storage and prompt on restore

This commit is contained in:
NGPixel committed 2026-10-11 20:07:41 -04:00
1 parent cf411e543f
commit b114a5fa65
14 files changed
+1675 -78

No files matched your search

+7
View File
@@ -2611,6 +2611,13 @@
"editor.redirect.title": "Redirection",
"editor.renderFailed": "The preview could not be rendered. The last successful render is kept.",
"editor.renderPreview": "Render Preview",
"editor.restore.body": "You made changes to this page on {date} that were never saved. They were kept in this browser. Do you want to restore them?",
"editor.restore.bodyNewPage": "You started writing a new page on {date} that was never saved. It was kept in this browser. Do you want to restore it?",
"editor.restore.othersEditing": "Other people are editing this page right now. Restoring replaces the shared document with your version, including anything they have changed.",
"editor.restore.restore": "Restore",
"editor.restore.restored": "Your unsaved changes have been restored.",
"editor.restore.stale": "This page has been saved since you made these changes. Restoring them puts back your version of the whole page, undoing that save.",
"editor.restore.title": "Restore Unsaved Changes?",
"editor.save.createSuccess": "Page created successfully.",
"editor.save.error": "An error occurred while creating the page",
"editor.save.pleaseWait": "Please wait...",
+670
View File
@@ -0,0 +1,670 @@
# Drafts, autosave and live documents
**Status:** phase 1 of [§13](#13-suggested-order) implemented — the browser buffer and the leave
warnings ([§11](#11-the-browser-buffer)). Everything else is proposed.
**Covers:** where unpublished edits to a page are kept, how they are saved without the author asking,
what happens to a collaborative session when everybody leaves, the per-site draft workflow, and
per-page live documents.
Today an edit lives in exactly two places: the browser that is making it, and — once somebody presses
Save — the page itself. Everything in between is lost when a tab closes or a browser crashes. A
collaborative room is lost the moment its last participant leaves. And every save goes straight to
readers, so a change that takes three people a week has to be written somewhere else and pasted in.
This document adds one thing in the middle: **a draft, kept on the server, one per page**, separate
from the page row that readers are served. Autosave writes to it, a collaborative room is saved into
it, and publishing is the one act that copies it onto the page. Which of those a site and a page use
is a matter of settings ([§4](#4-editing-modes)); the storage underneath is the same for all of them.
The constraints this is written against:
- **The page row stays the published version.** Every reader of a page — the page route, the app
shell, search, the sitemap, page links, storage targets — reads `pages` and goes on doing so
unchanged. A draft is something only the editor and the people allowed to edit ever see.
- **The server cannot produce a page.** The render is made by the browser at save time. The server
can only render with the Puppeteer extension, which is not installed by default, and only for some
editors. Neither the Visual editor's ProseMirror tree nor an Excalidraw scene can be turned back
into a page's source on the server at all. So the server may *store* an edit, but publishing
always happens in a browser ([§6](#6-publishing)).
- **History records versions somebody meant to make.** One row per publish, not one per autosave.
Every save today also fans out to search, links, storage targets (a git commit each), webhooks,
notifications and the audit log, so autosaving onto the page would flood all of those as well.
- **Suggestions stay what they are.** An edit suggested by somebody who may only read the page is
kept per author in `pageEditSubmissions` and reviewed, exactly as now
([§8.3](#83-suggestions)). A suggestion may well be unwanted and declined; a draft is work the
page's own editors have chosen to do.
---
## 1. Goals and non-goals
**Goals**
- **No edit is lost** to a closed tab, a crashed browser, a dropped connection or a server restart.
The browser keeps a local copy until the server has one ([§11](#11-the-browser-buffer)), and the
server keeps the draft until it is published or discarded.
- **A collaborative session outlives its participants.** When the last person leaves a room, what
they wrote is in the draft, and the next person to open the editor carries on from it
([§5](#5-drafts-and-collaborative-rooms)).
- **Autosave without flooding history.** Saving the draft records nothing in history; publishing
records one version.
- **A draft workflow, per site** (`features.drafts`). One shared draft per page, worked on by any
number of people over any number of days and published when it is ready, while readers go on
seeing the last published version.
- **Live documents, per page.** A page marked live publishes itself as it is edited, within seconds,
the way a HedgeDoc note is a live document. History is recorded in checkpoints rather than per
keystroke ([§7](#7-live-documents)).
- **Direct edits to the live page are refused while a draft exists** on a site using drafts, or on a
live document ([§8](#8-writes-to-the-page-while-a-draft-exists)), so that the draft cannot silently
be built on top of a page that has since moved.
- **Each history version records everyone who wrote it**, not only whoever pressed Publish
(`pageHistory.contributorIds`).
**Non-goals for the first version**
- **Readers watching a live document update in real time.** A reader sees the published version as
of their page load. The shape of the follow-up is in [§7.5](#75-later-readers-watching-live).
- **More than one draft per page.** A draft is shared and there is exactly one. Branches are a
different feature.
- **Drafting moves, deletions or translations.** Path, locale, alias, locale relations and
deletion stay immediate actions ([§3.2](#32-what-a-draft-holds)).
- **Scheduled publishing of a draft** ("publish this on Monday"). The existing `scheduled` publish
state is about when a page is visible, which is a different thing.
- **Rendering a draft on the server.** Even where Puppeteer could do it for markdown, the Visual and
Excalidraw editors rule it out, and a feature that works for some editors only is worse than one
that waits for a browser.
- **Thinning old history** (keep hourly versions for a week, daily after that). It is a separate
retention policy, and becomes far less pressing once autosave stops writing versions.
- **Notifications about drafts.** Watchers are told when something is published, as now.
---
## 2. Vocabulary
Two existing words collide here, and the interface has to keep them apart.
- **`publishState`** answers *whether a page is visible to readers at all*: `draft`, `published` or
`scheduled`, on the page row, unchanged by this document.
- **A draft** (this document) is *unpublished changes to a page*, which may itself be published or
not.
So a page may be published and also have a draft (the case this feature exists for), or be
unpublished with no draft. The stored `publishState` value stays `draft`; renaming it would need a
migration for nothing a reader sees. **The interface should stop labelling that state "Draft"** and
call it "Not published", so that "Draft" means only the new thing.
Inside this document, **draft** always means a `pageDrafts` row, whatever the site's mode. In
[direct mode](#41-direct-drafts-off) the interface calls it "unsaved changes" instead, because there
it is a recovery buffer rather than something anybody chose to keep.
---
## 3. Storage
### 3.1 `pageDrafts`
One row per page at most.
| Column | Type | Notes |
| --- | --- | --- |
| `id` | uuid | Primary key |
| `pageId` | uuid | FK → `pages.id`, **unique**, `on delete cascade` |
| `siteId` | uuid | FK → `sites.id` |
| `generation` | uuid | Identity of this draft's collaborative document ([§5.3](#53-generations)). New for every new row |
| `baseVersionId` | uuid | The `pageHistory` version the draft started from. Not a FK: history can be purged |
| `baseHash` | varchar(64) | SHA-256 of the page content the draft started from, as `pageEditSubmissions.baseHash` |
| `content` | text | The page source as the draft has it — written by a browser ([§5.2](#52-the-scribe)) |
| `meta` | jsonb | The other drafted fields ([§3.2](#32-what-a-draft-holds)) |
| `render` | text | Nullable. HTML for previewing the draft, post-processed as a page render is |
| `ydoc` | bytea | Nullable. The collaborative document as a Yjs update. **When present, it is the draft**, and `content`/`meta` are what a browser last read out of it |
| `contributorIds` | uuid[] | Everyone who has changed the draft since it started, or since a live page's last checkpoint |
| `createdById`, `updatedById` | uuid | FK → `users.id`, `on delete set null` |
| `createdAt`, `updatedAt` | timestamp | |
| `refreshedAt`, `checkpointedAt` | timestamp | Live documents only ([§7](#7-live-documents)) |
Indexes: unique on `pageId`, plus `siteId` for the drafts list.
**Why `ydoc` and `content` both exist.** The server can store a collaborative document but, for two
of the four editors, cannot read the page out of it ([the constraints](#drafts-autosave-and-live-documents)).
So the Yjs state is what the draft *is*, and `content` is a copy a browser keeps up to date for
everything that has no Yjs decoder: the page banner, a preview, the drafts list, a staleness check,
and a session outside a room. `content` may trail `ydoc` by a few seconds. Nothing publishes from it
without a browser having rebuilt it first.
`bytea` needs a drizzle `customType`; nothing in the schema uses one yet.
### 3.2 What a draft holds
**The draft holds what the editor edits; anything that moves or addresses the page stays
immediate.**
| Drafted (in `content` / `meta`) | Immediate (written to the page, as today) |
| --- | --- |
| content | path and locale (a move) |
| title, description, icon | alias |
| tags | locale relations |
| relations | publish state and dates — set at publish time ([§6.1](#61-what-publishing-does)) |
| scripts: `scriptJsLoad`, `scriptJsUnload`, `scriptCss` | password |
| display settings: `allowBacklinks`, `allowComments`, `allowContributions`, `allowGlossaryLinks`, `allowRatings`, `showLastEditedBy`, `showSidebar`, `showTags`, `showToc`, `tocDepth` | `isBrowsable`, `isSearchable` |
| | navigation (its own API) |
| | `liveDocument` ([§7](#7-live-documents)) |
| | the editor itself — conversion is refused while a draft exists |
A drafted field reaches the page when the draft is published, and only then.
### 3.3 `pageHistory.contributorIds`
`uuid[] NOT NULL DEFAULT '{}'`. Everyone whose changes are in this version, **including** the
author. `authorId` keeps its meaning: who made the version — the person who pressed Publish, or, for
a live checkpoint nobody asked for, the most recent contributor.
The migration backfills `ARRAY["authorId"]` on every row whose `authorId` is not null, so there is
one shape afterwards and nothing reads `authorId` as a stand-in for the list. An array has no FK, so
the id of a deleted account stays in the list. The history view shows it as a deleted user, as it
already does for a null `authorId`.
### 3.4 Settings
- **`features.drafts`** — site config, boolean, **default `false`**: a site behaves as it does today
until an administrator turns this on. Set under **Admin → General → Features**. It goes into both
default literals in `models/sites.ts`, and **a migration writes `false` into every existing site's
`config.features`**. Site defaults are merged only when a site is created, so an existing site
would otherwise not have the key at all.
- **`liveDocument`** — page config, boolean, one of `CONFIG_FIELDS`. Absent means `false`, following
the convention every page config field already uses: the key is written only once somebody edits
the page's properties. Offered only while the site has `features.collaborativeEditing` on — a live
document is a shared one by nature, and outside a room two editors would spend their time
answering each other's 409s.
- **Timings** are constants in code for the first version. Each one is named where it is used:
[§5.1](#51-seeding-and-persisting-a-room), [§7.1](#71-refresh), [§7.2](#72-checkpoints),
[§11](#11-the-browser-buffer).
### 3.5 Migrations
1. `pageDrafts` — `npm run db-generate -- --name=page-drafts`.
2. `pageHistory.contributorIds`, with the backfill in the same migration —
`--name=history-contributors`.
3. `features.drafts: false` into each site's config — a hand-written SQL statement appended to one
of the two.
The new scheduled tasks need no migration: `SYSTEM_SCHEDULE` is reconciled on every boot.
---
## 4. Editing modes
There is one mechanism and three ways of using it. The site's `features.drafts` setting picks
between the first two, and a page's `liveDocument` setting overrides both.
| | Direct (`drafts` off) | Draft (`drafts` on) | Live (`liveDocument`) |
| --- | --- | --- | --- |
| The draft is | a recovery buffer | the work in progress | the room's saved state |
| Autosaved to the server | yes | yes | yes |
| Primary action | **Save** (publishes) | **Publish**, plus **Save draft** | none needed; **Save version** is optional |
| Readers see | the page as last saved | the page as last published | the page within seconds of an edit |
| History | one version per Save | one version per Publish | one version per checkpoint |
| Direct edits while a draft exists | allowed, and the draft goes stale | **refused** | **refused** |
| Closing the editor with unpublished changes | asks Save or Discard, as today | nothing to ask: it is saved | nothing to ask |
### 4.1 Direct (drafts off)
What an author does is unchanged: Save publishes, Discard throws the edit away. What changes is what
happens when the session ends badly:
- If the tab closes or crashes, the draft survives. The next person to open the editor on that page
is told: "*Unsaved changes by Ana, from 14:02 — Continue / Discard*".
- If the last participant leaves a room with changes nobody saved, the draft survives the same way.
**Why direct edits are not refused here.** A draft in this mode is the state of a session that
ended without anybody deciding to keep it. Blocking an API client for days because a laptop crashed
would be the wrong trade. Instead, the draft goes stale ([§6.2](#62-staleness-and-merging)), and
whoever continues it merges.
**Abandoned buffers are purged.** A daily task (`purgePageDrafts`) deletes drafts that have not been
touched in **30 days** on sites with `features.drafts` off. On a site using drafts, a draft is never
deleted by anything but a person.
### 4.2 Draft (drafts on)
- **Edit opens the draft** if one exists, and otherwise starts one on the first change, based on the
current version.
- **The editor saves as you go** ([§5](#5-drafts-and-collaborative-rooms), [§10](#10-api)). **Save
draft** is the same save, done now and acknowledged, for the author who wants to see it happen.
- **Publish** opens the publish dialog — reason for change, publish state and dates — and publishes
([§6](#6-publishing)).
- **Discard draft** deletes it for everybody. It always asks first and names the other contributors,
because the work being thrown away is often not only the author's.
- **Readers who may edit** see a banner on the page: "*Unpublished changes by Ana and Ben — last
edited 2 hours ago. Open draft · Preview*". Everyone else sees nothing.
**Turning `features.drafts` off** while drafts exist turns them into direct-mode buffers. Nothing is
deleted, and the 30-day clock starts from each draft's `updatedAt`. The admin screen says how many
pages are affected before the switch is saved. **Turning it on** makes any existing buffer a draft.
### 4.3 Live
See [§7](#7-live-documents).
---
## 5. Drafts and collaborative rooms
Today `core/collab.ts` says a room is not storage: an empty room is destroyed with whatever it held.
That stops being true, and that paragraph is rewritten.
### 5.1 Seeding and persisting a room
- **Seeding.** A room starts from `pageDrafts.ydoc` when there is one, and every instance reads the
same bytes. Only when there is no draft does it fall back to today's deterministic `buildSeed` from
the page, or to the Visual and Excalidraw editors seeding themselves client-side. A stored Visual
or Excalidraw document is not empty, so `seedIfEmpty` leaves it alone.
- **Persisting.** Each instance writes its room's state into the draft **2 s** after the last
update, at most every **10 s** while updates keep coming, and immediately when its last local
connection closes. The write is a merge, done in a transaction under `SELECT … FOR UPDATE`:
`Y.mergeUpdates([stored, ours])`. Yjs updates merge in any order and merge with themselves as a
no-op, so instances in an HA set can write without coordinating.
- **No instance has to know that "everybody has left".** An instance only knows about its own
connections, and nothing here needs the answer for the whole cluster. Each instance saves what it
holds, and the merge does the rest. This is the reason to persist continuously rather than "when
the room empties".
- **Grace period.** An empty room is kept for **30 s** before it is destroyed, so that a reload
rejoins the same room instead of re-reading the stored document. This is an optimisation only: the
state is already persisted.
- **Contributors.** The server attributes each incoming Yjs update to the session of the socket it
arrived on. The first update from a user not yet in `contributorIds` adds them, and records
`editDraft` in the audit log ([§12](#12-audit-log)).
### 5.2 The scribe
`content`, `meta` and `render` have to be written by a browser, and a room has several. **The scribe
is the participant with the lowest awareness client id**, among clients that advertise the ability
in their awareness state (every editor does). Awareness is relayed between instances, so every
participant reaches the same answer. While a client joins or leaves they may briefly disagree, and
then two scribes write the same state, which is harmless because the last write wins.
The scribe writes the draft's materialized fields ([§10](#10-api)) **5 s** after the document goes
quiet, at most every **30 s**, and when it leaves. Outside a room, the one editor is its own scribe.
Fields the room does not share — tags, scripts, the properties dialog — are written by whoever
changes them, through the same route. They are last-write-wins, exactly as in a room today.
### 5.3 Generations
A Yjs client identifies its changes by client id and clock, and the room's seed is pinned to client
0 (and the Visual seed to client 1) so that two identical seeds merge into one. That trick has a
known sharp edge: a document built from a *different* seed under the same client id claims clocks
the room has already seen, and Yjs discards it without saying so (see `SEED_CLIENT_ID` in
`editor/visual/collab.js`). Once rooms survive their participants, a browser that held a document
from before a publish or a discard and then reconnects becomes an ordinary event rather than a rare
one.
So every draft has a `generation`, and **a room belongs to a generation, not only to a page**:
- The server sends the generation as the first message on a new connection, before sync step 1.
This is a third message type beside the two y-websocket uses.
- A client holding a document of another generation discards it, along with any unsynced edits
(its browser buffer has those, [§11](#11-the-browser-buffer)), and syncs from empty. It never
answers sync step 1 with the old document.
- Deleting a draft (on publish, discard or compaction) ends its generation. The next room starts a
new one, from the page.
### 5.4 Discarding
Discard deletes the draft and closes the room on every instance: the relay gains a `discarded`
message alongside `saved`. Connected participants are told who discarded it. Their editors reload
from the page as a new generation.
---
## 6. Publishing
### 6.1 What publishing does
`POST …/draft/publish` ([§10](#10-api)) is sent by a browser with the content and render it has just
built from its own copy of the draft, plus the publish dialog's answers. The server:
1. Checks that the page has not moved on since the draft started ([§6.2](#62-staleness-and-merging)).
2. Applies the drafted fields and the dialog's publish state and dates through `updatePage`, with the
render permissions of [§6.3](#63-render-permissions). The rest is what any save already does: one
history row (now with `contributorIds`), search, links, storage targets, webhooks, notifications
and the app shell cache.
3. Deletes the draft, ending its generation. If a room is still open, the participants are told
through the existing `saved` message, and the room's next persist starts a new draft based on the
version just published.
The audit entry is `publishDraft`, carrying the version id and the contributors.
**Notifications leave out every contributor**, not only the actor. Somebody who wrote half a version
does not need to be told it was published.
**A brand new page** is created as it is today: Save As, the chosen path, the chosen publish state.
There is no page id to hang a draft on before that, so the [browser buffer](#11-the-browser-buffer)
covers the first session. Every later edit goes through a draft.
### 6.2 Staleness and merging
A draft is **stale** when the page's current content hash differs from `baseHash`. The page can move
on underneath a draft even with direct edits refused: an import, a git pull, a version restored
before the draft started ([§8](#8-writes-to-the-page-while-a-draft-exists)).
- `GET …/draft` reports `isStale`, and the editor shows it.
- Publishing a stale draft is refused with **409 `pageDraftStale`**, unless the request carries the
`currentHash` the author merged against.
- **Merging is three-way**: base (`baseVersionId` from history), current page, and draft. The
merged result is shown in a diff view, reusing the approvals review diff, where the author settles
conflicts. If the base version has been purged, it falls back to a two-way diff.
- **Excalidraw** cannot be text-merged. Its own `reconcileElements`, which merges scenes element by
element by id and version, is the candidate. Failing that, the author picks a side.
### 6.3 Render permissions
`rendering.postProcess` keeps or strips scripts and styles according to whoever saves. A draft has
several authors, and today's rooms already have a hole here: whoever presses Save publishes everyone
else's text under *their own* `write:scripts` / `write:styles`.
**Publishing renders with what every contributor may do:** `write:scripts` only if every
`contributorIds` member holds it on the page, and the same for `write:styles`, each checked with
`groups.checkAccess`. For a single author this is exactly today's rule. When the publisher holds a
permission that a contributor lacks, the publish dialog says so: "*Scripts will be removed: Ben may
not add scripts to this page*".
The draft's `scriptJsLoad` / `scriptJsUnload` / `scriptCss` fields are checked when they are
*saved*, against the person saving, as `PATCH /pages/:id` checks them now.
---
## 7. Live documents
A page with `liveDocument` on is edited the way a HedgeDoc note is: whoever has it open edits it, and
readers get the result without anybody pressing anything. The pieces are the same draft and room as
above. The difference is that **the scribe also publishes**, in two strengths.
### 7.1 Refresh
The scribe sends `POST …/draft/refresh` **5 s** after the document goes quiet, and at most every
**30 s** while it does not. A refresh writes the drafted fields and render onto the page row, then
updates search, links and the app shell cache, and sets `refreshedAt`. **It does not write history,
notifications, webhooks or storage targets, and does not delete the draft.** Readers see the page as
of the last refresh. A refresh whose content hash equals the page's is a no-op, so a second scribe
costs nothing.
Render permissions are those of [§6.3](#63-render-permissions), over the contributors since the last
checkpoint. A live page skips the staleness check: direct edits to it are refused
([§8](#8-writes-to-the-page-while-a-draft-exists)), and an import that lands anyway is overwritten
by the next refresh. That is the accepted trade for a page whose working copy *is* the document.
### 7.2 Checkpoints
A **checkpoint** is a full publish of what the page row already holds: one history row,
notifications, webhooks, storage targets. It needs no browser, since nothing is rendered: the row was
rendered by the last refresh. Checkpoints happen:
- **On idle** — a task, `checkpointLivePages`, runs every minute and checkpoints every live draft
with `refreshedAt > checkpointedAt` and no update in **10 minutes**. This is what "save once
everybody has left" means, and, as in [§5.1](#51-seeding-and-persisting-a-room), nobody has to
establish that everybody has left.
- **During long sessions** — the same task checkpoints a draft whose last checkpoint is more than
**60 minutes** old, so that a day of editing is a handful of versions rather than one.
- **On request** — **Save version** in the editor, which also lets the author give a reason.
`reasonForChange` is never demanded of a live page, whatever the site's setting: an automatic
version has nobody to ask.
- **Before any other history-writing action** on a live page with an unrecorded refresh — a move, a
deletion, a switch out of live mode — so that the move's version does not absorb the content edits
under the mover's name.
A checkpoint resets `contributorIds` and sets `checkpointedAt`. If the room is empty and the page row
matches the draft (`refreshedAt` ≥ `updatedAt`), the draft is deleted. That bounds the size of a live
page's Yjs document, which otherwise grows for the page's whole life. The next session starts a new
generation from the page.
The history entry's author is the most recent contributor, and `contributorIds` lists the rest.
Checkpoints run from the scheduler, so there is no audit row for them: the people involved were
recorded by `editDraft` as they joined ([§12](#12-audit-log)).
### 7.3 History is no longer always current
`pageHistory.list` documents that the newest row is the page as it stands. On a live page, between
a refresh and the checkpoint after it, the page row is ahead of its newest version. The history view
shows that interval as a synthetic first entry, "*Current — not yet recorded*", built from the page
row. Diff and restore already read the page itself for the current side, so this is a change to the
list and to that comment, not to the model.
### 7.4 What a crash leaves behind
If the last scribe vanishes between an edit and its refresh, the draft's `ydoc` holds edits the page
row does not, and the server cannot render them. They stay in the draft, and **the next browser to
open the editor refreshes as soon as it has synced**, so the page catches up then. Until then readers
see the last refresh, which is at most about 30 s of work behind. The checkpoint task does not
checkpoint such a draft (`refreshedAt` < `updatedAt`), but it does record what the row holds after
the 60-minute ceiling, so the history is never further behind than that.
Switching a page **into** live mode is refused while it has a draft: publish or discard first, so
that switching modes never publishes anything by itself. Switching **out** of it first checkpoints;
the draft, if any, then becomes an ordinary draft or buffer.
### 7.5 Later: readers watching live
Not in the first version. The shape it would take: a reader of a live page subscribes to a
lightweight `pageRefreshed` event and re-fetches the render. That costs no rendering in the reader's
browser, and the latency is the refresh interval. A keystroke-level view, as HedgeDoc has, would mean
readers joining the room read-only and rendering it themselves. That sends the raw source, which is
`read:source` rather than `read:pages`, and puts the markdown pipeline in every reader's bundle.
---
## 8. Writes to the page while a draft exists
### 8.1 Refused
On a site with `features.drafts` on, and on any live document, while a draft exists:
- `PATCH /sites/:siteId/pages/:pageId` setting any **drafted** field ([§3.2](#32-what-a-draft-holds))
answers **409 `pageHasDraft`**, naming the contributors and `updatedAt`. Immediate fields go
through.
- Changing the editor (`PUT …/editor`) is refused with the same error: the draft is in the old
editor's format.
- Restoring a version onto the page is refused. "Restore into the draft" is the natural follow-up and
is left for later.
In the interface, Edit always opens the draft, so none of these come up there.
### 8.2 Not refused
- **Imports and storage sync** (`adoptStoredPage`, the git target's pull) write the page as they do
today. Their source is outside the wiki and is authoritative by design, so the draft goes stale
([§6.2](#62-staleness-and-merging)), or, on a live page, the next refresh overwrites the import.
- **Moves and deletion.** A draft follows its page id through a move. Deleting the page cascades to
the draft, and the delete dialog warns when there is one, naming the contributors. Restoring the
page from the recycle bin does not bring the draft back.
- **Re-rendering** (`queueRerender`, `storeRender`) changes the render, not the content.
### 8.3 Suggestions
Unchanged: per author, against the published page, reviewed in the approvals queue. People
suggesting edits never see a draft.
What changes is **accepting one while the page has a draft**, since accepting is a direct edit. The
proposal is to **accept into the draft**: the review screen applies the suggestion's patch to the
draft — joining the room as an ordinary participant if one is open — and the change reaches readers
with the next publish. The reviewer becomes a contributor. This keeps suggestions flowing during a
long draft, rather than queueing for its whole life. See [§14](#14-open-questions).
---
## 9. Permissions
No new permission names. Every route here is a page-rule check in the handler, with a
`No route-level permissions:` comment.
- **Seeing and editing a draft** takes `write:pages` or `manage:pages` on the page, from a signed-in
session. This is the rule for seeing an unpublished page (`UNPUBLISHED_PERMISSIONS`), and for the
same reason: work in progress belongs to people. **An API key never sees a draft**, and therefore
cannot write a drafted field on a page that has one.
- **Publishing and discarding** take the same, through one function, `drafts.mayPublish(actor,
page)`, so that the open question in [§14](#14-open-questions) changes one place.
- **Switching a page into or out of live mode** takes the same as publishing, since a live page
publishes on its own.
- **Joining the room** is unchanged: today's editor permission check.
---
## 10. API
All under `/_api/sites/:siteId/pages/:pageId/draft`, with schemas in `api/schemas/` as usual.
| Method | Path | Does |
| --- | --- | --- |
| `GET` | `/draft` | The draft: `content`, `meta`, `render`, `generation`, `isStale`, `baseVersionId`, contributors (`id`, `name`), `createdAt`, `updatedAt`. 404 when there is none |
| `PUT` | `/draft` | Save. Creates the draft on the current version when there is none. Body: any of `content`, `meta`, `render`, plus `expectedUpdatedAt` |
| `DELETE` | `/draft` | Discard ([§5.4](#54-discarding)) |
| `POST` | `/draft/publish` | Publish ([§6](#6-publishing)). Body: `content`, `meta`, `render`, `reasonForChange`, `publishState`/dates, `currentHash` when merged. Answers a `PageChange` |
| `POST` | `/draft/refresh` | Live documents only ([§7.1](#71-refresh)). 400 otherwise |
| `POST` | `/draft/checkpoint` | Live documents only. **Save version** ([§7.2](#72-checkpoints)), with an optional reason |
Plus **`GET /_api/sites/:siteId/drafts`** — the drafts on the site that the caller may see, with
`?mine=true` for the ones they contributed to. People working on something over days need a way back
to it that does not depend on remembering the page.
**Concurrency outside a room** follows the glossary's rule: `PUT` carries the `updatedAt` it was
opened with, compared at millisecond precision inside the UPDATE, and a mismatch answers 409 with the
draft as it now stands. **Inside a room**, the room is the concurrency control. The scribe's `PUT`
omits `expectedUpdatedAt`, and the server's persists write only `ydoc`, `contributorIds` and
`updatedAt`, so the two never write the same column.
**The page payload** gains `draft: { updatedAt, contributors } | null`, filled only for a requester
who may see it ([§9](#9-permissions)). That is what the banner and the Edit button read.
---
## 11. The browser buffer
**Implemented** (phase 1): `frontend/src/helpers/editorBuffer.js` is the storage,
`frontend/src/composables/editorBuffer.js` decides when to write and what to offer back.
The server draft covers almost everything. The browser keeps a copy of what the server has not yet
acknowledged: a session offline, a request that failed, the first session of a page that does not
exist yet, and the seconds between keystroke and save. Until the server keeps drafts (phase 2) it is
the only copy of an unsaved edit there is.
- **Only the editors that hold a document**: Markdown, AsciiDoc, Visual and Excalidraw. A
redirection and a blog's settings are a few fields each, quicker typed again than restored.
- **Only for a signed-in account.** Every guest is the same nobody.
- **IndexedDB**, one entry per user, site, mode and page, keyed `userId:siteId:mode:pageId` — an edit
and a suggestion of the same page are different work. A page being created has no id and no settled
path, and its route carries none to come back to, so it is keyed `userId:siteId:create:editor`:
opening a new page in the same editor is what offers it back, path and all. An entry holds the
source, the page fields a save sends (less the render, which the editor makes again, and the
password, which is not written to disk), `savedAt`, and the page's `updatedAt` as the base. Phase 2
adds the `baseHash` and `generation`.
- **Pending assets are kept as `File`s**, in a second object store so that the entry can be rewritten
every second without copying them. Their `blob:` URLs do not survive a reload, so a restore makes
new ones and rewrites the source to match. If the files will not fit, the entry is kept without
them: the text is the part worth keeping.
- **Plain snapshots, never Yjs state.** A persisted Yjs document from another generation is exactly
the hazard of [§5.3](#53-generations). `y-indexeddb` is therefore not used.
- Written **1 s** after a change, and at once when the editor closes or the document is hidden.
Deleted when nothing is unsaved any more: a save, somebody else saving the room, a submitted
suggestion, Discard.
- **On opening the editor**, an entry that differs from what was loaded is offered back: "*You made
changes to this page on … that were never saved — Discard / Restore*". It is asked once the editor
is ready (`editorStore.isReady`), which in a room means after the binding — the room's document
would otherwise overwrite the restore, and is what the entry is compared with. One matching it says
nothing and is dropped. The prompt warns when the page has been saved since, and when other people
are in the room, since a restore replaces the whole document. Restoring goes through the editor
(`restoreEditorContent`), so it is an edit like any other and reaches a room through the binding.
Phase 2 turns the stale case into a merge.
- **Cleared for that user on logout.** Every read and write is wrapped in try/catch, because private
windows and blocked site data both make IndexedDB throw. Losing the buffer means losing the safety
net, not the editor.
- **`beforeunload` and the router's leave guard** warn while anything in the editor is unsaved, for
every editor. With no server draft yet, "the buffer has it" is not a reason to let a tab close
unasked. Once phase 2 autosaves, this narrows to a change that neither the server nor the buffer
holds. Leaving through the guard is discarding, and clears the entry.
Assets in a draft: uploading still happens on save. **On a site using drafts that means on the
first draft save** after the asset was added, since co-authors cannot see a file that lives in one
person's browser. An asset therefore exists before the page that uses it is published. The draft's
`meta` records the asset ids it introduced, so that Discard can offer to delete them.
---
## 12. Audit log
Three new `page` actions in `AUDIT_ACTIONS`, each with its `admin.audit.actions.*` string:
| Action | Recorded when | `meta` |
| --- | --- | --- |
| `editDraft` | A person **first** becomes a contributor to a draft, or to a live page since its last checkpoint — through `PUT` or a room | page, generation |
| `publishDraft` | A draft is published | page, version id, contributor ids |
| `discardDraft` | A draft is discarded | page, contributor ids |
Not per autosave, which would bury the table, and not per refresh. Room contributions are recorded
by the collab controller, which has the upgrade request and its session in hand. That keeps them
written by a request handler for a person, as the audit log requires. Live checkpoints are the
scheduler's and leave no row.
---
## 13. Suggested order
Each phase ships on its own.
1. **The browser buffer and the unload guard** ([§11](#11-the-browser-buffer)). No schema; covers the
crash case immediately for every site.
2. **`pageDrafts`, direct mode, and `contributorIds`.** The table, the API, autosave through `PUT`,
the scribe in rooms writing `content` (no `ydoc` yet), resume or discard of a leftover buffer, the
purge task.
3. **Draft mode.** `features.drafts`, Publish / Save draft / Discard, the banner, the refusals of
[§8](#8-writes-to-the-page-while-a-draft-exists), the render-permission intersection, the audit
actions, the "Not published" relabel.
4. **Rooms persist into the draft.** `ydoc`, seeding from it, merge-on-write, generations, the grace
period, `discarded`.
5. **Live documents.** `liveDocument`, refresh, checkpoints and their task, the synthetic history
entry.
6. **Later:** the stale-draft merge view, preview at `?draft`, the drafts list, accepting
suggestions into a draft, readers watching live documents, restoring a version into a draft.
---
## 14. Open questions
1. **Does publishing need a permission of its own?** Undecided. The first version uses
`write:pages`, through `drafts.mayPublish` alone. A separate page permission would let a group
draft without publishing — but approval rules already cover "somebody else signs it off", and
adding a permission is the maintainer's decision.
2. **Accepting a suggestion while a draft exists.** Proposed: accept into the draft
([§8.3](#83-suggestions)). Alternatives: refuse until the draft is published, or accept onto the
page and let the draft go stale.
3. **Should drafts be reachable by API keys?** Proposed: no, consistent with unpublished pages. The
cost is that automation cannot touch the content of a page somebody is drafting.
4. **The default of `features.drafts` for new sites.** Proposed: off, like existing sites.
5. **Should the timings be site settings** — autosave, refresh, idle checkpoint, the 60-minute
ceiling, the 30-day purge? Proposed: constants until somebody asks.
6. **Should a site be able to forbid live documents?** Proposed: not needed — `features.collaborativeEditing`
off already does, and per page it is an editor's choice.
---
## 15. Alternatives considered
- **A revisions table, with the page pointing at its published version.** Cleaner on paper. But
every reader of `pages.content` and `pages.render` would have to follow the pointer, and the
denormalized row is exactly what lets them not care.
- **Per-user drafts**, like `pageEditSubmissions`. They do not give "several people, several days"
without merging drafts into each other, and the decision was a shared draft per page.
- **Folding suggestions into drafts.** Rejected: a suggestion from somebody who may only read is a
proposal that may be declined, and a draft is the editors' own work. The two have different
audiences, lifetimes and permissions.
- **A draft as a hidden copy of the page at another path.** It collides with the tree, links,
search, ids and storage targets, which would all have to learn to ignore it.
- **Autosaving onto the page and merging recent history rows**, overwriting the newest version while
the same author keeps editing. It spares history, but not search, git, webhooks or notifications. It
also changes a version that an audit entry already points at by id.
- **Auto-publishing on the server when a room empties.** It needs Puppeteer, works for markdown only,
and needs a cluster-wide answer to "is the room empty". The scribe plus checkpoints
([§7](#7-live-documents)) get the same result for every editor.
- **Electing the last participant to publish as they leave.** The last participant is exactly the one
whose tab just crashed.
- **`y-indexeddb` for the browser buffer.** It merges stale Yjs state back into a room that may have
been re-seeded since ([§5.3](#53-generations)). Plain snapshots plus a merge prompt do not have
that failure.
+48 -8
View File
@@ -300,7 +300,12 @@ import {
import { useI18n } from 'vue-i18n'
import { useBlockCompletions } from '@/composables/blockCompletions'
import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab'
import {
bindCollabEditor,
isCollabActive,
startCollabSession,
stopCollabSession
} from '@/composables/collab'
import { dialog } from '@/composables/dialog'
import { useImagePropertiesLens } from '@/composables/imagePropertiesLens'
import { notify } from '@/composables/notify'
@@ -1380,6 +1385,21 @@ function reloadEditorContent({ replacements = [] } = {}) {
}
}
/**
* Put back a whole source the author wrote and never saved — see `composables/editorBuffer.js`.
*
* One edit over the full range rather than `setValue`, for the reasons the Markdown editor's copy of
* this gives: it stays undoable, and in a collaborative session it reaches the room through the
* binding as this author's change.
*/
function restoreEditorContent({ content }) {
const model = editor.getModel()
editor.pushUndoStop()
editor.executeEdits('restore', [{ range: model.getFullModelRange(), text: content ?? '' }])
editor.pushUndoStop()
syncContentToStore.flush()
}
// MOUNTED
onMounted(async () => {
@@ -1611,19 +1631,31 @@ onMounted(async () => {
collabWatchers.push(
watch(
() => collabStore.status,
(status) => {
if (status === 'connected') {
bindCollabEditor(editor)
}
if (status !== 'connecting') {
editor.updateOptions({ readOnly: false })
}
async (status) => {
if (status === 'denied') {
notify({
type: 'warning',
message: t('editor.collab.notAllowed')
})
}
if (status === 'connecting') {
return
}
editor.updateOptions({ readOnly: false })
if (status === 'connected') {
await bindCollabEditor(editor)
// -> The editor was closed while the binding loaded, and is not ready for anything
if (!isCollabActive()) {
return
}
}
/*
Holding the room's document now, or carrying on as a plain editor without one -- and the
store says so before anything asks: what the room holds may be somebody's unsaved edit,
and the debounce would leave the store on the loaded page for another half second.
*/
syncContentToStore.flush()
editorStore.isReady = true
}
),
/*
@@ -1656,6 +1688,13 @@ onMounted(async () => {
EVENT_BUS.on('insertBlock', insertBlockClb)
EVENT_BUS.on('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
EVENT_BUS.on('restoreEditorContent', restoreEditorContent)
// -> Ready now, unless a room is still to answer -- the status watcher above says when that is.
// Not if the editor was closed while this hook was still awaiting.
if (!collabEnabled.value && editorStore.isActive) {
editorStore.isReady = true
}
})
onBeforeUnmount(() => {
@@ -1668,6 +1707,7 @@ onBeforeUnmount(() => {
EVENT_BUS.off('insertBlock', insertBlockClb)
EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
EVENT_BUS.off('restoreEditorContent', restoreEditorContent)
pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true)
monacoRef.value?.removeEventListener('dragover', onEditorDragOver)
monacoRef.value?.removeEventListener('drop', onEditorDrop)
+61 -2
View File
@@ -139,6 +139,9 @@ const adopting = new Set()
/** Bumped per sync, so a slow export cannot overwrite a newer one. */
let syncToken = 0
/** The source as this editor last wrote it to the store, which is what a change is measured against. */
let lastWritten = null
// COMPUTED
/**
@@ -197,9 +200,16 @@ async function writeToStore({ touch }) {
contentLoaded: true
})
pageStore.setRender(render)
if (touch) {
/*
An edit only when the drawing actually changed. Joining a room runs through here too -- the room's
drawing goes onto the canvas and into the store -- and for a room holding exactly what was loaded
that is nothing anybody did; marking it made every visit unsaved, which the leave warning then
asked about on the way out of a drawing nobody had touched.
*/
if (touch && content !== lastWritten) {
editorStore.lastChangeTimestamp = Temporal.Now.instant()
}
lastWritten = content
}
/**
@@ -365,6 +375,31 @@ function reloadEditorContent({ replacements = [] } = {}) {
pageStore.setRender(render)
}
/**
* Put back a whole drawing the author made and never saved — see `composables/editorBuffer.js`.
*
* Through the canvas, as a change the author made: Excalidraw reports it to `onChange` like any other,
* which publishes it to the room and writes it to the store, and it is recorded for undo. Its files go
* in first, since an image element drawn before its file arrives draws as a placeholder. The pictures
* pasted in the lost session are pending assets again by now, and their entries already point at the
* new `blob:` URLs the buffer made for them.
*
* Only the background is taken from the stored app state: the rest of it is how the canvas is being
* looked at, which belongs to the author in front of it rather than to the drawing.
*/
function restoreEditorContent({ content }) {
if (!handle) {
return
}
const scene = parseScene(content)
handle.addFiles(Object.values(scene.files))
handle.updateScene({
elements: scene.elements,
appState: { viewBackgroundColor: scene.appState.viewBackgroundColor },
captureUpdate: CaptureUpdateAction.IMMEDIATELY
})
}
/** Take the room's drawing onto this canvas. */
function applyRemote() {
const handles = collabHandles()
@@ -439,8 +474,11 @@ onMounted(async () => {
onPointerUpdate: publishPointer
})
state.ready = true
// -> What the loaded drawing serializes to, set before a room can answer and be measured against it
lastWritten = serializeScene(handle.getScene())
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
EVENT_BUS.on('restoreEditorContent', restoreEditorContent)
/*
Live collaboration. Unlike the text editors there is nothing to lock while the room answers: a
@@ -457,13 +495,27 @@ onMounted(async () => {
collabWatchers.push(
watch(
() => collabStore.status,
(status) => {
async (status) => {
if (status === 'connected') {
enableCollab()
}
if (status === 'denied') {
notify({ type: 'warning', message: t('editor.collab.notAllowed') })
}
if (status === 'connecting') {
return
}
/*
Holding the room's drawing now, or carrying on alone without one -- and the store says so
before anything asks: what the room holds may be somebody's unsaved edit, and the debounce
would leave the store on the loaded drawing for a while yet.
*/
syncToStore.cancel()
await writeToStore({ touch: true })
// -> Closed while the export ran
if (handle) {
editorStore.isReady = true
}
}
),
watch(
@@ -486,6 +538,12 @@ onMounted(async () => {
neither until this runs. Not an edit, so it does not touch the change clock -- see `writeToStore`.
*/
await writeToStore({ touch: false })
// -> Ready now, unless a room is still to answer -- the status watcher above says when that is.
// Not if the editor was closed while this hook was still awaiting.
if (!collabEnabled.value && editorStore.isActive && handle) {
editorStore.isReady = true
}
})
/*
@@ -512,6 +570,7 @@ onBeforeUnmount(() => {
stopCollabSession()
collaborating = false
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
EVENT_BUS.off('restoreEditorContent', restoreEditorContent)
handle?.destroy()
handle = null
})
+49 -8
View File
@@ -342,7 +342,12 @@ import {
import { useI18n } from 'vue-i18n'
import { useBlockCompletions } from '@/composables/blockCompletions'
import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab'
import {
bindCollabEditor,
isCollabActive,
startCollabSession,
stopCollabSession
} from '@/composables/collab'
import { dialog } from '@/composables/dialog'
import { useFenceCompletions } from '@/composables/fenceCompletions'
import { useImagePropertiesLens } from '@/composables/imagePropertiesLens'
@@ -1635,6 +1640,22 @@ function reloadEditorContent({ replacements = [] } = {}) {
}
}
/**
* Put back a whole source the author wrote and never saved — see `composables/editorBuffer.js`.
*
* The one case where replacing everything IS the edit, so it is done as one: a single edit over the
* full range rather than `setValue`, which keeps it undoable and, in a collaborative session, carries
* it into the room through the binding as this author's change. Only asked for once `isReady` says
* the binding is in place, since a source put in before it would be overwritten by the room's.
*/
function restoreEditorContent({ content }) {
const model = editor.getModel()
editor.pushUndoStop()
editor.executeEdits('restore', [{ range: model.getFullModelRange(), text: content ?? '' }])
editor.pushUndoStop()
syncContentToStore.flush()
}
// MOUNTED
onMounted(async () => {
@@ -1939,19 +1960,31 @@ onMounted(async () => {
collabWatchers.push(
watch(
() => collabStore.status,
(status) => {
if (status === 'connected') {
bindCollabEditor(editor)
}
if (status !== 'connecting') {
editor.updateOptions({ readOnly: false })
}
async (status) => {
if (status === 'denied') {
notify({
type: 'warning',
message: t('editor.collab.notAllowed')
})
}
if (status === 'connecting') {
return
}
editor.updateOptions({ readOnly: false })
if (status === 'connected') {
await bindCollabEditor(editor)
// -> The editor was closed while the binding loaded, and is not ready for anything
if (!isCollabActive()) {
return
}
}
/*
Holding the room's document now, or carrying on as a plain editor without one -- and the
store says so before anything asks: what the room holds may be somebody's unsaved edit,
and the debounce would leave the store on the loaded page for another half second.
*/
syncContentToStore.flush()
editorStore.isReady = true
}
),
/*
@@ -1985,6 +2018,13 @@ onMounted(async () => {
EVENT_BUS.on('insertBlock', insertBlockClb)
EVENT_BUS.on('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
EVENT_BUS.on('restoreEditorContent', restoreEditorContent)
// -> Ready now, unless a room is still to answer -- the status watcher above says when that is.
// Not if the editor was closed while this hook was still awaiting.
if (!collabEnabled.value && editorStore.isActive) {
editorStore.isReady = true
}
})
onBeforeUnmount(() => {
@@ -1998,6 +2038,7 @@ onBeforeUnmount(() => {
EVENT_BUS.off('insertBlock', insertBlockClb)
EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
EVENT_BUS.off('restoreEditorContent', restoreEditorContent)
pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true)
monacoRef.value?.removeEventListener('dragover', onEditorDragOver)
monacoRef.value?.removeEventListener('drop', onEditorDrop)
+48 -1
View File
@@ -1291,12 +1291,26 @@ function onEditorDrop(event) {
*/
const collabWatchers = []
/**
* The source as this editor last wrote it, which is what a change is measured against.
*
* Measured against the editor's OWN serialization rather than the page store's: what was loaded is
* the stored markdown, which the serializer may spell differently without anything having changed,
* and comparing the two made merely joining a collaborative room count as an edit -- `enableCollab`
* writes the room's document to the store, and an unchanged one was still marked unsaved, which the
* leave warning then asked about on the way out of a page nobody had touched.
*/
let lastSerialized = null
const syncContentToStore = debounce(() => {
if (!editor) {
return
}
const markdown = editor.getMarkdown()
editorStore.$patch({ lastChangeTimestamp: Temporal.Now.instant() })
if (markdown !== lastSerialized) {
lastSerialized = markdown
editorStore.$patch({ lastChangeTimestamp: Temporal.Now.instant() })
}
pageStore.$patch({
content: markdown,
// -> What the author has written IS the source, whatever the load did or did not deliver; see
@@ -1334,6 +1348,21 @@ function reloadEditorContent({ replacements = [] } = {}) {
syncContentToStore.flush()
}
/**
* Put back a whole source the author wrote and never saved — see `composables/editorBuffer.js`.
*
* Parsed into the document as one transaction, which in a collaborative session `ySyncPlugin` carries
* into the room as this author's change. Only asked for once `isReady` says the room's document is in
* place, since one put in before it would be replaced by the room's.
*/
function restoreEditorContent({ content }) {
if (!editor) {
return
}
editor.setMarkdown(content ?? '')
syncContentToStore.flush()
}
/** Which blocks this site has, for the parameters form and for saying what is switched off. */
async function loadSiteBlocks() {
try {
@@ -1403,6 +1432,8 @@ onMounted(async () => {
onChange: syncContentToStore,
onSelectionChange: refreshActive
})
// -> What the loaded page serializes to, before anybody has changed anything
lastSerialized = editor.getMarkdown()
EVENT_BUS.on('insertAsset', insertAssetClb)
EVENT_BUS.on('insertBlock', insertBlockClb)
@@ -1444,6 +1475,13 @@ onMounted(async () => {
}
if (status !== 'connecting') {
editor.view.setProps({ editable: () => true })
/*
Holding the room's document now, or carrying on as a plain editor without one -- and the
store says so before anything asks: what the room holds may be somebody's unsaved edit,
and the debounce would leave the store on the loaded page for another half second.
*/
syncContentToStore.flush()
editorStore.isReady = true
}
if (status === 'denied') {
notify({ type: 'warning', message: t('editor.collab.notAllowed') })
@@ -1478,6 +1516,14 @@ onMounted(async () => {
refreshActive()
editor.view.focus()
})
EVENT_BUS.on('restoreEditorContent', restoreEditorContent)
// -> Ready now, unless a room is still to answer -- the status watcher above says when that is.
// Not if the editor was closed while this hook was still awaiting.
if (!collabEnabled.value && editorStore.isActive) {
editorStore.isReady = true
}
})
onBeforeUnmount(() => {
@@ -1497,6 +1543,7 @@ onBeforeUnmount(() => {
EVENT_BUS.off('insertTable', insertTableClb)
EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
EVENT_BUS.off('restoreEditorContent', restoreEditorContent)
editor?.destroy()
editor = null
})
+18 -17
View File
@@ -140,6 +140,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { confirmDiscardUnsaved } from '@/composables/editorBuffer'
import { useMinWidth } from '@/composables/screen'
import { splitLocalePath } from '@/helpers/pagePaths'
@@ -257,28 +258,28 @@ onBeforeUnmount(() => {
* Out of the editor, on the way home. The navigation itself is the link's.
*
* The logo is the way out of any screen the wiki can get into, which is why it is drawn in every one
* of them -- so it has to close the editor as well as go home. Leaving the editor is otherwise the
* job of the page view's route watcher, and that cannot help here: the editor opens WITHOUT changing
* the URL, so an author editing the home page is already at `/`, the router treats the push as a
* duplicate and runs no navigation at all, and the watcher never fires. The result was a logo that
* looked broken on the one page it is most likely to be tried from.
* of them -- so it has to be a way out of the editor as well. Almost always the link does that on its
* own: the editor has a path of its own (`/_edit/...`, `/_create/...`), so the click is a real
* navigation, the page view's guard asks about anything unsaved, and its route watcher closes the
* editor. Nothing is done here in that case, and must not be: closing the editor before the router
* runs left the guard looking at no editor at all, so unsaved edits were thrown away without a word.
*
* The page is re-read in exactly that case. Nothing else is going to: the store holds what the editor
* last wrote into it -- the source AND the render -- so closing the editor over it would draw
* somebody's unsaved draft as though it were the page. Where the click really does navigate, the
* destination is loaded anyway and a reload here would be a second request for a page already on its
* way.
*
* A page being CREATED never reaches that branch: it is written at `/_create/...`, which is never the
* home path, so the click is a real navigation and the page view's watcher does the rest.
* What is left is the click that goes NOWHERE, which no guard or watcher will ever see: a suggestion
* being written on the home page, which is written at the page's own address. The router treats the
* push as a duplicate, so this asks the guard's question itself, then closes the editor. The page is
* re-read in that case too, since nothing else is going to: the store holds what the editor last wrote
* into it -- the source AND the render -- and closing the editor over it would draw somebody's
* unsaved draft as though it were the page.
*/
function leaveEditorForHome() {
if (!editorStore.isActive) {
async function leaveEditorForHome() {
if (!editorStore.isActive || route.path !== homePath.value) {
return
}
if (!(await confirmDiscardUnsaved(t))) {
return
}
const isSamePlace = route.path === homePath.value
editorStore.closeEditor()
if (isSamePlace && pageStore.id) {
if (pageStore.id) {
// -> Failing to put the page back is not a reason to keep the reader in an editor they have just
// left; the view draws what it has
pageStore.pageLoad({ id: pageStore.id }).catch(() => {})
+10 -3
View File
@@ -609,6 +609,16 @@ function onEditableBlur(field, event) {
}
async function discardChanges() {
const hadPendingChanges = editorStore.hasPendingChanges
const wasSuggesting = isSuggesting.value
/*
Thrown away, which is said before anything navigates: the router asks before leaving an editor
with unsaved edits, and the browser buffer keeps whatever is unsaved when the editor closes --
neither of which is what the author just asked for.
*/
editorStore.settleChanges()
/*
Abandoning a page that is being written, which is a different thing from reverting an edit: there
is nothing stored to go back to, so the editor closes and the reader is put back on the site.
@@ -634,9 +644,6 @@ async function discardChanges() {
return
}
const hadPendingChanges = editorStore.hasPendingChanges
const wasSuggesting = isSuggesting.value
loading.show()
try {
/*
+407
View File
@@ -0,0 +1,407 @@
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { debounce } from 'es-toolkit/function'
import { pick } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { confirm } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import {
bufferKey,
deleteBufferEntry,
readBufferEntry,
readBufferFiles,
writeBufferEntry
} from '@/helpers/editorBuffer'
import { useCollabStore } from '@/stores/collab'
import { useEditorStore } from '@/stores/editor'
import { SAVED_PAGE_FIELDS, usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
/**
* Keep what is typed into an editor in this browser until it is saved, and offer it back if it never
* was -- a closed tab, a crashed browser, a laptop that ran out of battery.
*
* Phase 1 of `dev/specs/drafts.md` (§11): the server keeps nothing until a save, so this browser is
* the only place an unsaved edit can survive the document that holds it. The storage is
* `helpers/editorBuffer.js`; this decides when to write to it and what to do with what is there.
*
* **Only the editors that hold a document.** Markdown, AsciiDoc, Visual and Excalidraw. A redirection
* and a blog's settings are a handful of fields each, quicker typed again than restored, and neither
* editor is asked to take a document from outside.
*
* **Only for a signed-in account.** Every guest is the same nobody, and a suggestion typed on a shared
* computer by one of them is not something to offer to the next.
*
* The life of an entry:
*
* - **Written** a second after a change, while there is anything unsaved, and once more when the
* editor closes or the document is hidden -- the last chance a closing tab gives.
* - **Deleted** when nothing is unsaved any more: a save, somebody else saving the room this author
* is in, a submitted suggestion, and Discard, which settles the editor first so that this sees
* nothing left to keep.
* - **Offered back** when the editor next opens on the same page, once it is ready to take a
* document -- after a collaborative room has synced, since the room's binding would otherwise
* overwrite it. An entry that matches what was loaded says nothing and is dropped in silence.
*
* Restoring goes through the editor (`restoreEditorContent`) rather than into the page store, so that
* it is an edit like any other: undoable where the editor allows it, and carried into a collaborative
* room by the binding as this author's change.
*
* Called once, from the page view, which is what mounts the editors.
*/
/** How long after a change the buffer is written. The window an unsaved keystroke can be lost in. */
const WRITE_DELAY = 1000
/** The editors whose work is kept. See the note above on why not the other two. */
const BUFFERED_EDITORS = new Set(['markdown', 'asciidoc', 'visual', 'excalidraw'])
/**
* The page fields kept beside the source: what a save sends, less the render -- the editor makes that
* again from the source -- and the password, which is not written to disk for the sake of a restore.
*/
const BUFFERED_FIELDS = SAVED_PAGE_FIELDS.filter(
(field) => !['content', 'render', 'password'].includes(field)
)
/**
* And for a page being created, where it was going to go. An edit has a path already, and changing
* it is a move, which is not something a save does.
*/
const BUFFERED_CREATE_FIELDS = [...BUFFERED_FIELDS, 'path', 'locale']
/**
* Ask whether to throw away what is unsaved in the editor, before something closes it.
*
* Resolves true straight away when there is nothing to lose. A yes is settled before it resolves, so
* that the buffer does not keep what the author has just said to throw away, and so that the router's
* guard, which asks this same question, does not ask it a second time on the navigation that follows.
*
* The router's guard in `pages/Index.vue` is the usual caller. Anything that closes the editor WITHOUT
* a navigation the router would see has to ask here itself -- the header logo, clicked on the page the
* author is already at.
*
* @param {Function} t The caller's `t`; there is no i18n instance to reach from outside a component.
* @returns {Promise<boolean>} Whether the editor may close.
*/
export function confirmDiscardUnsaved(t) {
const editorStore = useEditorStore()
if (!editorStore.isActive || !editorStore.hasUnsavedEdits) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
confirm({
title: t('editor.unsaved.title'),
message: t('editor.unsaved.body'),
cancel: true,
color: 'negative',
okLabel: t('common.actions.discard')
})
.onOk(() => {
editorStore.settleChanges()
resolve(true)
})
.onCancel(() => resolve(false))
})
}
export function useEditorBuffer() {
const { t } = useI18n()
const collabStore = useCollabStore()
const editorStore = useEditorStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
/**
* The editor being kept: the key it is filed under, decided when it opened -- a page that is
* created mid-session gets an id, and the entry is still the one it started as -- and whether
* writing has begun. Not before an entry already there has been dealt with, since the first write
* would replace it.
*/
let session = null
// -> Bumped per session, so that an answer arriving for one that has since closed is dropped
let sessionSeq = 0
function fieldsFor(mode) {
// -> A suggestion is the source and nothing else; the page's fields are not the submitter's to change
if (mode === 'suggest') {
return []
}
return mode === 'create' ? BUFFERED_CREATE_FIELDS : BUFFERED_FIELDS
}
/**
* The edit as it stands, as plain data.
*
* The fields go through JSON: they are what a save sends as JSON anyway, and a store's arrays and
* objects are reactive proxies that IndexedDB refuses to clone.
*/
function snapshot(key, mode) {
return {
key,
userId: userStore.id,
siteId: siteStore.id,
pageId: pageStore.id || null,
mode,
editor: editorStore.editor,
savedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }),
baseUpdatedAt: pageStore.updatedAt || null,
content: pageStore.content ?? '',
fields: JSON.parse(JSON.stringify(pick(pageStore, fieldsFor(mode)))),
assets: editorStore.pendingAssets.map(({ id, kind, fileName, blobUrl }) => ({
id,
kind,
fileName,
blobUrl
}))
}
}
/** Write what is unsaved, or forget the entry when nothing is. */
function persist() {
if (!session?.armed) {
return
}
// -> Signed out since the editor opened: what was kept for that account has just been cleared,
// and a write landing now would put it straight back
if (!userStore.authenticated || userStore.id !== session.userId) {
return
}
if (editorStore.hasUnsavedEdits) {
// -> Taken now, synchronously: by the time the write lands the store may hold another page
const entry = snapshot(session.key, session.mode)
const files = editorStore.pendingAssets.map(({ id, file }) => ({ id, file }))
writeBufferEntry(entry, files)
} else {
deleteBufferEntry(session.key)
}
}
const schedulePersist = debounce(persist, WRITE_DELAY)
function arm() {
session.armed = true
if (editorStore.hasUnsavedEdits) {
schedulePersist()
}
}
/** Whether an entry holds anything the editor that just opened does not. */
function differsFromLoaded(entry) {
if ((entry.content ?? '') !== (pageStore.content ?? '')) {
return true
}
const fields = JSON.parse(JSON.stringify(pick(pageStore, fieldsFor(entry.mode))))
return !isEqual(entry.fields ?? {}, fields)
}
/** Two instants as the API or the room spelled them, compared as instants rather than as strings. */
function sameInstant(a, b) {
try {
return Temporal.Instant.compare(Temporal.Instant.from(a), Temporal.Instant.from(b)) === 0
} catch {
return a === b
}
}
/** Ask whether to put an entry back. Resolves true to restore, false to throw it away. */
function ask(entry) {
const date = userStore.formatDateTime(t, entry.savedAt)
const message = [
entry.mode === 'create'
? t('editor.restore.bodyNewPage', { date })
: t('editor.restore.body', { date })
]
// -> Saved by somebody since: the entry is the whole page as this author had it, so restoring it
// puts back what was there before that save as well
if (
entry.mode === 'edit' &&
entry.baseUpdatedAt &&
pageStore.updatedAt &&
!sameInstant(entry.baseUpdatedAt, pageStore.updatedAt)
) {
message.push(t('editor.restore.stale'))
}
// -> And replaced for everybody in the room, who may well have carried on without this author
if (collabStore.participants.some((participant) => !participant.isSelf)) {
message.push(t('editor.restore.othersEditing'))
}
return new Promise((resolve) => {
confirm({
title: t('editor.restore.title'),
message,
cancel: true,
persistent: true,
okLabel: t('editor.restore.restore'),
cancelLabel: t('common.actions.discard')
})
.onOk(() => resolve(true))
.onCancel(() => resolve(false))
})
}
/**
* Put an entry back into the open editor.
*
* Pending assets come first: their old `blob:` URLs died with the document that made them, so each
* file gets a new one and the source is rewritten to match before the editor sees it. Then the page
* fields, straight into the store as the properties panel writes them -- the three a room shares go
* on from there through its own watcher. Then the source, through the editor.
*/
async function restore(entry) {
let content = entry.content ?? ''
const files = new Map((await readBufferFiles(entry.key)).map(({ id, file }) => [id, file]))
for (const asset of entry.assets ?? []) {
const file = files.get(asset.id)
if (!file || editorStore.pendingAssets.some((item) => item.id === asset.id)) {
continue
}
const blobUrl = URL.createObjectURL(file)
content = content.split(asset.blobUrl).join(blobUrl)
editorStore.pendingAssets.push({
id: asset.id,
kind: asset.kind,
file,
fileName: asset.fileName,
blobUrl
})
}
if (Object.keys(entry.fields ?? {}).length > 0) {
pageStore.$patch(entry.fields)
}
EVENT_BUS.emit('restoreEditorContent', { content })
// -> Unsaved again, whatever the editor makes of the source: the fields alone are a change
editorStore.lastChangeTimestamp = Temporal.Now.instant()
notify({ type: 'positive', message: t('editor.restore.restored') })
}
/** The editor is ready: deal with what is kept for it, then start keeping. */
async function begin() {
const seq = ++sessionSeq
const mode = editorStore.mode
const key =
userStore.authenticated && BUFFERED_EDITORS.has(editorStore.editor)
? bufferKey({
userId: userStore.id,
siteId: siteStore.id,
mode,
pageId: pageStore.id,
editor: editorStore.editor
})
: null
session = { key, mode, userId: userStore.id, armed: false }
if (!key) {
return
}
const entry = await readBufferEntry(key)
if (seq !== sessionSeq) {
return
}
// -> A page converted to another editor since: that source would mean nothing to this one
if (!entry || entry.editor !== editorStore.editor || !differsFromLoaded(entry)) {
if (entry) {
await deleteBufferEntry(key)
}
arm()
return
}
const accepted = await ask(entry)
if (seq !== sessionSeq) {
return
}
if (accepted) {
await restore(entry)
if (seq !== sessionSeq) {
return
}
} else {
await deleteBufferEntry(key)
}
arm()
}
/** The editor has closed: one last write, or the entry goes if there is nothing left to keep. */
function end() {
sessionSeq++
schedulePersist.cancel()
persist()
session = null
}
/*
Synchronous, because the moment the editor closes is the moment that matters and it does not last:
leaving the editor closes it BEFORE the next page is loaded into the store (see the route watcher
in `pages/Index.vue`), so in this tick the store still holds the page that was being edited.
*/
watch(
() => editorStore.isReady,
(ready) => (ready ? begin() : session && end()),
{ flush: 'sync' }
)
watch(
() => [
editorStore.lastChangeTimestamp,
editorStore.lastSaveTimestamp,
editorStore.pendingAssets.length
],
() => {
if (session?.armed) {
schedulePersist()
}
}
)
/*
A tab being closed, or put away on a phone that may never bring it back: write now rather than in a
second's time, which is the one chance there is. IndexedDB may not finish before the document goes,
but it usually does, and the browser's own leave warning covers the rest.
*/
function onHidden() {
if (document.visibilityState === 'hidden') {
flushNow()
}
}
function flushNow() {
if (session?.armed) {
schedulePersist.flush()
}
}
/*
And the warning itself, for a document being unloaded with something in the editor that has not
been saved. The browser writes the words; all a page can do is ask for the prompt. Raised for every
editor, the two that keep nothing included -- the prompt costs nothing to dismiss, and the loss it
stops is silent.
*/
function onBeforeUnload(event) {
if (editorStore.isActive && editorStore.hasUnsavedEdits) {
event.preventDefault()
// -> Still what some browsers look at, in place of `preventDefault` alone
event.returnValue = ''
}
}
onMounted(() => {
document.addEventListener('visibilitychange', onHidden)
window.addEventListener('pagehide', flushNow)
window.addEventListener('beforeunload', onBeforeUnload)
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onHidden)
window.removeEventListener('pagehide', flushNow)
window.removeEventListener('beforeunload', onBeforeUnload)
if (session) {
end()
}
})
}
+234
View File
@@ -0,0 +1,234 @@
/**
* The browser's copy of an edit nobody has saved yet, in IndexedDB.
*
* This file is the storage alone; when to write, when to offer an entry back and how it is put into
* an editor are `composables/editorBuffer.js`. See `dev/specs/drafts.md` §11 for the design.
*
* **Every call here fails quietly.** A private window, blocked site data, a full disk and a browser
* with no IndexedDB at all each make it throw, and none of them is a reason for the editor to stop
* working -- losing the buffer is losing a safety net, not the edit in front of the author. So each
* export catches, warns once, and answers as though there were nothing stored.
*
* Two object stores, because the pictures pasted into an edit are kept too:
*
* - `entries` -- one per user and page (see `bufferKey`): the source, the page fields and a list of
* the pending assets. Rewritten whole on every write, a second after a change.
* - `files` -- one per pending asset, holding its `File`. Separate so that the entry can be
* rewritten every second without copying a 20 MB screenshot along with it each time: a file is
* written once, when it first appears, and deleted with the entry or once the edit stops using it.
* Their `blob:` URLs die with the document that made them, so the file is the only thing worth
* keeping; a restore makes new URLs and rewrites the source to match.
*/
const DB_NAME = 'wiki-editor-buffer'
const DB_VERSION = 1
const ENTRIES = 'entries'
const FILES = 'files'
/** The open database, or the failure to open it -- kept either way, so a refusal is not retried every second. */
let dbPromise = null
let warned = false
function warnOnce(err) {
if (!warned) {
warned = true
console.warn(`Unsaved edits cannot be kept in this browser: ${err?.message ?? err}`)
}
}
function openDb() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
db.createObjectStore(ENTRIES, { keyPath: 'key' }).createIndex('userId', 'userId')
db.createObjectStore(FILES, { keyPath: 'id' }).createIndex('key', 'key')
}
request.onsuccess = () => {
const db = request.result
// -> Another tab opening a newer version of this database has to be able to; this one goes
// on without a buffer rather than blocking it
db.onversionchange = () => db.close()
resolve(db)
}
request.onerror = () => reject(request.error)
request.onblocked = () => reject(new Error('The editor buffer database is blocked.'))
})
}
return dbPromise
}
/** One request as a promise. */
function done(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
/**
* Run `work` in one transaction, resolving with what it returned once the transaction has COMMITTED
* rather than once its last request answered -- a write is only kept when the whole of it is.
*/
async function transact(stores, mode, work) {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(stores, mode)
let result
tx.oncomplete = () => resolve(result)
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(tx.error ?? new Error('The editor buffer write was aborted.'))
Promise.resolve(work(tx)).then(
(value) => {
result = value
},
(err) => {
try {
tx.abort()
} catch {
// -> Already finished, which the handlers above have dealt with
}
reject(err)
}
)
})
}
/** Delete one entry's files, keeping those named in `keep`. Part of a transaction already open. */
async function pruneFiles(tx, key, keep = new Set()) {
const files = tx.objectStore(FILES)
const ids = await done(files.index('key').getAllKeys(key))
await Promise.all(ids.filter((id) => !keep.has(id)).map((id) => done(files.delete(id))))
}
/**
* Where an edit is kept.
*
* Per user, so that an account signed in after another on the same browser is never offered what the
* first one wrote. Per site and page, and per MODE, because a suggestion and an edit of the same page
* are two different pieces of work that happen to share a source.
*
* A page being created has no id to be filed under, nor a path that is settled -- the save-as dialog
* is where that is decided, and the create route carries no path of its own to come back to after a
* crash. So it is filed under its editor: opening a new page with the same editor is what offers it
* back, with the path it was going to have.
*
* @returns {?string} Null when there is nobody to file it under.
*/
export function bufferKey({ userId, siteId, mode, pageId, editor }) {
if (!userId || !siteId) {
return null
}
if (mode === 'create') {
return editor ? `${userId}:${siteId}:create:${editor}` : null
}
return pageId ? `${userId}:${siteId}:${mode}:${pageId}` : null
}
/** @returns {Promise<?object>} The entry, or null for none -- and for a buffer that cannot be read. */
export async function readBufferEntry(key) {
try {
return (
(await transact([ENTRIES], 'readonly', (tx) => done(tx.objectStore(ENTRIES).get(key)))) ??
null
)
} catch (err) {
warnOnce(err)
return null
}
}
/** @returns {Promise<Array<{ id: string, file: File }>>} The files kept with an entry. */
export async function readBufferFiles(key) {
try {
const rows = await transact([FILES], 'readonly', (tx) =>
done(tx.objectStore(FILES).index('key').getAll(key))
)
return (rows ?? []).map(({ id, file }) => ({ id, file }))
} catch (err) {
warnOnce(err)
return []
}
}
/**
* Keep an edit, replacing whatever was kept for it before.
*
* Files already stored are left where they are, and the ones the edit no longer uses go. When the
* files are what will not fit -- a quota is the likely failure, and a video is the likely cause -- the
* entry is written again without them: the text is the part worth keeping, and a restored page with a
* broken image in it is far better than no restore at all.
*
* @param {object} entry Plain data; nothing reactive survives being cloned into IndexedDB.
* @param {Array<{ id: string, file: File }>} files The pending assets the entry lists.
* @returns {Promise<boolean>} Whether it was kept.
*/
export async function writeBufferEntry(entry, files = []) {
const write = (withFiles) =>
transact([ENTRIES, FILES], 'readwrite', async (tx) => {
const store = tx.objectStore(FILES)
const keep = new Set()
if (withFiles) {
const stored = new Set(await done(store.index('key').getAllKeys(entry.key)))
for (const { id, file } of files) {
keep.add(id)
if (!stored.has(id)) {
await done(store.put({ id, key: entry.key, file }))
}
}
}
await pruneFiles(tx, entry.key, keep)
await done(tx.objectStore(ENTRIES).put(entry))
})
try {
await write(true)
return true
} catch (err) {
if (files.length < 1) {
warnOnce(err)
return false
}
}
try {
await write(false)
return true
} catch (err) {
warnOnce(err)
return false
}
}
/** Forget an edit and its files. */
export async function deleteBufferEntry(key) {
try {
await transact([ENTRIES, FILES], 'readwrite', async (tx) => {
await pruneFiles(tx, key)
await done(tx.objectStore(ENTRIES).delete(key))
})
} catch (err) {
warnOnce(err)
}
}
/**
* Forget everything kept for one user -- what signing out does, since the next person at this browser
* has no business finding somebody else's unsaved pages in it.
*/
export async function clearBufferForUser(userId) {
if (!userId) {
return
}
try {
await transact([ENTRIES, FILES], 'readwrite', async (tx) => {
const keys = await done(tx.objectStore(ENTRIES).index('userId').getAllKeys(userId))
for (const key of keys) {
await pruneFiles(tx, key)
await done(tx.objectStore(ENTRIES).delete(key))
}
})
} catch (err) {
warnOnce(err)
}
}
+29
View File
@@ -636,6 +636,7 @@ import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { dialog } from '@/composables/dialog'
import { confirmDiscardUnsaved, useEditorBuffer } from '@/composables/editorBuffer'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { usePageScripts } from '@/composables/pageScripts'
@@ -790,6 +791,12 @@ const dark = useDark()
*/
usePageScripts()
/*
What is typed into the editor, kept in this browser until it is saved and offered back if it never
is. Here because this is what mounts the editors; it follows the editor store on its own.
*/
useEditorBuffer()
// META
/*
@@ -1306,6 +1313,28 @@ async function guardAnnotations(to, from) {
onBeforeRouteLeave(guardAnnotations)
onBeforeRouteUpdate(guardAnnotations)
/*
Leaving the editor with unsaved edits throws them away, so the author is asked first: a link in the
sidebar, Back, a click on the logo. Leaving is going somewhere else -- a change of query or hash is
the same editor still -- and a create route being put in place by `pageCreate` itself is not leaving
anything (`ignoreRouteChange`).
Saying yes is discarding, and is settled as one before the navigation goes on, so that the browser
buffer does not keep what the author has just said to throw away. Everything that leaves the editor
on purpose -- a save, Discard, a submitted suggestion -- settles first for the same reason, and so
never reaches this question. A full unload is asked by the browser instead; see
`composables/editorBuffer.js`.
*/
async function guardUnsavedEdits(to, from) {
if (editorStore.ignoreRouteChange || to.path === from.path) {
return true
}
return confirmDiscardUnsaved(t)
}
onBeforeRouteLeave(guardUnsavedEdits)
onBeforeRouteUpdate(guardUnsavedEdits)
/*
Reading the page is reading what the reader was told about it: its content as soon as it is on
screen, its discussion once the Talk tab is open. This is what lets an email go out once and then
+32 -1
View File
@@ -34,11 +34,30 @@ export const useEditorStore = defineStore('editor', {
configIsLoaded: false,
reasonForChange: '',
ignoreRouteChange: false,
pendingAssets: []
pendingAssets: [],
/**
* Whether the open editor holds its document and can take an edit from outside -- mounted, and
* past the wait for a collaborative room. Until then a document put into it is overwritten: the
* room's binding replaces whatever the editor was holding. Set by the editor itself, since only it
* knows when that is; see `restoreEditorContent` in each one and `composables/editorBuffer.js`.
*/
isReady: false
}),
getters: {
hasPendingChanges: (state) => {
return state.lastSaveTimestamp && state.lastSaveTimestamp !== state.lastChangeTimestamp
},
/**
* Whether something was changed that has not been saved -- `hasPendingChanges`, and a page being
* created as well. That one never had a save, so `lastSaveTimestamp` is null there when the app
* was opened straight onto the create route, and `hasPendingChanges` says no however much has
* been written. Not tied to the editor being open: the moment it closes is exactly when this is
* asked, by the buffer deciding what to keep.
*/
hasUnsavedEdits: (state) => {
return (
Boolean(state.lastChangeTimestamp) && state.lastChangeTimestamp !== state.lastSaveTimestamp
)
}
},
actions: {
@@ -58,12 +77,24 @@ export const useEditorStore = defineStore('editor', {
closeEditor() {
this.$patch({
isActive: false,
isReady: false,
editor: '',
mode: 'edit',
// -> Only ever meaningful while a page is being created: it is where Discard goes back to
originPageId: ''
})
},
/**
* Say that nothing is unsaved any more, because what was has just been saved or thrown away.
*
* The same instant in both, which is how "no pending changes" is spelled. Done BEFORE anything
* navigates away from the editor: the router's guard asks about unsaved edits on the way out, and
* the browser buffer keeps whatever is still unsaved when the editor closes.
*/
settleChanges() {
const now = Temporal.Now.instant()
this.$patch({ lastChangeTimestamp: now, lastSaveTimestamp: now })
},
/**
* Take a file the author has just put into a page, to be uploaded when the page is saved.
*
+55 -38
View File
@@ -49,6 +49,43 @@ const DUPLICATED_PAGE_PROPS = [
'tocDepth'
]
/**
* The page fields a save sends, which is everything the editor and the properties panel can change.
*
* Shared with the browser buffer (`composables/editorBuffer.js`), so that what is kept for an edit
* nobody saved is exactly what saving it would have sent.
*/
export const SAVED_PAGE_FIELDS = [
'alias',
'allowBacklinks',
'allowComments',
'allowContributions',
'allowGlossaryLinks',
'allowRatings',
'content',
'description',
'icon',
'isBrowsable',
'isSearchable',
'localeRelations',
'password',
'publishEndDate',
'publishStartDate',
'publishState',
'relations',
'render',
'scriptJsLoad',
'scriptJsUnload',
'scriptCss',
'showLastEditedBy',
'showSidebar',
'showTags',
'showToc',
'tags',
'title',
'tocDepth'
]
export const usePageStore = defineStore('page', {
state: () => ({
alias: '',
@@ -707,6 +744,13 @@ export const usePageStore = defineStore('page', {
storedProps: { title: '', description: '', icon: '' },
mode: 'edit'
})
/*
Nothing about the new page is unsaved until somebody writes something. Whatever the store was
holding before -- a property edit left pending on the page this was started from -- belonged to
a page that is no longer in it, and would otherwise make the router and the browser buffer
treat an untouched new page as somebody's unsaved work.
*/
editorStore.settleChanges()
/*
-> Page permissions at the path the new page will sit at
@@ -832,6 +876,8 @@ export const usePageStore = defineStore('page', {
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
// -> Submitting is the suggestion's save: nothing typed into it is unsaved any more
useEditorStore().settleChanges()
this.hasOpenSuggestion = true
return resp.submission
},
@@ -991,36 +1037,7 @@ export const usePageStore = defineStore('page', {
// against what this author may embed, and deriving the table of contents — so the page it
// returns is the authority on what was actually saved.
const body = {
...pick(this, [
'alias',
'allowBacklinks',
'allowComments',
'allowContributions',
'allowGlossaryLinks',
'allowRatings',
'content',
'description',
'icon',
'isBrowsable',
'isSearchable',
'localeRelations',
'password',
'publishEndDate',
'publishStartDate',
'publishState',
'relations',
'render',
'scriptJsLoad',
'scriptJsUnload',
'scriptCss',
'showLastEditedBy',
'showSidebar',
'showTags',
'showToc',
'tags',
'title',
'tocDepth'
]),
...pick(this, SAVED_PAGE_FIELDS),
/*
Not a page field: it describes the save rather than the page, and the server records it on
the history version this save produces. Collected by the reason-for-change dialog before
@@ -1119,6 +1136,14 @@ export const usePageStore = defineStore('page', {
*/
siteStore.staleTags()
/*
Saved, so nothing is pending -- and said so BEFORE the navigation below rather than after it.
The router's guard asks whether the editor is being left with unsaved edits, and a page that
was just created would otherwise be asked about as though it were being abandoned.
*/
editorStore.settleChanges()
editorStore.reasonForChange = ''
if (editorStore.mode === 'create') {
editorStore.$patch({ mode: 'edit' })
/*
@@ -1129,14 +1154,6 @@ export const usePageStore = defineStore('page', {
*/
await this.router.replace(this.editorExitPath)
}
// Update editor state timestamps
const curDate = Temporal.Now.instant()
editorStore.$patch({
lastChangeTimestamp: curDate,
lastSaveTimestamp: curDate,
reasonForChange: ''
})
} catch (err) {
console.warn(err)
throw err
+7
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { getAccessibleColor } from '@/helpers/accessibility'
import { clearBufferForUser } from '@/helpers/editorBuffer'
import { useSiteStore } from './site'
@@ -171,6 +172,12 @@ export const useUserStore = defineStore('user', {
// left looking at a page that still says they are signed in.
console.warn(err)
}
/*
And whatever this browser kept of their unsaved edits. Whoever sits down at it next has no
business finding somebody else's pages in it, and the account that wrote them is not the one
that will be offered them -- see `helpers/editorBuffer.js`. Taken before the id below is gone.
*/
await clearBufferForUser(this.id)
this.setToGuest()
EVENT_BUS.emit('logout', { redirect })
},