edytor 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +2 -2
  2. package/dist/block/block.svelte.js +6 -21
  3. package/dist/block/inlineBlock.svelte.js +4 -6
  4. package/dist/clipboard/moveText.js +6 -8
  5. package/dist/cloudflare/DocumentRoom.d.ts +10 -3
  6. package/dist/cloudflare/index.d.ts +1 -1
  7. package/dist/cloudflare/room/access.d.ts +8 -3
  8. package/dist/cloudflare/room/access.js +44 -20
  9. package/dist/cloudflare/room/admission.js +28 -5
  10. package/dist/cloudflare/room/comments.js +28 -1
  11. package/dist/cloudflare/room/context.d.ts +106 -109
  12. package/dist/cloudflare/room/context.js +10 -3
  13. package/dist/cloudflare/room/forged.d.ts +14 -0
  14. package/dist/cloudflare/room/forged.js +30 -2
  15. package/dist/cloudflare/room/frames.d.ts +4 -0
  16. package/dist/cloudflare/room/frames.js +5 -1
  17. package/dist/cloudflare/room/purge.js +2 -1
  18. package/dist/cloudflare/room/replicas.d.ts +4 -2
  19. package/dist/cloudflare/room/replicas.js +8 -3
  20. package/dist/cloudflare/room/suggest.d.ts +7 -0
  21. package/dist/cloudflare/room/suggest.js +143 -0
  22. package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
  23. package/dist/cloudflare/routeDocumentSocket.js +5 -3
  24. package/dist/components/Content.svelte +9 -1
  25. package/dist/crdt/anchors.js +3 -2
  26. package/dist/crdt/attribution/attribution.d.ts +3 -0
  27. package/dist/crdt/attribution/attribution.js +3 -3
  28. package/dist/crdt/doc/funnel.d.ts +1 -1
  29. package/dist/crdt/doc/funnel.js +5 -2
  30. package/dist/crdt/doc/gate.d.ts +1 -1
  31. package/dist/crdt/doc/history.d.ts +6 -0
  32. package/dist/crdt/doc/history.js +79 -59
  33. package/dist/crdt/doc/ops.d.ts +81 -0
  34. package/dist/crdt/doc/ops.js +1898 -0
  35. package/dist/crdt/doc/reads.d.ts +2 -2
  36. package/dist/crdt/doc/reads.js +18 -28
  37. package/dist/crdt/doc/suggest.d.ts +167 -0
  38. package/dist/crdt/doc/suggest.js +579 -0
  39. package/dist/crdt/document.d.ts +10 -1
  40. package/dist/crdt/document.js +19 -11
  41. package/dist/crdt/edytor-doc.d.ts +294 -272
  42. package/dist/crdt/edytor-doc.js +123 -108
  43. package/dist/crdt/engine-api.d.ts +8 -2
  44. package/dist/crdt/engine.d.ts +2 -1
  45. package/dist/crdt/engine.js +3 -1
  46. package/dist/crdt/flow.d.ts +5 -47
  47. package/dist/crdt/flow.js +3 -269
  48. package/dist/crdt/index.d.ts +102 -108
  49. package/dist/crdt/index.js +3 -0
  50. package/dist/crdt/operations.d.ts +13 -4
  51. package/dist/crdt/parts.d.ts +43 -0
  52. package/dist/crdt/parts.js +44 -0
  53. package/dist/crdt/placement/display.d.ts +1 -1
  54. package/dist/crdt/placement/display.js +3 -3
  55. package/dist/crdt/placement/keys.d.ts +5 -0
  56. package/dist/crdt/placement/keys.js +5 -0
  57. package/dist/crdt/placement/resolve.d.ts +1 -1
  58. package/dist/crdt/placement/resolve.js +2 -1
  59. package/dist/crdt/protocol.d.ts +1 -1
  60. package/dist/crdt/protocol.js +1 -1
  61. package/dist/crdt/protocols/auth.d.ts +11 -1
  62. package/dist/crdt/protocols/auth.js +17 -1
  63. package/dist/crdt/protocols/awareness.js +46 -81
  64. package/dist/crdt/protocols/comments.d.ts +16 -1
  65. package/dist/crdt/protocols/comments.js +9 -1
  66. package/dist/crdt/providers/index.d.ts +12 -22
  67. package/dist/crdt/providers/index.js +3 -1
  68. package/dist/crdt/providers/indexeddb.d.ts +6 -10
  69. package/dist/crdt/providers/indexeddb.js +10 -16
  70. package/dist/crdt/providers/room.d.ts +11 -0
  71. package/dist/crdt/providers/room.js +26 -12
  72. package/dist/crdt/providers/websocket.d.ts +8 -12
  73. package/dist/crdt/providers/websocket.js +17 -19
  74. package/dist/crdt/purge.d.ts +7 -1
  75. package/dist/crdt/purge.js +32 -2
  76. package/dist/crdt/rangeDelete.d.ts +2 -47
  77. package/dist/crdt/rangeDelete.js +1 -169
  78. package/dist/crdt/restore.d.ts +2 -1
  79. package/dist/crdt/restore.js +9 -0
  80. package/dist/crdt/schema.d.ts +9 -1
  81. package/dist/crdt/schema.js +13 -3
  82. package/dist/crdt/structs.d.ts +2 -0
  83. package/dist/crdt/structs.js +2 -0
  84. package/dist/crdt/text/index/build.d.ts +125 -0
  85. package/dist/crdt/text/index/build.js +2895 -0
  86. package/dist/crdt/text/index/shared.d.ts +2 -6
  87. package/dist/crdt/text/index/shared.js +19 -19
  88. package/dist/crdt/text/model.d.ts +10 -0
  89. package/dist/crdt/text/model.js +10 -4
  90. package/dist/crdt/text/runs.d.ts +43 -42
  91. package/dist/crdt/text/runs.js +2 -165
  92. package/dist/crdt/vendor/yjs/UPSTREAM.md +83 -0
  93. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  94. package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +6 -3
  95. package/dist/crdt/vendor/yjs/dts/ynode.d.ts +13 -2
  96. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  97. package/dist/crdt/vendor/yjs/src/structs/Item.js +44 -5
  98. package/dist/crdt/vendor/yjs/src/ynode.js +29 -7
  99. package/dist/edytor.svelte.d.ts +1 -1
  100. package/dist/edytor.svelte.js +100 -75
  101. package/dist/events/beforeInputCommands.d.ts +2 -2
  102. package/dist/events/domTextOffset.d.ts +2 -0
  103. package/dist/events/domTextOffset.js +8 -0
  104. package/dist/events/nativeInteractiveControl.d.ts +11 -1
  105. package/dist/events/nativeInteractiveControl.js +41 -1
  106. package/dist/events/onBeforeInput.js +3 -3
  107. package/dist/events/onFocus.js +9 -11
  108. package/dist/export/forms.d.ts +75 -75
  109. package/dist/index.d.ts +4 -2
  110. package/dist/index.js +1 -1
  111. package/dist/labels.d.ts +8 -0
  112. package/dist/labels.js +6 -1
  113. package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
  114. package/dist/plugins/comments/CommentsController.svelte.js +14 -4
  115. package/dist/plugins/find/search.js +2 -2
  116. package/dist/plugins/index.d.ts +1 -0
  117. package/dist/plugins/index.js +1 -0
  118. package/dist/plugins/richtext/RichTextPlugin.svelte +63 -61
  119. package/dist/plugins/richtext/richTextOperations.js +26 -32
  120. package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
  121. package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
  122. package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
  123. package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
  124. package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
  125. package/dist/plugins/suggestions/shared.svelte.js +421 -0
  126. package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
  127. package/dist/selection/domSelection.d.ts +2 -0
  128. package/dist/selection/domSelection.js +6 -2
  129. package/dist/selection/pointer.svelte.js +75 -48
  130. package/dist/selection/replaceSelection.js +2 -6
  131. package/dist/selection/selection.svelte.d.ts +3 -3
  132. package/dist/selection/selection.svelte.js +39 -32
  133. package/dist/selection/selection.utils.d.ts +14 -0
  134. package/dist/selection/selection.utils.js +33 -11
  135. package/dist/session/commands.js +21 -11
  136. package/dist/session/composition.svelte.d.ts +5 -3
  137. package/dist/session/composition.svelte.js +21 -17
  138. package/dist/session/handles.d.ts +4 -17
  139. package/dist/session/handles.js +14 -44
  140. package/dist/session/navigation.js +6 -9
  141. package/dist/session/selection.d.ts +11 -5
  142. package/dist/session/selection.js +14 -48
  143. package/dist/session/suggestions.svelte.d.ts +38 -9
  144. package/dist/session/suggestions.svelte.js +37 -18
  145. package/dist/session/virtual.d.ts +1 -1
  146. package/dist/surface/attributes.d.ts +5 -0
  147. package/dist/surface/attributes.js +2 -2
  148. package/dist/surface/cells.d.ts +2 -26
  149. package/dist/surface/cells.js +2 -33
  150. package/dist/surface/observer.svelte.d.ts +6 -1
  151. package/dist/surface/observer.svelte.js +143 -111
  152. package/dist/surface/pin.svelte.d.ts +9 -9
  153. package/dist/surface/pin.svelte.js +15 -22
  154. package/dist/surface/projector.svelte.js +10 -16
  155. package/dist/text/text.svelte.js +14 -19
  156. package/dist/themes/notion.css +29 -0
  157. package/dist/utils.d.ts +5 -0
  158. package/dist/utils.js +7 -0
  159. package/package.json +3 -1
  160. package/dist/crdt/doc/capability.d.ts +0 -56
  161. package/dist/crdt/doc/capability.js +0 -211
  162. package/dist/crdt/doc/content.d.ts +0 -16
  163. package/dist/crdt/doc/content.js +0 -77
  164. package/dist/crdt/doc/delete.d.ts +0 -15
  165. package/dist/crdt/doc/delete.js +0 -84
  166. package/dist/crdt/doc/layout.d.ts +0 -9
  167. package/dist/crdt/doc/layout.js +0 -178
  168. package/dist/crdt/doc/meta.d.ts +0 -19
  169. package/dist/crdt/doc/meta.js +0 -199
  170. package/dist/crdt/doc/moves.d.ts +0 -21
  171. package/dist/crdt/doc/moves.js +0 -274
  172. package/dist/crdt/doc/split-merge.d.ts +0 -10
  173. package/dist/crdt/doc/split-merge.js +0 -179
  174. package/dist/crdt/doc/steps.d.ts +0 -65
  175. package/dist/crdt/doc/steps.js +0 -311
  176. package/dist/crdt/text/index/cache.d.ts +0 -16
  177. package/dist/crdt/text/index/cache.js +0 -175
  178. package/dist/crdt/text/index/claims.d.ts +0 -18
  179. package/dist/crdt/text/index/claims.js +0 -225
  180. package/dist/crdt/text/index/fold.d.ts +0 -21
  181. package/dist/crdt/text/index/fold.js +0 -656
  182. package/dist/crdt/text/index/layout.d.ts +0 -24
  183. package/dist/crdt/text/index/layout.js +0 -271
  184. package/dist/crdt/text/index/placement.d.ts +0 -47
  185. package/dist/crdt/text/index/placement.js +0 -741
  186. package/dist/crdt/text/index/records.d.ts +0 -13
  187. package/dist/crdt/text/index/records.js +0 -139
  188. package/dist/crdt/text/index/report.d.ts +0 -18
  189. package/dist/crdt/text/index/report.js +0 -337
  190. package/dist/crdt/text/index/state.d.ts +0 -197
  191. package/dist/crdt/text/index/state.js +0 -116
  192. package/dist/crdt/text/index/streams.d.ts +0 -25
  193. package/dist/crdt/text/index/streams.js +0 -248
