seemore 1.1.5 → 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/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  * Types shared by the node pipeline and the browser app. This file ships as source, next to
5
5
  * `src/app`, so both halves agree on the shape of the virtual modules.
6
6
  */
7
- declare const FEATURES: readonly ["navigation.instant.prefetch", "navigation.instant.preview", "navigation.footer", "navigation.top", "navigation.path", "navigation.sections", "navigation.prune", "toc.follow", "toc.integrate", "content.code.copy", "content.action.edit", "content.image.zoom", "search.suggest", "search.highlight", "social.cards"];
7
+ declare const FEATURES: readonly ["navigation.instant.prefetch", "navigation.instant.preview", "navigation.footer", "navigation.top", "navigation.path", "navigation.sections", "navigation.prune", "toc.follow", "toc.integrate", "content.code.copy", "content.action.edit", "content.edit", "content.image.zoom", "search.suggest", "search.highlight", "social.cards"];
8
8
  type Feature = (typeof FEATURES)[number];
9
9
  /** What a user may write in `features`: a flag, or `!flag` to switch a default-on flag off. */
10
10
  type FeatureFlag = Feature | `!${Feature}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seemore",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "description": "Let AI write the Markdown. Let seemore show it better — zero config documentation framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,285 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { RouteEntry } from '../../shared/types.js';
3
+
4
+ /**
5
+ * Editing a block of a page from the browser.
6
+ *
7
+ * What is edited is the block's **Markdown source**, not its rendered HTML. A rehype plugin
8
+ * stamped every editable block with its `start:end` offsets into the file, so a save replaces
9
+ * exactly those characters and every other byte of the file is left alone — no HTML-to-
10
+ * Markdown round trip to mangle a table, a fence or a link reference, and no whole-file
11
+ * reflow in the diff.
12
+ *
13
+ * Nothing here re-renders the page. The dev server writes the file, the watcher notices, and
14
+ * the page hot-reloads through the same path an edit made in an editor takes.
15
+ */
16
+
17
+ const ENDPOINT = '/__seemore/source';
18
+
19
+ interface Editing {
20
+ element: HTMLElement;
21
+ start: number;
22
+ end: number;
23
+ /**
24
+ * The slice exactly as the server sent it, kept out of the DOM.
25
+ *
26
+ * A textarea reports its value with `\n` whatever was put into it, so a CRLF file's block
27
+ * cannot be compared against the copy that came back through the editor. This is the copy
28
+ * the server checks the file against.
29
+ */
30
+ original: string;
31
+ top: number;
32
+ left: number;
33
+ width: number;
34
+ }
35
+
36
+ export function InlineEditor({ entry }: { entry: RouteEntry }) {
37
+ const layer = useRef<HTMLDivElement | null>(null);
38
+ const textarea = useRef<HTMLTextAreaElement | null>(null);
39
+ const [editing, setEditing] = useState<Editing | undefined>(undefined);
40
+ /** Read inside the dblclick listener, which is bound once and would capture stale state. */
41
+ const editingRef = useRef<Editing | undefined>(undefined);
42
+ const [error, setError] = useState<string | undefined>(undefined);
43
+ const [saving, setSaving] = useState(false);
44
+ /**
45
+ * Set when a save was refused, which in practice means the file moved under the offsets
46
+ * this editor is holding. It disarms the automatic save-on-blur: the text on screen is
47
+ * stale by definition, and leaving blur armed means a later click — or navigating away —
48
+ * silently commits it the moment the file happens to match `original` again.
49
+ */
50
+ const [refused, setRefused] = useState(false);
51
+
52
+ const close = useCallback(() => {
53
+ setEditing((current) => {
54
+ current?.element.classList.remove('seemore-editing');
55
+ return undefined;
56
+ });
57
+ setError(undefined);
58
+ setRefused(false);
59
+ }, []);
60
+
61
+ // The article is this layer's own parent, so there is no ref to thread down from the layout.
62
+ const article = () => layer.current?.parentElement ?? undefined;
63
+
64
+ useEffect(() => {
65
+ const host = article();
66
+ if (host === undefined) return;
67
+
68
+ const onDoubleClick = (event: MouseEvent) => {
69
+ const target = event.target as HTMLElement | null;
70
+ if (target === null) return;
71
+ // A link would have navigated on the first of the two clicks. Leave it alone; the rest
72
+ // of the paragraph around it still opens the editor.
73
+ if (target.closest('a') !== null) return;
74
+
75
+ const block = target.closest<HTMLElement>('[data-seemore-pos]');
76
+ if (block === null || !host.contains(block)) return;
77
+
78
+ // An editor is already open. Moving to another block is fine while nothing has been
79
+ // typed, but with unsaved text it would discard the edit without asking — so leave it
80
+ // where it is and let Save or Cancel decide.
81
+ const field = textarea.current;
82
+ if (field !== null && editingRef.current !== undefined) {
83
+ const original = editingRef.current.original;
84
+ if (field.value.replace(/\r\n/g, '\n') !== original.replace(/\r\n/g, '\n')) return;
85
+ }
86
+
87
+ const [start, end] = (block.dataset['seemorePos'] ?? '').split(':').map(Number);
88
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return;
89
+
90
+ event.preventDefault();
91
+ void open(block, start as number, end as number);
92
+ };
93
+
94
+ const open = async (block: HTMLElement, start: number, end: number) => {
95
+ const query = new URLSearchParams({ file: entry.absPath, start: String(start), end: String(end) });
96
+ let original: string;
97
+ try {
98
+ const response = await fetch(`${ENDPOINT}?${query.toString()}`);
99
+ const body = (await response.json()) as { text?: string; error?: string };
100
+ if (!response.ok || typeof body.text !== 'string') {
101
+ setError(body.error ?? 'Could not read this block from the file.');
102
+ return;
103
+ }
104
+ original = body.text;
105
+ } catch {
106
+ setError('Could not reach the dev server.');
107
+ return;
108
+ }
109
+
110
+ // Measured against the article, which is the positioning context of the layer.
111
+ const host = article();
112
+ if (host === undefined) return;
113
+ const bounds = host.getBoundingClientRect();
114
+ const rect = block.getBoundingClientRect();
115
+
116
+ block.classList.add('seemore-editing');
117
+ setError(undefined);
118
+ setRefused(false);
119
+ setEditing({
120
+ element: block,
121
+ start,
122
+ end,
123
+ original,
124
+ top: rect.top - bounds.top,
125
+ left: rect.left - bounds.left,
126
+ width: rect.width,
127
+ });
128
+ };
129
+
130
+ host.addEventListener('dblclick', onDoubleClick);
131
+ return () => host.removeEventListener('dblclick', onDoubleClick);
132
+ }, [entry.absPath]);
133
+
134
+ useEffect(() => {
135
+ editingRef.current = editing;
136
+ }, [editing]);
137
+
138
+ // The block is hidden rather than removed while it is edited, so the page does not jump.
139
+ // If this component goes away mid-edit — a navigation, a hot reload — put it back.
140
+ useEffect(() => () => editing?.element.classList.remove('seemore-editing'), [editing]);
141
+
142
+ const save = useCallback(async () => {
143
+ const current = editing;
144
+ const field = textarea.current;
145
+ if (current === undefined || field === null || saving || refused) return;
146
+
147
+ // Both sides normalised: a textarea reports `\n` even for the `\r\n` a CRLF file gave it,
148
+ // so comparing raw would make every no-op edit on Windows look like a change and rewrite
149
+ // the file — same bytes, but a fresh mtime and a pointless reload.
150
+ if (field.value.replace(/\r\n/g, '\n') === current.original.replace(/\r\n/g, '\n')) {
151
+ close();
152
+ return;
153
+ }
154
+
155
+ setSaving(true);
156
+ try {
157
+ const response = await fetch(ENDPOINT, {
158
+ method: 'PUT',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({
161
+ file: entry.absPath,
162
+ start: current.start,
163
+ end: current.end,
164
+ expected: current.original,
165
+ text: field.value,
166
+ }),
167
+ });
168
+ if (!response.ok) {
169
+ const body = (await response.json().catch(() => ({}))) as { error?: string };
170
+ setError(body.error ?? 'The edit could not be saved.');
171
+ setRefused(true);
172
+ return;
173
+ }
174
+ // The watcher takes it from here: the file changed, so the page reloads its own module.
175
+ close();
176
+ } catch {
177
+ setError('Could not reach the dev server.');
178
+ setRefused(true);
179
+ } finally {
180
+ setSaving(false);
181
+ }
182
+ }, [editing, entry.absPath, close, saving, refused]);
183
+
184
+ return (
185
+ <div className="seemore-editor-layer" ref={layer}>
186
+ {editing === undefined ? undefined : (
187
+ <div
188
+ className="seemore-editor"
189
+ // `nearest` scrolls only when the box does not already fit, so opening an editor on
190
+ // a block that is comfortably in view does not move the page under the reader.
191
+ ref={(node) => node?.scrollIntoView({ block: 'nearest' })}
192
+ style={{ top: editing.top, left: editing.left, width: editing.width }}
193
+ >
194
+ <textarea
195
+ ref={(node) => {
196
+ textarea.current = node;
197
+ if (node === null) return;
198
+ node.focus();
199
+ node.setSelectionRange(node.value.length, node.value.length);
200
+ resize(node);
201
+ requestAnimationFrame(() => resize(node));
202
+ }}
203
+ className="seemore-editor-input"
204
+ // One row, so `height: auto` in `resize` collapses to the content rather than to
205
+ // the two-row default a textarea otherwise floors itself at.
206
+ rows={1}
207
+ defaultValue={editing.original}
208
+ spellCheck={false}
209
+ disabled={saving}
210
+ onInput={(event) => resize(event.currentTarget)}
211
+ onKeyDown={(event) => {
212
+ if (event.key === 'Escape') {
213
+ event.preventDefault();
214
+ close();
215
+ return;
216
+ }
217
+ // `metaKey` on macOS, `ctrlKey` everywhere else — accept either rather than
218
+ // sniffing the platform.
219
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
220
+ event.preventDefault();
221
+ void save();
222
+ }
223
+ }}
224
+ />
225
+ <div className="seemore-editor-actions">
226
+ <span className="seemore-editor-status">
227
+ {saving ? 'Saving…' : refused ? 'Not saved — copy your text, then reload the page' : undefined}
228
+ </span>
229
+ {/*
230
+ `onMouseDown` is prevented on both buttons so focus never leaves the textarea.
231
+ Taking focus would fire its blur handler, which saves — so a click on Cancel
232
+ would commit the very edit it is meant to discard.
233
+ */}
234
+ <button
235
+ type="button"
236
+ className="seemore-editor-button"
237
+ onMouseDown={(event) => event.preventDefault()}
238
+ onClick={close}
239
+ >
240
+ Cancel
241
+ </button>
242
+ <button
243
+ type="button"
244
+ className="seemore-editor-button seemore-editor-button-primary"
245
+ onMouseDown={(event) => event.preventDefault()}
246
+ onClick={() => void save()}
247
+ disabled={saving || refused}
248
+ >
249
+ Save
250
+ </button>
251
+ </div>
252
+ </div>
253
+ )}
254
+
255
+ {error === undefined ? undefined : (
256
+ <p
257
+ className="seemore-editor-error"
258
+ role="alert"
259
+ title="Dismiss"
260
+ onClick={() => setError(undefined)}
261
+ >
262
+ {error}
263
+ </p>
264
+ )}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Grow with the content: the Markdown behind a block is rarely the height of the block.
271
+ *
272
+ * Capped at part of the window, because a block whose source runs longer than the viewport
273
+ * would otherwise push Save and Cancel off the bottom of the screen. Past the cap the
274
+ * textarea scrolls inside itself and the action bar stays put.
275
+ */
276
+ function resize(node: HTMLTextAreaElement): void {
277
+ const cap = Math.max(MIN_EDITOR_HEIGHT, Math.round(window.innerHeight * 0.55));
278
+ node.style.height = 'auto';
279
+ const wanted = node.scrollHeight;
280
+ node.style.height = `${Math.min(wanted, cap)}px`;
281
+ node.style.overflowY = wanted > cap ? 'auto' : 'hidden';
282
+ }
283
+
284
+ /** Floor for the cap, so a very short window still shows a usable amount of text. */
285
+ const MIN_EDITOR_HEIGHT = 160;
@@ -4,8 +4,11 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <!-- Default icon so a zero-config folder does not 404 on /favicon.ico. A favicon from
7
- the config is injected at the marker below, after this link, and the last one wins. -->
8
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%236D5EF5'/%3E%3Cpath d='M10 11h12M10 16h12M10 21h7' stroke='%23f5f5f5' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E" />
7
+ the config is injected at the marker below, after this link, and the last one wins.
8
+ This is seemore's own mark, inlined from assets/icon.svg at the repo root the same
9
+ artwork the editor extension ships as its icon. Inlined rather than linked so the
10
+ built site needs no extra request and no file in the user's folder. -->
11
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='28' fill='%236D5EF5'/%3E%3Crect x='30' y='26' width='52' height='68' rx='8' fill='%23ffffff' opacity='0.35' transform='rotate(-8 56 60)'/%3E%3Crect x='38' y='22' width='52' height='68' rx='8' fill='%23ffffff' opacity='0.6' transform='rotate(-3 64 56)'/%3E%3Crect x='46' y='24' width='52' height='72' rx='8' fill='%23ffffff'/%3E%3Cg stroke-linecap='round'%3E%3Cline x1='56' y1='42' x2='82' y2='42' stroke='%236D5EF5' stroke-width='6'/%3E%3Cline x1='56' y1='56' x2='88' y2='56' stroke='%23C6C8E6' stroke-width='4'/%3E%3Cline x1='56' y1='66' x2='80' y2='66' stroke='%23C6C8E6' stroke-width='4'/%3E%3Cline x1='56' y1='76' x2='88' y2='76' stroke='%236D5EF5' stroke-width='4'/%3E%3Cline x1='56' y1='86' x2='74' y2='86' stroke='%23C6C8E6' stroke-width='4'/%3E%3C/g%3E%3C/svg%3E" />
9
12
  <!--seemore-head-->
10
13
  </head>
11
14
  <body>
@@ -13,6 +13,7 @@ import { usePrefetch } from '../features/prefetch.js';
13
13
  import { PagePreview } from '../features/preview.js';
14
14
  import { useSearchHighlight } from '../features/highlight.js';
15
15
  import { useHashScroll } from '../features/anchors.js';
16
+ import { InlineEditor } from '../features/edit.js';
16
17
  import { SeemoreProvider } from './Provider.js';
17
18
  import { Header } from './Header.js';
18
19
  import { Sidebar } from './Sidebar.js';
@@ -52,6 +53,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
52
53
  useHashScroll();
53
54
 
54
55
  const integrated = feature('toc.integrate');
56
+ // Dev only, and off unless asked for: the editor writes to the user's files, and a static
57
+ // build has no server to write through. `import.meta.env.DEV` also keeps it out of the bundle.
58
+ const editable = import.meta.env.DEV && feature('content.edit');
55
59
 
56
60
  return (
57
61
  <TocProvider toc={page.toc ?? []}>
@@ -62,8 +66,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
62
66
 
63
67
  <main className="seemore-main">
64
68
  {feature('navigation.path') ? <Breadcrumb /> : undefined}
65
- <article className="seemore-article prose">
69
+ <article className={editable ? 'seemore-article prose seemore-editable' : 'seemore-article prose'}>
66
70
  <Content components={mdxComponents} />
71
+ {editable ? <InlineEditor key={entry.url} entry={entry} /> : undefined}
67
72
  </article>
68
73
 
69
74
  {config.editLink !== undefined && feature('content.action.edit') ? (
@@ -292,6 +292,70 @@
292
292
  @apply my-6 rounded-lg border border-fd-border p-4 text-sm text-fd-muted-foreground;
293
293
  }
294
294
 
295
+ /* Inline editing (dev only, `content.edit`). The article is the positioning context for
296
+ the editor layer, which sits over the block being edited. */
297
+ .seemore-editable {
298
+ @apply relative;
299
+ }
300
+
301
+ .seemore-editable [data-seemore-pos]:hover {
302
+ @apply cursor-text rounded-sm bg-fd-muted/40;
303
+ box-shadow: 0 0 0 4px var(--color-fd-muted);
304
+ }
305
+
306
+ .seemore-editable .seemore-editing {
307
+ @apply invisible;
308
+ }
309
+
310
+ .seemore-editor-layer {
311
+ @apply pointer-events-none absolute inset-0;
312
+ }
313
+
314
+ /* `scroll-mt-20` clears the sticky header: without it, scrolling a tall editor into view
315
+ tucks its first lines underneath the header bar. */
316
+ .seemore-editor {
317
+ @apply pointer-events-auto absolute z-20 scroll-mt-20 rounded-lg border border-fd-primary/40 bg-fd-background shadow-lg;
318
+ }
319
+
320
+ .seemore-editor-input {
321
+ @apply block w-full resize-none bg-transparent p-3 font-mono text-sm leading-relaxed text-fd-foreground outline-none;
322
+ }
323
+
324
+ .seemore-editor-actions {
325
+ @apply flex items-center justify-end gap-2 border-t border-fd-border px-2.5 py-2;
326
+ }
327
+
328
+ .seemore-editor-status {
329
+ @apply me-auto ps-0.5 text-xs text-fd-muted-foreground;
330
+ }
331
+
332
+ .seemore-editor-button {
333
+ @apply rounded-md border border-fd-border px-3 py-1 text-xs font-medium text-fd-foreground transition-colors;
334
+ }
335
+
336
+ .seemore-editor-button:hover:not(:disabled) {
337
+ @apply bg-fd-accent text-fd-accent-foreground;
338
+ }
339
+
340
+ .seemore-editor-button:disabled {
341
+ @apply cursor-not-allowed opacity-50;
342
+ }
343
+
344
+ .seemore-editor-button-primary {
345
+ @apply border-transparent bg-fd-primary text-fd-primary-foreground;
346
+ }
347
+
348
+ .seemore-editor-button-primary:hover:not(:disabled) {
349
+ @apply bg-fd-primary/90 text-fd-primary-foreground;
350
+ }
351
+
352
+ /* Anchored to the viewport, not to the layer: the layer is `absolute inset-0` over the
353
+ article, so a `sticky` child of it renders at the article's top and lands on the
354
+ heading. A conflict is rare and worth reading, so it sits out of the content's way. */
355
+ .seemore-editor-error {
356
+ @apply pointer-events-auto fixed bottom-6 left-1/2 z-50 max-w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 cursor-pointer rounded-lg border border-fd-border bg-fd-background px-4 py-2.5 text-sm text-fd-foreground shadow-xl;
357
+ }
358
+
295
359
  .seemore-pdf {
296
360
  @apply my-6 block;
297
361
  }
@@ -17,6 +17,7 @@ export const FEATURES = [
17
17
  'toc.integrate',
18
18
  'content.code.copy',
19
19
  'content.action.edit',
20
+ 'content.edit',
20
21
  'content.image.zoom',
21
22
  'search.suggest',
22
23
  'search.highlight',