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.
- package/README.md +2 -2
- package/dist/block/block.svelte.js +6 -21
- package/dist/block/inlineBlock.svelte.js +4 -6
- package/dist/clipboard/moveText.js +6 -8
- package/dist/cloudflare/DocumentRoom.d.ts +10 -3
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/room/access.d.ts +8 -3
- package/dist/cloudflare/room/access.js +44 -20
- package/dist/cloudflare/room/admission.js +28 -5
- package/dist/cloudflare/room/comments.js +28 -1
- package/dist/cloudflare/room/context.d.ts +115 -106
- package/dist/cloudflare/room/context.js +10 -3
- package/dist/cloudflare/room/forged.d.ts +14 -0
- package/dist/cloudflare/room/forged.js +30 -2
- package/dist/cloudflare/room/frames.d.ts +4 -0
- package/dist/cloudflare/room/frames.js +5 -1
- package/dist/cloudflare/room/purge.js +2 -1
- package/dist/cloudflare/room/replicas.d.ts +4 -2
- package/dist/cloudflare/room/replicas.js +8 -3
- package/dist/cloudflare/room/suggest.d.ts +7 -0
- package/dist/cloudflare/room/suggest.js +143 -0
- package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
- package/dist/cloudflare/routeDocumentSocket.js +5 -3
- package/dist/components/Content.svelte +9 -1
- package/dist/crdt/anchors.js +3 -2
- package/dist/crdt/attribution/attribution.d.ts +3 -0
- package/dist/crdt/attribution/attribution.js +3 -3
- package/dist/crdt/doc/capability.js +9 -13
- package/dist/crdt/doc/funnel.d.ts +1 -1
- package/dist/crdt/doc/funnel.js +5 -2
- package/dist/crdt/doc/gate.d.ts +1 -1
- package/dist/crdt/doc/history.d.ts +6 -0
- package/dist/crdt/doc/history.js +77 -56
- package/dist/crdt/doc/layout.js +5 -5
- package/dist/crdt/doc/meta.js +6 -7
- package/dist/crdt/doc/reads.d.ts +2 -2
- package/dist/crdt/doc/reads.js +18 -28
- package/dist/crdt/doc/steps.js +4 -4
- package/dist/crdt/doc/suggest.d.ts +167 -0
- package/dist/crdt/doc/suggest.js +579 -0
- package/dist/crdt/document.d.ts +10 -1
- package/dist/crdt/document.js +19 -11
- package/dist/crdt/edytor-doc.d.ts +355 -333
- package/dist/crdt/edytor-doc.js +125 -82
- package/dist/crdt/engine-api.d.ts +8 -2
- package/dist/crdt/engine.d.ts +2 -1
- package/dist/crdt/engine.js +3 -1
- package/dist/crdt/flow.d.ts +1 -1
- package/dist/crdt/flow.js +4 -10
- package/dist/crdt/index.d.ts +111 -105
- package/dist/crdt/index.js +3 -0
- package/dist/crdt/operations.d.ts +12 -3
- package/dist/crdt/parts.d.ts +43 -0
- package/dist/crdt/parts.js +44 -0
- package/dist/crdt/placement/display.d.ts +1 -1
- package/dist/crdt/placement/display.js +3 -3
- package/dist/crdt/placement/keys.d.ts +5 -0
- package/dist/crdt/placement/keys.js +5 -0
- package/dist/crdt/placement/resolve.js +2 -1
- package/dist/crdt/protocol.d.ts +1 -1
- package/dist/crdt/protocol.js +1 -1
- package/dist/crdt/protocols/auth.d.ts +11 -1
- package/dist/crdt/protocols/auth.js +17 -1
- package/dist/crdt/protocols/comments.d.ts +16 -1
- package/dist/crdt/protocols/comments.js +9 -1
- package/dist/crdt/providers/index.d.ts +6 -4
- package/dist/crdt/providers/index.js +3 -1
- package/dist/crdt/providers/websocket.d.ts +6 -4
- package/dist/crdt/providers/websocket.js +9 -4
- package/dist/crdt/purge.d.ts +7 -1
- package/dist/crdt/purge.js +32 -2
- package/dist/crdt/restore.d.ts +2 -1
- package/dist/crdt/restore.js +9 -0
- package/dist/crdt/schema.d.ts +9 -1
- package/dist/crdt/schema.js +13 -3
- package/dist/crdt/structs.d.ts +2 -0
- package/dist/crdt/structs.js +2 -0
- package/dist/crdt/text/index/claims.js +1 -23
- package/dist/crdt/text/index/layout.d.ts +2 -4
- package/dist/crdt/text/index/layout.js +9 -22
- package/dist/crdt/text/index/placement.js +21 -24
- package/dist/crdt/text/index/shared.js +17 -14
- package/dist/crdt/text/model.d.ts +10 -0
- package/dist/crdt/text/model.js +8 -1
- package/dist/crdt/text/runs.d.ts +34 -30
- package/dist/crdt/vendor/yjs/UPSTREAM.md +83 -0
- package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
- package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +6 -3
- package/dist/crdt/vendor/yjs/dts/ynode.d.ts +13 -2
- package/dist/crdt/vendor/yjs/src/index.js +1 -1
- package/dist/crdt/vendor/yjs/src/structs/Item.js +44 -5
- package/dist/crdt/vendor/yjs/src/ynode.js +29 -7
- package/dist/edytor.svelte.d.ts +1 -1
- package/dist/edytor.svelte.js +100 -75
- package/dist/events/beforeInputCommands.d.ts +2 -2
- package/dist/events/nativeInteractiveControl.d.ts +11 -1
- package/dist/events/nativeInteractiveControl.js +41 -1
- package/dist/events/onBeforeInput.js +3 -3
- package/dist/events/onFocus.js +9 -11
- package/dist/export/forms.d.ts +25 -25
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/labels.d.ts +8 -0
- package/dist/labels.js +6 -1
- package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
- package/dist/plugins/comments/CommentsController.svelte.js +14 -4
- package/dist/plugins/find/search.js +2 -2
- package/dist/plugins/index.d.ts +1 -0
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
- package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
- package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
- package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
- package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
- package/dist/plugins/suggestions/shared.svelte.js +421 -0
- package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
- package/dist/selection/domSelection.d.ts +2 -0
- package/dist/selection/domSelection.js +6 -2
- package/dist/selection/pointer.svelte.js +75 -48
- package/dist/selection/replaceSelection.js +2 -6
- package/dist/selection/selection.svelte.d.ts +3 -3
- package/dist/selection/selection.svelte.js +39 -32
- package/dist/selection/selection.utils.d.ts +14 -0
- package/dist/selection/selection.utils.js +28 -3
- package/dist/session/commands.js +21 -11
- package/dist/session/composition.svelte.d.ts +5 -3
- package/dist/session/composition.svelte.js +21 -17
- package/dist/session/handles.d.ts +4 -17
- package/dist/session/handles.js +11 -37
- package/dist/session/navigation.js +6 -9
- package/dist/session/selection.d.ts +11 -5
- package/dist/session/selection.js +14 -48
- package/dist/session/suggestions.svelte.d.ts +38 -9
- package/dist/session/suggestions.svelte.js +37 -18
- package/dist/session/virtual.d.ts +1 -1
- package/dist/surface/attributes.d.ts +5 -0
- package/dist/surface/attributes.js +2 -2
- package/dist/surface/cells.d.ts +2 -26
- package/dist/surface/cells.js +2 -33
- package/dist/surface/observer.svelte.d.ts +6 -1
- package/dist/surface/observer.svelte.js +128 -81
- package/dist/surface/pin.svelte.d.ts +9 -9
- package/dist/surface/pin.svelte.js +15 -22
- package/dist/surface/projector.svelte.js +8 -14
- package/dist/text/text.svelte.js +14 -19
- package/dist/themes/notion.css +29 -0
- 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
|
-
> **
|
|
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 `
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
78
|
-
|
|
77
|
+
return hit && edytor.idToBlock.has(hit.blockId)
|
|
78
|
+
? { block: hit.blockId, offset: hit.offset }
|
|
79
|
+
: null;
|
|
79
80
|
};
|
|
80
|
-
const [
|
|
81
|
-
|
|
82
|
-
|
|
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:
|
|
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:
|
|
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' | '
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
|
51
|
+
if (!LEVELS.includes(access))
|
|
44
52
|
return null;
|
|
45
53
|
if (expiresAt !== null && !Number.isFinite(expiresAt))
|
|
46
54
|
return null;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
-
/**
|
|
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 !== '
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 (
|
|
205
|
-
room.note({ reason: 'access', detail: { user: userId, access, sockets:
|
|
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 {
|
|
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
|
|
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
|
|
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] });
|