vzcode 0.12.0 → 0.14.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-8a130b74.js"></script>
21
+ <script type="module" crossorigin src="/assets/index-176d73ed.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.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -48,15 +48,15 @@
48
48
  "dependencies": {
49
49
  "@codemirror/lang-css": "^6.2.1",
50
50
  "@codemirror/lang-html": "^6.4.5",
51
- "@codemirror/lang-javascript": "^6.1.9",
51
+ "@codemirror/lang-javascript": "^6.2.0",
52
52
  "@codemirror/lang-markdown": "^6.2.0",
53
53
  "@codemirror/state": "^6.2.1",
54
54
  "@codemirror/theme-one-dark": "^6.1.2",
55
- "@replit/codemirror-interact": "^6.1.0",
55
+ "@replit/codemirror-interact": "^6.3.0",
56
56
  "@teamwork/websocket-json-stream": "^2.0.0",
57
- "@uiw/codemirror-themes-all": "^4.21.9",
57
+ "@uiw/codemirror-themes-all": "^4.21.11",
58
58
  "codemirror": "^6.0.1",
59
- "codemirror-ot": "^4.0.0",
59
+ "codemirror-ot": "^4.1.0",
60
60
  "d3-array": "^3.2.4",
61
61
  "diff-match-patch": "^1.0.5",
62
62
  "dotenv": "^16.3.1",
@@ -69,17 +69,18 @@
69
69
  "react-dom": "^18.2.0",
70
70
  "react-select": "^5.7.4",
71
71
  "sharedb": "^4.0.0",
72
- "sharedb-client-browser": "^4.2.1",
72
+ "sharedb-client-browser": "^4.3.0",
73
73
  "ws": "^8.13.0"
74
74
  },
75
75
  "devDependencies": {
76
- "@types/react": "^18.2.20",
76
+ "@types/react": "^18.2.21",
77
77
  "@types/react-dom": "^18.2.7",
78
78
  "@vitejs/plugin-react": "^4.0.4",
79
- "prettier": "^3.0.1",
80
- "sass": "^1.65.1",
81
- "typescript": "^5.1.6",
79
+ "ot-json1": "^1.0.2",
80
+ "prettier": "^3.0.2",
81
+ "sass": "^1.66.1",
82
+ "typescript": "^5.2.2",
82
83
  "vite": "^4.4.9",
83
- "vitest": "^0.34.1"
84
+ "vitest": "^0.34.3"
84
85
  }
85
86
  }
@@ -6,12 +6,13 @@ import { CodeEditor } from './CodeEditor';
6
6
  import { diff } from './diff';
7
7
  import { Settings } from './Settings';
8
8
  import { Sidebar } from './Sidebar';
9
- import { FileId, Files } from '../types';
9
+ import { FileId, Files, ShareDBDoc, VZCodeContent } from '../types';
10
10
  import { TabList } from './TabList';
11
11
  import { useOpenDirectories } from './useOpenDirectories';
12
12
  import { useTabsState } from './useTabsState';
13
13
  import { defaultTheme } from './themes';
14
14
  import './style.scss';
15
+ import { usePrettier } from './usePrettier';
15
16
 
16
17
  // Register our custom JSON1 OT type that supports presence.
17
18
  // See https://github.com/vizhub-core/json1-presence
@@ -24,15 +25,27 @@ const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
24
25
  const socket = new WebSocket(wsProtocol + window.location.host + '/ws');
25
26
  const connection = new Connection(socket);
26
27
 
