collabmd 0.1.20 → 0.1.21

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 (43) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/public/assets/css/style.css +1 -1
  4. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  5. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
  6. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  7. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  8. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
  9. package/public/assets/js/excalidraw-editor.js +1 -1
  10. package/public/assets/js/main.js +68 -68
  11. package/public/assets/js/preview-render-worker.js +15 -15
  12. package/src/client/application/app-shell/git-feature.js +57 -18
  13. package/src/client/application/app-shell/ui-feature.js +4 -0
  14. package/src/client/bootstrap/collabmd-app-shell.js +12 -0
  15. package/src/client/domain/vault-utils.js +2 -2
  16. package/src/client/excalidraw-editor.js +2 -1
  17. package/src/client/infrastructure/editor-session.js +4 -0
  18. package/src/client/infrastructure/editor-view-adapter.js +181 -1
  19. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  20. package/src/client/presentation/comment-ui-controller.js +192 -5
  21. package/src/client/presentation/file-explorer-controller.js +15 -3
  22. package/src/client/presentation/file-explorer-view.js +152 -11
  23. package/src/client/styles/style.css +46 -2
  24. package/src/domain/wiki-link-resolver.js +16 -5
  25. package/src/domain/workspace-change.js +68 -0
  26. package/src/domain/workspace-room.js +3 -0
  27. package/src/server/create-app-server.js +41 -10
  28. package/src/server/domain/backlink-index.js +94 -1
  29. package/src/server/domain/collaboration/collaboration-room.js +191 -22
  30. package/src/server/domain/collaboration/room-registry.js +64 -10
  31. package/src/server/infrastructure/git/responses.js +12 -19
  32. package/src/server/infrastructure/http/create-git-api-command-handler.js +53 -43
  33. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  34. package/src/server/infrastructure/http/create-request-handler.js +7 -0
  35. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  36. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  37. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  38. package/src/server/infrastructure/http/http-response.js +65 -28
  39. package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
  40. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  41. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  42. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  43. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +0 -22
@@ -1,14 +1,16 @@
1
+ import { createWorkspaceChange } from '../../../domain/workspace-change.js';
1
2
  import { resolveApiUrl } from '../../domain/runtime-paths.js';
2
3
 
