vzcode 0.9.0 → 0.10.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/dist/index.html CHANGED
@@ -18,7 +18,7 @@
18
18
  href="https://unpkg.com/bootstrap@5.3.0/dist/css/bootstrap.min.css"
19
19
  rel="stylesheet"
20
20
  />
21
- <script type="module" crossorigin src="/assets/index-80e9e6df.js"></script>
21
+ <script type="module" crossorigin src="/assets/index-8a130b74.js"></script>
22
22
  <link rel="stylesheet" href="/assets/index-ccd52a89.css">
23
23
  </head>
24
24
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vzcode",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from 'react';
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
2
  import ShareDBClient from 'sharedb-client-browser/dist/sharedb-client-umd.cjs';
3
3
  import { json1Presence } from '../ot';
4
4
  import { randomId } from '../randomId';
@@ -24,6 +24,12 @@ const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
24
24
  const socket = new WebSocket(wsProtocol + window.location.host + '/ws');
25
25
  const connection = new Connection(socket);
26
26
 
27
+ // The ShareDB document type.
28
+ type Document = {
29
+ files: Files;
30
+ isInteracting: boolean;
31
+ };
32
+
27
33
  function App() {
28
34
  // The ShareDB document.
29
35
  const [shareDBDoc, setShareDBDoc] = useState(null);
@@ -39,7 +45,8 @@ function App() {
39
45
 
40
46
  // The `doc.data` part of the ShareDB document,
41
47
  // updated on each change to decouple rendering from ShareDB.
42
- const [data, setData] = useState<Files>(null);
48
+ // Starts out as `null` until the document is loaded.
49
+ const [data, setData] = useState<Document | null>(null);
43
50
 
44
51
  // The id of the currently open file tab.
45
52
  const [activeFileId, setActiveFileId] = useState<FileId>(null);
@@ -116,39 +123,60 @@ function App() {
116
123
  };
117
124
  }, []);
118
125
 
126
+ // A helper function to submit operations to the ShareDB document
127
+ const submitOperation = useCallback(
128
+ (updateFunction: (document: Document) => Document) => {
129
+ const currentDocument = shareDBDoc.data;
130
+ const nextDocument = updateFunction(currentDocument);
131
+ shareDBDoc.submitOp(diff(currentDocument, nextDocument));
132
+ },
133
+ [shareDBDoc],
134
+ );
135
+
119
136
  // Called when a file in the sidebar is double-clicked.
