${type === 'note'
? ``
: type === 'draw'
? _buildDrawHtml()
: type === 'goal'
? _buildGoalHtml(note, items)
: _buildChecklistHtml(items)}
${COLORS.map(c => ``).join('')}
${isEdit ? `
` : ''}
`;
let currentType = type;
let currentColor = color;
// Stash original-form values so round-trips (Note→Todo→Note) restore the
// user's hand-formatted text instead of a join of generated items. Same the
// other way: if you started in todo, switch to note, switch back, items
// come back unchanged.
let _stashedNoteText = (type === 'note') ? (note?.content || '') : null;
let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null;
// Goal mode kept its own pair of stashes (description + steps) so a
// Todo→Goal→Todo round-trip wouldn't lose either side. The Goal pill in
// the type picker was later removed, so the only entry point now is
// *editing* an existing goal-typed note : but the switch handler still
// accepts Goal→Todo/Note transitions (downgrading legacy goals), so
// these stashes still earn their keep.
let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null;
let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null;
// Drawing also stashes the saved image URL so it survives Note↔Draw flips.
let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;
const _refreshFormLayout = () => {
const body = form.closest('.notes-pane-body');
if (!body) return;
_applyMasonry(body);
requestAnimationFrame(() => {
_applyMasonry(body);
requestAnimationFrame(() => _applyMasonry(body));
});
};
// Type segmented control : Note | Todo | Draw
form.querySelectorAll('.note-form-type-pill').forEach(pill => {
pill.addEventListener('click', () => {
const newType = pill.dataset.type;
if (newType === currentType) return;
const bodyEl = form.querySelector('.note-form-body');
// Stash whatever the user has in the current mode before swapping it
// out, so a subsequent flip back restores their work.
if (currentType === 'note') {
_stashedNoteText = form.querySelector('.note-form-content')?.value || '';
} else if (currentType === 'todo') {
_stashedTodoItems = _collectItems(form);
} else if (currentType === 'goal') {
_stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || '';
_stashedGoalItems = _collectItems(form);
} else if (currentType === 'draw') {
const c = form.querySelector('.note-form-canvas');
if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} }
}
// Render the new mode's body and re-wire its inputs.
if (newType === 'todo') {
let nextItems;
if (_stashedTodoItems && _stashedTodoItems.length) {
nextItems = _stashedTodoItems;
} else if (_stashedGoalItems && _stashedGoalItems.length) {
// Going Goal→Todo keeps the AI-generated steps as a plain checklist.
nextItems = _stashedGoalItems;
} else if (_stashedNoteText) {
const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean);
nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }];
} else {
nextItems = [{ id: _uid(), text: '', done: false }];
}
bodyEl.innerHTML = _buildChecklistHtml(nextItems);
_wireChecklist(bodyEl);
} else if (newType === 'draw') {
bodyEl.innerHTML = _buildDrawHtml();
// If the user just attached a photo (via the photo button) and then
// toggled to Draw, paint that photo onto the canvas so they can draw
// on top of it. _stashedDrawUrl wins if they were drawing earlier in
// the same edit session.
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null);
} else {
const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '')
? _stashedNoteText
: (_stashedGoalDesc && _stashedGoalDesc)
|| (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n');
bodyEl.innerHTML = ``;
_wireHashtag(bodyEl.querySelector('.note-form-content'));
}
const focusEl = newType === 'note'
? bodyEl.querySelector('.note-form-content')
: newType === 'todo'
? bodyEl.querySelector('.note-cl-text')
: null;
if (focusEl) {
requestAnimationFrame(() => {
focusEl.focus({ preventScroll: true });
try {
const end = focusEl.value.length;
focusEl.setSelectionRange(end, end);
} catch {}
});
}
currentType = newType;
const seg = form.querySelector('.note-form-type-seg');
seg?.classList.toggle('is-todo', newType === 'todo');
seg?.classList.toggle('is-draw', newType === 'draw');
form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType));
// The standalone image preview (form-image-wrap) and the canvas would
// otherwise both show the same image_url when editing a drawn note.
// Hide it in draw mode, restore it when leaving draw mode.
const imgWrap = form.querySelector('.note-form-image-wrap');
if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : '';
// The background-color dots set the note card's bg : they make no sense
// for a drawn note (the canvas image IS the card content), so hide them.
const bgPicker = form.querySelector('.note-color-picker');
if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : '';
if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) {
form.style.gridColumn = '1 / -1';
form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64';
}
_refreshFormLayout();
});
});
// Slide a finger across the Note/Todo/Draw control to switch modes (mobile).
// On touchmove we find the pill under the finger and click it : reusing the
// pill click handler above, so the body re-renders + content stashing all
// work. Only fires when crossing into a *different* pill.
const _typeSeg = form.querySelector('.note-form-type-seg');
if (_typeSeg) {
let _sliding = false;
const _activateAt = (x, y) => {
const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill');
if (pill && !pill.classList.contains('active')) pill.click();
};
_typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true });
_typeSeg.addEventListener('touchmove', (e) => {
if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
_typeSeg.addEventListener('touchend', () => { _sliding = false; });
_typeSeg.addEventListener('touchcancel', () => { _sliding = false; });
}
_wireFormColors(form, value => { currentColor = value; });
if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body'));
if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body'));
if (currentType === 'draw') {
_wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null);
// Same hides we apply on type-switch : keep them consistent on initial open.
const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none';
const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none';
}
// Auto-grow the plain-note textarea so editing longer notes is
// comfortable : it expands with the content (up to a cap) instead of
// staying a cramped 4-row box. The user can still drag-resize too.
const _contentTa = form.querySelector('.note-form-content');
if (_contentTa) {
const _grow = () => {
_contentTa.style.height = 'auto';
// Inline form: cap at ~50vh so a huge note doesn't push the action
// buttons off-screen. Fullscreen mobile overlay: the body scrolls and
// there are no inline buttons crowding, so allow nearly the full height
// : capping at 50vh there clipped longer notes ("part disappears").
const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay');
const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5));
_contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px';
};
_contentTa.addEventListener('input', _grow);
// Grow on open so existing content is fully visible. Run again after the
// fullscreen overlay's open animation settles : measuring mid-animation
// (the overlay starts scaled/transitioning) can under-size the box.
setTimeout(_grow, 0);
setTimeout(_grow, 360);
}
_wireReminderPicker(form);
// Photo upload
const photoBtn = form.querySelector('.note-form-photo-btn');
const photoInput = form.querySelector('.note-form-photo-input');
if (photoBtn && photoInput) {
photoBtn.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', async () => {
const file = photoInput.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('files', file);
try {
const res = await api.upload(fd);
const data = await res.json();
const fileId = data.files?.[0]?.id;
if (!fileId) throw new Error('Upload failed');
currentImageUrl = api.uploadUrl(fileId);
// Only ever keep the latest attached photo : drop any existing wrap
// before inserting a fresh one. Picking a second photo replaces the
// first instead of stacking.
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = ``;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
form.querySelector('.note-form-header').after(wrap);
wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; });
wrap.querySelector('img').src = currentImageUrl;
} catch (err) { uiModule.showError('Image upload failed'); }
photoInput.value = '';
});
}
// Existing image remove
form.querySelector('.note-form-image-rm')?.addEventListener('click', () => {
form.querySelector('.note-form-image-wrap')?.remove();
currentImageUrl = '';
});
// Title Enter -> focus body (textarea or first checklist item)
form.querySelector('.note-form-title').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
const ta = form.querySelector('.note-form-content');
if (ta) { ta.focus(); return; }
const firstItem = form.querySelector('.note-cl-text');
if (firstItem) firstItem.focus();
}
});
// Hashtag → label: typing "#foo " in title/content appends "foo" to the
// space-separated tag list. Repeats are deduplicated, so #foo #foo only
// keeps one. Tags already present in the label field are left alone.
const labelInput = form.querySelector('.note-form-label');
const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/;
function _wireHashtag(el) {
if (!el || !labelInput) return;
el.addEventListener('input', () => {
const m = _hashtagRe.exec(el.value);
if (!m) return;
const tag = m[2];
// Dedup against the stripped form : labelInput may already hold `#tag`
// (after Enter normalised), so includes(tag) on the raw split would
// miss the duplicate and append a bare `tag` next to `#tag`.
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 = el.value.length - m[0].length + m[1].length;
el.value = el.value.slice(0, cut);
});
}
_wireHashtag(form.querySelector('.note-form-title'));
_wireHashtag(form.querySelector('.note-form-content'));
// Pressing Enter in the tag field commits the current word as its own tag
// and parks the cursor after a trailing space, so the next word becomes a
// separate tag rather than overwriting the previous one.
labelInput?.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return;
e.preventDefault();
e.stopPropagation();
// Strip any leading #s the user typed, dedupe, then re-prepend exactly
// one #. So typing "foo" or "#foo" both end up as "#foo " in the input;
// the save handler keeps stripping #s before storing so DB stays clean.
const tags = [...new Set(labelInput.value.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))];
if (!tags.length) return;
labelInput.value = tags.map(t => '#' + t).join(' ') + ' ';
labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length);
labelInput.classList.add('flash-once');
setTimeout(() => labelInput.classList.remove('flash-once'), 600);
});
// Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save
// Escape -> cancel edit
form.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
form.querySelector('.note-form-save')?.click();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
form.querySelector('.note-form-cancel')?.click();
}
});
// Save. Prevent the button from stealing focus on press: on mobile, the
// first tap would otherwise just blur the focused textarea/input (closing
// the keyboard and shifting layout), so the tap never reached the button and
// you had to tap "Done" twice. mousedown preventDefault keeps focus put while
// still letting the click fire.
const _saveBtnEl0 = form.querySelector('.note-form-save');
_saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault());
_saveBtnEl0.addEventListener('click', async () => {
// Guard against spam-clicks: the drawing save AWAITS a canvas upload before
// the optimistic re-render removes the form, so without this a slow upload
// let repeated clicks create duplicate notes.
const _saveBtn = form.querySelector('.note-form-save');
if (_saveBtn._saving) return;
// Mobile: when an existing note is opened and closed without edits, the
// Update (✓) button morphs into Archive (set up below). Route the click
// directly through the archive flow. Using a DOM .click() proxy here was
// fragile on mobile because the real archive button can move from the
// footer to the fullscreen header.
if (_saveBtn.classList.contains('archive-mode')) {
_archiveNoteById(note?.id);
return;
}
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
try {
form._flushDraft?.();
await form._stopDraftAutosave?.();
form._flushDraft?.();
const title = form.querySelector('.note-form-title').value.trim();
// Normalize tag input: split on whitespace, strip leading #s, dedupe,
// re-join with single spaces. Empty → null.
const _rawLabel = form.querySelector('.note-form-label')?.value || '';
const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))];
if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder');
const labelVal = _tags.length ? _tags.join(' ') : null;
const payload = {
title,
note_type: currentType,
color: currentColor,
label: labelVal,
due_date: form.querySelector('.note-form-due').value || null,
repeat: form.querySelector('.note-form-repeat')?.value || 'none',
image_url: currentImageUrl || null,
};
if (currentType === 'note') {
payload.content = form.querySelector('.note-form-content')?.value || '';
} else if (currentType === 'draw') {
// Upload the canvas PNG before saving so image_url points to a
// persistent file. We block the save until upload completes : drawings
// can't be re-rendered later without the URL.
const canvas = form.querySelector('.note-form-canvas');
const upload = await _uploadCanvasAsPng(canvas, note?.gallery_id || null);
if (!upload?.url) { uiModule.showError('Failed to save drawing'); return; }
payload.image_url = upload.url;
if (upload.galleryId) payload.gallery_id = upload.galleryId;
} else if (currentType === 'goal') {
// Legacy: existing goal-type notes still edit through this branch.
// No AI involvement : save as a normal note with description + items.
payload.content = form.querySelector('.note-form-goal-desc')?.value || '';
payload.items = _collectItems(form);
} else {
payload.items = _collectItems(form);
}
if (isEdit) payload.id = note.id;
submitNote(payload, note, isEdit);
} finally {
// Re-enable on early returns / errors. On success the form is removed by
// the optimistic re-render, so re-enabling the detached button is a no-op.
_saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = '';
}
});
// Mobile-only: when editing an existing note, the Update (✓) button starts in
// archive-mode (visually + behaviorally) and flips to Update on the first
// edit. Lets the user tap a note to skim, then tap ✓ to archive without ever
// touching a separate Archive button.
if (isEdit && window.innerWidth <= 768) {
const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label');
const _isFinishedTodo = () => {
if (currentType !== 'todo') return false;
const rows = [...form.querySelectorAll('.note-cl-row')];
const withText = rows.filter(row => (row.querySelector('.note-cl-text')?.value || '').trim());
return withText.length > 0 && withText.every(row => row.classList.contains('done'));
};
const _enterArchive = () => {
_saveBtnEl0.classList.add('archive-mode');
const label = _isFinishedTodo() ? 'Finish' : 'Archive';
if (_saveLabelEl) _saveLabelEl.textContent = label;
_saveBtnEl0.title = label;
};
const _enterUpdate = () => {
if (!_saveBtnEl0.classList.contains('archive-mode')) return;
_saveBtnEl0.classList.remove('archive-mode');
if (_saveLabelEl) _saveLabelEl.textContent = 'Update';
_saveBtnEl0.title = 'Update';
};
_enterArchive();
form.addEventListener('input', _enterUpdate, true);
form.addEventListener('change', _enterUpdate, true);
// Checklist dots use a custom control and dispatch their update before
// this click bubbles. Re-evaluate afterward so a fully completed todo
// offers Finish (which archives/removes it from the active list).
form.addEventListener('click', (event) => {
if (!event.target.closest('.note-cl-dot')) return;
setTimeout(() => {
if (_isFinishedTodo()) _enterArchive();
else _enterUpdate();
}, 0);
}, true);
}
// Cancel
form.querySelector('.note-form-cancel').addEventListener('click', () => { form._flushDraft?.(); app.setEditingId(null); _renderNotes(); });
// Archive / Delete : edit-mode-only buttons, mirror the (now-hidden) card actions.
form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => {
if (!isEdit) return;
_archiveNoteById(note.id);
});
form.querySelector('.note-form-delete-btn')?.addEventListener('click', () => {
if (!isEdit) return;
return deleteFromEditor(note.id);
});
// Autosave a draft to persistence on every change so unsaved edits
// survive connection loss / reload / accidental close.
_wireDraftAutosave(form, isEdit ? note.id : '__new__');
return form;
}
return { _buildForm };
}