seemore 1.12.21 → 1.12.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seemore",
3
- "version": "1.12.21",
3
+ "version": "1.12.22",
4
4
  "description": "Zero config documentation tool - Let AI write the Markdown. Let seemore show it better.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,7 +17,10 @@ import type { RouteEntry } from '../../shared/types.js';
17
17
  const ENDPOINT = '/__seemore/source';
18
18
 
19
19
  interface Editing {
20
- element: HTMLElement;
20
+ /** The block being edited; absent when appending, which has no block to hide. */
21
+ element?: HTMLElement;
22
+ /** Appending to the end of the file rather than replacing `start:end`. */
23
+ append: boolean;
21
24
  start: number;
22
25
  end: number;
23
26
  /**
@@ -51,7 +54,7 @@ export function InlineEditor({ entry }: { entry: RouteEntry }) {
51
54
 
52
55
  const close = useCallback(() => {
53
56
  setEditing((current) => {
54
- current?.element.classList.remove('seemore-editing');
57
+ current?.element?.classList.remove('seemore-editing');
55
58
  return undefined;
56
59
  });
57
60
  setError(undefined);
@@ -69,11 +72,15 @@ export function InlineEditor({ entry }: { entry: RouteEntry }) {
69
72
  const target = event.target as HTMLElement | null;
70
73
  if (target === null) return;
71
74
  // 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;
75
+ // of the paragraph around it still opens the editor. A double-click inside the editor
76
+ // selects a word, and must not read as one on the empty article behind it.
77
+ if (target.closest('a, .seemore-editor, .seemore-editor-error') !== null) return;
74
78
 
75
79
  const block = target.closest<HTMLElement>('[data-seemore-pos]');
76
- if (block === null || !host.contains(block)) return;
80
+ // Not on a block: below the last of them (or on an empty page) appends to the file.
81
+ // A gap between two blocks is left alone — the text would not land where it was asked.
82
+ const append = block === null && host.contains(target) && event.clientY > contentBottom(host);
83
+ if (!append && (block === null || !host.contains(block))) return;
77
84
 
78
85
  // An editor is already open. Moving to another block is fine while nothing has been
79
86
  // typed, but with unsaved text it would discard the edit without asking — so leave it
@@ -84,11 +91,37 @@ export function InlineEditor({ entry }: { entry: RouteEntry }) {
84
91
  if (field.value.replace(/\r\n/g, '\n') !== original.replace(/\r\n/g, '\n')) return;
85
92
  }
86
93
 
87
- const [start, end] = (block.dataset['seemorePos'] ?? '').split(':').map(Number);
94
+ event.preventDefault();
95
+ if (append) {
96
+ openAppend();
97
+ return;
98
+ }
99
+
100
+ const [start, end] = ((block as HTMLElement).dataset['seemorePos'] ?? '').split(':').map(Number);
88
101
  if (!Number.isInteger(start) || !Number.isInteger(end)) return;
89
102
 
90
- event.preventDefault();
91
- void open(block, start as number, end as number);
103
+ void open(block as HTMLElement, start as number, end as number);
104
+ };
105
+
106
+ // Nothing to fetch: the editor starts empty, and the server finds the end of the file
107
+ // when it writes.
108
+ const openAppend = () => {
109
+ const host = article();
110
+ if (host === undefined) return;
111
+ const bounds = host.getBoundingClientRect();
112
+ const bottom = contentBottom(host);
113
+
114
+ setError(undefined);
115
+ setRefused(false);
116
+ setEditing({
117
+ append: true,
118
+ start: 0,
119
+ end: 0,
120
+ original: '',
121
+ top: Number.isFinite(bottom) ? bottom - bounds.top + APPEND_GAP : 0,
122
+ left: 0,
123
+ width: bounds.width,
124
+ });
92
125
  };
93
126
 
94
127
  const open = async (block: HTMLElement, start: number, end: number) => {
@@ -118,6 +151,7 @@ export function InlineEditor({ entry }: { entry: RouteEntry }) {
118
151
  setRefused(false);
119
152
  setEditing({
120
153
  element: block,
154
+ append: false,
121
155
  start,
122
156
  end,
123
157
  original,
@@ -163,7 +197,7 @@ export function InlineEditor({ entry }: { entry: RouteEntry }) {
163
197
 
164
198
  // The block is hidden rather than removed while it is edited, so the page does not jump.
165
199
  // If this component goes away mid-edit — a navigation, a hot reload — put it back.
166
- useEffect(() => () => editing?.element.classList.remove('seemore-editing'), [editing]);
200
+ useEffect(() => () => editing?.element?.classList.remove('seemore-editing'), [editing]);
167
201
 
168
202
  const save = useCallback(async () => {
169
203
  const current = editing;
@@ -177,19 +211,28 @@ export function InlineEditor({ entry }: { entry: RouteEntry }) {
177
211
  close();
178
212
  return;
179
213
  }
214
+ // The server would write nothing for blank text either; skip the round trip.
215
+ if (current.append && field.value.trim() === '') {
216
+ close();
217
+ return;
218
+ }
180
219
 
181
220
  setSaving(true);
182
221
  try {
183
222
  const response = await fetch(ENDPOINT, {
184
223
  method: 'PUT',
185
224
  headers: { 'Content-Type': 'application/json' },
186
- body: JSON.stringify({
187
- file: entry.absPath,
188
- start: current.start,
189
- end: current.end,
190
- expected: current.original,
191
- text: field.value,
192
- }),
225
+ body: JSON.stringify(
226
+ current.append
227
+ ? { file: entry.absPath, append: true, text: field.value }
228
+ : {
229
+ file: entry.absPath,
230
+ start: current.start,
231
+ end: current.end,
232
+ expected: current.original,
233
+ text: field.value,
234
+ },
235
+ ),
193
236
  });
194
237
  if (!response.ok) {
195
238
  const body = (await response.json().catch(() => ({}))) as { error?: string };
@@ -310,3 +353,21 @@ function resize(node: HTMLTextAreaElement): void {
310
353
 
311
354
  /** Floor for the cap, so a very short window still shows a usable amount of text. */
312
355
  const MIN_EDITOR_HEIGHT = 160;
356
+
357
+ /** Space between the last block and an append editor opened below it. */
358
+ const APPEND_GAP = 8;
359
+
360
+ /**
361
+ * The bottom edge, in viewport coordinates, of the article's last rendered child — or
362
+ * `-Infinity` for an article with nothing in it, so any point counts as below the content.
363
+ * The editor layer is skipped, and so is anything not laid out (`display: none` reports a
364
+ * zero rect, which would put the "bottom" at the top of the viewport).
365
+ */
366
+ function contentBottom(host: HTMLElement): number {
367
+ for (let node = host.lastElementChild; node !== null; node = node.previousElementSibling) {
368
+ if (node.classList.contains('seemore-editor-layer')) continue;
369
+ if (node.getClientRects().length === 0) continue;
370
+ return node.getBoundingClientRect().bottom;
371
+ }
372
+ return Number.NEGATIVE_INFINITY;
373
+ }
@@ -354,9 +354,11 @@
354
354
  }
355
355
 
356
356
  /* Inline editing (dev only, `content.edit`). The article is the positioning context for
357
- the editor layer, which sits over the block being edited. */
357
+ the editor layer, which sits over the block being edited. The minimum height and the
358
+ bottom padding leave room below the content to double-click and append, including on
359
+ an empty page, which would otherwise collapse to nothing. */
358
360
  .seemore-editable {
359
- @apply relative;
361
+ @apply relative min-h-48 pb-12;
360
362
  }
361
363
 
362
364
  .seemore-editable [data-seemore-pos]:hover {