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
+123
View File
@@ -0,0 +1,123 @@
/** Card reflow animation and the owned masonry resize observer. */
export function createLayout({ isOpen = () => true } = {}) {
function _captureCardPositions() {
const body = document.querySelector('#notes-pane .notes-pane-body');
if (!body) return null;
const positions = new Map();
body.querySelectorAll('.note-card').forEach(card => {
const id = card.dataset.noteId;
if (id) positions.set(id, card.getBoundingClientRect());
});
return positions;
}
function _animateReflow(prevPositions) {
if (!prevPositions || !prevPositions.size) return;
const body = document.querySelector('#notes-pane .notes-pane-body');
if (!body || !body.isConnected) return;
body.querySelectorAll('.note-card').forEach(card => {
const id = card.dataset.noteId;
const prev = prevPositions.get(id);
if (!prev) return;
const next = card.getBoundingClientRect();
const dx = prev.left - next.left;
const dy = prev.top - next.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
// Invert: jump back to old position
card.style.transition = 'none';
card.style.transform = `translate(${dx}px, ${dy}px)`;
// Play: animate to 0
requestAnimationFrame(() => {
card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)';
card.style.transform = '';
card.addEventListener('transitionend', () => {
card.style.transition = '';
}, { once: true });
});
});
}
function _applyMasonry(body) {
if (!body || !body.isConnected) return;
const pane = body.closest('.notes-pane');
const isGrid = pane?.classList.contains('notes-view-grid');
const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches;
// Tear down any prior observer (defensive : _renderNotes wipes body.innerHTML).
if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; }
// A save that resolves after close re-renders the leaving pane; don't
// re-create the observer that panel teardown already disposed.
if (!isOpen()) return;
if (!isGrid) {
// Clear any leftover inline spans so list view lays out normally.
body.querySelectorAll('.note-card, .notes-label-toggle, .notes-labels-bar, .notes-quick-add, .note-form, .doclib-chip-scroll-frame:has(> .notes-labels-bar)').forEach(c => { c.style.gridRowEnd = ''; });
return;
}
const ROW_PX = 4;
const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX));
const recomputeFullRows = () => {
const quickAdd = body.querySelector('.notes-quick-add');
const labelsToggle = body.querySelector('.notes-label-toggle');
const labelsBar = body.querySelector('.notes-labels-bar');
const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame')
? labelsBar.parentElement
: labelsBar;
if (labelsToggle && getComputedStyle(labelsToggle).display !== 'none') {
labelsToggle.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsToggle.scrollHeight))}`;
}
if (labelsLayout && getComputedStyle(labelsLayout).display !== 'none') {
const shave = isMobileGrid ? 4 : 0;
labelsLayout.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsLayout.scrollHeight) - shave)}`;
}
if (quickAdd) {
const shave = isMobileGrid ? 4 : 0;
quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`;
}
body.querySelectorAll('.note-form').forEach(form => {
form.style.gridColumn = '1 / -1';
const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw');
const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1;
const renderedHeight = form.getBoundingClientRect?.().height || 0;
const drawReserve = isDrawForm && isMobileGrid ? 12 : 12;
const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve;
form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`;
});
};
const recompute = (card) => {
// scrollHeight returns the natural content height : card.getBoundingClientRect()
// would return the grid cell height (collapsed to 4px until the span is set,
// which is the value we're trying to compute).
const h = card.scrollHeight + (isMobileGrid ? 6 : 8);
if (h <= 0) return;
card.style.gridRowEnd = `span ${spanForHeight(h)}`;
};
recomputeFullRows();
body.querySelectorAll('.note-card').forEach(recompute);
// Watch masonry participants : content can grow (image load, todo edits,
// quick-add/form expansion), and stale spans are what cause visual merging.
if ('ResizeObserver' in window) {
_masonryObserver = new ResizeObserver(entries => {
let fullRowsChanged = false;
for (const e of entries) {
if (e.target.classList.contains('note-card')) recompute(e.target);
else fullRowsChanged = true;
}
if (fullRowsChanged) recomputeFullRows();
});
body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c));
body.querySelectorAll('.notes-label-toggle, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c));
const labelsBar = body.querySelector('.notes-labels-bar');
const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame')
? labelsBar.parentElement
: labelsBar;
if (labelsLayout) _masonryObserver.observe(labelsLayout);
}
}
let _masonryObserver = null;
function dispose() {
_masonryObserver?.disconnect();
_masonryObserver = null;
}
return { dispose, _captureCardPositions, _animateReflow, _applyMasonry };
}