// ── Context menu ── import { $, esc, api } from './util.js'; import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js'; import { showDialog } from './dialog.js'; import { pushCardEdit, pushCardDelete, pushBatch, pushUndo, snapshotCard } from './undo.js'; import { stripMd } from './markdown.js'; const menu = $('ctx-menu'); const items = $('ctx-items'); const colors = $('ctx-colors'); const buckets = $('ctx-buckets'); const members = $('ctx-members'); const tags = $('ctx-tags'); const relayTenants = $('ctx-relay-tenants'); // Cached relay tenant list. Loaded on first push-relay submenu open // for the session, then reused. Refreshed if the user closes/reopens // the menu without success. let cachedRelayTenants = null; export function hideContextMenu() { menu.classList.add('hidden'); } let ctxCloseTimer = null; menu.addEventListener('mouseleave', () => { ctxCloseTimer = setTimeout(hideContextMenu, 400); }); menu.addEventListener('mouseenter', () => { if (ctxCloseTimer) { clearTimeout(ctxCloseTimer); ctxCloseTimer = null; } }); document.addEventListener('click', hideContextMenu); document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); }); /** * Merge every currently-selected card into a single card. Title strategy: * if the selection involved a bulk gesture (rubber-band) we use the * longest title — there's no meaningful "first" in that case. Otherwise * the first ctrl-clicked card wins (state.selectedIds is a Set, which * preserves insertion order). * * Body format: list of all titles as `# heading` lines at the top, then * each card's body separated by `---`. The winner keeps its color, tags, * bucket, due_date, parent_id; the others are deleted. * * The whole operation is bundled into a single undo entry that recreates * the deleted cards verbatim and restores the winner's original body. */ export async function joinSelectedCards() { const ids = [...state.selectedIds].map(String); const cards = ids .map(id => state.cards.find(c => String(c.id) === id)) .filter(Boolean); if (cards.length < 2) return; // We need bodies for the merge. /api/cards GET excludes body for // performance, so fetch the full rows for the selected ids in one shot. const fullRes = await fetch(`api/cards?ids=${cards.map(c => c.id).join(',')}`).then(r => r.json()); const fullById = new Map(fullRes.map(c => [String(c.id), c])); const full = cards.map(c => fullById.get(String(c.id)) || c); // Pick winner let winner; if (state.selectionWasBulk) { winner = full.reduce((a, b) => (b.title || '').length > (a.title || '').length ? b : a); } else { winner = full[0]; // first in Set iteration order = first selected } const losers = full.filter(c => c.id !== winner.id); // Build merged body: title list, then bodies separated by `---`. // Order: winner first (its body stays at the top after the title list), // then losers in their selection order so the result is deterministic. const ordered = [winner, ...losers]; const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n'); const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean); let mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n'); // Dedupe hashtag mentions across the merged body — first occurrence // wins, subsequent ones get stripped (case-insensitive). Without // this the merged card has every shared tag listed once per source // card, which clutters the body and makes the rendered tag chips // look duplicated to a casual reader (the server's extractTags also // dedupes the tags column, but the body text itself is what the // user actually edits). const seenTags = new Set(); mergedBody = mergedBody.replace(/(^|\s)#([a-zA-Z0-9_/-]+)/g, (match, lead, tag) => { const key = tag.toLowerCase(); if (seenTags.has(key)) return lead; // strip the dupe, keep the leading whitespace seenTags.add(key); return match; }).replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n'); // Snapshot for undo BEFORE we mutate anything const winnerPrev = { title: winner.title, body: winner.body || '' }; const loserSnapshots = losers.map(c => snapshotCard(c)); // Apply: PATCH winner with the merged body, DELETE losers. pushUndo(`join ${ordered.length} cards`, async () => { await api(`api/cards/${winner.id}`, { method: 'PATCH', body: winnerPrev }); for (const snap of loserSnapshots) { const created = await api('api/cards', { method: 'POST', body: { title: snap.title, body: snap.body, color: snap.color, bucket_id: snap.bucket_id, parent_id: snap.parent_id, position: snap.position, }}); if (created?.id && snap.due_date) { await api(`api/cards/${created.id}`, { method: 'PATCH', body: { due_date: snap.due_date } }); } } }); await api(`api/cards/${winner.id}`, { method: 'PATCH', body: { body: mergedBody } }); for (const c of losers) { await api(`api/cards/${c.id}`, { method: 'DELETE' }); } state.selectedIds.clear(); state.selectionWasBulk = false; await window._reloadAll(); } export function showContextMenu(x, y, card) { const children = getChildren(card.id); const isParent = children.length > 0; const isChild = !!card.parent_id; // Multi-select join: only offered when the right-clicked card is part // of a selection of ≥2 cards. The action merges them into one. const selected = state.selectedIds; const inMultiSelect = selected.size >= 2 && selected.has(String(card.id)); items.innerHTML = ` ${inMultiSelect ? `
` : ''} ${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `` : ''} ${typeof window._giteaMakeIssueFromCard === 'function' ? `` : ''} ${isParent ? ` ` : ''} ${isChild ? ` ` : ''} `; // Color sub-panel const grid = $('ctx-color-grid'); grid.innerHTML = CARD_COLORS.map(c => ``).join(''); colors.classList.add('hidden'); // Members sub-panel if (isParent) { members.innerHTML = [card, ...children].map(gc => ` `).join(''); } members.classList.add('hidden'); // Bucket sub-panel buckets.innerHTML = `` + state.buckets.map(b => ``).join(''); buckets.classList.add('hidden'); // Tag sub-panel const cardTags = parseTags(card); const allTags = [...new Set(state.cards.flatMap(c => parseTags(c)))].sort(); const tagInput = $('ctx-tag-input'); const tagList = $('ctx-tag-list'); function renderCtxTags(filter = '') { const f = filter.toLowerCase().replace(/^#/, ''); const shown = f ? allTags.filter(t => t.includes(f)) : allTags; const hasCard = tag => cardTags.includes(tag); tagList.innerHTML = shown.map(t => ` `).join('') + (f && !allTags.includes(f) ? `` : ''); tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); e.preventDefault(); const targetId = card.id; const tag = btn.dataset.ctxTag; // Re-read the current card from state to avoid stale body const fresh = state.cards.find(x => x.id == targetId) || card; const freshTags = parseTags(fresh); const has = freshTags.includes(tag); let newBody = fresh.body || ''; if (has) { newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), ''); } else { newBody = newBody.trimEnd() + ' #' + tag; } const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } }); // Trust the server's response for the new tags string const c = state.cards.find(x => x.id == targetId); if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; } // Update our local working copy card.body = c?.body ?? newBody; cardTags.length = 0; cardTags.push(...parseTags(c || { tags: updated?.tags || '' })); renderCtxTags(tagInput.value); window._renderBoard(); })); tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); e.preventDefault(); const targetId = card.id; const tag = btn.dataset.ctxTagNew; const fresh = state.cards.find(x => x.id == targetId) || card; const newBody = (fresh.body || '').trimEnd() + ' #' + tag; const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } }); const c = state.cards.find(x => x.id == targetId); if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; } card.body = c?.body ?? newBody; cardTags.length = 0; cardTags.push(...parseTags(c || { tags: updated?.tags || '' })); if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); } tagInput.value = ''; renderCtxTags(''); window._renderBoard(); })); } renderCtxTags(); tagInput.value = ''; tagInput.addEventListener('input', () => renderCtxTags(tagInput.value)); tagInput.addEventListener('keydown', async e => { if (e.key !== 'Enter') return; e.preventDefault(); const raw = tagInput.value.replace(/^#/, '').trim().toLowerCase(); if (!raw) return; const tag = raw; if (!cardTags.includes(tag)) { const newBody = (card.body || '').trimEnd() + ' #' + tag; card.body = newBody; cardTags.push(tag); if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); } await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } }); const c = state.cards.find(x => x.id == card.id); if (c) { c.body = newBody; c.tags = cardTags.join(','); } window._renderBoard(); } tagInput.value = ''; renderCtxTags(''); }); tags.classList.add('hidden'); menu.style.left = '0px'; menu.style.top = '0px'; menu.classList.remove('hidden'); const menuRect = menu.getBoundingClientRect(); menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px'; menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px'; const toggle = (panel) => { [colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); }; // Actions items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); const a = btn.dataset.action; if (a === 'join') { hideContextMenu(); await joinSelectedCards(); return; } if (a === 'open') { hideContextMenu(); window._openDetail(card.id); } else if (a === 'edit') { hideContextMenu(); window._openModal(card); } else if (a === 'duplicate') { hideContextMenu(); const hasDate = /^\d{4}-\d{2}-\d{2}/.test(card.title); if (hasDate) { const curDate = card.title.substring(0, 10); const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'Duplicate' }); if (!newDate) return; const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim()); const dup1 = await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } }); if (dup1?.id) state.recentlyCreatedIds?.add(String(dup1.id)); } else { const dup2 = await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } }); if (dup2?.id) state.recentlyCreatedIds?.add(String(dup2.id)); } await window._reloadAll(); } else if (a === 'move-date') { hideContextMenu(); const curDate = card.title.substring(0, 10); const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'Move' }); if (!newDate || newDate === curDate) return; const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim()); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { title: newTitle } }); await window._reloadAll(); if (card.caldav_uid) window._triggerSync?.(); } else if (a === 'due-done') { hideContextMenu(); const isDone = parseTags(card).includes('done'); let newBody; if (isDone) { newBody = (card.body || '').replace(/(^|\s)#done\b/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n'); } else { newBody = (card.body || '').trimEnd() + ((card.body || '').trimEnd() ? ' ' : '') + '#done'; } const patch = { body: newBody }; if (!isDone && card.due_date) patch.due_date = null; await api(`api/cards/${card.id}`, { method: 'PATCH', body: patch }); await window._reloadAll(); } else if (a === 'due-date') { hideContextMenu(); const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'Update' : 'Set' }); if (newDate === null) return; await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } }); await window._reloadAll(); } else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; } else if (a === 'make-ticket') { hideContextMenu(); // Gitea plugin pre-fills the create-issue form from this // card and leaves it open — the user reviews and submits. await window._giteaMakeIssueFromCard?.(card); } else if (a === 'copy-text') { hideContextMenu(); let body = card.body || ''; // Drop fenced code blocks (incl. slide-image), then markdown structure, then hashtags body = body.replace(/```[\s\S]*?```/g, ''); body = stripMd(body); body = body.replace(/(^|\s)#[\w/-]+/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{2,}/g, '\n\n').trim(); const text = (card.title || '') + '\n\n' + body; try { await navigator.clipboard.writeText(text); } catch {} } else if (a === 'push-relay') { await openRelayTenantSubmenu(card); } else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); } else if (a === 'color') toggle(colors); else if (a === 'bucket') toggle(buckets); else if (a === 'tags') toggle(tags); else if (a === 'group-members') toggle(members); else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); } else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } } else if (a === 'dissolve') { hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Dissolve group "${card.title}"?`, danger: true, okLabel: 'Dissolve' }); if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); } } else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); } else if (a === 'delete') { hideContextMenu(); const children = getChildren(card.id); const isGroup = children.length > 0; const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0'; if (isGroup) { // Always confirm when deleting a group — list every child // so the user knows exactly what they're losing. const childList = children.map(c => `• ${c.title || '(untitled)'}`).join('\n'); const msg = `Delete group "${card.title}" and all ${children.length} card${children.length > 1 ? 's' : ''} inside?\n\n${childList}`; const ok = await showDialog({ title: 'delete_group', message: msg, danger: true, okLabel: `Delete all ${children.length + 1}` }); if (!ok) return; } else if (needConfirm) { const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' }); if (!ok) return; } // Snapshot everything for undo BEFORE deleting const allCards = isGroup ? [card, ...children] : [card]; const snapshots = allCards.map(c => snapshotCard(c)); const wasSync = allCards.some(c => !!c.caldav_uid); pushUndo(`delete ${isGroup ? 'group' : 'card'} "${card.title}"`, async () => { // Recreate parent first so children can reference it const parent = await api('api/cards', { method: 'POST', body: { title: snapshots[0].title, body: snapshots[0].body, color: snapshots[0].color, bucket_id: snapshots[0].bucket_id, position: snapshots[0].position, }}); if (parent?.id && snapshots[0].due_date) { await api(`api/cards/${parent.id}`, { method: 'PATCH', body: { due_date: snapshots[0].due_date } }); } // Recreate children pointing at the new parent for (let i = 1; i < snapshots.length; i++) { const s = snapshots[i]; const child = await api('api/cards', { method: 'POST', body: { title: s.title, body: s.body, color: s.color, bucket_id: s.bucket_id, parent_id: parent?.id, position: s.position, }}); if (child?.id && s.due_date) { await api(`api/cards/${child.id}`, { method: 'PATCH', body: { due_date: s.due_date } }); } } }); // Animate out, then delete children first, then parent const { animateCardRemoval } = await import('./board.js'); await animateCardRemoval(allCards.map(c => c.id)); for (const c of children) await api(`api/cards/${c.id}`, { method: 'DELETE' }); await api(`api/cards/${card.id}`, { method: 'DELETE' }); await window._reloadAll(); if (wasSync) window._triggerSync?.(); } })); grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); })); buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id }); window._markCardsMoving?.([card.id]); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await window._reloadAll(); })); members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); })); members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); })); } // ── Push to relay submenu (cross-tenant recipient picker) ── async function openRelayTenantSubmenu(card) { // Show submenu immediately with a loading state, then fetch the // tenant list (cached after first successful load). const toggle = (panel) => { [colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.remove('hidden'); }; toggle(relayTenants); if (!cachedRelayTenants) { relayTenants.innerHTML = '