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 +3 -2
- package/dist/cloudflare/DocumentRoom.d.ts +24 -1
- package/dist/cloudflare/DocumentRoom.js +9 -1
- package/dist/cloudflare/index.d.ts +2 -2
- package/dist/cloudflare/index.js +2 -2
- package/dist/cloudflare/room/admission.d.ts +11 -6
- package/dist/cloudflare/room/admission.js +7 -8
- package/dist/cloudflare/room/comments.d.ts +18 -2
- package/dist/cloudflare/room/comments.js +65 -7
- package/dist/cloudflare/room/context.d.ts +13 -13
- package/dist/cloudflare/routeDocumentSocket.d.ts +2 -1
- package/dist/cloudflare/routeDocumentSocket.js +33 -3
- package/dist/collaboration/awarenessSelection.d.ts +1 -2
- package/dist/crdt/protocols/comments.d.ts +22 -0
- package/dist/crdt/protocols/comments.js +41 -1
- package/dist/edytor.svelte.d.ts +6 -0
- package/dist/edytor.svelte.js +16 -2
- package/dist/events/onBeforeInput.js +1 -1
- package/dist/events/onKeyDown.js +1 -1
- package/dist/index.d.ts +23 -0
- package/dist/plugins/blockHandles/BlockHandleController.svelte.js +1 -1
- package/dist/plugins/columns/ColumnsPlugin.svelte +1 -1
- package/dist/plugins/table/TablePlugin.svelte +18 -5
- package/dist/selection/replaceSelection.js +1 -1
- package/dist/selection/selection.svelte.d.ts +11 -7
- package/dist/selection/selection.svelte.js +22 -8
- package/dist/session/commands.d.ts +2 -2
- package/dist/session/commands.js +20 -8
- package/dist/session/keymap.d.ts +5 -3
- package/dist/session/moves.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
: {
|
|
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';
|
package/dist/cloudflare/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
6
|
-
export type
|
|
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
|
|
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:
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
43
|
+
allowances.set(key, { tokens, at: now });
|
|
45
44
|
return false;
|
|
46
45
|
}
|
|
47
|
-
allowances.set(
|
|
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
|
|
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.
|
|
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.
|
|
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 (!
|
|
244
|
-
return
|
|
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("../../
|
|
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("../../
|
|
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("../../
|
|
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("../../
|
|
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("../../
|
|
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("../../
|
|
278
|
-
resolveAnchor: (anchor: import("../../
|
|
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("../../
|
|
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("../../
|
|
408
|
-
lastUpdated: (options: import("../../
|
|
409
|
-
documentSnapshot: (options: import("../../
|
|
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("../../
|
|
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("../../
|
|
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,
|
|
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,
|
|
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
|
|
178
|
-
if (
|
|
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
|
|
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':
|
package/dist/edytor.svelte.d.ts
CHANGED
|
@@ -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
|
package/dist/edytor.svelte.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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) {
|
package/dist/events/onKeyDown.js
CHANGED
|
@@ -198,7 +198,7 @@ export function onKeyDown(e) {
|
|
|
198
198
|
this.markUserGesture();
|
|
199
199
|
if (refused) {
|
|
200
200
|
e.preventDefault();
|
|
201
|
-
this.dispatcher.
|
|
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';
|
|
@@ -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.
|
|
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
|
-
/**
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
152
|
+
* one inline atom, or a set of block ids. Read-only: only `select()`
|
|
153
|
+
* replaces it.
|
|
143
154
|
*/
|
|
144
|
-
value
|
|
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
|
|
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
|
|
279
|
-
this.#sync(this
|
|
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
|
*/
|
package/dist/session/commands.js
CHANGED
|
@@ -130,8 +130,20 @@ const specJSON = (spec) => ({
|
|
|
130
130
|
});
|
|
131
131
|
export class Dispatcher {
|
|
132
132
|
edytor;
|
|
133
|
-
|
|
134
|
-
last
|
|
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
|
|
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
|
|
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
|
-
|
|
415
|
+
const own = {
|
|
404
416
|
operation,
|
|
405
417
|
status: this.edytor.facade.version === version ? 'noop' : 'applied'
|
|
406
418
|
};
|
|
407
|
-
|
|
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
|
|
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
|
|
632
|
+
return (this.#last = { operation, status: 'refused' });
|
|
621
633
|
}
|
|
622
634
|
settle(error, onPrevent) {
|
|
623
635
|
const stop = prevented(error);
|
package/dist/session/keymap.d.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
type
|
|
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>];
|
package/dist/session/moves.js
CHANGED
|
@@ -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.
|
|
118
|
+
edytor.dispatcher.record({ operation, status: request.blocks.length ? 'refused' : 'noop' });
|
|
119
119
|
return [];
|
|
120
120
|
}
|
|
121
121
|
const [first, ...rest] = move.blocks;
|