@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.
Files changed (93) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +21 -0
  3. package/package.json +121 -0
  4. package/src/defaults.ts +34 -0
  5. package/src/extensions/annotations.ts +55 -0
  6. package/src/extensions/autocomplete/autocomplete.ts +151 -0
  7. package/src/extensions/autocomplete/index.ts +8 -0
  8. package/src/extensions/autocomplete/match.ts +46 -0
  9. package/src/extensions/autocomplete/placeholder.ts +117 -0
  10. package/src/extensions/autocomplete/typeahead.ts +87 -0
  11. package/src/extensions/automerge/automerge.test.tsx +76 -0
  12. package/src/extensions/automerge/automerge.ts +105 -0
  13. package/src/extensions/automerge/cursor.ts +28 -0
  14. package/src/extensions/automerge/defs.ts +31 -0
  15. package/src/extensions/automerge/index.ts +5 -0
  16. package/src/extensions/automerge/sync.ts +79 -0
  17. package/src/extensions/automerge/update-automerge.ts +50 -0
  18. package/src/extensions/automerge/update-codemirror.ts +115 -0
  19. package/src/extensions/autoscroll.ts +165 -0
  20. package/src/extensions/awareness/awareness-provider.ts +127 -0
  21. package/src/extensions/awareness/awareness.ts +315 -0
  22. package/src/extensions/awareness/index.ts +6 -0
  23. package/src/extensions/blast.ts +363 -0
  24. package/src/extensions/blocks.ts +131 -0
  25. package/src/extensions/bookmarks.ts +77 -0
  26. package/src/extensions/comments.ts +579 -0
  27. package/src/extensions/debug.ts +15 -0
  28. package/src/extensions/dnd.ts +39 -0
  29. package/src/extensions/factories.ts +284 -0
  30. package/src/extensions/focus.ts +36 -0
  31. package/src/extensions/folding.ts +63 -0
  32. package/src/extensions/hashtag.ts +68 -0
  33. package/src/extensions/index.ts +34 -0
  34. package/src/extensions/json.ts +57 -0
  35. package/src/extensions/listener.ts +32 -0
  36. package/src/extensions/markdown/action.ts +117 -0
  37. package/src/extensions/markdown/bundle.ts +105 -0
  38. package/src/extensions/markdown/changes.test.ts +26 -0
  39. package/src/extensions/markdown/changes.ts +149 -0
  40. package/src/extensions/markdown/debug.ts +44 -0
  41. package/src/extensions/markdown/decorate.ts +622 -0
  42. package/src/extensions/markdown/formatting.test.ts +498 -0
  43. package/src/extensions/markdown/formatting.ts +1265 -0
  44. package/src/extensions/markdown/highlight.ts +183 -0
  45. package/src/extensions/markdown/image.ts +118 -0
  46. package/src/extensions/markdown/index.ts +13 -0
  47. package/src/extensions/markdown/link.ts +50 -0
  48. package/src/extensions/markdown/parser.test.ts +75 -0
  49. package/src/extensions/markdown/styles.ts +135 -0
  50. package/src/extensions/markdown/table.ts +150 -0
  51. package/src/extensions/mention.ts +41 -0
  52. package/src/extensions/modal.ts +24 -0
  53. package/src/extensions/modes.ts +41 -0
  54. package/src/extensions/outliner/commands.ts +270 -0
  55. package/src/extensions/outliner/editor.test.ts +33 -0
  56. package/src/extensions/outliner/editor.ts +184 -0
  57. package/src/extensions/outliner/index.ts +7 -0
  58. package/src/extensions/outliner/menu.ts +128 -0
  59. package/src/extensions/outliner/outliner.test.ts +100 -0
  60. package/src/extensions/outliner/outliner.ts +167 -0
  61. package/src/extensions/outliner/selection.ts +50 -0
  62. package/src/extensions/outliner/tree.test.ts +168 -0
  63. package/src/extensions/outliner/tree.ts +317 -0
  64. package/src/extensions/preview/index.ts +5 -0
  65. package/src/extensions/preview/preview.ts +193 -0
  66. package/src/extensions/replacer.test.ts +75 -0
  67. package/src/extensions/replacer.ts +93 -0
  68. package/src/extensions/scrolling.ts +189 -0
  69. package/src/extensions/selection.ts +100 -0
  70. package/src/extensions/state.ts +7 -0
  71. package/src/extensions/submit.ts +62 -0
  72. package/src/extensions/tags/extended-markdown.test.ts +263 -0
  73. package/src/extensions/tags/extended-markdown.ts +78 -0
  74. package/src/extensions/tags/index.ts +7 -0
  75. package/src/extensions/tags/streamer.ts +243 -0
  76. package/src/extensions/tags/xml-tags.ts +507 -0
  77. package/src/extensions/tags/xml-util.test.ts +48 -0
  78. package/src/extensions/tags/xml-util.ts +93 -0
  79. package/src/extensions/typewriter.ts +68 -0
  80. package/src/index.ts +14 -0
  81. package/src/styles/index.ts +7 -0
  82. package/src/styles/markdown.ts +26 -0
  83. package/src/styles/theme.ts +293 -0
  84. package/src/styles/tokens.ts +17 -0
  85. package/src/types/index.ts +5 -0
  86. package/src/types/types.ts +32 -0
  87. package/src/util/cursor.ts +56 -0
  88. package/src/util/debug.ts +56 -0
  89. package/src/util/decorations.ts +21 -0
  90. package/src/util/dom.ts +36 -0
  91. package/src/util/facet.ts +13 -0
  92. package/src/util/index.ts +10 -0
  93. package/src/util/util.ts +29 -0
