edytor 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/block/block.svelte.js +6 -21
- package/dist/block/inlineBlock.svelte.js +4 -6
- package/dist/clipboard/moveText.js +6 -8
- package/dist/cloudflare/DocumentRoom.d.ts +10 -3
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/room/access.d.ts +8 -3
- package/dist/cloudflare/room/access.js +44 -20
- package/dist/cloudflare/room/admission.js +28 -5
- package/dist/cloudflare/room/comments.js +28 -1
- package/dist/cloudflare/room/context.d.ts +115 -106
- package/dist/cloudflare/room/context.js +10 -3
- package/dist/cloudflare/room/forged.d.ts +14 -0
- package/dist/cloudflare/room/forged.js +30 -2
- package/dist/cloudflare/room/frames.d.ts +4 -0
- package/dist/cloudflare/room/frames.js +5 -1
- package/dist/cloudflare/room/purge.js +2 -1
- package/dist/cloudflare/room/replicas.d.ts +4 -2
- package/dist/cloudflare/room/replicas.js +8 -3
- package/dist/cloudflare/room/suggest.d.ts +7 -0
- package/dist/cloudflare/room/suggest.js +143 -0
- package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
- package/dist/cloudflare/routeDocumentSocket.js +5 -3
- package/dist/components/Content.svelte +9 -1
- package/dist/crdt/anchors.js +3 -2
- package/dist/crdt/attribution/attribution.d.ts +3 -0
- package/dist/crdt/attribution/attribution.js +3 -3
- package/dist/crdt/doc/capability.js +9 -13
- package/dist/crdt/doc/funnel.d.ts +1 -1
- package/dist/crdt/doc/funnel.js +5 -2
- package/dist/crdt/doc/gate.d.ts +1 -1
- package/dist/crdt/doc/history.d.ts +6 -0
- package/dist/crdt/doc/history.js +77 -56
- package/dist/crdt/doc/layout.js +5 -5
- package/dist/crdt/doc/meta.js +6 -7
- package/dist/crdt/doc/reads.d.ts +2 -2
- package/dist/crdt/doc/reads.js +18 -28
- package/dist/crdt/doc/steps.js +4 -4
- package/dist/crdt/doc/suggest.d.ts +167 -0
- package/dist/crdt/doc/suggest.js +579 -0
- package/dist/crdt/document.d.ts +10 -1
- package/dist/crdt/document.js +19 -11
- package/dist/crdt/edytor-doc.d.ts +355 -333
- package/dist/crdt/edytor-doc.js +125 -82
- package/dist/crdt/engine-api.d.ts +8 -2
- package/dist/crdt/engine.d.ts +2 -1
- package/dist/crdt/engine.js +3 -1
- package/dist/crdt/flow.d.ts +1 -1
- package/dist/crdt/flow.js +4 -10
- package/dist/crdt/index.d.ts +111 -105
- package/dist/crdt/index.js +3 -0
- package/dist/crdt/operations.d.ts +12 -3
- package/dist/crdt/parts.d.ts +43 -0
- package/dist/crdt/parts.js +44 -0
- package/dist/crdt/placement/display.d.ts +1 -1
- package/dist/crdt/placement/display.js +3 -3
- package/dist/crdt/placement/keys.d.ts +5 -0
- package/dist/crdt/placement/keys.js +5 -0
- package/dist/crdt/placement/resolve.js +2 -1
- package/dist/crdt/protocol.d.ts +1 -1
- package/dist/crdt/protocol.js +1 -1
- package/dist/crdt/protocols/auth.d.ts +11 -1
- package/dist/crdt/protocols/auth.js +17 -1
- package/dist/crdt/protocols/comments.d.ts +16 -1
- package/dist/crdt/protocols/comments.js +9 -1
- package/dist/crdt/providers/index.d.ts +6 -4
- package/dist/crdt/providers/index.js +3 -1
- package/dist/crdt/providers/websocket.d.ts +6 -4
- package/dist/crdt/providers/websocket.js +9 -4
- package/dist/crdt/purge.d.ts +7 -1
- package/dist/crdt/purge.js +32 -2
- package/dist/crdt/restore.d.ts +2 -1
- package/dist/crdt/restore.js +9 -0
- package/dist/crdt/schema.d.ts +9 -1
- package/dist/crdt/schema.js +13 -3
- package/dist/crdt/structs.d.ts +2 -0
- package/dist/crdt/structs.js +2 -0
- package/dist/crdt/text/index/claims.js +1 -23
- package/dist/crdt/text/index/layout.d.ts +2 -4
- package/dist/crdt/text/index/layout.js +9 -22
- package/dist/crdt/text/index/placement.js +21 -24
- package/dist/crdt/text/index/shared.js +17 -14
- package/dist/crdt/text/model.d.ts +10 -0
- package/dist/crdt/text/model.js +8 -1
- package/dist/crdt/text/runs.d.ts +34 -30
- package/dist/crdt/vendor/yjs/UPSTREAM.md +83 -0
- package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
- package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +6 -3
- package/dist/crdt/vendor/yjs/dts/ynode.d.ts +13 -2
- package/dist/crdt/vendor/yjs/src/index.js +1 -1
- package/dist/crdt/vendor/yjs/src/structs/Item.js +44 -5
- package/dist/crdt/vendor/yjs/src/ynode.js +29 -7
- package/dist/edytor.svelte.d.ts +1 -1
- package/dist/edytor.svelte.js +100 -75
- package/dist/events/beforeInputCommands.d.ts +2 -2
- package/dist/events/nativeInteractiveControl.d.ts +11 -1
- package/dist/events/nativeInteractiveControl.js +41 -1
- package/dist/events/onBeforeInput.js +3 -3
- package/dist/events/onFocus.js +9 -11
- package/dist/export/forms.d.ts +25 -25
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/labels.d.ts +8 -0
- package/dist/labels.js +6 -1
- package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
- package/dist/plugins/comments/CommentsController.svelte.js +14 -4
- package/dist/plugins/find/search.js +2 -2
- package/dist/plugins/index.d.ts +1 -0
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
- package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
- package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
- package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
- package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
- package/dist/plugins/suggestions/shared.svelte.js +421 -0
- package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
- package/dist/selection/domSelection.d.ts +2 -0
- package/dist/selection/domSelection.js +6 -2
- package/dist/selection/pointer.svelte.js +75 -48
- package/dist/selection/replaceSelection.js +2 -6
- package/dist/selection/selection.svelte.d.ts +3 -3
- package/dist/selection/selection.svelte.js +39 -32
- package/dist/selection/selection.utils.d.ts +14 -0
- package/dist/selection/selection.utils.js +28 -3
- package/dist/session/commands.js +21 -11
- package/dist/session/composition.svelte.d.ts +5 -3
- package/dist/session/composition.svelte.js +21 -17
- package/dist/session/handles.d.ts +4 -17
- package/dist/session/handles.js +11 -37
- package/dist/session/navigation.js +6 -9
- package/dist/session/selection.d.ts +11 -5
- package/dist/session/selection.js +14 -48
- package/dist/session/suggestions.svelte.d.ts +38 -9
- package/dist/session/suggestions.svelte.js +37 -18
- package/dist/session/virtual.d.ts +1 -1
- package/dist/surface/attributes.d.ts +5 -0
- package/dist/surface/attributes.js +2 -2
- package/dist/surface/cells.d.ts +2 -26
- package/dist/surface/cells.js +2 -33
- package/dist/surface/observer.svelte.d.ts +6 -1
- package/dist/surface/observer.svelte.js +128 -81
- package/dist/surface/pin.svelte.d.ts +9 -9
- package/dist/surface/pin.svelte.js +15 -22
- package/dist/surface/projector.svelte.js +8 -14
- package/dist/text/text.svelte.js +14 -19
- package/dist/themes/notion.css +29 -0
- package/package.json +3 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { rangeRects, type RemoteSelectionRect } from '../../collaboration/remoteSelection.js';
|
|
4
|
+
import type { Edytor } from '../../edytor.svelte.js';
|
|
5
|
+
import type { SharedSuggestionsController } from './shared.svelte.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A shared suggestion's pending runs in the overlay (`sugg.view.runs`),
|
|
9
|
+
* one rectangle per line box, measured once per frame: each inserted run
|
|
10
|
+
* carries `data-edytor-suggestion-inserted`, each deleted one
|
|
11
|
+
* `data-edytor-suggestion-deleted`, in its author's colour
|
|
12
|
+
* (`--edytor-range-color`).
|
|
13
|
+
*/
|
|
14
|
+
let { edytor, shared }: { edytor: Edytor; shared: SharedSuggestionsController } = $props();
|
|
15
|
+
|
|
16
|
+
type Run = RemoteSelectionRect & { kind: string; color?: string };
|
|
17
|
+
let rects: Run[] = $state([]);
|
|
18
|
+
|
|
19
|
+
const runs = () =>
|
|
20
|
+
shared.changes().flatMap((c) => {
|
|
21
|
+
const block = edytor.idToBlock.get(c.block);
|
|
22
|
+
const [start, end] = [
|
|
23
|
+
block?.textAtOffset(c.offset),
|
|
24
|
+
block?.textAtOffset(c.offset + c.length)
|
|
25
|
+
];
|
|
26
|
+
return c.length && start && end && (c.kind === 'insert' || c.kind === 'delete')
|
|
27
|
+
? [
|
|
28
|
+
{
|
|
29
|
+
start,
|
|
30
|
+
end,
|
|
31
|
+
kind: c.kind === 'insert' ? 'inserted' : 'deleted',
|
|
32
|
+
color: shared.colorOf(c.suggestion)
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
: [];
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
onMount(() =>
|
|
39
|
+
edytor.overlay.add((origin) => {
|
|
40
|
+
const next = runs().flatMap(({ start, end, kind, color }) =>
|
|
41
|
+
rangeRects(edytor, start, end, origin).map((rect) => ({ ...rect, kind, color }))
|
|
42
|
+
);
|
|
43
|
+
if (next.length || rects.length) return () => (rects = next);
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
// What the runs read changed: the overlay measures again.
|
|
47
|
+
$effect(() => {
|
|
48
|
+
void runs();
|
|
49
|
+
edytor.overlay.invalidate();
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div data-edytor-suggestion-runs contenteditable="false" aria-hidden="true">
|
|
54
|
+
{#each rects as rect, index (index)}
|
|
55
|
+
<span
|
|
56
|
+
{...{ [`data-edytor-suggestion-${rect.kind}`]: '' }}
|
|
57
|
+
style:left={`${rect.left}px`}
|
|
58
|
+
style:top={`${rect.top}px`}
|
|
59
|
+
style:width={`${rect.width}px`}
|
|
60
|
+
style:height={`${rect.height}px`}
|
|
61
|
+
style:--edytor-range-color={rect.color}
|
|
62
|
+
></span>
|
|
63
|
+
{/each}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<style>
|
|
67
|
+
[data-edytor-suggestion-runs] {
|
|
68
|
+
position: absolute;
|
|
69
|
+
left: 0;
|
|
70
|
+
top: 0;
|
|
71
|
+
z-index: 19;
|
|
72
|
+
pointer-events: none;
|
|
73
|
+
}
|
|
74
|
+
[data-edytor-suggestion-runs] > span {
|
|
75
|
+
position: absolute;
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Edytor } from '../../edytor.svelte.js';
|
|
2
|
+
import type { SharedSuggestionsController } from './shared.svelte.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
edytor: Edytor;
|
|
5
|
+
shared: SharedSuggestionsController;
|
|
6
|
+
};
|
|
7
|
+
declare const SuggestionRuns: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type SuggestionRuns = ReturnType<typeof SuggestionRuns>;
|
|
9
|
+
export default SuggestionRuns;
|
|
@@ -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 {
|
|
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:
|
|
7
|
-
/**
|
|
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
|
-
|
|
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)
|