From ff3e0e8736e79c711d4a0870e972120ff0b65480 Mon Sep 17 00:00:00 2001
From: th
Date: Sat, 11 Apr 2026 18:50:01 +0200
Subject: [PATCH] context-menu: add Copy text action and always show Mark done
- New "Copy text" entry next to "Save .md": copies title + blank line +
body to the clipboard, with fenced code blocks (incl. slide-image),
markdown images / links / formatting, and inline #tags stripped.
- "Mark done" is now visible on every card (used to require a due_date
to even show up). On a card already tagged #done it flips to "Unmark
done" and removes the tag. The original due-date-clearing behavior is
preserved when transitioning a due card to done.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
public/js/modules/context-menu.js | 29 ++++++++++++++++++++++++-----
1 file changed, 24 insertions(+), 5 deletions(-)
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index 8058ee8..ac99d40 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -4,6 +4,7 @@ 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, snapshotCard } from './undo.js';
+import { stripMd } from './markdown.js';
const menu = $('ctx-menu');
const items = $('ctx-items');
@@ -42,8 +43,9 @@ export function showContextMenu(x, y, card) {
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `` : ''}
- ${card.due_date ? `` : ''}
+
+
@@ -202,9 +204,16 @@ export function showContextMenu(x, y, card) {
}
else if (a === 'due-done') {
hideContextMenu();
- // Clear due date and mark as done
- const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done';
- await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } });
+ 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') {
@@ -215,6 +224,16 @@ export function showContextMenu(x, y, card) {
await window._reloadAll();
}
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
+ 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);
}
@@ -284,7 +303,7 @@ function renderRelayTenants(card) {
const list = cachedRelayTenants?.tenants || [];
const self = cachedRelayTenants?.self || '';
if (!list.length) {
- relayTenants.innerHTML = ' no tenants ';
+ relayTenants.innerHTML = 'no tenants ';
return;
}
// Self first, then others alphabetical
|