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.
@@ -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>