projview 1.1.1 → 1.2.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 +5 -3
- package/docs/reference/api.md +32 -1
- package/package.json +1 -1
- package/public/comments.js +4 -4
- package/public/editor.js +306 -0
- package/public/history.js +162 -0
- package/public/index.html +20 -10
- package/public/live.js +7 -1
- package/public/main.js +7 -1
- package/public/mermaid.js +17 -10
- package/public/store.js +3 -1
- package/public/style.css +318 -125
- package/public/theme.js +52 -0
- package/public/toc.js +13 -5
- package/public/tree.js +2 -1
- package/public/viewer.js +14 -3
- package/src/render.js +24 -2
- package/src/search.js +15 -5
- package/src/server.js +130 -18
- package/src/store.js +88 -3
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# projview
|
|
2
2
|
|
|
3
3
|
a fast, zero-config local viewer for **markdown** + **mermaid** files. run it in
|
|
4
|
-
any directory, get a
|
|
5
|
-
(plus `.json` / `.yaml`) under it
|
|
6
|
-
behind.
|
|
4
|
+
any directory, get a quiet localhost view - light or dark, following your OS - of
|
|
5
|
+
every `.md` / `.mmd` (plus `.json` / `.yaml`) under it: rendered diagrams, search,
|
|
6
|
+
live reload. close it and nothing's left behind.
|
|
7
7
|
|
|
8
8
|
that's the product. point it at a docs folder, read comfortably, ctrl-c, gone.
|
|
9
9
|
|
|
@@ -38,6 +38,8 @@ projview /path/to/notes --no-open
|
|
|
38
38
|
| data files | JSON pretty-printed (literals kept exact), YAML shown as written - both with line numbers + indent guides |
|
|
39
39
|
| search | `⌘K` palette - fuzzy filenames + full-text content with line snippets |
|
|
40
40
|
| live reload | edit a file, the browser updates |
|
|
41
|
+
| source editor | `edit` (or press `e`) opens the raw text beside a live preview; `⌘S` saves to disk. local runs only - read-only when exposed on a network |
|
|
42
|
+
| history | the last 5 earlier versions of each file - from any change, not just projview's editor - to view and restore. kept until projview stops, or across runs with `--persist` |
|
|
41
43
|
| ephemeral | in-memory by default; nothing written to disk, nothing left behind |
|
|
42
44
|
|
|
43
45
|
see [samples/architecture.md](samples/architecture.md) for a mermaid demo.
|
package/docs/reference/api.md
CHANGED
|
@@ -5,7 +5,8 @@ and scroll-spy (the active section should track as you scroll).
|
|
|
5
5
|
|
|
6
6
|
## overview
|
|
7
7
|
|
|
8
|
-
projview exposes a tiny HTTP surface.
|
|
8
|
+
projview exposes a tiny HTTP surface. The only file it writes is one you save
|
|
9
|
+
from the source editor (`PUT /api/source`, loopback only); otherwise the only
|
|
9
10
|
state it records is its own usage events (views / searches) into the store.
|
|
10
11
|
|
|
11
12
|
## endpoints
|
|
@@ -20,6 +21,36 @@ flag (false when projview is exposed on a non-loopback host).
|
|
|
20
21
|
Renders a single file to HTML on demand. Query param `p` is the relative path.
|
|
21
22
|
Records a `view` usage event.
|
|
22
23
|
|
|
24
|
+
### GET /api/source · PUT /api/source
|
|
25
|
+
|
|
26
|
+
`GET ?p=<path>` returns `{ path, content, mtime }` - the raw text the editor
|
|
27
|
+
opens. `PUT` saves `{ path, content, mtime, force? }`:
|
|
28
|
+
|
|
29
|
+
| response | when |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `200 { path, mtime }` | saved |
|
|
32
|
+
| `400` | not a previewable file, outside the root, or under a hidden dir |
|
|
33
|
+
| `403` | bound to a non-loopback host, or the `Host` header isn't a local name |
|
|
34
|
+
| `409 { mtime }` | the file changed on disk since `mtime` - resend with `force: true` to overwrite |
|
|
35
|
+
|
|
36
|
+
### POST /api/preview
|
|
37
|
+
|
|
38
|
+
Renders `{ path, content }` - unsaved editor text - exactly as `/api/file` would
|
|
39
|
+
render that file, and returns `{ html }`. Never writes; `400` for a path that
|
|
40
|
+
isn't a previewable type.
|
|
41
|
+
|
|
42
|
+
### GET /api/versions · GET /api/versions/:id
|
|
43
|
+
|
|
44
|
+
`GET ?p=<path>` lists a file's earlier versions, newest first:
|
|
45
|
+
`{ path, limit, versions: [{ id, file, mtime, replacedAt, size }] }`.
|
|
46
|
+
`GET /api/versions/:id` returns one with its `content`.
|
|
47
|
+
|
|
48
|
+
A version is the text a file had just before it changed - whether the change
|
|
49
|
+
came from projview's editor or anywhere else (an IDE, `git pull`, an agent). The
|
|
50
|
+
newest 5 are kept per file, in the same store as comments: gone when projview
|
|
51
|
+
stops by default, kept across runs with `--persist` or a `--store-url`. Files over
|
|
52
|
+
5 MB aren't versioned. Restoring is an ordinary `PUT /api/source` of the old text.
|
|
53
|
+
|
|
23
54
|
### GET /api/events
|
|
24
55
|
|
|
25
56
|
Server-sent events stream: `change` / `tree` for live reload, `comments` when a
|
package/package.json
CHANGED
package/public/comments.js
CHANGED
|
@@ -54,7 +54,7 @@ drawer.innerHTML =
|
|
|
54
54
|
<div class="cmt-composer-slot"></div>
|
|
55
55
|
<div class="cmt-list"></div>`;
|
|
56
56
|
|
|
57
|
-
const chip = cre('button', 'cmt-selchip', '
|
|
57
|
+
const chip = cre('button', 'cmt-selchip', 'comment');
|
|
58
58
|
chip.hidden = true;
|
|
59
59
|
|
|
60
60
|
document.body.append(fab, drawer, chip);
|
|
@@ -203,7 +203,7 @@ contentEl.addEventListener('click', (e) => {
|
|
|
203
203
|
}
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
-
// A hover "
|
|
206
|
+
// A hover "+ comment" at the end of every heading -> comment on that section.
|
|
207
207
|
function addHeadingAffordances() {
|
|
208
208
|
if (!state.writable) return;
|
|
209
209
|
for (const h of contentEl.querySelectorAll('h1, h2, h3')) {
|
|
@@ -301,7 +301,7 @@ function renderComposer() {
|
|
|
301
301
|
function renderList() {
|
|
302
302
|
listEl.replaceChildren();
|
|
303
303
|
if (!current.length) {
|
|
304
|
-
listEl.append(cre('div', 'cmt-empty', '
|
|
304
|
+
listEl.append(cre('div', 'cmt-empty', 'no comments yet' + (state.writable ? '<br><small>select text, hover a heading, or “+ note”</small>' : '')));
|
|
305
305
|
return;
|
|
306
306
|
}
|
|
307
307
|
for (const c of current) {
|
|
@@ -377,7 +377,7 @@ function updateBadge() {
|
|
|
377
377
|
|
|
378
378
|
function anchorLabel(a, quoteOverride) {
|
|
379
379
|
if (!a) return 'file';
|
|
380
|
-
if (a.type === 'file') return '
|
|
380
|
+
if (a.type === 'file') return 'whole file';
|
|
381
381
|
if (a.type === 'heading') {
|
|
382
382
|
const el = document.getElementById(a.id);
|
|
383
383
|
return '§ ' + (quoteOverride || (el ? el.textContent : a.id));
|
package/public/editor.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/* source editor: swap the rendered page for its raw text, save back to disk.
|
|
2
|
+
* Loopback-only like comments (state.writable). Saves carry the mtime the file
|
|
3
|
+
* had when it was opened, so a change made elsewhere is never silently clobbered. */
|
|
4
|
+
|
|
5
|
+
import { $, esc } from './dom.js';
|
|
6
|
+
import { state } from './store.js';
|
|
7
|
+
import { openFile } from './viewer.js';
|
|
8
|
+
import { toast } from './toast.js';
|
|
9
|
+
import { runMermaid } from './mermaid.js';
|
|
10
|
+
import { fileKind } from './icons.js';
|
|
11
|
+
import { showHistory } from './history.js';
|
|
12
|
+
|
|
13
|
+
const contentEl = $('content');
|
|
14
|
+
const breadcrumbEl = $('breadcrumb');
|
|
15
|
+
|
|
16
|
+
let session = null; // { path, mtime, original, crlf, textarea, noticeEl } while editing
|
|
17
|
+
|
|
18
|
+
export const isEditing = (p) => !!session && (p === undefined || session.path === p);
|
|
19
|
+
const isDirty = () => !!session && session.textarea.value !== session.original;
|
|
20
|
+
|
|
21
|
+
// Called by the viewer before it navigates away: false keeps the editor open.
|
|
22
|
+
export function confirmLeave() {
|
|
23
|
+
if (!isDirty()) { session = null; document.body.classList.remove('editing'); return true; }
|
|
24
|
+
if (!confirm('discard unsaved changes?')) return false;
|
|
25
|
+
session = null;
|
|
26
|
+
document.body.classList.remove('editing');
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// md / mmd render to something other than their source, so they get a raw view too.
|
|
31
|
+
export const hasRawView = (p) => ['md', 'mmd'].includes(fileKind(p));
|
|
32
|
+
|
|
33
|
+
// Called after every render: the view switcher in the breadcrumb bar.
|
|
34
|
+
// md / mmd: rendered | raw | edit | history. json / yaml (already shown as
|
|
35
|
+
// source): edit | history. "edit" only where this client may write.
|
|
36
|
+
export function onFileShown(p) {
|
|
37
|
+
if (!p) return;
|
|
38
|
+
const views = [...(hasRawView(p) ? ['rendered', 'raw'] : []), ...(state.writable ? ['edit'] : []), 'history'];
|
|
39
|
+
const wrap = document.createElement('div');
|
|
40
|
+
wrap.className = 'view-switch';
|
|
41
|
+
for (const v of views) {
|
|
42
|
+
const btn = document.createElement('button');
|
|
43
|
+
btn.dataset.view = v;
|
|
44
|
+
btn.textContent = v;
|
|
45
|
+
if (v === 'edit') btn.title = 'edit source (e)';
|
|
46
|
+
btn.addEventListener('click', () => switchView(p, v));
|
|
47
|
+
wrap.appendChild(btn);
|
|
48
|
+
}
|
|
49
|
+
breadcrumbEl.appendChild(wrap);
|
|
50
|
+
markView(hasRawView(p) ? state.view : null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function markView(v) {
|
|
54
|
+
for (const b of breadcrumbEl.querySelectorAll('.view-switch button')) b.classList.toggle('on', b.dataset.view === v);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function switchView(p, v) {
|
|
58
|
+
if (v === 'edit') { if (!session) startEditing(p); return; }
|
|
59
|
+
if (session && !confirmLeave()) return;
|
|
60
|
+
if (v === 'history') { markView('history'); showHistory(p); return; } // never sticky
|
|
61
|
+
state.view = v;
|
|
62
|
+
openFile(p);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Live reload saw this file change while it was open in the editor.
|
|
66
|
+
export function onDiskChange() {
|
|
67
|
+
if (!session || session.saving) return; // our own save's change event - not a conflict
|
|
68
|
+
showNotice('this file changed on disk since you opened it', [
|
|
69
|
+
['reload', () => { session.original = session.textarea.value; startEditing(session.path); }],
|
|
70
|
+
['keep mine', () => hideNotice()]
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function startEditing(p) {
|
|
75
|
+
let data;
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(`/api/source?p=${encodeURIComponent(p)}`);
|
|
78
|
+
if (!res.ok) throw new Error();
|
|
79
|
+
data = await res.json();
|
|
80
|
+
} catch {
|
|
81
|
+
toast(`couldn't load ${p} for editing`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// only a consistently CRLF file is written back as CRLF; a mixed one is saved as LF
|
|
85
|
+
const crlf = data.content.includes('\r\n') && !/(^|[^\r])\n/.test(data.content);
|
|
86
|
+
const text = crlf ? data.content.replace(/\r\n/g, '\n') : data.content;
|
|
87
|
+
|
|
88
|
+
contentEl.innerHTML =
|
|
89
|
+
`<div class="editor">` +
|
|
90
|
+
`<div class="editor-bar">` +
|
|
91
|
+
`<span class="editor-name">${esc(p.split('/').pop())}</span>` +
|
|
92
|
+
`<span class="editor-dirty" hidden>unsaved</span>` +
|
|
93
|
+
`<span class="editor-spacer"></span>` +
|
|
94
|
+
`<span class="editor-hint">⌘S save · esc cancel · ctrl-m tab focus</span>` +
|
|
95
|
+
`<button class="editor-btn" data-act="cancel">cancel</button>` +
|
|
96
|
+
`<button class="editor-btn primary" data-act="save">save</button>` +
|
|
97
|
+
`</div>` +
|
|
98
|
+
`<div class="editor-notice" hidden></div>` +
|
|
99
|
+
`<div class="editor-panes" style="--split:${readSplit()}%">` +
|
|
100
|
+
`<textarea class="editor-text" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>` +
|
|
101
|
+
`<div class="editor-divider" role="separator" aria-orientation="vertical" aria-label="resize editor and preview" tabindex="0" title="drag to resize · double-click to reset"></div>` +
|
|
102
|
+
`<div class="editor-preview markdown-body"></div>` +
|
|
103
|
+
`</div>` +
|
|
104
|
+
`</div>`;
|
|
105
|
+
const textarea = contentEl.querySelector('.editor-text');
|
|
106
|
+
textarea.value = text;
|
|
107
|
+
session = { path: p, mtime: data.mtime, original: text, crlf, textarea, noticeEl: contentEl.querySelector('.editor-notice') };
|
|
108
|
+
document.body.classList.add('editing');
|
|
109
|
+
markView('edit');
|
|
110
|
+
|
|
111
|
+
const dirtyEl = contentEl.querySelector('.editor-dirty');
|
|
112
|
+
const previewEl = contentEl.querySelector('.editor-preview');
|
|
113
|
+
textarea.addEventListener('input', () => { dirtyEl.hidden = !isDirty(); schedulePreview(p, textarea, previewEl); });
|
|
114
|
+
textarea.addEventListener('scroll', () => syncScroll(textarea, previewEl));
|
|
115
|
+
wireDivider(contentEl.querySelector('.editor-panes'), contentEl.querySelector('.editor-divider'));
|
|
116
|
+
renderPreview(p, textarea, previewEl);
|
|
117
|
+
textarea.addEventListener('keydown', onKey);
|
|
118
|
+
contentEl.querySelector('[data-act="save"]').addEventListener('click', () => save());
|
|
119
|
+
contentEl.querySelector('[data-act="cancel"]').addEventListener('click', cancel);
|
|
120
|
+
textarea.focus();
|
|
121
|
+
textarea.setSelectionRange(0, 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---------- live preview (right pane) ---------- */
|
|
125
|
+
|
|
126
|
+
let previewTimer = 0;
|
|
127
|
+
let previewSeq = 0;
|
|
128
|
+
|
|
129
|
+
function schedulePreview(p, textarea, previewEl) {
|
|
130
|
+
clearTimeout(previewTimer);
|
|
131
|
+
previewTimer = setTimeout(() => renderPreview(p, textarea, previewEl), 250);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Rendered by the server exactly as the viewer renders the file, but from the
|
|
135
|
+
// unsaved text - nothing is written. Out-of-order responses are dropped.
|
|
136
|
+
async function renderPreview(p, textarea, previewEl) {
|
|
137
|
+
const seq = ++previewSeq;
|
|
138
|
+
let html;
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch('/api/preview', {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'content-type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ path: p, content: textarea.value })
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) throw new Error();
|
|
146
|
+
({ html } = await res.json());
|
|
147
|
+
} catch {
|
|
148
|
+
return; // keep the last good preview
|
|
149
|
+
}
|
|
150
|
+
if (seq !== previewSeq || !previewEl.isConnected) return;
|
|
151
|
+
previewEl.innerHTML = html;
|
|
152
|
+
await runMermaid();
|
|
153
|
+
syncScroll(textarea, previewEl);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Proportional: the preview sits at the same fraction of its height as the source.
|
|
157
|
+
function syncScroll(textarea, previewEl) {
|
|
158
|
+
const max = textarea.scrollHeight - textarea.clientHeight;
|
|
159
|
+
const frac = max > 0 ? textarea.scrollTop / max : 0;
|
|
160
|
+
previewEl.scrollTop = frac * (previewEl.scrollHeight - previewEl.clientHeight);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ---------- resizable split ---------- */
|
|
164
|
+
|
|
165
|
+
const SPLIT_KEY = 'pv-editor-split';
|
|
166
|
+
const clampSplit = (n) => Math.min(80, Math.max(20, n));
|
|
167
|
+
|
|
168
|
+
function readSplit() {
|
|
169
|
+
try { const n = Number(localStorage.getItem(SPLIT_KEY)); return n ? clampSplit(n) : 50; } catch { return 50; }
|
|
170
|
+
}
|
|
171
|
+
function saveSplit(n) {
|
|
172
|
+
try { localStorage.setItem(SPLIT_KEY, String(Math.round(n))); } catch { /* not persisted - fine */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function wireDivider(panes, divider) {
|
|
176
|
+
let split = readSplit();
|
|
177
|
+
const set = (n) => { split = clampSplit(n); panes.style.setProperty('--split', `${split}%`); };
|
|
178
|
+
divider.addEventListener('pointerdown', (e) => {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
divider.setPointerCapture(e.pointerId);
|
|
181
|
+
panes.classList.add('resizing');
|
|
182
|
+
const move = (ev) => {
|
|
183
|
+
const r = panes.getBoundingClientRect();
|
|
184
|
+
set(((ev.clientX - r.left) / r.width) * 100);
|
|
185
|
+
};
|
|
186
|
+
const up = () => {
|
|
187
|
+
divider.removeEventListener('pointerup', up);
|
|
188
|
+
divider.removeEventListener('pointercancel', up);
|
|
189
|
+
divider.removeEventListener('pointermove', move);
|
|
190
|
+
panes.classList.remove('resizing');
|
|
191
|
+
saveSplit(split);
|
|
192
|
+
};
|
|
193
|
+
divider.addEventListener('pointermove', move);
|
|
194
|
+
divider.addEventListener('pointerup', up, { once: true });
|
|
195
|
+
divider.addEventListener('pointercancel', up, { once: true });
|
|
196
|
+
});
|
|
197
|
+
divider.addEventListener('dblclick', () => { set(50); saveSplit(50); });
|
|
198
|
+
divider.addEventListener('keydown', (e) => {
|
|
199
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
set(split + (e.key === 'ArrowLeft' ? -5 : 5));
|
|
202
|
+
saveSplit(split);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ---------- keys ---------- */
|
|
207
|
+
|
|
208
|
+
let tabMovesFocus = false;
|
|
209
|
+
|
|
210
|
+
function onKey(e) {
|
|
211
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save(); return; }
|
|
212
|
+
if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
|
|
213
|
+
// ctrl-m toggles Tab between indenting and moving focus (so the keyboard can leave)
|
|
214
|
+
if (e.ctrlKey && e.key.toLowerCase() === 'm') {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
tabMovesFocus = !tabMovesFocus;
|
|
217
|
+
toast(tabMovesFocus ? 'tab moves focus (ctrl-m to indent)' : 'tab indents (ctrl-m to move focus)');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (e.key === 'Tab' && !tabMovesFocus && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
221
|
+
// two spaces, never a tab character - YAML forbids tabs for indentation
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
const ta = e.target;
|
|
224
|
+
if (e.shiftKey) { dedentLine(ta); return; }
|
|
225
|
+
ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end');
|
|
226
|
+
ta.dispatchEvent(new Event('input'));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function dedentLine(ta) {
|
|
231
|
+
const start = ta.value.lastIndexOf('\n', ta.selectionStart - 1) + 1;
|
|
232
|
+
const strip = ta.value.slice(start, start + 2).match(/^ {1,2}/)?.[0].length || 0;
|
|
233
|
+
if (!strip) return;
|
|
234
|
+
const caret = ta.selectionStart;
|
|
235
|
+
ta.setRangeText('', start, start + strip, 'preserve');
|
|
236
|
+
ta.setSelectionRange(Math.max(start, caret - strip), Math.max(start, caret - strip));
|
|
237
|
+
ta.dispatchEvent(new Event('input'));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function save(force = false) {
|
|
241
|
+
if (!session) return;
|
|
242
|
+
const { path, crlf, textarea } = session;
|
|
243
|
+
const content = crlf ? textarea.value.replace(/\n/g, '\r\n') : textarea.value;
|
|
244
|
+
let res;
|
|
245
|
+
session.saving = true;
|
|
246
|
+
try {
|
|
247
|
+
res = await fetch('/api/source', {
|
|
248
|
+
method: 'PUT',
|
|
249
|
+
headers: { 'content-type': 'application/json' },
|
|
250
|
+
body: JSON.stringify({ path, content, mtime: session.mtime, force })
|
|
251
|
+
});
|
|
252
|
+
} catch {
|
|
253
|
+
session.saving = false;
|
|
254
|
+
toast('save failed - is projview still running?');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
session.saving = false;
|
|
258
|
+
if (res.status === 409) {
|
|
259
|
+
showNotice('this file changed on disk since you opened it', [
|
|
260
|
+
['overwrite', () => save(true)],
|
|
261
|
+
['reload theirs', () => { session.original = session.textarea.value; startEditing(path); }]
|
|
262
|
+
]);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
const { error } = await res.json().catch(() => ({}));
|
|
267
|
+
toast(error || `save failed (${res.status})`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
session = null;
|
|
271
|
+
document.body.classList.remove('editing');
|
|
272
|
+
toast(`saved ${path.split('/').pop()}`);
|
|
273
|
+
openFile(path);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function cancel() {
|
|
277
|
+
if (!session) return;
|
|
278
|
+
const p = session.path;
|
|
279
|
+
if (confirmLeave()) openFile(p);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function showNotice(msg, actions) {
|
|
283
|
+
const el = session.noticeEl;
|
|
284
|
+
el.replaceChildren(document.createTextNode(msg));
|
|
285
|
+
for (const [label, fn] of actions) {
|
|
286
|
+
const b = document.createElement('button');
|
|
287
|
+
b.className = 'editor-btn';
|
|
288
|
+
b.textContent = label;
|
|
289
|
+
b.addEventListener('click', fn);
|
|
290
|
+
el.appendChild(b);
|
|
291
|
+
}
|
|
292
|
+
el.hidden = false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function hideNotice() { if (session) session.noticeEl.hidden = true; }
|
|
296
|
+
|
|
297
|
+
// "e" opens the editor on the current page, unless the user is typing somewhere
|
|
298
|
+
document.addEventListener('keydown', (e) => {
|
|
299
|
+
if (e.key !== 'e' || e.metaKey || e.ctrlKey || e.altKey || session) return;
|
|
300
|
+
if (e.target.closest?.('input, textarea, [contenteditable]') || !state.writable || !state.currentPath) return;
|
|
301
|
+
if (!$('palette').hidden || !$('zoom').hidden) return;
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
startEditing(state.currentPath);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
window.addEventListener('beforeunload', (e) => { if (isDirty()) e.preventDefault(); });
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/* history view: the file's earlier versions (newest first), each viewable as the
|
|
2
|
+
* file would render, and restorable. Versions are kept by the server whenever a
|
|
3
|
+
* file changes - from projview's editor or anywhere else. Restoring is an
|
|
4
|
+
* ordinary save, so the text it replaces becomes a version too. */
|
|
5
|
+
|
|
6
|
+
import { $, esc } from './dom.js';
|
|
7
|
+
import { state } from './store.js';
|
|
8
|
+
import { openFile } from './viewer.js';
|
|
9
|
+
import { runMermaid } from './mermaid.js';
|
|
10
|
+
import { toast } from './toast.js';
|
|
11
|
+
|
|
12
|
+
const contentEl = $('content');
|
|
13
|
+
|
|
14
|
+
let open = null; // { path, selected, mtime } while the history view is showing - mtime of the file as shown
|
|
15
|
+
|
|
16
|
+
export const isHistory = (p) => !!open && (p === undefined || open.path === p);
|
|
17
|
+
|
|
18
|
+
// The viewer calls this before rendering anything else.
|
|
19
|
+
export function leaveHistory() {
|
|
20
|
+
open = null;
|
|
21
|
+
document.body.classList.remove('history');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function showHistory(p) {
|
|
25
|
+
open = { path: p, selected: 'current' };
|
|
26
|
+
document.body.classList.add('history');
|
|
27
|
+
await refreshHistory();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Re-list (live reload calls this when the file changes while history is open).
|
|
31
|
+
export async function refreshHistory() {
|
|
32
|
+
if (!open) return;
|
|
33
|
+
const p = open.path;
|
|
34
|
+
let data, mtime = null;
|
|
35
|
+
try {
|
|
36
|
+
const [res, src] = await Promise.all([
|
|
37
|
+
fetch(`/api/versions?p=${encodeURIComponent(p)}`),
|
|
38
|
+
state.writable ? fetch(`/api/source?p=${encodeURIComponent(p)}`) : null
|
|
39
|
+
]);
|
|
40
|
+
if (!res.ok) throw new Error();
|
|
41
|
+
data = await res.json();
|
|
42
|
+
if (src?.ok) ({ mtime } = await src.json());
|
|
43
|
+
} catch {
|
|
44
|
+
toast(`couldn't load history for ${p}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!isHistory(p)) return;
|
|
48
|
+
open.mtime = mtime; // restore checks the file is still what "current" showed
|
|
49
|
+
const keep = state.persistent ? 'kept across runs' : 'kept until projview stops';
|
|
50
|
+
const rows = [{ id: 'current', label: 'current', when: null }, ...data.versions.map((v) => { const when = v.mtime ?? v.replacedAt; return { id: v.id, label: clock(when), sub: ago(when), when, size: v.size }; })];
|
|
51
|
+
if (!rows.some((r) => r.id === open.selected)) open.selected = 'current';
|
|
52
|
+
|
|
53
|
+
contentEl.innerHTML =
|
|
54
|
+
`<div class="history-view">` +
|
|
55
|
+
`<div class="history-bar">` +
|
|
56
|
+
`<span class="history-name">${esc(p.split('/').pop())}</span>` +
|
|
57
|
+
`<span class="history-hint">last ${data.limit} earlier versions · ${keep}</span>` +
|
|
58
|
+
`</div>` +
|
|
59
|
+
`<div class="history-panes">` +
|
|
60
|
+
`<div class="history-list" aria-label="versions">` +
|
|
61
|
+
rows.map((r) =>
|
|
62
|
+
`<button class="history-item" data-id="${esc(r.id)}"${r.when ? ` title="${esc(new Date(r.when).toLocaleString())}"` : ''}>` +
|
|
63
|
+
`<span class="hi-label">${esc(r.label)}${r.sub ? `<small>${esc(r.sub)}</small>` : ''}</span>` +
|
|
64
|
+
(r.size != null ? `<span class="hi-size">${fmtSize(r.size)}</span>` : '') +
|
|
65
|
+
`</button>`).join('') +
|
|
66
|
+
(data.versions.length ? '' : `<p class="history-empty">no earlier versions yet - they appear here as the file changes</p>`) +
|
|
67
|
+
`</div>` +
|
|
68
|
+
`<div class="history-pane">` +
|
|
69
|
+
`<div class="history-pane-bar"></div>` +
|
|
70
|
+
`<div class="history-preview markdown-body"></div>` +
|
|
71
|
+
`</div>` +
|
|
72
|
+
`</div>` +
|
|
73
|
+
`</div>`;
|
|
74
|
+
for (const b of contentEl.querySelectorAll('.history-item')) b.addEventListener('click', () => select(b.dataset.id));
|
|
75
|
+
await select(open.selected);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function select(id) {
|
|
79
|
+
if (!open) return;
|
|
80
|
+
const p = open.path;
|
|
81
|
+
open.selected = id;
|
|
82
|
+
for (const b of contentEl.querySelectorAll('.history-item')) {
|
|
83
|
+
if (b.dataset.id === id) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
|
|
84
|
+
}
|
|
85
|
+
const bar = contentEl.querySelector('.history-pane-bar');
|
|
86
|
+
const preview = contentEl.querySelector('.history-preview');
|
|
87
|
+
|
|
88
|
+
let html, version = null;
|
|
89
|
+
try {
|
|
90
|
+
if (id === 'current') {
|
|
91
|
+
({ html } = await (await fetch(`/api/file?p=${encodeURIComponent(p)}`)).json());
|
|
92
|
+
} else {
|
|
93
|
+
version = await (await fetch(`/api/versions/${encodeURIComponent(id)}`)).json();
|
|
94
|
+
({ html } = await (await fetch('/api/preview', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ path: p, content: version.content })
|
|
98
|
+
})).json());
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
toast('couldn’t load that version');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!isHistory(p) || open.selected !== id) return; // moved on while loading
|
|
105
|
+
|
|
106
|
+
bar.replaceChildren();
|
|
107
|
+
const label = document.createElement('span');
|
|
108
|
+
label.textContent = version ? `version from ${new Date(version.mtime ?? version.replacedAt).toLocaleString()}` : 'the file as it is now';
|
|
109
|
+
bar.appendChild(label);
|
|
110
|
+
if (version && state.writable) {
|
|
111
|
+
const btn = document.createElement('button');
|
|
112
|
+
btn.className = 'editor-btn primary';
|
|
113
|
+
btn.textContent = 'restore this version';
|
|
114
|
+
btn.addEventListener('click', () => restore(p, version));
|
|
115
|
+
bar.appendChild(btn);
|
|
116
|
+
}
|
|
117
|
+
preview.innerHTML = html;
|
|
118
|
+
await runMermaid();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function restore(p, version) {
|
|
122
|
+
if (!confirm('restore this version? the current text is kept in history, so this can be undone.')) return;
|
|
123
|
+
try {
|
|
124
|
+
// the mtime from when history loaded: if the file changed since, the save is
|
|
125
|
+
// refused (409) instead of overwriting a change the user never saw
|
|
126
|
+
const res = await fetch('/api/source', {
|
|
127
|
+
method: 'PUT',
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
body: JSON.stringify({ path: p, content: version.content, mtime: open?.mtime })
|
|
130
|
+
});
|
|
131
|
+
if (res.status === 409) {
|
|
132
|
+
toast('the file changed since you opened history - check the new list and try again');
|
|
133
|
+
refreshHistory();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.status);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
toast(`restore failed - ${err.message}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
toast('restored - the replaced text is in history');
|
|
142
|
+
openFile(p);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 14:02:31 today, else a short date + time
|
|
146
|
+
function clock(ts) {
|
|
147
|
+
const d = new Date(ts);
|
|
148
|
+
const today = d.toDateString() === new Date().toDateString();
|
|
149
|
+
return today
|
|
150
|
+
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
151
|
+
: d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ago(ts) {
|
|
155
|
+
const s = Math.round((Date.now() - ts) / 1000);
|
|
156
|
+
if (s < 60) return 'just now';
|
|
157
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
158
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
|
159
|
+
return new Date(ts).toLocaleDateString();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const fmtSize = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
package/public/index.html
CHANGED
|
@@ -3,9 +3,19 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<!-- we ship our own dark
|
|
7
|
-
<meta name="color-scheme" content="dark" />
|
|
6
|
+
<!-- we ship our own light + dark themes: tell the UA + Dark Reader to leave colors alone -->
|
|
7
|
+
<meta name="color-scheme" content="dark light" />
|
|
8
8
|
<meta name="darkreader-lock" />
|
|
9
|
+
<!-- resolve the saved theme before first paint (theme.js owns it after load;
|
|
10
|
+
the 'pv-theme' key and fallback must match theme.js) -->
|
|
11
|
+
<script>
|
|
12
|
+
(function () {
|
|
13
|
+
var p = 'system';
|
|
14
|
+
try { p = localStorage.getItem('pv-theme') || 'system'; } catch (e) {}
|
|
15
|
+
var light = p === 'light' || (p !== 'dark' && matchMedia('(prefers-color-scheme: light)').matches);
|
|
16
|
+
document.documentElement.dataset.theme = light ? 'light' : 'dark';
|
|
17
|
+
})();
|
|
18
|
+
</script>
|
|
9
19
|
<title>projview</title>
|
|
10
20
|
<link rel="stylesheet" href="/vendor/hljs.css" />
|
|
11
21
|
<link rel="stylesheet" href="/style.css" />
|
|
@@ -16,21 +26,22 @@
|
|
|
16
26
|
<header id="brand">
|
|
17
27
|
<span class="logo" aria-hidden="true">
|
|
18
28
|
<svg viewBox="0 0 24 24" width="26" height="26" fill="none">
|
|
19
|
-
<rect x="2" y="4.5" width="20" height="15" rx="4" stroke="
|
|
20
|
-
<line x1="8.5" y1="4.5" x2="8.5" y2="19.5" stroke="
|
|
21
|
-
<circle cx="13.4" cy="9.4" r="1.25" fill="
|
|
22
|
-
<circle cx="18" cy="9.4" r="1.25" fill="
|
|
23
|
-
<circle cx="15.7" cy="14.6" r="1.25" fill="
|
|
24
|
-
<path d="M13.4 9.4 15.7 14.6 18 9.4" stroke="
|
|
29
|
+
<rect x="2" y="4.5" width="20" height="15" rx="4" stroke="currentColor" stroke-width="1.8"/>
|
|
30
|
+
<line x1="8.5" y1="4.5" x2="8.5" y2="19.5" stroke="currentColor" stroke-width="1.8"/>
|
|
31
|
+
<circle cx="13.4" cy="9.4" r="1.25" fill="currentColor"/>
|
|
32
|
+
<circle cx="18" cy="9.4" r="1.25" fill="currentColor"/>
|
|
33
|
+
<circle cx="15.7" cy="14.6" r="1.25" fill="currentColor"/>
|
|
34
|
+
<path d="M13.4 9.4 15.7 14.6 18 9.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
25
35
|
</svg>
|
|
26
36
|
</span>
|
|
27
37
|
<div class="brand-text">
|
|
28
38
|
<strong class="wordmark"><span class="wm-proj">proj</span><span class="wm-view">view</span></strong>
|
|
29
39
|
<small id="version" class="version"></small>
|
|
30
40
|
</div>
|
|
41
|
+
<button id="theme-toggle" class="theme-toggle" title="theme"></button>
|
|
31
42
|
</header>
|
|
32
43
|
<button id="open-palette" class="open-palette" title="search (⌘K)">
|
|
33
|
-
<span class="op-ico"
|
|
44
|
+
<span class="op-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg></span><span class="op-text">search…</span><kbd>⌘K</kbd>
|
|
34
45
|
</button>
|
|
35
46
|
<div id="tree" class="tree"></div>
|
|
36
47
|
<footer id="side-foot"><span id="file-count">0</span> files · ctrl-c to stop</footer>
|
|
@@ -40,7 +51,6 @@
|
|
|
40
51
|
<nav id="breadcrumb" hidden></nav>
|
|
41
52
|
<div id="content" class="markdown-body">
|
|
42
53
|
<div class="empty-state">
|
|
43
|
-
<div class="empty-emoji">👈</div>
|
|
44
54
|
<p>pick a file from the tree to preview it</p>
|
|
45
55
|
</div>
|
|
46
56
|
</div>
|
package/public/live.js
CHANGED
|
@@ -5,6 +5,8 @@ import { state } from './store.js';
|
|
|
5
5
|
import { loadTree } from './tree.js';
|
|
6
6
|
import { openFile } from './viewer.js';
|
|
7
7
|
import { refreshComments } from './comments.js';
|
|
8
|
+
import { isEditing, onDiskChange } from './editor.js';
|
|
9
|
+
import { isHistory, refreshHistory } from './history.js';
|
|
8
10
|
|
|
9
11
|
const versionEl = $('version');
|
|
10
12
|
|
|
@@ -12,7 +14,11 @@ export function connectEvents() {
|
|
|
12
14
|
const es = new EventSource('/api/events');
|
|
13
15
|
es.addEventListener('change', (e) => {
|
|
14
16
|
const { path } = JSON.parse(e.data);
|
|
15
|
-
if (path
|
|
17
|
+
if (path !== state.currentPath) return;
|
|
18
|
+
pulse();
|
|
19
|
+
if (isEditing(path)) onDiskChange(); // never re-render over an open editor
|
|
20
|
+
else if (isHistory(path)) setTimeout(refreshHistory, 300); // the new version lands just after the change event
|
|
21
|
+
else openFile(path);
|
|
16
22
|
});
|
|
17
23
|
es.addEventListener('tree', async () => { pulse(); await loadTree(); });
|
|
18
24
|
es.addEventListener('comments', (e) => {
|