3
4
  function normalizeWorkspaceChange(workspaceChange = {}) {
4
- return {
5
- changedPaths: Array.isArray(workspaceChange.changedPaths) ? workspaceChange.changedPaths.filter(Boolean) : [],
6
- deletedPaths: Array.isArray(workspaceChange.deletedPaths) ? workspaceChange.deletedPaths.filter(Boolean) : [],
7
- refreshExplorer: workspaceChange.refreshExplorer !== false,
8
- renamedPaths: Array.isArray(workspaceChange.renamedPaths)
9
- ? workspaceChange.renamedPaths.filter((entry) => entry?.oldPath && entry?.newPath)
10
- : [],
11
- };
5
+ return createWorkspaceChange(workspaceChange);
6
+ }
7
+
8
+ function createWorkspaceRequestId() {
9
+ if (globalThis.crypto?.randomUUID) {
10
+ return globalThis.crypto.randomUUID();
11
+ }
12
+
13
+ return `workspace-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
12
14
  }
13
15
 
14
16
  export const gitFeature = {
@@ -94,15 +96,19 @@ export const gitFeature = {
94
96
  },
95
97
 
96
98
  async postGitAction(endpoint, payload) {
99
+ const requestId = createWorkspaceRequestId();
100
+ this.pendingWorkspaceRequestIds?.add(requestId);
97
101
  const response = await fetch(endpoint, {
98
102
  body: JSON.stringify(payload),
99
103
  headers: {
100
104
  'Content-Type': 'application/json',
105
+ 'X-CollabMD-Request-Id': requestId,
101
106
  },
102
107
  method: 'POST',
103
108
  });
104
109
  const data = await response.json();
105
110
  if (!response.ok) {
111
+ this.pendingWorkspaceRequestIds?.delete(requestId);
106
112
  const error = new Error(data.error || 'Git action failed');
107
113
  if (typeof data?.code === 'string') {
108
114
  error.code = data.code;
@@ -145,11 +151,6 @@ export const gitFeature = {
145
151
  const workspaceChange = normalizeWorkspaceChange(result.workspaceChange);
146
152
  await this.refreshWorkspaceAfterGitAction({ filePath, preferredScope });
147
153
  this.handleWorkspaceChangeForCurrentFile(workspaceChange, { action, local: true, showToast: showLocalFileToast });
148
- this.lobby.sendWorkspaceEvent({
149
- action,
150
- sourceRef: result.sourceRef ?? null,
151
- workspaceChange,
152
- });
153
154
  return workspaceChange;
154
155
  },
155
156
 
@@ -165,21 +166,27 @@ export const gitFeature = {
165
166
  return false;
166
167
  }
167
168
 
168
- this.navigation.navigateToFile(null);
169
169
  if (!showToast) {
170
+ if (renameEntry) {
171
+ this.navigation.navigateToFile(renameEntry.newPath);
172
+ } else {
173
+ this.navigation.navigateToFile(null);
174
+ }
170
175
  return true;
171
176
  }
172
177
 
173
178
  const displayName = this.getDisplayName(currentFilePath);
174
179
  if (renameEntry) {
180
+ this.navigation.navigateToFile(renameEntry.newPath);
175
181
  this.toastController.show(
176
182
  local
177
- ? `${displayName} was reset away from this branch path`
178
- : `${displayName} moved after a ${action} operation`,
183
+ ? `${displayName} moved to ${this.getDisplayName(renameEntry.newPath)}`
184
+ : `${displayName} moved on disk`,
179
185
  );
180
186
  return true;
181
187
  }
182
188
 
189
+ this.navigation.navigateToFile(null);
183
190
  this.toastController.show(
184
191
  local
185
192
  ? `${displayName} was removed by ${action}`
@@ -193,13 +200,45 @@ export const gitFeature = {
193
200
  return;
194
201
  }
195
202
 
203
+ if (event.requestId && this.pendingWorkspaceRequestIds?.has(event.requestId)) {
204
+ this.pendingWorkspaceRequestIds.delete(event.requestId);
205
+ return;
206
+ }
207
+
196
208
  const workspaceChange = normalizeWorkspaceChange(event.workspaceChange);
197
- await this.refreshWorkspaceAfterGitAction();
209
+ if (event.origin === 'git') {
210
+ await this.refreshGitAfterAction();
211
+ }
198
212
  this.handleWorkspaceChangeForCurrentFile(workspaceChange, {
199
- action: event.action || 'git',
213
+ action: event.action || event.origin || 'workspace',
200
214
  local: false,
201
215
  showToast: true,
202
216
  });
217
+
218
+ if (
219
+ event.origin === 'filesystem'
220
+ && this.currentFilePath
221
+ && workspaceChange.changedPaths.includes(this.currentFilePath)
222
+ ) {
223
+ const canUseInlineCue = workspaceChange.changedPaths.length === 1;
224
+ const highlightRange = canUseInlineCue && Array.isArray(event.highlightRanges)
225
+ ? event.highlightRanges.find((entry) => entry.path === this.currentFilePath)
226
+ : null;
227
+ const didFlash = highlightRange
228
+ ? this.session?.flashExternalUpdate?.(highlightRange)
229
+ : false;
230
+ if (!didFlash) {
231
+ this.toastController.show(`${this.getDisplayName(this.currentFilePath)} updated from disk`);
232
+ }
233
+ }
234
+
235
+ if (
236
+ this.currentFilePath
237
+ && Array.isArray(event.reloadRequiredPaths)
238
+ && event.reloadRequiredPaths.includes(this.currentFilePath)
239
+ ) {
240
+ this.toastController.show(`${this.getDisplayName(this.currentFilePath)} needs a manual reload`);
241
+ }
203
242
  },
204
243
 
205
244
  async stageGitFile(filePath, { scope = 'working-tree' } = {}) {
@@ -534,6 +534,9 @@ export const uiFeature = {
534
534
  if (!this.lobby.provider) {
535
535
  this.lobby.connect();
536
536
  }
537
+ if (!this.workspaceSync.provider) {
538
+ this.workspaceSync.connect();
539
+ }
537
540
 
538
541
  if (wasInactive) {
539
542
  if (this.fileExplorerReady) {
@@ -554,6 +557,7 @@ export const uiFeature = {
554
557
  this.elements.displayNameDialog.close();
555
558
  }
556
559
  this.lobby.disconnect();
560
+ this.workspaceSync.disconnect();
557
561
  this.globalUsers = [];
558
562
  this.chatMessages = [];
559
563
  this.chatMessageIds.clear();
@@ -18,6 +18,7 @@ import { BrowserPreferencesPort } from '../infrastructure/browser-preferences-po
18
18
  import { getRuntimeConfig } from '../infrastructure/runtime-config.js';
19
19
  import { TabActivityLock } from '../infrastructure/tab-activity-lock.js';
20
20
  import { vaultApiClient } from '../infrastructure/vault-api-client.js';
21
+ import { WorkspaceSyncClient } from '../infrastructure/workspace-sync-client.js';
21
22
  import { BacklinksPanel } from '../presentation/backlinks-panel.js';
22
23
  import { CommentUiController } from '../presentation/comment-ui-controller.js';
23
24
  import { ExcalidrawEmbedController } from '../presentation/excalidraw-embed-controller.js';
@@ -111,11 +112,22 @@ export class CollabMdAppShell {
111
112
  this.quickSwitcherModulePromise = null;
112
113
  this.fileExplorerReadyPromise = Promise.resolve();
113
114
  this.mobileBreakpointQuery = window.matchMedia('(max-width: 768px)');
115
+ this.pendingWorkspaceRequestIds = new Set();
114
116
 
115
117
  this.lobby = new LobbyPresence({
116
118
  preferredUserName: this.getStoredUserName(),
117
119
  onChange: (users) => this.updateGlobalUsers(users),
118
120
  onChatChange: (messages, meta) => this.updateChatMessages(messages, meta),
121
+ });
122
+ this.workspaceSync = new WorkspaceSyncClient({
123
+ onTreeChange: (tree, metadata = {}) => {
124
+ const wasReady = this.fileExplorerReady;
125
+ this.fileExplorer.setTree(tree, metadata);
126
+ this.fileExplorerReady = true;
127
+ if (!wasReady && this.isTabActive) {
128
+ void this.handleHashChange();
129
+ }
130
+ },
119
131
  onWorkspaceEvent: (event) => {
120
132
  void this.handleIncomingWorkspaceEvent(event);
121
133
  },
@@ -25,8 +25,8 @@ export function escapeHtml(text) {
25
25
  *
26
26
  * Matching rules (in order):
27
27
  * 1. Exact path match (with .md appended if missing)
28
- * 2. Filename match at any directory depth
29
- * 3. Path without .md extension matches the target
28
+ * 2. Exact path without .md extension matches the target
29
+ * 3. Filename/path suffix match at any directory depth
30
30
  *
31
31
  * @param {string} target — the raw wiki-link target text
32
32
  * @param {string[]} files — list of vault file paths
@@ -149,6 +149,7 @@ if (isTestMode) {
149
149
  && collabReady
150
150
  && roomClient.canWriteToRoom === true
151
151
  && roomClient.waitingForAuthoritativeSync === false
152
+ && roomClient.isApplyingSharedSnapshot() === false
152
153
  ),
153
154
  isReady: () => collabReady && Boolean(excalidrawAPI) && Boolean(getNativeHistoryButton('undo')) && Boolean(getNativeHistoryButton('redo')),
154
155
  redoShared: () => triggerNativeHistory('redo'),
@@ -506,7 +507,7 @@ window.addEventListener('message', (event) => {
506
507
  });
507
508
 
508
509
  function scheduleSyncToRoom(elements, appState, files) {
509
- if (!collabReady || suppressOnChange || roomClient.isApplyingSharedSnapshot()) {
510
+ if (!collabReady || suppressOnChange) {
510
511
  return;
511
512
  }
512
513
 
@@ -229,6 +229,10 @@ export class EditorSession {
229
229
  return this.viewAdapter.insertText(text);
230
230
  }
231
231
 
232
+ flashExternalUpdate(range) {
233
+ return this.viewAdapter.flashRemoteRange(range);
234
+ }
235
+
232
236
  waitForInitialSync(timeoutMs = 1500) {
233
237
  return this.collaborationClient.waitForInitialSync(timeoutMs);
234
238
  }
@@ -11,11 +11,12 @@ import {
11
11
  } from '@codemirror/language';
12
12
  import { languages } from '@codemirror/language-data';
13
13
  import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
14
- import { Compartment, EditorSelection, EditorState, Prec } from '@codemirror/state';
14
+ import { Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField } from '@codemirror/state';
15
15
  import { oneDark } from '@codemirror/theme-one-dark';
16
16
  import {
17
17
  Decoration,
18
18
  EditorView,
19
+ WidgetType,
19
20
  crosshairCursor,
20
21
  drawSelection,
21
22
  highlightActiveLine,
@@ -36,6 +37,59 @@ import { handleImagePasteEvent } from './editor-paste-utils.js';
36
37
  const markdownCodeLanguages = [...languages, plantUmlLanguageDescription];
37
38
  const pairedMatchingBracketMark = Decoration.mark({ class: 'cm-matchingBracket cm-matchingBracket-paired' });
38
39
  const nonmatchingBracketMark = Decoration.mark({ class: 'cm-nonmatchingBracket' });
40
+ const remoteUpdateMark = Decoration.mark({ class: 'cm-remoteUpdateFlash' });
41
+ const REMOTE_UPDATE_FLASH_DURATION_MS = 1350;
42
+ const REMOTE_UPDATE_CARET_MAX_LENGTH = 160;
43
+ const RECENT_LOCAL_INPUT_WINDOW_MS = 900;
44
+
45
+ class RemoteUpdateCaretWidget extends WidgetType {
46
+ toDOM() {
47
+ const element = document.createElement('span');
48
+ element.className = 'cm-remoteUpdateCaretWidget';
49
+ element.setAttribute('aria-hidden', 'true');
50
+ return element;
51
+ }
52
+
53
+ ignoreEvent() {
54
+ return true;
55
+ }
56
+ }
57
+
58
+ const addRemoteUpdateFlashEffect = StateEffect.define();
59
+ const clearRemoteUpdateFlashEffect = StateEffect.define();
60
+ const remoteUpdateFlashField = StateField.define({
61
+ create: () => Decoration.none,
62
+ update(decorations, transaction) {
63
+ let nextDecorations = decorations.map(transaction.changes);
64
+
65
+ transaction.effects.forEach((effect) => {
66
+ if (effect.is(clearRemoteUpdateFlashEffect)) {
67
+ nextDecorations = Decoration.none;
68
+ return;
69
+ }
70
+
71
+ if (effect.is(addRemoteUpdateFlashEffect)) {
72
+ const { from, showCaret, to } = effect.value;
73
+ const nextRanges = [];
74
+ if (to > from) {
75
+ nextRanges.push(remoteUpdateMark.range(from, to));
76
+ }
77
+ if (showCaret) {
78
+ nextRanges.push(Decoration.widget({
79
+ side: -1,
80
+ widget: new RemoteUpdateCaretWidget(),
81
+ }).range(from));
82
+ }
83
+ nextDecorations = nextRanges.length > 0
84
+ ? Decoration.set(nextRanges, true)
85
+ : Decoration.none;
86
+ }
87
+ });
88
+
89
+ return nextDecorations;
90
+ },
91
+ provide: (field) => EditorView.decorations.from(field),
92
+ });
39
93
 
40
94
  function isBracketBeforeCaret(range, state) {
41
95
  return state.selection.ranges.some((selectionRange) =>
@@ -150,6 +204,35 @@ function createEditorTheme(theme) {
150
204
  '.cm-selectionMatch': {
151
205
  backgroundColor: 'var(--color-primary-highlight)',
152
206
  },
207
+ '.cm-remoteUpdateFlash': {
208
+ animation: 'collabmd-remote-update-flash 1.35s ease-out',
209
+ backgroundColor: theme === 'dark'
210
+ ? 'oklch(from var(--color-primary) l c h / 0.22)'
211
+ : 'oklch(from var(--color-primary) l c h / 0.16)',
212
+ borderRadius: '3px',
213
+ boxShadow: `0 0 0 1px ${theme === 'dark'
214
+ ? 'oklch(from var(--color-primary) calc(l + 0.05) c h / 0.26)'
215
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.2)'}`,
216
+ },
217
+ '.cm-remoteUpdateCaretWidget': {
218
+ animation: 'collabmd-remote-update-caret 1s ease-out',
219
+ backgroundColor: 'transparent',
220
+ borderLeft: `2px solid ${theme === 'dark'
221
+ ? 'color-mix(in oklab, var(--color-primary) 78%, white)'
222
+ : 'color-mix(in oklab, var(--color-primary) 84%, black)'}`,
223
+ borderRadius: '999px',
224
+ boxShadow: `0 0 0 1px ${theme === 'dark'
225
+ ? 'oklch(from var(--color-primary) calc(l + 0.06) c h / 0.22)'
226
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.16)'}`,
227
+ display: 'inline-block',
228
+ height: '1.2em',
229
+ marginLeft: '-1px',
230
+ marginRight: '-1px',
231
+ pointerEvents: 'none',
232
+ transformOrigin: 'center bottom',
233
+ verticalAlign: 'text-bottom',
234
+ width: '0',
235
+ },
153
236
  '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
