edytor 4.0.0 → 5.0.0

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.
Files changed (147) hide show
  1. package/README.md +2 -2
  2. package/dist/block/block.svelte.js +6 -21
  3. package/dist/block/inlineBlock.svelte.js +4 -6
  4. package/dist/clipboard/moveText.js +6 -8
  5. package/dist/cloudflare/DocumentRoom.d.ts +10 -3
  6. package/dist/cloudflare/index.d.ts +1 -1
  7. package/dist/cloudflare/room/access.d.ts +8 -3
  8. package/dist/cloudflare/room/access.js +44 -20
  9. package/dist/cloudflare/room/admission.js +28 -5
  10. package/dist/cloudflare/room/comments.js +28 -1
  11. package/dist/cloudflare/room/context.d.ts +115 -106
  12. package/dist/cloudflare/room/context.js +10 -3
  13. package/dist/cloudflare/room/forged.d.ts +14 -0
  14. package/dist/cloudflare/room/forged.js +30 -2
  15. package/dist/cloudflare/room/frames.d.ts +4 -0
  16. package/dist/cloudflare/room/frames.js +5 -1
  17. package/dist/cloudflare/room/purge.js +2 -1
  18. package/dist/cloudflare/room/replicas.d.ts +4 -2
  19. package/dist/cloudflare/room/replicas.js +8 -3
  20. package/dist/cloudflare/room/suggest.d.ts +7 -0
  21. package/dist/cloudflare/room/suggest.js +143 -0
  22. package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
  23. package/dist/cloudflare/routeDocumentSocket.js +5 -3
  24. package/dist/components/Content.svelte +9 -1
  25. package/dist/crdt/anchors.js +3 -2
  26. package/dist/crdt/attribution/attribution.d.ts +3 -0
  27. package/dist/crdt/attribution/attribution.js +3 -3
  28. package/dist/crdt/doc/capability.js +9 -13
  29. package/dist/crdt/doc/funnel.d.ts +1 -1
  30. package/dist/crdt/doc/funnel.js +5 -2
  31. package/dist/crdt/doc/gate.d.ts +1 -1
  32. package/dist/crdt/doc/history.d.ts +6 -0
  33. package/dist/crdt/doc/history.js +77 -56
  34. package/dist/crdt/doc/layout.js +5 -5
  35. package/dist/crdt/doc/meta.js +6 -7
  36. package/dist/crdt/doc/reads.d.ts +2 -2
  37. package/dist/crdt/doc/reads.js +18 -28
  38. package/dist/crdt/doc/steps.js +4 -4
  39. package/dist/crdt/doc/suggest.d.ts +167 -0
  40. package/dist/crdt/doc/suggest.js +579 -0
  41. package/dist/crdt/document.d.ts +10 -1
  42. package/dist/crdt/document.js +19 -11
  43. package/dist/crdt/edytor-doc.d.ts +355 -333
  44. package/dist/crdt/edytor-doc.js +125 -82
  45. package/dist/crdt/engine-api.d.ts +8 -2
  46. package/dist/crdt/engine.d.ts +2 -1
  47. package/dist/crdt/engine.js +3 -1
  48. package/dist/crdt/flow.d.ts +1 -1
  49. package/dist/crdt/flow.js +4 -10
  50. package/dist/crdt/index.d.ts +111 -105
  51. package/dist/crdt/index.js +3 -0
  52. package/dist/crdt/operations.d.ts +12 -3
  53. package/dist/crdt/parts.d.ts +43 -0
  54. package/dist/crdt/parts.js +44 -0
  55. package/dist/crdt/placement/display.d.ts +1 -1
  56. package/dist/crdt/placement/display.js +3 -3
  57. package/dist/crdt/placement/keys.d.ts +5 -0
  58. package/dist/crdt/placement/keys.js +5 -0
  59. package/dist/crdt/placement/resolve.js +2 -1
  60. package/dist/crdt/protocol.d.ts +1 -1
  61. package/dist/crdt/protocol.js +1 -1
  62. package/dist/crdt/protocols/auth.d.ts +11 -1
  63. package/dist/crdt/protocols/auth.js +17 -1
  64. package/dist/crdt/protocols/comments.d.ts +16 -1
  65. package/dist/crdt/protocols/comments.js +9 -1
  66. package/dist/crdt/providers/index.d.ts +6 -4
  67. package/dist/crdt/providers/index.js +3 -1
  68. package/dist/crdt/providers/websocket.d.ts +6 -4
  69. package/dist/crdt/providers/websocket.js +9 -4
  70. package/dist/crdt/purge.d.ts +7 -1
  71. package/dist/crdt/purge.js +32 -2
  72. package/dist/crdt/restore.d.ts +2 -1
  73. package/dist/crdt/restore.js +9 -0
  74. package/dist/crdt/schema.d.ts +9 -1
  75. package/dist/crdt/schema.js +13 -3
  76. package/dist/crdt/structs.d.ts +2 -0
  77. package/dist/crdt/structs.js +2 -0
  78. package/dist/crdt/text/index/claims.js +1 -23
  79. package/dist/crdt/text/index/layout.d.ts +2 -4
  80. package/dist/crdt/text/index/layout.js +9 -22
  81. package/dist/crdt/text/index/placement.js +21 -24
  82. package/dist/crdt/text/index/shared.js +17 -14
  83. package/dist/crdt/text/model.d.ts +10 -0
  84. package/dist/crdt/text/model.js +8 -1
  85. package/dist/crdt/text/runs.d.ts +34 -30
  86. package/dist/crdt/vendor/yjs/UPSTREAM.md +83 -0
  87. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  88. package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +6 -3
  89. package/dist/crdt/vendor/yjs/dts/ynode.d.ts +13 -2
  90. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  91. package/dist/crdt/vendor/yjs/src/structs/Item.js +44 -5
  92. package/dist/crdt/vendor/yjs/src/ynode.js +29 -7
  93. package/dist/edytor.svelte.d.ts +1 -1
  94. package/dist/edytor.svelte.js +100 -75
  95. package/dist/events/beforeInputCommands.d.ts +2 -2
  96. package/dist/events/nativeInteractiveControl.d.ts +11 -1
  97. package/dist/events/nativeInteractiveControl.js +41 -1
  98. package/dist/events/onBeforeInput.js +3 -3
  99. package/dist/events/onFocus.js +9 -11
  100. package/dist/export/forms.d.ts +25 -25
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +1 -1
  103. package/dist/labels.d.ts +8 -0
  104. package/dist/labels.js +6 -1
  105. package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
  106. package/dist/plugins/comments/CommentsController.svelte.js +14 -4
  107. package/dist/plugins/find/search.js +2 -2
  108. package/dist/plugins/index.d.ts +1 -0
  109. package/dist/plugins/index.js +1 -0
  110. package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
  111. package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
  112. package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
  113. package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
  114. package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
  115. package/dist/plugins/suggestions/shared.svelte.js +421 -0
  116. package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
  117. package/dist/selection/domSelection.d.ts +2 -0
  118. package/dist/selection/domSelection.js +6 -2
  119. package/dist/selection/pointer.svelte.js +75 -48
  120. package/dist/selection/replaceSelection.js +2 -6
  121. package/dist/selection/selection.svelte.d.ts +3 -3
  122. package/dist/selection/selection.svelte.js +39 -32
  123. package/dist/selection/selection.utils.d.ts +14 -0
  124. package/dist/selection/selection.utils.js +28 -3
  125. package/dist/session/commands.js +21 -11
  126. package/dist/session/composition.svelte.d.ts +5 -3
  127. package/dist/session/composition.svelte.js +21 -17
  128. package/dist/session/handles.d.ts +4 -17
  129. package/dist/session/handles.js +11 -37
  130. package/dist/session/navigation.js +6 -9
  131. package/dist/session/selection.d.ts +11 -5
  132. package/dist/session/selection.js +14 -48
  133. package/dist/session/suggestions.svelte.d.ts +38 -9
  134. package/dist/session/suggestions.svelte.js +37 -18
  135. package/dist/session/virtual.d.ts +1 -1
  136. package/dist/surface/attributes.d.ts +5 -0
  137. package/dist/surface/attributes.js +2 -2
  138. package/dist/surface/cells.d.ts +2 -26
  139. package/dist/surface/cells.js +2 -33
  140. package/dist/surface/observer.svelte.d.ts +6 -1
  141. package/dist/surface/observer.svelte.js +128 -81
  142. package/dist/surface/pin.svelte.d.ts +9 -9
  143. package/dist/surface/pin.svelte.js +15 -22
  144. package/dist/surface/projector.svelte.js +8 -14
  145. package/dist/text/text.svelte.js +14 -19
  146. package/dist/themes/notion.css +29 -0
  147. package/package.json +3 -1
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
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.
19
19
 
