@open330/kiwimu 0.7.1 → 1.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.
@@ -0,0 +1,470 @@
1
+ /*
2
+ * peek-panel.js — Notion-style side-panel preview for /wiki/* links.
3
+ * Intercepts plain-clicks, fetches /api/page/{slug}?format=html, renders in a
4
+ * slide-in panel with a navigation stack (cap 5), URL state (?peek=<slug>),
5
+ * focus trap, ESC-to-close. Idempotent via window.__kiwiPeekInit.
6
+ */
7
+ (function () {
8
+ 'use strict';
9
+
10
+ if (window.__kiwiPeekInit) return;
11
+ window.__kiwiPeekInit = true;
12
+
13
+ const WIKI_LINK_RE = /^\/wiki\/([^/?#]+)\.html(?:[?#].*)?$/;
14
+ const STACK_LIMIT = 5;
15
+ const WIDTH_KEY = 'kiwi-peek-width';
16
+ const WIDTH_MIN = 320;
17
+ const WIDTH_MAX_VW = 0.95;
18
+
19
+ // Auth: read token from <meta name="kiwi-auth"> (matches dynamic-qa.js).
20
+ function authHeaders() {
21
+ const t = document.querySelector('meta[name="kiwi-auth"]')?.content || '';
22
+ return t ? { Authorization: 'Bearer ' + t } : {};
23
+ }
24
+
25
+ // --- State ---------------------------------------------------------------
26
+ let panel, backdrop, titleEl, contentEl, backBtn, expandBtn;
27
+ let titleId;
28
+ const stack = []; // previous slugs for "back"
29
+ let currentSlug = null;
30
+ let isOpen = false;
31
+ let lastFocused = null;
32
+ let savedBodyOverflow = '';
33
+ let abortCtrl = null;
34
+ let pushedHistoryState = false; // did we push the current ?peek state?
35
+
36
+ // --- Resize --------------------------------------------------------------
37
+ function clampWidth(w) {
38
+ const max = Math.min(window.innerWidth * WIDTH_MAX_VW, 1400);
39
+ return Math.max(WIDTH_MIN, Math.min(max, w));
40
+ }
41
+ function loadSavedWidth() {
42
+ try {
43
+ const v = parseInt(localStorage.getItem(WIDTH_KEY) || '', 10);
44
+ return v > 0 ? clampWidth(v) : null;
45
+ } catch { return null; }
46
+ }
47
+ function saveWidth(w) {
48
+ try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); } catch { /* no-op */ }
49
+ }
50
+ function applySavedWidth() {
51
+ // Skip on mobile — CSS forces full-screen there.
52
+ if (window.matchMedia('(max-width: 768px)').matches) return;
53
+ const w = loadSavedWidth();
54
+ if (w) panel.style.width = w + 'px';
55
+ }
56
+ function attachResizeHandle(handle) {
57
+ let dragging = false;
58
+ let startX = 0;
59
+ let startW = 0;
60
+ handle.addEventListener('pointerdown', (e) => {
61
+ if (window.matchMedia('(max-width: 768px)').matches) return;
62
+ dragging = true;
63
+ startX = e.clientX;
64
+ startW = panel.getBoundingClientRect().width;
65
+ handle.setPointerCapture(e.pointerId);
66
+ document.body.classList.add('peek-resizing');
67
+ e.preventDefault();
68
+ });
69
+ handle.addEventListener('pointermove', (e) => {
70
+ if (!dragging) return;
71
+ // Panel is anchored to the right edge — moving handle left grows width.
72
+ const w = clampWidth(startW + (startX - e.clientX));
73
+ panel.style.width = w + 'px';
74
+ });
75
+ function stop(e) {
76
+ if (!dragging) return;
77
+ dragging = false;
78
+ try { handle.releasePointerCapture(e.pointerId); } catch { /* no-op */ }
79
+ document.body.classList.remove('peek-resizing');
80
+ saveWidth(panel.getBoundingClientRect().width);
81
+ }
82
+ handle.addEventListener('pointerup', stop);
83
+ handle.addEventListener('pointercancel', stop);
84
+ handle.addEventListener('dblclick', () => {
85
+ // Reset to default on double-click.
86
+ panel.style.width = '';
87
+ try { localStorage.removeItem(WIDTH_KEY); } catch { /* no-op */ }
88
+ });
89
+ }
90
+
91
+ // --- DOM construction ----------------------------------------------------
92
+ function build() {
93
+ titleId = 'peek-title-' + Math.random().toString(36).slice(2, 9);
94
+
95
+ backdrop = document.createElement('div');
96
+ backdrop.className = 'peek-backdrop';
97
+ backdrop.setAttribute('aria-hidden', 'true');
98
+ backdrop.addEventListener('click', () => close());
99
+
100
+ panel = document.createElement('aside');
101
+ panel.className = 'peek-panel';
102
+ panel.setAttribute('role', 'dialog');
103
+ panel.setAttribute('aria-modal', 'true');
104
+ panel.setAttribute('aria-labelledby', titleId);
105
+ panel.setAttribute('tabindex', '-1');
106
+ panel.hidden = true;
107
+
108
+ const resizeHandle = document.createElement('div');
109
+ resizeHandle.className = 'peek-resize-handle';
110
+ resizeHandle.setAttribute('role', 'separator');
111
+ resizeHandle.setAttribute('aria-orientation', 'vertical');
112
+ resizeHandle.title = '드래그하여 너비 조절 · 더블클릭으로 초기화';
113
+ attachResizeHandle(resizeHandle);
114
+ panel.appendChild(resizeHandle);
115
+
116
+ const headerEl = document.createElement('header');
117
+ headerEl.className = 'peek-header';
118
+
119
+ backBtn = document.createElement('button');
120
+ backBtn.type = 'button';
121
+ backBtn.className = 'peek-button peek-back';
122
+ backBtn.setAttribute('aria-label', 'Back');
123
+ backBtn.textContent = '←';
124
+ backBtn.hidden = true;
125
+ backBtn.addEventListener('click', goBack);
126
+
127
+ titleEl = document.createElement('h2');
128
+ titleEl.className = 'peek-title';
129
+ titleEl.id = titleId;
130
+ titleEl.textContent = '';
131
+
132
+ const actions = document.createElement('div');
133
+ actions.className = 'peek-actions';
134
+
135
+ expandBtn = document.createElement('button');
136
+ expandBtn.type = 'button';
137
+ expandBtn.className = 'peek-button peek-expand';
138
+ expandBtn.setAttribute('aria-label', 'Open full page');
139
+ expandBtn.title = 'Open full page';
140
+ expandBtn.textContent = '↗';
141
+ expandBtn.addEventListener('click', () => {
142
+ if (currentSlug) {
143
+ // Plain navigation in same tab.
144
+ window.location.href = '/wiki/' + encodeURIComponent(currentSlug) + '.html';
145
+ }
146
+ });
147
+
148
+ const closeBtn = document.createElement('button');
149
+ closeBtn.type = 'button';
150
+ closeBtn.className = 'peek-button peek-close';
151
+ closeBtn.setAttribute('aria-label', 'Close');
152
+ closeBtn.title = 'Close';
153
+ closeBtn.textContent = '×';
154
+ closeBtn.addEventListener('click', () => close());
155
+
156
+ actions.append(expandBtn, closeBtn);
157
+ headerEl.append(backBtn, titleEl, actions);
158
+
159
+ contentEl = document.createElement('div');
160
+ contentEl.className = 'peek-content';
161
+
162
+ panel.append(headerEl, contentEl);
163
+
164
+ document.body.append(backdrop, panel);
165
+ }
166
+
167
+ // --- Open / close lifecycle ---------------------------------------------
168
+ function lockBodyScroll() {
169
+ savedBodyOverflow = document.body.style.overflow;
170
+ document.body.style.overflow = 'hidden';
171
+ }
172
+ function unlockBodyScroll() {
173
+ document.body.style.overflow = savedBodyOverflow;
174
+ savedBodyOverflow = '';
175
+ }
176
+
177
+ function setUrlPeek(slug) {
178
+ try {
179
+ const url = new URL(window.location.href);
180
+ url.searchParams.set('peek', slug);
181
+ if (pushedHistoryState) {
182
+ history.replaceState({ kiwiPeek: slug }, '', url.toString());
183
+ } else {
184
+ history.pushState({ kiwiPeek: slug }, '', url.toString());
185
+ pushedHistoryState = true;
186
+ }
187
+ } catch { /* no-op */ }
188
+ }
189
+
190
+ function clearUrlPeek() {
191
+ try {
192
+ const url = new URL(window.location.href);
193
+ if (!url.searchParams.has('peek')) return;
194
+ url.searchParams.delete('peek');
195
+ const target = url.pathname + (url.search ? url.search : '') + url.hash;
196
+ if (pushedHistoryState) {
197
+ // Step back through history so we restore the original entry.
198
+ pushedHistoryState = false;
199
+ history.back();
200
+ } else {
201
+ history.replaceState({}, '', target);
202
+ }
203
+ } catch { /* no-op */ }
204
+ }
205
+
206
+ function open(slug, opts) {
207
+ opts = opts || {};
208
+ if (!slug) return;
209
+
210
+ if (!isOpen) {
211
+ lastFocused = document.activeElement;
212
+ applySavedWidth();
213
+ panel.hidden = false;
214
+ backdrop.classList.add('is-open');
215
+ // Force layout so the transform transition runs.
216
+ // eslint-disable-next-line no-unused-expressions
217
+ panel.offsetWidth;
218
+ panel.classList.add('is-open');
219
+ lockBodyScroll();
220
+ isOpen = true;
221
+ }
222
+
223
+ if (opts.resetStack) {
224
+ stack.length = 0;
225
+ }
226
+ if (opts.pushOnto && currentSlug && currentSlug !== slug) {
227
+ stack.push(currentSlug);
228
+ while (stack.length > STACK_LIMIT) stack.shift();
229
+ }
230
+
231
+ currentSlug = slug;
232
+ backBtn.hidden = stack.length === 0;
233
+
234
+ if (!opts.skipUrl) setUrlPeek(slug);
235
+
236
+ fetchAndRender(slug);
237
+
238
+ // Focus the panel after open so keyboard users land inside.
239
+ requestAnimationFrame(() => {
240
+ panel.focus({ preventScroll: true });
241
+ });
242
+ }
243
+
244
+ function close(opts) {
245
+ opts = opts || {};
246
+ if (!isOpen) return;
247
+ if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
248
+
249
+ panel.classList.remove('is-open');
250
+ backdrop.classList.remove('is-open');
251
+ // Hide after transition (250ms) for a11y; cheap timeout is fine.
252
+ setTimeout(() => { if (!isOpen) panel.hidden = true; }, 280);
253
+
254
+ isOpen = false;
255
+ currentSlug = null;
256
+ stack.length = 0;
257
+ backBtn.hidden = true;
258
+ unlockBodyScroll();
259
+
260
+ if (!opts.skipUrl) clearUrlPeek();
261
+
262
+ if (lastFocused && typeof lastFocused.focus === 'function') {
263
+ try { lastFocused.focus({ preventScroll: true }); } catch { /* no-op */ }
264
+ }
265
+ lastFocused = null;
266
+ }
267
+
268
+ function goBack() {
269
+ if (stack.length === 0) { close(); return; }
270
+ const prev = stack.pop();
271
+ currentSlug = prev;
272
+ backBtn.hidden = stack.length === 0;
273
+ setUrlPeek(prev);
274
+ fetchAndRender(prev);
275
+ }
276
+
277
+ // --- Fetch + render ------------------------------------------------------
278
+ function escapeHtml(s) {
279
+ const d = document.createElement('div');
280
+ d.textContent = String(s == null ? '' : s);
281
+ return d.innerHTML;
282
+ }
283
+
284
+ function showLoading() {
285
+ titleEl.textContent = 'Loading…';
286
+ contentEl.innerHTML =
287
+ '<div class="peek-loading"><span class="peek-spinner"></span>Loading…</div>';
288
+ }
289
+
290
+ function showError(slug, msg) {
291
+ titleEl.textContent = 'Error';
292
+ const safeMsg = escapeHtml(msg || 'Failed to load page.');
293
+ contentEl.innerHTML =
294
+ '<div class="peek-error">' + safeMsg +
295
+ '<div><button type="button" class="peek-button" data-peek-retry>Retry</button></div>' +
296
+ '</div>';
297
+ const retry = contentEl.querySelector('[data-peek-retry]');
298
+ if (retry) retry.addEventListener('click', () => fetchAndRender(slug));
299
+ }
300
+
301
+ async function fetchAndRender(slug) {
302
+ if (abortCtrl) abortCtrl.abort();
303
+ abortCtrl = new AbortController();
304
+ const ctrl = abortCtrl;
305
+
306
+ showLoading();
307
+
308
+ try {
309
+ const resp = await fetch(
310
+ '/api/page/' + encodeURIComponent(slug) + '?format=html',
311
+ { headers: authHeaders(), signal: ctrl.signal, credentials: 'same-origin' }
312
+ );
313
+ if (!resp.ok) {
314
+ const detail = resp.status === 404 ? 'Page not found.' : 'Server error (' + resp.status + ').';
315
+ showError(slug, detail);
316
+ return;
317
+ }
318
+ const data = await resp.json();
319
+ if (ctrl.signal.aborted) return;
320
+
321
+ titleEl.textContent = data.title || slug;
322
+
323
+ let html = data.html;
324
+ if (!html && typeof data.content === 'string') {
325
+ // Defensive fallback if server hasn't enabled ?format=html yet.
326
+ html = '<pre>' + escapeHtml(data.content) + '</pre>';
327
+ }
328
+ // Wrap in .page-body so existing article styles + dynamic-qa selection
329
+ // gating work inside the peek panel without duplicating logic.
330
+ contentEl.innerHTML =
331
+ '<article class="wiki-page peek-article"><div class="page-body">' +
332
+ (html || '') +
333
+ '</div></article>';
334
+ contentEl.scrollTop = 0;
335
+
336
+ // Re-run mermaid if the host page exposes the helper.
337
+ try {
338
+ if (typeof window.kiwiRenderMermaid === 'function') {
339
+ window.kiwiRenderMermaid(contentEl);
340
+ }
341
+ } catch { /* don't crash on mermaid errors */ }
342
+ } catch (err) {
343
+ if (err && err.name === 'AbortError') return;
344
+ showError(slug, 'Network error. Check your connection and try again.');
345
+ }
346
+ }
347
+
348
+ // --- Link interception ---------------------------------------------------
349
+ function isPlainLeftClick(e) {
350
+ return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
351
+ }
352
+
353
+ function slugFromHref(href) {
354
+ if (!href) return null;
355
+ let path;
356
+ try {
357
+ // Resolve relative against current location.
358
+ const u = new URL(href, window.location.href);
359
+ // Only intercept same-origin links.
360
+ if (u.origin !== window.location.origin) return null;
361
+ path = u.pathname;
362
+ } catch { return null; }
363
+ const m = WIKI_LINK_RE.exec(path);
364
+ return m ? decodeURIComponent(m[1]) : null;
365
+ }
366
+
367
+ function onClick(e) {
368
+ if (!isPlainLeftClick(e)) return;
369
+ if (e.defaultPrevented) return;
370
+
371
+ const a = e.target.closest && e.target.closest('a[href]');
372
+ if (!a) return;
373
+ if (a.target && a.target !== '' && a.target !== '_self') return;
374
+ if (a.hasAttribute('download')) return;
375
+ if (a.dataset && a.dataset.peek === 'off') return;
376
+
377
+ const slug = slugFromHref(a.getAttribute('href'));
378
+ if (!slug) return;
379
+
380
+ e.preventDefault();
381
+
382
+ if (isOpen) {
383
+ // Navigating within the panel — push current onto the stack.
384
+ open(slug, { pushOnto: true });
385
+ } else {
386
+ open(slug, { resetStack: true });
387
+ }
388
+ }
389
+
390
+ // --- Keyboard: ESC + focus trap -----------------------------------------
391
+ const FOCUSABLE_SEL =
392
+ 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]),' +
393
+ ' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
394
+
395
+ function focusableNodes() {
396
+ return Array.prototype.filter.call(
397
+ panel.querySelectorAll(FOCUSABLE_SEL),
398
+ (el) => !el.hidden && el.offsetParent !== null
399
+ );
400
+ }
401
+
402
+ function onKeydown(e) {
403
+ if (!isOpen) return;
404
+ if (e.key === 'Escape') {
405
+ e.stopPropagation();
406
+ close();
407
+ return;
408
+ }
409
+ if (e.key !== 'Tab') return;
410
+
411
+ const nodes = focusableNodes();
412
+ if (nodes.length === 0) {
413
+ e.preventDefault();
414
+ panel.focus();
415
+ return;
416
+ }
417
+ const first = nodes[0];
418
+ const last = nodes[nodes.length - 1];
419
+ const active = document.activeElement;
420
+
421
+ if (e.shiftKey) {
422
+ if (active === first || active === panel || !panel.contains(active)) {
423
+ e.preventDefault();
424
+ last.focus();
425
+ }
426
+ } else {
427
+ if (active === last) {
428
+ e.preventDefault();
429
+ first.focus();
430
+ }
431
+ }
432
+ }
433
+
434
+ // --- History sync (back/forward) ----------------------------------------
435
+ function onPopState() {
436
+ const url = new URL(window.location.href);
437
+ const slug = url.searchParams.get('peek');
438
+ // Whatever history says — make our state match.
439
+ pushedHistoryState = false;
440
+ if (slug) {
441
+ open(slug, { skipUrl: true, resetStack: true });
442
+ } else if (isOpen) {
443
+ close({ skipUrl: true });
444
+ }
445
+ }
446
+
447
+ // --- Bootstrap -----------------------------------------------------------
448
+ function init() {
449
+ build();
450
+ document.addEventListener('click', onClick, true);
451
+ document.addEventListener('keydown', onKeydown);
452
+ window.addEventListener('popstate', onPopState);
453
+
454
+ // Auto-open if the current URL has ?peek=<slug>.
455
+ try {
456
+ const url = new URL(window.location.href);
457
+ const slug = url.searchParams.get('peek');
458
+ if (slug) {
459
+ // Don't push history; the URL is already correct.
460
+ open(slug, { skipUrl: true, resetStack: true });
461
+ }
462
+ } catch { /* no-op */ }
463
+ }
464
+
465
+ if (document.readyState === 'loading') {
466
+ document.addEventListener('DOMContentLoaded', init, { once: true });
467
+ } else {
468
+ init();
469
+ }
470
+ })();
@@ -6,6 +6,12 @@ document.addEventListener("DOMContentLoaded", async () => {
6
6
 
7
7
  let searchData = [];
8
8
  let selectedIndex = -1;
9
+ let debounceTimer;
10
+
11
+ input.setAttribute('role', 'combobox');
12
+ input.setAttribute('aria-expanded', 'false');
13
+ input.setAttribute('aria-haspopup', 'listbox');
14
+ input.setAttribute('aria-autocomplete', 'list');
9
15
  try {
10
16
  const resp = await fetch("/search-index.json");
11
17
  searchData = await resp.json();
@@ -38,24 +44,33 @@ document.addEventListener("DOMContentLoaded", async () => {
38
44
  }
39
45
 
40
46
  input.addEventListener("input", () => {
41
- selectedIndex = -1;
42
- const results = search(input.value);
43
- if (results.length === 0) {
44
- dropdown.classList.remove("active");
45
- dropdown.innerHTML = "";
46
- return;
47
- }
48
- dropdown.innerHTML = results.map(r =>
49
- `<a href="/wiki/${r.slug}.html">
50
- <strong>${escapeHtml(r.title)}</strong>
51
- <div style="font-size:12px;color:#6c757d;margin-top:2px;">${escapeHtml(r.preview.slice(0, 80))}...</div>
52
- </a>`
53
- ).join("");
54
- dropdown.classList.add("active");
47
+ clearTimeout(debounceTimer);
48
+ debounceTimer = setTimeout(() => {
49
+ selectedIndex = -1;
50
+ const results = search(input.value);
51
+ if (results.length === 0) {
52
+ dropdown.classList.remove("active");
53
+ dropdown.innerHTML = "";
54
+ input.setAttribute('aria-expanded', 'false');
55
+ return;
56
+ }
57
+ dropdown.setAttribute('role', 'listbox');
58
+ dropdown.innerHTML = results.map(r =>
59
+ `<a href="/wiki/${r.slug}.html" role="option">
60
+ <strong>${escapeHtml(r.title)}</strong>
61
+ <div style="font-size:12px;color:#6c757d;margin-top:2px;">${escapeHtml(r.preview.slice(0, 80))}...</div>
62
+ </a>`
63
+ ).join("");
64
+ dropdown.classList.add("active");
65
+ input.setAttribute('aria-expanded', 'true');
66
+ }, 150);
55
67
  });
56
68
 
57
69
  input.addEventListener("blur", () => {
58
- setTimeout(() => dropdown.classList.remove("active"), 200);
70
+ setTimeout(() => {
71
+ dropdown.classList.remove("active");
72
+ input.setAttribute('aria-expanded', 'false');
73
+ }, 200);
59
74
  });
60
75
 
61
76
  input.addEventListener("focus", () => {