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:
Alexandre Teixeira
2026-10-15 09:29:41 +01:00
committed by GitHub
co-authored by Léo
parent 9f8eae72fd
commit f1a47f3812
121 changed files with 15318 additions and 5544 deletions
@@ -0,0 +1,214 @@
/** Mobile card long press, placeholder drag and reorder persistence. */
export function createMobileReorder({
lifecycle,
_applyMasonry,
_enterSelectMode,
commitMobileOrder,
app,
}) {
function _bindLongPressDrag(card) {
let pressTimer = null;
let startX = 0, startY = 0;
let armed = false;
const CANCEL_PX = 8;
const HOLD_MS = 450;
card.addEventListener('touchstart', (e) => {
// Don't fight scroll on touchpoints over real interactive children
// (in mobile-mode they're CSS-hidden anyway, but be defensive).
if (e.target.closest('button, input, a, .note-form')) return;
if (e.touches.length !== 1) return;
armed = true;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
pressTimer = setTimeout(() => {
if (!armed) return;
try { navigator.vibrate?.(15); } catch {}
app.setSuppressNextNoteTapId(card.dataset.noteId);
_enterSelectMode(card.dataset.noteId);
}, HOLD_MS);
}, { passive: true });
card.addEventListener('touchmove', (e) => {
if (!armed) return;
const t = e.touches[0];
if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) {
armed = false;
if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
}
}, { passive: true });
const cancel = () => {
armed = false;
if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
};
lifecycle.onDispose(card, cancel);
card.addEventListener('touchend', cancel, { passive: true });
card.addEventListener('touchcancel', cancel, { passive: true });
}
let _dragState = null;
let _docDragHandlersBound = false;
function _enterDragMode(initialCard, initialTouch) {
document.body.classList.add('notes-drag-mode');
document.querySelectorAll('.note-card').forEach(_setupDragForCard);
if (!_docDragHandlersBound) {
lifecycle.listen(document.getElementById('notes-pane'), document, 'touchmove', _onDocTouchMove, { passive: false });
lifecycle.listen(document.getElementById('notes-pane'), document, 'touchend', _onDocTouchEnd, { passive: true });
lifecycle.listen(document.getElementById('notes-pane'), document, 'touchcancel', _onDocTouchEnd, { passive: true });
_docDragHandlersBound = true;
}
// Auto-grab the card the user long-pressed : they're already holding
// their finger on it, so begin the drag straight away. Releasing
// (touchend) commits the reorder AND exits drag mode in one motion.
if (initialCard && initialTouch) {
_beginGrab(initialCard, initialTouch);
}
}
function _exitDragMode() {
document.body.classList.remove('notes-drag-mode');
if (_dragState) {
// Defensive: if exit fires while a drag is in flight, snap the card back.
_onDocTouchEnd();
}
// Leave _docDragHandlersBound true so re-entering drag mode reuses them.
}
function _setupDragForCard(card) {
if (card.dataset.dragBound === '1') return;
card.dataset.dragBound = '1';
card.addEventListener('touchstart', (e) => {
if (!document.body.classList.contains('notes-drag-mode')) return;
if (e.touches.length !== 1) return;
if (_dragState) return;
e.preventDefault();
e.stopPropagation();
_beginGrab(card, e.touches[0]);
}, { passive: false });
}
function _beginGrab(card, touch) {
const rect = card.getBoundingClientRect();
const prevStyle = card.getAttribute('style') || '';
// Placeholder fills the card's old slot so the grid layout doesn't reflow.
const placeholder = document.createElement('div');
placeholder.className = 'note-card-placeholder';
placeholder.style.width = rect.width + 'px';
placeholder.style.height = rect.height + 'px';
placeholder.style.margin = getComputedStyle(card).margin;
if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd;
const grid = card.parentNode;
grid.insertBefore(placeholder, card);
// Detach the card visually : fixed-position, anchored to the finger.
card.classList.add('note-card-dragging');
card.style.position = 'fixed';
card.style.left = rect.left + 'px';
card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px';
card.style.margin = '0';
card.style.zIndex = '10001';
// pointer-events:none so elementFromPoint sees the card BENEATH the finger
card.style.pointerEvents = 'none';
_dragState = {
card, placeholder, grid, prevStyle,
grabOffsetX: touch.clientX - rect.left,
grabOffsetY: touch.clientY - rect.top,
};
try { navigator.vibrate?.(8); } catch {}
}
function _onDocTouchMove(e) {
if (!_dragState) return;
if (e.touches.length !== 1) return;
e.preventDefault();
const touch = e.touches[0];
const { card, placeholder, grid } = _dragState;
card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px';
const quickAdd = grid.querySelector('.notes-quick-add');
const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top;
const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8);
const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY));
card.style.top = nextTop + 'px';
const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY));
const under = document.elementFromPoint(touch.clientX, hitY);
const target = under && under.closest
? under.closest('.note-card:not(.note-card-dragging)')
: null;
if (!target || target === card) return;
if (target.parentNode !== grid) return;
// Move the PLACEHOLDER (not the card) above or below the target depending
// on which half of the target the finger is in. This is the hysteresis
// that stops the oscillation : once the placeholder moves past a card,
// the cursor has to cross THAT card's midpoint in the other direction
// to swap back.
const tRect = target.getBoundingClientRect();
const targetMidY = tRect.top + tRect.height / 2;
if (touch.clientY < targetMidY) {
if (placeholder.nextElementSibling !== target) {
grid.insertBefore(placeholder, target);
}
} else {
if (target.nextElementSibling !== placeholder) {
grid.insertBefore(placeholder, target.nextElementSibling);
}
}
}
function _onDocTouchEnd() {
if (!_dragState) return;
const { card, placeholder, grid, prevStyle } = _dragState;
_dragState = null;
// Animate the card from its current fixed position to where the
// placeholder sits, then re-parent and clear inline styles. Drag
// mode auto-exits once the snap finishes : release = done.
const phRect = placeholder.getBoundingClientRect();
card.style.transition = 'left 0.2s ease, top 0.2s ease';
card.style.left = phRect.left + 'px';
card.style.top = phRect.top + 'px';
setTimeout(() => {
placeholder.parentNode.insertBefore(card, placeholder);
placeholder.remove();
card.classList.remove('note-card-dragging');
// Restore the card's pre-drag inline styles. Mobile masonry stores
// grid-row-end inline, and custom backgrounds use inline style too; wiping
// cssText made dropped cards collapse into neighboring notes in grid view.
if (prevStyle) card.setAttribute('style', prevStyle);
else card.removeAttribute('style');
_applyMasonry(grid);
_commitNoteReorder();
// One drag, one exit : release ends the rearrange session entirely.
if (document.body.classList.contains('notes-drag-mode')) {
document.body.classList.remove('notes-drag-mode');
}
}, 210);
}
async function _commitNoteReorder() {
const grid = document.querySelector('#notes-pane .notes-pane-body');
if (!grid) return;
const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean);
if (!ids.length) return;
await commitMobileOrder(ids);
}
function dispose() {
if (_dragState) {
const { card, placeholder, prevStyle } = _dragState;
_dragState = null;
placeholder.parentNode?.insertBefore(card, placeholder);
placeholder.remove();
card.classList.remove('note-card-dragging');
if (prevStyle) card.setAttribute('style', prevStyle);
else card.removeAttribute('style');
}
document.body.classList.remove('notes-drag-mode');
_docDragHandlersBound = false;
}
return { dispose, _bindLongPressDrag, _enterDragMode, _exitDragMode, _setupDragForCard, _beginGrab, _onDocTouchMove, _onDocTouchEnd, _commitNoteReorder };
}