20
- > **4.0.** Edytor `4.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
20
+ > **5.0.** Edytor `5.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
21
21
 
22
22
  ## Features
23
23
 
@@ -40,7 +40,7 @@ Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable
40
40
 
41
41
  ## Install
42
42
 
43
- Edytor `4.0.0` is on npm under `latest`:
43
+ Edytor `5.0.0` is on npm under `latest`:
44
44
 
45
45
  ```bash
46
46
  pnpm add edytor
@@ -1,5 +1,6 @@
1
1
  import { cleanups } from '../utils.js';
2
2
  import { Text } from '../text/text.svelte.js';
3
+ import { segmentAt } from '../crdt/parts.js';
3
4
  /** Mark an element inside a block's markup as non-editable chrome (`block.void`, a preview's too). */
4
5
  export const voidChrome = (node) => {
5
6
  node.setAttribute('data-edytor-void', `true`);
@@ -63,12 +64,9 @@ export class Block {
63
64
  get content() {
64
65
  this.#track();
65
66
  const handles = this.edytor.idToBlock;
66
- let ordinal = 0;
67
67
  return handles
68
68
  .parts(this.id)
69
- .map((part) => part.kind === 'text'
70
- ? handles.text(this.id, ordinal++)
71
- : handles.atom(this.id, part.item.id));
69
+ .map((part) => part.kind === 'text' ? handles.text(this.id, part.segOrd) : handles.atom(this.id, part.id));
72
70
  }
73
71
  get type() {
74
72
  if (this.isRoot)
@@ -299,23 +297,10 @@ export class Block {
299
297
  * last text's end).
300
298
  */
301
299
  textAtOffset = (offset) => {
302
- let at = 0;
303
- let hit = null;
304
- for (const part of this.content) {
305
- if (!(part instanceof Text)) {
306
- if (offset <= at)
307
- return hit;
308
- at += 1;
309
- }
310
- else if (offset <= at + part.length) {
311
- return { text: part, offset: Math.max(0, offset - at) };
312
- }
313
- else {
314
- hit = { text: part, offset: part.length };
315
- at += part.length;
316
- }
317
- }
318
- return hit;
300
+ this.#track();
301
+ const { start, end, segOrd } = segmentAt(this.edytor.idToBlock.parts(this.id), offset);
302
+ const text = this.edytor.idToBlock.text(this.id, segOrd);
303
+ return { text, offset: Math.min(Math.max(0, offset - start), end - start) };
319
304
  };
320
305
  /** The declared view state (`open` on a `details`) the block's last element held. */
321
306
  #viewState = [];
@@ -27,9 +27,9 @@ export class InlineBlock {
27
27
  get #item() {
28
28
  if ($effect.tracking())
29
29
  this.edytor.cells?.get(this.blockId);
30
- const parts = this.edytor.idToBlock.parts(this.blockId);
31
- const part = parts.find((part) => part.kind === 'inline' && part.item.id === this.id);
32
- return part?.kind === 'inline' ? part.item : undefined;
30
+ return this.edytor.idToBlock
31
+ .parts(this.blockId)
32
+ .find((part) => part.kind === 'inline' && part.id === this.id);
33
33
  }
34
34
  get selected() {
35
35
  return this.edytor.selection.selectedInlineBlock.has(this);
@@ -66,9 +66,8 @@ export class InlineBlock {
66
66
  node.dataset.edytorId = this.id;
67
67
  node.dataset.edytorInlineBlock = this.type;
68
68
  this.#attachedNodes.add(node);
69
- this.edytor.nodeToInlineBlock.set(node, this);
70
69
  this.node = node;
71
- const release = this.edytor.surface.register(node, 'atom', this.blockId);
70
+ const release = this.edytor.surface.register(node, 'atom', this.blockId, undefined, undefined, this);
72
71
  const selectInlineBlock = (event) => {
73
72
  if (event.button !== 0)
74
73
  return;
@@ -100,7 +99,6 @@ export class InlineBlock {
100
99
  if (this.node === node) {
101
100
  this.node = this.#attachedNodes.values().next().value;
102
101
  }
103
- this.edytor.nodeToInlineBlock.delete(node);
104
102
  release();
105
103
  }
106
104
  };
@@ -74,14 +74,12 @@ export const dropText = (edytor, drag, to) => {
74
74
  /** The text and offset an anchor resolves to. */
75
75
  const point = (anchor) => {
76
76
  const hit = anchor && facade.resolveAnchor(anchor);
77
- const at = hit && edytor.idToBlock.get(hit.blockId)?.textAtOffset(hit.offset);
78
- return at ? [at.text, at.offset] : [null, 0];
77
+ return hit && edytor.idToBlock.has(hit.blockId)
78
+ ? { block: hit.blockId, offset: hit.offset }
79
+ : null;
79
80
  };
80
- const [startText, startOffset] = point(placed.from);
81
- const [endText, endOffset] = point(placed.to);
82
- if (startText && endText)
83
- edytor.selection.selectText(startText, startOffset, endText, endOffset);
84
- else if (endText)
85
- edytor.selection.selectText(endText, endOffset);
81
+ const [from, at] = [point(placed.from), point(placed.to)];
82
+ if (at)
83
+ edytor.selection.selectAt(from ?? at, at);
86
84
  return true;
87
85
  };
@@ -106,6 +106,7 @@ import * as E from '../crdt/protocol.js';
106
106
  import type { PurgeReport } from '../crdt/purge.js';
107
107
  import { type HistoryEntry, type HistoryOptions, type KVLike, type R2BucketLike } from './history.js';
108
108
  import type { LockOptions } from './locks.js';
109
+ import type { DocumentAccessLevel } from './routeDocumentSocket.js';
109
110
  import type { CommitResult, ExportedBlocks, ImportReceipt, ImportRequest, LateEditBatch, MoveNamespace } from './move.js';
110
111
  import type { AwarenessEntry } from '../crdt/protocol.js';
111
112
  import type { DocumentSemanticsConfig, DocumentOperations, JSONBlock, JSONDoc, YDoc } from '../crdt/index.js';
@@ -258,7 +259,13 @@ export type SocketIdentity = {
258
259
  user: string;
259
260
  /** `null` until bound — by `authorize`, or by the socket's first presence entry. */
260
261
  replica: number | null;
262
+ /** It writes nothing to the document (a `read` or `comment` access). */
261
263
  readOnly: boolean;
264
+ /**
265
+ * Less than `write`, more than `read`: `suggest` writes only suggestions
266
+ * (`room.access.suggest`), `comment` only comments (`room.access.comment`).
267
+ */
268
+ access?: 'suggest' | 'comment';
262
269
  /**
263
270
  * When its credential expires (ms since the epoch, the room's clock):
264
271
  * the room closes it `4401` then (`room.access`). `null` or absent: never.
@@ -274,7 +281,7 @@ export type Attachment = SocketIdentity & {
274
281
  comments?: boolean;
275
282
  };
276
283
  export type Refusal = {
277
- reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
284
+ reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'suggest-only' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
278
285
  detail: unknown;
279
286
  };
280
287
  /** A duration series: how many, their total and longest (ms; see {@link RoomMetrics}). */
@@ -849,7 +856,7 @@ export declare class AttachedDocument {
849
856
  * socket `4403` (`access revoked`), as {@link closeUser}. Your `authorize` must decide the
850
857
  * same from then on. Returns how many sockets it changed.
851
858
  */
852
- setAccess(userId: string, access: 'write' | 'read' | 'none'): {
859
+ setAccess(userId: string, access: DocumentAccessLevel | 'none'): {
853
860
  sockets: number;
854
861
  };
855
862
  /** Is `ws` one of this document's sockets? */
@@ -983,7 +990,7 @@ export declare class DocumentRoom<Env = DocumentRoomEnv> extends DurableObject<E
983
990
  sockets: number;
984
991
  };
985
992
  /** Change a user's access on its sockets, also over RPC — see {@link AttachedDocument.setAccess}. */
986
- setAccess(userId: string, access: 'write' | 'read' | 'none'): {
993
+ setAccess(userId: string, access: DocumentAccessLevel | 'none'): {
987
994
  sockets: number;
988
995
  };
989
996
  /** Compaction, also over RPC. */
@@ -10,4 +10,4 @@ export { type PurgeReport } from '../crdt/purge.js';
10
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
- export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentIdentity, type RouteDocumentOptions } from './routeDocumentSocket.js';
13
+ export { routeDocumentSocket, routeDocumentHistory, routeDocumentComments, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentAccessLevel, type DocumentIdentity, type RouteDocumentOptions } from './routeDocumentSocket.js';
@@ -1,4 +1,5 @@
1
1
  import { type RoomContext } from './context.js';
2
+ import type { DocumentAccessLevel } from '../routeDocumentSocket.js';
2
3
  /** A Yjs client id (a non-negative safe integer), or `null`. */
3
4
  export declare const parseReplica: (raw: unknown) => number | null;
4
5
  export declare class RoomAccess {
@@ -11,15 +12,19 @@ export declare class RoomAccess {
11
12
  /** The open sockets of `user`. */
12
13
  private socketsOf;
13
14
  /** Close `sockets` with `code`, their departure announced, the change logged (`access`). */
14
- endAccess(sockets: WebSocket[], access: 'expired' | 'closed' | 'write' | 'none', code: number, reason: string): void;
15
+ endAccess(sockets: WebSocket[], access: 'expired' | 'closed' | 'none' | DocumentAccessLevel, code: number, reason: string): void;
15
16
  /** Revoke: close every socket of `userId` (`AttachedDocument.closeUser`). */
16
17
  closeUser(userId: string, code?: number, reason?: string): {
17
18
  sockets: number;
18
19
  };
19
20
  /** Close every socket of `userId`, logged as `access`. */
20
21
  private revoke;
21
- /** Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`). */
22
- setAccess(userId: string, access: 'write' | 'read' | 'none'): {
22
+ /**
23
+ * Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`):
24
+ * a downgrade to `read` in place, any other change by a `1012` close
25
+ * the provider redials (so `authorize` grants the new access).
26
+ */
27
+ setAccess(userId: string, access: DocumentAccessLevel | 'none'): {
23
28
  sockets: number;
24
29
  };
25
30
  /**
@@ -8,7 +8,7 @@
8
8
  import { CLOSE } from '../../crdt/providers/room.js';
9
9
  import { COMMENTS_HEADER, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, PROBE_HEADER, SOCKET_TAG, closedSocket, noTimers } from './shared.js';
10
10
  import { STORAGE_FAILURE } from './context.js';
11
- import { readOnlyFrame, step1Frame } from './frames.js';
11
+ import { accessFrame, readOnlyFrame, step1Frame } from './frames.js';
12
12
  import { StorageFault } from './replicas.js';
13
13
  import { stateVector } from './updates.js';
14
14
  /** The longest close reason the WebSocket API accepts, in UTF-8 bytes. */
@@ -22,6 +22,14 @@ export const parseReplica = (raw) => {
22
22
  const n = typeof raw === 'string' && /^\d{1,16}$/.test(raw) ? Number(raw) : raw;
23
23
  return typeof n === 'number' && Number.isSafeInteger(n) && n >= 0 ? n : null;
24
24
  };
25
+ const LEVELS = ['read', 'comment', 'suggest', 'write'];
26
+ /** An access level as a socket's identity carries it (`readOnly`, and `access` between read and write). */
27
+ const levelOf = (level) => ({
28
+ readOnly: level === 'read' || level === 'comment',
29
+ ...((level === 'suggest' || level === 'comment') && { access: level })
30
+ });
31
+ /** A socket identity's level. */
32
+ const levelIn = (identity) => identity.access ?? (identity.readOnly ? 'read' : 'write');
25
33
  /** The user id `routeDocumentSocket` encoded into its header, or `null`. */
26
34
  const readUser = (raw) => {
27
35
  try {
@@ -40,14 +48,16 @@ const readIdentity = (headers) => {
40
48
  const expiresAt = rawExpires === null ? null : Number(rawExpires);
41
49
  if (!user || user.length > 256 || (rawReplica && replica === null))
42
50
  return null;
43
- if (access !== 'read' && access !== 'write')
51
+ if (!LEVELS.includes(access))
44
52
  return null;
45
53
  if (expiresAt !== null && !Number.isFinite(expiresAt))
46
54
  return null;
47
- const identity = { user, replica, readOnly: access === 'read' };
48
- if (expiresAt !== null)
49
- identity.expiresAt = expiresAt;
50
- return identity;
55
+ return {
56
+ user,
57
+ replica,
58
+ ...levelOf(access),
59
+ ...(expiresAt !== null && { expiresAt })
60
+ };
51
61
  };
52
62
  export class RoomAccess {
53
63
  room;
@@ -131,10 +141,20 @@ export class RoomAccess {
131
141
  room.refuseContainer(server);
132
142
  }
133
143
  else {
134
- // A read-only socket is never asked for its state: it has nothing
135
- // to give. It gets the read-only notice instead (not a refusal),
136
- // so its provider tracks nothing.
137
- room.send(server, identity.readOnly ? readOnlyFrame() : step1Frame(doc));
144
+ // Every socket is told its access as it joins (`room.access.suggest`),
145
+ // before anything else it reads. A read-only socket is never asked
146
+ // for its state: it has nothing to give. It gets the read-only
147
+ // notice instead (not a refusal), so its provider tracks nothing,
148
+ // and its level after it.
149
+ const level = levelIn(identity);
150
+ if (identity.readOnly) {
151
+ room.send(server, readOnlyFrame());
152
+ room.send(server, accessFrame(level));
153
+ }
154
+ else {
155
+ room.send(server, accessFrame(level));
156
+ room.send(server, step1Frame(doc));
157
+ }
138
158
  if (room.state.presence.size > 0)
139
159
  room.send(server, room.presence.snapshot());
140
160
  }
@@ -183,26 +203,30 @@ export class RoomAccess {
183
203
  return { sockets: sockets.length };
184
204
  });
185
205
  }
186
- /** Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`). */
206
+ /**
207
+ * Change `userId`'s access on its open sockets (`AttachedDocument.setAccess`):
208
+ * a downgrade to `read` in place, any other change by a `1012` close
209
+ * the provider redials (so `authorize` grants the new access).
210
+ */
187
211
  setAccess(userId, access) {
188
212
  const room = this.room;
189
- if (access !== 'write' && access !== 'read' && access !== 'none') {
213
+ if (access !== 'none' && !LEVELS.includes(access)) {
190
214
  throw new RangeError(`setAccess: unknown access ${String(access)}`);
191
215
  }
192
216
  if (access === 'none')
193
217
  return this.revoke(userId, 'none', CLOSE.denied, 'access revoked');
194
218
  return noTimers(() => {
195
- const sockets = this.socketsOf(userId).filter(({ attachment }) => attachment.readOnly !== (access === 'read'));
196
- if (access === 'write') {
197
- this.endAccess(sockets.map(({ ws }) => ws), 'write', CLOSE.accessChanged, 'access changed');
198
- return { sockets: sockets.length };
199
- }
200
- for (const { ws, attachment } of sockets) {
219
+ const sockets = this.socketsOf(userId).filter(({ attachment }) => levelIn(attachment) !== access);
220
+ const inPlace = sockets.filter(({ attachment }) => access === 'read' && levelIn(attachment) === 'write');
221
+ const closed = sockets.filter((socket) => !inPlace.includes(socket));
222
+ this.endAccess(closed.map(({ ws }) => ws), access, CLOSE.accessChanged, 'access changed');
223
+ for (const { ws, attachment } of inPlace) {
201
224
  ws.serializeAttachment({ ...attachment, readOnly: true });
202
225
  room.send(ws, readOnlyFrame());
226
+ room.send(ws, accessFrame('read'));
203
227
  }
204
- if (sockets.length > 0) {
205
- room.note({ reason: 'access', detail: { user: userId, access, sockets: sockets.length } });
228
+ if (inPlace.length > 0) {
229
+ room.note({ reason: 'access', detail: { user: userId, access, sockets: inPlace.length } });
206
230
  }
207
231
  return { sockets: sockets.length };
208
232
  });
@@ -14,8 +14,9 @@ import * as E from '../../crdt/protocol.js';
14
14
  import { ChunkError, CLOSE } from '../../crdt/providers/room.js';
15
15
  import { MAX_WAITING_DELETES, noTimers } from './shared.js';
16
16
  import { STORAGE_FAILURE, now, sync, tally } from './context.js';
17
- import { attributionWrites, BINDING_PREFIX, forgedAttributionDeletes, forgedDeletes, forgedWriters } from './forged.js';
18
- import { MalformedFrame, decode, readOnlyDenialFrame, savedFrame, step1Frame, storedStep2, updateFrame } from './frames.js';
17
+ import { attributionWrites, BINDING_PREFIX, forgedAttributionDeletes, forgedDeletes, forgedWriters, selfBound } from './forged.js';
18
+ import { suggestOnly } from './suggest.js';
19
+ import { MalformedFrame, decode, readOnlyDenialFrame, savedFrame, suggestOnlyDenialFrame, step1Frame, storedStep2, updateFrame } from './frames.js';
19
20
  import { StorageFault } from './replicas.js';
20
21
  import { addIds, deletesUpdate, forgetWaiting, freedBy, heldDeletes, newWriters, pendingDeletes, rangeCount, releasedWaiting, replacedEntries, stateVector, storedStruct, unheldDeletes, withoutClients } from './updates.js';
21
22
  /**
@@ -350,7 +351,8 @@ export class Admission {
350
351
  }
351
352
  const sv = stateVector(doc);
352
353
  const orphans = new Set();
353
- const stripped = replicas.attribute(attachment, newWriters(decoded, sv), sv, orphans);
354
+ const bound = selfBound(doc, decoded.structs, attachment.user);
355
+ const stripped = replicas.attribute(attachment, newWriters(decoded, sv), sv, orphans, bound);
354
356
  // Per-writer block marks: only `n` writes or deletes `del.<n>` /
355
357
  // `wd.<n>`. A client writing another's is stripped whole, as a client
356
358
  // under another user's id is; a delete of another's mark is dropped.
@@ -374,6 +376,23 @@ export class Admission {
374
376
  detail: { user: attachment.user, writers: [...forgers], ranges: rangeCount(forgedMarks) }
375
377
  });
376
378
  }
379
+ // A user who may only suggest writes only suggestions (`room.access.suggest`).
380
+ const suggest = attachment.access === 'suggest'
381
+ ? suggestOnly(doc, decoded, stripped, (client) => mayDelete(client), attachment.replica)
382
+ : null;
383
+ if (suggest && (suggest.strip.size > 0 || !suggest.deletes.isEmpty())) {
384
+ for (const client of suggest.strip)
385
+ stripped.add(client);
386
+ room.note({
387
+ reason: 'suggest-only',
388
+ detail: {
389
+ user: attachment.user,
390
+ writers: [...suggest.strip],
391
+ ranges: rangeCount(suggest.deletes)
392
+ }
393
+ });
394
+ room.send(ws, suggestOnlyDenialFrame());
395
+ }
377
396
  // Attribution (`room.attribution.trust`): a replica binds only
378
397
  // itself, to its verified user, and a user writes only its own
379
398
  // profile. Another's entry is collected (its clock kept as a GC), a
@@ -405,9 +424,13 @@ export class Admission {
405
424
  detail: { user: attachment.user, ranges: rangeCount(overflow) }
406
425
  });
407
426
  }
408
- const dropped = overflow === null && forgedMarks.isEmpty() && forgedEntries.ids.isEmpty()
427
+ const suggestDeletes = suggest?.deletes ?? Y.createIdSet();
428
+ const dropped = overflow === null &&
429
+ forgedMarks.isEmpty() &&
430
+ forgedEntries.ids.isEmpty() &&
431
+ suggestDeletes.isEmpty()
409
432
  ? null
410
- : addIds(addIds(addIds(Y.createIdSet(), overflow ?? Y.createIdSet()), forgedMarks), forgedEntries.ids);
433
+ : addIds(addIds(addIds(addIds(Y.createIdSet(), overflow ?? Y.createIdSet()), forgedMarks), forgedEntries.ids), suggestDeletes);
411
434
  const admitted = stripped.size === 0 && dropped === null && attribution.collected.size === 0
412
435
  ? update
413
436
  : withoutClients(decoded, stripped, doc, dropped ?? undefined, attribution.collected);
@@ -213,6 +213,9 @@ export class RoomComments {
213
213
  this.broadcast(decided.change);
214
214
  if (decided.change.type === 'removed')
215
215
  this.#removeAnchors(decided.change.thread.id);
216
+ // A user who may comment but not write: the room writes the thread's anchor (`room.access.comment`).
217
+ if (decided.change.type === 'added' && actor.anchors && request.op === 'add')
218
+ this.#writeAnchors(decided.change.thread.id, request.anchors ?? []);
216
219
  room.log({
217
220
  edytor: 'comment',
218
221
  type: decided.change.type,
@@ -232,6 +235,27 @@ export class RoomComments {
232
235
  }
233
236
  return outcome;
234
237
  }
238
+ /** A new thread's anchor over `spans` (anchors), written by the room: one room transaction. */
239
+ #writeAnchors(id, spans) {
240
+ const room = this.room;
241
+ if (room.live === null || room.busy || spans.length === 0)
242
+ return;
243
+ try {
244
+ const mark = `${COMMENT_MARK}:${id}`;
245
+ room.transact((facade) => {
246
+ for (const { start, end } of spans) {
247
+ const [from, to] = [start, end].map((a) => facade.resolveAnchor(a));
248
+ if (from && to && from.blockId === to.blockId && to.offset > from.offset)
249
+ facade.formatRange(from.blockId, from.offset, to.offset - from.offset, {
250
+ [mark]: { id }
251
+ });
252
+ }
253
+ });
254
+ }
255
+ catch (error) {
256
+ room.log({ edytor: 'fault', reason: 'internal', detail: `comment anchor: ${String(error)}` });
257
+ }
258
+ }
235
259
  /** A removed thread's anchor: its marks leave the document, as one room transaction. */
236
260
  #removeAnchors(id) {
237
261
  const room = this.room;
@@ -322,9 +346,12 @@ export class RoomComments {
322
346
  const request = parseCommentRequest(json);
323
347
  if (request === null)
324
348
  return new Response('invalid request', { status: 400 });
349
+ // A user who may comment but not write: the room writes what its view may not (the anchor).
350
+ const anchors = identity.access === 'comment' || identity.access === 'suggest';
325
351
  const outcome = await this.apply(request, {
326
352
  user: identity.user,
327
- readOnly: identity.readOnly
353
+ readOnly: identity.readOnly && !anchors,
354
+ ...(anchors && { anchors })
328
355
  });
329
356
  if (outcome.status === 'refused')
330
357
  return new Response(outcome.message, { status: COMMENT_STATUS[outcome.reason] });