154
237
  backgroundColor: selectionBackground,
155
238
  },
@@ -157,6 +240,42 @@ function createEditorTheme(theme) {
157
240
  border: `1px solid ${selectionBorder}`,
158
241
  borderRadius: '2px',
159
242
  },
243
+ '@keyframes collabmd-remote-update-flash': {
244
+ '0%': {
245
+ backgroundColor: theme === 'dark'
246
+ ? 'oklch(from var(--color-primary) l c h / 0.34)'
247
+ : 'oklch(from var(--color-primary) l c h / 0.26)',
248
+ },
249
+ '100%': {
250
+ backgroundColor: theme === 'dark'
251
+ ? 'oklch(from var(--color-primary) l c h / 0)'
252
+ : 'oklch(from var(--color-primary) l c h / 0)',
253
+ },
254
+ },
255
+ '@keyframes collabmd-remote-update-caret': {
256
+ '0%': {
257
+ boxShadow: `0 0 0 1px ${theme === 'dark'
258
+ ? 'oklch(from var(--color-primary) calc(l + 0.08) c h / 0.34)'
259
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.24)'}`,
260
+ opacity: '0',
261
+ transform: 'translateY(0.2em) scaleY(0.7)',
262
+ },
263
+ '18%': {
264
+ opacity: '1',
265
+ transform: 'translateY(0) scaleY(1)',
266
+ },
267
+ '72%': {
268
+ opacity: '1',
269
+ transform: 'translateY(0) scaleY(1)',
270
+ },
271
+ '100%': {
272
+ boxShadow: `0 0 0 1px ${theme === 'dark'
273
+ ? 'oklch(from var(--color-primary) calc(l + 0.08) c h / 0)'
274
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0)'}`,
275
+ opacity: '0',
276
+ transform: 'translateY(-0.05em) scaleY(0.9)',
277
+ },
278
+ },
160
279
  }, { dark: theme === 'dark' });
