vzcode 0.2.0 → 0.3.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/README.md +30 -17
- package/cli.js +5 -0
- package/dist/assets/{index-5e86a45f.js → index-4694dca8.js} +33 -33
- package/dist/assets/index-58524f84.css +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.html +3 -4
- package/package.json +3 -2
- package/src/App.jsx +315 -0
- package/src/CodeEditor.jsx +31 -0
- package/src/diff.js +6 -0
- package/src/getOrCreateEditor.js +85 -0
- package/src/json1PresenceBroadcast.js +26 -0
- package/src/json1PresenceDisplay.js +143 -0
- package/src/main.jsx +9 -0
- package/src/ot.js +5 -0
- package/src/randomId.js +2 -0
- package/src/settings.jsx +141 -0
- package/src/style.css +207 -0
- package/dist/assets/index-5c2f5f84.css +0 -1
- package/dist/vite.svg +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import"https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap";@import"https://unpkg.com/boxicons@2.0.7/css/boxicons.min.css";@import"https://unpkg.com/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css";*{margin:0;padding:0;box-sizing:border-box}::-webkit-scrollbar{width:10px;height:20px}::-webkit-scrollbar-track{background:rgb(41,44,52)}::-webkit-scrollbar-thumb{background:rgb(37,37,38)}::-webkit-scrollbar-thumb:hover{background:#555}:root{--main-font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace}body{background:rgb(41,44,52);font-family:Poppins,sans-serif;overflow-x:hidden}.tab{width:max-content;height:30px;background:rgb(45,45,45);text-align:center;color:#fff;font-family:var(--main-font-family);justify-content:center;align-items:center;display:flex;padding-left:10px;padding-right:10px;border-top-left-radius:10px;border-top-right-radius:10px;margin-top:5px;margin-left:2px;margin-right:2px;cursor:pointer}.tab.active{background-color:#292c34}.tab-close{display:flex;cursor:pointer;margin-left:5px;justify-content:center;align-items:center}.tab-close:hover{border-radius:30%;background-color:#252526}.editor{position:relative;margin-left:200px;width:100%;height:100%;top:30px;bottom:0;background-color:#292c34;color:#fff;font-family:var(--main-font-family)}.tab-list{position:fixed;display:flex;flex-direction:row;top:0;left:200px;height:35px;width:100%;background:rgb(37,37,38);z-index:100;transition:all .5s ease}.bottom-bar{position:absolute;bottom:0;left:0;height:20px;width:100%;background:rgb(45,45,45);z-index:100;transition:all .5s ease}.sidebar{position:fixed;top:0;left:0;height:100%;width:200px;background:rgb(54,65,89);z-index:100;transition:all .5s ease}.link-name{margin-top:30px;margin-left:10px;color:#9aa0ac;font-size:12px;text-decoration:none}.file{display:flex;flex-direction:row;align-items:center;justify-content:left;width:180px;cursor:pointer;margin-left:10px;color:#dbdde1;border-radius:5px}.full-Box{width:inherit;height:inherit;padding:7px;display:flex;flex-direction:row;align-items:center;justify-content:space-between}.utilities{justify-content:right;margin-right:3px}.noUtils{display:none}.settings{position:absolute;bottom:0;height:30px;width:180px;z-index:100;color:#dbdde1;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:14px 0;margin:10px;border-radius:5px}.file:hover,.settings:hover{background-color:#4a5469}.newBTN{justify-content:right;margin-right:10px;cursor:pointer}.themes{color:#252526}.name{text-overflow:ellipsis;white-space:nowrap;overflow:hidden;max-width:130px}
|
package/dist/favicon.ico
ADDED
|
Binary file
|
package/dist/index.html
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
-
<title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" href="/assets/index-
|
|
6
|
+
<title>VZCode</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-4694dca8.js"></script>
|
|
8
|
+
<link rel="stylesheet" href="/assets/index-58524f84.css">
|
|
10
9
|
</head>
|
|
11
10
|
<body>
|
|
12
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vzcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Multiplayer code editor system",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": "cli.js",
|
|
8
8
|
"files": [
|
|
9
|
-
"dist/**"
|
|
9
|
+
"dist/**",
|
|
10
|
+
"src/**"
|
|
10
11
|
],
|
|
11
12
|
"scripts": {
|
|
12
13
|
"test": "npm run build; cd test; node ../cli.js",
|
package/src/App.jsx
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
+
import ShareDBClient from 'sharedb-client-browser/dist/sharedb-client-umd.cjs';
|
|
3
|
+
import { json1Presence } from './ot';
|
|
4
|
+
import { CodeEditor } from './CodeEditor';
|
|
5
|
+
import { diff } from './diff';
|
|
6
|
+
import { randomId } from './randomId';
|
|
7
|
+
import { Settings } from './settings';
|
|
8
|
+
import './style.css';
|
|
9
|
+
|
|
10
|
+
// Register our custom JSON1 OT type that supports presence.
|
|
11
|
+
// See https://github.com/vizhub-core/json1-presence
|
|
12
|
+
ShareDBClient.types.register(json1Presence.type);
|
|
13
|
+
|
|
14
|
+
// Establish the singleton ShareDB connection over WebSockets.
|
|
15
|
+
// TODO consider using reconnecting WebSocket
|
|
16
|
+
const { Connection } = ShareDBClient;
|
|
17
|
+
const socket = new WebSocket('ws://' + window.location.host + '/ws');
|
|
18
|
+
const connection = new Connection(socket);
|
|
19
|
+
|
|
20
|
+
// A feature flag to disable settings for release, until it's completed.
|
|
21
|
+
// See https://vitejs.dev/guide/env-and-mode.html
|
|
22
|
+
const disableSettings = import.meta.env.VITE_DISABLE_SETTINGS === 'true';
|
|
23
|
+
|
|
24
|
+
function App() {
|
|
25
|
+
// The ShareDB document.
|
|
26
|
+
const [shareDBDoc, setShareDBDoc] = useState(null);
|
|
27
|
+
|
|
28
|
+
// Local ShareDB presence, for broadcasting our cursor position
|
|
29
|
+
// so other clients can see it.
|
|
30
|
+
// See https://share.github.io/sharedb/api/local-presence
|
|
31
|
+
const [localPresence, setLocalPresence] = useState(null);
|
|
32
|
+
|
|
33
|
+
// The document-level presence object, which emits
|
|
34
|
+
// changes in remote presence.
|
|
35
|
+
const [docPresence, setDocPresence] = useState(null);
|
|
36
|
+
|
|
37
|
+
// The `doc.data` part of the ShareDB document,
|
|
38
|
+
// updated on each change to decouple rendering from ShareDB.
|
|
39
|
+
const [data, setData] = useState(null);
|
|
40
|
+
|
|
41
|
+
// True if the file menu is open.
|
|
42
|
+
const [isFileMenuOpen, setIsFileMenuOpen] = useState(false);
|
|
43
|
+
|
|
44
|
+
// The id of the currently open file tab.
|
|
45
|
+
const [activeFileId, setActiveFileId] = useState(null);
|
|
46
|
+
|
|
47
|
+
// The ordered list of tabs.
|
|
48
|
+
const [tabList, setTabList] = useState([]);
|
|
49
|
+
|
|
50
|
+
// The current theme.
|
|
51
|
+
const [theme, setTheme] = useState('OneDark');
|
|
52
|
+
|
|
53
|
+
// The currently hovered over file, for showing
|
|
54
|
+
// "utils", meaning buttons for rename and delete.
|
|
55
|
+
const [utils, setUtils] = useState(false);
|
|
56
|
+
|
|
57
|
+
// True to show the settings modal.
|
|
58
|
+
const [settings, setSettings] = useState(false);
|
|
59
|
+
|
|
60
|
+
// Set up the connection to ShareDB.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
// Since there is only ever a single document,
|
|
63
|
+
// these things are pretty arbitrary.
|
|
64
|
+
// * `collection` - the ShareDB collection to use
|
|
65
|
+
// * `id` - the id of the ShareDB document to use
|
|
66
|
+
const collection = 'documents';
|
|
67
|
+
const id = '1';
|
|
68
|
+
|
|
69
|
+
// Initialize the ShareDB document.
|
|
70
|
+
const shareDBDoc = connection.get(collection, id);
|
|
71
|
+
|
|
72
|
+
// Subscribe to the document to get updates.
|
|
73
|
+
// This callback gets called once only.
|
|
74
|
+
shareDBDoc.subscribe(() => {
|
|
75
|
+
// Expose ShareDB doc to downstream logic.
|
|
76
|
+
setShareDBDoc(shareDBDoc);
|
|
77
|
+
|
|
78
|
+
// Set initial data.
|
|
79
|
+
setData(shareDBDoc.data);
|
|
80
|
+
|
|
81
|
+
// Listen for all changes and update `data`.
|
|
82
|
+
// This decouples rendering logic from ShareDB.
|
|
83
|
+
// This callback gets called on each change.
|
|
84
|
+
shareDBDoc.on('op', (op) => {
|
|
85
|
+
setData(shareDBDoc.data);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Set up presence.
|
|
89
|
+
// See https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js#L53
|
|
90
|
+
const docPresence = shareDBDoc.connection.getDocPresence(collection, id);
|
|
91
|
+
|
|
92
|
+
// Subscribe to receive remote presence updates.
|
|
93
|
+
docPresence.subscribe(function (error) {
|
|
94
|
+
if (error) throw error;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Set up our local presence for broadcasting this client's presence.
|
|
98
|
+
setLocalPresence(docPresence.create(randomId()));
|
|
99
|
+
|
|
100
|
+
// Store docPresence so child components can listen for changes.
|
|
101
|
+
setDocPresence(docPresence);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// TODO unsubscribe from presence
|
|
105
|
+
// TODO unsubscribe from doc
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
// Called when a tab is closed.
|
|
109
|
+
const closeTab = useCallback(
|
|
110
|
+
(fileIdToRemove) => {
|
|
111
|
+
const i = tabList.findIndex((fileId) => fileId === fileIdToRemove);
|
|
112
|
+
|
|
113
|
+
// Support calling `closeTab` on a fileId that is not open,
|
|
114
|
+
// such as when a file is deleted..
|
|
115
|
+
if (i !== -1) {
|
|
116
|
+
// Remove the tab from the tab list.
|
|
117
|
+
const newTabList = [...tabList.slice(0, i), ...tabList.slice(i + 1)];
|
|
118
|
+
setTabList(newTabList);
|
|
119
|
+
|
|
120
|
+
// If we are closing the active file,
|
|
121
|
+
if (activeFileId === fileIdToRemove) {
|
|
122
|
+
// set the new active file to the next tab over,
|
|
123
|
+
if (newTabList.length > 0) {
|
|
124
|
+
setActiveFileId(i === 0 ? newTabList[i] : newTabList[i - 1]);
|
|
125
|
+
} else {
|
|
126
|
+
// or clear out the active file
|
|
127
|
+
// if we've closed the last tab.
|
|
128
|
+
setActiveFileId(null);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[tabList, activeFileId]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const handleCloseTabClick = useCallback(
|
|
137
|
+
(fileIdToRemove) => (event) => {
|
|
138
|
+
// Stop propagation so that the outer listener doesn't fire.
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
closeTab(fileIdToRemove);
|
|
141
|
+
},
|
|
142
|
+
[closeTab]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Called when a file in the sidebar is double-clicked.
|
|
146
|
+
const renameFile = useCallback(
|
|
147
|
+
(key) => {
|
|
148
|
+
const newName = prompt('Enter new name');
|
|
149
|
+
if (newName) {
|
|
150
|
+
const currentDocument = shareDBDoc.data;
|
|
151
|
+
const nextDocument = {
|
|
152
|
+
...currentDocument,
|
|
153
|
+
[key]: {
|
|
154
|
+
...currentDocument[key],
|
|
155
|
+
name: newName,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
shareDBDoc.submitOp(diff(currentDocument, nextDocument));
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
[shareDBDoc]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const deleteFile = useCallback(
|
|
165
|
+
(key) => {
|
|
166
|
+
// Close the tab in case it's open.
|
|
167
|
+
// TODO consistently use either "key" or "fileId" naming
|
|
168
|
+
closeTab(key);
|
|
169
|
+
|
|
170
|
+
const currentDocument = shareDBDoc.data;
|
|
171
|
+
const nextDocument = { ...currentDocument };
|
|
172
|
+
delete nextDocument[key];
|
|
173
|
+
shareDBDoc.submitOp(diff(currentDocument, nextDocument));
|
|
174
|
+
},
|
|
175
|
+
[shareDBDoc, closeTab]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// TODO prompt the user "Are you sure?"
|
|
179
|
+
const handleDeleteFileClick = useCallback(
|
|
180
|
+
(fileId) => (event) => {
|
|
181
|
+
// Stop propagation so that the outer listener doesn't fire,
|
|
182
|
+
// which would try to open this file in a tab.
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
deleteFile(fileId);
|
|
185
|
+
},
|
|
186
|
+
[deleteFile]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const createFile = useCallback(() => {
|
|
190
|
+
const name = prompt('Enter new file name');
|
|
191
|
+
if (!name) return;
|
|
192
|
+
const currentDocument = shareDBDoc.data;
|
|
193
|
+
const nextDocument = {
|
|
194
|
+
...currentDocument,
|
|
195
|
+
[randomId()]: { name, text: '' },
|
|
196
|
+
};
|
|
197
|
+
shareDBDoc.submitOp(diff(currentDocument, nextDocument));
|
|
198
|
+
}, [shareDBDoc]);
|
|
199
|
+
|
|
200
|
+
// True if we are ready to actually render the active tab.
|
|
201
|
+
const tabValid = data && activeFileId;
|
|
202
|
+
|
|
203
|
+
const handleSettingsClose = useCallback(() => {
|
|
204
|
+
setSettings(false);
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<>
|
|
209
|
+
<Settings
|
|
210
|
+
show={settings}
|
|
211
|
+
onClose={handleSettingsClose}
|
|
212
|
+
setTheme={setTheme}
|
|
213
|
+
/>
|
|
214
|
+
<div className="tab-list">
|
|
215
|
+
{tabList.map((fileId) => (
|
|
216
|
+
<div
|
|
217
|
+
key={fileId}
|
|
218
|
+
className={
|
|
219
|
+
tabValid ? `tab${fileId === activeFileId ? ' active' : ''}` : null
|
|
220
|
+
}
|
|
221
|
+
onClick={() => {
|
|
222
|
+
setActiveFileId(fileId);
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
{tabValid ? data[fileId].name : ''}
|
|
226
|
+
<div
|
|
227
|
+
className={activeFileId ? 'bx bx-x tab-close' : ''}
|
|
228
|
+
onClick={handleCloseTabClick(fileId)}
|
|
229
|
+
></div>
|
|
230
|
+
</div>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
<div className="sidebar show">
|
|
234
|
+
<div className="nav-links">
|
|
235
|
+
<div className="show-menu">
|
|
236
|
+
<div className="sub-menu">
|
|
237
|
+
<div className="files">
|
|
238
|
+
<div className="full-Box">
|
|
239
|
+
<div>
|
|
240
|
+
<a className="link-name" href="#">
|
|
241
|
+
Files
|
|
242
|
+
</a>
|
|
243
|
+
</div>
|
|
244
|
+
<div>
|
|
245
|
+
<i
|
|
246
|
+
className="bx bxs-file-plus newBTN"
|
|
247
|
+
style={{ color: '#dbdde1' }}
|
|
248
|
+
onClick={createFile}
|
|
249
|
+
></i>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
{data
|
|
254
|
+
? Object.keys(data).map((key) => (
|
|
255
|
+
<div className="file" key={key}>
|
|
256
|
+
<div
|
|
257
|
+
className="full-Box"
|
|
258
|
+
onClick={() => {
|
|
259
|
+
setActiveFileId(key);
|
|
260
|
+
if (!tabList.includes(key)) {
|
|
261
|
+
setTabList([...tabList, key]);
|
|
262
|
+
}
|
|
263
|
+
}}
|
|
264
|
+
onMouseEnter={() => {
|
|
265
|
+
setUtils(key);
|
|
266
|
+
}}
|
|
267
|
+
onMouseLeave={() => {
|
|
268
|
+
setUtils(null);
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<div className={utils === key ? 'name' : ''}>
|
|
272
|
+
{data[key].name}
|
|
273
|
+
</div>
|
|
274
|
+
<div className={utils === key ? 'utils' : 'noUtils'}>
|
|
275
|
+
<i
|
|
276
|
+
className="bx bxs-edit utilities"
|
|
277
|
+
style={{ color: '#abdafb' }}
|
|
278
|
+
onClick={() => {
|
|
279
|
+
renameFile(key);
|
|
280
|
+
}}
|
|
281
|
+
></i>
|
|
282
|
+
<i
|
|
283
|
+
className="bx bx-trash"
|
|
284
|
+
style={{ color: '#eb336c' }}
|
|
285
|
+
onClick={handleDeleteFileClick(key)}
|
|
286
|
+
></i>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
))
|
|
291
|
+
: null}
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
{disableSettings ? null : (
|
|
295
|
+
<div className="settings" onClick={() => setSettings(!settings)}>
|
|
296
|
+
Settings
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
{data && activeFileId ? (
|
|
302
|
+
<CodeEditor
|
|
303
|
+
className="editor"
|
|
304
|
+
shareDBDoc={shareDBDoc}
|
|
305
|
+
localPresence={localPresence}
|
|
306
|
+
docPresence={docPresence}
|
|
307
|
+
activeFileId={activeFileId}
|
|
308
|
+
theme={theme}
|
|
309
|
+
/>
|
|
310
|
+
) : null}
|
|
311
|
+
</>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export default App;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useRef, useLayoutEffect } from 'react';
|
|
2
|
+
import { getOrCreateEditor } from './getOrCreateEditor';
|
|
3
|
+
|
|
4
|
+
export const CodeEditor = ({
|
|
5
|
+
activeFileId,
|
|
6
|
+
shareDBDoc,
|
|
7
|
+
localPresence,
|
|
8
|
+
docPresence,
|
|
9
|
+
theme,
|
|
10
|
+
}) => {
|
|
11
|
+
const ref = useRef();
|
|
12
|
+
|
|
13
|
+
// useEffect was buggy in that sometimes ref.current was undefined.
|
|
14
|
+
// useLayoutEffect seems to solve that issue.
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
const editor = getOrCreateEditor({
|
|
17
|
+
fileId: activeFileId,
|
|
18
|
+
shareDBDoc,
|
|
19
|
+
localPresence,
|
|
20
|
+
docPresence,
|
|
21
|
+
theme,
|
|
22
|
+
});
|
|
23
|
+
ref.current.appendChild(editor.dom);
|
|
24
|
+
|
|
25
|
+
return () => {
|
|
26
|
+
ref.current.removeChild(editor.dom);
|
|
27
|
+
};
|
|
28
|
+
}, [shareDBDoc, activeFileId]);
|
|
29
|
+
|
|
30
|
+
return <div className="editor" ref={ref} />;
|
|
31
|
+
};
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { EditorView, basicSetup } from 'codemirror';
|
|
2
|
+
import { EditorState } from '@codemirror/state';
|
|
3
|
+
import { javascript } from '@codemirror/lang-javascript';
|
|
4
|
+
import { markdown } from '@codemirror/lang-markdown';
|
|
5
|
+
import { html } from '@codemirror/lang-html';
|
|
6
|
+
import { css } from '@codemirror/lang-css';
|
|
7
|
+
import { oneDark } from '@codemirror/theme-one-dark';
|
|
8
|
+
import { json1Sync } from 'codemirror-ot';
|
|
9
|
+
import { json1Presence, textUnicode } from './ot';
|
|
10
|
+
import { json1PresenceBroadcast } from './json1PresenceBroadcast';
|
|
11
|
+
import { json1PresenceDisplay } from './json1PresenceDisplay';
|
|
12
|
+
|
|
13
|
+
// Singleton cache of CodeMirror instances
|
|
14
|
+
// These are created, but never destroyed.
|
|
15
|
+
// TODO consider invalidating this cache at some point.
|
|
16
|
+
// * Keys are file ids
|
|
17
|
+
// * Values are CodeMirror instances
|
|
18
|
+
const editorCache = new Map();
|
|
19
|
+
|
|
20
|
+
// Gets or creates a CodeMirror editor for the given file id.
|
|
21
|
+
export const getOrCreateEditor = ({
|
|
22
|
+
fileId,
|
|
23
|
+
shareDBDoc,
|
|
24
|
+
localPresence,
|
|
25
|
+
docPresence,
|
|
26
|
+
theme,
|
|
27
|
+
}) => {
|
|
28
|
+
const data = shareDBDoc.data;
|
|
29
|
+
|
|
30
|
+
const fileExtension = data[fileId].name.split('.').pop();
|
|
31
|
+
|
|
32
|
+
// The path for this file in the ShareDB document.
|
|
33
|
+
const path = [fileId, 'text'];
|
|
34
|
+
|
|
35
|
+
const extensions = [
|
|
36
|
+
// This plugin implements multiplayer editing,
|
|
37
|
+
// real-time synchronozation of changes across clients.
|
|
38
|
+
// Does not deal with showing others' cursors.
|
|
39
|
+
json1Sync({
|
|
40
|
+
shareDBDoc,
|
|
41
|
+
path,
|
|
42
|
+
json1: json1Presence,
|
|
43
|
+
textUnicode,
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
// Deals with broadcasting changes in cursor location and selection.
|
|
47
|
+
json1PresenceBroadcast({ path, localPresence }),
|
|
48
|
+
|
|
49
|
+
// Deals with receiving the broadcas from other clients and displaying them.
|
|
50
|
+
json1PresenceDisplay({ path, docPresence }),
|
|
51
|
+
|
|
52
|
+
basicSetup,
|
|
53
|
+
oneDark,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
fileExtension === 'js' ||
|
|
58
|
+
fileExtension === 'json' ||
|
|
59
|
+
fileExtension == 'jsx' ||
|
|
60
|
+
fileExtension == 'ts'
|
|
61
|
+
) {
|
|
62
|
+
extensions.push(javascript());
|
|
63
|
+
} else if (fileExtension === 'html') {
|
|
64
|
+
extensions.push(html());
|
|
65
|
+
} else if (fileExtension === 'css') {
|
|
66
|
+
extensions.push(css());
|
|
67
|
+
} else if (fileExtension === 'md') {
|
|
68
|
+
extensions.push(markdown());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let editor = editorCache.get(fileId);
|
|
72
|
+
if (!editor) {
|
|
73
|
+
// Cache miss --> mint a new editor.
|
|
74
|
+
editor = new EditorView({
|
|
75
|
+
state: EditorState.create({
|
|
76
|
+
doc: data[fileId].text,
|
|
77
|
+
extensions,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Populate the cache.
|
|
82
|
+
editorCache.set(fileId, editor);
|
|
83
|
+
}
|
|
84
|
+
return editor;
|
|
85
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { EditorView } from 'codemirror';
|
|
2
|
+
|
|
3
|
+
// Deals with broadcasting changes in cursor location and selection.
|
|
4
|
+
export const json1PresenceBroadcast = ({ path, localPresence }) =>
|
|
5
|
+
// See https://discuss.codemirror.net/t/codemirror-6-proper-way-to-listen-for-changes/2395/10
|
|
6
|
+
EditorView.updateListener.of((viewUpdate) => {
|
|
7
|
+
// If this update modified the cursor / selection,
|
|
8
|
+
// we broadcast the selection update via ShareDB presence.
|
|
9
|
+
if (viewUpdate.selectionSet) {
|
|
10
|
+
// Isolate the single selection to use for presence.
|
|
11
|
+
// Unfortunately JSON1 with presence does not yet
|
|
12
|
+
// support multiple selections.
|
|
13
|
+
// See https://github.com/ottypes/json1/pull/25#issuecomment-1459616521
|
|
14
|
+
const selection = viewUpdate.state.selection.ranges[0];
|
|
15
|
+
const { from, to } = selection;
|
|
16
|
+
|
|
17
|
+
// Translate this into the form expected by json1Presence.
|
|
18
|
+
const presence = { start: [...path, from], end: [...path, to] };
|
|
19
|
+
|
|
20
|
+
// Broadcast presence to remote clients!
|
|
21
|
+
// See https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js#L71
|
|
22
|
+
localPresence.submit(presence, (error) => {
|
|
23
|
+
if (error) throw error;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ViewPlugin,
|
|
3
|
+
EditorView,
|
|
4
|
+
WidgetType,
|
|
5
|
+
Decoration,
|
|
6
|
+
} from '@codemirror/view';
|
|
7
|
+
import { Annotation, RangeSet } from '@codemirror/state';
|
|
8
|
+
import { randomId } from './randomId';
|
|
9
|
+
|
|
10
|
+
// Deals with receiving the broadcasted presence cursor locations
|
|
11
|
+
// from other clients and displaying them.
|
|
12
|
+
//
|
|
13
|
+
// Inspired by
|
|
14
|
+
// * https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js
|
|
15
|
+
// * https://codemirror.net/examples/decoration/
|
|
16
|
+
// * https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js
|
|
17
|
+
// * https://share.github.io/sharedb/presence
|
|
18
|
+
export const json1PresenceDisplay = ({ path, docPresence }) => [
|
|
19
|
+
ViewPlugin.fromClass(
|
|
20
|
+
class {
|
|
21
|
+
constructor(view) {
|
|
22
|
+
// Initialize decorations to empty array so CodeMirror doesn't crash.
|
|
23
|
+
this.decorations = RangeSet.of([]);
|
|
24
|
+
|
|
25
|
+
// Mutable state local to this closure representing aggregated presence.
|
|
26
|
+
// * Keys are presence ids
|
|
27
|
+
// * Values are presence objects as defined by ot-json1-presence
|
|
28
|
+
const presenceState = {};
|
|
29
|
+
|
|
30
|
+
// Receive remote presence changes.
|
|
31
|
+
docPresence.on('receive', (id, presence) => {
|
|
32
|
+
// If presence === null, the user has disconnected / exited
|
|
33
|
+
// We also check if the presence is for the current file or not.
|
|
34
|
+
if (presence && pathMatches(path, presence)) {
|
|
35
|
+
presenceState[id] = presence;
|
|
36
|
+
} else {
|
|
37
|
+
delete presenceState[id];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Update decorations to reflect new presence state.
|
|
41
|
+
// TODO consider mutating this rather than recomputing it on each change.
|
|
42
|
+
this.decorations = Decoration.set(
|
|
43
|
+
Object.keys(presenceState).map((id) => {
|
|
44
|
+
const presence = presenceState[id];
|
|
45
|
+
const { start, end } = presence;
|
|
46
|
+
const from = start[start.length - 1];
|
|
47
|
+
// TODO support selection ranges (first attempt introduced layout errors)
|
|
48
|
+
//const to = end[end.length - 1];
|
|
49
|
+
return {
|
|
50
|
+
from,
|
|
51
|
+
//to,
|
|
52
|
+
to: from, // Temporary meaure
|
|
53
|
+
value: Decoration.widget({
|
|
54
|
+
side: -1,
|
|
55
|
+
block: false,
|
|
56
|
+
widget: new PresenceWidget(id),
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
}),
|
|
60
|
+
// Without this argument, we get the following error:
|
|
61
|
+
// Uncaught Error: Ranges must be added sorted by `from` position and `startSide`
|
|
62
|
+
true
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Somehow this triggers re-rendering of the Decorations.
|
|
66
|
+
// Not sure if this is the correct usage of the API.
|
|
67
|
+
// Inspired by https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js
|
|
68
|
+
// Set timeout so that the current CodeMirror update finishes
|
|
69
|
+
// before the next ones that render presence begin.
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
view.dispatch({ annotations: [presenceAnnotation.of(true)] });
|
|
72
|
+
}, 0);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
decorations: (v) => v.decorations,
|
|
78
|
+
}
|
|
79
|
+
),
|
|
80
|
+
presenceTheme,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const presenceAnnotation = Annotation.define();
|
|
84
|
+
|
|
85
|
+
// Checks that the path of this file
|
|
86
|
+
// matches the path of the presence.
|
|
87
|
+
// * If true is returned, the presence is in this file.
|
|
88
|
+
// * If false is returned, the presence is in another file.
|
|
89
|
+
// Assumption: start and end path are the same except the cursor position.
|
|
90
|
+
const pathMatches = (path, presence) => {
|
|
91
|
+
for (let i = 0; i < path.length; i++) {
|
|
92
|
+
if (path[i] !== presence.start[i]) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Displays a single remote presence cursor.
|
|
100
|
+
class PresenceWidget extends WidgetType {
|
|
101
|
+
constructor(id) {
|
|
102
|
+
super();
|
|
103
|
+
this.id = id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
eq(other) {
|
|
107
|
+
return other.id === this.id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
toDOM() {
|
|
111
|
+
const span = document.createElement('span');
|
|
112
|
+
span.setAttribute('aria-hidden', 'true');
|
|
113
|
+
span.className = 'cm-json1-presence';
|
|
114
|
+
|
|
115
|
+
// This child is what actually displays the presence.
|
|
116
|
+
// Nested so that the layout is not impacted.
|
|
117
|
+
//
|
|
118
|
+
// The initial attempt using the top level span to render
|
|
119
|
+
// the cursor caused a wonky layout with adjacent characters shifting
|
|
120
|
+
// left and right by 1 pixel or so.
|
|
121
|
+
span.appendChild(document.createElement('div'));
|
|
122
|
+
return span;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
ignoreEvent() {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const presenceTheme = EditorView.baseTheme({
|
|
131
|
+
'.cm-json1-presence': {
|
|
132
|
+
position: 'relative',
|
|
133
|
+
},
|
|
134
|
+
'.cm-json1-presence > div': {
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
top: '0',
|
|
137
|
+
bottom: '0',
|
|
138
|
+
left: '0',
|
|
139
|
+
right: '0',
|
|
140
|
+
borderLeft: '1px solid yellow',
|
|
141
|
+
//borderRight: '1px solid black',
|
|
142
|
+
},
|
|
143
|
+
});
|
package/src/main.jsx
ADDED
package/src/ot.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// This file is the central point where the OT types are imported.
|
|
2
|
+
// Localized to one file so it's easy to change it in future.
|
|
3
|
+
import OTJSON1Presence from 'sharedb-client-browser/dist/ot-json1-presence-umd.cjs';
|
|
4
|
+
const { json1Presence, textUnicode } = OTJSON1Presence;
|
|
5
|
+
export { json1Presence, textUnicode };
|
package/src/randomId.js
ADDED