edytor 0.1.0-next.41 → 0.1.0-next.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,13 +10,14 @@
10
10
  <p>
11
11
  <a href="https://edytor.dev/docs">Documentation</a> •
12
12
  <a href="#quick-start">Quick start</a> •
13
- <a href="https://edytor.dev/docs/reference/migration">Changelog</a>
13
+ <a href="https://edytor.dev/docs/reference/migration">Changelog</a> •
14
+ <a href="SECURITY.md">Security</a>
14
15
  </p>
15
16
  </div>
16
17
 
17
18
  Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
18
19
 
19
- > **Work in progress.** Edytor is a pre-release (`0.1.0-next.41`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
20
+ > **Work in progress.** Edytor is a pre-release (`0.1.0-next.43`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
20
21
 
21
22
  ## Features
22
23
 
@@ -278,6 +278,10 @@ export type DocumentRoomEnv = {
278
278
  EDYTOR_ROOMS?: MoveNamespace;
279
279
  /** `off`: the room keeps no comment threads (its comments requests answer `404`). */
280
280
  EDYTOR_COMMENTS?: string;
281
+ /** The bytes the room's comments may hold ({@link CommentOptions.maxBytes}). */
282
+ EDYTOR_MAX_COMMENT_BYTES?: string | number;
283
+ /** Comment requests per second ({@link CommentOptions.maxRequestsPerSecond}). */
284
+ EDYTOR_MAX_COMMENT_REQUESTS_PER_SECOND?: string | number;
281
285
  };
282
286
  /** What a socket is bound to: its verified user, its replica (Yjs client id), its access. */
283
287
  export type SocketIdentity = {
@@ -300,7 +304,7 @@ export type Attachment = SocketIdentity & {
300
304
  comments?: boolean;
301
305
  };
302
306
  export type Refusal = {
303
- reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'mark' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
307
+ reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
304
308
  detail: unknown;
305
309
  };
306
310
  /** A duration series: how many, their total and longest (ms; see {@link RoomMetrics}). */
@@ -490,7 +494,26 @@ export type CommentOptions = {
490
494
  onComment?: (change: CommentChange) => void | Promise<void>;
491
495
  /** The longest comment body (default and ceiling 10,000 characters). */
492
496
  maxLength?: number;
497
+ /**
498
+ * The bytes the room's comments may hold (default
499
+ * {@link DEFAULT_MAX_COMMENT_BYTES}, 4 MiB): each body and quote in UTF-8
500
+ * plus 128 a comment. A thread or reply past it is refused `413`; a
501
+ * delete frees its bytes.
502
+ */
503
+ maxBytes?: number;
504
+ /**
505
+ * Comment requests per second (default
506
+ * {@link DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND}, a ten-second burst):
507
+ * over HTTP per verified user (past it, `429`), and a socket's comment
508
+ * messages per socket (past it, dropped). RPC calls are the host's own and
509
+ * are not counted.
510
+ */
511
+ maxRequestsPerSecond?: number;
493
512
  };
513
+ /** The bytes a room's comments hold at most, by default (4 MiB). */
514
+ export declare const DEFAULT_MAX_COMMENT_BYTES: number;
515
+ /** Comment requests per second and user (or socket), by default. */
516
+ export declare const DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND = 2;
494
517
  /** What {@link attachRoom} (and `DocumentRoom`) takes. */
495
518
  export type AttachRoomOptions = {
496
519
  /**
@@ -242,6 +242,10 @@ export const noTimers = (fn) => {
242
242
  [g.setTimeout, g.setInterval] = saved;
243
243
  }
244
244
  };
245
+ /** The bytes a room's comments hold at most, by default (4 MiB). */
246
+ export const DEFAULT_MAX_COMMENT_BYTES = 4 * 1024 * 1024;
247
+ /** Comment requests per second and user (or socket), by default. */
248
+ export const DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND = 2;
245
249
  /** `EDYTOR_PURGE_AFTER_DAYS`: `off` (or `false`) never purges; a number of days; anything else the default. */
246
250
  const purgeAfterDays = (value) => {
247
251
  if (value === 'off' || value === 'false' || value === false)
@@ -703,7 +707,11 @@ export class DocumentRoom extends DurableObject {
703
707
  const locks = () => this.locks();
704
708
  const comments = () => String(knobs.EDYTOR_COMMENTS ?? '') === 'off'
705
709
  ? false
706
- : { onComment: (change) => this.onComment(change) };
710
+ : {
711
+ onComment: (change) => this.onComment(change),
712
+ maxBytes: Number(knobs.EDYTOR_MAX_COMMENT_BYTES),
713
+ maxRequestsPerSecond: Number(knobs.EDYTOR_MAX_COMMENT_REQUESTS_PER_SECOND)
714
+ };
707
715
  this.room = new AttachedDocument(ctx, {
708
716
  maxRowBytes: Number(knobs.EDYTOR_MAX_ROW_BYTES),
709
717
  maxFrameBytes: Number(knobs.EDYTOR_MAX_FRAME_BYTES),
@@ -4,10 +4,10 @@
4
4
  * `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
5
5
  * the site's `server/` section (room, authorization, extending, protocol).
6
6
  */
7
- export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, type AttachRoomOptions, type CommentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity, type StoredRecord, type FrameValidation, type ValidatedBlock, type RoomMetrics, type RoomLogEntry, type RestoreResult, type Timing } from './DocumentRoom.js';
7
+ export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, type AttachRoomOptions, type CommentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity, type StoredRecord, type FrameValidation, type ValidatedBlock, type RoomMetrics, type RoomLogEntry, type RestoreResult, type Timing } from './DocumentRoom.js';
8
8
  export { DEFAULT_RETENTION_DAYS, HISTORY_MAX_VALUE_BYTES, R2_HISTORY_MAX_VALUE_BYTES, ROOM_HISTORY_MAX_BYTES, ROOM_HISTORY_MAX_VALUE_BYTES, kvHistory, r2History, roomHistory, type HistoryEntry, type HistoryMetadata, type HistoryOptions, type HistoryRoomStorage, type HistoryStore, type HistoryStoreFactory, type HistoryStoreRecord, type KVLike, type R2BucketLike } from './history.js';
9
9
  export { type PurgeReport } from '../crdt/purge.js';
10
- export { COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, decideComment, type ThreadComment, type CommentActor, type CommentChange, type CommentOutcome, type CommentRefusal, type CommentRequest, type CommentRun, type CommentSnapshot, type CommentThread } from '../crdt/protocols/comments.js';
10
+ export { COMMENT_ROW_BYTES, COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, commentBytes, decideComment, type ThreadComment, type CommentActor, type CommentChange, type CommentOutcome, type CommentRefusal, type CommentRequest, type CommentRun, type CommentSnapshot, type CommentThread } from '../crdt/protocols/comments.js';
11
11
  export { lockedBlocks, type LockOptions } from './locks.js';
12
12
  export { moveBlocksBetweenRooms, forwardLateEdits, DEFAULT_MOVE_GRACE_DAYS, type CommitResult, type ExportedBlocks, type ImportReceipt, type ImportRequest, type LateEdit, type LateEditBatch, type MoveDestination, type MovedState, type MoveNamespace, type MoveRoom } from './move.js';
13
13
  export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentIdentity, type RouteDocumentOptions } from './routeDocumentSocket.js';
@@ -4,10 +4,10 @@
4
4
  * `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
5
5
  * the site's `server/` section (room, authorization, extending, protocol).
6
6
  */
7
- export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
7
+ export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
8
8
  export { DEFAULT_RETENTION_DAYS, HISTORY_MAX_VALUE_BYTES, R2_HISTORY_MAX_VALUE_BYTES, ROOM_HISTORY_MAX_BYTES, ROOM_HISTORY_MAX_VALUE_BYTES, kvHistory, r2History, roomHistory } from './history.js';
9
9
  export {} from '../crdt/purge.js';
10
- export { COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, decideComment } from '../crdt/protocols/comments.js';
10
+ export { COMMENT_ROW_BYTES, COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, commentBytes, decideComment } from '../crdt/protocols/comments.js';
11
11
  export { lockedBlocks } from './locks.js';
12
12
  export { moveBlocksBetweenRooms, forwardLateEdits, DEFAULT_MOVE_GRACE_DAYS } from './move.js';
13
13
  export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica } from './routeDocumentSocket.js';
@@ -2,16 +2,21 @@ import { type RoomContext } from './context.js';
2
2
  /** The provider's keepalive text frame, and the room's answer. */
3
3
  export declare const PING = "ping";
4
4
  export declare const PONG = "pong";
5
- /** Each socket's allowance (a token bucket; memory: a wake refills it). */
6
- export type Allowances = WeakMap<WebSocket, {
5
+ /** A token bucket: its tokens, refilled since `at` (ms). */
6
+ export type Bucket = {
7
7
  tokens: number;
8
8
  at: number;
9
- }>;
9
+ };
10
+ /** Each socket's allowance (a token bucket; memory: a wake refills it). */
11
+ export type Allowances = WeakMap<WebSocket, Bucket>;
10
12
  /**
11
- * A token bucket per socket, refilled at `perSecond` up to ten seconds'
12
- * worth: takes one token. `false`: over the rate.
13
+ * A token bucket per key (a socket, a user), refilled at `perSecond` up to
14
+ * ten seconds' worth: takes one token. `false`: over the rate.
13
15
  */
14
- export declare const allowance: (allowances: Allowances, ws: WebSocket, perSecond: number) => boolean;
16
+ export declare const allowance: <K>(allowances: {
17
+ get(key: K): Bucket | undefined;
18
+ set(key: K, bucket: Bucket): unknown;
19
+ }, key: K, perSecond: number, now?: number) => boolean;
15
20
  export declare class Admission {
16
21
  private readonly room;
17
22
  /** Each socket's update allowance. */
@@ -32,19 +32,18 @@ export const PONG = 'pong';
32
32
  /** Seconds of the update rate a socket may spend at once. */
33
33
  const BURST_SECONDS = 10;
34
34
  /**
35
- * A token bucket per socket, refilled at `perSecond` up to ten seconds'
36
- * worth: takes one token. `false`: over the rate.
35
+ * A token bucket per key (a socket, a user), refilled at `perSecond` up to
36
+ * ten seconds' worth: takes one token. `false`: over the rate.
37
37
  */
38
- export const allowance = (allowances, ws, perSecond) => {
39
- const now = Date.now();
38
+ export const allowance = (allowances, key, perSecond, now = Date.now()) => {
40
39
  const burst = perSecond * BURST_SECONDS;
41
- const held = allowances.get(ws) ?? { tokens: burst, at: now };
42
- const tokens = Math.min(burst, held.tokens + ((now - held.at) / 1000) * perSecond);
40
+ const held = allowances.get(key) ?? { tokens: burst, at: now };
41
+ const tokens = Math.min(burst, held.tokens + (Math.max(0, now - held.at) / 1000) * perSecond);
43
42
  if (tokens < 1) {
44
- allowances.set(ws, { tokens, at: now });
43
+ allowances.set(key, { tokens, at: now });
45
44
  return false;
46
45
  }
47
- allowances.set(ws, { tokens: tokens - 1, at: now });
46
+ allowances.set(key, { tokens: tokens - 1, at: now });
48
47
  return true;
49
48
  };
50
49
  /** A chunk sequence the room does not take at its start: past the rate, or past the room's buffer. */
@@ -9,12 +9,16 @@
9
9
  import * as E from '../../crdt/protocol.js';
10
10
  import { type CommentActor, type CommentOutcome, type CommentRequest, type CommentSnapshot } from '../../crdt/protocols/comments.js';
11
11
  import { type Attachment, type SocketIdentity } from '../DocumentRoom.js';
12
- import type { RoomContext } from './context.js';
12
+ import { type RoomContext } from './context.js';
13
13
  /** The largest request body `routeDocumentComments` forwards, in bytes. */
14
14
  export declare const MAX_COMMENT_REQUEST_BYTES: number;
15
15
  export declare class RoomComments {
16
16
  private readonly room;
17
17
  private ready;
18
+ /** Each verified user's HTTP request allowance (memory: a wake refills it). */
19
+ private readonly users;
20
+ /** Each socket's comment message allowance. */
21
+ private readonly sockets;
18
22
  constructor(room: RoomContext);
19
23
  /** Comments are on unless the room's `comments` option is `false`. */
20
24
  get enabled(): boolean;
@@ -28,6 +32,16 @@ export declare class RoomComments {
28
32
  private seq;
29
33
  /** One thread as stored, or `null`. */
30
34
  private read;
35
+ /**
36
+ * The bytes the comments hold, as the byte quota counts them
37
+ * (`commentBytes`: each body and quote in UTF-8, plus a row's cost).
38
+ */
39
+ private bytes;
40
+ /**
41
+ * Whether `key` (a user over HTTP, a socket) is within the comment
42
+ * rate; past it the room notes it (`comments`, `quota: 'rate'`).
43
+ */
44
+ private allow;
31
45
  /** Every thread, oldest first, with the last sequence number. */
32
46
  list(): CommentSnapshot;
33
47
  /** Store `change` (one SQLite transaction with its sequence number). */
@@ -46,7 +60,9 @@ export declare class RoomComments {
46
60
  * A socket's comment message (`messageComments`): a subscribe marks its
47
61
  * attachment (it survives hibernation) and is answered with every
48
62
  * thread; an unsubscribe clears it. Any other comment message from a
49
- * client is malformed. Subscribes share the socket's presence rate.
63
+ * client is malformed. A subscribe while subscribed answers nothing; a
64
+ * socket's comment messages past the comment rate are dropped (its
65
+ * subscription unchanged), the socket stays.
50
66
  */
51
67
  onMessage(ws: WebSocket, attachment: Attachment, decoder: E.Decoder): void;
52
68
  /**
@@ -7,13 +7,17 @@
7
7
  * may do what) are `crdt/protocols/comments.ts`, shared with the clients.
8
8
  */
9
9
  import * as E from '../../crdt/protocol.js';
10
- import { COMMENT_MARK, COMMENT_STATUS, commentAnchors, decideComment, parseCommentRequest, validCommentId } from '../../crdt/protocols/comments.js';
11
- import { SOCKET_TAG, noTimers } from '../DocumentRoom.js';
10
+ import { COMMENT_MARK, COMMENT_ROW_BYTES, COMMENT_STATUS, commentAnchors, decideComment, OVER_BYTE_QUOTA, parseCommentRequest, validCommentId } from '../../crdt/protocols/comments.js';
11
+ import { DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, SOCKET_TAG, noTimers } from '../DocumentRoom.js';
12
+ import { allowance } from './admission.js';
13
+ import { knob } from './context.js';
12
14
  import { decode } from './frames.js';
13
15
  /** The `meta` key holding the comments' last sequence number. */
14
16
  const SEQ_KEY = 'comments';
15
17
  /** The largest request body `routeDocumentComments` forwards, in bytes. */
16
18
  export const MAX_COMMENT_REQUEST_BYTES = 64 * 1024;
19
+ /** The users whose request rate the room tracks at once (the oldest is forgotten past it). */
20
+ const MAX_RATE_USERS = 1_000;
17
21
  const threadOf = (row, comments) => ({
18
22
  id: row.id,
19
23
  block: row.block,
@@ -35,6 +39,10 @@ const commentOf = (row) => ({
35
39
  export class RoomComments {
36
40
  room;
37
41
  ready = false;
42
+ /** Each verified user's HTTP request allowance (memory: a wake refills it). */
43
+ users = new Map();
44
+ /** Each socket's comment message allowance. */
45
+ sockets = new WeakMap();
38
46
  constructor(room) {
39
47
  this.room = room;
40
48
  }
@@ -95,6 +103,32 @@ export class RoomComments {
95
103
  .map(commentOf);
96
104
  return threadOf(row, comments);
97
105
  }
106
+ /**
107
+ * The bytes the comments hold, as the byte quota counts them
108
+ * (`commentBytes`: each body and quote in UTF-8, plus a row's cost).
109
+ */
110
+ bytes() {
111
+ const { sql, tables } = this.room;
112
+ const comments = sql
113
+ .exec(`SELECT SUM(LENGTH(CAST(body AS BLOB))) AS bytes, COUNT(*) AS n FROM ${tables.comments}`)
114
+ .one();
115
+ const quotes = sql
116
+ .exec(`SELECT SUM(LENGTH(CAST(quote AS BLOB))) AS bytes FROM ${tables.threads}`)
117
+ .one().bytes ?? 0;
118
+ return (comments.bytes ?? 0) + quotes + comments.n * COMMENT_ROW_BYTES;
119
+ }
120
+ /**
121
+ * Whether `key` (a user over HTTP, a socket) is within the comment
122
+ * rate; past it the room notes it (`comments`, `quota: 'rate'`).
123
+ */
124
+ allow(buckets, key, user) {
125
+ const room = this.room;
126
+ const limit = limitsOf(room).maxRequestsPerSecond;
127
+ if (allowance(buckets, key, limit, room.clock()))
128
+ return true;
129
+ room.note({ reason: 'comments', detail: { user, quota: 'rate', limit } });
130
+ return false;
131
+ }
98
132
  /** Every thread, oldest first, with the last sequence number. */
99
133
  list() {
100
134
  if (!this.enabled)
@@ -158,13 +192,21 @@ export class RoomComments {
158
192
  const threads = request.op === 'add'
159
193
  ? sql.exec(`SELECT COUNT(*) AS n FROM ${tables.threads}`).one().n
160
194
  : 0;
195
+ const maxBytes = limitsOf(room).maxBytes;
161
196
  const decided = decideComment(request, actor, {
162
197
  thread,
163
198
  threads,
164
199
  now: room.clock(),
165
200
  seq: this.seq() + 1,
166
- maxLength: commentsOptions(room).maxLength
201
+ maxLength: commentsOptions(room).maxLength,
202
+ bytes: request.op === 'add' || request.op === 'reply' ? this.bytes() : undefined,
203
+ maxBytes
167
204
  });
205
+ if (decided.status === 'refused' && decided.message === OVER_BYTE_QUOTA)
206
+ room.note({
207
+ reason: 'comments',
208
+ detail: { user: actor.user, quota: 'bytes', limit: maxBytes }
209
+ });
168
210
  if (decided.status !== 'applied')
169
211
  return decided;
170
212
  this.store(decided.change);
@@ -232,7 +274,9 @@ export class RoomComments {
232
274
  * A socket's comment message (`messageComments`): a subscribe marks its
233
275
  * attachment (it survives hibernation) and is answered with every
234
276
  * thread; an unsubscribe clears it. Any other comment message from a
235
- * client is malformed. Subscribes share the socket's presence rate.
277
+ * client is malformed. A subscribe while subscribed answers nothing; a
278
+ * socket's comment messages past the comment rate are dropped (its
279
+ * subscription unchanged), the socket stays.
236
280
  */
237
281
  onMessage(ws, attachment, decoder) {
238
282
  const room = this.room;
@@ -240,10 +284,11 @@ export class RoomComments {
240
284
  if (message.type !== 'subscribe') {
241
285
  return room.refuse(ws, { reason: 'malformed', detail: `comment message ${message.type}` });
242
286
  }
243
- if (!room.presence.allow(ws))
244
- return room.presence.overRate(ws, attachment);
287
+ if (!this.allow(this.sockets, ws, attachment.user))
288
+ return;
289
+ const was = attachment.comments === true;
245
290
  ws.serializeAttachment({ ...attachment, comments: message.on });
246
- if (!message.on)
291
+ if (!message.on || was)
247
292
  return;
248
293
  const snapshot = this.list();
249
294
  room.send(ws, E.frame(E.messageComments, (e) => E.writeCommentsSnapshot(e, snapshot)));
@@ -257,6 +302,11 @@ export class RoomComments {
257
302
  async request(op, identity, body) {
258
303
  if (!this.enabled)
259
304
  return new Response('no comments', { status: 404 });
305
+ const users = this.users;
306
+ if (!users.has(identity.user) && users.size >= MAX_RATE_USERS)
307
+ users.delete(users.keys().next().value);
308
+ if (!this.allow(users, identity.user, identity.user))
309
+ return new Response('too many comment requests', { status: 429 });
260
310
  try {
261
311
  if (op === 'list')
262
312
  return Response.json(this.list());
@@ -291,3 +341,11 @@ const commentsOptions = (room) => {
291
341
  const options = room.options.comments;
292
342
  return options === false || options === undefined ? {} : options;
293
343
  };
344
+ /** The room's comment quotas, defaults applied. */
345
+ const limitsOf = (room) => {
346
+ const options = commentsOptions(room);
347
+ return {
348
+ maxBytes: knob(options.maxBytes, DEFAULT_MAX_COMMENT_BYTES, 128 * 1024 * 1024),
349
+ maxRequestsPerSecond: knob(options.maxRequestsPerSecond, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, 1_000)
350
+ };
351
+ };
@@ -56,7 +56,7 @@ export declare const crdt: {
56
56
  SchemaMismatchError: typeof E.SchemaMismatchError;
57
57
  create: (doc: E.EngineDoc, config?: import("../../crdt/edytor-doc.js").EdytorDocConfig) => {
58
58
  prepare: {
59
- insertFlow: (target: import("../../crdt/flow.js").FlowTarget, flow: import("../../crdt/flow.js").Flow, view?: import("../../crdt/flow.js").FlowView) => import("../../index.js").Prepared;
59
+ insertFlow: (target: import("../../index.js").FlowTarget, flow: import("../../index.js").Flow, view?: import("../../crdt/flow.js").FlowView) => import("../../index.js").Prepared;
60
60
  deleteRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView) => import("../../index.js").Prepared;
61
61
  replaceRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView) => import("../../index.js").Prepared;
62
62
  insertTableRow: (table: import("../../crdt/index.js").BlockId, index: number) => import("../../index.js").Prepared;
@@ -93,7 +93,7 @@ export declare const crdt: {
93
93
  children?: import("../../crdt/index.js").BlockSpec[];
94
94
  }) => import("../../index.js").Prepared;
95
95
  setBlockType: (id: import("../../crdt/index.js").BlockId, type: string) => import("../../index.js").Prepared;
96
- patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../crdt/data.js").DataPatch[]) => import("../../index.js").Prepared;
96
+ patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../index.js").DataPatch[]) => import("../../index.js").Prepared;
97
97
  setBlockData: (id: import("../../crdt/index.js").BlockId, data: Record<string, unknown>) => import("../../index.js").Prepared;
98
98
  duplicateBlock: (id: import("../../crdt/index.js").BlockId, freshId: (oldId: string, kind: "block" | "inline") => string) => import("../../index.js").Prepared;
99
99
  insertText: (id: import("../../crdt/index.js").BlockId, offset: number, text: string, marks?: Record<string, unknown>) => import("../../index.js").Prepared;
@@ -180,7 +180,7 @@ export declare const crdt: {
180
180
  runsView: import("../../crdt/text/runs.js").RunView;
181
181
  version: number;
182
182
  block: (id: import("../../crdt/index.js").BlockId) => import("../../crdt/nodes.js").DocBlock;
183
- insertFlow: (target: import("../../crdt/flow.js").FlowTarget, flow: import("../../crdt/flow.js").Flow, view?: import("../../crdt/flow.js").FlowView | undefined) => import("../../index.js").OpResult;
183
+ insertFlow: (target: import("../../index.js").FlowTarget, flow: import("../../index.js").Flow, view?: import("../../crdt/flow.js").FlowView | undefined) => import("../../index.js").OpResult;
184
184
  deleteRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView | undefined) => import("../../index.js").OpResult;
185
185
  replaceRange: (from: import("../../crdt/rangeDelete.js").DocPosition, to: import("../../crdt/rangeDelete.js").DocPosition, view?: import("../../crdt/rangeDelete.js").RangeView | undefined) => import("../../index.js").OpResult;
186
186
  insertTableRow: (table: string, index: number) => import("../../index.js").OpResult;
@@ -217,7 +217,7 @@ export declare const crdt: {
217
217
  children?: import("../../crdt/index.js").BlockSpec[];
218
218
  }) => import("../../index.js").OpResult;
219
219
  setBlockType: (id: string, type: string) => import("../../index.js").OpResult;
220
- patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../crdt/data.js").DataPatch[]) => import("../../index.js").OpResult;
220
+ patchData: (target: import("../../crdt/edytor-doc.js").DataTarget, patches: readonly import("../../index.js").DataPatch[]) => import("../../index.js").OpResult;
221
221
  setBlockData: (id: string, data: Record<string, unknown>) => import("../../index.js").OpResult;
222
222
  duplicateBlock: (id: string, freshId: (oldId: string, kind: "block" | "inline") => string) => import("../../index.js").OpResult;
223
223
  insertText: (id: string, offset: number, text: string, marks?: Record<string, unknown> | undefined) => import("../../index.js").OpResult;
@@ -263,7 +263,7 @@ export declare const crdt: {
263
263
  blockDataOf: (id: string) => Record<string, unknown> | undefined;
264
264
  docData: () => import("../../crdt/doc/types.js").JsonObj;
265
265
  dataItemIds: (target: import("../../crdt/edytor-doc.js").DataTarget, path: readonly string[]) => string[];
266
- blockAttribution: (id: string) => import("../../crdt/index.js").BlockAttribution | undefined;
266
+ blockAttribution: (id: string) => import("../../index.js").BlockAttribution | undefined;
267
267
  crdtId: (id: string) => string | null;
268
268
  resolveBlock: (id: string) => E.EngineNode | null;
269
269
  displayLength: (id: string) => number;
@@ -274,12 +274,12 @@ export declare const crdt: {
274
274
  compare: (a: import("../../crdt/index.js").BlockId, b: import("../../crdt/index.js").BlockId) => number;
275
275
  next: (id: string, policy?: import("../../crdt/edytor-doc.js").OrderPolicy | undefined) => string | null;
276
276
  previous: (id: string, policy?: import("../../crdt/edytor-doc.js").OrderPolicy | undefined) => string | null;
277
- anchorAt: (id: string, offset: number, affinity?: import("../../crdt/edytor-doc.js").AnchorAffinity | undefined) => import("../../crdt/edytor-doc.js").DocAnchor | null;
278
- resolveAnchor: (anchor: import("../../crdt/edytor-doc.js").DocAnchor) => {
277
+ anchorAt: (id: string, offset: number, affinity?: import("../../index.js").AnchorAffinity | undefined) => import("../../index.js").DocAnchor | null;
278
+ resolveAnchor: (anchor: import("../../index.js").DocAnchor) => {
279
279
  blockId: import("../../crdt/index.js").BlockId;
280
280
  offset: number;
281
281
  } | null;
282
- followUndo: (anchor: import("../../crdt/edytor-doc.js").DocAnchor) => import("../../crdt/edytor-doc.js").DocAnchor;
282
+ followUndo: (anchor: import("../../index.js").DocAnchor) => import("../../index.js").DocAnchor;
283
283
  rolesChanged: () => void;
284
284
  isVoid: (id: string) => boolean;
285
285
  isIsland: (id: string) => boolean;
@@ -404,9 +404,9 @@ export declare const crdt: {
404
404
  };
405
405
  };
406
406
  providers: {
407
- prefetch: (options: import("../../crdt/index.js").PrefetchOptions) => Promise<import("../../crdt/index.js").PrefetchResult>;
408
- lastUpdated: (options: import("../../crdt/index.js").LastUpdatedOptions) => Promise<number | null>;
409
- documentSnapshot: (options: import("../../crdt/index.js").LastUpdatedOptions) => Promise<JSONDoc | null>;
407
+ prefetch: (options: import("../../index.js").PrefetchOptions) => Promise<import("../../index.js").PrefetchResult>;
408
+ lastUpdated: (options: import("../../index.js").LastUpdatedOptions) => Promise<number | null>;
409
+ documentSnapshot: (options: import("../../index.js").LastUpdatedOptions) => Promise<JSONDoc | null>;
410
410
  IndexeddbPersistence: {
411
411
  new (name: string, doc: YDoc, options?: E.IndexeddbPersistenceOptions): {
412
412
  doc: YDoc;
@@ -627,7 +627,7 @@ export declare const crdt: {
627
627
  };
628
628
  attribution: {
629
629
  attach: (doc: E.EngineDoc, opts: {
630
- actor: import("../../crdt/index.js").AttributionActor;
630
+ actor: import("../../index.js").AttributionActor;
631
631
  }) => import("../../crdt/index.js").AttributionController;
632
632
  };
633
633
  };
@@ -671,7 +671,7 @@ export declare const tally: (t: Timing, ms: number) => void;
671
671
  export declare const encodeJSON: (value: unknown) => Uint8Array<ArrayBuffer>;
672
672
  /** A semantics config as the facade's lookups — own keys only: block types come off the wire. */
673
673
  declare const lookups: (semantics: DocumentSemanticsConfig) => {
674
- roleOf: (type: string) => import("../../crdt/edytor-doc.js").BlockRole | undefined;
674
+ roleOf: (type: string) => import("../../index.js").BlockRole | undefined;
675
675
  kinds: () => string[];
676
676
  defaultChildOf: (type: string) => string | undefined;
677
677
  rendersContent: (type: string) => boolean;
@@ -88,7 +88,8 @@ export declare function routeDocumentHistory(request: Request, rooms: DocumentNa
88
88
  * write, another user's comment, an `Origin` that `options.allowedOrigins`
89
89
  * does not list), `404` (no such thread or comment, or the room keeps no
90
90
  * comments), `405` (another method), `409` (a thread or comment id taken),
91
- * `413` (a body over 64 KiB, or too many threads or comments).
91
+ * `413` (a body over 64 KiB, too many threads or comments, or the room's
92
+ * comment bytes full), `429` (the user past the room's comment rate).
92
93
  */
93
94
  export declare function routeDocumentComments(request: Request, rooms: DocumentNamespace, documentId: string, authorize: AuthorizeDocumentSocket, options?: RouteDocumentOptions): Promise<Response>;
94
95
  export declare function routeDocumentSocket(request: Request, rooms: DocumentNamespace, documentId: string, authorize: AuthorizeDocumentSocket, options?: RouteDocumentOptions): Promise<Response>;
@@ -141,6 +141,35 @@ export async function routeDocumentHistory(request, rooms, documentId, authorize
141
141
  }
142
142
  return rooms.getByName(documentId).fetch(new Request(request.url, { method: 'GET', headers }));
143
143
  }
144
+ /**
145
+ * A request's body as text, read no further than `max` bytes (`null`: it
146
+ * holds more, its declared length or not).
147
+ */
148
+ const bodyWithin = async (request, max) => {
149
+ if (request.body === null)
150
+ return '';
151
+ const reader = request.body.getReader();
152
+ const chunks = [];
153
+ let length = 0;
154
+ for (;;) {
155
+ const { done, value } = await reader.read();
156
+ if (done)
157
+ break;
158
+ length += value.byteLength;
159
+ if (length > max) {
160
+ await reader.cancel().catch(() => { });
161
+ return null;
162
+ }
163
+ chunks.push(value);
164
+ }
165
+ const bytes = new Uint8Array(length);
166
+ let at = 0;
167
+ for (const chunk of chunks) {
168
+ bytes.set(chunk, at);
169
+ at += chunk.byteLength;
170
+ }
171
+ return new TextDecoder().decode(bytes);
172
+ };
144
173
  /**
145
174
  * The host Worker's door to a document's comment threads, authorized as
146
175
  * {@link routeDocumentSocket} authorizes a dial, forwarding only the
@@ -158,7 +187,8 @@ export async function routeDocumentHistory(request, rooms, documentId, authorize
158
187
  * write, another user's comment, an `Origin` that `options.allowedOrigins`
159
188
  * does not list), `404` (no such thread or comment, or the room keeps no
160
189
  * comments), `405` (another method), `409` (a thread or comment id taken),
161
- * `413` (a body over 64 KiB, or too many threads or comments).
190
+ * `413` (a body over 64 KiB, too many threads or comments, or the room's
191
+ * comment bytes full), `429` (the user past the room's comment rate).
162
192
  */
163
193
  export async function routeDocumentComments(request, rooms, documentId, authorize, options) {
164
194
  const op = request.method === 'GET' ? 'list' : request.method === 'POST' ? 'post' : null;
@@ -174,8 +204,8 @@ export async function routeDocumentComments(request, rooms, documentId, authoriz
174
204
  const declared = Number(request.headers.get('Content-Length') ?? 0);
175
205
  if (declared > MAX_COMMENT_REQUEST_BYTES)
176
206
  return new Response('request too large', { status: 413 });
177
- body = await request.text();
178
- if (new TextEncoder().encode(body).length > MAX_COMMENT_REQUEST_BYTES)
207
+ body = await bodyWithin(request, MAX_COMMENT_REQUEST_BYTES);
208
+ if (body === null)
179
209
  return new Response('request too large', { status: 413 });
180
210
  }
181
211
  const headers = identityHeaders(identity);
@@ -52,7 +52,7 @@ export type EdytorAwarenessState = {
52
52
  * Structural awareness surface used by `publishPresence` — the real
53
53
  * `Awareness` satisfies it; tests may substitute stubs.
54
54
  */
55
- type AwarenessLike = {
55
+ export type AwarenessLike = {
56
56
  getLocalState: () => Record<string, unknown> | null;
57
57
  setLocalState: (state: Record<string, unknown> | null) => void;
58
58
  };
@@ -130,4 +130,3 @@ export declare const resolvePeerSelection: (edytor: Edytor, state: unknown) => {
130
130
  } | {
131
131
  blocks: string[];
132
132
  } | null;
133
- export {};
@@ -19,6 +19,12 @@ export declare const MAX_QUOTE_LENGTH = 300;
19
19
  export declare const MAX_COMMENTS_PER_THREAD = 500;
20
20
  /** Threads one document holds at most, resolved ones included. */
21
21
  export declare const MAX_THREADS = 5000;
22
+ /**
23
+ * What one stored comment counts against a store's byte quota beyond its
24
+ * text (its ids, author, times; a thread's row is counted with its first
25
+ * comment).
26
+ */
27
+ export declare const COMMENT_ROW_BYTES = 128;
22
28
  /** The mark a thread's anchor is: `comment:<thread id>` (a key of its own per thread). */
23
29
  export declare const COMMENT_MARK = "comment";
24
30
  /** One comment of a thread. */
@@ -137,9 +143,25 @@ export type CommentContext = {
137
143
  seq: number;
138
144
  /** The longest body (default {@link MAX_COMMENT_LENGTH}; only lowered). */
139
145
  maxLength?: number;
146
+ /**
147
+ * The bytes the store holds ({@link commentBytes} of every comment and
148
+ * quote): a thread or reply that would pass `maxBytes` is refused
149
+ * (`full`). Absent: no byte quota.
150
+ */
151
+ bytes?: number;
152
+ /** The store's byte quota (with `bytes`). */
153
+ maxBytes?: number;
140
154
  /** A fresh comment id (default {@link commentId}). */
141
155
  newId?: () => string;
142
156
  };
157
+ /** The message of a thread or reply refused past its store's byte quota (`full`). */
158
+ export declare const OVER_BYTE_QUOTA = "comments over their byte quota";
159
+ /**
160
+ * What a stored comment counts against a byte quota: its body's and its
161
+ * thread's quote's UTF-8 bytes (the quote with the first comment only),
162
+ * plus {@link COMMENT_ROW_BYTES}.
163
+ */
164
+ export declare const commentBytes: (body: string, quote?: string) => number;
143
165
  /**
144
166
  * Decide `request` by `actor`: the one rule of every comment store.
145
167
  *
@@ -19,6 +19,12 @@ export const MAX_QUOTE_LENGTH = 300;
19
19
  export const MAX_COMMENTS_PER_THREAD = 500;
20
20
  /** Threads one document holds at most, resolved ones included. */
21
21
  export const MAX_THREADS = 5_000;
22
+ /**
23
+ * What one stored comment counts against a store's byte quota beyond its
24
+ * text (its ids, author, times; a thread's row is counted with its first
25
+ * comment).
26
+ */
27
+ export const COMMENT_ROW_BYTES = 128;
22
28
  /** The mark a thread's anchor is: `comment:<thread id>` (a key of its own per thread). */
23
29
  export const COMMENT_MARK = 'comment';
24
30
  /** The HTTP status of each refusal. */
@@ -97,6 +103,32 @@ const bodyOf = (body, max) => {
97
103
  return refuse('invalid', `comment over ${max} characters`);
98
104
  return text;
99
105
  };
106
+ /** The message of a thread or reply refused past its store's byte quota (`full`). */
107
+ export const OVER_BYTE_QUOTA = 'comments over their byte quota';
108
+ /** UTF-8 bytes of `text`. */
109
+ const utf8 = (text) => {
110
+ let bytes = 0;
111
+ for (let i = 0; i < text.length; i++) {
112
+ const code = text.charCodeAt(i);
113
+ if (code < 0x80)
114
+ bytes += 1;
115
+ else if (code < 0x800)
116
+ bytes += 2;
117
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
118
+ bytes += 4;
119
+ i++;
120
+ }
121
+ else
122
+ bytes += 3;
123
+ }
124
+ return bytes;
125
+ };
126
+ /**
127
+ * What a stored comment counts against a byte quota: its body's and its
128
+ * thread's quote's UTF-8 bytes (the quote with the first comment only),
129
+ * plus {@link COMMENT_ROW_BYTES}.
130
+ */
131
+ export const commentBytes = (body, quote = '') => utf8(body) + utf8(quote) + COMMENT_ROW_BYTES;
100
132
  /** A quote as stored: one line of white space collapsed, cut at {@link MAX_QUOTE_LENGTH}. */
101
133
  const quoteOf = (quote) => (quote ?? '').replace(/\s+/gu, ' ').trim().slice(0, MAX_QUOTE_LENGTH);
102
134
  /**
@@ -121,6 +153,9 @@ export const decideComment = (request, actor, context) => {
121
153
  const { thread, now: at, seq } = context;
122
154
  const max = Math.min(context.maxLength ?? MAX_COMMENT_LENGTH, MAX_COMMENT_LENGTH);
123
155
  const user = actor.user;
156
+ const overQuota = (body, quote) => context.bytes !== undefined &&
157
+ context.maxBytes !== undefined &&
158
+ context.bytes + commentBytes(body, quote) > context.maxBytes;
124
159
  const newComment = (body, id) => {
125
160
  const text = bodyOf(body, max);
126
161
  if (typeof text !== 'string')
@@ -143,10 +178,13 @@ export const decideComment = (request, actor, context) => {
143
178
  const comment = newComment(request.body, request.comment);
144
179
  if ('status' in comment)
145
180
  return comment;
181
+ const quote = quoteOf(request.quote);
182
+ if (overQuota(comment.body, quote))
183
+ return refuse('full', OVER_BYTE_QUOTA);
146
184
  return applied('added', {
147
185
  id: request.thread,
148
186
  block: request.block ?? null,
149
- quote: quoteOf(request.quote),
187
+ quote,
150
188
  createdBy: user,
151
189
  createdAt: at,
152
190
  resolved: null,
@@ -165,6 +203,8 @@ export const decideComment = (request, actor, context) => {
165
203
  return comment;
166
204
  if (thread.comments.some(({ id }) => id === comment.id))
167
205
  return refuse('exists', 'comment exists');
206
+ if (overQuota(comment.body))
207
+ return refuse('full', OVER_BYTE_QUOTA);
168
208
  return applied('replied', { ...thread, comments: [...thread.comments, comment], rev: seq }, comment);
169
209
  }
170
210
  case 'resolve':
@@ -216,6 +216,12 @@ export declare class Edytor {
216
216
  deleteBlocks: (payload: {
217
217
  blocks: Block[];
218
218
  }) => boolean;
219
+ /**
220
+ * Focus the editor, as a click in it would, without scrolling: the
221
+ * selection it has stays, and with none the caret goes to the start of
222
+ * the first text. For a menu button or a shortcut outside the editor.
223
+ */
224
+ focus: () => void;
219
225
  /**
220
226
  * Replace the whole document with one empty block and put the caret in it,
221
227
  * as one undo step. A readonly view or a read-only document refuses it
@@ -319,7 +319,7 @@ export class Edytor {
319
319
  historyUndo = () => this.#replay('undo');
320
320
  historyRedo = () => this.#replay('redo');
321
321
  #replay = (command) => {
322
- this.dispatcher.last = { operation: command, status: this.history.replay(command) };
322
+ this.dispatcher.record({ operation: command, status: this.history.replay(command) });
323
323
  };
324
324
  constructor(options) {
325
325
  const { snippets, readonly, hotkeys, plugins, document, doc, awareness, actor, requireHydration, presence, sync, value, onSelectionChange, placeholder, labels, onChange, onDocChange } = options;
@@ -789,6 +789,20 @@ export class Edytor {
789
789
  };
790
790
  /** @internal The handle of inline atom `atom`, shown in block `id`. */
791
791
  atomAt = (id, atom) => this.idToBlock.atom(id, atom);
792
+ /**
793
+ * Focus the editor, as a click in it would, without scrolling: the
794
+ * selection it has stays, and with none the caret goes to the start of
795
+ * the first text. For a menu button or a shortcut outside the editor.
796
+ */
797
+ focus = () => {
798
+ if (this.selection.value.kind === 'none') {
799
+ const text = this.root?.firstText;
800
+ if (text)
801
+ this.selection.setAtTextOffset(text, 0);
802
+ }
803
+ this.expectInternalFocus();
804
+ this.node?.focus({ preventScroll: true });
805
+ };
792
806
  /**
793
807
  * Replace the whole document with one empty block and put the caret in it,
794
808
  * as one undo step. A readonly view or a read-only document refuses it
@@ -804,7 +818,7 @@ export class Edytor {
804
818
  }));
805
819
  if (!newBlock)
806
820
  return false;
807
- this.dispatcher.last = { operation: 'clear', status: 'applied' };
821
+ this.dispatcher.record({ operation: 'clear', status: 'applied' });
808
822
  this.selection.setAtTextOffset(newBlock.firstText ?? this.root?.children[0]?.firstText, 0);
809
823
  this.expectInternalFocus();
810
824
  this.node?.focus({ preventScroll: true });
@@ -379,7 +379,7 @@ const occur = (edytor, occurrence, key = null, offered = null, reuse) => {
379
379
  const refuseTargetless = (edytor, occurrence, key, reuse) => {
380
380
  if (reuse)
381
381
  edytor.attempts.close(reuse);
382
- edytor.dispatcher.last = { operation: occurrence.inputType, status: 'refused' };
382
+ edytor.dispatcher.record({ operation: occurrence.inputType, status: 'refused' });
383
383
  // The caret the browser placed for this key is its own, never adopted.
384
384
  edytor.projector.parked();
385
385
  if (occurrence.cancelable || !occurrence.event) {
@@ -198,7 +198,7 @@ export function onKeyDown(e) {
198
198
  this.markUserGesture();
199
199
  if (refused) {
200
200
  e.preventDefault();
201
- this.dispatcher.last = { operation: keyed, status: 'refused' };
201
+ this.dispatcher.record({ operation: keyed, status: 'refused' });
202
202
  this.projector.parked();
203
203
  return;
204
204
  }
package/dist/index.d.ts CHANGED
@@ -49,3 +49,26 @@ export { EdytorDocument, createDocument, loadDocument, attachDocument, defaultSe
49
49
  export { createIndexeddbSync, createWebsocketSync, clearDocument, prefetch, lastUpdated, documentSnapshot, DEFAULT_PRESENCE_THROTTLE, type EdytorSync, type EdytorSyncPayload, type IndexeddbSyncOptions, type WebsocketSync, type WebsocketSyncOptions, type PresenceOptions, type PresenceShare } from './collaboration/index.js';
50
50
  export { createCommentsClient, createMemoryCommentsClient, CommentRequestError, commentAnchors, type CommentDocument, type CommentFeed, type CommentResult, type CommentsClient, type CommentsClientOptions, type MemoryCommentsClient, type MemoryCommentsClientOptions, type CommentChange, type CommentRequest, type CommentRun, type CommentSnapshot, type CommentThread, type ThreadComment } from './collaboration/comments/index.js';
51
51
  export { HistoryPanel, createHistoryClient, HistoryRequestError, versionDiff, defaultHistoryLabels, type HistoryClient, type HistoryClientOptions, type HistoryRestoreResult, type HistoryUndoResult, type HistoryVersion, type HistoryPanelLabels, type HistoryPanelProps, type VersionChange, type VersionDiff } from './collaboration/history/index.js';
52
+ export type { EdytorProps } from './components/Edytor.svelte';
53
+ export type { EdytorOptions, Snippets } from './edytor.svelte.js';
54
+ export type { BlockHandlesLabels, BlockMenuLabels, CodeLabels, ColumnsLabels, CommentsLabels, EditorLabels, EquationLabels, FindLabels, ImageLabels, MediaKindLabels, MediaLabels, MentionLabels, PageLabels, PageLinkLabels, RichTextLabels, SlashMenuLabels, SuggestionsLabels, TableLabels, TocLabels, ToolbarLabels } from './labels.js';
55
+ export type { BlockElement, BlockView, InitializedPlugin, InlineBlockView, PluginDefinitions, PluginOperations } from './plugins.js';
56
+ export type { Dispatcher } from './session/commands.js';
57
+ export type { PresenceSelection, SelectCause, SelectionPoint, SelectionProjection, SelectionSegment, SelectionValue } from './session/selection.js';
58
+ export type { SelectionState, TextAnchor } from './selection/selection.svelte.js';
59
+ export type { Key, Modifier, Modifiers } from './session/keymap.js';
60
+ export type { TextOperations } from './text/text.utils.js';
61
+ export type { BlockBeside, BlockOperations } from './block/block.utils.js';
62
+ export type { TextInsertionPayload, TriggerRange } from './plugins/triggers/TriggerController.svelte.js';
63
+ export type { RichTextLink, RichTextMark } from './plugins/richtext/richTextOperations.js';
64
+ export type { AwarenessLike, PresenceWriter } from './collaboration/awarenessSelection.js';
65
+ export type { DataPatch } from './crdt/data.js';
66
+ export type { AnchorAffinity, BlockRole, DocAnchor, TextRange } from './crdt/doc/types.js';
67
+ export type { Flow, FlowLine, FlowTarget } from './crdt/flow.js';
68
+ export type { EdytorDoc } from './crdt/edytor-doc.js';
69
+ export type { DocumentOptions } from './crdt/document.js';
70
+ export type { ActorProfile, AttributionActor, BlockLineageEntry, DocumentAttribution } from './crdt/attribution/attribution.js';
71
+ export type { ActorId, BlockAttribution } from './crdt/attribution/block.js';
72
+ export type { CommentMessage } from './crdt/protocols/comments.js';
73
+ export type { EdytorSyncCleanup, LastUpdatedOptions, PrefetchOptions, PrefetchResult, WebsocketTarget } from './crdt/providers/index.js';
74
+ export type { ProtocolMismatch, SchemaMismatchDetail } from './crdt/providers/room.js';
@@ -307,7 +307,7 @@ export class BlockHandleController {
307
307
  if (!refused)
308
308
  return out;
309
309
  selection.select(held);
310
- dispatcher.last = refused;
310
+ dispatcher.record(refused);
311
311
  return false;
312
312
  };
313
313
  const detail = { block, anchor, insert };
@@ -116,7 +116,7 @@
116
116
  return wrapBlocks(edytor, selected, 'columns', n);
117
117
  const block = edytor.selection.state.startBlock;
118
118
  if (inColumn(block)) {
119
- edytor.dispatcher.last = { operation: 'setBlock', status: 'refused' };
119
+ edytor.dispatcher.record({ operation: 'setBlock', status: 'refused' });
120
120
  return false;
121
121
  }
122
122
  return convertToKind(edytor, block, layoutRow(edytor, n, options));
@@ -75,13 +75,26 @@
75
75
  return Array.from({ length: widest }, (_, i) => String(i));
76
76
  };
77
77
 
78
- /** A cell's column position in its table (reactive through the cells), `-1` when none. */
78
+ /**
79
+ * A cell's column position in its table (reactive through the cells), `-1`
80
+ * when none. A cell naming a listed column reads only the table's list:
81
+ * a keystroke in another cell re-renders nothing here
82
+ * (`table.render.scale`). Only a column the table no longer lists (an
83
+ * undo's) scans the rows, as `columnsOfTable` orders those after the
84
+ * listed ones.
85
+ */
79
86
  const columnIndex = (edytor: Edytor, table: string, row: string, id: string): number => {
80
- const columns = columnsOfTable(edytor, table);
81
- const listed = Array.isArray(edytor.cells?.get(table)?.data?.columns);
82
- if (!listed) return edytor.cells?.get(row)?.childIds.indexOf(id) ?? -1;
87
+ const listed = edytor.cells?.get(table)?.data?.columns;
88
+ if (!Array.isArray(listed)) return edytor.cells?.get(row)?.childIds.indexOf(id) ?? -1;
83
89
  const column = edytor.cells?.get(id)?.data?.column;
84
- return typeof column === 'string' ? columns.indexOf(column) : -1;
90
+ if (typeof column !== 'string') return -1;
91
+ const ids: string[] = [];
92
+ for (const c of listed) {
93
+ const at = (c as { id?: unknown } | null)?.id;
94
+ if (typeof at === 'string' && !ids.includes(at)) ids.push(at);
95
+ }
96
+ const index = ids.indexOf(column);
97
+ return index !== -1 ? index : columnsOfTable(edytor, table).indexOf(column);
85
98
  };
86
99
 
87
100
  /**
@@ -322,7 +322,7 @@ export const caretAfterBlockDelete = (blocks) => {
322
322
  export const keepsSelectedBlocks = (edytor, blocks) => {
323
323
  const kept = edytor.dispatcher.intercept((plugin) => vetoable((prevent) => plugin.onDeleteSelectedBlocks?.({ prevent, selectedBlocks: blocks })));
324
324
  if (kept)
325
- edytor.dispatcher.last = { operation: 'deleteSelectedBlocks', status: 'refused' };
325
+ edytor.dispatcher.record({ operation: 'deleteSelectedBlocks', status: 'refused' });
326
326
  return kept;
327
327
  };
328
328
  /**
@@ -35,7 +35,7 @@ export type Caret = {
35
35
  * `content`, `marks`, the `isAt…`/`is…Spanning` flags, `islandRoot`,
36
36
  * `voidRoot` — are read from `selection.projection`.
37
37
  */
38
- type SelectionState = {
38
+ export type SelectionState = {
39
39
  yStart: number;
40
40
  yEnd: number;
41
41
  isCollapsed: boolean;
@@ -67,9 +67,13 @@ export declare class EdytorSelection {
67
67
  #private;
68
68
  private edytorOnSelectionChange?;
69
69
  edytor: Edytor;
70
- focusedBlocks: SvelteSet<Block>;
71
- /** The selected blocks as clicked: a grip-selected list is one block. */
72
- selectedBlocks: SvelteSet<Block>;
70
+ /** The blocks the selection touches (read-only: `select()` keeps it). */
71
+ get focusedBlocks(): ReadonlySet<Block>;
72
+ /**
73
+ * The selected blocks as clicked: a grip-selected list is one block
74
+ * (read-only: `select()` keeps it).
75
+ */
76
+ get selectedBlocks(): ReadonlySet<Block>;
73
77
  /**
74
78
  * The blocks a command over the block selection acts on, in document
75
79
  * order: the selected blocks, a selected list or code block with its
@@ -84,9 +88,10 @@ export declare class EdytorSelection {
84
88
  private modelSelectionPreservationBlock;
85
89
  /**
86
90
  * The selection: a value — none, a text range of two anchors,
87
- * one inline atom, or a set of block ids. Only `select()` replaces it.
91
+ * one inline atom, or a set of block ids. Read-only: only `select()`
92
+ * replaces it.
88
93
  */
89
- value: SelectionValue;
94
+ get value(): SelectionValue;
90
95
  constructor(edytor: Edytor, edytorOnSelectionChange?: ((selection: EdytorSelection) => void) | undefined);
91
96
  /** The projection of the current value at the current document version. */
92
97
  get projection(): SelectionProjection;
@@ -231,4 +236,3 @@ export declare class EdytorSelection {
231
236
  */
232
237
  setAtBlockRange: (block?: Block | null, startOffset?: number, endOffset?: number) => void;
233
238
  }
234
- export {};
@@ -110,9 +110,19 @@ const rangeEdge = (block, edge, cover = false) => {
110
110
  export class EdytorSelection {
111
111
  edytorOnSelectionChange;
112
112
  edytor;
113
- focusedBlocks = new SvelteSet();
114
- /** The selected blocks as clicked: a grip-selected list is one block. */
115
- selectedBlocks = new SvelteSet();
113
+ #focused = new SvelteSet();
114
+ #selected = new SvelteSet();
115
+ /** The blocks the selection touches (read-only: `select()` keeps it). */
116
+ get focusedBlocks() {
117
+ return this.#focused;
118
+ }
119
+ /**
120
+ * The selected blocks as clicked: a grip-selected list is one block
121
+ * (read-only: `select()` keeps it).
122
+ */
123
+ get selectedBlocks() {
124
+ return this.#selected;
125
+ }
116
126
  /**
117
127
  * The blocks a command over the block selection acts on, in document
118
128
  * order: the selected blocks, a selected list or code block with its
@@ -139,9 +149,13 @@ export class EdytorSelection {
139
149
  modelSelectionPreservationBlock = null;
140
150
  /**
141
151
  * The selection: a value — none, a text range of two anchors,
142
- * one inline atom, or a set of block ids. Only `select()` replaces it.
152
+ * one inline atom, or a set of block ids. Read-only: only `select()`
153
+ * replaces it.
143
154
  */
144
- value = $state.raw(noSelection);
155
+ get value() {
156
+ return this.#value;
157
+ }
158
+ #value = $state.raw(noSelection);
145
159
  /** @internal Advanced by every `select()`. */
146
160
  epoch = 0;
147
161
  /**
@@ -248,7 +262,7 @@ export class EdytorSelection {
248
262
  next = this.#keepPending(this.#shown(next));
249
263
  const changed = !sameValue(this.value, next);
250
264
  if (changed)
251
- this.value = next;
265
+ this.#value = next;
252
266
  const value = this.value;
253
267
  this.epoch++;
254
268
  // A repair is a background display (it never takes focus); a command,
@@ -275,8 +289,8 @@ export class EdytorSelection {
275
289
  : value.kind === 'atom'
276
290
  ? [blockOf(value.blockId) ?? []].flat()
277
291
  : [];
278
- this.#sync(this.selectedBlocks, selected, 'selected');
279
- this.#sync(this.focusedBlocks, focused, 'focused');
292
+ this.#sync(this.#selected, selected, 'selected');
293
+ this.#sync(this.#focused, focused, 'focused');
280
294
  if (atom !== this.inlineBlockDeletionTarget || (!atom && this.selectedInlineBlock.size)) {
281
295
  this.selectedInlineBlock.clear();
282
296
  if (atom)
@@ -35,8 +35,8 @@ type Normalizer = (this: Block) => void;
35
35
  export declare class Dispatcher {
36
36
  #private;
37
37
  private edytor;
38
- /** The last operation's result. */
39
- last: CommandResult | null;
38
+ /** The last operation's result (read-only: the dispatcher records it). */
39
+ get last(): CommandResult | null;
40
40
  /**
41
41
  * The result selection a command in flight declared before its operations: the seam repair leaves this view's endpoints to it.
42
42
  */
@@ -130,8 +130,20 @@ const specJSON = (spec) => ({
130
130
  });
131
131
  export class Dispatcher {
132
132
  edytor;
133
- /** The last operation's result. */
134
- last = null;
133
+ #last = null;
134
+ /** The last operation's result (read-only: the dispatcher records it). */
135
+ get last() {
136
+ return this.#last;
137
+ }
138
+ /**
139
+ * Record `result` as the last operation's: a refusal or result decided
140
+ * outside a dispatch (a key or input no command took, a gesture the
141
+ * layout refused, a history replay).
142
+ * @internal
143
+ */
144
+ record(result) {
145
+ this.#last = result;
146
+ }
135
147
  /**
136
148
  * The result selection a command in flight declared before its operations: the seam repair leaves this view's endpoints to it.
137
149
  */
@@ -266,7 +278,7 @@ export class Dispatcher {
266
278
  }
267
279
  });
268
280
  if (this.applied && this.last?.status !== 'applied' && this.last?.status !== 'failed')
269
- this.last = this.applied;
281
+ this.#last = this.applied;
270
282
  this.applied = null;
271
283
  const after = () => this.cut(kind, 'after');
272
284
  if (out instanceof Promise)
@@ -395,16 +407,16 @@ export class Dispatcher {
395
407
  });
396
408
  }
397
409
  catch (error) {
398
- this.last = { operation, status: 'failed', error };
410
+ this.#last = { operation, status: 'failed', error };
399
411
  throw error;
400
412
  }
401
413
  if (cut === 'both')
402
414
  this.edytor.undoManager?.stopCapturing();
403
- this.last = {
415
+ const own = {
404
416
  operation,
405
417
  status: this.edytor.facade.version === version ? 'noop' : 'applied'
406
418
  };
407
- const own = this.last;
419
+ this.#last = own;
408
420
  if (this.running && own.status === 'applied')
409
421
  this.applied = own;
410
422
  const change = { operation, payload: original, ...context };
@@ -435,7 +447,7 @@ export class Dispatcher {
435
447
  const next = { ...this.last, ...patch };
436
448
  if (this.#produced === this.last)
437
449
  this.#produced = next;
438
- this.last = next;
450
+ this.#last = next;
439
451
  }
440
452
  /**
441
453
  * Run `body` with `lead` (a plan prepared now) composed into the first
@@ -617,7 +629,7 @@ export class Dispatcher {
617
629
  }
618
630
  /** Record `operation` as refused; answers that result. */
619
631
  #refused(operation) {
620
- return (this.last = { operation, status: 'refused' });
632
+ return (this.#last = { operation, status: 'refused' });
621
633
  }
622
634
  settle(error, onPrevent) {
623
635
  const stop = prevented(error);
@@ -10,10 +10,12 @@ type Chars<S extends string, Acc = never> = S extends `${infer C}${infer R}` ? C
10
10
  /** The one-character keys a chord names: `KeyboardEvent.key`, lower case. */
11
11
  declare const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789`~!@#$%^&*()-_=+[]{}\\|;:'\",.<>/?";
12
12
  declare const NAMED: readonly ["arrowup", "arrowdown", "arrowleft", "arrowright", "tab", "enter", "backspace", "delete", "space", "escape", "home", "end", "pageup", "pagedown", "insert", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12"];
13
- type Key = Chars<typeof CHARS> | (typeof NAMED)[number];
14
- type Modifier = 'mod' | 'alt' | 'ctrl' | 'shift';
13
+ /** A key a chord names: a one-character key or a named one, lower case. */
14
+ export type Key = Chars<typeof CHARS> | (typeof NAMED)[number];
15
+ /** A chord's modifier (`mod`: Cmd on Apple platforms, Ctrl elsewhere). */
16
+ export type Modifier = 'mod' | 'alt' | 'ctrl' | 'shift';
15
17
  /** One to three distinct modifiers, in any order. */
16
- type Modifiers = {
18
+ export type Modifiers = {
17
19
  [A in Modifier]: A | {
18
20
  [B in Exclude<Modifier, A>]: `${A}+${B}` | `${A}+${B}+${Exclude<Modifier, A | B>}`;
19
21
  }[Exclude<Modifier, A>];
@@ -115,7 +115,7 @@ const place = (edytor, request) => {
115
115
  if (!move) {
116
116
  // Refused before any command ran: `last` still reports it (commands#results).
117
117
  const operation = request.blocks.length > 1 ? 'moveBlocks' : 'moveBlock';
118
- edytor.dispatcher.last = { operation, status: request.blocks.length ? 'refused' : 'noop' };
118
+ edytor.dispatcher.record({ operation, status: request.blocks.length ? 'refused' : 'noop' });
119
119
  return [];
120
120
  }
121
121
  const [first, ...rest] = move.blocks;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "edytor",
3
3
  "license": "MIT",
4
- "version": "0.1.0-next.41",
4
+ "version": "0.1.0-next.43",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/beynar/edytor.git"