161
280
  }
162
281
 
@@ -204,6 +323,8 @@ export class EditorViewAdapter {
204
323
  this.syntaxThemeCompartment = new Compartment();
205
324
  this.lineWrappingCompartment = new Compartment();
206
325
  this.viewportFrame = 0;
326
+ this.remoteUpdateFlashTimer = 0;
327
+ this.lastLocalInputAt = 0;
207
328
  this.handleScroll = () => {
208
329
  if (this.viewportFrame) {
209
330
  return;
@@ -214,6 +335,9 @@ export class EditorViewAdapter {
214
335
  this.emitViewportChange();
215
336
  });
216
337
  };
338
+ this.handleLocalInputActivity = () => {
339
+ this.lastLocalInputAt = Date.now();
340
+ };
217
341
  }
218
342
 
219
343
  initialize({ awareness, filePath, undoManager, ytext }) {
@@ -276,6 +400,7 @@ export class EditorViewAdapter {
276
400
  this.themeCompartment.of(createEditorTheme(this.initialTheme)),
277
401
  this.syntaxThemeCompartment.of(this.initialTheme === 'dark' ? oneDark : []),
278
402
  this.lineWrappingCompartment.of(this.lineWrappingEnabled ? EditorView.lineWrapping : []),
403
+ remoteUpdateFlashField,
279
404
  yCollab(ytext, awareness, { undoManager }),
280
405
  updateListener,
281
406
  ],
@@ -283,6 +408,10 @@ export class EditorViewAdapter {
283
408
  });