@@ -0,0 +1,58 @@
1
+ import type { Plugin } from '../../plugins.js';
2
+ import type { Edytor } from '../../edytor.svelte.js';
3
+ import { type SuggestedChange } from '../../crdt/doc/suggest.js';
4
+ import type { OpResult } from '../../crdt/edytor-doc.js';
5
+ import { type AnySuggestion, type SuggestionAt, type SuggestionContent, type SuggestionOptions } from '../../session/suggestions.svelte.js';
6
+ /** What `add` takes: a suggestion's options, and who writes it for its author. */
7
+ export type SharedSuggestionOptions = SuggestionOptions & {
8
+ /** Who writes it for its author (default `'ai'`): a reviewer sees "Ada (AI)". */
9
+ via?: string;
10
+ };
11
+ /** The shared suggestions plugin's controller of `edytor`; `undefined` when the plugin is not listed. */
12
+ export declare const sharedSuggestionsController: (edytor: Edytor) => SharedSuggestionsController | undefined;
13
+ /**
14
+ * The shared suggestions of a view (`sharedSuggestionsController(edytor)`):
15
+ * suggesting mode, the pending changes, the decisions.
16
+ */
17
+ export declare class SharedSuggestionsController {
18
+ #private;
19
+ readonly edytor: Edytor;
20
+ /**
21
+ * Suggesting mode: this view's writes are a shared suggestion of its
22
+ * author's (`sugg.*`), a move refused. Set by the app (a toggle); always
23
+ * on for a user the room lets only suggest (`document.access`).
24
+ */
25
+ get suggesting(): boolean;
26
+ set suggesting(on: boolean);
27
+ /** Every pending change of a shared suggestion, in document order (reactive). */
28
+ changes: () => readonly SuggestedChange[];
29
+ /**
30
+ * Propose `content` at `at`, shared: written live into the document as
31
+ * it streams (`update`, `append`), one suggestion of its own every
32
+ * collaborator sees and reviews. At an `end` (a completion of this
33
+ * view's line), a view-local one. Throws as `edytor.suggestions.add`.
34
+ */
35
+ add: (at: SuggestionAt, content?: SuggestionContent, options?: SharedSuggestionOptions) => AnySuggestion;
36
+ /**
37
+ * Accept (`accept`) or reject shared `changes`: one command
38
+ * (`acceptSuggestion`, `rejectSuggestion`, its payload `{ changes }`),
39
+ * one undo step, refused unless this view decides. Its writes are id
40
+ * lists (the text that stays settled in its record, the text that goes
41
+ * held by this view's user) and the view's operations (`deleteBlock`,
42
+ * `mergeBlocks`).
43
+ */
44
+ decide: (changes: readonly SuggestedChange[], accept: boolean) => OpResult;
45
+ /** Accept (or reject) every pending shared suggestion: one command, one undo step. */
46
+ acceptAll: () => OpResult;
47
+ rejectAll: () => OpResult;
48
+ }
49
+ /**
50
+ * Shared suggestions (Google Docs' suggesting mode): suggestions that live
51
+ * in the document, which every collaborator sees with its author and a
52
+ * writer accepts or rejects. With it, `edytor.suggestions.list` holds the
53
+ * shared ones, and its controller (`sharedSuggestionsController(edytor)`)
54
+ * suggests while `suggesting` is set and writes an answer live into the
55
+ * document (`add`). Without it, a document's suggestions read as plain
56
+ * content.
57
+ */
58
+ export declare const createSharedSuggestionsPlugin: () => Plugin;
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Shared suggestions (site `editor/suggestions`, the `sugg.*` rows of
3
+ * `docs/editor-delete-contract.md`): suggestions that live in the document,
4
+ * so every replica, the room and a reload see them with their author.
5
+ *
6
+ * - **Suggesting mode** (`suggesting`): the view's writes are a suggestion
7
+ * of its user's (the dispatcher's plan writer, `written`), continued
8
+ * while they touch the block it last wrote in or a neighbour, a new one
9
+ * elsewhere. Only text and blocks are suggested: a move, a merge, a
10
+ * retype, a data change or a format is refused and announced.
11
+ * - **Decisions** are one command each (`acceptSuggestion`,
12
+ * `rejectSuggestion`, payload `{ changes }`) of a view that may decide;
13
+ * its author may reject it too (`discard`, by a writer of theirs).
14
+ * - **An app's answer** (`add`) is written live as it streams, only what
15
+ * changed per chunk (`write`, `sugg.ai.live`), one suggestion of its own
16
+ * (`via: 'ai'`), outside the history.
17
+ * - **The view** draws each pending run in the overlay in its author's
18
+ * colour (`SuggestionRuns`) and marks the blocks a suggestion made or
19
+ * removes (`data-edytor-suggested`).
20
+ *
21
+ * The document part (`crdt/doc/suggest.ts`) holds the records, what is
22
+ * pending and the decisions' writes; built over the view's document by this
23
+ * plugin, its `toJSON()` leaves pending suggestions out.
24
+ */
25
+ import { mount, unmount } from 'svelte';
26
+ import { NOOP, REFUSED } from '../../crdt/doc/plan.js';
27
+ import { suggestionsOf } from '../../crdt/doc/suggest.js';
28
+ import { canonKey } from '../../crdt/text/ranges.js';
29
+ import { labelsOf, suggestionsEnglish } from '../../labels.js';
30
+ import { jsonBlockToSpec } from '../../utils/json.js';
31
+ import { project, textSelection } from '../../session/selection.js';
32
+ import { Suggestion } from '../../session/suggestions.svelte.js';
33
+ import SuggestionRuns from './SuggestionRuns.svelte';
34
+ /** A block change's mark (`marked`): its kind's, but for these. */
35
+ const MARKS = { block: 'inserted', remove: 'removed' };
36
+ /** Two blocks alike but for their ids. */
37
+ const same = (a, b) => canonKey({ ...a, id: 0 }) === canonKey({ ...b, id: 0 });
38
+ /**
39
+ * The runs `next` adds after `prev`'s content when it extends it (one kind
40
+ * and data, `prev`'s content its start), else `null`.
41
+ */
42
+ const grown = (prev, next) => {
43
+ const [a, b] = [prev.content ?? [], next.content ?? []];
44
+ if (!same({ ...prev, content: [] }, { ...next, content: [] }) || b.length < a.length)
45
+ return null;
46
+ const last = a.at(-1);
47
+ const tail = b[a.length - 1];
48
+ const head = a.length - (last && 'text' in last && tail && 'text' in tail ? 1 : 0);
49
+ if (a.slice(0, head).some((run, i) => canonKey(run) !== canonKey(b[i])))
50
+ return null;
51
+ if (head < a.length &&
52
+ (canonKey(last.marks) !== canonKey(tail.marks) || !tail.text.startsWith(last.text)))
53
+ return null;
54
+ const rest = b.slice(a.length);
55
+ return head < a.length && tail.text.length > last.text.length
56
+ ? [{ ...tail, text: tail.text.slice(last.text.length) }, ...rest]
57
+ : rest;
58
+ };
59
+ /** The blocks position `at` names (a text range: its ends' blocks). */
60
+ const blocksOf = (edytor, at) => {
61
+ if (!('replace' in at))
62
+ return [Object.values(at)[0]];
63
+ const { replace } = at;
64
+ if (Array.isArray(replace))
65
+ return [...replace];
66
+ const { anchor, focus } = replace;
67
+ const { start, end } = project(textSelection(anchor, focus), edytor.facade);
68
+ return start && end ? [start.block, end.block] : [];
69
+ };
70
+ const controllers = new WeakMap();
71
+ /** The shared suggestions plugin's controller of `edytor`; `undefined` when the plugin is not listed. */
72
+ export const sharedSuggestionsController = (edytor) => controllers.get(edytor);
73
+ /** A shared suggestion of the list: this view's (`add`), or another view's. */
74
+ class Shared extends Suggestion {
75
+ #c;
76
+ #via;
77
+ /** @internal What this view wrote of it so far (`write`): its blocks, with their ids. */
78
+ written = [];
79
+ constructor(layer, at, kind, options, status) {
80
+ super(layer, at, kind, options, status);
81
+ this.shared = true;
82
+ this.#c = controllers.get(layer.edytor);
83
+ this.#via = options.via ?? 'ai';
84
+ // What this view adds is written live, while it is listed; another view's it never writes.
85
+ const { update, append } = this;
86
+ this.update = (content) => {
87
+ if (!at || !this.live)
88
+ return;
89
+ update(content);
90
+ this.#c.write(this);
91
+ };
92
+ this.append = (text) => {
93
+ if (!at || !this.live)
94
+ return;
95
+ append(text);
96
+ this.#c.write(this);
97
+ };
98
+ }
99
+ /** Its record in the document. */
100
+ get #record() {
101
+ void this.#c.edytor.suggestions.revision;
102
+ return this.#c.part.records().find((r) => r.id === this.id);
103
+ }
104
+ get by() {
105
+ return this.#record?.by;
106
+ }
107
+ get via() {
108
+ return this.#record?.via ?? this.#via;
109
+ }
110
+ get changes() {
111
+ return this.#c.changes().filter((c) => c.suggestion === this.id);
112
+ }
113
+ get canDecide() {
114
+ return this.#c.decides();
115
+ }
116
+ get mine() {
117
+ return this.by === this.#c.edytor.document.actor.id;
118
+ }
119
+ accept = () => this.#c.decide(this.changes, true);
120
+ reject = () => this.#c.decide(this.changes, false);
121
+ /** Drop it and reject it (its author's withdrawal). */
122
+ discard = () => {
123
+ this.#c.drop(this);
124
+ this.reject();
125
+ };
126
+ }
127
+ /** A shared suggestion, as the list types it (to the compiler, `Shared` is a view-local one's class). */
128
+ const make = (...args) => new Shared(...args);
129
+ /**
130
+ * The shared suggestions of a view (`sharedSuggestionsController(edytor)`):
131
+ * suggesting mode, the pending changes, the decisions.
132
+ */
133
+ export class SharedSuggestionsController {
134
+ edytor;
135
+ #suggesting = $state(false);
136
+ /** This view's writer, its open suggestion and the block it last wrote in (the grouping). */
137
+ #writer = 0;
138
+ #open = null;
139
+ /** The writer its author's rejections are written by when this view may not decide (never a record's writer, whose id lists propose). */
140
+ #decider = 0;
141
+ #last = null;
142
+ /** A decision runs: its writes are the decider's, never suggested. */
143
+ #deciding = false;
144
+ /** The ones this view added (`add`), and the ones other views made, as this view lists them. */
145
+ #mine = $state.raw([]);
146
+ #others = new Map();
147
+ /** @internal */
148
+ constructor(edytor) {
149
+ this.edytor = edytor;
150
+ }
151
+ /** @internal The document's shared suggestions (`crdt/doc/suggest.ts`). */
152
+ get part() {
153
+ return suggestionsOf(this.edytor.facade);
154
+ }
155
+ /**
156
+ * Suggesting mode: this view's writes are a shared suggestion of its
157
+ * author's (`sugg.*`), a move refused. Set by the app (a toggle); always
158
+ * on for a user the room lets only suggest (`document.access`).
159
+ */
160
+ get suggesting() {
161
+ void this.edytor.suggestions.revision;
162
+ return this.#suggesting || this.edytor.document.access === 'suggest';
163
+ }
164
+ set suggesting(on) {
165
+ // A switch ends the undo step and the open suggestion.
166
+ this.edytor.undoManager?.stopCapturing();
167
+ this.#open = null;
168
+ this.#suggesting = on;
169
+ }
170
+ /** Every pending change of a shared suggestion, in document order (reactive). */
171
+ changes = () => {
172
+ void [this.edytor.suggestions.revision, this.edytor.valueRevision];
173
+ return this.edytor.root ? this.part.changes() : [];
174
+ };
175
+ /**
176
+ * Propose `content` at `at`, shared: written live into the document as
177
+ * it streams (`update`, `append`), one suggestion of its own every
178
+ * collaborator sees and reviews. At an `end` (a completion of this
179
+ * view's line), a view-local one. Throws as `edytor.suggestions.add`.
180
+ */
181
+ add = (at, content, options = {}) => {
182
+ const { suggestions, facade, document } = this.edytor;
183
+ const resolved = suggestions.resolve(at);
184
+ if ('end' in resolved)
185
+ return suggestions.add(at, content, options);
186
+ const anchor = blocksOf(this.edytor, resolved)[0];
187
+ const parent = 'inside' in resolved ? anchor : (facade.parentOf(anchor) ?? null);
188
+ const kind = document.defaultChild(parent === null ? null : (facade.blockTypeOf(parent) ?? null));
189
+ const status = options.status ?? (content === undefined ? 'streaming' : 'ready');
190
+ const suggestion = make(suggestions, resolved, kind, options, status);
191
+ this.#mine = [...this.#mine.filter((s) => s.id !== suggestion.id), suggestion];
192
+ if (content !== undefined)
193
+ suggestion.update(content);
194
+ suggestions.revision++;
195
+ return suggestion;
196
+ };
197
+ /** @internal Drop `suggestion` from this view's own. */
198
+ drop = (suggestion) => {
199
+ this.#mine = this.#mine.filter((s) => s !== suggestion);
200
+ this.edytor.suggestions.revision++;
201
+ };
202
+ /** @internal The list with the shared ones (`Suggestions.shared`): this view's while pending or streaming, then the others'. */
203
+ list = (mine) => {
204
+ const shared = new Set(this.changes().map((c) => c.suggestion));
205
+ const own = this.#mine.filter((s) => s.status === 'streaming' || shared.has(s.id));
206
+ for (const s of own)
207
+ shared.delete(s.id);
208
+ const others = [...shared].map((id) => this.#others.get(id) ??
209
+ this.#others.set(id, make(this.edytor.suggestions, null, '', { id }, 'ready')).get(id));
210
+ return [...mine, ...own, ...others];
211
+ };
212
+ /** @internal This view may accept or reject: it may write and is no suggest-only user's. */
213
+ decides = () => this.edytor.dispatcher.permits() && this.edytor.document.access === 'write';
214
+ /**
215
+ * @internal `plan` written as this view's suggestion while it suggests
216
+ * (the dispatcher's plan writer), with where its caret is now; `null`
217
+ * while it does not. It continues the open suggestion while it writes in
218
+ * the block that one last wrote in or a neighbour; a plan the document
219
+ * refuses to suggest (a move, a merge, a retype, a data change, a
220
+ * format: `sugg.refused`) is refused and announced.
221
+ */
222
+ written = (plan) => {
223
+ if (!this.suggesting || this.#deciding)
224
+ return null;
225
+ const { facade } = this.edytor;
226
+ const part = this.part;
227
+ const block = plan.effect.textRanges[0]?.block ?? plan.ids[0] ?? null;
228
+ const last = this.#last;
229
+ const near = block === last || (!!last && [facade.next(last), facade.previous(last)].includes(block));
230
+ const out = part.apply(near ? this.#open : null, plan, (this.#writer ||= part.writer()));
231
+ if (out.result.status === 'refused')
232
+ this.#refused();
233
+ this.#open = out.id ?? this.#open;
234
+ if (out.result.status === 'applied')
235
+ this.#last = block;
236
+ // Its caret stands where the plan's does with the proposed text shown.
237
+ if (out.at)
238
+ plan.at = out.at;
239
+ return out.result;
240
+ };
241
+ /** @internal Nothing moves through a suggestion (`sugg.refused`): a move is refused, and said. */
242
+ moves = ({ operation, prevent }) => {
243
+ if (operation !== 'moveBlocks' || !this.suggesting || this.#deciding)
244
+ return;
245
+ this.#refused();
246
+ prevent();
247
+ };
248
+ /** Said to assistive technology: what the view tried is not suggested. */
249
+ #refused() {
250
+ this.edytor.announcer.say(labelsOf(this.edytor, 'suggestions', suggestionsEnglish).notSuggested);
251
+ }
252
+ /**
253
+ * Accept (`accept`) or reject shared `changes`: one command
254
+ * (`acceptSuggestion`, `rejectSuggestion`, its payload `{ changes }`),
255
+ * one undo step, refused unless this view decides. Its writes are id
256
+ * lists (the text that stays settled in its record, the text that goes
257
+ * held by this view's user) and the view's operations (`deleteBlock`,
258
+ * `mergeBlocks`).
259
+ */
260
+ decide = (changes, accept) => {
261
+ const { edytor } = this;
262
+ const part = this.part;
263
+ const operation = accept ? 'acceptSuggestion' : 'rejectSuggestion';
264
+ if (!changes.length)
265
+ return NOOP;
266
+ // Its author may reject it, written by a writer of theirs (a suggest-only user's view too).
267
+ const author = !this.decides() &&
268
+ !accept &&
269
+ changes.every((c) => edytor.suggestions.get(c.suggestion)?.mine) &&
270
+ (this.#decider ||= part.writer());
271
+ if (!this.decides() && !author)
272
+ return (void edytor.dispatcher.record({ operation, status: 'refused' }), REFUSED);
273
+ const block = edytor.idToBlock.get(changes[0].block) ?? edytor.root;
274
+ // A decision is a step of its own: typing after it is another.
275
+ edytor.undoManager?.stopCapturing();
276
+ edytor.dispatcher.dispatch(operation, { changes }, { block }, () => {
277
+ this.#deciding = true;
278
+ try {
279
+ part.run(part.decide(changes, accept), (op, args) => edytor[op](...args), author || undefined);
280
+ }
281
+ finally {
282
+ this.#deciding = false;
283
+ }
284
+ });
285
+ edytor.undoManager?.stopCapturing();
286
+ return edytor.dispatcher.last?.status === 'applied' ? { status: 'applied', ids: [] } : NOOP;
287
+ };
288
+ /** Accept (or reject) every pending shared suggestion: one command, one undo step. */
289
+ acceptAll = () => this.decide(this.changes(), true);
290
+ rejectAll = () => this.decide(this.changes(), false);
291
+ /**
292
+ * @internal How a shared suggestion marks block `id` (`data-edytor-suggested`):
293
+ * `inserted` or `split` (it made the block), `removed` (it proposes
294
+ * deleting it); `undefined`: none.
295
+ */
296
+ marked = (id) => {
297
+ void [this.edytor.suggestions.revision, this.edytor.valueRevision];
298
+ const kind = this.part.at(id).find((c) => c.kind !== 'insert' && c.kind !== 'delete')?.kind;
299
+ return kind && (MARKS[kind] ?? kind);
300
+ };
301
+ /** @internal The colour of suggestion `id`'s author (its profile's), for its runs. */
302
+ colorOf = (id) => this.edytor.document.attribution.actors.get(this.part.records().find((r) => r.id === id)?.by)?.color;
303
+ /**
304
+ * @internal Write shared `suggestion`'s content as it is now (`update`,
305
+ * `append`), live (`sugg.ai.live`): the first time at its position (a
306
+ * `replace` proposed for deletion), then only what changed — its equal
307
+ * blocks kept, its last block's text extended when the new one extends
308
+ * it, the rest replaced. Its own writer's blocks, outside the undo history.
309
+ */
310
+ write = (suggestion) => {
311
+ const { facade, dispatcher } = this.edytor;
312
+ const { id, at, content: next } = suggestion;
313
+ if (!at || !dispatcher.permits())
314
+ return;
315
+ const { prepare } = facade;
316
+ const part = this.part;
317
+ const prev = suggestion.written;
318
+ const put = (plan) => void part.apply(id, plan);
319
+ dispatcher.outside(() => {
320
+ if (!part.records().some((r) => r.id === id)) {
321
+ part.open({ id, via: suggestion.via, label: suggestion.label });
322
+ if ('replace' in at) {
323
+ const { replace } = at;
324
+ if (Array.isArray(replace))
325
+ put(prepare.deleteBlocks(replace));
326
+ else {
327
+ const { anchor, focus } = replace;
328
+ const { start, end } = project(textSelection(anchor, focus), facade);
329
+ if (start && end)
330
+ put(prepare.replaceRange(start, end));
331
+ }
332
+ }
333
+ }
334
+ let p = 0;
335
+ while (p < prev.length && p < next.length && same(prev[p], next[p]))
336
+ p++;
337
+ const runs = p === prev.length - 1 && p < next.length ? grown(prev[p], next[p]) : null;
338
+ if (runs) {
339
+ const block = prev[p].id;
340
+ for (const run of runs) {
341
+ const offset = facade.displayLength(block);
342
+ put('text' in run
343
+ ? prepare.insertText(block, offset, run.text, run.marks ?? {})
344
+ : prepare.insertInline(block, offset, run));
345
+ }
346
+ prev[p] = { ...next[p], id: block };
347
+ p++;
348
+ }
349
+ const doomed = prev.splice(p).map((b) => b.id);
350
+ if (doomed.length)
351
+ put(prepare.deleteBlocks(doomed));
352
+ const specs = next.slice(p).map((b) => jsonBlockToSpec(b, true));
353
+ const dest = p ? facade.positionOf(prev[p - 1].id) : this.#slot(at);
354
+ if (specs.length && dest) {
355
+ put(prepare.insertBlocks({ parent: dest.parent, index: dest.index + (p ? 1 : 0) }, specs));
356
+ prev.push(...specs.map((spec, i) => ({ ...next[p + i], id: spec.id })));
357
+ }
358
+ });
359
+ };
360
+ /** Where a shared suggestion's blocks go: its position's slot (a `replace`: after what it replaces). */
361
+ #slot = (at) => {
362
+ const { facade } = this.edytor;
363
+ if ('inside' in at)
364
+ return { parent: at.inside, index: facade.childrenIds(at.inside).length };
365
+ const before = 'before' in at;
366
+ const ids = blocksOf(this.edytor, at);
367
+ const pos = facade.positionOf((before ? ids[0] : ids.toSorted(facade.compare).at(-1)));
368
+ return pos && { parent: pos.parent, index: pos.index + (before ? 0 : 1) };
369
+ };
370
+ /** @internal Mounted: the list follows the records and the access, the runs are drawn in the overlay. */
371
+ attach = () => {
372
+ const { edytor } = this;
373
+ const bump = () => edytor.suggestions.revision++;
374
+ // The room's access arrives as the document joins: read again once it is ready.
375
+ const offs = [this.part.onChange(bump), edytor.document.onReady(bump)];
376
+ const runs = mount(SuggestionRuns, {
377
+ target: edytor.overlay.layer,
378
+ props: { edytor, shared: this }
379
+ });
380
+ return () => {
381
+ for (const off of offs)
382
+ off();
383
+ void unmount(runs);
384
+ };
385
+ };
386
+ /** @internal Its block's mark (`data-edytor-suggested`), kept on `node` while it is attached. */
387
+ mark = (node, id) => $effect.root(() => {
388
+ $effect(() => {
389
+ const kind = this.marked(id);
390
+ if (kind)
391
+ node.setAttribute('data-edytor-suggested', kind);
392
+ else
393
+ node.removeAttribute('data-edytor-suggested');
394
+ });
395
+ });
396
+ }
397
+ /**
398
+ * Shared suggestions (Google Docs' suggesting mode): suggestions that live
399
+ * in the document, which every collaborator sees with its author and a
400
+ * writer accepts or rejects. With it, `edytor.suggestions.list` holds the
401
+ * shared ones, and its controller (`sharedSuggestionsController(edytor)`)
402
+ * suggests while `suggesting` is set and writes an answer live into the
403
+ * document (`add`). Without it, a document's suggestions read as plain
404
+ * content.
405
+ */
406
+ export const createSharedSuggestionsPlugin = () => (edytor) => {
407
+ const shared = new SharedSuggestionsController(edytor);
408
+ controllers.set(edytor, shared);
409
+ // The document leaves its pending suggestions out of `toJSON()` from now on.
410
+ void shared.part;
411
+ edytor.suggestions.shared = shared.list;
412
+ edytor.dispatcher.writer = shared.written;
413
+ // While it suggests, the view offers no move (each is refused, `moves`).
414
+ const canMove = edytor.canMove;
415
+ edytor.canMove = (request) => !shared.suggesting && canMove(request);
416
+ return {
417
+ onBeforeOperation: shared.moves,
418
+ onEdytorAttached: shared.attach,
419
+ onBlockAttached: ({ node, block }) => shared.mark(node, block.id)
420
+ };
421
+ };
@@ -1,15 +1,26 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Plugin } from '../../plugins.js';
3
- import type { Suggestion } from '../../session/suggestions.svelte.js';
4
- /** What a `bar` snippet receives: one block suggestion and its actions. */
3
+ import type { AnySuggestion } from '../../session/suggestions.svelte.js';
4
+ /** What a `bar` snippet receives: one suggestion (a block suggestion, or a shared one) and its actions. */
5
5
  export type SuggestionBarPayload = {
6
- suggestion: Suggestion;
7
- /** Place it in the document and give the editor its focus back. */
6
+ suggestion: AnySuggestion;
7
+ /** Accept it (a view-local one: place it in the document) and give the editor its focus back. */
8
8
  accept: () => void;
9
+ /** Drop it (a shared one: its author withdraws it, a decider rejects it). */
9
10
  discard: () => void;
11
+ /** Reject a shared one (a view-local one: drop it). */
12
+ reject: () => void;
10
13
  /** Ask for another answer (`onRetry`); `undefined` without one. */
11
14
  retry: (() => void) | undefined;
12
15
  readonly: boolean;
16
+ /** This view may accept or reject it (a shared one; else: not readonly). */
17
+ canDecide: boolean;
18
+ /** A shared one's author, as their profile names them ("Ada", with `suggestion.via`: "Ada (AI)"). */
19
+ author: {
20
+ id: string;
21
+ name?: string;
22
+ color?: string;
23
+ } | undefined;
13
24
  /** The view's keys are a Mac's: Mod is ⌘ (name the Accept key with it). */
14
25
  mac: boolean;
15
26
  };
@@ -3,6 +3,8 @@ export type DomSelectionSnapshot = Pick<Selection, 'anchorNode' | 'anchorOffset'
3
3
  };
4
4
  export declare const getDomSelection: (node?: Node | null) => Selection | null;
5
5
  export declare const getActiveElement: (node?: Node | null) => Element | null;
6
+ /** The focused element when it is `node` or inside it (the editor's own focus), else `null`. */
7
+ export declare const focusIn: (node?: Node | null) => Element | null;
6
8
  export declare const clearDomSelection: (node?: Node | null) => void;
7
9
  /**
8
10
  * The caret point under (`x`, `y`): `caretPositionFromPoint` (Firefox,
@@ -7,6 +7,11 @@ const shadowOf = (node) => {
7
7
  const documentSelection = (node) => ownerOf(node)?.getSelection() ?? null;
8
8
  export const getDomSelection = (node) => shadowOf(node)?.getSelection?.() ?? documentSelection(node);
9
9
  export const getActiveElement = (node) => shadowOf(node)?.activeElement ?? ownerOf(node)?.activeElement ?? null;
10
+ /** The focused element when it is `node` or inside it (the editor's own focus), else `null`. */
11
+ export const focusIn = (node) => {
12
+ const active = getActiveElement(node);
13
+ return active && node?.contains(active) ? active : null;
14
+ };
10
15
  export const clearDomSelection = (node) => {
11
16
  getDomSelection(node)?.removeAllRanges();
12
17
  };
@@ -96,8 +101,7 @@ export const scrollCaretIntoView = (root) => {
96
101
  const focusNode = selection?.focusNode;
97
102
  if (!selection || !focusNode || selection.rangeCount === 0 || !root.contains(focusNode))
98
103
  return;
99
- const active = getActiveElement(root);
100
- if (!active || !root.contains(active))
104
+ if (!focusIn(root))
101
105
  return;
102
106
  const element = focusNode instanceof Element ? focusNode : focusNode.parentElement;
103
107
  if (!element)