mdinterface 0.1.1
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/LICENSE +21 -0
- package/README.md +131 -0
- package/access.js +60 -0
- package/mcp-server.js +269 -0
- package/package.json +64 -0
- package/public/index.html +805 -0
- package/public/render-core.js +103 -0
- package/public/vendor/addon-fit.min.js +8 -0
- package/public/vendor/addon-webgl.min.js +8 -0
- package/public/vendor/marked.min.js +6 -0
- package/public/vendor/purify.min.js +3 -0
- package/public/vendor/xterm.min.css +8 -0
- package/public/vendor/xterm.min.js +8 -0
- package/server.js +723 -0
- package/start.sh +31 -0
|
@@ -0,0 +1,805 @@
|
|
|
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" />
|
|
6
|
+
<!-- Belt-and-suspenders with the server's Referrer-Policy header: never leak the ?t= token via Referer. -->
|
|
7
|
+
<meta name="referrer" content="no-referrer" />
|
|
8
|
+
<title>mdinterface</title>
|
|
9
|
+
<link rel="stylesheet" href="vendor/xterm.min.css" />
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--paper: #FBFAF7;
|
|
13
|
+
--ink: #20201C;
|
|
14
|
+
--ink-soft: #6E6C64;
|
|
15
|
+
--hairline: #E4E1D8;
|
|
16
|
+
--live: #0F6E56;
|
|
17
|
+
--live-wash: #DDF0E8;
|
|
18
|
+
--changed-wash: #FBEFC9; /* amber: "recently changed" — distinct from green selection */
|
|
19
|
+
--changed-bar: #C9920A;
|
|
20
|
+
--term-bg: #16161A;
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
html, body { height: 100%; margin: 0; }
|
|
24
|
+
body {
|
|
25
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
26
|
+
color: var(--ink);
|
|
27
|
+
background: var(--paper);
|
|
28
|
+
display: grid;
|
|
29
|
+
grid-template-rows: 1fr; /* main fills the window; the header overlays the top */
|
|
30
|
+
}
|
|
31
|
+
header {
|
|
32
|
+
position: fixed; top: 0; left: 0; right: 0; z-index: 20; height: 42px;
|
|
33
|
+
display: flex; align-items: center; gap: 10px;
|
|
34
|
+
padding: 0 16px;
|
|
35
|
+
border-bottom: 1px solid var(--hairline);
|
|
36
|
+
font-size: 13px; color: var(--ink-soft);
|
|
37
|
+
background: var(--paper);
|
|
38
|
+
transform: translateY(-100%); transition: transform 0.18s ease;
|
|
39
|
+
}
|
|
40
|
+
header.show { transform: translateY(0); box-shadow: 0 2px 10px rgba(0,0,0,0.07); }
|
|
41
|
+
header .dot {
|
|
42
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
43
|
+
background: var(--live);
|
|
44
|
+
}
|
|
45
|
+
header .dot.off { background: #C9482E; }
|
|
46
|
+
header strong { color: var(--ink); font-weight: 600; }
|
|
47
|
+
header .hint { margin-left: auto; font-size: 12px; }
|
|
48
|
+
|
|
49
|
+
main {
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: 1fr 6px 0.85fr;
|
|
52
|
+
min-height: 0;
|
|
53
|
+
}
|
|
54
|
+
#docPane { overflow-y: auto; min-width: 0; }
|
|
55
|
+
#doc {
|
|
56
|
+
max-width: 660px;
|
|
57
|
+
margin: 0 auto;
|
|
58
|
+
padding: 40px 36px 120px;
|
|
59
|
+
font-family: "Source Serif 4", Georgia, "Times New Roman", serif;
|
|
60
|
+
font-size: 17px;
|
|
61
|
+
line-height: 1.65;
|
|
62
|
+
}
|
|
63
|
+
#doc ::selection { background: var(--live-wash); }
|
|
64
|
+
.block { padding: 2px 10px; margin: 0 -10px; border-radius: 6px; }
|
|
65
|
+
.block h1, .block h2, .block h3 {
|
|
66
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
67
|
+
line-height: 1.25; letter-spacing: -0.01em;
|
|
68
|
+
}
|
|
69
|
+
.block h1 { font-size: 28px; } .block h2 { font-size: 21px; } .block h3 { font-size: 17px; }
|
|
70
|
+
.block pre {
|
|
71
|
+
font-size: 13.5px; background: #F2F0E9; border: 1px solid var(--hairline);
|
|
72
|
+
border-radius: 8px; padding: 12px 14px; overflow-x: auto;
|
|
73
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
74
|
+
}
|
|
75
|
+
.block code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.88em; }
|
|
76
|
+
.block blockquote { margin: 0; padding-left: 14px; border-left: 3px solid var(--hairline); color: var(--ink-soft); }
|
|
77
|
+
.block a { color: var(--live); }
|
|
78
|
+
/* persistent change highlight — marks what changed and STAYS until the next change
|
|
79
|
+
(each re-render clears the old marks and applies new ones). Amber, so it doesn't
|
|
80
|
+
read as the green text selection. */
|
|
81
|
+
.block.changed { background: var(--changed-wash); box-shadow: inset 3px 0 0 var(--changed-bar); }
|
|
82
|
+
.block span.changed { background: var(--changed-wash); border-radius: 3px; }
|
|
83
|
+
.block { cursor: text; }
|
|
84
|
+
.block.editing { background: #FFFDF4; box-shadow: inset 3px 0 0 var(--live); }
|
|
85
|
+
textarea.edit {
|
|
86
|
+
width: 100%; box-sizing: border-box; margin: 0; border: 0; resize: none;
|
|
87
|
+
background: transparent; color: var(--ink);
|
|
88
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
89
|
+
font-size: 14px; line-height: 1.5; outline: none; overflow: hidden;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#divider { background: var(--hairline); cursor: col-resize; }
|
|
93
|
+
#divider:hover, #divider.active { background: var(--live); }
|
|
94
|
+
|
|
95
|
+
/* doc-side Undo: rolls back the last change. Sits on whichever side the doc is on. */
|
|
96
|
+
#undo {
|
|
97
|
+
position: fixed; bottom: 16px; left: 16px; z-index: 15;
|
|
98
|
+
background: var(--paper); color: var(--ink-soft);
|
|
99
|
+
border: 1px solid var(--hairline); border-radius: 999px;
|
|
100
|
+
padding: 6px 14px; font-size: 12.5px; cursor: pointer;
|
|
101
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
|
102
|
+
}
|
|
103
|
+
#undo:hover:not(:disabled) { border-color: var(--live); color: var(--live); }
|
|
104
|
+
#undo:disabled { opacity: 0.4; cursor: default; }
|
|
105
|
+
main.swapped ~ #undo { left: auto; right: 16px; } /* doc moved right → button follows */
|
|
106
|
+
|
|
107
|
+
#termPane {
|
|
108
|
+
background: var(--term-bg);
|
|
109
|
+
min-width: 0; display: grid; grid-template-rows: 30px 1fr;
|
|
110
|
+
overflow: hidden; /* clip the xterm canvas to its column — never paint over the doc */
|
|
111
|
+
}
|
|
112
|
+
#termPane .bar {
|
|
113
|
+
display: flex; align-items: center; padding: 0 12px;
|
|
114
|
+
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
|
|
115
|
+
color: #8B8B94;
|
|
116
|
+
}
|
|
117
|
+
/* always-visible connection indicator (the header dot hides with the toolbar) */
|
|
118
|
+
#termPane .bar #conn { margin-left: auto; color: #E0613F; letter-spacing: 0; text-transform: none; }
|
|
119
|
+
#term { padding: 4px 6px 10px 10px; min-height: 0; min-width: 0; overflow: hidden; }
|
|
120
|
+
#term .xterm { height: 100%; width: 100%; }
|
|
121
|
+
|
|
122
|
+
/* selected text in the canvas is mirrored to Claude as ambient context */
|
|
123
|
+
#doc ::selection { background: var(--live-wash); }
|
|
124
|
+
/* the exact armed text — persists (via the CSS Custom Highlight API) after the
|
|
125
|
+
native selection collapses, e.g. when you click into the Claude pane */
|
|
126
|
+
::highlight(mdinterface-selection) { background: var(--live-wash); }
|
|
127
|
+
/* fallback for browsers without the Highlight API: mark the containing block */
|
|
128
|
+
.block.armed { background: var(--live-wash); box-shadow: inset 3px 0 0 var(--live); }
|
|
129
|
+
|
|
130
|
+
/* swap which side the terminal is on (doc keeps the larger share either way) */
|
|
131
|
+
main.swapped { grid-template-columns: 0.85fr 6px 1fr; }
|
|
132
|
+
main.swapped #termPane { order: 1; }
|
|
133
|
+
main.swapped #divider { order: 2; }
|
|
134
|
+
main.swapped #docPane { order: 3; }
|
|
135
|
+
|
|
136
|
+
header #swap, header #restart {
|
|
137
|
+
margin-left: 8px; background: none; border: 1px solid var(--hairline);
|
|
138
|
+
color: var(--ink-soft); border-radius: 6px; padding: 3px 9px;
|
|
139
|
+
font-size: 12px; cursor: pointer; white-space: nowrap;
|
|
140
|
+
}
|
|
141
|
+
header #restart { margin-left: auto; } /* push the Restart/Swap group to the right edge */
|
|
142
|
+
header #swap:hover, header #restart:hover { border-color: var(--live); color: var(--live); }
|
|
143
|
+
header #restart.armed { border-color: #C9482E; color: #C9482E; }
|
|
144
|
+
|
|
145
|
+
/* file picker: type/paste a path, Enter to switch documents */
|
|
146
|
+
header #filepicker {
|
|
147
|
+
flex: 1 1 auto; max-width: 460px; min-width: 120px;
|
|
148
|
+
background: var(--paper); color: var(--ink);
|
|
149
|
+
border: 1px solid var(--hairline); border-radius: 6px; padding: 4px 10px;
|
|
150
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px;
|
|
151
|
+
}
|
|
152
|
+
header #filepicker:focus { outline: none; border-color: var(--live); }
|
|
153
|
+
header #filepicker.error { border-color: #C9482E; }
|
|
154
|
+
header #browse {
|
|
155
|
+
margin-left: 6px; background: none; border: 1px solid var(--hairline);
|
|
156
|
+
color: var(--ink-soft); border-radius: 6px; padding: 3px 9px; font-size: 12px;
|
|
157
|
+
cursor: pointer; white-space: nowrap;
|
|
158
|
+
}
|
|
159
|
+
header #browse:hover { border-color: var(--live); color: var(--live); }
|
|
160
|
+
/* shown only when the open doc is Notion-backed (has a mdinterface:notion marker) */
|
|
161
|
+
header #notionlink {
|
|
162
|
+
margin-left: 8px; text-decoration: none; white-space: nowrap; font-size: 12px;
|
|
163
|
+
color: var(--live); border: 1px solid var(--hairline); border-radius: 6px; padding: 3px 9px;
|
|
164
|
+
}
|
|
165
|
+
header #notionlink[hidden] { display: none; }
|
|
166
|
+
header #notionlink:hover { border-color: var(--live); background: var(--live-wash); }
|
|
167
|
+
|
|
168
|
+
/* file browser dropdown */
|
|
169
|
+
#browser {
|
|
170
|
+
position: fixed; top: 44px; left: 16px; z-index: 40;
|
|
171
|
+
width: min(560px, 92vw); max-height: 60vh; display: flex; flex-direction: column;
|
|
172
|
+
background: var(--paper); color: var(--ink);
|
|
173
|
+
border: 1px solid var(--hairline); border-radius: 10px;
|
|
174
|
+
box-shadow: 0 10px 34px rgba(0,0,0,0.18); overflow: hidden;
|
|
175
|
+
}
|
|
176
|
+
#browser[hidden] { display: none; }
|
|
177
|
+
#bpath {
|
|
178
|
+
padding: 9px 12px; border-bottom: 1px solid var(--hairline);
|
|
179
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px;
|
|
180
|
+
color: var(--ink-soft); word-break: break-all;
|
|
181
|
+
}
|
|
182
|
+
#blist { overflow-y: auto; padding: 4px; }
|
|
183
|
+
#blist .brow {
|
|
184
|
+
display: block; width: 100%; text-align: left; border: 0; background: none;
|
|
185
|
+
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13.5px; color: var(--ink);
|
|
186
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
187
|
+
}
|
|
188
|
+
#blist .brow:hover { background: var(--live-wash); }
|
|
189
|
+
#blist .brow.dim { color: var(--ink-soft); }
|
|
190
|
+
#blist .empty { padding: 10px; color: var(--ink-soft); font-size: 13px; }
|
|
191
|
+
</style>
|
|
192
|
+
</head>
|
|
193
|
+
<body>
|
|
194
|
+
<header>
|
|
195
|
+
<input id="filepicker" type="text" spellcheck="false" autocomplete="off"
|
|
196
|
+
placeholder="path to a .md file — press Enter, or click Browse"
|
|
197
|
+
title="Open a different document — type or paste a path, then press Enter" />
|
|
198
|
+
<button id="browse" type="button" title="Browse for a file">📁 Browse</button>
|
|
199
|
+
<a id="notionlink" hidden target="_blank" rel="noopener noreferrer" title="Open the source page in Notion">↗ Notion</a>
|
|
200
|
+
<button id="restart" type="button" title="Restart the Claude session (reloads hooks, CLAUDE.md, MCP) — ends the current chat">⟲ Restart Claude</button>
|
|
201
|
+
<button id="swap" type="button" title="Swap which side the Claude terminal is on">⇄ swap sides</button>
|
|
202
|
+
</header>
|
|
203
|
+
|
|
204
|
+
<div id="browser" hidden role="dialog" aria-label="Open a file">
|
|
205
|
+
<div id="bpath"></div>
|
|
206
|
+
<div id="blist"></div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<main>
|
|
210
|
+
<div id="docPane"><article id="doc" aria-label="Rendered document"></article></div>
|
|
211
|
+
<div id="divider" role="separator" aria-orientation="vertical" aria-label="Resize panes"></div>
|
|
212
|
+
<section id="termPane" aria-label="Claude Code session">
|
|
213
|
+
<div class="bar">claude code<span id="conn"></span></div>
|
|
214
|
+
<div id="term"></div>
|
|
215
|
+
</section>
|
|
216
|
+
</main>
|
|
217
|
+
|
|
218
|
+
<button id="undo" type="button" disabled title="Roll back the last change">↶ Undo</button>
|
|
219
|
+
|
|
220
|
+
<!-- Libraries are vendored locally (served from public/vendor) so the app works offline
|
|
221
|
+
and never breaks rendering when a CDN is slow/blocked. -->
|
|
222
|
+
<script src="vendor/marked.min.js"></script>
|
|
223
|
+
<script src="vendor/xterm.min.js"></script>
|
|
224
|
+
<script src="vendor/addon-fit.min.js"></script>
|
|
225
|
+
<script src="vendor/addon-webgl.min.js"></script>
|
|
226
|
+
<script src="vendor/purify.min.js"></script>
|
|
227
|
+
<script src="render-core.js"></script>
|
|
228
|
+
<script>
|
|
229
|
+
// The launch URL carries a per-session token; the server requires it on /doc and the WS.
|
|
230
|
+
const token = new URLSearchParams(location.search).get("t") || "";
|
|
231
|
+
let ws; // (re)assigned by connect()
|
|
232
|
+
const docEl = document.getElementById("doc");
|
|
233
|
+
const conn = document.getElementById("conn");
|
|
234
|
+
const undoBtn = document.getElementById("undo");
|
|
235
|
+
const filepicker = document.getElementById("filepicker");
|
|
236
|
+
const notionLink = document.getElementById("notionlink");
|
|
237
|
+
undoBtn.addEventListener("click", () => send({ type: "undo" }));
|
|
238
|
+
// Show a clickable "↗ Notion" link when the doc carries a `<!-- mdinterface:notion URL -->` marker.
|
|
239
|
+
// Also accept the legacy `line0:notion` and `mdcanvas:notion` markers so documents synced
|
|
240
|
+
// under either earlier name keep working without edits.
|
|
241
|
+
function updateNotionLink(src) {
|
|
242
|
+
const m = src?.match(/<!--\s*(?:mdinterface|line0|mdcanvas):notion\s+(.+?)\s*-->/i);
|
|
243
|
+
if (m?.[1]) {
|
|
244
|
+
let u = m[1].trim();
|
|
245
|
+
if (!/^https?:\/\//i.test(u)) u = `https://www.notion.so/${u.replace(/-/g, "")}`; // bare id → URL
|
|
246
|
+
notionLink.href = u; notionLink.hidden = false;
|
|
247
|
+
} else {
|
|
248
|
+
notionLink.hidden = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function setConnected(on) { conn.textContent = on ? "" : "· reconnecting…"; } // status lives in the terminal bar
|
|
252
|
+
|
|
253
|
+
function setDocMeta(p, n) {
|
|
254
|
+
if (p && document.activeElement !== filepicker) filepicker.value = p; // don't clobber mid-type
|
|
255
|
+
document.title = `${n || "mdinterface"} — mdinterface`;
|
|
256
|
+
}
|
|
257
|
+
fetch(`/doc?t=${encodeURIComponent(token)}`).then(r => r.json()).then(d => setDocMeta(d.path, d.name));
|
|
258
|
+
|
|
259
|
+
// File picker: type/paste a path, Enter to switch documents.
|
|
260
|
+
filepicker.addEventListener("keydown", (e) => {
|
|
261
|
+
if (e.key === "Enter") { e.preventDefault(); filepicker.classList.remove("error"); send({ type: "open", path: filepicker.value }); }
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// File browser: click through folders (server lists them) to pick a .md file.
|
|
265
|
+
const browseBtn = document.getElementById("browse");
|
|
266
|
+
const browserEl = document.getElementById("browser");
|
|
267
|
+
const bpath = document.getElementById("bpath");
|
|
268
|
+
const blist = document.getElementById("blist");
|
|
269
|
+
let browserOpen = false;
|
|
270
|
+
function brow(label, onClick, dim) {
|
|
271
|
+
const b = document.createElement("button");
|
|
272
|
+
b.className = `brow${dim ? " dim" : ""}`;
|
|
273
|
+
b.textContent = label;
|
|
274
|
+
b.addEventListener("click", onClick);
|
|
275
|
+
return b;
|
|
276
|
+
}
|
|
277
|
+
async function openBrowser(dir) {
|
|
278
|
+
let d;
|
|
279
|
+
try {
|
|
280
|
+
const r = await fetch(`/ls?t=${encodeURIComponent(token)}${dir ? `&dir=${encodeURIComponent(dir)}` : ""}`);
|
|
281
|
+
d = await r.json();
|
|
282
|
+
if (!r.ok) throw new Error(d.error || "Cannot read folder");
|
|
283
|
+
} catch (e) {
|
|
284
|
+
bpath.textContent = `⚠ ${e.message}`; blist.innerHTML = ""; browserEl.hidden = false; browserOpen = true; return;
|
|
285
|
+
}
|
|
286
|
+
bpath.textContent = d.dir;
|
|
287
|
+
blist.innerHTML = "";
|
|
288
|
+
if (d.parent) blist.appendChild(brow("⬆ ..", () => openBrowser(d.parent), true));
|
|
289
|
+
for (const ent of d.entries) {
|
|
290
|
+
const tag = ent.path === d.current ? " • open" : "";
|
|
291
|
+
blist.appendChild(brow((ent.isDir ? "📁 " : "📄 ") + ent.name + tag, () => {
|
|
292
|
+
if (ent.isDir) openBrowser(ent.path);
|
|
293
|
+
else { filepicker.value = ent.path; send({ type: "open", path: ent.path }); closeBrowser(); }
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
if (!d.entries.length) {
|
|
297
|
+
const e = document.createElement("div"); e.className = "empty";
|
|
298
|
+
e.textContent = "No folders or .md files here."; blist.appendChild(e);
|
|
299
|
+
}
|
|
300
|
+
browserEl.hidden = false; browserOpen = true;
|
|
301
|
+
}
|
|
302
|
+
function closeBrowser() { browserEl.hidden = true; browserOpen = false; }
|
|
303
|
+
browseBtn.addEventListener("click", () => (browserOpen ? closeBrowser() : openBrowser()));
|
|
304
|
+
document.addEventListener("click", (e) => {
|
|
305
|
+
if (browserOpen && !browserEl.contains(e.target) && e.target !== browseBtn) closeBrowser();
|
|
306
|
+
});
|
|
307
|
+
document.addEventListener("keydown", (e) => { if (browserOpen && e.key === "Escape") closeBrowser(); });
|
|
308
|
+
|
|
309
|
+
// ---------- terminal: the real Claude Code session ----------
|
|
310
|
+
const term = new Terminal({
|
|
311
|
+
fontSize: 13,
|
|
312
|
+
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
|
313
|
+
cursorBlink: true,
|
|
314
|
+
theme: { background: "#16161A", foreground: "#E6E6EA", cursor: "#0F6E56", selectionBackground: "#2E4B41" }
|
|
315
|
+
});
|
|
316
|
+
const fit = new FitAddon.FitAddon();
|
|
317
|
+
term.loadAddon(fit);
|
|
318
|
+
term.open(document.getElementById("term"));
|
|
319
|
+
// GPU-accelerated rendering — Claude Code's TUI repaints constantly, and the default
|
|
320
|
+
// DOM renderer can't keep up (this is the "insane latency"). Fall back to DOM if the
|
|
321
|
+
// WebGL context is unavailable or lost.
|
|
322
|
+
try {
|
|
323
|
+
const webgl = new WebglAddon.WebglAddon();
|
|
324
|
+
webgl.onContextLoss(() => webgl.dispose());
|
|
325
|
+
term.loadAddon(webgl);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
console.warn("WebGL renderer unavailable, using DOM renderer:", e.message);
|
|
328
|
+
}
|
|
329
|
+
term.onData(d => send({ type: "term-in", data: d }));
|
|
330
|
+
// Shift+Enter → insert a newline instead of submitting. xterm sends a plain `\r` for both
|
|
331
|
+
// Enter and Shift+Enter, so the app can't tell them apart. Emit ESC+CR — the exact bytes
|
|
332
|
+
// Option/Alt+Enter produces — which Claude Code reads as "insert newline" with no setup.
|
|
333
|
+
// (The previous CSI-u sequence `\x1b[13;2u` only works once the kitty keyboard protocol is
|
|
334
|
+
// negotiated, which doesn't happen here, so Enter fell through and submitted the message.)
|
|
335
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
336
|
+
if (e.type === "keydown" && e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
337
|
+
send({ type: "term-in", data: "\x1b\r" }); // ESC+CR = Meta/Option+Enter = newline
|
|
338
|
+
return false; // don't let xterm also send a bare carriage return (which submits)
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
341
|
+
});
|
|
342
|
+
let fitTimer, repaintTimer;
|
|
343
|
+
function refit() {
|
|
344
|
+
try { fit.fit(); } catch {}
|
|
345
|
+
send({ type: "resize", cols: term.cols, rows: term.rows });
|
|
346
|
+
}
|
|
347
|
+
// Ask the server to force a clean TUI repaint — fixes a terminal whose drawing desynced
|
|
348
|
+
// from its size (stacked/spread text) when a size change alone won't trigger a redraw.
|
|
349
|
+
function nudgeRepaint() { clearTimeout(repaintTimer); repaintTimer = setTimeout(() => send({ type: "repaint" }), 90); }
|
|
350
|
+
function refitSoon() { clearTimeout(fitTimer); fitTimer = setTimeout(() => { refit(); nudgeRepaint(); }, 50); }
|
|
351
|
+
window.addEventListener("resize", refitSoon);
|
|
352
|
+
// Refit when the terminal container's size actually settles (after a reload, a divider
|
|
353
|
+
// drag, or fonts loading) rather than guessing with a one-shot timeout — the guess
|
|
354
|
+
// sometimes measured before layout was ready and mis-sized the terminal on reload.
|
|
355
|
+
new ResizeObserver(refitSoon).observe(document.getElementById("term"));
|
|
356
|
+
if (document.fonts?.ready) document.fonts.ready.then(refit);
|
|
357
|
+
// Returning to the tab/window is a prime moment for a desynced terminal — re-fit and
|
|
358
|
+
// force a clean repaint so it self-heals without a reload.
|
|
359
|
+
window.addEventListener("focus", () => { refit(); nudgeRepaint(); });
|
|
360
|
+
document.addEventListener("visibilitychange", () => { if (!document.hidden) { refit(); nudgeRepaint(); } });
|
|
361
|
+
|
|
362
|
+
// ---------- websocket plumbing (auto-reconnecting) ----------
|
|
363
|
+
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
|
364
|
+
let reconnectDelay = 500;
|
|
365
|
+
function connect() {
|
|
366
|
+
ws = new WebSocket(`${(location.protocol === "https:" ? "wss://" : "ws://") + location.host}/?t=${encodeURIComponent(token)}`);
|
|
367
|
+
ws.onopen = () => {
|
|
368
|
+
setConnected(true);
|
|
369
|
+
reconnectDelay = 500;
|
|
370
|
+
flushSelection(); // re-sync the current selection in case it changed while disconnected
|
|
371
|
+
term.reset(); // clear before the server replays the current screen (avoids duplication)
|
|
372
|
+
refit();
|
|
373
|
+
};
|
|
374
|
+
ws.onclose = () => {
|
|
375
|
+
setConnected(false);
|
|
376
|
+
setTimeout(connect, reconnectDelay); // reconnect with capped backoff
|
|
377
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 5000);
|
|
378
|
+
};
|
|
379
|
+
ws.onerror = () => { try { ws.close(); } catch {} };
|
|
380
|
+
ws.onmessage = (ev) => {
|
|
381
|
+
const msg = JSON.parse(ev.data);
|
|
382
|
+
if (msg.type === "term") term.write(msg.data);
|
|
383
|
+
if (msg.type === "doc") {
|
|
384
|
+
if (msg.missing) { showMissing(); return; }
|
|
385
|
+
lastSrc = msg.content;
|
|
386
|
+
// Don't blow away an in-progress inline edit; re-sync when it finishes.
|
|
387
|
+
if (!editingEl) render(msg.content);
|
|
388
|
+
}
|
|
389
|
+
if (msg.type === "history") undoBtn.disabled = !msg.canUndo;
|
|
390
|
+
if (msg.type === "opened") {
|
|
391
|
+
setDocMeta(msg.path, msg.name);
|
|
392
|
+
filepicker.classList.remove("error");
|
|
393
|
+
// New document — reset the diff baseline so the whole thing isn't flagged as
|
|
394
|
+
// "changed" against the file we just came from.
|
|
395
|
+
prevBlocks = []; prevEls = []; flashedEls = []; lastSrc = "";
|
|
396
|
+
}
|
|
397
|
+
if (msg.type === "open-error") { filepicker.classList.add("error"); filepicker.title = msg.message; showHeader(); }
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function showMissing() {
|
|
401
|
+
lastSrc = ""; prevBlocks = []; prevEls = []; flashedEls = []; notionLink.hidden = true;
|
|
402
|
+
docEl.innerHTML = '<p style="color:var(--ink-soft);font-style:italic">The document is no longer on disk — waiting for it to return…</p>';
|
|
403
|
+
}
|
|
404
|
+
connect();
|
|
405
|
+
|
|
406
|
+
// ---------- block rendering + change flash ----------
|
|
407
|
+
// A change flashes only the words that actually changed, not the whole block:
|
|
408
|
+
// 1) a block-level diff (LCS over each block's raw markdown) finds which blocks are
|
|
409
|
+
// unchanged, changed, or brand new, pairing each changed block with its old text;
|
|
410
|
+
// 2) a word-level diff inside each changed block wraps just the new/changed words in
|
|
411
|
+
// a <span class="flash">. Brand-new blocks flash whole.
|
|
412
|
+
let prevBlocks = []; // [{ raw, text }] from the previous render
|
|
413
|
+
let prevEls = []; // the .block DOM nodes from the previous render, parallel to prevBlocks
|
|
414
|
+
let flashedEls = []; // nodes carrying a change-highlight, cleared on the next render
|
|
415
|
+
let lastSrc = ""; // most recent doc source (for restoring after an edit)
|
|
416
|
+
let editingEl = null; // the .block currently being edited in place
|
|
417
|
+
|
|
418
|
+
// tokenizeWords / diffOps / classifyBlocks now live in render-core.js (loaded as a <script>
|
|
419
|
+
// before this one, so they're globals here) — shared verbatim with the node:test suite.
|
|
420
|
+
|
|
421
|
+
// Wrap the changed words of `el` (vs oldText) in flashing spans. Returns false if it
|
|
422
|
+
// punts (too large / wholly changed) so the caller can flash the whole block instead.
|
|
423
|
+
function flashChangedWords(el, oldText) {
|
|
424
|
+
const newText = el.textContent;
|
|
425
|
+
const a = tokenizeWords(oldText), b = tokenizeWords(newText);
|
|
426
|
+
if (b.length === 0) return true; // nothing visible to flash
|
|
427
|
+
if (a.length > 600 || b.length > 600) return false; // skip pathological diffs
|
|
428
|
+
const ops = diffOps(a, b);
|
|
429
|
+
// Char ranges in newText that are inserted/changed (skip pure-whitespace segments).
|
|
430
|
+
const ranges = []; const offs = []; let o = 0;
|
|
431
|
+
for (const seg of b) { offs.push(o); o += seg.length; }
|
|
432
|
+
for (const op of ops) {
|
|
433
|
+
if (op[0] !== "ins") continue;
|
|
434
|
+
const j = op[2], seg = b[j];
|
|
435
|
+
if (!/\S/.test(seg)) continue;
|
|
436
|
+
const s = offs[j], e = s + seg.length;
|
|
437
|
+
if (ranges.length && ranges[ranges.length - 1][1] >= s) ranges[ranges.length - 1][1] = e;
|
|
438
|
+
else ranges.push([s, e]);
|
|
439
|
+
}
|
|
440
|
+
if (!ranges.length) return true; // changed raw but identical text
|
|
441
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
442
|
+
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
443
|
+
let pos = 0;
|
|
444
|
+
for (const node of nodes) {
|
|
445
|
+
const text = node.nodeValue, start = pos; pos += text.length;
|
|
446
|
+
const local = [];
|
|
447
|
+
for (const [rs, re] of ranges) {
|
|
448
|
+
const s = Math.max(rs, start) - start, e = Math.min(re, pos) - start;
|
|
449
|
+
if (s < e) local.push([s, e]);
|
|
450
|
+
}
|
|
451
|
+
if (!local.length) continue;
|
|
452
|
+
const frag = document.createDocumentFragment();
|
|
453
|
+
let cur = 0;
|
|
454
|
+
for (const [ls, le] of local) {
|
|
455
|
+
if (ls > cur) frag.appendChild(document.createTextNode(text.slice(cur, ls)));
|
|
456
|
+
const span = document.createElement("span");
|
|
457
|
+
span.className = "changed";
|
|
458
|
+
span.textContent = text.slice(ls, le);
|
|
459
|
+
frag.appendChild(span);
|
|
460
|
+
cur = le;
|
|
461
|
+
}
|
|
462
|
+
if (cur < text.length) frag.appendChild(document.createTextNode(text.slice(cur)));
|
|
463
|
+
node.parentNode.replaceChild(frag, node);
|
|
464
|
+
}
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Render one filtered token into a fresh, sanitized .block element.
|
|
469
|
+
function makeBlock(tok) {
|
|
470
|
+
const div = document.createElement("div");
|
|
471
|
+
div.className = "block";
|
|
472
|
+
div.dataset.raw = tok.raw;
|
|
473
|
+
// Sanitize: a malicious doc could embed <img onerror>/<script> that would run in this
|
|
474
|
+
// page — which holds the session token — so raw HTML in the markdown must be scrubbed.
|
|
475
|
+
// If the sanitizer didn't load (CDN failure), fail safe to plain text rather than
|
|
476
|
+
// ever rendering unsanitized HTML.
|
|
477
|
+
if (window.DOMPurify) div.innerHTML = DOMPurify.sanitize(marked.parser([tok]));
|
|
478
|
+
else div.textContent = tok.raw;
|
|
479
|
+
return div;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Strip a prior change-highlight from a reused node without re-parsing it: drop the
|
|
483
|
+
// whole-block class and unwrap any per-word <span class="changed"> (keeping their text).
|
|
484
|
+
function clearFlash(el) {
|
|
485
|
+
el.classList.remove("changed");
|
|
486
|
+
const spans = el.querySelectorAll("span.changed");
|
|
487
|
+
if (spans.length) {
|
|
488
|
+
spans.forEach((s) => {
|
|
489
|
+
s.replaceWith(document.createTextNode(s.textContent));
|
|
490
|
+
});
|
|
491
|
+
el.normalize();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function render(src) {
|
|
496
|
+
lastSrc = src;
|
|
497
|
+
updateNotionLink(src);
|
|
498
|
+
// Drop blank blocks and any standalone HTML comment (e.g. the mdinterface:notion marker),
|
|
499
|
+
// so the marker stays invisible in the rendered doc.
|
|
500
|
+
const tokens = marked.lexer(src).filter((t) => {
|
|
501
|
+
const r = t.raw.trim();
|
|
502
|
+
return r !== "" && !/^<!--[\s\S]*-->$/.test(r);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// The previous render's highlights clear on this change — undo them now (cheap: only the
|
|
506
|
+
// handful of nodes that were flashed, and unwrapping never re-parses).
|
|
507
|
+
for (const el of flashedEls) clearFlash(el);
|
|
508
|
+
flashedEls = [];
|
|
509
|
+
|
|
510
|
+
// First render, or after a document switch: build everything once, with no change-flash.
|
|
511
|
+
if (!prevBlocks.length) {
|
|
512
|
+
docEl.innerHTML = "";
|
|
513
|
+
const els = [], blocks = [];
|
|
514
|
+
for (const tok of tokens) {
|
|
515
|
+
const div = makeBlock(tok);
|
|
516
|
+
docEl.appendChild(div);
|
|
517
|
+
els.push(div);
|
|
518
|
+
blocks.push({ raw: tok.raw, text: div.textContent });
|
|
519
|
+
}
|
|
520
|
+
prevEls = els; prevBlocks = blocks;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Incremental: classify against the previous blocks, then REUSE the DOM nodes of unchanged
|
|
525
|
+
// blocks and only parse/sanitize the ones that actually changed. Per-edit work becomes
|
|
526
|
+
// proportional to the edit, not the whole document — and because untouched nodes are never
|
|
527
|
+
// detached, a text selection or the scroll position in an unchanged region is preserved.
|
|
528
|
+
const newB = tokens.map((t) => ({ raw: t.raw }));
|
|
529
|
+
const cls = classifyBlocks(prevBlocks, newB);
|
|
530
|
+
const scroll = docEl.parentElement.scrollTop;
|
|
531
|
+
|
|
532
|
+
const newEls = new Array(newB.length);
|
|
533
|
+
const newBlocks = new Array(newB.length);
|
|
534
|
+
const toFlash = []; // defer flashing until the DOM is reconciled
|
|
535
|
+
for (let idx = 0; idx < newB.length; idx++) {
|
|
536
|
+
const c = cls[idx] || { type: "new" };
|
|
537
|
+
if (c.type === "same") {
|
|
538
|
+
newEls[idx] = prevEls[c.oldIdx];
|
|
539
|
+
newBlocks[idx] = prevBlocks[c.oldIdx];
|
|
540
|
+
} else {
|
|
541
|
+
const el = makeBlock(tokens[idx]);
|
|
542
|
+
newEls[idx] = el;
|
|
543
|
+
newBlocks[idx] = { raw: newB[idx].raw, text: el.textContent }; // text captured pre-wrap
|
|
544
|
+
toFlash.push(c.type === "new" ? { el } : { el, oldText: c.oldText });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Reconcile docEl's children to newEls with minimal mutation: remove nodes that are gone,
|
|
549
|
+
// then place each desired node in order. Reused nodes already in position aren't touched.
|
|
550
|
+
const keep = new Set(newEls);
|
|
551
|
+
for (const old of Array.from(docEl.childNodes)) if (!keep.has(old)) docEl.removeChild(old);
|
|
552
|
+
let ref = docEl.firstChild;
|
|
553
|
+
for (const node of newEls) {
|
|
554
|
+
if (ref === node) ref = ref.nextSibling;
|
|
555
|
+
else docEl.insertBefore(node, ref);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (const f of toFlash) {
|
|
559
|
+
if (f.oldText === undefined) f.el.classList.add("changed"); // brand-new block: flash whole
|
|
560
|
+
else if (!flashChangedWords(f.el, f.oldText)) f.el.classList.add("changed");
|
|
561
|
+
flashedEls.push(f.el);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
docEl.parentElement.scrollTop = scroll;
|
|
565
|
+
prevEls = newEls; prevBlocks = newBlocks;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------- selection → mirrored to disk → ambient context for Claude ----------
|
|
569
|
+
// No pasting into the prompt: whatever is selected here is written to a file that a
|
|
570
|
+
// UserPromptSubmit hook injects as context on the user's next message.
|
|
571
|
+
//
|
|
572
|
+
// The selection is STICKY: clicking into the Claude pane (or anywhere outside the
|
|
573
|
+
// canvas) collapses the browser selection, but we keep the last canvas selection so
|
|
574
|
+
// it stays available while you type your instruction. It clears only when you click
|
|
575
|
+
// to deselect inside the doc, or make a new selection.
|
|
576
|
+
//
|
|
577
|
+
// The armed text is highlighted via the CSS Custom Highlight API, which paints the
|
|
578
|
+
// exact range without mutating the DOM and persists after the native selection
|
|
579
|
+
// collapses. Browsers without it fall back to marking the containing block(s).
|
|
580
|
+
const HL = "mdinterface-selection";
|
|
581
|
+
const canHighlight = typeof CSS !== "undefined" && CSS.highlights && typeof Highlight !== "undefined";
|
|
582
|
+
let selDebounce;
|
|
583
|
+
let armed = false;
|
|
584
|
+
let armedEls = []; // fallback: blocks marked when the Highlight API is unavailable
|
|
585
|
+
|
|
586
|
+
function blocksForSelection(sel) {
|
|
587
|
+
// The .block elements the selection touches — used for the server-side line range.
|
|
588
|
+
const els = [];
|
|
589
|
+
for (const el of docEl.querySelectorAll(".block")) {
|
|
590
|
+
if (sel.containsNode(el, true) && el.dataset.raw) els.push(el);
|
|
591
|
+
}
|
|
592
|
+
return els;
|
|
593
|
+
}
|
|
594
|
+
function applyHighlight(sel) {
|
|
595
|
+
if (canHighlight) {
|
|
596
|
+
const ranges = [];
|
|
597
|
+
for (let i = 0; i < sel.rangeCount; i++) {
|
|
598
|
+
const r = sel.getRangeAt(i);
|
|
599
|
+
if (!r.collapsed) ranges.push(r.cloneRange());
|
|
600
|
+
}
|
|
601
|
+
if (ranges.length) CSS.highlights.set(HL, new Highlight(...ranges));
|
|
602
|
+
else CSS.highlights.delete(HL);
|
|
603
|
+
} else {
|
|
604
|
+
armedEls.forEach((el) => {
|
|
605
|
+
el.classList.remove("armed");
|
|
606
|
+
});
|
|
607
|
+
armedEls = blocksForSelection(sel);
|
|
608
|
+
armedEls.forEach((el) => {
|
|
609
|
+
el.classList.add("armed");
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function clearHighlight() {
|
|
614
|
+
if (canHighlight) CSS.highlights.delete(HL);
|
|
615
|
+
armedEls.forEach((el) => {
|
|
616
|
+
el.classList.remove("armed");
|
|
617
|
+
});
|
|
618
|
+
armedEls = [];
|
|
619
|
+
}
|
|
620
|
+
function pushSelection() {
|
|
621
|
+
const sel = window.getSelection();
|
|
622
|
+
const text = sel ? sel.toString().trim() : "";
|
|
623
|
+
// Use the range's common ancestor: true iff the WHOLE selection is inside the doc.
|
|
624
|
+
// More robust than checking anchor/focus separately (which is sensitive to selection
|
|
625
|
+
// direction and boundary nodes).
|
|
626
|
+
const range = sel?.rangeCount ? sel.getRangeAt(0) : null;
|
|
627
|
+
const inDoc = range && docEl.contains(range.commonAncestorContainer);
|
|
628
|
+
if (text && inDoc) {
|
|
629
|
+
applyHighlight(sel);
|
|
630
|
+
armed = true;
|
|
631
|
+
send({ type: "selection", text, blocks: blocksForSelection(sel).map(el => el.dataset.raw) });
|
|
632
|
+
} else if (!text && inDoc && armed) {
|
|
633
|
+
// collapsed the selection by clicking inside the doc → clear
|
|
634
|
+
clearHighlight();
|
|
635
|
+
armed = false;
|
|
636
|
+
send({ type: "selection", text: "", blocks: [] });
|
|
637
|
+
}
|
|
638
|
+
// else: focus moved outside the canvas (e.g. into Claude) → keep the last selection
|
|
639
|
+
}
|
|
640
|
+
function flushSelection() { clearTimeout(selDebounce); pushSelection(); }
|
|
641
|
+
// Debounced during a live drag, but flushed IMMEDIATELY the moment selecting finishes
|
|
642
|
+
// (mouse release / key release), so the file is current before you can switch to the
|
|
643
|
+
// terminal and submit — closing the race that dropped selections.
|
|
644
|
+
document.addEventListener("selectionchange", () => {
|
|
645
|
+
clearTimeout(selDebounce);
|
|
646
|
+
selDebounce = setTimeout(pushSelection, 120);
|
|
647
|
+
});
|
|
648
|
+
document.addEventListener("mouseup", flushSelection);
|
|
649
|
+
// Flush on keyboard selection too, but skip keystrokes inside the terminal (every keypress
|
|
650
|
+
// there would otherwise run a selection check for nothing).
|
|
651
|
+
document.addEventListener("keyup", (e) => {
|
|
652
|
+
const tp = document.getElementById("termPane");
|
|
653
|
+
if (!tp?.contains(e.target)) flushSelection();
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// ---------- edit the markdown directly in the canvas ----------
|
|
657
|
+
// Double-click a block to edit its raw markdown in place. Click away or ⌘/Ctrl+Enter to
|
|
658
|
+
// save (writes the file → re-renders); Esc to cancel. Editing the raw markdown (not the
|
|
659
|
+
// rendered HTML) round-trips losslessly back to disk.
|
|
660
|
+
function autoGrow(ta) { ta.style.height = "auto"; ta.style.height = `${ta.scrollHeight}px`; }
|
|
661
|
+
|
|
662
|
+
function beginEdit(block) {
|
|
663
|
+
if (editingEl || !block.dataset.raw) return;
|
|
664
|
+
editingEl = block;
|
|
665
|
+
if (armed) { clearHighlight(); armed = false; send({ type: "selection", text: "", blocks: [] }); }
|
|
666
|
+
window.getSelection().removeAllRanges();
|
|
667
|
+
|
|
668
|
+
const oldRaw = block.dataset.raw;
|
|
669
|
+
const trailing = (oldRaw.match(/\n+$/) || ["\n"])[0]; // preserve block spacing on save
|
|
670
|
+
// occurrence index among identical blocks, so the server edits the right one
|
|
671
|
+
let nth = 0;
|
|
672
|
+
for (const b of docEl.querySelectorAll(".block")) { if (b === block) break; if (b.dataset.raw === oldRaw) nth++; }
|
|
673
|
+
|
|
674
|
+
block.classList.add("editing");
|
|
675
|
+
const ta = document.createElement("textarea");
|
|
676
|
+
ta.className = "edit";
|
|
677
|
+
ta.value = oldRaw.replace(/\n+$/, "");
|
|
678
|
+
block.innerHTML = "";
|
|
679
|
+
block.appendChild(ta);
|
|
680
|
+
autoGrow(ta);
|
|
681
|
+
ta.focus();
|
|
682
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
683
|
+
|
|
684
|
+
let done = false;
|
|
685
|
+
function finish(save) {
|
|
686
|
+
if (done) return; done = true;
|
|
687
|
+
editingEl = null;
|
|
688
|
+
block.classList.remove("editing");
|
|
689
|
+
const newRaw = ta.value;
|
|
690
|
+
if (save && newRaw !== oldRaw.replace(/\n+$/, "")) {
|
|
691
|
+
send({ type: "edit", oldRaw, newRaw: newRaw + trailing, nth });
|
|
692
|
+
// server writes → watcher broadcasts → render() replaces this textarea shortly
|
|
693
|
+
} else {
|
|
694
|
+
// No change / cancel: beginEdit gutted this node into a <textarea>. Rebuild THIS block
|
|
695
|
+
// in place from its raw and keep its render-cache entry intact, so the incremental
|
|
696
|
+
// renderer neither reuses the broken textarea node nor flashes the block as "changed"
|
|
697
|
+
// (a no-op edit must not light up amber). render() then still picks up any external edits.
|
|
698
|
+
const i = prevEls.indexOf(block);
|
|
699
|
+
if (i !== -1) {
|
|
700
|
+
const fresh = document.createElement("div");
|
|
701
|
+
fresh.className = "block";
|
|
702
|
+
fresh.dataset.raw = block.dataset.raw;
|
|
703
|
+
// Rebuild from the raw markdown the same way render() does, but lex it first so
|
|
704
|
+
// marked gets real tokens (makeBlock takes a token, not a bare {raw}).
|
|
705
|
+
if (window.DOMPurify)
|
|
706
|
+
fresh.innerHTML = DOMPurify.sanitize(marked.parser(marked.lexer(block.dataset.raw)));
|
|
707
|
+
else fresh.textContent = block.dataset.raw;
|
|
708
|
+
block.replaceWith(fresh);
|
|
709
|
+
prevEls[i] = fresh;
|
|
710
|
+
}
|
|
711
|
+
render(lastSrc); // restore current doc (incl. any Claude edits made while editing)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
ta.addEventListener("blur", () => finish(true));
|
|
715
|
+
ta.addEventListener("input", () => autoGrow(ta));
|
|
716
|
+
ta.addEventListener("keydown", (e) => {
|
|
717
|
+
if (e.key === "Escape") { e.preventDefault(); finish(false); }
|
|
718
|
+
else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); ta.blur(); }
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
docEl.addEventListener("dblclick", (e) => {
|
|
722
|
+
const block = e.target.closest(".block");
|
|
723
|
+
if (block && docEl.contains(block)) beginEdit(block);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// ---------- draggable divider ----------
|
|
727
|
+
const divider = document.getElementById("divider");
|
|
728
|
+
const main = document.querySelector("main");
|
|
729
|
+
const MIN_PANE = 280; // px; min width for either pane
|
|
730
|
+
divider.addEventListener("pointerdown", e => {
|
|
731
|
+
e.preventDefault();
|
|
732
|
+
divider.classList.add("active");
|
|
733
|
+
divider.setPointerCapture(e.pointerId);
|
|
734
|
+
// Track the left column's width by accumulating cursor deltas against a clamped
|
|
735
|
+
// running value (not absolute clientX). Clamping the stored width — not the cursor —
|
|
736
|
+
// means reversing direction responds immediately, with no overshoot dead-zone. Works
|
|
737
|
+
// for either layout, since the left column is whichever pane has order 1.
|
|
738
|
+
let lastX = e.clientX;
|
|
739
|
+
let width = divider.getBoundingClientRect().left - main.getBoundingClientRect().left;
|
|
740
|
+
let rafPending = false;
|
|
741
|
+
const move = ev => {
|
|
742
|
+
width += ev.clientX - lastX;
|
|
743
|
+
lastX = ev.clientX;
|
|
744
|
+
const max = main.clientWidth - 6 - MIN_PANE;
|
|
745
|
+
width = Math.max(MIN_PANE, Math.min(width, max));
|
|
746
|
+
main.style.gridTemplateColumns = `${width}px 6px 1fr`;
|
|
747
|
+
if (!rafPending) { rafPending = true; requestAnimationFrame(() => { rafPending = false; refit(); }); }
|
|
748
|
+
};
|
|
749
|
+
const up = () => {
|
|
750
|
+
divider.classList.remove("active");
|
|
751
|
+
divider.removeEventListener("pointermove", move);
|
|
752
|
+
divider.removeEventListener("pointerup", up);
|
|
753
|
+
refit();
|
|
754
|
+
};
|
|
755
|
+
divider.addEventListener("pointermove", move);
|
|
756
|
+
divider.addEventListener("pointerup", up);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// ---------- swap which side the Claude terminal is on (remembered across reloads) ----------
|
|
760
|
+
const swapBtn = document.getElementById("swap");
|
|
761
|
+
function applySwap(on) {
|
|
762
|
+
main.classList.toggle("swapped", on);
|
|
763
|
+
main.style.gridTemplateColumns = ""; // drop any divider-drag override → clean default ratio
|
|
764
|
+
refit();
|
|
765
|
+
}
|
|
766
|
+
applySwap(localStorage.getItem("mdinterface.swap") === "1");
|
|
767
|
+
swapBtn.addEventListener("click", () => {
|
|
768
|
+
const on = !main.classList.contains("swapped");
|
|
769
|
+
localStorage.setItem("mdinterface.swap", on ? "1" : "0");
|
|
770
|
+
applySwap(on);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ---------- restart the Claude session (reloads hooks / CLAUDE.md / MCP) ----------
|
|
774
|
+
const restartBtn = document.getElementById("restart");
|
|
775
|
+
let restartArmed = false, restartTimer;
|
|
776
|
+
function disarmRestart() {
|
|
777
|
+
restartArmed = false; clearTimeout(restartTimer);
|
|
778
|
+
restartBtn.textContent = "⟲ Restart Claude"; restartBtn.classList.remove("armed");
|
|
779
|
+
}
|
|
780
|
+
restartBtn.addEventListener("click", () => {
|
|
781
|
+
if (!restartArmed) { // first click arms; a second click within 2.5s confirms (avoids accidents)
|
|
782
|
+
restartArmed = true;
|
|
783
|
+
restartBtn.textContent = "⟲ Click again to restart";
|
|
784
|
+
restartBtn.classList.add("armed");
|
|
785
|
+
restartTimer = setTimeout(disarmRestart, 2500);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
disarmRestart();
|
|
789
|
+
term.reset(); // clear the old session's screen before the fresh one draws
|
|
790
|
+
send({ type: "restart" });
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// ---------- auto-hiding toolbar: reveal only when the cursor reaches the top ----------
|
|
794
|
+
const header = document.querySelector("header");
|
|
795
|
+
let headerHide;
|
|
796
|
+
function showHeader() { clearTimeout(headerHide); header.classList.add("show"); }
|
|
797
|
+
function hideHeaderSoon(delay) { clearTimeout(headerHide); headerHide = setTimeout(() => header.classList.remove("show"), delay); }
|
|
798
|
+
document.addEventListener("mousemove", (e) => { if (e.clientY <= 6) showHeader(); });
|
|
799
|
+
header.addEventListener("mouseenter", showHeader);
|
|
800
|
+
header.addEventListener("mouseleave", () => hideHeaderSoon(300));
|
|
801
|
+
// flash it on load so the controls are discoverable, then tuck away
|
|
802
|
+
showHeader(); hideHeaderSoon(1800);
|
|
803
|
+
</script>
|
|
804
|
+
</body>
|
|
805
|
+
</html>
|