27
- // The ShareDB document type.
28
- type Document = {
29
- files: Files;
30
- isInteracting: boolean;
31
- };
32
-
33
28
  function App() {
34
29
  // The ShareDB document.
35
- const [shareDBDoc, setShareDBDoc] = useState(null);
30
+ const [shareDBDoc, setShareDBDoc] =
31
+ useState<ShareDBDoc<VZCodeContent> | null>(null);
32
+
33
+ // A helper function to submit operations to the ShareDB document
34
+ const submitOperation: (
35
+ next: (content: VZCodeContent) => VZCodeContent,
36
+ ) => void = useCallback(
37
+ (next) => {
38
+ const content: VZCodeContent = shareDBDoc.data;
39
+ const op = diff(content, next(content));
40
+ if (op && shareDBDoc) {
41
+ shareDBDoc.submitOp(op);
42
+ }
43
+ },
44
+ [shareDBDoc],
45
+ );
46
+
47
+ // Auto-run Pretter after local changes.
48
+ usePrettier(shareDBDoc, submitOperation);
36
49
 
37
50
  // Local ShareDB presence, for broadcasting our cursor position
38
51
  // so other clients can see it.
@@ -46,7 +59,7 @@ function App() {
46
59
  // The `doc.data` part of the ShareDB document,
47
60
  // updated on each change to decouple rendering from ShareDB.
48
61
  // Starts out as `null` until the document is loaded.
49
- const [data, setData] = useState<Document | null>(null);
62
+ const [content, setContent] = useState<VZCodeContent | null>(null);
50
63
 
51
64
  // The id of the currently open file tab.
52
65
  const [activeFileId, setActiveFileId] = useState<FileId>(null);
@@ -90,13 +103,13 @@ function App() {
90
103
  setShareDBDoc(shareDBDoc);
91
104
 
92
105
  // Set initial data.
93
- setData(shareDBDoc.data);
106
+ setContent(shareDBDoc.data);
94
107
 
95
108
  // Listen for all changes and update `data`.
96
109
  // This decouples rendering logic from ShareDB.
97
110
  // This callback gets called on each change.
98
111
  shareDBDoc.on('op', () => {
99
- setData(shareDBDoc.data);
112
+ setContent(shareDBDoc.data);
100
113
  });
101
114
 
102
115
  // Set up presence.
@@ -123,16 +136,6 @@ function App() {
123
136
  };
124
137
  }, []);
125
138
 
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
-
136
139
  // Called when a file in the sidebar is double-clicked.
137
140
  const handleRenameFileClick = useCallback(
138
141
  (fileId: FileId) => {
@@ -193,27 +196,31 @@ function App() {
193
196
  }, []);
194
197
 
195
198
  // Set `doc.data.isInteracting` to `true` when the user is interacting
196
- // via interactive code widgets, and `false` when they are not.
199
+ // via interactive code widgets (e.g. Alt+drag), and `false` when they are not.
197
200
  const interactTimeoutRef = useRef(null);
198
201
  const handleInteract = useCallback(() => {
202
+ // Set `isInteracting: true` if not already set.
199
203
  if (!interactTimeoutRef.current) {
200
204
  submitOperation((document) => ({ ...document, isInteracting: true }));
201
205
  } else {
202
206
  clearTimeout(interactTimeoutRef.current);
203
207
  }
204
208
 
209
+ // Set `isInteracting: false` after a delay.
205
210
  interactTimeoutRef.current = setTimeout(() => {
206
211
  interactTimeoutRef.current = null;
207
212
  submitOperation((document) => ({ ...document, isInteracting: false }));
208
213
  }, 800);
209
214
  }, [submitOperation]);
210
215
 
216
+ const files: Files | null = content ? content.files : null;
217
+
211
218
  return (
212
219
  <div className="app">
213
220
  <div className="left">
214
221
  <Sidebar
215
222
  createFile={createFile}
216
- files={data?.files}
223
+ files={files}
217
224
  handleRenameFileClick={handleRenameFileClick}
218
225
  handleDeleteFileClick={handleDeleteFileClick}
219
226
  handleFileClick={openTab}
@@ -229,13 +236,13 @@ function App() {
229
236
  </div>
230
237
  <div className="right">
231
238
  <TabList
232
- files={data?.files}
239
+ files={files}
233
240
  tabList={tabList}
234
241
  activeFileId={activeFileId}
235
242
  setActiveFileId={setActiveFileId}
236
243
  closeTab={closeTab}
237
244
  />
238
- {data && activeFileId ? (
245
+ {content && activeFileId ? (
239
246
  <CodeEditor
240
247
  shareDBDoc={shareDBDoc}
241
248
  localPresence={localPresence}
@@ -0,0 +1,161 @@
1
+ import { JSONOp } from 'ot-json1';
2
+ import { FileId, ShareDBDoc, VZCodeContent } from '../../types';
3
+ import { useEffect, useRef } from 'react';
4
+ // @ts-ignore
5
+ import PrettierWorker from './worker?worker';
6
+
7
+ // The time in milliseconds by which auto-saving is debounced.
8
+ const autoPrettierDebounceTimeMS = 1200;
9
+
10
+ // Computes a file extension from a file name.
11
+ // Example: 'foo.js' => '.js'
12
+ const extension = (fileName: string) => {
13
+ const match = fileName.match(/\.([^.]+)$/);
14
+ if (match) {
15
+ return match[1];
16
+ } else {
17
+ return '';
18
+ }
19
+ };
20
+
21
+ export const usePrettier = (
22
+ shareDBDoc: ShareDBDoc<VZCodeContent> | null,
23
+ submitOperation,
24
+ ) => {
25
+ // The set of files that have been modified
26
+ // since the last Prettier run.
27
+ const dirtyFiles: Set<FileId> = new Set<FileId>();
28
+
29
+ // A ref to keep track of whether an op is being applied.
30
+ // This is used to prevent Prettier from running
31
+ // when the op is coming from Prettier itself.
32
+ const isApplyingOpRef = useRef(false);
33
+
34
+ useEffect(() => {
35
+ if (!shareDBDoc) {
36
+ return;
37
+ }
38
+
39
+ const prettierWorker = new PrettierWorker();
40
+
41
+ // Get the message that comes back from the worker.
42
+ // This is the prettified text for a specific file.
43
+ const handleMessage = (event) => {
44
+ const { fileId, error, fileTextPrettified } = event.data;
45
+ if (error) {
46
+ console.log(error);
47
+ return;
48
+ }
49
+
50
+ isApplyingOpRef.current = true;
51
+ submitOperation((document) => ({
52
+ ...document,
53
+ files: {
54
+ ...document.files,
55
+ [fileId]: {
56
+ ...document.files[fileId],
57
+ text: fileTextPrettified,
58
+ },
59
+ },
60
+ }));
61
+ isApplyingOpRef.current = false;
62
+ };
63
+
64
+ // Listen for messages from the worker.
65
+ prettierWorker.addEventListener('message', handleMessage);
66
+
67
+ const runPrettier = async () => {
68
+ // Get the content of the document
69
+ const content = shareDBDoc.data;
70
+
71
+ // Get the files
72
+ const files = content.files;
73
+
74
+ // Get the dirty files
75
+ const dirtyFileIds = Array.from(dirtyFiles);
76
+
77
+ // Clear the set of dirty files
78
+ dirtyFiles.clear();
79
+
80
+ // Run Prettier on each dirty file
81
+ for (const fileId of dirtyFileIds) {
82
+ // Get the file
83
+ const file = files[fileId];
84
+
85
+ // Craft the data to send to the worker
86
+ const data = {
87
+ fileText: file.text,
88
+ fileExtension: extension(file.name),
89
+ fileId,
90
+ };
91
+
92
+ // Run Prettier for this file
93
+ prettierWorker.postMessage(data);
94
+ }
95
+ };
96
+
97
+ // Function to debounce running Prettier
98
+ let debounceTimeout;
99
+ function debouncePrettier() {
100
+ clearTimeout(debounceTimeout);
101
+ debounceTimeout = setTimeout(runPrettier, autoPrettierDebounceTimeMS);
102
+ }
103
+
104
+ // Listen for changes
105
+ shareDBDoc.on('op batch', (op: JSONOp, isLocal: boolean) => {
106
+ // Only act on changes coming from the client
107
+ if (isLocal) {
108
+ // If the op is coming from Prettier itself,
109
+ // then do nothing.
110
+ if (isApplyingOpRef.current) {
111
+ // console.log('ignoring op from Prettier');
112
+ return;
113
+ }
114
+
115
+ // Example op that we want to act on:
116
+ // op [
117
+ // "files",
118
+ // "2514857669",
119
+ // "text",
120
+ // {
121
+ // "es": [
122
+ // 18,
123
+ // "d"
124
+ // ]
125
+ // }
126
+ // ]
127
+
128
+ // Check if the path of this op is the text content of a file
129
+ if (
130
+ op &&
131
+ op.length === 4 &&
132
+ op[0] === 'files' &&
133
+ typeof op[1] === 'string' &&
134
+ op[2] === 'text'
135
+ ) {
136
+ // Get the file id
137
+ const fileId: FileId = op[1];
138
+
139
+ // Add the file id to the set of dirty files
140
+ dirtyFiles.add(fileId);
141
+
142
+ // Debounce running Prettier
143
+ debouncePrettier();
144
+
145
+ // console.log('Op is for file', fileId);
146
+ }
147
+ } else {
148
+ // console.log('ignoring op from remote', op, isLocal);
149
+ }
150
+
151
+ // In the event that the component unmounts,
152
+ return () => {
153
+ // Remove the event listener
154
+ prettierWorker.removeEventListener('message', handleMessage);
155
+
156
+ // Clear the timeout
157
+ clearTimeout(debounceTimeout);
158
+ };
159
+ });
160
+ }, [shareDBDoc]);
161
+ };
@@ -0,0 +1,93 @@
1
+ import { format } from 'prettier/standalone';
2
+ import * as prettierPluginBabel from 'prettier/plugins/babel';
3
+ import * as prettierPluginEstree from 'prettier/plugins/estree';
4
+ import * as prettierPluginHtml from 'prettier/plugins/html';
5
+ import * as prettierPluginMarkdown from 'prettier/plugins/markdown';
6
+ import * as prettierPluginCSS from 'prettier/plugins/postcss';
7
+ // import * as prettierPluginYaml from 'prettier/plugins/yaml';
8
+ // import * as prettierPluginGraphql from 'prettier/plugins/graphql';
9
+ // import * as prettierPluginAngular from 'prettier/plugins/angular';
10
+ import * as prettierPluginTypescript from 'prettier/plugins/typescript';
11
+
12
+ import { FileId } from '../../types';
13
+
14
+ const parsers = {
15
+ js: 'babel',
16
+ jsx: 'babel',
17
+ mjs: 'babel',
18
+ cjs: 'babel',
19
+ ts: 'typescript',
20
+ tsx: 'typescript',
21
+ css: 'css',
22
+ // '.less': 'less',
23
+ // '.scss': 'scss',
24
+ html: 'html',
25
+ json: 'json',
26
+ json5: 'json5',
27
+ // '.graphql': 'graphql',
28
+ // '.gql': 'graphql',
29
+ md: 'markdown',
30
+ markdown: 'markdown',
31
+ // '.yaml': 'yaml',
32
+ // '.yml': 'yaml',
33
+ // '.vue': 'vue',
34
+ // '.component.html': 'angular',
35
+ };
36
+
37
+ const plugins = [
38
+ prettierPluginBabel,
39
+ prettierPluginEstree,
40
+ prettierPluginHtml,
41
+ prettierPluginMarkdown,
42
+ prettierPluginTypescript,
43
+ prettierPluginCSS,
44
+ ];
45
+
46
+ onmessage = async ({
47
+ data,
48
+ }: {
49
+ data: {
50
+ // The text content of the file
51
+ fileText: string;
52
+
53
+ // The file extension
54
+ // Supported extensions: https://prettier.io/docs/en/options.html#parser
55
+ // VizHub only supports
56
+ // - JavaScript
57
+ // - TypeScript
58
+ // - HTML
59
+ // - CSS
60
+ // - JSON
61
+ // - Markdown
62
+
63
+ fileExtension: string;
64
+
65
+ // The file id
66
+ fileId: FileId;
67
+ };
68
+ }) => {
69
+ const { fileExtension, fileText, fileId } = data;
70
+ const parser = parsers[fileExtension];
71
+
72
+ if (!parser) {
73
+ postMessage({
74
+ fileId,
75
+ error: `Unsupported file extension for Prettier: ${fileExtension}`,
76
+ });
77
+ return;
78
+ }
79
+
80
+ try {
81
+ const fileTextPrettified = await format(fileText, { parser, plugins });
82
+
83
+ postMessage({
84
+ fileId,
85
+ fileTextPrettified,
86
+ });
87
+ } catch (error) {
88
+ postMessage({
89
+ fileId,
90
+ error,
91
+ });
92
+ }
93
+ };
@@ -18,6 +18,10 @@ dotenv.config({ path: '../../.env' });
18
18
  // The time in milliseconds by which auto-saving is debounced.
19
19
  const autoSaveDebounceTimeMS = 800;
20
20
 
21
+ // The time in milliseconds by which auto-saving is throttled
22
+ // when the user is interacting with the widgets in the editor.
23
+ const throttleTimeMS = 100;
24
+
21
25
  // Server port.
22
26
  const port = 3030;
23
27
 
@@ -49,7 +53,23 @@ const shareDBBackend = new ShareDB({
49
53
  const server = http.createServer(app);
50
54
  const wss = new WebSocketServer({ server });
51
55
  wss.on('connection', (ws) => {
52
- shareDBBackend.listen(new WebSocketJSONStream(ws));
56
+ const clientStream = new WebSocketJSONStream(ws);
57
+ shareDBBackend.listen(clientStream);
58
+
59
+ // Prevent server crashes on errors.
60
+ clientStream.on('error', (error) => {
61
+ console.log('clientStream error: ' + error.message);
62
+ });
63
+
64
+ // Handle errors
65
+ ws.on('error', (error) => {
66
+ console.log('ws error: ' + error.message);
67
+ });
68
+
69
+ // Handle disconnections
70
+ ws.on('close', (code) => {
71
+ clientStream.end();
72
+ });
53
73
  });
54
74
 
55
75
  // Serve static files
@@ -69,7 +89,6 @@ let previousDocument = initialDocument;
69
89
 
70
90
  // Saves the files that changed.
71
91
  const save = () => {
72
- console.log('saving');
73
92
  const currentDocument = shareDBDoc.data;
74
93
 
75
94
  // Take a look at each file (key) previously and currently.
@@ -119,31 +138,37 @@ const save = () => {
119
138
  // });
120
139
  // });
121
140
 
122
- const throttleTimeMS = 200;
123
-
124
141
  let lastExecutedTime = Date.now();
142
+ let lastChangeTimeout; // This timeout ensures the last change is saved
125
143
 
126
144
  // Function to throttle the saving
127
145
  function throttleSave() {
128
- console.log('throttling');
129
146
  const now = Date.now();
130
- if (now - lastExecutedTime > throttleTimeMS) {
147
+ const timeSinceLastExecution = now - lastExecutedTime;
148
+
149
+ if (timeSinceLastExecution > throttleTimeMS) {
131
150
  save();
132
151
  lastExecutedTime = now;
152
+ } else {
153
+ // Clear the previous timeout and set a new one to save the last change
154
+ clearTimeout(lastChangeTimeout);
155
+ lastChangeTimeout = setTimeout(() => {
156
+ save();
157
+ lastExecutedTime = now;
158
+ }, throttleTimeMS - timeSinceLastExecution);
133
159
  }
134
160
  }
135
161
 
136
162
  // Function to debounce the saving
137
163
  let debounceTimeout;
138
164
  function debounceSave() {
139
- console.log('debouncing');
140
165
  clearTimeout(debounceTimeout);
141
166
  debounceTimeout = setTimeout(save, autoSaveDebounceTimeMS);
142
167
  }
143
168
 
144
169
  // Subscribe to listen for modifications
145
170
  shareDBDoc.subscribe(() => {
146
- shareDBDoc.on('op', () => {
171
+ shareDBDoc.on('op', (op) => {
147
172
  if (shareDBDoc.data.isInteracting) {
148
173
  throttleSave();
149
174
  } else {
package/src/types.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  // FileId
2
2
  // * A unique ID for a file.
3
+
4
+ import { JSONOp } from 'ot-json1';
5
+
3
6
  // * This is a random string.
4
7
  export type FileId = string;
5
8
 
@@ -64,6 +67,22 @@ export type ShareDBDoc<T> = {
64
67
  data: T;
65
68
  ingestSnapshot: (snapshot: any, callback) => void;
66
69
  subscribe: (callback) => void;
67
- on: (event: string, callback: () => void) => void;
70
+ on: (event: string, callback: (op: JSONOp, source: boolean) => void) => void;
68
71
  off: (event: string, callback: () => void) => void;
72
+ submitOp: (op: JSONOp, options?: any, callback?: () => void) => void;
73
+ };
74
+
75
+ // The ShareDB document type for VZCode.
76
+ export type VZCodeContent = {
77
+ // `files`
78
+ // * The files in the VZCode instance.
79
+ files: Files;
80
+
81
+ // `isInteracting`
82
+ // * `true` when the user is interacting
83
+ // via interactive code widgets (e.g. Alt+drag)
84
+ // * Hot reloading is throttled when this is `true`.
85
+ // * `false` when they are not (e.g. normal typing)
86
+ // * Hot reloading is debounced when this is `false`.
87
+ isInteracting: boolean;
69
88
  };