@dxos/ui-editor 0.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/LICENSE +8 -0
- package/README.md +21 -0
- package/package.json +121 -0
- package/src/defaults.ts +34 -0
- package/src/extensions/annotations.ts +55 -0
- package/src/extensions/autocomplete/autocomplete.ts +151 -0
- package/src/extensions/autocomplete/index.ts +8 -0
- package/src/extensions/autocomplete/match.ts +46 -0
- package/src/extensions/autocomplete/placeholder.ts +117 -0
- package/src/extensions/autocomplete/typeahead.ts +87 -0
- package/src/extensions/automerge/automerge.test.tsx +76 -0
- package/src/extensions/automerge/automerge.ts +105 -0
- package/src/extensions/automerge/cursor.ts +28 -0
- package/src/extensions/automerge/defs.ts +31 -0
- package/src/extensions/automerge/index.ts +5 -0
- package/src/extensions/automerge/sync.ts +79 -0
- package/src/extensions/automerge/update-automerge.ts +50 -0
- package/src/extensions/automerge/update-codemirror.ts +115 -0
- package/src/extensions/autoscroll.ts +165 -0
- package/src/extensions/awareness/awareness-provider.ts +127 -0
- package/src/extensions/awareness/awareness.ts +315 -0
- package/src/extensions/awareness/index.ts +6 -0
- package/src/extensions/blast.ts +363 -0
- package/src/extensions/blocks.ts +131 -0
- package/src/extensions/bookmarks.ts +77 -0
- package/src/extensions/comments.ts +579 -0
- package/src/extensions/debug.ts +15 -0
- package/src/extensions/dnd.ts +39 -0
- package/src/extensions/factories.ts +284 -0
- package/src/extensions/focus.ts +36 -0
- package/src/extensions/folding.ts +63 -0
- package/src/extensions/hashtag.ts +68 -0
- package/src/extensions/index.ts +34 -0
- package/src/extensions/json.ts +57 -0
- package/src/extensions/listener.ts +32 -0
- package/src/extensions/markdown/action.ts +117 -0
- package/src/extensions/markdown/bundle.ts +105 -0
- package/src/extensions/markdown/changes.test.ts +26 -0
- package/src/extensions/markdown/changes.ts +149 -0
- package/src/extensions/markdown/debug.ts +44 -0
- package/src/extensions/markdown/decorate.ts +622 -0
- package/src/extensions/markdown/formatting.test.ts +498 -0
- package/src/extensions/markdown/formatting.ts +1265 -0
- package/src/extensions/markdown/highlight.ts +183 -0
- package/src/extensions/markdown/image.ts +118 -0
- package/src/extensions/markdown/index.ts +13 -0
- package/src/extensions/markdown/link.ts +50 -0
- package/src/extensions/markdown/parser.test.ts +75 -0
- package/src/extensions/markdown/styles.ts +135 -0
- package/src/extensions/markdown/table.ts +150 -0
- package/src/extensions/mention.ts +41 -0
- package/src/extensions/modal.ts +24 -0
- package/src/extensions/modes.ts +41 -0
- package/src/extensions/outliner/commands.ts +270 -0
- package/src/extensions/outliner/editor.test.ts +33 -0
- package/src/extensions/outliner/editor.ts +184 -0
- package/src/extensions/outliner/index.ts +7 -0
- package/src/extensions/outliner/menu.ts +128 -0
- package/src/extensions/outliner/outliner.test.ts +100 -0
- package/src/extensions/outliner/outliner.ts +167 -0
- package/src/extensions/outliner/selection.ts +50 -0
- package/src/extensions/outliner/tree.test.ts +168 -0
- package/src/extensions/outliner/tree.ts +317 -0
- package/src/extensions/preview/index.ts +5 -0
- package/src/extensions/preview/preview.ts +193 -0
- package/src/extensions/replacer.test.ts +75 -0
- package/src/extensions/replacer.ts +93 -0
- package/src/extensions/scrolling.ts +189 -0
- package/src/extensions/selection.ts +100 -0
- package/src/extensions/state.ts +7 -0
- package/src/extensions/submit.ts +62 -0
- package/src/extensions/tags/extended-markdown.test.ts +263 -0
- package/src/extensions/tags/extended-markdown.ts +78 -0
- package/src/extensions/tags/index.ts +7 -0
- package/src/extensions/tags/streamer.ts +243 -0
- package/src/extensions/tags/xml-tags.ts +507 -0
- package/src/extensions/tags/xml-util.test.ts +48 -0
- package/src/extensions/tags/xml-util.ts +93 -0
- package/src/extensions/typewriter.ts +68 -0
- package/src/index.ts +14 -0
- package/src/styles/index.ts +7 -0
- package/src/styles/markdown.ts +26 -0
- package/src/styles/theme.ts +293 -0
- package/src/styles/tokens.ts +17 -0
- package/src/types/index.ts +5 -0
- package/src/types/types.ts +32 -0
- package/src/util/cursor.ts +56 -0
- package/src/util/debug.ts +56 -0
- package/src/util/decorations.ts +21 -0
- package/src/util/dom.ts +36 -0
- package/src/util/facet.ts +13 -0
- package/src/util/index.ts +10 -0
- package/src/util/util.ts +29 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type DocHandle, Repo } from '@automerge/automerge-repo';
|
|
6
|
+
import { EditorState } from '@codemirror/state';
|
|
7
|
+
import { EditorView } from '@codemirror/view';
|
|
8
|
+
import { render, screen } from '@testing-library/react';
|
|
9
|
+
import React, { type FC, useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { describe, test } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import { get } from '@dxos/util';
|
|
13
|
+
|
|
14
|
+
import { automerge } from './automerge';
|
|
15
|
+
|
|
16
|
+
type TestObject = {
|
|
17
|
+
text: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const path = ['text'];
|
|
21
|
+
|
|
22
|
+
class Generator {
|
|
23
|
+
constructor(private readonly _handle: DocHandle<TestObject>) {}
|
|
24
|
+
update(text: string): void {
|
|
25
|
+
this._handle.change((doc: TestObject) => {
|
|
26
|
+
doc.text = text;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ handle, generator }) => {
|
|
32
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
33
|
+
const [view, setView] = useState<EditorView>();
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const extensions = [
|
|
36
|
+
// TODO(mykola): Fix types.
|
|
37
|
+
automerge({ handle: handle as any, path }),
|
|
38
|
+
EditorView.updateListener.of((update) => {
|
|
39
|
+
if (view.state.doc.toString() === 'hello!') {
|
|
40
|
+
// Update editor.
|
|
41
|
+
update.view.dispatch({
|
|
42
|
+
changes: { from: view.state.doc.length - 1, insert: ' world' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const view = new EditorView({
|
|
49
|
+
state: EditorState.create({ doc: get(handle.doc()!, path), extensions }),
|
|
50
|
+
parent: ref.current!,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
setView(view);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {}, [view]);
|
|
57
|
+
|
|
58
|
+
return <div ref={ref} data-testid='editor' />;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe('Automerge', () => {
|
|
62
|
+
test('basic sync', ({ expect }) => {
|
|
63
|
+
const repo = new Repo({ network: [] });
|
|
64
|
+
const handle = repo.create<TestObject>();
|
|
65
|
+
const generator = new Generator(handle);
|
|
66
|
+
render(<Test handle={handle} generator={generator} />);
|
|
67
|
+
const editor = screen.getByTestId('editor');
|
|
68
|
+
expect(editor.textContent).toBe('');
|
|
69
|
+
|
|
70
|
+
generator.update('hello!');
|
|
71
|
+
expect(editor.textContent).toBe('hello world!');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// TODO(burdon): Test history/undo.
|
|
75
|
+
// TODO(burdon): https://testing-library.com/docs/react-testing-library/example-intro/
|
|
76
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
// Copyright 2024 Automerge
|
|
4
|
+
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
import { next as A } from '@automerge/automerge';
|
|
8
|
+
import { type Extension, StateField, Transaction } from '@codemirror/state';
|
|
9
|
+
import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
10
|
+
|
|
11
|
+
import { DocAccessor } from '@dxos/echo-db';
|
|
12
|
+
|
|
13
|
+
import { Cursor } from '../../util';
|
|
14
|
+
import { initialSync } from '../state';
|
|
15
|
+
|
|
16
|
+
import { cursorConverter } from './cursor';
|
|
17
|
+
import { type State, isReconcile, updateHeadsEffect } from './defs';
|
|
18
|
+
import { Syncer } from './sync';
|
|
19
|
+
|
|
20
|
+
export const automerge = (accessor: DocAccessor): Extension => {
|
|
21
|
+
const syncState = StateField.define<State>({
|
|
22
|
+
create: () => {
|
|
23
|
+
return {
|
|
24
|
+
path: accessor.path.slice(),
|
|
25
|
+
lastHeads: A.getHeads(accessor.handle.doc()!),
|
|
26
|
+
unreconciledTransactions: [],
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
update: (value, tr) => {
|
|
31
|
+
const result: State = {
|
|
32
|
+
path: accessor.path.slice(),
|
|
33
|
+
lastHeads: value.lastHeads,
|
|
34
|
+
unreconciledTransactions: value.unreconciledTransactions.slice(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let clearUnreconciled = false;
|
|
38
|
+
for (const effect of tr.effects) {
|
|
39
|
+
if (effect.is(updateHeadsEffect)) {
|
|
40
|
+
result.lastHeads = effect.value.newHeads;
|
|
41
|
+
clearUnreconciled = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (clearUnreconciled) {
|
|
46
|
+
result.unreconciledTransactions = [];
|
|
47
|
+
} else {
|
|
48
|
+
if (!isReconcile(tr)) {
|
|
49
|
+
result.unreconciledTransactions.push(tr);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const syncer = new Syncer(accessor.handle, syncState);
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
Cursor.converter.of(cursorConverter(accessor)),
|
|
61
|
+
|
|
62
|
+
// Track heads.
|
|
63
|
+
syncState,
|
|
64
|
+
|
|
65
|
+
// Reconcile external updates.
|
|
66
|
+
ViewPlugin.fromClass(
|
|
67
|
+
class {
|
|
68
|
+
constructor(private readonly _view: EditorView) {
|
|
69
|
+
accessor.handle.addListener('change', this._handleChange);
|
|
70
|
+
|
|
71
|
+
requestAnimationFrame(() => {
|
|
72
|
+
const value = DocAccessor.getValue<string>(accessor);
|
|
73
|
+
const current = this._view.state.doc.toString();
|
|
74
|
+
if (value !== current) {
|
|
75
|
+
// TODO(burdon): This attempts to set the initial state, but creates problems.
|
|
76
|
+
// this._view.dispatch({
|
|
77
|
+
// changes: { from: 0, to: this._view.state.doc.length, insert: value },
|
|
78
|
+
// annotations: initialSync,
|
|
79
|
+
// });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
destroy() {
|
|
85
|
+
accessor.handle.removeListener('change', this._handleChange);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
readonly _handleChange = () => {
|
|
89
|
+
syncer.reconcile(this._view, false);
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
|
|
94
|
+
// Reconcile local updates.
|
|
95
|
+
EditorView.updateListener.of(({ view, changes, transactions }) => {
|
|
96
|
+
if (!changes.empty) {
|
|
97
|
+
// Only reconcile if it's not an initial sync (to avoid loops)
|
|
98
|
+
const isInitialSync = transactions.some((tr) => tr.annotation(Transaction.userEvent) === initialSync.value);
|
|
99
|
+
if (!isInitialSync) {
|
|
100
|
+
syncer.reconcile(view, true);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}),
|
|
104
|
+
];
|
|
105
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type DocAccessor, fromCursor, toCursor } from '@dxos/echo-db';
|
|
6
|
+
import { log } from '@dxos/log';
|
|
7
|
+
|
|
8
|
+
import { type CursorConverter } from '../../util';
|
|
9
|
+
|
|
10
|
+
export const cursorConverter = (accessor: DocAccessor): CursorConverter => ({
|
|
11
|
+
toCursor: (pos, assoc) => {
|
|
12
|
+
try {
|
|
13
|
+
return toCursor(accessor, pos, assoc);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
log.catch(err);
|
|
16
|
+
return ''; // In case of invalid request (e.g., wrong document).
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
fromCursor: (cursor) => {
|
|
21
|
+
try {
|
|
22
|
+
return fromCursor(accessor, cursor);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
log.catch(err);
|
|
25
|
+
return 0; // In case of invalid request (e.g., wrong document).
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
// Copyright 2024 Automerge
|
|
4
|
+
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
import { type Heads, type Prop } from '@automerge/automerge';
|
|
8
|
+
import { Annotation, type EditorState, StateEffect, type StateField, type Transaction } from '@codemirror/state';
|
|
9
|
+
|
|
10
|
+
export type State = {
|
|
11
|
+
path: Prop[];
|
|
12
|
+
lastHeads: Heads;
|
|
13
|
+
unreconciledTransactions: Transaction[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const getPath = (state: EditorState, field: StateField<State>): Prop[] => state.field(field).path;
|
|
17
|
+
export const getLastHeads = (state: EditorState, field: StateField<State>): Heads => state.field(field).lastHeads;
|
|
18
|
+
|
|
19
|
+
export type UpdateHeads = {
|
|
20
|
+
newHeads: Heads;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const updateHeadsEffect = StateEffect.define<UpdateHeads>({});
|
|
24
|
+
|
|
25
|
+
export const updateHeads = (newHeads: Heads): StateEffect<UpdateHeads> => updateHeadsEffect.of({ newHeads });
|
|
26
|
+
|
|
27
|
+
export const reconcileAnnotation = Annotation.define<boolean>();
|
|
28
|
+
|
|
29
|
+
export const isReconcile = (tr: Transaction): boolean => {
|
|
30
|
+
return !!tr.annotation(reconcileAnnotation);
|
|
31
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
// Copyright 2024 Automerge
|
|
4
|
+
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
import { next as A } from '@automerge/automerge';
|
|
8
|
+
import { type StateField } from '@codemirror/state';
|
|
9
|
+
import { type EditorView } from '@codemirror/view';
|
|
10
|
+
|
|
11
|
+
import { type IDocHandle } from '@dxos/echo-db';
|
|
12
|
+
import { log } from '@dxos/log';
|
|
13
|
+
|
|
14
|
+
import { type State, getLastHeads, getPath, isReconcile, reconcileAnnotation, updateHeads } from './defs';
|
|
15
|
+
import { updateAutomerge } from './update-automerge';
|
|
16
|
+
import { updateCodeMirror } from './update-codemirror';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Implements three-way merge (on each mutation).
|
|
20
|
+
*/
|
|
21
|
+
export class Syncer {
|
|
22
|
+
private _pending = false;
|
|
23
|
+
|
|
24
|
+
// prettier-ignore
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly _handle: IDocHandle,
|
|
27
|
+
private readonly _state: StateField<State>
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
reconcile(view: EditorView, editor: boolean): void {
|
|
31
|
+
if (this._pending) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this._pending = true;
|
|
36
|
+
if (editor) {
|
|
37
|
+
this.onEditorChange(view);
|
|
38
|
+
} else {
|
|
39
|
+
this.onAutomergeChange(view);
|
|
40
|
+
}
|
|
41
|
+
this._pending = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private onEditorChange(view: EditorView): void {
|
|
45
|
+
log('onEditorChange');
|
|
46
|
+
|
|
47
|
+
// Apply the unreconciled transactions to the document.
|
|
48
|
+
const transactions = view.state.field(this._state).unreconciledTransactions.filter((tx) => !isReconcile(tx));
|
|
49
|
+
const newHeads = updateAutomerge(this._state, this._handle, transactions, view.state);
|
|
50
|
+
|
|
51
|
+
if (newHeads) {
|
|
52
|
+
view.dispatch({
|
|
53
|
+
effects: updateHeads(newHeads),
|
|
54
|
+
annotations: reconcileAnnotation.of(false),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private onAutomergeChange(view: EditorView): void {
|
|
60
|
+
log('onAutomergeChange');
|
|
61
|
+
|
|
62
|
+
// Get the diff between the updated state of the document and the heads and apply that to the codemirror doc.
|
|
63
|
+
const oldHeads = getLastHeads(view.state, this._state);
|
|
64
|
+
const newHeads = A.getHeads(this._handle.doc()!);
|
|
65
|
+
const diff = A.equals(oldHeads, newHeads) ? [] : A.diff(this._handle.doc()!, oldHeads, newHeads);
|
|
66
|
+
|
|
67
|
+
const selection = view.state.selection;
|
|
68
|
+
const path = getPath(view.state, this._state);
|
|
69
|
+
updateCodeMirror(view, selection, path, diff);
|
|
70
|
+
|
|
71
|
+
// TODO(burdon): Test conflicts?
|
|
72
|
+
// A.getConflicts(this._handle.doc()!, path[0]);
|
|
73
|
+
|
|
74
|
+
view.dispatch({
|
|
75
|
+
effects: updateHeads(newHeads),
|
|
76
|
+
annotations: reconcileAnnotation.of(false),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
// Copyright 2024 Automerge
|
|
4
|
+
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
import { next as A, type Heads } from '@automerge/automerge';
|
|
8
|
+
import { type EditorState, type StateField, type Text, type Transaction } from '@codemirror/state';
|
|
9
|
+
|
|
10
|
+
import { type IDocHandle } from '@dxos/echo-db';
|
|
11
|
+
|
|
12
|
+
import { type State } from './defs';
|
|
13
|
+
|
|
14
|
+
export const updateAutomerge = (
|
|
15
|
+
field: StateField<State>,
|
|
16
|
+
handle: IDocHandle,
|
|
17
|
+
transactions: Transaction[],
|
|
18
|
+
state: EditorState, // TODO(burdon): Just pass in the state field value?
|
|
19
|
+
): Heads | undefined => {
|
|
20
|
+
const { lastHeads, path } = state.field(field);
|
|
21
|
+
|
|
22
|
+
// We don't want to call `automerge.updateAt` if there are no changes.
|
|
23
|
+
// Otherwise, later on `automerge.diff` will return empty patches that result in a no-op but still mess up the selection.
|
|
24
|
+
let hasChanges = false;
|
|
25
|
+
for (const tr of transactions) {
|
|
26
|
+
tr.changes.iterChanges(() => {
|
|
27
|
+
hasChanges = true;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!hasChanges) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const newHeads = handle.changeAt(lastHeads, (doc: A.Doc<unknown>) => {
|
|
36
|
+
const invertedTransactions: { from: number; del: number; insert: Text }[] = [];
|
|
37
|
+
for (const tr of transactions) {
|
|
38
|
+
tr.changes.iterChanges((fromA, toA, _fromB, _toB, insert) => {
|
|
39
|
+
invertedTransactions.push({ from: fromA, del: toA - fromA, insert });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// TODO(burdon): Hack to apply in reverse order to properly apply range.
|
|
44
|
+
invertedTransactions.reverse().forEach(({ from, del, insert }) => {
|
|
45
|
+
A.splice(doc, path.slice(), from, del, insert.toString());
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return newHeads ?? undefined;
|
|
50
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
// Copyright 2024 Automerge
|
|
4
|
+
// Ref: https://github.com/automerge/automerge-codemirror
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type DelPatch,
|
|
9
|
+
type InsertPatch,
|
|
10
|
+
type Patch,
|
|
11
|
+
type Prop,
|
|
12
|
+
type PutPatch,
|
|
13
|
+
type SpliceTextPatch,
|
|
14
|
+
} from '@automerge/automerge';
|
|
15
|
+
import { ChangeSet, type ChangeSpec, type EditorSelection, type EditorState } from '@codemirror/state';
|
|
16
|
+
import { type EditorView } from '@codemirror/view';
|
|
17
|
+
|
|
18
|
+
import { reconcileAnnotation } from './defs';
|
|
19
|
+
|
|
20
|
+
export const updateCodeMirror = (view: EditorView, selection: EditorSelection, target: Prop[], patches: Patch[]) => {
|
|
21
|
+
for (const patch of patches) {
|
|
22
|
+
const changeSpec = handlePatch(patch, target, view.state);
|
|
23
|
+
if (changeSpec != null) {
|
|
24
|
+
const changeSet = ChangeSet.of(changeSpec, view.state.doc.length, '\n');
|
|
25
|
+
selection = selection.map(changeSet, 1);
|
|
26
|
+
view.dispatch({
|
|
27
|
+
changes: changeSet,
|
|
28
|
+
annotations: reconcileAnnotation.of(false),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
view.dispatch({
|
|
34
|
+
selection,
|
|
35
|
+
annotations: reconcileAnnotation.of(false),
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handlePatch = (patch: Patch, target: Prop[], state: EditorState): ChangeSpec | null => {
|
|
40
|
+
if (patch.action === 'insert') {
|
|
41
|
+
return handleInsert(target, patch);
|
|
42
|
+
} else if (patch.action === 'splice') {
|
|
43
|
+
return handleSplice(target, patch);
|
|
44
|
+
} else if (patch.action === 'del') {
|
|
45
|
+
return handleDel(target, patch);
|
|
46
|
+
} else if (patch.action === 'put') {
|
|
47
|
+
return handlePut(target, patch, state);
|
|
48
|
+
} else {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleInsert = (target: Prop[], patch: InsertPatch): Array<ChangeSpec> => {
|
|
54
|
+
const index = charPath(target, patch.path);
|
|
55
|
+
if (index == null) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const text = patch.values.map((value) => (value ? value.toString() : '')).join('');
|
|
60
|
+
return [{ from: index, to: index, insert: text }];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handleSplice = (target: Prop[], patch: SpliceTextPatch): Array<ChangeSpec> => {
|
|
64
|
+
const index = charPath(target, patch.path);
|
|
65
|
+
if (index == null) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [{ from: index, insert: patch.value }];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleDel = (target: Prop[], patch: DelPatch): Array<ChangeSpec> => {
|
|
73
|
+
const index = charPath(target, patch.path);
|
|
74
|
+
if (index == null) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const length = patch.length || 1;
|
|
79
|
+
return [{ from: index, to: index + length }];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handlePut = (target: Prop[], patch: PutPatch, state: EditorState): Array<ChangeSpec> => {
|
|
83
|
+
const index = charPath(target, [...patch.path, 0]);
|
|
84
|
+
if (index == null) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const length = state.doc.length;
|
|
89
|
+
if (typeof patch.value !== 'string') {
|
|
90
|
+
return []; // TODO(dmaretskyi): How to handle non-string values?
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [{ from: 0, to: length, insert: patch.value as any }];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// If the path of the patch is of the form [path, <index>] then we know this is a path to a character
|
|
97
|
+
// within the sequence given by path.
|
|
98
|
+
const charPath = (textPath: Prop[], candidatePath: Prop[]): number | null => {
|
|
99
|
+
if (candidatePath.length !== textPath.length + 1) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < textPath.length; i++) {
|
|
104
|
+
if (textPath[i] !== candidatePath[i]) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const index = candidatePath[candidatePath.length - 1];
|
|
110
|
+
if (typeof index === 'number') {
|
|
111
|
+
return index;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { StateEffect } from '@codemirror/state';
|
|
6
|
+
import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
import { debounce } from '@dxos/async';
|
|
9
|
+
import { Domino } from '@dxos/ui';
|
|
10
|
+
|
|
11
|
+
import { scrollToLineEffect } from './scrolling';
|
|
12
|
+
|
|
13
|
+
// TODO(burdon): Reconcile with scrollToLineEffect (scrolling).
|
|
14
|
+
export const scrollToBottomEffect = StateEffect.define<ScrollBehavior | undefined>();
|
|
15
|
+
|
|
16
|
+
export type AutoScrollOptions = {
|
|
17
|
+
/** Auto-scroll when reaches the bottom. */
|
|
18
|
+
autoScroll?: boolean;
|
|
19
|
+
/** Threshold in px to trigger scroll from bottom. */
|
|
20
|
+
threshold?: number;
|
|
21
|
+
/** Throttle time in ms. */
|
|
22
|
+
throttleDelay?: number;
|
|
23
|
+
/** Callback when auto-scrolling. */
|
|
24
|
+
onAutoScroll?: (props: { view: EditorView; distanceFromBottom: number }) => boolean | void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extension that supports pinning the scroll position and automatically scrolls to the bottom when content is added.
|
|
29
|
+
*/
|
|
30
|
+
// TODO(burdon): Reconcile with transcript-extension.
|
|
31
|
+
export const autoScroll = ({
|
|
32
|
+
autoScroll = true,
|
|
33
|
+
threshold = 100,
|
|
34
|
+
throttleDelay = 1_000,
|
|
35
|
+
onAutoScroll,
|
|
36
|
+
}: Partial<AutoScrollOptions> = {}) => {
|
|
37
|
+
let buttonContainer: HTMLDivElement | undefined;
|
|
38
|
+
let hideTimeout: NodeJS.Timeout | undefined;
|
|
39
|
+
let lastScrollTop = 0;
|
|
40
|
+
let isPinned = true;
|
|
41
|
+
|
|
42
|
+
const setPinned = (pin: boolean) => {
|
|
43
|
+
isPinned = pin;
|
|
44
|
+
buttonContainer?.classList.toggle('opacity-0', pin);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Temporarily hide the scrollbar while auto-scrolling.
|
|
48
|
+
const hideScrollbar = (view: EditorView) => {
|
|
49
|
+
view.scrollDOM.classList.add('cm-hide-scrollbar');
|
|
50
|
+
clearTimeout(hideTimeout);
|
|
51
|
+
hideTimeout = setTimeout(() => {
|
|
52
|
+
view.scrollDOM.classList.remove('cm-hide-scrollbar');
|
|
53
|
+
}, 1_000);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Throttled scroll to bottom.
|
|
57
|
+
const scrollToBottom = (view: EditorView, behavior?: ScrollBehavior) => {
|
|
58
|
+
setPinned(true);
|
|
59
|
+
hideScrollbar(view);
|
|
60
|
+
const line = view.state.doc.lineAt(view.state.doc.length);
|
|
61
|
+
view.dispatch({
|
|
62
|
+
selection: { anchor: line.to, head: line.to },
|
|
63
|
+
effects: scrollToLineEffect.of({
|
|
64
|
+
line: line.number,
|
|
65
|
+
options: { position: 'end', offset: threshold, behavior },
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Throttled check for distance from bottom (for downward scrolls only).
|
|
71
|
+
const checkDistance = debounce((view: EditorView) => {
|
|
72
|
+
const scrollerRect = view.scrollDOM.getBoundingClientRect();
|
|
73
|
+
const coords = view.coordsAtPos(view.state.doc.length);
|
|
74
|
+
const distanceFromBottom = coords ? coords.bottom - scrollerRect.bottom : 0;
|
|
75
|
+
setPinned(distanceFromBottom < 0);
|
|
76
|
+
}, 1_000);
|
|
77
|
+
|
|
78
|
+
// Debounce scroll updates so rapid edits don't cause clunky scrolling.
|
|
79
|
+
const triggerUpdate = debounce((view: EditorView) => scrollToBottom(view), throttleDelay);
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
// Update listener for logging when scrolling is needed.
|
|
83
|
+
EditorView.updateListener.of(({ view, transactions, heightChanged }) => {
|
|
84
|
+
// TODO(burdon): Remove and use scrollToLineEffect instead.
|
|
85
|
+
transactions.forEach((transaction) => {
|
|
86
|
+
for (const effect of transaction.effects) {
|
|
87
|
+
if (effect.is(scrollToBottomEffect)) {
|
|
88
|
+
scrollToBottom(view, effect.value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Maybe scroll if doc changed and pinned.
|
|
94
|
+
// NOTE: Geometry changed is triggered when widgets change height (e.g., toggle tool block).
|
|
95
|
+
if (heightChanged && isPinned) {
|
|
96
|
+
const coords = view.coordsAtPos(view.state.doc.length);
|
|
97
|
+
const scrollerRect = view.scrollDOM.getBoundingClientRect();
|
|
98
|
+
const distanceFromBottom = coords ? scrollerRect.bottom - coords.bottom : 0;
|
|
99
|
+
if (autoScroll && distanceFromBottom < threshold) {
|
|
100
|
+
const shouldScroll = onAutoScroll?.({ view, distanceFromBottom }) ?? true;
|
|
101
|
+
if (shouldScroll) {
|
|
102
|
+
triggerUpdate(view);
|
|
103
|
+
}
|
|
104
|
+
} else if (distanceFromBottom < 0) {
|
|
105
|
+
setPinned(false);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}),
|
|
109
|
+
|
|
110
|
+
// Detect user scroll.
|
|
111
|
+
EditorView.domEventHandlers({
|
|
112
|
+
scroll: (event, view) => {
|
|
113
|
+
const currentScrollTop = view.scrollDOM.scrollTop;
|
|
114
|
+
const scrollingUp = currentScrollTop < lastScrollTop;
|
|
115
|
+
lastScrollTop = currentScrollTop;
|
|
116
|
+
|
|
117
|
+
// If user scrolls up, immediately unpin auto-scroll.
|
|
118
|
+
if (scrollingUp) {
|
|
119
|
+
setPinned(false);
|
|
120
|
+
} else {
|
|
121
|
+
checkDistance(view);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
// Scroll button.
|
|
127
|
+
ViewPlugin.fromClass(
|
|
128
|
+
class {
|
|
129
|
+
constructor(view: EditorView) {
|
|
130
|
+
const icon = Domino.of('dx-icon' as any).attributes({ icon: 'ph--arrow-down--regular' });
|
|
131
|
+
const button = Domino.of('button')
|
|
132
|
+
.classNames('dx-button bg-accentSurface')
|
|
133
|
+
.attributes({ 'data-density': 'fine' })
|
|
134
|
+
.children(icon)
|
|
135
|
+
.on('click', () => {
|
|
136
|
+
scrollToBottom(view);
|
|
137
|
+
});
|
|
138
|
+
buttonContainer = Domino.of('div')
|
|
139
|
+
.classNames('cm-scroll-button transition-opacity duration-300 opacity-0')
|
|
140
|
+
.children(button).root as HTMLDivElement;
|
|
141
|
+
|
|
142
|
+
view.scrollDOM.parentElement!.appendChild(buttonContainer);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
),
|
|
146
|
+
|
|
147
|
+
// Styles.
|
|
148
|
+
EditorView.theme({
|
|
149
|
+
'.cm-scroller': {
|
|
150
|
+
scrollbarWidth: 'thin',
|
|
151
|
+
},
|
|
152
|
+
'.cm-scroller.cm-hide-scrollbar': {
|
|
153
|
+
scrollbarWidth: 'none',
|
|
154
|
+
},
|
|
155
|
+
'.cm-scroller.cm-hide-scrollbar::-webkit-scrollbar': {
|
|
156
|
+
display: 'none',
|
|
157
|
+
},
|
|
158
|
+
'.cm-scroll-button': {
|
|
159
|
+
position: 'absolute',
|
|
160
|
+
bottom: '0.5rem',
|
|
161
|
+
right: '1rem',
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
];
|
|
165
|
+
};
|