@@ -0,0 +1,363 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ // Based on a demo by Joel Besada and Kushagra Gour.
4
+ // https://twitter.com/JoelBesada/status/670343885655293952
5
+ // https://github.com/chinchang/code-blast-codemirror/blob/master/code-blast.js
6
+ //
7
+
8
+ import { type Extension } from '@codemirror/state';
9
+ import { EditorView, keymap } from '@codemirror/view';
10
+ import defaultsDeep from 'lodash.defaultsdeep';
11
+
12
+ import { throttle } from '@dxos/async';
13
+ import { invariant } from '@dxos/invariant';
14
+
15
+ export type BlastOptions = {
16
+ effect?: number;
17
+ maxParticles: number;
18
+ particleGravity: number;
19
+ particleAlphaFadeout: number;
20
+ particleSize: { min: number; max: number };
21
+ particleNumRange: { min: number; max: number };
22
+ particleVelocityRange: { x: [number, number]; y: [number, number] };
23
+ particleShrinkRate: number;
24
+ shakeIntensity: number;
25
+ color?: () => number[];
26
+ };
27
+
28
+ export const defaultOptions: BlastOptions = {
29
+ effect: 2,
30
+ maxParticles: 200,
31
+ particleGravity: 0.08,
32
+ particleAlphaFadeout: 0.996,
33
+ particleSize: { min: 2, max: 8 },
34
+ particleNumRange: { min: 5, max: 10 },
35
+ particleVelocityRange: { x: [-1, 1], y: [-3.5, -1.5] },
36
+ particleShrinkRate: 0.95,
37
+ shakeIntensity: 5,
38
+ };
39
+
40
+ export const blast = (options: Partial<BlastOptions> = defaultOptions): Extension => {
41
+ let blaster: Blaster | undefined;
42
+ let last = 0;
43
+
44
+ const getPoint = (view: EditorView, pos: number) => {
45
+ const { left: x = 0, top: y = 0 } = view.coordsForChar(pos) ?? {};
46
+ const element = document.elementFromPoint(x, y);
47
+
48
+ const { offsetLeft: left = 0, offsetTop: top = 0 } = view.scrollDOM.parentElement ?? {};
49
+ const point = { x: x - left, y: y - top };
50
+
51
+ return { element, point };
52
+ };
53
+
54
+ return [
55
+ // Cursor moved.
56
+ EditorView.updateListener.of((update) => {
57
+ // NOTE: The MarkdownEditor may recreated the EditorView.
58
+ if (blaster?.node !== update.view.scrollDOM) {
59
+ if (blaster) {
60
+ blaster.destroy();
61
+ }
62
+
63
+ blaster = new Blaster(
64
+ update.view.scrollDOM,
65
+ defaultsDeep(
66
+ {
67
+ particleGravity: 0.2,
68
+ particleShrinkRate: 0.995,
69
+ color: () => [100 + Math.random() * 100, 0, 0],
70
+ },
71
+ options,
72
+ defaultOptions,
73
+ ),
74
+ );
75
+ blaster.initialize();
76
+ blaster.start(); // TODO(burdon): Stop/clean-up.
77
+ } else {
78
+ blaster.resize();
79
+ }
80
+
81
+ const current = update.state.selection.main.head;
82
+ if (current !== last) {
83
+ last = current;
84
+ // TODO(burdon): Null if end of line.
85
+ const { element, point } = getPoint(update.view, current - 1);
86
+ if (element && point) {
87
+ blaster.spawn({ element, point });
88
+ }
89
+ }
90
+ }),
91
+
92
+ keymap.of([
93
+ {
94
+ any: (_, event) => {
95
+ if (blaster) {
96
+ if (event.key === 'Enter' && event.shiftKey) {
97
+ blaster.shake({ time: 0.8 });
98
+ return true;
99
+ }
100
+ }
101
+
102
+ return false;
103
+ },
104
+ },
105
+ ]),
106
+ ];
107
+ };
108
+
109
+ //
110
+ // Blaster (no CM deps).
111
+ //
112
+
113
+ class Blaster {
114
+ private readonly _effect: Effect;
115
+
116
+ _canvas: HTMLCanvasElement | undefined;
117
+ _ctx: CanvasRenderingContext2D | undefined | null;
118
+
119
+ _running = false;
120
+ _lastTime: number | undefined;
121
+ _shakeTime = 0;
122
+ _shakeTimeMax = 0;
123
+
124
+ _particles: Particle[] = [];
125
+ _particlePointer = 0;
126
+
127
+ _lastPoint = { x: 0, y: 0 };
128
+
129
+ constructor(
130
+ private readonly _node: HTMLElement,
131
+ private readonly _options: BlastOptions,
132
+ ) {
133
+ this._effect = this._options.effect === 1 ? new Effect1(_options) : new Effect2(_options);
134
+ }
135
+
136
+ get node() {
137
+ return this._node;
138
+ }
139
+
140
+ initialize(): void {
141
+ // console.log('initialize');
142
+ invariant(!this._canvas && !this._ctx);
143
+
144
+ this._canvas = document.createElement('canvas');
145
+ this._canvas.id = 'code-blast-canvas';
146
+ this._canvas.style.position = 'absolute';
147
+ this._canvas.style.zIndex = '0';
148
+ this._canvas.style.pointerEvents = 'none';
149
+ // this._canvas.style.border = '2px dashed red';
150
+
151
+ this._ctx = this._canvas.getContext('2d');
152
+
153
+ const parent = this._node.parentElement?.parentElement;
154
+ parent?.appendChild(this._canvas);
155
+
156
+ this.resize();
157
+ }
158
+
159
+ destroy(): void {
160
+ this.stop();
161
+ // console.log('destroy');
162
+ if (this._canvas) {
163
+ this._canvas.remove();
164
+ this._canvas = undefined;
165
+ this._ctx = undefined;
166
+ }
167
+ }
168
+
169
+ resize(): void {
170
+ if (this._node.parentElement && this._canvas) {
171
+ const { offsetLeft: x, offsetTop: y, offsetWidth: width, offsetHeight: height } = this._node.parentElement;
172
+ this._canvas.style.top = `${y}px`;
173
+ this._canvas.style.left = `${x}px`;
174
+ this._canvas.width = width;
175
+ this._canvas.height = height;
176
+ }
177
+ }
178
+
179
+ start(): void {
180
+ // console.log('start');
181
+ invariant(this._canvas && this._ctx);
182
+ this._running = true;
183
+ this.loop();
184
+ }
185
+
186
+ stop(): void {
187
+ // console.log('stop');
188
+ this._running = false;
189
+ this._node.style.transform = 'translate(0px, 0px)';
190
+ }
191
+
192
+ loop(): void {
193
+ if (!this._running || !this._canvas || !this._ctx) {
194
+ return;
195
+ }
196
+
197
+ this._ctx.clearRect(0, 0, this._canvas.width ?? 0, this._canvas.height ?? 0);
198
+
199
+ // Calculate the delta from the previous frame.
200
+ const now = new Date().getTime();
201
+ this._lastTime ??= now;
202
+ const dt = (now - this._lastTime) / 1000;
203
+ this._lastTime = now;
204
+
205
+ if (this._shakeTime > 0) {
206
+ this._shakeTime -= dt;
207
+ const magnitude = (this._shakeTime / this._shakeTimeMax) * this._options.shakeIntensity;
208
+ const shakeX = random(-magnitude, magnitude);
209
+ const shakeY = random(-magnitude, magnitude);
210
+ this._node!.style.transform = `translate(${shakeX}px,${shakeY}px)`;
211
+ }
212
+
213
+ this.drawParticles();
214
+
215
+ requestAnimationFrame(this.loop.bind(this));
216
+ }
217
+
218
+ shake = throttle(({ time }: { time: number }) => {
219
+ this._shakeTime = this._shakeTimeMax || time;
220
+ this._shakeTimeMax = time;
221
+ }, 100);
222
+
223
+ spawn = throttle(({ element, point }: { element: Element; point: { x: number; y: number } }) => {
224
+ const color = getRGBComponents(element, this._options.color);
225
+ const numParticles = random(this._options.particleNumRange.min, this._options.particleNumRange.max);
226
+ const dir = this._lastPoint.x === point.x ? 0 : this._lastPoint.x < point.x ? 1 : -1;
227
+ this._lastPoint = point;
228
+ for (let i = numParticles; i--; i > 0) {
229
+ this._particles[this._particlePointer] = this._effect.create(point.x - dir * 16, point.y, color);
230
+ this._particlePointer = (this._particlePointer + 1) % this._options.maxParticles;
231
+ }
232
+ }, 100);
233
+
234
+ drawParticles(): void {
235
+ for (let i = this._particles.length; i--; i > 0) {
236
+ const particle = this._particles[i];
237
+ if (!particle) {
238
+ continue;
239
+ }
240
+
241
+ if (particle.alpha < 0.01 || particle.size <= 0.5) {
242
+ continue;
243
+ }
244
+
245
+ this._effect.update(this._ctx!, particle);
246
+ }
247
+ }
248
+ }
249
+
250
+ //
251
+ // Effects
252
+ //
253
+
254
+ type Particle = {
255
+ x: number;
256
+ y: number;
257
+ vx: number;
258
+ vy: number;
259
+ size: number;
260
+ color: (string | number)[];
261
+ alpha: number;
262
+ theta?: number;
263
+ drag?: number;
264
+ wander?: number;
265
+ };
266
+
267
+ abstract class Effect {
268
+ constructor(protected readonly _options: BlastOptions) {}
269
+ abstract create(x: number, y: number, color: Particle['color']): Particle;
270
+ abstract update(ctx: CanvasRenderingContext2D, particle: Particle): void;
271
+ }
272
+
273
+ class Effect1 extends Effect {
274
+ create(x: number, y: number, color: Particle['color']) {
275
+ return {
276
+ x,
277
+ y: y + 10,
278
+ vx: random(this._options.particleVelocityRange.x[0], this._options.particleVelocityRange.x[1]),
279
+ vy: random(this._options.particleVelocityRange.y[0], this._options.particleVelocityRange.y[1]),
280
+ size: random(this._options.particleSize.min, this._options.particleSize.max),
281
+ color,
282
+ alpha: 1,
283
+ };
284
+ }
285
+
286
+ update(ctx: CanvasRenderingContext2D, particle: Particle): void {
287
+ particle.vy += this._options.particleGravity;
288
+ particle.x += particle.vx;
289
+ particle.y += particle.vy;
290
+ particle.alpha *= this._options.particleAlphaFadeout;
291
+
292
+ ctx.fillStyle = `rgba(${particle.color[0]},${particle.color[1]},${particle.color[2]},${particle.alpha})`;
293
+ ctx.fillRect(Math.round(particle.x - 1), Math.round(particle.y - 1), particle.size, particle.size);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Based on Soulwire's demo.
299
+ * http://codepen.io/soulwire/pen/foktm
300
+ */
301
+ class Effect2 extends Effect {
302
+ create(x: number, y: number, color: Particle['color']) {
303
+ return {
304
+ x,
305
+ y: y + 10,
306
+ vx: random(this._options.particleVelocityRange.x[0], this._options.particleVelocityRange.x[1]),
307
+ vy: random(this._options.particleVelocityRange.y[0], this._options.particleVelocityRange.y[1]),
308
+ size: random(this._options.particleSize.min, this._options.particleSize.max),
309
+ color,
310
+ alpha: 1,
311
+ theta: (random(0, 360) * Math.PI) / 180,
312
+ drag: 0.92,
313
+ wander: 0.15,
314
+ };
315
+ }
316
+
317
+ update(ctx: CanvasRenderingContext2D, particle: Particle): void {
318
+ particle.vy += this._options.particleGravity;
319
+ particle.x += particle.vx;
320
+ particle.y += particle.vy;
321
+ particle.vx *= particle.drag!;
322
+ particle.vy *= particle.drag!;
323
+ particle.theta! += random(-0.5, 0.5);
324
+ particle.vx += Math.sin(particle.theta!) * 0.1;
325
+ particle.vy += Math.cos(particle.theta!) * 0.1;
326
+ particle.size *= this._options.particleShrinkRate;
327
+ particle.alpha *= this._options.particleAlphaFadeout;
328
+
329
+ ctx.fillStyle = `rgba(${particle.color[0]},${particle.color[1]},${particle.color[2]},${particle.alpha})`;
330
+ ctx.beginPath();
331
+ ctx.arc(Math.round(particle.x - 1), Math.round(particle.y - 1), particle.size, 0, 2 * Math.PI);
332
+ ctx.fill();
333
+ }
334
+ }
335
+
336
+ //
337
+ // Utils
338
+ //
339
+
340
+ const getRGBComponents = (node: Element, color: BlastOptions['color']): Particle['color'] => {
341
+ if (typeof color === 'function') {
342
+ return color();
343
+ }
344
+
345
+ const bgColor = getComputedStyle(node).color;
346
+ if (bgColor) {
347
+ const x = bgColor.match(/(\d+), (\d+), (\d+)/)?.slice(1);
348
+ if (x) {
349
+ return x;
350
+ }
351
+ }
352
+
353
+ return [50, 50, 50];
354
+ };
355
+
356
+ const random = (min: number, max: number) => {
357
+ if (!max) {
358
+ max = min;
359
+ min = 0;
360
+ }
361
+
362
+ return min + ~~(Math.random() * (max - min + 1));
363
+ };
@@ -0,0 +1,131 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { RangeSetBuilder } from '@codemirror/state';
6
+ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
7
+
8
+ import { mx } from '@dxos/ui-theme';
9
+
10
+ const paragraphBlockPlugin = ViewPlugin.fromClass(
11
+ class {
12
+ decorations: DecorationSet;
13
+
14
+ constructor(view: EditorView) {
15
+ this.decorations = this.build(view);
16
+ }
17
+
18
+ update(update: ViewUpdate) {
19
+ if (update.docChanged || update.viewportChanged) {
20
+ this.decorations = this.build(update.view);
21
+ }
22
+ }
23
+
24
+ build({ state }: EditorView) {
25
+ const builder = new RangeSetBuilder<Decoration>();
26
+
27
+ // Helper: commit a block from blockStart to endLine (inclusive).
28
+ const pushBlock = (fromLine: number, toLine: number) => {
29
+ // Add line decorations for each line in the block.
30
+ for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
31
+ const line = state.doc.line(lineNum);
32
+ builder.add(
33
+ line.from,
34
+ line.from,
35
+ Decoration.line({
36
+ class: mx(
37
+ 'block-line',
38
+ fromLine === toLine && 'block-single',
39
+ lineNum === fromLine && 'block-first',
40
+ lineNum > fromLine && lineNum < toLine && 'block-middle',
41
+ lineNum === toLine && 'block-last',
42
+ ),
43
+ }),
44
+ );
45
+ }
46
+ };
47
+
48
+ let blockStart: number | null = null;
49
+ let consecutiveBlankLines = 0;
50
+ const totalLines = state.doc.lines;
51
+ for (let i = 1; i <= totalLines; i++) {
52
+ const line = state.doc.line(i);
53
+ const isBlank = /^\s*$/.test(line.text);
54
+
55
+ if (!isBlank) {
56
+ // Reset blank line counter.
57
+ consecutiveBlankLines = 0;
58
+ // Start a new block if we're not already in one.
59
+ if (blockStart === null) {
60
+ blockStart = i;
61
+ }
62
+ } else {
63
+ // Increment blank line counter.
64
+ consecutiveBlankLines++;
65
+
66
+ // End the current block if we have 2+ consecutive blank lines.
67
+ if (consecutiveBlankLines >= 2 && blockStart !== null) {
68
+ pushBlock(blockStart, i - consecutiveBlankLines);
69
+ blockStart = null;
70
+ }
71
+ }
72
+ }
73
+
74
+ // Handle any remaining block at the end of the document.
75
+ if (blockStart !== null) {
76
+ // Find the last non-blank line for the block end.
77
+ let lastNonBlankLine = totalLines;
78
+ while (lastNonBlankLine >= blockStart) {
79
+ const line = state.doc.line(lastNonBlankLine);
80
+ if (!/^\s*$/.test(line.text)) {
81
+ break;
82
+ }
83
+ lastNonBlankLine--;
84
+ }
85
+ if (lastNonBlankLine >= blockStart) {
86
+ pushBlock(blockStart, lastNonBlankLine);
87
+ }
88
+ }
89
+
90
+ return builder.finish();
91
+ }
92
+ },
93
+ {
94
+ decorations: (v) => v.decorations,
95
+ },
96
+ );
97
+
98
+ export const blocks = () => [
99
+ paragraphBlockPlugin,
100
+ EditorView.baseTheme({
101
+ '.cm-line.block-line': {
102
+ paddingLeft: '0.75rem',
103
+ paddingRight: '0.75rem',
104
+ borderLeft: '1px solid var(--dx-subduedSeparator)',
105
+ borderRight: '1px solid var(--dx-subduedSeparator)',
106
+ },
107
+ '.cm-line.block-single': {
108
+ border: '1px solid var(--dx-subduedSeparator)',
109
+ borderRadius: '6px',
110
+ paddingTop: '0.5rem',
111
+ paddingBottom: '0.5rem',
112
+ marginTop: '0.5rem',
113
+ marginBottom: '0.5rem',
114
+ },
115
+ '.cm-line.block-first': {
116
+ borderTop: '1px solid var(--dx-subduedSeparator)',
117
+ borderTopLeftRadius: '6px',
118
+ borderTopRightRadius: '6px',
119
+ paddingTop: '0.5rem',
120
+ marginTop: '0.5rem',
121
+ },
122
+ '.cm-line.block-middle': {},
123
+ '.cm-line.block-last': {
124
+ borderBottom: '1px solid var(--dx-subduedSeparator)',
125
+ borderBottomLeftRadius: '6px',
126
+ borderBottomRightRadius: '6px',
127
+ paddingBottom: '0.5rem',
128
+ marginBottom: '0.5rem',
129
+ },
130
+ }),
131
+ ];
@@ -0,0 +1,77 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension, Prec, StateEffect, StateField } from '@codemirror/state';
6
+ import { keymap } from '@codemirror/view';
7
+
8
+ import { log } from '@dxos/log';
9
+
10
+ type Bookmark = {
11
+ id: string;
12
+ pos: number;
13
+ name: string;
14
+ };
15
+
16
+ export const addBookmark = StateEffect.define<Bookmark>();
17
+ export const removeBookmark = StateEffect.define<string>();
18
+ export const clearBookmarks = StateEffect.define<void>();
19
+
20
+ export const bookmarks = (): Extension => {
21
+ return [
22
+ bookmarksField,
23
+ Prec.highest(
24
+ keymap.of([
25
+ {
26
+ key: 'Mod-ArrowUp',
27
+ run: (view) => {
28
+ const bookmarks = view.state.field(bookmarksField);
29
+ log('up', bookmarks);
30
+ return true;
31
+ },
32
+ },
33
+ {
34
+ key: 'Mod-ArrowDown',
35
+ run: (view) => {
36
+ const bookmarks = view.state.field(bookmarksField);
37
+ log('down', bookmarks);
38
+ return true;
39
+ },
40
+ },
41
+ ]),
42
+ ),
43
+ ];
44
+ };
45
+
46
+ type BookmarkFieldState = {
47
+ bookmarks: Bookmark[];
48
+ };
49
+
50
+ const bookmarksField = StateField.define<BookmarkFieldState>({
51
+ create: (): BookmarkFieldState => ({
52
+ bookmarks: [],
53
+ }),
54
+
55
+ update: (value: BookmarkFieldState, tr): BookmarkFieldState => {
56
+ // Map bookmark positions through document changes.
57
+ let bookmarks = value.bookmarks.map((bookmark) => ({
58
+ ...bookmark,
59
+ pos: tr.changes.mapPos(bookmark.pos),
60
+ }));
61
+
62
+ // Process effects.
63
+ for (const effect of tr.effects) {
64
+ if (effect.is(addBookmark)) {
65
+ bookmarks = [...bookmarks, effect.value];
66
+ } else if (effect.is(removeBookmark)) {
67
+ bookmarks = bookmarks.filter((b) => b.id !== effect.value);
68
+ } else if (effect.is(clearBookmarks)) {
69
+ bookmarks = [];
70
+ }
71
+ }
72
+
73
+ // Sort bookmarks by position.
74
+ bookmarks.sort(({ pos: a }, { pos: b }) => a - b);
75
+ return { bookmarks };
76
+ },
77
+ });