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>
258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
/** Mobile fullscreen editing, link reader mode and return to the panel. */
|
|
export function createMobileEditor({
|
|
_applyDraftToNote,
|
|
_buildForm,
|
|
_fetchNotes,
|
|
_linkify,
|
|
_renderNotes,
|
|
_uid,
|
|
_wireChecklistTouchReorder,
|
|
_wireRow,
|
|
app,
|
|
state,
|
|
uiModule
|
|
}) {
|
|
function _isNotesMobileMode() {
|
|
return ('ontouchstart' in window) && window.innerWidth <= 768;
|
|
}
|
|
|
|
let _mobileFsOverlay = null;
|
|
|
|
let _mobileFsNoteId = null;
|
|
|
|
function _openMobileFullscreenEdit(id, fromCard) {
|
|
const note = state.notes.find(n => n.id === id);
|
|
if (!note) return;
|
|
// Tear down any previous overlay (defensive).
|
|
_closeMobileFullscreenEdit({ save: false });
|
|
_mobileFsNoteId = id;
|
|
app.setEditingId(id);
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'note-fullscreen-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="note-fullscreen-header">
|
|
<button type="button" class="note-fullscreen-back" title="Back">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
</button>
|
|
<div class="note-fullscreen-actions"></div>
|
|
</div>
|
|
<div class="note-fullscreen-body"></div>
|
|
`;
|
|
const body = overlay.querySelector('.note-fullscreen-body');
|
|
// Reuse the same edit form the in-place flow builds. Save buttons,
|
|
// checklist toggles, etc. all work as-is. Restore any unsaved draft.
|
|
const { note: _n, restored } = _applyDraftToNote(note, id);
|
|
const form = _buildForm(_n);
|
|
body.appendChild(form);
|
|
if (restored) uiModule.showToast('Restored unsaved changes');
|
|
document.body.appendChild(overlay);
|
|
_mobileFsOverlay = overlay;
|
|
|
|
// Animate up from the tapped tile's position so the transition reads
|
|
// as a zoom rather than a hard cut.
|
|
if (fromCard) {
|
|
const r = fromCard.getBoundingClientRect();
|
|
const vw = window.innerWidth, vh = window.innerHeight;
|
|
overlay.style.transformOrigin =
|
|
`${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`;
|
|
}
|
|
overlay.classList.add('opening');
|
|
requestAnimationFrame(() => overlay.classList.add('open'));
|
|
|
|
// Wire the back button : saves whatever the form has and closes.
|
|
// mousedown preventDefault so it doesn't blur the input on first tap (which
|
|
// would eat the tap and require a second one).
|
|
const _backBtn = overlay.querySelector('.note-fullscreen-back');
|
|
_backBtn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
_backBtn.addEventListener('click', () => {
|
|
_closeMobileFullscreenEdit({ save: true });
|
|
});
|
|
|
|
// The form's built-in Cancel only resets the in-place edit state; in
|
|
// the overlay context it does nothing visible. Replace its handler so
|
|
// Cancel actually dismisses the overlay without saving.
|
|
const cancelBtn = form.querySelector('.note-form-cancel');
|
|
if (cancelBtn) {
|
|
const fresh = cancelBtn.cloneNode(true);
|
|
cancelBtn.parentNode.replaceChild(fresh, cancelBtn);
|
|
fresh.addEventListener('mousedown', (e) => e.preventDefault());
|
|
fresh.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
_closeMobileFullscreenEdit({ save: false });
|
|
});
|
|
}
|
|
// The built-in Save handler does the API call + refresh, but doesn't
|
|
// dismiss our overlay. Augment it (do NOT replace : the original is
|
|
// async and we'd lose the API call) to schedule a close once the
|
|
// save+render has had time to complete.
|
|
const saveBtn = form.querySelector('.note-form-save');
|
|
if (saveBtn) {
|
|
saveBtn.addEventListener('click', () => {
|
|
setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350);
|
|
});
|
|
}
|
|
// Make the checklist row drag handle (⋮⋮) actually work on touch.
|
|
// The form's default uses HTML5 native draggable which never fires
|
|
// on iOS/Android. Wire touch-based reorder for any row inside the
|
|
// overlay's checklist.
|
|
_wireChecklistTouchReorder(form);
|
|
|
|
// For each todo row whose text contains a URL, swap the bare <input>
|
|
// for a linkified <span> so URLs are tappable. Tapping anywhere that
|
|
// ISN'T a link flips back to the input for editing.
|
|
form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode);
|
|
|
|
// Move only Delete from the form's footer actions row into the header.
|
|
// Archive intentionally stays out of the header: the footer Update button
|
|
// already morphs into the mobile Archive action for an untouched note.
|
|
const headerActions = overlay.querySelector('.note-fullscreen-actions');
|
|
const archiveBtn = form.querySelector('.note-form-archive-btn');
|
|
const deleteBtn = form.querySelector('.note-form-delete-btn');
|
|
if (headerActions && deleteBtn) {
|
|
deleteBtn.classList.remove('note-form-collapsible');
|
|
headerActions.appendChild(deleteBtn);
|
|
}
|
|
// The built-in archive/delete handlers re-render the notes grid but
|
|
// leave THIS overlay sitting in front of it : looks like nothing
|
|
// happened. Add follow-up listeners that close the overlay so the
|
|
// user sees the action take effect.
|
|
if (archiveBtn) {
|
|
archiveBtn.addEventListener('click', () => {
|
|
setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200);
|
|
});
|
|
}
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener('click', () => {
|
|
// Delete shows a styled confirm : give it room to resolve before
|
|
// we try to dismiss the overlay.
|
|
setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500);
|
|
});
|
|
}
|
|
|
|
// Tuck the tags input into the bottom actions row (Cancel / Update),
|
|
// pinned to the LEFT. Frees the meta row of an extra wrapping line
|
|
// and groups all the "exit" controls together.
|
|
const actionsGroup = form.querySelector('.note-form-actions-group');
|
|
const tagsInput = form.querySelector('.note-form-label');
|
|
if (actionsGroup && tagsInput) {
|
|
actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild);
|
|
}
|
|
|
|
// For checklist-type notes, move the photo (attach image) button into
|
|
// the same row as the + Add button (right side) : keeps the meta row
|
|
// tidy and puts the camera within thumb-reach of the active edit.
|
|
const addBtn = form.querySelector('.note-cl-add');
|
|
const photoBtn = form.querySelector('.note-form-photo-btn');
|
|
if (addBtn && photoBtn) {
|
|
const addRow = document.createElement('div');
|
|
addRow.className = 'note-cl-add-row';
|
|
addBtn.parentNode.insertBefore(addRow, addBtn);
|
|
addRow.appendChild(addBtn);
|
|
addRow.appendChild(photoBtn);
|
|
// Tapping anywhere on the row (the empty gap, the dashed border,
|
|
// the "+ Add" label) triggers add. The photo button keeps its own
|
|
// click target so attach-image isn't ambushed.
|
|
addRow.addEventListener('click', (e) => {
|
|
if (e.target.closest('.note-form-photo-btn')) return;
|
|
if (e.target === addBtn || addBtn.contains(e.target)) return;
|
|
addBtn.click();
|
|
});
|
|
// The form's delegated "+ Add" handler does
|
|
// inputs.insertBefore(newRow, addBtn)
|
|
// : but addBtn is no longer a direct child of `.note-checklist-inputs`
|
|
// now that we wrapped it. Bind a fresh handler that does the same
|
|
// thing but inserts before the WRAPPING row, and stop propagation
|
|
// so the broken delegate never runs.
|
|
addBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const inputs = form.querySelector('.note-checklist-inputs');
|
|
if (!inputs) return;
|
|
const newRow = document.createElement('div');
|
|
newRow.className = 'note-cl-row';
|
|
newRow.draggable = true;
|
|
newRow.dataset.itemId = _uid();
|
|
newRow.dataset.indent = '0';
|
|
newRow.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(newRow, addRow);
|
|
_wireRow(newRow, inputs);
|
|
// Touch reorder on the freshly-added row's grip.
|
|
_wireChecklistTouchReorder(form);
|
|
newRow.querySelector('.note-cl-text')?.focus();
|
|
}, { capture: true });
|
|
}
|
|
|
|
// Read-mode overlay for plain notes: render the content as a div with
|
|
// clickable hyperlinks, layered above the textarea. Tapping anywhere
|
|
// in the overlay that ISN'T a link hides the overlay and focuses the
|
|
// textarea so the user can start editing. Tapping a link opens it.
|
|
const ta = form.querySelector('.note-form-content');
|
|
if (ta && (note.content || '').trim()) {
|
|
const reader = document.createElement('div');
|
|
reader.className = 'note-form-content-reader';
|
|
reader.innerHTML = _linkify(note.content || '');
|
|
ta.style.display = 'none';
|
|
ta.insertAdjacentElement('beforebegin', reader);
|
|
reader.addEventListener('click', (e) => {
|
|
if (e.target.closest('a')) return; // let links open normally
|
|
reader.remove();
|
|
ta.style.display = '';
|
|
// Let the browser place the cursor naturally : forcing
|
|
// setSelectionRange right after focus() raced with the underlying
|
|
// tap event and produced inconsistent cursor positions on mobile.
|
|
ta.focus({ preventScroll: true });
|
|
});
|
|
}
|
|
|
|
// Opening an EXISTING note → read mode, no keyboard pop. Only a
|
|
// brand-new note (created via the + button) should auto-focus an
|
|
// input field. The user can tap the content to switch to edit.
|
|
// (New-note creation flows through _createNote, not this function.)
|
|
}
|
|
|
|
function _closeMobileFullscreenEdit(opts = {}) {
|
|
if (!_mobileFsOverlay) return;
|
|
const overlay = _mobileFsOverlay;
|
|
overlay.querySelector('.note-form')?._flushDraft?.();
|
|
_mobileFsOverlay = null;
|
|
// If the form has a Save button, click it on close so edits aren't lost
|
|
// when the user uses the back arrow instead of an explicit Save.
|
|
if (opts.save) {
|
|
const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]');
|
|
if (saveBtn) try { saveBtn.click(); } catch {}
|
|
}
|
|
overlay.classList.remove('open');
|
|
overlay.classList.add('closing');
|
|
setTimeout(() => {
|
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
_mobileFsNoteId = null;
|
|
app.setEditingId(null);
|
|
// Refresh the grid so any save the user made is reflected.
|
|
if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); }
|
|
}, 220);
|
|
}
|
|
|
|
function _addRowReadMode(row) {
|
|
const txt = row.querySelector('.note-cl-text');
|
|
if (!txt) return;
|
|
const val = txt.value || '';
|
|
if (!/(https?:\/\/|www\.)/i.test(val)) return;
|
|
if (row.querySelector('.note-cl-text-reader')) return; // already wired
|
|
const span = document.createElement('span');
|
|
span.className = 'note-cl-text-reader';
|
|
span.innerHTML = _linkify(val);
|
|
txt.style.display = 'none';
|
|
txt.insertAdjacentElement('beforebegin', span);
|
|
span.addEventListener('click', (e) => {
|
|
if (e.target.closest('a')) return; // let the link open
|
|
span.remove();
|
|
txt.style.display = '';
|
|
txt.focus({ preventScroll: true });
|
|
});
|
|
}
|
|
|
|
return { _isNotesMobileMode, _openMobileFullscreenEdit, _closeMobileFullscreenEdit, _addRowReadMode };
|
|
}
|