mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
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>
This commit is contained in:
co-authored by
Léo
parent
9f8eae72fd
commit
f1a47f3812
@@ -0,0 +1,337 @@
|
||||
/** 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user