@z29k/notabene 0.1.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/LICENSE +21 -0
- package/README.md +200 -0
- package/astro.config.mjs +37 -0
- package/bin/notabene.mjs +116 -0
- package/package.json +33 -0
- package/src/components/Comments.astro +458 -0
- package/src/components/NavTree.astro +44 -0
- package/src/components/Sidebar.astro +57 -0
- package/src/components/SpaceIndex.astro +45 -0
- package/src/components/Toc.astro +29 -0
- package/src/config.mjs +127 -0
- package/src/content.config.ts +16 -0
- package/src/layouts/DocLayout.astro +153 -0
- package/src/lib/comment-types.ts +53 -0
- package/src/lib/comments.ts +141 -0
- package/src/lib/icons.ts +17 -0
- package/src/lib/journal.ts +40 -0
- package/src/lib/nav.ts +132 -0
- package/src/pages/[space]/[...slug].astro +71 -0
- package/src/pages/api/comments.ts +68 -0
- package/src/pages/comments.astro +146 -0
- package/src/pages/index.astro +23 -0
- package/src/pages/journal.astro +50 -0
- package/src/pages/search-index.json.ts +49 -0
- package/src/remark/rewrite-links.mjs +63 -0
- package/src/styles/global.css +1048 -0
- package/templates/notabene.config.mjs +36 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Commentaires type Google Docs.
|
|
3
|
+
// - Ancrés (sélection) → marge droite (#cmt-rail, fourni par DocLayout) + surlignage.
|
|
4
|
+
// - Globaux (page) → section discussion EN BAS de la page (rendue ici).
|
|
5
|
+
// Bouton flottant + popover en position:fixed (coords viewport). Persistance /api/comments.
|
|
6
|
+
interface Props {
|
|
7
|
+
page?: string;
|
|
8
|
+
space?: string;
|
|
9
|
+
}
|
|
10
|
+
const { page, space } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
page && (
|
|
15
|
+
<section id="cmt-page" class="cmt-page" data-page={page} data-space={space}>
|
|
16
|
+
<header class="cmt-page-head">
|
|
17
|
+
<h2>Discussion</h2>
|
|
18
|
+
<span id="cmt-count" class="cmt-count" />
|
|
19
|
+
</header>
|
|
20
|
+
<form id="cmt-composer" class="cmt-composer">
|
|
21
|
+
<textarea id="cmt-input" rows="3" placeholder="Commentaire sur cette page…" />
|
|
22
|
+
<button type="submit">Commenter la page</button>
|
|
23
|
+
</form>
|
|
24
|
+
<div id="cmt-page-list" class="cmt-list" />
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<button id="cmt-float" class="cmt-float" hidden type="button">
|
|
28
|
+
💬 Commenter
|
|
29
|
+
</button>
|
|
30
|
+
<div id="cmt-pop" class="cmt-pop" hidden>
|
|
31
|
+
<div id="cmt-pop-quote" class="cmt-pop-quote" />
|
|
32
|
+
<textarea id="cmt-pop-input" rows="3" placeholder="Commentaire sur la sélection…" />
|
|
33
|
+
<div class="cmt-pop-actions">
|
|
34
|
+
<button type="button" id="cmt-pop-cancel">Annuler</button>
|
|
35
|
+
<button type="button" id="cmt-pop-save">Commenter</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div id="cmt-view" class="cmt-view" hidden>
|
|
40
|
+
<div class="cmt-view-list" />
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
<script>
|
|
46
|
+
import { ICONS } from "../lib/icons";
|
|
47
|
+
|
|
48
|
+
const pageSection = document.getElementById("cmt-page");
|
|
49
|
+
const article = document.getElementById("doc-content");
|
|
50
|
+
if (pageSection && article) {
|
|
51
|
+
const NB_ROOTS: { key: string }[] = JSON.parse(
|
|
52
|
+
document.getElementById("notabene-roots")?.textContent || "[]",
|
|
53
|
+
);
|
|
54
|
+
const page = pageSection.dataset.page!;
|
|
55
|
+
// data-space est toujours posé par la route [space] ; fallback = 1er espace.
|
|
56
|
+
const space = pageSection.dataset.space || NB_ROOTS[0]?.key || "";
|
|
57
|
+
const railEl = document.getElementById("cmt-rail");
|
|
58
|
+
const pageListEl = document.getElementById("cmt-page-list")!;
|
|
59
|
+
const countEl = document.getElementById("cmt-count")!;
|
|
60
|
+
const inputEl = document.getElementById("cmt-input") as HTMLTextAreaElement;
|
|
61
|
+
const floatBtn = document.getElementById("cmt-float")!;
|
|
62
|
+
const pop = document.getElementById("cmt-pop")!;
|
|
63
|
+
const popQuote = document.getElementById("cmt-pop-quote")!;
|
|
64
|
+
const popInput = document.getElementById("cmt-pop-input") as HTMLTextAreaElement;
|
|
65
|
+
const viewEl = document.getElementById("cmt-view")!;
|
|
66
|
+
|
|
67
|
+
interface Anchor { quote: string; prefix: string; suffix: string; section: string | null }
|
|
68
|
+
interface Reply { author: string; body: string; ts: string }
|
|
69
|
+
interface Comment {
|
|
70
|
+
id: string; space: string; page: string; scope: string;
|
|
71
|
+
anchor: Anchor | null; thread: Reply[]; status: string;
|
|
72
|
+
resolution: { note: string; journalEntryId?: string } | null; createdAt: string; updatedAt: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let comments: Comment[] = [];
|
|
76
|
+
let railFilter = "open";
|
|
77
|
+
let pendingAnchor: Anchor | null = null;
|
|
78
|
+
let viewIds: string[] = [];
|
|
79
|
+
const ranges = new Map<string, Range>();
|
|
80
|
+
|
|
81
|
+
const esc = (s: string) =>
|
|
82
|
+
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] || c);
|
|
83
|
+
const api = (method: string, body?: unknown) =>
|
|
84
|
+
fetch("/api/comments" + (method === "GET" ? `?page=${encodeURIComponent(page)}` : ""), {
|
|
85
|
+
method,
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
88
|
+
}).then((r) => r.json());
|
|
89
|
+
|
|
90
|
+
// ── flat text mapping (offset ⇄ DOM) ──────────────────────────────────
|
|
91
|
+
function buildFlat() {
|
|
92
|
+
const walker = document.createTreeWalker(article!, NodeFilter.SHOW_TEXT);
|
|
93
|
+
let text = "";
|
|
94
|
+
const map: { node: Node; start: number; end: number }[] = [];
|
|
95
|
+
let n: Node | null;
|
|
96
|
+
while ((n = walker.nextNode())) {
|
|
97
|
+
const start = text.length;
|
|
98
|
+
text += n.nodeValue || "";
|
|
99
|
+
map.push({ node: n, start, end: text.length });
|
|
100
|
+
}
|
|
101
|
+
return { text, map };
|
|
102
|
+
}
|
|
103
|
+
function pointToOffset(map: { node: Node; start: number }[], node: Node, off: number) {
|
|
104
|
+
for (const m of map) if (m.node === node) return m.start + off;
|
|
105
|
+
return -1;
|
|
106
|
+
}
|
|
107
|
+
function offsetToPoint(map: { node: Node; start: number; end: number }[], offset: number) {
|
|
108
|
+
for (const m of map) if (offset <= m.end) return { node: m.node, offset: offset - m.start };
|
|
109
|
+
const last = map[map.length - 1];
|
|
110
|
+
return { node: last.node, offset: last.end - last.start };
|
|
111
|
+
}
|
|
112
|
+
function nearestSection(node: Node): string | null {
|
|
113
|
+
const heads = [...article!.querySelectorAll("h1,h2,h3,h4")];
|
|
114
|
+
let best: Element | null = null;
|
|
115
|
+
for (const h of heads)
|
|
116
|
+
if (h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) best = h;
|
|
117
|
+
return best ? best.textContent!.trim() : null;
|
|
118
|
+
}
|
|
119
|
+
function computeAnchor(range: Range): Anchor {
|
|
120
|
+
const { text, map } = buildFlat();
|
|
121
|
+
const start = pointToOffset(map, range.startContainer, range.startOffset);
|
|
122
|
+
const end = pointToOffset(map, range.endContainer, range.endOffset);
|
|
123
|
+
return {
|
|
124
|
+
quote: text.slice(start, end),
|
|
125
|
+
prefix: text.slice(Math.max(0, start - 40), start),
|
|
126
|
+
suffix: text.slice(end, end + 40),
|
|
127
|
+
section: nearestSection(range.startContainer),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function findRange(anchor: Anchor): Range | null {
|
|
131
|
+
const { text, map } = buildFlat();
|
|
132
|
+
if (!anchor.quote) return null;
|
|
133
|
+
let idx = text.indexOf(anchor.prefix + anchor.quote + anchor.suffix);
|
|
134
|
+
idx = idx >= 0 ? idx + anchor.prefix.length : text.indexOf(anchor.quote);
|
|
135
|
+
if (idx < 0) return null;
|
|
136
|
+
const s = offsetToPoint(map, idx);
|
|
137
|
+
const e = offsetToPoint(map, idx + anchor.quote.length);
|
|
138
|
+
const r = document.createRange();
|
|
139
|
+
try {
|
|
140
|
+
r.setStart(s.node, s.offset);
|
|
141
|
+
r.setEnd(e.node, e.offset);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return r;
|
|
146
|
+
}
|
|
147
|
+
function applyHighlights() {
|
|
148
|
+
ranges.clear();
|
|
149
|
+
const C = window.CSS as any;
|
|
150
|
+
if (!C || !C.highlights) return;
|
|
151
|
+
const hl = new (window as any).Highlight();
|
|
152
|
+
for (const c of comments) {
|
|
153
|
+
if (c.scope !== "selection" || !c.anchor || c.status === "resolved") continue;
|
|
154
|
+
const r = findRange(c.anchor);
|
|
155
|
+
if (r) {
|
|
156
|
+
ranges.set(c.id, r);
|
|
157
|
+
hl.add(r);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
C.highlights.set("cmt", hl);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── card rendering (shared) ────────────────────────────────────────────
|
|
164
|
+
const fmt = (s: string) => s.slice(0, 16).replace("T", " ");
|
|
165
|
+
function card(c: Comment): string {
|
|
166
|
+
const head = c.thread[0];
|
|
167
|
+
const replies = c.thread.slice(1);
|
|
168
|
+
const orphan = c.scope === "selection" && c.anchor && !findRange(c.anchor);
|
|
169
|
+
return `<article class="cmt-card ${c.status}${c.hold ? " held" : ""}" data-id="${c.id}">
|
|
170
|
+
<div class="cmt-meta"><span class="cmt-dot ${c.status}"></span>
|
|
171
|
+
${
|
|
172
|
+
c.scope === "selection"
|
|
173
|
+
? `<span class="cmt-quote${orphan ? " orphan" : ""}" data-jump="${c.id}">${orphan ? "⚠ " : ""}“${esc((c.anchor?.quote || "").slice(0, 70))}”</span>`
|
|
174
|
+
: `<span class="cmt-scope">📄 page</span>`
|
|
175
|
+
}
|
|
176
|
+
${c.hold ? '<span class="cmt-hold-badge">⏸ en attente</span>' : ""}
|
|
177
|
+
</div>
|
|
178
|
+
<div class="cmt-body"><b>${esc(head.author)}</b> ${esc(head.body)}</div>
|
|
179
|
+
${replies.map((r) => `<div class="cmt-reply"><b>${esc(r.author)}</b> ${esc(r.body)}</div>`).join("")}
|
|
180
|
+
${c.resolution?.note ? `<div class="cmt-resolution">✔ ${esc(c.resolution.note)}</div>` : ""}
|
|
181
|
+
<div class="cmt-actions">
|
|
182
|
+
<span class="cmt-ts">${fmt(head.ts)}</span>
|
|
183
|
+
${c.status === "open" && c.thread.length === 1 ? `<button data-act="edit" data-id="${c.id}" title="Éditer" aria-label="Éditer">${ICONS.edit}</button>` : ""}
|
|
184
|
+
${
|
|
185
|
+
c.status === "resolved"
|
|
186
|
+
? `<button data-act="reopen" data-id="${c.id}" title="Rouvrir" aria-label="Rouvrir">${ICONS.reopen}</button>`
|
|
187
|
+
: `<button data-act="resolve" data-id="${c.id}" title="Résoudre" aria-label="Résoudre">${ICONS.check}</button>`
|
|
188
|
+
}
|
|
189
|
+
<button data-act="${c.hold ? "unhold" : "hold"}" data-id="${c.id}" title="${c.hold ? "Réactiver" : "Mettre en attente"}" aria-label="${c.hold ? "Réactiver" : "Mettre en attente"}">${c.hold ? ICONS.play : ICONS.pause}</button>
|
|
190
|
+
<button data-act="reply" data-id="${c.id}" title="Répondre" aria-label="Répondre">${ICONS.reply}</button>
|
|
191
|
+
<button data-act="delete" data-id="${c.id}" class="danger" title="Supprimer" aria-label="Supprimer">${ICONS.trash}</button>
|
|
192
|
+
</div>
|
|
193
|
+
<form class="cmt-reply-form" data-id="${c.id}" hidden>
|
|
194
|
+
<textarea rows="2" placeholder="Réponse…"></textarea><button type="submit">Envoyer</button>
|
|
195
|
+
</form>
|
|
196
|
+
<form class="cmt-edit-form" data-id="${c.id}" hidden>
|
|
197
|
+
<textarea rows="2">${esc(head.body)}</textarea><button type="submit">Enregistrer</button>
|
|
198
|
+
</form>
|
|
199
|
+
</article>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function render() {
|
|
203
|
+
const anchored = comments.filter((c) => c.scope === "selection");
|
|
204
|
+
const pageWide = comments.filter((c) => c.scope === "page");
|
|
205
|
+
const openCount = comments.filter((c) => c.status !== "resolved").length;
|
|
206
|
+
countEl.textContent = openCount ? `${openCount} ouverts` : `${comments.length}`;
|
|
207
|
+
|
|
208
|
+
// marge droite : commentaires ancrés
|
|
209
|
+
if (railEl) {
|
|
210
|
+
const shown = anchored.filter((c) =>
|
|
211
|
+
railFilter === "all" ? true : railFilter === "resolved" ? c.status === "resolved" : c.status !== "resolved",
|
|
212
|
+
);
|
|
213
|
+
railEl.innerHTML = `<div class="cmt-rail-head">
|
|
214
|
+
<span class="cmt-rail-title">Annotations</span>
|
|
215
|
+
<span class="cmt-rail-count">${anchored.length}</span>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="cmt-filter" id="cmt-filter">
|
|
218
|
+
<button data-f="open"${railFilter === "open" ? ' class="active"' : ""}>Ouverts</button>
|
|
219
|
+
<button data-f="resolved"${railFilter === "resolved" ? ' class="active"' : ""}>Résolus</button>
|
|
220
|
+
<button data-f="all"${railFilter === "all" ? ' class="active"' : ""}>Tous</button>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="cmt-list">${
|
|
223
|
+
shown.length ? shown.map(card).join("") : `<p class="cmt-empty">Sélectionne du texte pour annoter.</p>`
|
|
224
|
+
}</div>`;
|
|
225
|
+
}
|
|
226
|
+
// bas de page : commentaires de page
|
|
227
|
+
pageListEl.innerHTML = pageWide.length
|
|
228
|
+
? pageWide.map(card).join("")
|
|
229
|
+
: `<p class="cmt-empty">Aucun commentaire de page.</p>`;
|
|
230
|
+
applyHighlights();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function load() {
|
|
234
|
+
comments = await api("GET");
|
|
235
|
+
render();
|
|
236
|
+
renderView();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── clic sur texte surligné → popover de lecture au niveau du texte ──────
|
|
240
|
+
function pointAt(x: number, y: number): { node: Node; offset: number } | null {
|
|
241
|
+
const d = document as any;
|
|
242
|
+
if (d.caretRangeFromPoint) {
|
|
243
|
+
const r = d.caretRangeFromPoint(x, y);
|
|
244
|
+
return r ? { node: r.startContainer, offset: r.startOffset } : null;
|
|
245
|
+
}
|
|
246
|
+
if (d.caretPositionFromPoint) {
|
|
247
|
+
const p = d.caretPositionFromPoint(x, y);
|
|
248
|
+
return p ? { node: p.offsetNode, offset: p.offset } : null;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
function renderView() {
|
|
253
|
+
const cs = comments.filter((c) => viewIds.includes(c.id));
|
|
254
|
+
if (!cs.length) {
|
|
255
|
+
viewEl.hidden = true;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
(viewEl.querySelector(".cmt-view-list") as HTMLElement).innerHTML = cs.map(card).join("");
|
|
259
|
+
viewEl.hidden = false;
|
|
260
|
+
}
|
|
261
|
+
article.addEventListener("click", (e) => {
|
|
262
|
+
const me = e as MouseEvent;
|
|
263
|
+
if ((e.target as HTMLElement).closest("a")) return;
|
|
264
|
+
if (window.getSelection()?.toString().trim()) return;
|
|
265
|
+
const pt = pointAt(me.clientX, me.clientY);
|
|
266
|
+
if (!pt) return;
|
|
267
|
+
const hit: string[] = [];
|
|
268
|
+
for (const [cid, r] of ranges) if (r.isPointInRange(pt.node, pt.offset)) hit.push(cid);
|
|
269
|
+
viewIds = hit;
|
|
270
|
+
if (!hit.length) {
|
|
271
|
+
viewEl.hidden = true;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
viewEl.style.top = `${Math.min(window.innerHeight - 80, me.clientY + 10)}px`;
|
|
275
|
+
viewEl.style.left = `${Math.min(window.innerWidth - 340, me.clientX)}px`;
|
|
276
|
+
renderView();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── selection → floating button → popover ──────────────────────────────
|
|
280
|
+
let savedRange: Range | null = null;
|
|
281
|
+
document.addEventListener("mouseup", (e) => {
|
|
282
|
+
const t = e.target as HTMLElement;
|
|
283
|
+
if (t.closest("#cmt-float,#cmt-pop,#cmt-page,#cmt-rail")) return;
|
|
284
|
+
const sel = window.getSelection();
|
|
285
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
|
|
286
|
+
floatBtn.hidden = true;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const range = sel.getRangeAt(0);
|
|
290
|
+
if (!article!.contains(range.commonAncestorContainer)) {
|
|
291
|
+
floatBtn.hidden = true;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
savedRange = range.cloneRange();
|
|
295
|
+
const rect = range.getBoundingClientRect();
|
|
296
|
+
floatBtn.style.top = `${Math.max(8, rect.top - 40)}px`;
|
|
297
|
+
floatBtn.style.left = `${rect.left}px`;
|
|
298
|
+
floatBtn.hidden = false;
|
|
299
|
+
});
|
|
300
|
+
floatBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
301
|
+
floatBtn.addEventListener("click", () => {
|
|
302
|
+
if (!savedRange) return;
|
|
303
|
+
pendingAnchor = computeAnchor(savedRange);
|
|
304
|
+
const rect = savedRange.getBoundingClientRect();
|
|
305
|
+
pop.style.top = `${Math.min(window.innerHeight - 180, rect.bottom + 8)}px`;
|
|
306
|
+
pop.style.left = `${Math.min(window.innerWidth - 320, rect.left)}px`;
|
|
307
|
+
popQuote.textContent = `“${pendingAnchor.quote.slice(0, 120)}”`;
|
|
308
|
+
pop.hidden = false;
|
|
309
|
+
floatBtn.hidden = true;
|
|
310
|
+
popInput.value = "";
|
|
311
|
+
popInput.focus();
|
|
312
|
+
});
|
|
313
|
+
document.getElementById("cmt-pop-cancel")!.addEventListener("click", () => {
|
|
314
|
+
pop.hidden = true;
|
|
315
|
+
pendingAnchor = null;
|
|
316
|
+
});
|
|
317
|
+
document.getElementById("cmt-pop-save")!.addEventListener("click", async () => {
|
|
318
|
+
const body = popInput.value.trim();
|
|
319
|
+
if (!body || !pendingAnchor) return;
|
|
320
|
+
await api("POST", { page, space, scope: "selection", anchor: pendingAnchor, body });
|
|
321
|
+
pop.hidden = true;
|
|
322
|
+
pendingAnchor = null;
|
|
323
|
+
window.getSelection()?.removeAllRanges();
|
|
324
|
+
await load();
|
|
325
|
+
});
|
|
326
|
+
document.addEventListener("mousedown", (e) => {
|
|
327
|
+
const t = e.target as HTMLElement;
|
|
328
|
+
if (!t.closest("#cmt-pop,#cmt-float")) {
|
|
329
|
+
pop.hidden = true;
|
|
330
|
+
if (!window.getSelection()?.toString().trim()) floatBtn.hidden = true;
|
|
331
|
+
}
|
|
332
|
+
// Ferme le popover de lecture sauf clic dedans ou re-clic dans le contenu
|
|
333
|
+
// (le handler de clic du contenu le rouvrira si on tape sur du surligné).
|
|
334
|
+
if (!t.closest("#cmt-view") && !article!.contains(t)) {
|
|
335
|
+
viewIds = [];
|
|
336
|
+
viewEl.hidden = true;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
document.addEventListener("keydown", (e) => {
|
|
340
|
+
if (e.key === "Escape") {
|
|
341
|
+
pop.hidden = true;
|
|
342
|
+
viewEl.hidden = true;
|
|
343
|
+
viewIds = [];
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── page composer ──────────────────────────────────────────────────────
|
|
348
|
+
document.getElementById("cmt-composer")!.addEventListener("submit", async (e) => {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
const body = inputEl.value.trim();
|
|
351
|
+
if (!body) return;
|
|
352
|
+
await api("POST", { page, space, scope: "page", anchor: null, body });
|
|
353
|
+
inputEl.value = "";
|
|
354
|
+
await load();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ── actions (delegation on both regions) ───────────────────────────────
|
|
358
|
+
async function onActionClick(e: Event) {
|
|
359
|
+
const t = e.target as HTMLElement;
|
|
360
|
+
const jump = t.closest("[data-jump]") as HTMLElement | null;
|
|
361
|
+
if (jump) {
|
|
362
|
+
const r = ranges.get(jump.dataset.jump!);
|
|
363
|
+
if (r) {
|
|
364
|
+
(r.startContainer.parentElement || article)!.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
365
|
+
const C = window.CSS as any;
|
|
366
|
+
if (C?.highlights) {
|
|
367
|
+
C.highlights.set("cmt-active", new (window as any).Highlight(r));
|
|
368
|
+
setTimeout(() => C.highlights.delete("cmt-active"), 1500);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const btn = t.closest("button[data-act]") as HTMLElement | null;
|
|
374
|
+
if (!btn) return;
|
|
375
|
+
const id = btn.dataset.id!;
|
|
376
|
+
const act = btn.dataset.act!;
|
|
377
|
+
if (act === "resolve" || act === "reopen") {
|
|
378
|
+
await api("PATCH", { page, id, status: act === "resolve" ? "resolved" : "open" });
|
|
379
|
+
await load();
|
|
380
|
+
} else if (act === "hold" || act === "unhold") {
|
|
381
|
+
await api("PATCH", { page, id, hold: act === "hold" });
|
|
382
|
+
await load();
|
|
383
|
+
} else if (act === "delete") {
|
|
384
|
+
await api("DELETE", { page, id });
|
|
385
|
+
await load();
|
|
386
|
+
} else if (act === "reply") {
|
|
387
|
+
const form = (btn.closest(".cmt-card") as HTMLElement).querySelector(".cmt-reply-form") as HTMLElement;
|
|
388
|
+
form.hidden = !form.hidden;
|
|
389
|
+
if (!form.hidden) (form.querySelector("textarea") as HTMLTextAreaElement).focus();
|
|
390
|
+
} else if (act === "edit") {
|
|
391
|
+
const form = (btn.closest(".cmt-card") as HTMLElement).querySelector(".cmt-edit-form") as HTMLElement;
|
|
392
|
+
form.hidden = !form.hidden;
|
|
393
|
+
if (!form.hidden) {
|
|
394
|
+
const ta = form.querySelector("textarea") as HTMLTextAreaElement;
|
|
395
|
+
ta.focus();
|
|
396
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function onFormSubmit(e: Event) {
|
|
401
|
+
const t = e.target as HTMLElement;
|
|
402
|
+
const editForm = t.closest(".cmt-edit-form") as HTMLElement | null;
|
|
403
|
+
if (editForm) {
|
|
404
|
+
e.preventDefault();
|
|
405
|
+
const ta = editForm.querySelector("textarea") as HTMLTextAreaElement;
|
|
406
|
+
if (!ta.value.trim()) return;
|
|
407
|
+
await api("PATCH", { page, id: editForm.dataset.id, edit: { body: ta.value.trim() } });
|
|
408
|
+
await load();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const form = t.closest(".cmt-reply-form") as HTMLElement | null;
|
|
412
|
+
if (!form) return;
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
const ta = form.querySelector("textarea") as HTMLTextAreaElement;
|
|
415
|
+
if (!ta.value.trim()) return;
|
|
416
|
+
await api("PATCH", { page, id: form.dataset.id, reply: { body: ta.value.trim() } });
|
|
417
|
+
await load();
|
|
418
|
+
}
|
|
419
|
+
for (const region of [railEl, pageListEl, viewEl]) {
|
|
420
|
+
if (!region) continue;
|
|
421
|
+
region.addEventListener("click", onActionClick);
|
|
422
|
+
region.addEventListener("submit", onFormSubmit);
|
|
423
|
+
}
|
|
424
|
+
// filtre (rail) — délégué car re-rendu
|
|
425
|
+
railEl?.addEventListener("click", (e) => {
|
|
426
|
+
const b = (e.target as HTMLElement).closest("#cmt-filter button");
|
|
427
|
+
if (!b) return;
|
|
428
|
+
railFilter = (b as HTMLElement).dataset.f!;
|
|
429
|
+
render();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Focus d'un commentaire ciblé depuis la page globale (?c=<id>).
|
|
433
|
+
function focusFromUrl() {
|
|
434
|
+
const cid = new URLSearchParams(location.search).get("c");
|
|
435
|
+
if (!cid) return;
|
|
436
|
+
const c = comments.find((x) => x.id === cid);
|
|
437
|
+
if (!c) return;
|
|
438
|
+
const r = ranges.get(cid);
|
|
439
|
+
if (c.scope === "selection" && r) {
|
|
440
|
+
(r.startContainer.parentElement || article)!.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
441
|
+
const C = window.CSS as any;
|
|
442
|
+
if (C?.highlights) {
|
|
443
|
+
C.highlights.set("cmt-active", new (window as any).Highlight(r));
|
|
444
|
+
setTimeout(() => C.highlights.delete("cmt-active"), 2500);
|
|
445
|
+
}
|
|
446
|
+
viewIds = [cid];
|
|
447
|
+
const rect = r.getBoundingClientRect();
|
|
448
|
+
viewEl.style.top = `${Math.min(window.innerHeight - 80, rect.bottom + 10)}px`;
|
|
449
|
+
viewEl.style.left = `${Math.min(window.innerWidth - 340, rect.left)}px`;
|
|
450
|
+
renderView();
|
|
451
|
+
} else {
|
|
452
|
+
document.getElementById("cmt-page")?.scrollIntoView({ behavior: "smooth" });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
load().then(focusFromUrl);
|
|
457
|
+
}
|
|
458
|
+
</script>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { NavNode } from "../lib/nav";
|
|
3
|
+
import NavTree from "./NavTree.astro";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
nodes: NavNode[];
|
|
7
|
+
current: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { nodes, current } = Astro.props;
|
|
11
|
+
|
|
12
|
+
// Un groupe est ouvert s'il contient (à n'importe quelle profondeur) la page
|
|
13
|
+
// courante. Marche pour les groupes-dossiers ET les sous-groupes éditoriaux
|
|
14
|
+
// synthétiques (cf. nav.config), qui n'ont pas de préfixe de chemin réel.
|
|
15
|
+
function containsCurrent(node: NavNode, target: string): boolean {
|
|
16
|
+
if (node.type === "leaf") return node.href === target;
|
|
17
|
+
return node.children.some((child) => containsCurrent(child, target));
|
|
18
|
+
}
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<ul class="nav-list">
|
|
22
|
+
{
|
|
23
|
+
nodes.map((node) =>
|
|
24
|
+
node.type === "group" ? (
|
|
25
|
+
<li class="nav-group">
|
|
26
|
+
<details open={containsCurrent(node, current)}>
|
|
27
|
+
<summary>{node.label}</summary>
|
|
28
|
+
<NavTree nodes={node.children} current={current} />
|
|
29
|
+
</details>
|
|
30
|
+
</li>
|
|
31
|
+
) : (
|
|
32
|
+
<li class="nav-leaf">
|
|
33
|
+
<a
|
|
34
|
+
href={node.href}
|
|
35
|
+
class:list={["nav-a", { active: node.href === current }]}
|
|
36
|
+
data-title={node.title}
|
|
37
|
+
>
|
|
38
|
+
{node.title}
|
|
39
|
+
</a>
|
|
40
|
+
</li>
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
</ul>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { buildNav, type Space } from "../lib/nav";
|
|
3
|
+
import NavTree from "./NavTree.astro";
|
|
4
|
+
import { roots } from "../config.mjs";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
current?: string;
|
|
8
|
+
activeSpace?: Space;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { current = "", activeSpace } = Astro.props;
|
|
12
|
+
// Un arbre de nav par espace déclaré dans notabene.config (plus le duo figé).
|
|
13
|
+
const spaces = await Promise.all(
|
|
14
|
+
roots.map(async (root) => ({ root, nodes: await buildNav(root.key) })),
|
|
15
|
+
);
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<div class="nav-filter">
|
|
19
|
+
<input id="nav-filter" type="search" placeholder="Filter…" autocomplete="off" />
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<nav class="nav">
|
|
23
|
+
{
|
|
24
|
+
spaces.map(({ root, nodes }) => (
|
|
25
|
+
<details class="space" open={!activeSpace || activeSpace === root.key}>
|
|
26
|
+
<summary class="space-title">
|
|
27
|
+
<span class="space-name">{root.label}</span>
|
|
28
|
+
<span class="space-sub">{root.subLabel}</span>
|
|
29
|
+
</summary>
|
|
30
|
+
<p class="space-link"><a href={`/${root.key}`}>Overview →</a></p>
|
|
31
|
+
<NavTree nodes={nodes} current={current} />
|
|
32
|
+
</details>
|
|
33
|
+
))
|
|
34
|
+
}
|
|
35
|
+
</nav>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
// Filtre client-side : masque les feuilles dont le titre ne matche pas, et
|
|
39
|
+
// déplie tous les groupes pendant la recherche.
|
|
40
|
+
const input = document.getElementById("nav-filter") as HTMLInputElement | null;
|
|
41
|
+
if (input) {
|
|
42
|
+
input.addEventListener("input", () => {
|
|
43
|
+
const q = input.value.trim().toLowerCase();
|
|
44
|
+
for (const a of document.querySelectorAll<HTMLElement>(".nav a[data-title]")) {
|
|
45
|
+
const li = a.closest("li");
|
|
46
|
+
if (!li) continue;
|
|
47
|
+
const hit = !q || (a.dataset.title ?? "").toLowerCase().includes(q);
|
|
48
|
+
li.style.display = hit ? "" : "none";
|
|
49
|
+
}
|
|
50
|
+
if (q) {
|
|
51
|
+
for (const d of document.querySelectorAll<HTMLDetailsElement>(".nav details")) {
|
|
52
|
+
d.open = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { NavNode, NavLeaf, Space } from "../lib/nav";
|
|
3
|
+
import { roots } from "../config.mjs";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
nodes: NavNode[];
|
|
7
|
+
space: Space;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { nodes, space } = Astro.props;
|
|
11
|
+
const root = roots.find((r) => r.key === space);
|
|
12
|
+
const label = root?.label ?? space;
|
|
13
|
+
const blurb = root?.description || `Documentation sourced from ${root?.subLabel ?? space}.`;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<h1>{label}</h1>
|
|
17
|
+
<p class="lede">{blurb}</p>
|
|
18
|
+
|
|
19
|
+
<div class="cards">
|
|
20
|
+
{
|
|
21
|
+
nodes.map((n) =>
|
|
22
|
+
n.type === "group" ? (
|
|
23
|
+
<section class="card">
|
|
24
|
+
<h3>{n.label}</h3>
|
|
25
|
+
<ul>
|
|
26
|
+
{n.children
|
|
27
|
+
.filter((c): c is NavLeaf => c.type === "leaf")
|
|
28
|
+
.map((c) => (
|
|
29
|
+
<li>
|
|
30
|
+
<a href={c.href}>{c.title}</a>
|
|
31
|
+
</li>
|
|
32
|
+
))}
|
|
33
|
+
</ul>
|
|
34
|
+
{n.children.some((c) => c.type === "group") && <p class="more">+ subsections…</p>}
|
|
35
|
+
</section>
|
|
36
|
+
) : (
|
|
37
|
+
<section class="card card-leaf">
|
|
38
|
+
<h3>
|
|
39
|
+
<a href={n.href}>{n.title}</a>
|
|
40
|
+
</h3>
|
|
41
|
+
</section>
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Heading {
|
|
3
|
+
depth: number;
|
|
4
|
+
slug: string;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
headings: Heading[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { headings } = Astro.props;
|
|
13
|
+
const items = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
items.length > 0 && (
|
|
18
|
+
<nav class="toc-inner" aria-label="Sur cette page">
|
|
19
|
+
<p class="toc-title">On this page</p>
|
|
20
|
+
<ul>
|
|
21
|
+
{items.map((h) => (
|
|
22
|
+
<li class:list={[`d${h.depth}`]}>
|
|
23
|
+
<a href={`#${h.slug}`}>{h.text}</a>
|
|
24
|
+
</li>
|
|
25
|
+
))}
|
|
26
|
+
</ul>
|
|
27
|
+
</nav>
|
|
28
|
+
)
|
|
29
|
+
}
|