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,170 @@
|
||||
/** Quick-add expansion, draft restoration and opening inline editors. */
|
||||
export function createQuickAdd({
|
||||
_applyDraftToNote,
|
||||
_applyMasonry,
|
||||
_buildForm,
|
||||
app,
|
||||
openPanel,
|
||||
state,
|
||||
uiModule
|
||||
}) {
|
||||
function _renderQuickAdd(body) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'notes-quick-add';
|
||||
// 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw :
|
||||
// drawing happens in the expanded form). The pill that's active steers
|
||||
// both the placeholder and the type the form opens in.
|
||||
wrap.innerHTML = `
|
||||
<div class="notes-quick-type-seg is-todo" role="group" aria-label="New item type">
|
||||
<button type="button" class="notes-quick-type-pill" data-type="note" aria-label="Note" aria-pressed="false" title="Note">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
|
||||
</button>
|
||||
<button type="button" class="notes-quick-type-pill active" data-type="todo" aria-label="To-do" aria-pressed="true" title="To-do">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" class="notes-quick-input" placeholder="Add a to-do…" />
|
||||
<button class="notes-quick-icon" data-action="photo" title="Attach photo">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
`;
|
||||
body.appendChild(wrap);
|
||||
|
||||
const input = wrap.querySelector('.notes-quick-input');
|
||||
const seg = wrap.querySelector('.notes-quick-type-seg');
|
||||
let currentType = 'todo';
|
||||
const setType = (t) => {
|
||||
if (t !== 'note' && t !== 'todo') return;
|
||||
currentType = t;
|
||||
seg.classList.toggle('is-todo', t === 'todo');
|
||||
seg.classList.toggle('is-note', t === 'note');
|
||||
seg.querySelectorAll('.notes-quick-type-pill').forEach(p => {
|
||||
const on = p.dataset.type === t;
|
||||
p.classList.toggle('active', on);
|
||||
p.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…';
|
||||
};
|
||||
seg.querySelectorAll('.notes-quick-type-pill').forEach(p => {
|
||||
p.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
setType(p.dataset.type);
|
||||
});
|
||||
});
|
||||
// Click input or type → expand to full form
|
||||
const expandToForm = (initialType = 'note', initialText = '') => {
|
||||
app.setEditingId('__new__');
|
||||
const form = _buildForm({ note_type: initialType });
|
||||
form.classList.add('note-form-new');
|
||||
if (initialText) {
|
||||
const titleEl = form.querySelector('.note-form-title');
|
||||
if (titleEl) titleEl.value = initialText;
|
||||
}
|
||||
const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid')
|
||||
&& window.matchMedia('(max-width: 768px)').matches;
|
||||
if (mobileGrid) {
|
||||
form.style.gridColumn = '1 / -1';
|
||||
form.style.gridRowEnd = 'span 64';
|
||||
}
|
||||
wrap.replaceWith(form);
|
||||
_applyMasonry(body);
|
||||
requestAnimationFrame(() => _applyMasonry(body));
|
||||
const titleEl = form.querySelector('.note-form-title');
|
||||
if (titleEl) {
|
||||
titleEl.focus();
|
||||
// Move caret to end
|
||||
titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length);
|
||||
}
|
||||
};
|
||||
// Expand only on real intent: a click directly on the input, or actual
|
||||
// typing. Focus alone : including focus stolen from a missed nearby
|
||||
// click : no longer creates an empty form.
|
||||
input.addEventListener('click', () => expandToForm(currentType, input.value));
|
||||
input.addEventListener('input', () => expandToForm(currentType, input.value));
|
||||
wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
expandToForm(currentType);
|
||||
// Trigger photo input on the new form
|
||||
setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50);
|
||||
});
|
||||
}
|
||||
|
||||
function _createNote(type = 'todo') {
|
||||
const body = document.querySelector('#notes-pane .notes-pane-body');
|
||||
if (!body || state.editingId === '__new__') return;
|
||||
app.setEditingId('__new__');
|
||||
// Restore an unsaved new-note draft if one survived a prior close/loss.
|
||||
const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__');
|
||||
const form = _buildForm(_n);
|
||||
form.classList.add('note-form-new');
|
||||
body.prepend(form);
|
||||
const initialField = type === 'note'
|
||||
? form.querySelector('.note-form-content')
|
||||
: form.querySelector('.note-cl-text, .note-form-title');
|
||||
initialField?.focus();
|
||||
if (restored) uiModule.showToast('Restored unsaved note');
|
||||
}
|
||||
|
||||
function newNote() {
|
||||
if (!state.open) openPanel();
|
||||
requestAnimationFrame(() => _createNote('note'));
|
||||
}
|
||||
|
||||
function _editNote(id) {
|
||||
const note = state.notes.find(n => n.id === id);
|
||||
if (!note) return;
|
||||
app.setEditingId(id);
|
||||
const card = document.querySelector(`.note-card[data-note-id="${id}"]`);
|
||||
if (!card) return;
|
||||
// Restore an unsaved draft (from a prior connection loss / close) over
|
||||
// the saved note so the user picks up where they left off.
|
||||
const { note: _n, restored } = _applyDraftToNote(note, id);
|
||||
const form = _buildForm(_n);
|
||||
card.replaceWith(form);
|
||||
if (restored) uiModule.showToast('Restored unsaved changes');
|
||||
// Pinned notes live in the first masonry column : the edit form has
|
||||
// column-span:all, which can leave the form rendered above the fold or
|
||||
// visually buried under neighboring pinned cards. Bring it into view
|
||||
// (and onto a higher stacking context) so editing a pinned note always
|
||||
// pops to the top.
|
||||
form.style.position = 'relative';
|
||||
form.style.zIndex = '5';
|
||||
// Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px)
|
||||
// after replaceWith, which is much shorter than the actual form. Recompute
|
||||
// masonry so the form gets the correct row span and cards below stop
|
||||
// overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as
|
||||
// the user types / adds checklist items.
|
||||
const _body = form.closest('.notes-pane-body');
|
||||
if (_body) {
|
||||
_applyMasonry(_body);
|
||||
requestAnimationFrame(() => _applyMasonry(_body));
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
|
||||
catch { form.scrollIntoView(); }
|
||||
});
|
||||
// Pick the most useful field to focus. On phones especially, the user
|
||||
// taps Edit to type : landing in the title when there's already a title
|
||||
// (and likely a body to extend) loses momentum. Prefer the body textarea
|
||||
// for plain notes, the first checklist item for todos, fall back to title.
|
||||
const _focusBest = () => {
|
||||
if (note.note_type === 'note' || !note.note_type) {
|
||||
const ta = form.querySelector('.note-form-content');
|
||||
if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; }
|
||||
}
|
||||
if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') {
|
||||
// Last non-empty checklist row, or the first row if all empty.
|
||||
const rows = form.querySelectorAll('.note-cl-row .note-cl-text');
|
||||
let target = null;
|
||||
for (const inp of rows) { if ((inp.value || '').trim()) target = inp; }
|
||||
target = target || rows[0];
|
||||
if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; }
|
||||
}
|
||||
const titleEl = form.querySelector('.note-form-title');
|
||||
if (titleEl) titleEl.focus();
|
||||
};
|
||||
_focusBest();
|
||||
}
|
||||
|
||||
return { _renderQuickAdd, _createNote, newNote, _editNote };
|
||||
}
|
||||
Reference in New Issue
Block a user