Files
odysseus/frontend/src/features/notes/ui/cardInteractions.js
T
Alexandre TeixeiraandLéo f1a47f3812 refactor(notes): decompose Notes into feature-owned modules (#6713)
* 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>
2026-10-15 09:29:41 +01:00

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 };
}