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>
191 lines
8.5 KiB
JavaScript
191 lines
8.5 KiB
JavaScript
/** Checklist rows, goal forms, keyboard input and item collection. */
|
|
export function createChecklist({
|
|
_esc,
|
|
_uid,
|
|
spawnConfetti
|
|
}) {
|
|
function _buildGoalHtml(note, items) {
|
|
const desc = (note?.content || '').toString();
|
|
return `
|
|
<div class="note-form-goal">
|
|
<textarea class="note-form-goal-desc" placeholder="Description (optional)" rows="3">${_esc(desc)}</textarea>
|
|
${_buildChecklistHtml(items)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function _wireGoalForm(form, container) {
|
|
if (!container) return;
|
|
// _wireHashtag is a closure inside _buildForm : out of scope here. Inline
|
|
// the same behavior (type "#foo " in the description → tag added to the
|
|
// form's label input) so editing a goal note doesn't ReferenceError.
|
|
const desc = container.querySelector('.note-form-goal-desc');
|
|
const labelInput = form?.querySelector('.note-form-label');
|
|
if (desc && labelInput) {
|
|
const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/;
|
|
desc.addEventListener('input', () => {
|
|
const m = tagRe.exec(desc.value);
|
|
if (!m) return;
|
|
const tag = m[2];
|
|
// Same dedup-after-stripping fix as the plain note hashtag handler.
|
|
const existing = labelInput.value.trim().split(/\s+/).filter(Boolean);
|
|
const stripped = existing.map(t => t.replace(/^#+/, ''));
|
|
if (!stripped.includes(tag)) {
|
|
existing.push('#' + tag);
|
|
labelInput.value = existing.join(' ');
|
|
labelInput.classList.add('flash-once');
|
|
setTimeout(() => labelInput.classList.remove('flash-once'), 600);
|
|
}
|
|
const cut = desc.value.length - m[0].length + m[1].length;
|
|
desc.value = desc.value.slice(0, cut);
|
|
});
|
|
}
|
|
// Always wire the checklist. The previous gate on a `note-form-goal-fresh`
|
|
// class was dead code : nothing ever set that class, so the editor never
|
|
// hooked up add/drag/Tab.
|
|
_wireChecklist(container);
|
|
}
|
|
|
|
function _buildChecklistHtml(items) {
|
|
let html = '<div class="note-checklist-inputs">';
|
|
for (const item of items) {
|
|
const indent = Math.min(item.indent || 0, 3);
|
|
html += `<div class="note-cl-row${item.done ? ' done' : ''}" draggable="true" data-item-id="${item.id || _uid()}" data-indent="${indent}" style="padding-left:${indent * 16}px">
|
|
<span class="note-cl-grip" title="Drag to reorder">⋮⋮</span>
|
|
<span class="note-cl-dot"></span>
|
|
<input type="text" class="note-cl-text" value="${_esc(item.text)}" placeholder="Item..." />
|
|
<button type="button" class="note-cl-rm">×</button>
|
|
</div>`;
|
|
}
|
|
// `type="button"` matters on mobile : without it some browsers treat
|
|
// bare <button> as form-submit and the click handler never fires inside
|
|
// certain containers. Also bumped tap target so fingers don't miss.
|
|
html += `<button type="button" class="note-cl-add">+ Add</button></div>`;
|
|
return html;
|
|
}
|
|
|
|
function _wireRow(row, container) {
|
|
row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove());
|
|
row.querySelector('.note-cl-dot')?.addEventListener('click', () => {
|
|
const wasDone = row.classList.contains('done');
|
|
row.classList.toggle('done');
|
|
// The custom dot has no native input/change event. Notify the form so
|
|
// mobile edit mode switches its action from Archive back to Update.
|
|
container.dispatchEvent(new Event('input', { bubbles: true }));
|
|
const becameDone = !wasDone; // we just flipped it
|
|
const dot = row.querySelector('.note-cl-dot');
|
|
const dRect = (dot || row).getBoundingClientRect();
|
|
// Small confetti burst on each fresh check so the user gets a
|
|
// "well done" beat per item, not just the grand-finale on all-done.
|
|
if (becameDone) {
|
|
spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16);
|
|
}
|
|
// Bigger burst when the whole list is now done.
|
|
const rows = [...container.querySelectorAll('.note-cl-row')];
|
|
const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0);
|
|
if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) {
|
|
spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60);
|
|
}
|
|
});
|
|
const txt = row.querySelector('.note-cl-text');
|
|
txt?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); }
|
|
else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const cur = parseInt(row.dataset.indent || '0');
|
|
const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1);
|
|
row.dataset.indent = String(next);
|
|
row.style.paddingLeft = (next * 16) + 'px';
|
|
} else if (e.key === 'Backspace' && txt.value === '') {
|
|
e.preventDefault();
|
|
const prev = row.previousElementSibling;
|
|
row.remove();
|
|
if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus();
|
|
}
|
|
});
|
|
// Drag handlers
|
|
row.addEventListener('dragstart', (e) => {
|
|
row.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {}
|
|
});
|
|
row.addEventListener('dragend', () => {
|
|
row.classList.remove('dragging');
|
|
container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after'));
|
|
});
|
|
}
|
|
|
|
function _wireChecklist(container) {
|
|
if (!container) return;
|
|
// Delegate the + Add click off the container so re-renders + mobile
|
|
// touch quirks don't leave the button dead. The previous direct
|
|
// `addEventListener` on the button silently broke on mobile when
|
|
// _wireChecklist ran more than once (or before the button was in DOM).
|
|
if (!container._addDelegated) {
|
|
container._addDelegated = true;
|
|
container.addEventListener('click', (ev) => {
|
|
const addBtn = ev.target.closest('.note-cl-add');
|
|
if (!addBtn || !container.contains(addBtn)) return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
const inputs = container.querySelector('.note-checklist-inputs');
|
|
if (!inputs) return;
|
|
const row = document.createElement('div');
|
|
row.className = 'note-cl-row';
|
|
row.draggable = true;
|
|
row.dataset.itemId = _uid();
|
|
row.dataset.indent = '0';
|
|
row.innerHTML = `<span class="note-cl-grip" title="Drag">⋮⋮</span><span class="note-cl-dot"></span><input type="text" class="note-cl-text" placeholder="Item..." /><button type="button" class="note-cl-rm">×</button>`;
|
|
inputs.insertBefore(row, addBtn);
|
|
row.querySelector('.note-cl-text').focus();
|
|
_wireRow(row, container);
|
|
});
|
|
}
|
|
container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container));
|
|
|
|
// Drag-over handler on the inputs container
|
|
const inputs = container.querySelector('.note-checklist-inputs');
|
|
if (inputs) {
|
|
inputs.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
const dragging = inputs.querySelector('.note-cl-row.dragging');
|
|
if (!dragging) return;
|
|
inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after'));
|
|
const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')];
|
|
const after = rows.find(r => {
|
|
const box = r.getBoundingClientRect();
|
|
return e.clientY < box.top + box.height / 2;
|
|
});
|
|
if (after) {
|
|
after.classList.add('drop-before');
|
|
inputs.insertBefore(dragging, after);
|
|
} else if (rows.length) {
|
|
rows[rows.length - 1].classList.add('drop-after');
|
|
inputs.insertBefore(dragging, container.querySelector('.note-cl-add'));
|
|
}
|
|
});
|
|
inputs.addEventListener('dragleave', (e) => {
|
|
if (!inputs.contains(e.relatedTarget)) {
|
|
inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after'));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function _collectItems(form) {
|
|
const items = [];
|
|
form.querySelectorAll('.note-cl-row').forEach(row => {
|
|
const text = row.querySelector('.note-cl-text')?.value?.trim();
|
|
if (text) items.push({
|
|
id: row.dataset.itemId || _uid(),
|
|
text,
|
|
done: row.classList.contains('done'),
|
|
indent: parseInt(row.dataset.indent || '0'),
|
|
});
|
|
});
|
|
return items;
|
|
}
|
|
|
|
return { _buildGoalHtml, _wireGoalForm, _buildChecklistHtml, _wireRow, _wireChecklist, _collectItems };
|
|
}
|