284
409
 
285
410
  this.editorView.scrollDOM.addEventListener('scroll', this.handleScroll, { passive: true });
411
+ this.editorView.contentDOM.addEventListener('beforeinput', this.handleLocalInputActivity);
412
+ this.editorView.contentDOM.addEventListener('keydown', this.handleLocalInputActivity);
413
+ this.editorView.contentDOM.addEventListener('paste', this.handleLocalInputActivity);
414
+ this.editorView.contentDOM.addEventListener('compositionstart', this.handleLocalInputActivity);
286
415
  this.updateCursorInfo(this.editorView.state);
287
416
  this.onSelectionChanged?.(this.editorView.state);
288
417
  this.emitViewportChange();
@@ -293,6 +422,14 @@ export class EditorViewAdapter {
293
422
  cancelAnimationFrame(this.viewportFrame);
294
423
  this.viewportFrame = 0;
295
424
  }
425
+ if (this.remoteUpdateFlashTimer) {
426
+ clearTimeout(this.remoteUpdateFlashTimer);
427
+ this.remoteUpdateFlashTimer = 0;
428
+ }
429
+ this.editorView?.contentDOM?.removeEventListener('beforeinput', this.handleLocalInputActivity);
430
+ this.editorView?.contentDOM?.removeEventListener('keydown', this.handleLocalInputActivity);
431
+ this.editorView?.contentDOM?.removeEventListener('paste', this.handleLocalInputActivity);
432
+ this.editorView?.contentDOM?.removeEventListener('compositionstart', this.handleLocalInputActivity);
296
433
  this.editorView?.scrollDOM?.removeEventListener('scroll', this.handleScroll);
