// Pure helpers for note stacking, shared by the browser and the tests. Keep it import-free. export const MAX_STACKED = 8; const PARAM = 'with'; /** * Slugs to stack from a query string: known notes only, deduped, without the page's own note. * @param {string} search location.search, with or without "?" * @param {string[]} knownSlugs slugs from stack.json * @param {string} selfSlug * @returns {string[]} */ export function parseWith(search, knownSlugs, selfSlug) { const raw = new URLSearchParams(search).get(PARAM) ?? ''; const known = new Set(knownSlugs); /** @type {string[]} */ const out = []; for (const part of raw.split(',')) { const slug = part.trim(); if (slug && slug !== selfSlug && known.has(slug) && !out.includes(slug)) out.push(slug); } return out.slice(0, MAX_STACKED); } /** * The query string with `with` set to the stacked slugs (or removed when empty); other params kept. * @param {string} search * @param {string[]} slugs * @returns {string} "" or "?…" */ export function serializeWith(search, slugs) { const params = new URLSearchParams(search); params.delete(PARAM); const rest = params.toString(); const withPart = slugs.length ? `${PARAM}=${slugs.map(encodeURIComponent).join(',')}` : ''; const query = [rest, withPart].filter(Boolean).join('&'); return query ? `?${query}` : ''; } /** A plain left click: modified clicks keep the browser's own behaviour (new tab, download…). @type {(e: MouseEvent) => boolean} */ export const plainClick = (e) => e.button === 0 && !e.defaultPrevented && !(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey); /** Alphabetical by title, ignoring case. @type {(a: { title: string }, b: { title: string }) => number} */ export const byTitle = (a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }); /** * Tracks which stacked note should receive focus when it finishes loading, * so only the most recent add moves focus and scroll. * @returns {{ claim(slug: string): void, take(slug: string): boolean }} */ export function focusIntent() { /** @type {string | null} */ let pending = null; return { claim(slug) { pending = slug; }, take(slug) { if (pending !== slug) return false; pending = null; return true; }, }; } /** * Reject if `promise` hasn't settled within `ms`. * @template T * @param {Promise} promise * @param {number} ms * @returns {Promise} */ export function withTimeout(promise, ms) { /** @type {ReturnType | undefined} */ let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timed out')), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); }