easymd-cli 0.1.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 +110 -0
- package/bin/easymd.js +159 -0
- package/client/index.html +49 -0
- package/client/main.js +169 -0
- package/client/styles.css +328 -0
- package/dist/client.js +37659 -0
- package/dist/client.js.map +7 -0
- package/package.json +55 -0
- package/src/cli/auth.js +91 -0
- package/src/cli/auto.js +65 -0
- package/src/cli/config.js +55 -0
- package/src/cli/sync.js +109 -0
- package/src/file-sync.js +85 -0
- package/src/server.js +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# easymd
|
|
2
|
+
|
|
3
|
+
**Google Docs for markdown — except the file on disk stays canonical.**
|
|
4
|
+
|
|
5
|
+
Collaborate on `CLAUDE.md`, `AGENTS.md`, or any `.md` file in real time. The document on screen is the actual file in your repo. No import/export round-trip, no copy-paste tax.
|
|
6
|
+
|
|
7
|
+
## Web app (landing + demo)
|
|
8
|
+
|
|
9
|
+
Professional landing page with **token savings** messaging, **Clerk auth** for signups, and a **protected live demo** at `/demo`.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
cd web
|
|
13
|
+
cp .env.example .env.local # add Clerk keys from dashboard.clerk.com
|
|
14
|
+
npm install
|
|
15
|
+
npm run dev # Next.js + collab WebSocket server
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Open [http://localhost:3000](http://localhost:3000) → Sign up → Live collaborative editor.
|
|
19
|
+
|
|
20
|
+
### Token savings (why markdown for AI)
|
|
21
|
+
|
|
22
|
+
Converting PDFs, Word/DOCX, or HTML to clean `.md` cuts token usage dramatically:
|
|
23
|
+
|
|
24
|
+
| Format | Typical savings |
|
|
25
|
+
|--------|-----------------|
|
|
26
|
+
| Raw HTML | 75–90% |
|
|
27
|
+
| Word / DOCX | 50–70% |
|
|
28
|
+
| Text-based PDF | 40–65% |
|
|
29
|
+
|
|
30
|
+
Markdown strips hidden formatting metadata while keeping structure agents need — then reference `@CLAUDE.md` on demand instead of pasting every prompt.
|
|
31
|
+
|
|
32
|
+
## CLI (local file editing)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install
|
|
36
|
+
npm run build
|
|
37
|
+
easymd open CLAUDE.md
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Share the URL with a teammate. Edits sync live via CRDT. Changes land straight back to the file on disk and into git.
|
|
41
|
+
|
|
42
|
+
## CLI auto-sync (push every .md into your account)
|
|
43
|
+
|
|
44
|
+
Sign in once, then have every markdown file in your repo sync into your easymd account
|
|
45
|
+
automatically — so anything you or an AI agent writes to disk shows up in the dashboard
|
|
46
|
+
and is editable live.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx easymd-cli login # opens your browser, sign in with Clerk, authorize this machine
|
|
50
|
+
npx easymd-cli auto on # background watcher: every new/changed .md syncs to your account
|
|
51
|
+
npx easymd-cli auto status # check it's running
|
|
52
|
+
npx easymd-cli auto off # stop it
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Other commands:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
easymd sync ./docs # one-shot push of all .md under a folder
|
|
59
|
+
easymd watch . # foreground watcher (what `auto on` runs detached)
|
|
60
|
+
easymd whoami # show the logged-in account
|
|
61
|
+
easymd logout # remove credentials + stop auto-sync
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Credentials live in `~/.easymd/credentials.json` (a long-lived token bound to your Clerk
|
|
65
|
+
user id — secrets stay on the server). Point the CLI at a self-hosted/production instance
|
|
66
|
+
with `EASYMD_URL=https://your-easymd.example.com`.
|
|
67
|
+
|
|
68
|
+
## AI agents (MCP)
|
|
69
|
+
|
|
70
|
+
easymd ships an MCP server so AI agents edit the **same live documents** humans do — changes sync in real time and persist to Supabase.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cd web
|
|
74
|
+
npm run mcp # stdio MCP server (needs the collab server running + Supabase env)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Tools: `list_documents`, `read_document`, `create_document`, `update_document`, `append_to_document`. Docs an agent creates show up in the app's document switcher for everyone; edits an agent makes appear live in any open editor. Register it with Cursor/Claude via `.cursor/mcp.json`.
|
|
78
|
+
|
|
79
|
+
## Why
|
|
80
|
+
|
|
81
|
+
AI agents made markdown load-bearing. `AGENTS.md` and `CLAUDE.md` are now the single source of truth for how your project works — but collaborating on them still means pull requests for engineers or copy-paste between Google Docs and your repo for everyone else.
|
|
82
|
+
|
|
83
|
+
easymd fixes the one painful shared file:
|
|
84
|
+
|
|
85
|
+
- **Canonical file stays canonical** — edit the real `.md` on disk, not a hosted copy
|
|
86
|
+
- **Real-time multiplayer** — live cursors, presence, CRDT conflict resolution
|
|
87
|
+
- **CLI keeps it honest** — `easymd open path/to/file.md` from your terminal
|
|
88
|
+
- **Agents stay in the loop** — the same file humans edit is what agents read and write
|
|
89
|
+
- **Token-efficient by design** — lightweight `.md` beats bloated DOCX/HTML/PDF for agent context
|
|
90
|
+
|
|
91
|
+
## Project structure
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
web/ Next.js landing, Clerk auth, protected demo
|
|
95
|
+
bin/easymd.js CLI entry point
|
|
96
|
+
src/server.js Local HTTP + WebSocket server
|
|
97
|
+
src/file-sync.js Disk ↔ Yjs sync
|
|
98
|
+
client/ CLI browser editor UI
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Clerk setup
|
|
102
|
+
|
|
103
|
+
1. Create an app at [dashboard.clerk.com](https://dashboard.clerk.com)
|
|
104
|
+
2. Copy keys into `web/.env.local` (see `web/.env.example`)
|
|
105
|
+
3. Enable Email + Google sign-in (recommended)
|
|
106
|
+
4. Set redirect URLs: after sign-in/sign-up → `/demo`
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/bin/easymd.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { access } from 'fs/promises';
|
|
5
|
+
import openBrowser from 'open';
|
|
6
|
+
import { startServer } from '../src/server.js';
|
|
7
|
+
import { login, logout, whoami } from '../src/cli/auth.js';
|
|
8
|
+
import { syncDir, watchDir } from '../src/cli/sync.js';
|
|
9
|
+
import { autoOn, autoOff, autoStatus } from '../src/cli/auto.js';
|
|
10
|
+
import { getCredentials } from '../src/cli/config.js';
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
easymd — collaborate on markdown files in your repo, live with humans and AI agents
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
easymd login Sign in with Clerk (opens your browser) and authorize this machine
|
|
17
|
+
easymd logout Remove stored credentials and stop auto-sync
|
|
18
|
+
easymd whoami Show the logged-in account
|
|
19
|
+
|
|
20
|
+
easymd auto on [dir] Start background auto-sync: every .md is pushed to your account
|
|
21
|
+
easymd auto off Stop background auto-sync
|
|
22
|
+
easymd auto status Show whether auto-sync is running
|
|
23
|
+
|
|
24
|
+
easymd sync [dir] One-shot: push all .md files under dir (default: .) to your account
|
|
25
|
+
easymd watch [dir] Foreground watcher (what 'auto on' runs in the background)
|
|
26
|
+
|
|
27
|
+
easymd open <file> Open a local .md for real-time collaborative editing in the browser
|
|
28
|
+
easymd open <file> --port N Use a fixed port (default: random)
|
|
29
|
+
|
|
30
|
+
Environment:
|
|
31
|
+
EASYMD_URL easymd web app URL (default: http://localhost:3000)
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
easymd login
|
|
35
|
+
easymd auto on # auto-sync the current repo's markdown into your account
|
|
36
|
+
easymd sync ./docs
|
|
37
|
+
easymd open CLAUDE.md
|
|
38
|
+
`.trim();
|
|
39
|
+
|
|
40
|
+
function flag(args, name) {
|
|
41
|
+
return args.includes(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function cmdOpen(args) {
|
|
45
|
+
const file = args[0];
|
|
46
|
+
if (!file) {
|
|
47
|
+
console.error('Missing file path.\n');
|
|
48
|
+
console.log(HELP);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
let port;
|
|
52
|
+
const portIdx = args.indexOf('--port');
|
|
53
|
+
if (portIdx !== -1) {
|
|
54
|
+
port = Number(args[portIdx + 1]);
|
|
55
|
+
if (!Number.isFinite(port) || port < 1) {
|
|
56
|
+
console.error('Invalid --port value.');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const absPath = resolve(file);
|
|
62
|
+
try {
|
|
63
|
+
await access(absPath);
|
|
64
|
+
} catch {
|
|
65
|
+
console.log(`Note: ${absPath} does not exist yet — it will be created on save.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { url, shutdown } = await startServer(absPath, { port });
|
|
69
|
+
console.log('');
|
|
70
|
+
console.log(' easymd');
|
|
71
|
+
console.log(' ─────────────────────────────────────');
|
|
72
|
+
console.log(` File: ${absPath}`);
|
|
73
|
+
console.log(` Local: ${url}`);
|
|
74
|
+
console.log(' Share this URL with teammates on your network.');
|
|
75
|
+
console.log(' Press Ctrl+C to stop.');
|
|
76
|
+
console.log('');
|
|
77
|
+
await openBrowser(url);
|
|
78
|
+
|
|
79
|
+
const onExit = () => {
|
|
80
|
+
shutdown();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
};
|
|
83
|
+
process.on('SIGINT', onExit);
|
|
84
|
+
process.on('SIGTERM', onExit);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function cmdAuto(args) {
|
|
88
|
+
const sub = args[0];
|
|
89
|
+
if (sub === 'on') {
|
|
90
|
+
// One command does it all: log in via the browser if needed, then start auto-sync.
|
|
91
|
+
const creds = await getCredentials();
|
|
92
|
+
if (!creds?.token) {
|
|
93
|
+
console.log('Not logged in yet — opening the browser to sign in first…\n');
|
|
94
|
+
await login();
|
|
95
|
+
}
|
|
96
|
+
return autoOn(resolve(args[1] || '.'));
|
|
97
|
+
}
|
|
98
|
+
if (sub === 'off') return autoOff();
|
|
99
|
+
if (sub === 'status' || !sub) return autoStatus();
|
|
100
|
+
console.error(`Unknown: easymd auto ${sub}\n`);
|
|
101
|
+
console.log('Use: easymd auto on|off|status');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function cmdWatch(args) {
|
|
106
|
+
const dirArgs = args.filter((a) => !a.startsWith('-'));
|
|
107
|
+
const root = resolve(dirArgs[0] || '.');
|
|
108
|
+
const quiet = flag(args, '--quiet');
|
|
109
|
+
const watcher = await watchDir(root, { quiet });
|
|
110
|
+
const onExit = async () => {
|
|
111
|
+
await watcher.close();
|
|
112
|
+
process.exit(0);
|
|
113
|
+
};
|
|
114
|
+
process.on('SIGINT', onExit);
|
|
115
|
+
process.on('SIGTERM', onExit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function cmdSync(args) {
|
|
119
|
+
const dirArgs = args.filter((a) => !a.startsWith('-'));
|
|
120
|
+
const root = resolve(dirArgs[0] || '.');
|
|
121
|
+
await syncDir(root, { quiet: flag(args, '--quiet') });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function main() {
|
|
125
|
+
const argv = process.argv.slice(2);
|
|
126
|
+
const command = argv[0];
|
|
127
|
+
const rest = argv.slice(1);
|
|
128
|
+
|
|
129
|
+
if (!command || command === '-h' || command === '--help' || command === 'help') {
|
|
130
|
+
console.log(HELP);
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
switch (command) {
|
|
135
|
+
case 'login':
|
|
136
|
+
return login();
|
|
137
|
+
case 'logout':
|
|
138
|
+
return logout();
|
|
139
|
+
case 'whoami':
|
|
140
|
+
return whoami();
|
|
141
|
+
case 'auto':
|
|
142
|
+
return cmdAuto(rest);
|
|
143
|
+
case 'watch':
|
|
144
|
+
return cmdWatch(rest);
|
|
145
|
+
case 'sync':
|
|
146
|
+
return cmdSync(rest);
|
|
147
|
+
case 'open':
|
|
148
|
+
return cmdOpen(rest);
|
|
149
|
+
default:
|
|
150
|
+
console.error(`Unknown command: ${command}\n`);
|
|
151
|
+
console.log(HELP);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
main().catch((err) => {
|
|
157
|
+
console.error('easymd:', err.message);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>easymd</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link
|
|
11
|
+
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap"
|
|
12
|
+
rel="stylesheet"
|
|
13
|
+
/>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<header class="toolbar">
|
|
17
|
+
<div class="brand">
|
|
18
|
+
<span class="logo">easymd</span>
|
|
19
|
+
<span class="filename" id="filename">loading…</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="toolbar-right">
|
|
22
|
+
<div class="presence" id="presence"></div>
|
|
23
|
+
<div class="view-toggle" role="tablist" aria-label="View mode">
|
|
24
|
+
<button class="toggle-btn active" data-view="edit" role="tab" aria-selected="true">
|
|
25
|
+
Edit
|
|
26
|
+
</button>
|
|
27
|
+
<button class="toggle-btn" data-view="split" role="tab" aria-selected="false">
|
|
28
|
+
Split
|
|
29
|
+
</button>
|
|
30
|
+
<button class="toggle-btn" data-view="preview" role="tab" aria-selected="false">
|
|
31
|
+
Preview
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
<span class="sync-status" id="sync-status" title="Sync status">●</span>
|
|
35
|
+
</div>
|
|
36
|
+
</header>
|
|
37
|
+
|
|
38
|
+
<main class="workspace" id="workspace">
|
|
39
|
+
<section class="editor-pane" id="editor-pane">
|
|
40
|
+
<div id="editor"></div>
|
|
41
|
+
</section>
|
|
42
|
+
<section class="preview-pane hidden" id="preview-pane">
|
|
43
|
+
<article class="markdown-body" id="preview"></article>
|
|
44
|
+
</section>
|
|
45
|
+
</main>
|
|
46
|
+
|
|
47
|
+
<script type="module" src="/client.js"></script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
package/client/main.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
import { WebsocketProvider } from 'y-websocket';
|
|
3
|
+
import { EditorState } from '@codemirror/state';
|
|
4
|
+
import { EditorView, keymap, lineNumbers, drawSelection } from '@codemirror/view';
|
|
5
|
+
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
6
|
+
import { markdown } from '@codemirror/lang-markdown';
|
|
7
|
+
import { yCollab } from 'y-codemirror.next';
|
|
8
|
+
import { marked } from 'marked';
|
|
9
|
+
|
|
10
|
+
const COLORS = ['#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72', '#79c0ff', '#e3b341'];
|
|
11
|
+
|
|
12
|
+
const filenameEl = document.getElementById('filename');
|
|
13
|
+
const presenceEl = document.getElementById('presence');
|
|
14
|
+
const syncStatusEl = document.getElementById('sync-status');
|
|
15
|
+
const workspaceEl = document.getElementById('workspace');
|
|
16
|
+
const editorPaneEl = document.getElementById('editor-pane');
|
|
17
|
+
const previewPaneEl = document.getElementById('preview-pane');
|
|
18
|
+
const previewEl = document.getElementById('preview');
|
|
19
|
+
const toggleButtons = document.querySelectorAll('.toggle-btn');
|
|
20
|
+
|
|
21
|
+
function randomName() {
|
|
22
|
+
const names = ['Alex', 'Sam', 'Jordan', 'Riley', 'Casey', 'Morgan', 'Taylor', 'Quinn'];
|
|
23
|
+
return names[Math.floor(Math.random() * names.length)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function colorForUser(userId) {
|
|
27
|
+
let hash = 0;
|
|
28
|
+
for (let i = 0; i < userId.length; i++) {
|
|
29
|
+
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
|
|
30
|
+
}
|
|
31
|
+
return COLORS[Math.abs(hash) % COLORS.length];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setSyncStatus(state) {
|
|
35
|
+
syncStatusEl.className = 'sync-status';
|
|
36
|
+
if (state === 'connected') {
|
|
37
|
+
syncStatusEl.textContent = '●';
|
|
38
|
+
syncStatusEl.title = 'Connected — changes save to disk';
|
|
39
|
+
} else if (state === 'connecting') {
|
|
40
|
+
syncStatusEl.classList.add('connecting');
|
|
41
|
+
syncStatusEl.textContent = '●';
|
|
42
|
+
syncStatusEl.title = 'Connecting…';
|
|
43
|
+
} else {
|
|
44
|
+
syncStatusEl.classList.add('disconnected');
|
|
45
|
+
syncStatusEl.textContent = '●';
|
|
46
|
+
syncStatusEl.title = 'Disconnected';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderPreview(markdownText) {
|
|
51
|
+
previewEl.innerHTML = marked.parse(markdownText, { gfm: true, breaks: false });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function setView(mode) {
|
|
55
|
+
workspaceEl.classList.remove('split', 'preview');
|
|
56
|
+
editorPaneEl.classList.remove('hidden');
|
|
57
|
+
previewPaneEl.classList.add('hidden');
|
|
58
|
+
|
|
59
|
+
toggleButtons.forEach((btn) => {
|
|
60
|
+
const active = btn.dataset.view === mode;
|
|
61
|
+
btn.classList.toggle('active', active);
|
|
62
|
+
btn.setAttribute('aria-selected', String(active));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (mode === 'split') {
|
|
66
|
+
workspaceEl.classList.add('split');
|
|
67
|
+
previewPaneEl.classList.remove('hidden');
|
|
68
|
+
} else if (mode === 'preview') {
|
|
69
|
+
workspaceEl.classList.add('preview');
|
|
70
|
+
previewPaneEl.classList.remove('hidden');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toggleButtons.forEach((btn) => {
|
|
75
|
+
btn.addEventListener('click', () => setView(btn.dataset.view));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function init() {
|
|
79
|
+
setSyncStatus('connecting');
|
|
80
|
+
|
|
81
|
+
const sessionRes = await fetch('/api/session');
|
|
82
|
+
const session = await sessionRes.json();
|
|
83
|
+
filenameEl.textContent = session.fileName;
|
|
84
|
+
filenameEl.title = session.filePath;
|
|
85
|
+
document.title = `${session.fileName} — easymd`;
|
|
86
|
+
|
|
87
|
+
const ydoc = new Y.Doc();
|
|
88
|
+
const ytext = ydoc.getText('markdown');
|
|
89
|
+
|
|
90
|
+
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
91
|
+
const provider = new WebsocketProvider(
|
|
92
|
+
`${wsProtocol}//${location.host}`,
|
|
93
|
+
session.docName,
|
|
94
|
+
ydoc,
|
|
95
|
+
{ connect: true }
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const userId = crypto.randomUUID();
|
|
99
|
+
const userName = randomName();
|
|
100
|
+
const userColor = colorForUser(userId);
|
|
101
|
+
|
|
102
|
+
provider.awareness.setLocalStateField('user', {
|
|
103
|
+
name: userName,
|
|
104
|
+
color: userColor,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
provider.on('status', ({ status }) => {
|
|
108
|
+
setSyncStatus(status === 'connected' ? 'connected' : 'connecting');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
provider.on('connection-close', () => setSyncStatus('disconnected'));
|
|
112
|
+
|
|
113
|
+
function renderPresence() {
|
|
114
|
+
const states = provider.awareness.getStates();
|
|
115
|
+
presenceEl.innerHTML = '';
|
|
116
|
+
|
|
117
|
+
states.forEach((state) => {
|
|
118
|
+
const user = state.user;
|
|
119
|
+
if (!user) return;
|
|
120
|
+
const avatar = document.createElement('span');
|
|
121
|
+
avatar.className = 'presence-avatar';
|
|
122
|
+
avatar.style.background = user.color;
|
|
123
|
+
avatar.textContent = user.name.slice(0, 1).toUpperCase();
|
|
124
|
+
avatar.title = user.name;
|
|
125
|
+
presenceEl.appendChild(avatar);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
provider.awareness.on('change', renderPresence);
|
|
130
|
+
renderPresence();
|
|
131
|
+
|
|
132
|
+
const undoManager = new Y.UndoManager(ytext);
|
|
133
|
+
|
|
134
|
+
const state = EditorState.create({
|
|
135
|
+
doc: ytext.toString(),
|
|
136
|
+
extensions: [
|
|
137
|
+
lineNumbers(),
|
|
138
|
+
drawSelection(),
|
|
139
|
+
history(),
|
|
140
|
+
markdown(),
|
|
141
|
+
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
142
|
+
yCollab(ytext, provider.awareness, { undoManager }),
|
|
143
|
+
EditorView.updateListener.of((update) => {
|
|
144
|
+
if (update.docChanged) {
|
|
145
|
+
renderPreview(update.state.doc.toString());
|
|
146
|
+
}
|
|
147
|
+
}),
|
|
148
|
+
EditorView.theme({
|
|
149
|
+
'&': { backgroundColor: 'transparent', color: 'var(--text)' },
|
|
150
|
+
'.cm-gutters': { backgroundColor: 'var(--bg)', color: 'var(--muted)' },
|
|
151
|
+
}),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const view = new EditorView({ state, parent: document.getElementById('editor') });
|
|
156
|
+
renderPreview(view.state.doc.toString());
|
|
157
|
+
|
|
158
|
+
ytext.observe(() => {
|
|
159
|
+
renderPreview(ytext.toString());
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return { view, provider };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
init().catch((err) => {
|
|
166
|
+
console.error(err);
|
|
167
|
+
filenameEl.textContent = 'Failed to connect';
|
|
168
|
+
setSyncStatus('disconnected');
|
|
169
|
+
});
|