297
434
  this.editorView?.destroy();
298
435
  this.editorView = null;
@@ -355,6 +492,49 @@ export class EditorViewAdapter {
355
492
  this.editorView?.requestMeasure();
356
493
  }
357
494
 
495
+ flashRemoteRange({ from = 0, to = 0 } = {}, durationMs = REMOTE_UPDATE_FLASH_DURATION_MS) {
496
+ const state = this.editorView?.state;
497
+ if (!state || state.doc.length === 0) {
498
+ return false;
499
+ }
500
+
501
+ let start = Math.min(Math.max(Math.round(from), 0), state.doc.length);
502
+ let end = Math.min(Math.max(Math.round(to), 0), state.doc.length);
503
+ if (end <= start) {
504
+ if (start < state.doc.length) {
505
+ end = start + 1;
506
+ } else if (start > 0) {
507
+ start -= 1;
508
+ end = start + 1;
509
+ } else {
510
+ return false;
511
+ }
512
+ }
513
+
514
+ const rangeLength = Math.max(end - start, 0);
515
+ const showCaret = (
516
+ rangeLength <= REMOTE_UPDATE_CARET_MAX_LENGTH
517
+ && (Date.now() - this.lastLocalInputAt) > RECENT_LOCAL_INPUT_WINDOW_MS
518
+ );
519
+
520
+ this.editorView.dispatch({
521
+ effects: addRemoteUpdateFlashEffect.of({ from: start, showCaret, to: end }),
522
+ });
523
+
524
+ if (this.remoteUpdateFlashTimer) {
525
+ clearTimeout(this.remoteUpdateFlashTimer);
526
+ }
527
+
528
+ this.remoteUpdateFlashTimer = window.setTimeout(() => {
529
+ this.remoteUpdateFlashTimer = 0;
530
+ this.editorView?.dispatch({
531
+ effects: clearRemoteUpdateFlashEffect.of(null),
532
+ });
533
+ }, durationMs);
534
+
535
+ return true;
536
+ }
537
+
358
538
  getViewportState(viewportRatio = 0.35) {
359
539
  return {
360
540
  topLine: this.getTopVisibleLineNumber(viewportRatio),