vzcode 2.3.0 → 2.4.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/package.json +3 -2
- package/src/cli.ts +208 -0
- package/src/client/AIAssist/AIAssistCodeMirrorKeyMap.ts +42 -0
- package/src/client/AIAssist/AIAssistWidget/index.tsx +135 -0
- package/src/client/AIAssist/AIAssistWidget/style.scss +47 -0
- package/src/client/AIAssist/Spinner.jsx +76 -0
- package/src/client/AIAssist/formatFile.ts +27 -0
- package/src/client/AIAssist/generateFilesContext.ts +25 -0
- package/src/client/AIAssist/index.ts +2 -0
- package/src/client/AIAssist/startAIAssist.ts +95 -0
- package/src/client/App/index.tsx +152 -0
- package/src/client/App/style.scss +82 -0
- package/src/client/App/usePending.ts +51 -0
- package/src/client/App/useShareDB.ts +161 -0
- package/src/client/CodeEditor/Copilot/index.ts +74 -0
- package/src/client/CodeEditor/InteractiveWidgets/boolToggler.ts +12 -0
- package/src/client/CodeEditor/InteractiveWidgets/colorPicker.ts +68 -0
- package/src/client/CodeEditor/InteractiveWidgets/colorsInTextPlugin.ts +174 -0
- package/src/client/CodeEditor/InteractiveWidgets/highlightWidgets.ts +79 -0
- package/src/client/CodeEditor/InteractiveWidgets/index.ts +44 -0
- package/src/client/CodeEditor/InteractiveWidgets/markdownCheckboxToggler.ts +12 -0
- package/src/client/CodeEditor/InteractiveWidgets/numberDragger.ts +20 -0
- package/src/client/CodeEditor/InteractiveWidgets/rotateWidget.ts +143 -0
- package/src/client/CodeEditor/InteractiveWidgets/urlClicker.test.ts +48 -0
- package/src/client/CodeEditor/InteractiveWidgets/urlClicker.ts +12 -0
- package/src/client/CodeEditor/InteractiveWidgets/vec2Slider.ts +25 -0
- package/src/client/CodeEditor/basicSetup.ts +73 -0
- package/src/client/CodeEditor/getOrCreateEditor.tsx +615 -0
- package/src/client/CodeEditor/index.tsx +202 -0
- package/src/client/CodeEditor/json1PresenceBroadcast.ts +41 -0
- package/src/client/CodeEditor/json1PresenceDisplay.ts +350 -0
- package/src/client/CodeEditor/rainbowBrackets.ts +122 -0
- package/src/client/CodeEditor/style.scss +156 -0
- package/src/client/CodeEditor/typescriptExtension/worker.ts +32 -0
- package/src/client/CodeErrorOverlay/index.tsx +77 -0
- package/src/client/CodeErrorOverlay/style.scss +33 -0
- package/src/client/Fonts/fonts.ts +8 -0
- package/src/client/Icons/AdjustmentSVG.tsx +19 -0
- package/src/client/Icons/BugSVG.tsx +20 -0
- package/src/client/Icons/CloseSVG.tsx +19 -0
- package/src/client/Icons/CssSVG.tsx +16 -0
- package/src/client/Icons/DirectoryArrowSVG.tsx +19 -0
- package/src/client/Icons/EditSVG.tsx +19 -0
- package/src/client/Icons/FileSVG.tsx +19 -0
- package/src/client/Icons/FolderSVG.tsx +20 -0
- package/src/client/Icons/GearSVG.tsx +16 -0
- package/src/client/Icons/HtmlSVG.tsx +16 -0
- package/src/client/Icons/JavaScriptSVG.tsx +16 -0
- package/src/client/Icons/JsonSVG.tsx +16 -0
- package/src/client/Icons/MarkdownSVG.tsx +16 -0
- package/src/client/Icons/MicOffSVG.tsx +35 -0
- package/src/client/Icons/MicSVG.tsx +25 -0
- package/src/client/Icons/NewSVG.tsx +19 -0
- package/src/client/Icons/PinSVG.tsx +14 -0
- package/src/client/Icons/PlaySVG.tsx +16 -0
- package/src/client/Icons/QuestionMarkSVG.tsx +18 -0
- package/src/client/Icons/ReactSVG.tsx +16 -0
- package/src/client/Icons/SearchSVG.tsx +20 -0
- package/src/client/Icons/SparklesSVG.tsx +23 -0
- package/src/client/Icons/SplitEditorSVG.tsx +34 -0
- package/src/client/Icons/StopSVG.tsx +15 -0
- package/src/client/Icons/SvelteSVG.tsx +16 -0
- package/src/client/Icons/TrashSVG.tsx +19 -0
- package/src/client/Icons/TypeScriptSVG.tsx +16 -0
- package/src/client/Icons/index.tsx +26 -0
- package/src/client/ImageViewer/index.tsx +91 -0
- package/src/client/ImageViewer/style.scss +41 -0
- package/src/client/PresenceNotifications/PresenceNotifications.test.ts +134 -0
- package/src/client/PresenceNotifications/index.tsx +158 -0
- package/src/client/PresenceNotifications/style.scss +17 -0
- package/src/client/RunCodeWidget/index.tsx +117 -0
- package/src/client/RunCodeWidget/style.scss +24 -0
- package/src/client/SplitPaneResizeContext.tsx +159 -0
- package/src/client/TabList/Tab.tsx +73 -0
- package/src/client/TabList/index.tsx +35 -0
- package/src/client/TabList/style.scss +51 -0
- package/src/client/VZCodeContext/index.tsx +29 -0
- package/src/client/VZCodeContext/types.ts +248 -0
- package/src/client/VZCodeContext/useVZCodeState.ts +602 -0
- package/src/client/VZKeyboardShortcutsDoc.tsx +148 -0
- package/src/client/VZLeft.tsx +28 -0
- package/src/client/VZMiddle.tsx +172 -0
- package/src/client/VZResizer/index.tsx +128 -0
- package/src/client/VZResizer/styles.scss +33 -0
- package/src/client/VZRight.tsx +113 -0
- package/src/client/VZSettings.tsx +275 -0
- package/src/client/VZSidebar/AIChat/ChatInput.tsx +243 -0
- package/src/client/VZSidebar/AIChat/DiffView.scss +136 -0
- package/src/client/VZSidebar/AIChat/DiffView.tsx +176 -0
- package/src/client/VZSidebar/AIChat/Message.tsx +79 -0
- package/src/client/VZSidebar/AIChat/MessageList.tsx +190 -0
- package/src/client/VZSidebar/AIChat/StreamingMessage.tsx +35 -0
- package/src/client/VZSidebar/AIChat/ThinkingScratchpad.tsx +156 -0
- package/src/client/VZSidebar/AIChat/TypingIndicator.tsx +19 -0
- package/src/client/VZSidebar/AIChat/index.tsx +357 -0
- package/src/client/VZSidebar/AIChat/styles.scss +646 -0
- package/src/client/VZSidebar/AIChat/useSpeechRecognition.ts +117 -0
- package/src/client/VZSidebar/CreateDirModal.tsx +111 -0
- package/src/client/VZSidebar/CreateFileModal.tsx +111 -0
- package/src/client/VZSidebar/DeleteConfirmationModal.tsx +76 -0
- package/src/client/VZSidebar/DirectoryListing.tsx +89 -0
- package/src/client/VZSidebar/EmptyState.tsx +11 -0
- package/src/client/VZSidebar/FileListing.tsx +91 -0
- package/src/client/VZSidebar/FileTypeIcon.tsx +49 -0
- package/src/client/VZSidebar/Item.tsx +237 -0
- package/src/client/VZSidebar/Listing.tsx +47 -0
- package/src/client/VZSidebar/Search.tsx +527 -0
- package/src/client/VZSidebar/VisualEditor.tsx +282 -0
- package/src/client/VZSidebar/VoiceChatModal.tsx +121 -0
- package/src/client/VZSidebar/aiCopyPaste.ts +199 -0
- package/src/client/VZSidebar/index.tsx +728 -0
- package/src/client/VZSidebar/styles.scss +612 -0
- package/src/client/VZSidebar/useDragAndDrop.tsx +408 -0
- package/src/client/bootstrap.ts +26 -0
- package/src/client/buildWorker.ts +3 -0
- package/src/client/featureFlags.ts +8 -0
- package/src/client/generateRequestId.ts +6 -0
- package/src/client/getFileTree.ts +53 -0
- package/src/client/index.ts +19 -0
- package/src/client/main.jsx +10 -0
- package/src/client/presenceColor.ts +8 -0
- package/src/client/shouldTriggerRun.ts +31 -0
- package/src/client/sortFileTree.ts +21 -0
- package/src/client/tabsSearchParameters.test.ts +79 -0
- package/src/client/tabsSearchParameters.ts +137 -0
- package/src/client/themes/index.ts +7 -0
- package/src/client/themes/themes.ts +77 -0
- package/src/client/themes/uiwImports.ts +32 -0
- package/src/client/themes/useDynamicTheme.ts +29 -0
- package/src/client/themes/vizhubTheme/colors.ts +80 -0
- package/src/client/themes/vizhubTheme/index.ts +193 -0
- package/src/client/useActions.ts +288 -0
- package/src/client/useESLint/index.ts +72 -0
- package/src/client/useESLint/worker.ts +113 -0
- package/src/client/useEditorCache.ts +35 -0
- package/src/client/useFileCRUD.ts +228 -0
- package/src/client/useKeyboardShortcuts.ts +451 -0
- package/src/client/useOpenDirectories.ts +99 -0
- package/src/client/usePresenceAutoFollow.ts +85 -0
- package/src/client/usePrettier/index.ts +178 -0
- package/src/client/usePrettier/worker.ts +109 -0
- package/src/client/useRunCode.tsx +15 -0
- package/src/client/useRuntimeError.ts +32 -0
- package/src/client/useSubmitOperation.ts +15 -0
- package/src/client/useURLSync.ts +137 -0
- package/src/client/usernameLocalStorage.ts +43 -0
- package/src/client/utils/fileExtension.ts +10 -0
- package/src/client/utils/isImageFile.ts +12 -0
- package/src/client/vzReducer/aiChatReducer.ts +44 -0
- package/src/client/vzReducer/closeTabReducer.test.ts +205 -0
- package/src/client/vzReducer/closeTabsReducer.ts +96 -0
- package/src/client/vzReducer/createInitialState.ts +38 -0
- package/src/client/vzReducer/editorNoLongerWantsFocusReducer.ts +9 -0
- package/src/client/vzReducer/findPane.ts +19 -0
- package/src/client/vzReducer/index.ts +250 -0
- package/src/client/vzReducer/openTabReducer.test.ts +127 -0
- package/src/client/vzReducer/openTabReducer.ts +74 -0
- package/src/client/vzReducer/searchReducer.test.ts +292 -0
- package/src/client/vzReducer/searchReducer.ts +181 -0
- package/src/client/vzReducer/setActiveFileIdReducer.ts +28 -0
- package/src/client/vzReducer/setActiveFileLeftRightReducer.ts +76 -0
- package/src/client/vzReducer/setIsDocOpenReducer.ts +9 -0
- package/src/client/vzReducer/setIsSettingsOpenReducer.ts +9 -0
- package/src/client/vzReducer/setThemeReducer.ts +9 -0
- package/src/client/vzReducer/setUsernameReducer.ts +9 -0
- package/src/client/vzReducer/splitCurrentPaneReducer.ts +66 -0
- package/src/client/vzReducer/toggleAutoFollowReducer.ts +12 -0
- package/src/client/vzReducer/updatePane.ts +40 -0
- package/src/client/vzReducer/updatePresenceIndicatorReducer.ts +30 -0
- package/src/client/vzReducer/visualEditorReducer.ts +9 -0
- package/src/ot.ts +24 -0
- package/src/randomId.ts +15 -0
- package/src/reactRouterExports.ts +15 -0
- package/src/runCode.ts +27 -0
- package/src/server/aiChatHandler/aiEditing.ts +151 -0
- package/src/server/aiChatHandler/chatOperations.ts +476 -0
- package/src/server/aiChatHandler/errorHandling.ts +66 -0
- package/src/server/aiChatHandler/index.ts +199 -0
- package/src/server/aiChatHandler/llmStreaming.ts +419 -0
- package/src/server/aiChatHandler/validation.ts +24 -0
- package/src/server/computeInitialDocument.ts +220 -0
- package/src/server/config.ts +24 -0
- package/src/server/featureFlags.ts +8 -0
- package/src/server/generateAIResponse.ts +188 -0
- package/src/server/handleAIAssist.ts +53 -0
- package/src/server/handleAIChatMessage.ts +2 -0
- package/src/server/handleAICopilot.ts +97 -0
- package/src/server/index.ts +379 -0
- package/src/server/isDirectory.ts +1 -0
- package/src/server/livekit.ts +24 -0
- package/src/server/prettier.ts +115 -0
- package/src/server/setupEnv.ts +8 -0
- package/src/submitOperation.ts +25 -0
- package/src/types.ts +205 -0
- package/src/utils/fileDiff.ts +117 -0
- package/src/vite-env.d.ts +10 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useRef,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { Username } from '../../types';
|
|
10
|
+
import { EditorCacheValue } from '../useEditorCache';
|
|
11
|
+
import { getOrCreateEditor } from './getOrCreateEditor';
|
|
12
|
+
import { VZCodeContext } from '../VZCodeContext';
|
|
13
|
+
import { InteractRule } from '@replit/codemirror-interact';
|
|
14
|
+
import { EditorView } from 'codemirror'; // Import EditorView
|
|
15
|
+
import { Diagnostic } from '@codemirror/lint'; // Import Diagnostic
|
|
16
|
+
import './style.scss';
|
|
17
|
+
|
|
18
|
+
// The path in the ShareDB document where the files live.
|
|
19
|
+
const filesPath = ['files'];
|
|
20
|
+
|
|
21
|
+
export const CodeEditor = ({
|
|
22
|
+
customInteractRules,
|
|
23
|
+
aiCopilotEndpoint,
|
|
24
|
+
esLintSource,
|
|
25
|
+
}: {
|
|
26
|
+
customInteractRules?: Array<InteractRule>;
|
|
27
|
+
aiCopilotEndpoint?: string;
|
|
28
|
+
esLintSource: (
|
|
29
|
+
view: EditorView,
|
|
30
|
+
) => Promise<readonly Diagnostic[]>;
|
|
31
|
+
}) => {
|
|
32
|
+
const {
|
|
33
|
+
activePane,
|
|
34
|
+
shareDBDoc,
|
|
35
|
+
content,
|
|
36
|
+
submitOperation,
|
|
37
|
+
localPresence,
|
|
38
|
+
docPresence,
|
|
39
|
+
username,
|
|
40
|
+
editorCache,
|
|
41
|
+
editorWantsFocus,
|
|
42
|
+
editorNoLongerWantsFocus,
|
|
43
|
+
theme,
|
|
44
|
+
codeEditorRef,
|
|
45
|
+
enableAutoFollow,
|
|
46
|
+
setIsAIChatOpen,
|
|
47
|
+
setAIChatMessage,
|
|
48
|
+
handleSendMessage,
|
|
49
|
+
} = useContext(VZCodeContext);
|
|
50
|
+
|
|
51
|
+
// Set `doc.data.isInteracting` to `true` when the user is interacting
|
|
52
|
+
// via interactive code widgets (e.g. Alt+drag), and `false` when they are not.
|
|
53
|
+
const interactTimeoutRef = useRef(null);
|
|
54
|
+
|
|
55
|
+
const onInteract = useCallback(() => {
|
|
56
|
+
// Set `isInteracting: true` if not already set.
|
|
57
|
+
if (!interactTimeoutRef.current) {
|
|
58
|
+
submitOperation((document) => ({
|
|
59
|
+
...document,
|
|
60
|
+
isInteracting: true,
|
|
61
|
+
}));
|
|
62
|
+
} else {
|
|
63
|
+
clearTimeout(interactTimeoutRef.current);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Set `isInteracting: undefined` after a delay,
|
|
67
|
+
// so that when the runId changes in the future,
|
|
68
|
+
// it will trigger a hard re-run, not a hot reload.
|
|
69
|
+
interactTimeoutRef.current = setTimeout(() => {
|
|
70
|
+
interactTimeoutRef.current = null;
|
|
71
|
+
|
|
72
|
+
// This logic deletes the `isInteracting` property from the document
|
|
73
|
+
submitOperation(
|
|
74
|
+
({ isInteracting, ...newDocument }) => ({
|
|
75
|
+
...newDocument,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
}, 800);
|
|
79
|
+
}, [submitOperation]);
|
|
80
|
+
|
|
81
|
+
// Track username on a ref, so that we can use it in the
|
|
82
|
+
// presence object's `username` property.
|
|
83
|
+
const usernameRef = useRef<Username>(username);
|
|
84
|
+
usernameRef.current = username;
|
|
85
|
+
|
|
86
|
+
// Track enableAutoFollow on a ref, so that we can use it in the
|
|
87
|
+
// CodeMirror extension.
|
|
88
|
+
const enableAutoFollowRef = useRef(enableAutoFollow);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
enableAutoFollowRef.current = enableAutoFollow;
|
|
91
|
+
}, [enableAutoFollow]);
|
|
92
|
+
|
|
93
|
+
// State to hold the editor cache value
|
|
94
|
+
const [editorCacheValue, setEditorCacheValue] =
|
|
95
|
+
useState<EditorCacheValue | null>(null);
|
|
96
|
+
|
|
97
|
+
// Get the editor corresponding to the active file.
|
|
98
|
+
// Looks in `editorCache` first, and if not found, creates a new editor.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!shareDBDoc) return;
|
|
101
|
+
let isMounted = true;
|
|
102
|
+
|
|
103
|
+
const initEditor = async () => {
|
|
104
|
+
const editor = await getOrCreateEditor({
|
|
105
|
+
fileId: activePane.activeFileId,
|
|
106
|
+
shareDBDoc,
|
|
107
|
+
filesPath,
|
|
108
|
+
localPresence,
|
|
109
|
+
docPresence,
|
|
110
|
+
theme,
|
|
111
|
+
onInteract,
|
|
112
|
+
editorCache,
|
|
113
|
+
usernameRef,
|
|
114
|
+
customInteractRules,
|
|
115
|
+
enableAutoFollowRef,
|
|
116
|
+
aiCopilotEndpoint,
|
|
117
|
+
esLintSource,
|
|
118
|
+
setIsAIChatOpen,
|
|
119
|
+
setAIChatMessage,
|
|
120
|
+
handleSendMessage,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (isMounted) {
|
|
124
|
+
setEditorCacheValue(editor);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
initEditor();
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
isMounted = false;
|
|
132
|
+
};
|
|
133
|
+
}, [
|
|
134
|
+
activePane,
|
|
135
|
+
shareDBDoc,
|
|
136
|
+
filesPath,
|
|
137
|
+
localPresence,
|
|
138
|
+
docPresence,
|
|
139
|
+
theme,
|
|
140
|
+
onInteract,
|
|
141
|
+
editorCache,
|
|
142
|
+
usernameRef,
|
|
143
|
+
aiCopilotEndpoint,
|
|
144
|
+
esLintSource,
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
// Every time the active file switches from one file to another,
|
|
148
|
+
// the editor corresponding to the old file is removed from the DOM,
|
|
149
|
+
// and the editor corresponding to the new file is added to the DOM.
|
|
150
|
+
useLayoutEffect(() => {
|
|
151
|
+
// Guard against cases where page is still loading.
|
|
152
|
+
if (!codeEditorRef.current) return;
|
|
153
|
+
if (!content) return;
|
|
154
|
+
if (!editorCacheValue) return; // Guard against null editorCacheValue
|
|
155
|
+
// if (openSplitPane) return;
|
|
156
|
+
|
|
157
|
+
// Add the editor and apply the prior scroll position to the DOM.
|
|
158
|
+
codeEditorRef.current.appendChild(
|
|
159
|
+
editorCacheValue.editor.dom,
|
|
160
|
+
);
|
|
161
|
+
editorCacheValue.editor.scrollDOM.scrollTo({
|
|
162
|
+
top: editorCacheValue.scrollPosition ?? 0,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
// Remove the old editor from the DOM and store the current scroll position.
|
|
167
|
+
// This happens every time `activeFileId` changes.
|
|
168
|
+
if (
|
|
169
|
+
editorCacheValue &&
|
|
170
|
+
codeEditorRef.current?.contains(
|
|
171
|
+
editorCacheValue.editor.dom,
|
|
172
|
+
)
|
|
173
|
+
) {
|
|
174
|
+
editorCacheValue.scrollPosition =
|
|
175
|
+
editorCacheValue.editor.scrollDOM.scrollTop;
|
|
176
|
+
codeEditorRef.current.removeChild(
|
|
177
|
+
editorCacheValue.editor.dom,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}, [shareDBDoc, editorCacheValue]);
|
|
182
|
+
|
|
183
|
+
// Focus the editor
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (editorWantsFocus && editorCacheValue) {
|
|
186
|
+
editorCacheValue.editor.focus();
|
|
187
|
+
editorNoLongerWantsFocus();
|
|
188
|
+
}
|
|
189
|
+
}, [
|
|
190
|
+
editorWantsFocus,
|
|
191
|
+
editorCacheValue,
|
|
192
|
+
editorNoLongerWantsFocus,
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
className="vz-code-editor"
|
|
198
|
+
ref={codeEditorRef}
|
|
199
|
+
tabIndex={-1}
|
|
200
|
+
/>
|
|
201
|
+
);
|
|
202
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ViewUpdate } from '@codemirror/view';
|
|
2
|
+
import { EditorView } from 'codemirror';
|
|
3
|
+
|
|
4
|
+
// Deals with broadcasting changes in cursor location and selection.
|
|
5
|
+
export const json1PresenceBroadcast = ({
|
|
6
|
+
path,
|
|
7
|
+
localPresence,
|
|
8
|
+
usernameRef,
|
|
9
|
+
}) =>
|
|
10
|
+
// See https://discuss.codemirror.net/t/codemirror-6-proper-way-to-listen-for-changes/2395/10
|
|
11
|
+
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
|
12
|
+
// If this update modified the cursor / selection,
|
|
13
|
+
// we broadcast the selection update via ShareDB presence.
|
|
14
|
+
|
|
15
|
+
if (viewUpdate.selectionSet) {
|
|
16
|
+
// Isolate the single selection to use for presence.
|
|
17
|
+
// Unfortunately JSON1 with presence does not yet
|
|
18
|
+
// support multiple selections.
|
|
19
|
+
// See https://github.com/ottypes/json1/pull/25#issuecomment-1459616521
|
|
20
|
+
const selection =
|
|
21
|
+
viewUpdate.state.selection.ranges[0];
|
|
22
|
+
const { from, to } = selection;
|
|
23
|
+
|
|
24
|
+
// Translate this into the form expected by json1Presence.
|
|
25
|
+
const presence = {
|
|
26
|
+
start: [...path, from],
|
|
27
|
+
end: [...path, to],
|
|
28
|
+
|
|
29
|
+
// We use a ref here so that the presence always broadcasts
|
|
30
|
+
// the latest username, in the case that it has been changed
|
|
31
|
+
// in the Settings mid-session.
|
|
32
|
+
username: usernameRef.current,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Broadcast presence to remote clients!
|
|
36
|
+
// See https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js#L71
|
|
37
|
+
localPresence.submit(presence, (error) => {
|
|
38
|
+
if (error) throw error;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ViewPlugin,
|
|
3
|
+
EditorView,
|
|
4
|
+
WidgetType,
|
|
5
|
+
Decoration,
|
|
6
|
+
} from '@codemirror/view';
|
|
7
|
+
import { Annotation, RangeSet } from '@codemirror/state';
|
|
8
|
+
import { assignUserColor } from '../presenceColor';
|
|
9
|
+
import {
|
|
10
|
+
Presence,
|
|
11
|
+
PresenceId,
|
|
12
|
+
Username,
|
|
13
|
+
} from '../../types';
|
|
14
|
+
|
|
15
|
+
const DEBUG = false;
|
|
16
|
+
|
|
17
|
+
// Deals with receiving the broadcasted presence cursor locations
|
|
18
|
+
// from other clients and displaying them.
|
|
19
|
+
//
|
|
20
|
+
// Inspired by
|
|
21
|
+
// * https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js
|
|
22
|
+
// * https://codemirror.net/examples/decoration/
|
|
23
|
+
// * https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js
|
|
24
|
+
// * https://share.github.io/sharedb/presence
|
|
25
|
+
export const json1PresenceDisplay = ({
|
|
26
|
+
path,
|
|
27
|
+
docPresence,
|
|
28
|
+
enableAutoFollowRef,
|
|
29
|
+
}: {
|
|
30
|
+
path: Array<string>;
|
|
31
|
+
docPresence: any;
|
|
32
|
+
enableAutoFollowRef: React.MutableRefObject<boolean>;
|
|
33
|
+
}) => [
|
|
34
|
+
ViewPlugin.fromClass(
|
|
35
|
+
class {
|
|
36
|
+
// The decorations to display.
|
|
37
|
+
// This is a RangeSet of Decoration objects.
|
|
38
|
+
// See https://codemirror.net/6/docs/ref/#view.Decoration
|
|
39
|
+
decorations: RangeSet<Decoration>;
|
|
40
|
+
|
|
41
|
+
//Added variable for cursor position
|
|
42
|
+
cursorPosition = {};
|
|
43
|
+
|
|
44
|
+
// Track previous cursor positions to detect which cursors actually moved
|
|
45
|
+
previousCursorPositions = {};
|
|
46
|
+
|
|
47
|
+
// Flag to prevent multiple pending updates
|
|
48
|
+
pendingUpdate = false;
|
|
49
|
+
|
|
50
|
+
// Flag to prevent multiple pending scroll updates
|
|
51
|
+
pendingScrollUpdate = false;
|
|
52
|
+
|
|
53
|
+
constructor(view: EditorView) {
|
|
54
|
+
// Initialize decorations to empty array so CodeMirror doesn't crash.
|
|
55
|
+
this.decorations = RangeSet.of([]);
|
|
56
|
+
|
|
57
|
+
// Mutable state local to this closure representing aggregated presence.
|
|
58
|
+
// * Keys are presence ids
|
|
59
|
+
// * Values are presence objects as defined by ot-json1-presence
|
|
60
|
+
const presenceState: Record<PresenceId, Presence> =
|
|
61
|
+
{};
|
|
62
|
+
|
|
63
|
+
// Add the scroll event listener
|
|
64
|
+
//This runs for the arrow key scrolling, it should result in the users scrolling to eachother's location.
|
|
65
|
+
// view.dom.addEventListener('scroll', () => {
|
|
66
|
+
// this.scrollToCursor(view);
|
|
67
|
+
// });
|
|
68
|
+
// Receive remote presence changes.
|
|
69
|
+
docPresence.on(
|
|
70
|
+
'receive',
|
|
71
|
+
(id: PresenceId, presence: Presence) => {
|
|
72
|
+
if (DEBUG) {
|
|
73
|
+
console.log(
|
|
74
|
+
`Received presence for id ${id}`,
|
|
75
|
+
presence,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// If presence is null, the user has disconnected.
|
|
80
|
+
// We must remove them from our local state.
|
|
81
|
+
if (!presence) {
|
|
82
|
+
delete presenceState[id];
|
|
83
|
+
// Also remove their cursor position to prevent errors.
|
|
84
|
+
delete this.cursorPosition[id];
|
|
85
|
+
delete this.previousCursorPositions[id];
|
|
86
|
+
} else {
|
|
87
|
+
// Otherwise, the user is active. Check if the presence
|
|
88
|
+
// is for the current file.
|
|
89
|
+
const isPresenceInCurrentFile = pathMatches(
|
|
90
|
+
path,
|
|
91
|
+
presence,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// If it's the current file, add/update their state.
|
|
95
|
+
if (isPresenceInCurrentFile) {
|
|
96
|
+
presenceState[id] = presence;
|
|
97
|
+
} else {
|
|
98
|
+
// If it's for another file, remove them from this view's state.
|
|
99
|
+
delete presenceState[id];
|
|
100
|
+
delete this.cursorPosition[id];
|
|
101
|
+
delete this.previousCursorPositions[id];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const presenceDecorations = [];
|
|
106
|
+
|
|
107
|
+
for (const id of Object.keys(presenceState)) {
|
|
108
|
+
const presence: Presence = presenceState[id];
|
|
109
|
+
const { start, end } = presence;
|
|
110
|
+
const from = +start[start.length - 1];
|
|
111
|
+
const to = +end[end.length - 1];
|
|
112
|
+
const userColor = assignUserColor(
|
|
113
|
+
presence.username,
|
|
114
|
+
);
|
|
115
|
+
const { username } = presence;
|
|
116
|
+
|
|
117
|
+
// Check if this cursor actually moved by comparing with previous position
|
|
118
|
+
const previousPosition =
|
|
119
|
+
this.previousCursorPositions[id];
|
|
120
|
+
const cursorMoved = previousPosition !== from;
|
|
121
|
+
|
|
122
|
+
presenceDecorations.push({
|
|
123
|
+
from,
|
|
124
|
+
to: from,
|
|
125
|
+
value: Decoration.widget({
|
|
126
|
+
side: -1,
|
|
127
|
+
block: false,
|
|
128
|
+
widget: new PresenceWidget(
|
|
129
|
+
'' + Math.random(),
|
|
130
|
+
userColor,
|
|
131
|
+
username,
|
|
132
|
+
cursorMoved, // Pass whether this cursor moved
|
|
133
|
+
),
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (from !== to) {
|
|
138
|
+
presenceDecorations.push({
|
|
139
|
+
from,
|
|
140
|
+
to,
|
|
141
|
+
value: Decoration.mark({
|
|
142
|
+
class: 'cm-json1-presence',
|
|
143
|
+
attributes: {
|
|
144
|
+
style: `
|
|
145
|
+
background-color: rgba(${userColor}, 0.75);
|
|
146
|
+
mix-blend-mode: luminosity;
|
|
147
|
+
`,
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (view.state.doc.length >= from) {
|
|
153
|
+
this.cursorPosition[id] = from;
|
|
154
|
+
// Update previous position for next comparison
|
|
155
|
+
this.previousCursorPositions[id] = from;
|
|
156
|
+
} else {
|
|
157
|
+
// The cursor position is invalid, so remove it.
|
|
158
|
+
delete this.cursorPosition[id];
|
|
159
|
+
delete this.previousCursorPositions[id];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.decorations = Decoration.set(
|
|
164
|
+
presenceDecorations,
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Safely dispatch decoration updates without causing race conditions
|
|
169
|
+
// Use requestAnimationFrame to ensure we're not in the middle of an update
|
|
170
|
+
if (!this.pendingUpdate) {
|
|
171
|
+
this.pendingUpdate = true;
|
|
172
|
+
requestAnimationFrame(() => {
|
|
173
|
+
this.pendingUpdate = false;
|
|
174
|
+
// Check if view is still valid and not currently updating
|
|
175
|
+
if (view.state && view.dom.isConnected) {
|
|
176
|
+
view.dispatch({
|
|
177
|
+
annotations: [
|
|
178
|
+
presenceAnnotation.of(true),
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (enableAutoFollowRef.current) {
|
|
186
|
+
this.scrollToCursor(view);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
// Method to scroll the view to keep the cursor in view
|
|
192
|
+
scrollToCursor(view) {
|
|
193
|
+
// Debounce scroll updates to prevent conflicts with typing
|
|
194
|
+
if (!this.pendingScrollUpdate) {
|
|
195
|
+
this.pendingScrollUpdate = true;
|
|
196
|
+
requestAnimationFrame(() => {
|
|
197
|
+
this.pendingScrollUpdate = false;
|
|
198
|
+
// Check if view is still valid
|
|
199
|
+
if (view.state && view.dom.isConnected) {
|
|
200
|
+
for (const id in this.cursorPosition) {
|
|
201
|
+
//getting the cursor position of the other cursor
|
|
202
|
+
const cursorPos = this.cursorPosition[id];
|
|
203
|
+
view.dispatch({
|
|
204
|
+
//if the other person's cursor has jumped off screen, we will follow it by scrolling there directly.
|
|
205
|
+
effects:
|
|
206
|
+
EditorView.scrollIntoView(cursorPos),
|
|
207
|
+
});
|
|
208
|
+
// Only scroll to the first cursor to avoid multiple dispatches
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
decorations: (v) => v.decorations,
|
|
218
|
+
},
|
|
219
|
+
),
|
|
220
|
+
presenceTheme,
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
const presenceAnnotation = Annotation.define();
|
|
224
|
+
|
|
225
|
+
// Checks that the path of this file
|
|
226
|
+
// matches the path of the presence.
|
|
227
|
+
// * If true is returned, the presence is in this file.
|
|
228
|
+
// * If false is returned, the presence is in another file.
|
|
229
|
+
// Assumption: start and end path are the same except the cursor position.
|
|
230
|
+
const pathMatches = (path, presence) => {
|
|
231
|
+
for (let i = 0; i < path.length; i++) {
|
|
232
|
+
if (path[i] !== presence.start[i]) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Displays a single remote presence cursor.
|
|
240
|
+
class PresenceWidget extends WidgetType {
|
|
241
|
+
id: string;
|
|
242
|
+
color: string;
|
|
243
|
+
username: Username;
|
|
244
|
+
timeout: number;
|
|
245
|
+
cursorMoved: boolean;
|
|
246
|
+
constructor(
|
|
247
|
+
id: string,
|
|
248
|
+
color: string,
|
|
249
|
+
username: Username,
|
|
250
|
+
cursorMoved: boolean = true, // Default to true for backward compatibility
|
|
251
|
+
) {
|
|
252
|
+
super();
|
|
253
|
+
this.id = id;
|
|
254
|
+
this.color = color;
|
|
255
|
+
this.username = username;
|
|
256
|
+
this.cursorMoved = cursorMoved;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
eq(other: PresenceWidget) {
|
|
260
|
+
return (
|
|
261
|
+
other.id === this.id &&
|
|
262
|
+
other.cursorMoved === this.cursorMoved
|
|
263
|
+
);
|
|
264
|
+
// return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
toDOM() {
|
|
268
|
+
// console.log('inside toDOM');
|
|
269
|
+
const span = document.createElement('span');
|
|
270
|
+
span.setAttribute('aria-hidden', 'true');
|
|
271
|
+
span.className = 'cm-json1-presence';
|
|
272
|
+
// This child is what actually displays the presence.
|
|
273
|
+
// Nested so that the layout is not impacted.
|
|
274
|
+
//
|
|
275
|
+
// The initial attempt using the top level span to render
|
|
276
|
+
// the cursor caused a wonky layout with adjacent characters shifting
|
|
277
|
+
// left and right by 1 pixel or so.
|
|
278
|
+
const div = document.createElement('div');
|
|
279
|
+
span.appendChild(div);
|
|
280
|
+
div.style.borderLeft = `1px solid rgba(${this.color})`;
|
|
281
|
+
|
|
282
|
+
// background color behind username
|
|
283
|
+
const userDiv = document.createElement('div');
|
|
284
|
+
userDiv.className = 'remote-cursor-username';
|
|
285
|
+
userDiv.style.top = `-20px`;
|
|
286
|
+
userDiv.style.height = `20px`;
|
|
287
|
+
userDiv.style.width = `${this.username.length * 12}px`;
|
|
288
|
+
userDiv.style.backgroundColor = `rgba(${this.color})`;
|
|
289
|
+
userDiv.style.color = `black`;
|
|
290
|
+
// userDiv.style.textAlign = `center`;
|
|
291
|
+
userDiv.appendChild(
|
|
292
|
+
document.createTextNode(this.username),
|
|
293
|
+
);
|
|
294
|
+
span.appendChild(userDiv);
|
|
295
|
+
|
|
296
|
+
// Only reset opacity and start timeout for cursors that actually moved
|
|
297
|
+
if (this.cursorMoved) {
|
|
298
|
+
// Start with full opacity when cursor moves
|
|
299
|
+
userDiv.style.opacity = '1';
|
|
300
|
+
|
|
301
|
+
// Clear any existing timeout to prevent interference
|
|
302
|
+
if (this.timeout) {
|
|
303
|
+
window.clearTimeout(this.timeout);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// after 2 seconds of inactivity, username is made less visible
|
|
307
|
+
this.timeout = window.setTimeout(() => {
|
|
308
|
+
// userDiv.style.backgroundColor = `rgba(${this.color}, 0.2)`;
|
|
309
|
+
// userDiv.style.color = 'rgba(0,0,0,0.2)';
|
|
310
|
+
userDiv.style.opacity = '0.3';
|
|
311
|
+
}, 2000);
|
|
312
|
+
} else {
|
|
313
|
+
// For cursors that didn't move, keep their reduced opacity
|
|
314
|
+
userDiv.style.opacity = '0.3';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return span;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// TODO try to use this instead of toDOM
|
|
321
|
+
// updateDOM(dom: HTMLElement, view: EditorView) {
|
|
322
|
+
// console.log('inside updateDOM');
|
|
323
|
+
// dom.style.opacity = '1';
|
|
324
|
+
// window.clearTimeout(this.timeout);
|
|
325
|
+
|
|
326
|
+
// // after 2 seconds of inactivity, username is made less visible
|
|
327
|
+
// this.timeout = window.setTimeout(() => {
|
|
328
|
+
// // userDiv.style.backgroundColor = `rgba(${this.color}, 0.2)`;
|
|
329
|
+
// // userDiv.style.color = 'rgba(0,0,0,0.2)';
|
|
330
|
+
// dom.style.opacity = '0.3';
|
|
331
|
+
// }, 2000);
|
|
332
|
+
|
|
333
|
+
// return false;
|
|
334
|
+
// }
|
|
335
|
+
ignoreEvent() {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const presenceTheme = EditorView.baseTheme({
|
|
340
|
+
'.cm-json1-presence': {
|
|
341
|
+
position: 'relative',
|
|
342
|
+
},
|
|
343
|
+
'.cm-json1-presence > div': {
|
|
344
|
+
position: 'absolute',
|
|
345
|
+
top: '0',
|
|
346
|
+
bottom: '0',
|
|
347
|
+
left: '0',
|
|
348
|
+
right: '0',
|
|
349
|
+
},
|
|
350
|
+
});
|