mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 11:22:21 +02:00
* refactor(notes): decompose Notes into feature-owned modules * fix(notes): avoid HTML interpolation in reminder picker * docs(notes): drop checkpoint archives, session logs and publication drafts The N0-N6 tarballs and raw pytest/node logs are not read by any script or test, and the logs carry absolute home-directory paths. Once squashed into dev they would stay in history for good. notes-pr.md and notes-issue.md were the drafts used to open #6712 and #6713, not documentation. notes-validation.md keeps the counts, the checkpoint hashes and implementation.sha256 as the record; local interpreter paths and the local Docker image name are replaced with portable placeholders. * fix(notes): keep the state store raw and stop masonry re-observing after close - state: writes built from views (filter, spread, splice) stored nested read-only views in the store, so structuredClone on a stored note threw DataCloneError. Store writes now unwrap nested views. - layout: a save that resolves after closePanel re-renders the leaving pane and re-created the masonry ResizeObserver that teardown had disposed. Skip creating it once Notes is closed. - index.js: drop 30 imported or declared names that were never used and group the imports at the top. Import order is unchanged. - Revert unrelated punctuation edits in .gitattributes and keep the header comment and docstring first in compare/vote.js and test_notes_dom_xss_helpers.py. Both fixes have a node test that fails on the previous head. * test(notes): make script rejection assertion case-insensitive Address CodeQL js/bad-tag-filter alert #1150 in test-only assertion and refresh Notes evidence manifest checksum. --------- Co-authored-by: Léo <leograndcontact@gmail.com>
338 lines
13 KiB
JavaScript
338 lines
13 KiB
JavaScript
/** Card taps, pin/color controls, menus and archive animation. */
|
|
export function createCardInteractions({
|
|
cardOperations,
|
|
COLORS,
|
|
_archiveNoteById,
|
|
_bindLongPressDrag,
|
|
_customColorStyle,
|
|
_dotBg,
|
|
_dotIsActive,
|
|
_editNote,
|
|
_isBgImage,
|
|
_isNotesMobileMode,
|
|
_openMobileFullscreenEdit,
|
|
_openNoteCornerMenu,
|
|
_pickCustomBgImage,
|
|
_renderNotes,
|
|
_setReminderCardGlow,
|
|
_smoothScrollToNoteCard,
|
|
_updateBulkBar,
|
|
_wireCardChecklist,
|
|
_wireCardReorder,
|
|
app,
|
|
spawnConfetti,
|
|
state,
|
|
uiModule
|
|
}) {
|
|
function _bindCardEvents(body) {
|
|
// Skip completed leading rows so the first unfinished checklist item is
|
|
// visible when a note preview has its own scroll area.
|
|
requestAnimationFrame(() => {
|
|
body.querySelectorAll('.note-checklist-preview').forEach(preview => {
|
|
const firstPending = preview.querySelector('.note-checkbox:not(.done)');
|
|
if (firstPending) preview.scrollTop = Math.max(0, firstPending.offsetTop - 4);
|
|
});
|
|
});
|
|
const tapToEditOrSelect = (cardEl) => {
|
|
const id = cardEl.dataset.noteId;
|
|
// A long-press enters select mode and re-renders the card. Ignore the
|
|
// synthetic click that mobile browsers emit when that same touch ends.
|
|
if (state.suppressNextNoteTapId === id) {
|
|
app.setSuppressNextNoteTapId(null);
|
|
return;
|
|
}
|
|
if (state.selectMode) {
|
|
const cb = cardEl.querySelector('.note-card-cb');
|
|
if (cb) {
|
|
cb.checked = !cb.checked;
|
|
cb.dispatchEvent(new Event('change'));
|
|
}
|
|
} else if (_isNotesMobileMode()) {
|
|
// Mobile: open the per-note fullscreen edit overlay instead of the
|
|
// in-place form. Tiles on mobile are read-only previews.
|
|
_openMobileFullscreenEdit(id, cardEl);
|
|
} else {
|
|
_editNote(id);
|
|
}
|
|
};
|
|
// Mobile: long-press anywhere on a note card → enter Select mode and
|
|
// select that card. Cancelled by movement (so it doesn't interfere with
|
|
// vertical scrolling) or by lifting the finger before the timer fires.
|
|
if (_isNotesMobileMode()) {
|
|
body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card));
|
|
}
|
|
body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => {
|
|
card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true);
|
|
});
|
|
// Click title : edit, or toggle select in select mode
|
|
body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => {
|
|
el.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
_smoothScrollToNoteCard(el.closest('.note-card'));
|
|
tapToEditOrSelect(el.closest('.note-card'));
|
|
});
|
|
});
|
|
// Click content : edit, or toggle select in select mode
|
|
body.querySelectorAll('.note-content-preview').forEach(el => {
|
|
el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); });
|
|
});
|
|
// A saved drawing is the note's primary content. Open its editor directly
|
|
// when the preview is clicked, matching title/content preview behavior.
|
|
body.querySelectorAll('.note-card-drawing .note-card-image').forEach(el => {
|
|
el.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
tapToEditOrSelect(el.closest('.note-card'));
|
|
});
|
|
});
|
|
// Click empty area of checklist preview (not on checkbox/X) : edit
|
|
body.querySelectorAll('.note-checklist-preview').forEach(el => {
|
|
el.addEventListener('wheel', (e) => {
|
|
const atTop = el.scrollTop <= 0;
|
|
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
|
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
|
|
const pane = el.closest('.notes-pane-body');
|
|
if (pane) {
|
|
pane.scrollTop += e.deltaY;
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
}, { passive: false });
|
|
el.addEventListener('click', (e) => {
|
|
if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return;
|
|
e.stopPropagation();
|
|
tapToEditOrSelect(el.closest('.note-card'));
|
|
});
|
|
});
|
|
// Clicking todo item text now toggles its checkbox : let the click bubble
|
|
// up to the parent .note-checkbox row handler. To open the editor, the
|
|
// user clicks the pencil corner.
|
|
// (No-op block kept as a marker : removing the listener entirely means
|
|
// clicks naturally bubble to the row toggle below.)
|
|
// In select mode, clicking anywhere on the card toggles selection
|
|
if (state.selectMode) {
|
|
body.querySelectorAll('.note-card').forEach(card => {
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
|
|
e.stopPropagation();
|
|
tapToEditOrSelect(card);
|
|
});
|
|
});
|
|
}
|
|
// Mobile, non-select: tapping anywhere on the card body (not on an
|
|
// interactive child : buttons, pin, checkbox, color dot, reminder pill,
|
|
// agent tag, links) opens the fullscreen editor. Previously only the
|
|
// title / content preview triggered edit, so padding + empty gutters were
|
|
// dead zones that felt broken on mobile.
|
|
if (_isNotesMobileMode() && !state.selectMode) {
|
|
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
|
|
body.querySelectorAll('.note-card').forEach(card => {
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest(_INTERACTIVE)) return;
|
|
e.stopPropagation();
|
|
tapToEditOrSelect(card);
|
|
});
|
|
});
|
|
}
|
|
// Multi-select checkbox (only in select mode)
|
|
body.querySelectorAll('.note-card-cb').forEach(cb => {
|
|
cb.addEventListener('click', (e) => e.stopPropagation());
|
|
cb.addEventListener('change', () => {
|
|
const id = cb.dataset.noteId;
|
|
if (cb.checked) app.selectId(id);
|
|
else app.deselectId(id);
|
|
cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked);
|
|
_updateBulkBar();
|
|
});
|
|
});
|
|
// Pin toggle (optimistic)
|
|
body.querySelectorAll('.note-card-pin').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
cardOperations.togglePin(id);
|
|
});
|
|
});
|
|
// Color picker
|
|
const _applyCardColor = async (card, id, newColor) => {
|
|
const isBg = _isBgImage(newColor);
|
|
COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); });
|
|
if (newColor && !isBg) card.classList.add('note-color-' + newColor);
|
|
if (isBg) card.setAttribute('style', _customColorStyle(newColor));
|
|
else card.removeAttribute('style');
|
|
card.querySelectorAll('.note-card-color-dot').forEach(d => {
|
|
d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor));
|
|
d.style.background = _dotBg(d.dataset.color, newColor);
|
|
});
|
|
await cardOperations.updateColor(id, newColor);
|
|
};
|
|
body.querySelectorAll('.note-card-color-dot').forEach(dot => {
|
|
dot.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const card = dot.closest('.note-card');
|
|
const id = card.dataset.noteId;
|
|
if (dot.dataset.color === 'custom') {
|
|
_pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); });
|
|
return;
|
|
}
|
|
_applyCardColor(card, id, dot.dataset.color);
|
|
});
|
|
});
|
|
// Plain pencil corner → open editor. The unarchive corner shares the
|
|
// .note-card-edit-corner class for styling, so :not() keeps the edit
|
|
// handler off it.
|
|
body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
if (id) _editNote(id);
|
|
});
|
|
});
|
|
// Copy corner : bottom-right, just left of the Done check. Shared with
|
|
// the Ctrl/Cmd+C shortcut wired further down so both code paths run the
|
|
// same serializer + feedback flash.
|
|
// ⋯ corner menu : Copy + Agent (solve-this-todo).
|
|
body.querySelectorAll('.note-card-corner-menu').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
_openNoteCornerMenu(btn);
|
|
});
|
|
});
|
|
body.querySelectorAll('.note-card-label-chip').forEach(chip => {
|
|
chip.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const label = chip.dataset.noteLabelFilter;
|
|
if (!label) return;
|
|
if (state.activeLabel === label && state.activeFilter === null) {
|
|
app.setActiveLabel(null);
|
|
} else {
|
|
app.setActiveFilter(null);
|
|
app.setActiveLabel(label);
|
|
}
|
|
_renderNotes();
|
|
});
|
|
});
|
|
// Done (✓) at bottom-right : only visible on hover for active notes.
|
|
body.querySelectorAll('.note-card-done').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
const card = btn.closest('.note-card');
|
|
const idx = state.notes.findIndex(n => n.id === id);
|
|
if (idx < 0) return;
|
|
// Celebrate completion : same confetti shower the bulk-archive uses.
|
|
if (card) {
|
|
const r = card.getBoundingClientRect();
|
|
spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
|
|
}
|
|
const finish = cardOperations.stageArchive(id);
|
|
if (!finish) return;
|
|
if (card) {
|
|
card.classList.add('note-card-sliding-out');
|
|
let done = false;
|
|
const once = () => { if (done) return; done = true; finish(); };
|
|
card.addEventListener('transitionend', once, { once: true });
|
|
setTimeout(once, 400);
|
|
} else {
|
|
finish();
|
|
}
|
|
});
|
|
});
|
|
// Unarchive corner : only visible in archive view.
|
|
body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
cardOperations.unarchive(id);
|
|
});
|
|
});
|
|
// Trash corner : archive view only. Permanent delete, no confirmation.
|
|
body.querySelectorAll('.note-card-corner-trash').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
cardOperations.deleteOptimistically(id, true);
|
|
});
|
|
});
|
|
|
|
body.querySelectorAll('.note-card-archive').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
if (!id) return;
|
|
const card = btn.closest('.note-card');
|
|
let done = false;
|
|
const finishRemove = () => {
|
|
if (done) return;
|
|
done = true;
|
|
_archiveNoteById(id, { card, celebrate: true });
|
|
};
|
|
if (card) {
|
|
card.classList.add('note-card-sliding-out');
|
|
card.addEventListener('transitionend', finishRemove, { once: true });
|
|
setTimeout(finishRemove, 400);
|
|
} else {
|
|
finishRemove();
|
|
}
|
|
});
|
|
});
|
|
// Unarchive (optimistic) : only present in archive view
|
|
body.querySelectorAll('.note-card-unarchive').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
cardOperations.unarchive(id);
|
|
});
|
|
});
|
|
// Delete (optimistic)
|
|
body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.noteId;
|
|
cardOperations.deleteOptimistically(id);
|
|
});
|
|
});
|
|
// Copy entire checklist (title + items, markdown-style)
|
|
body.querySelectorAll('.note-card-copy').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
const id = btn.dataset.noteId;
|
|
const note = state.notes.find(n => n.id === id);
|
|
if (!note) return;
|
|
const lines = [];
|
|
if (note.title) lines.push(note.title);
|
|
if (note.content) lines.push(note.content);
|
|
if (lines.length) lines.push('');
|
|
for (const it of (note.items || [])) {
|
|
if (!it || !(it.text || '').trim()) continue;
|
|
lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`);
|
|
}
|
|
const text = lines.join('\n').trim();
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`);
|
|
} catch {
|
|
// Fallback for browsers blocking the async API
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand('copy'); uiModule.showToast?.('Copied'); }
|
|
catch { uiModule.showError?.('Copy failed'); }
|
|
ta.remove();
|
|
}
|
|
});
|
|
});
|
|
|
|
_wireCardChecklist(body);
|
|
_wireCardReorder(body);
|
|
|
|
}
|
|
|
|
return { _bindCardEvents };
|
|
}
|