120
137
  const handleRenameFileClick = useCallback(
121
138
  (fileId: FileId) => {
139
+ // TODO better UX, maybe Bootstrap modal? Maybe edit inline?
122
140
  const newName = prompt('Enter new name');
141
+
123
142
  if (newName) {
124
- const currentDocument = shareDBDoc.data;
125
- const nextDocument = {
126
- ...currentDocument,
127
- [fileId]: {
128
- ...currentDocument[fileId],
129
- name: newName,
143
+ submitOperation((document) => ({
144
+ ...document,
145
+ files: {
146
+ ...document.files,
147
+ [fileId]: {
148
+ ...document.files[fileId],
149
+ name: newName,
150
+ },
130
151
  },
131
- };
132
- shareDBDoc.submitOp(diff(currentDocument, nextDocument));
152
+ }));
133
153
  }
134
154
  },
135
- [shareDBDoc],
155
+ [submitOperation],
136
156
  );
137
157
 
138
158
  const deleteFile = useCallback(
139
159
  (fileId: FileId) => {
140
- // Close the tab in case it's open.
141
- // TODO consistently use either "key" or "fileId" naming
142
160
  closeTab(fileId);
143
-
144
- const currentDocument = shareDBDoc.data;
145
- const nextDocument = { ...currentDocument };
146
- delete nextDocument[fileId];
147
- shareDBDoc.submitOp(diff(currentDocument, nextDocument));
161
+ submitOperation((document) => {
162
+ const updatedFiles = { ...document.files };
163
+ delete updatedFiles[fileId];
164
+ return { ...document, files: updatedFiles };
165
+ });
148
166
  },
149
- [shareDBDoc, closeTab],
167
+ [submitOperation, closeTab],
150
168
  );
151
169
 
170
+ const createFile = useCallback(() => {
171
+ const name = prompt('Enter new file name');
172
+ if (name) {
173
+ submitOperation((document) => ({
174
+ ...document,
175
+ files: { ...document.files, [randomId()]: { name, text: '' } },
176
+ }));
177
+ }
178
+ }, [submitOperation]);
179
+
152
180
  // TODO prompt the user "Are you sure?"
153
181
  const handleDeleteFileClick = useCallback(
154
182
  (fileId: FileId, event: React.MouseEvent) => {
@@ -160,28 +188,32 @@ function App() {
160
188
  [deleteFile],
161
189
  );
162
190
 
163
- const createFile = useCallback(() => {
164
- // TODO better UI, maybe Bootstrap modal? Maybe edit inline?
165
- const name = prompt('Enter new file name');
166
- if (!name) return;
167
- const currentDocument = shareDBDoc.data;
168
- const nextDocument = {
169
- ...currentDocument,
170
- [randomId()]: { name, text: '' },
171
- };
172
- shareDBDoc.submitOp(diff(currentDocument, nextDocument));
173
- }, [shareDBDoc]);
174
-
175
191
  const handleSettingsClose = useCallback(() => {
176
192
  setIsSettingsOpen(false);
177
193
  }, []);
178
194
 
195
+ // Set `doc.data.isInteracting` to `true` when the user is interacting
196
+ // via interactive code widgets, and `false` when they are not.
197
+ const interactTimeoutRef = useRef(null);
198
+ const handleInteract = useCallback(() => {
199
+ if (!interactTimeoutRef.current) {
200
+ submitOperation((document) => ({ ...document, isInteracting: true }));
201
+ } else {
202
+ clearTimeout(interactTimeoutRef.current);
203
+ }
204
+
205
+ interactTimeoutRef.current = setTimeout(() => {
206
+ interactTimeoutRef.current = null;
207
+ submitOperation((document) => ({ ...document, isInteracting: false }));
208
+ }, 800);
209
+ }, [submitOperation]);
210
+
179
211
  return (
180
212
  <div className="app">
181
213
  <div className="left">
182
214
  <Sidebar
183
215
  createFile={createFile}
184
- files={data}
216
+ files={data?.files}
185
217
  handleRenameFileClick={handleRenameFileClick}
186
218
  handleDeleteFileClick={handleDeleteFileClick}
187
219
  handleFileClick={openTab}
@@ -197,7 +229,7 @@ function App() {
197
229
  </div>
198
230
  <div className="right">
199
231
  <TabList
200
- files={data}
232
+ files={data?.files}
201
233
  tabList={tabList}
202
234
  activeFileId={activeFileId}
203
235
  setActiveFileId={setActiveFileId}
@@ -210,6 +242,7 @@ function App() {
210
242
  docPresence={docPresence}
211
243
  activeFileId={activeFileId}
212
244
  theme={theme}
245
+ onInteract={handleInteract}
213
246
  />
214
247
  ) : null}
215
248
  </div>
@@ -10,6 +10,7 @@ import { FileId, ShareDBDoc } from '../../types';
10
10
  import { json1PresenceBroadcast } from './json1PresenceBroadcast';
11
11
  import { json1PresenceDisplay } from './json1PresenceDisplay';
12
12
  import { widgets } from './widgets';
13
+
13
14
  // Singleton cache of CodeMirror instances
14
15
  // These are created, but never destroyed.
15
16
  // TODO consider invalidating this cache at some point.
@@ -36,6 +37,7 @@ export const getOrCreateEditor = ({
36
37
  localPresence,
37
38
  docPresence,
38
39
  theme,
40
+ onInteract,
39
41
  }: {
40
42
  fileId: FileId;
41
43
  shareDBDoc: ShareDBDoc<any>;
@@ -43,6 +45,7 @@ export const getOrCreateEditor = ({
43
45
  localPresence: any;
44
46
  docPresence: any;
45
47
  theme: any;
48
+ onInteract?: () => void;
46
49
  }) => {
47
50
  const data = shareDBDoc.data;
48
51
  const textPath = [...filesPath, fileId, 'text'];
@@ -97,7 +100,7 @@ export const getOrCreateEditor = ({
97
100
  extensions.push(markdown());
98
101
  }
99
102
 
100
- extensions.push(widgets());
103
+ extensions.push(widgets({ onInteract }));
101
104
 
102
105
  let editor = editorCache.get(fileId);
103
106
  if (!editor) {
@@ -10,7 +10,8 @@ export const CodeEditor = ({
10
10
  localPresence,
11
11
  docPresence,
12
12
  theme = defaultTheme,
13
- filesPath = [],
13
+ filesPath = ['files'],
14
+ onInteract,
14
15
  }: {
15
16
  activeFileId: FileId;
16
17
  shareDBDoc: any;
@@ -21,6 +22,7 @@ export const CodeEditor = ({
21
22
  // The path of the files object in the ShareDB document.
22
23
  // Defaults to the root of the document.
23
24
  filesPath?: string[];
25
+ onInteract?: () => void;
24
26
  }) => {
25
27
  const ref = useRef<HTMLDivElement>(null);
26
28
 
@@ -38,6 +40,7 @@ export const CodeEditor = ({
38
40
  docPresence,
39
41
  // TODO refactor this, make dynamic themes work
40
42
  theme: themesByLabel[theme],
43
+ onInteract,
41
44
  });
42
45
  ref.current.appendChild(editor.dom);
43
46
 
@@ -7,7 +7,8 @@ import interact from '@replit/codemirror-interact';
7
7
  // * TODO color picker
8
8
  // Inspired by:
9
9
  // https://github.com/replit/codemirror-interact/blob/master/dev/index.ts
10
- export const widgets = () =>
10
+ // `onInteract` is called when the user interacts with a widget.
11
+ export const widgets = ({ onInteract }: { onInteract?: () => void }) =>
11
12
  interact({
12
13
  rules: [
13
14
  // a rule for a number dragger
@@ -18,6 +19,7 @@ export const widgets = () =>
18
19
  cursor: 'ew-resize',
19
20
  // change number value based on mouse X movement on drag
20
21
  onDrag: (text, setText, e) => {
22
+ if (onInteract) onInteract();
21
23
  const newVal = Number(text) + e.movementX;
22
24
  if (isNaN(newVal)) return;
23
25
  setText(newVal.toString());
@@ -28,6 +30,7 @@ export const widgets = () =>
28
30
  regexp: /true|false/g,
29
31
  cursor: 'pointer',
30
32
  onClick: (text, setText) => {
33
+ if (onInteract) onInteract();
31
34
  switch (text) {
32
35
  case 'true':
33
36
  return setText('false');
@@ -15,11 +15,21 @@ export const computeInitialDocument = ({ fullPath }) => {
15
15
  const unsearchedDirectories = [''];
16
16
 
17
17
  // Initialize the document using our data structure for representing files.
18
- // * Keys are file ids, which are random numbers.
19
- // * Values are objects with properties:
20
- // * text - the text content of the file
21
- // * name - the file name
22
- const initialDocument = {};
18
+ const initialDocument = {
19
+ // `files`
20
+ // * Keys are file ids, which are random numbers.
21
+ // * Values are objects with properties:
22
+ // * text - the text content of the file
23
+ // * name - the file name
24
+ files: {},
25
+
26
+ // `isInteracting`
27
+ // * Whether the user is currently interacting with the document
28
+ // using an interactive code widget such as number dragger.
29
+ // * When true, the auto-save to the file system is changed to be
30
+ // more frequent (throttled not debounced).
31
+ isInteracting: false,
32
+ };
23
33
 
24
34
  // Stack for recursively traversing directories.
25
35
  let files = [];
@@ -51,7 +61,7 @@ export const computeInitialDocument = ({ fullPath }) => {
51
61
 
52
62
  files.forEach((file) => {
53
63
  const id = randomId();
54
- initialDocument[id] = {
64
+ initialDocument.files[id] = {
55
65
  text: isDirectory(file)
56
66
  ? null
57
67
  : fs.readFileSync(path.join(fullPath, file), 'utf-8'),
@@ -32,6 +32,8 @@ ShareDB.types.register(json1Presence.type);
32
32
 
33
33
  const app = express();
34
34
 
35
+ // TODO make this configurable
36
+ // See https://github.com/vizhub-core/vzcode/issues/95
35
37
  app.post('/saveTime', (req, res) => {
36
38
  //autoSaveDebounceTimeMS = req.body.autoSaveDebounceTimeMS;
37
39
  console.log('autoSaveDebounceTimeMS', req.body);
@@ -67,10 +69,14 @@ let previousDocument = initialDocument;
67
69
 
68
70
  // Saves the files that changed.
69
71
  const save = () => {
72
+ // console.log('saving');
70
73
  const currentDocument = shareDBDoc.data;
71
74
 
72
75
  // Take a look at each file (key) previously and currently.
73
- const allKeys = Object.keys({ ...currentDocument, ...previousDocument });
76
+ const allKeys = Object.keys({
77
+ ...currentDocument.files,
78
+ ...previousDocument.files,
79
+ });
74
80
  for (const key of allKeys) {
75
81
  const previous = previousDocument[key];
76
82
  const current = currentDocument[key];
@@ -101,14 +107,46 @@ const save = () => {
101
107
  previousDocument = currentDocument;
102
108
  };
103
109
 
104
- // Listen for when users modify files.
105
- // Files get written to disk after `autoSaveDebounceTimeMS`
106
- // milliseconds of inactivity.
107
- let timeout;
110
+ // // Listen for when users modify files.
111
+ // // Files get written to disk after `autoSaveDebounceTimeMS`
112
+ // // milliseconds of inactivity.
113
+ // let timeout;
114
+ // shareDBDoc.subscribe(() => {
115
+ // shareDBDoc.on('op', () => {
116
+ // // console.log(shareDBDoc.data.isInteracting);
117
+ // clearTimeout(timeout);
118
+ // timeout = setTimeout(save, autoSaveDebounceTimeMS);
119
+ // });
120
+ // });
121
+
122
+ const throttleTimeMS = 200;
123
+
124
+ let lastExecutedTime = Date.now();
125
+
126
+ // Function to throttle the saving
127
+ function throttleSave() {
128
+ const now = Date.now();
129
+ if (now - lastExecutedTime > throttleTimeMS) {
130
+ save();
131
+ lastExecutedTime = now;
132
+ }
133
+ }
134
+
135
+ // Function to debounce the saving
136
+ let debounceTimeout;
137
+ function debounceSave() {
138
+ clearTimeout(debounceTimeout);
139
+ debounceTimeout = setTimeout(save, autoSaveDebounceTimeMS);
140
+ }
141
+
142
+ // Subscribe to listen for modifications
108
143
  shareDBDoc.subscribe(() => {
109
- shareDBDoc.on('op', (op) => {
110
- clearTimeout(timeout);
111
- timeout = setTimeout(save, autoSaveDebounceTimeMS);
144
+ shareDBDoc.on('op', () => {
145
+ if (shareDBDoc.data.isInteracting) {
146
+ throttleSave();
147
+ } else {
148
+ debounceSave();
149
+ }
112
150
  });
113
151
  });
114
152