Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent c9dd68d890
commit 84aa9a91de
871 changed files with 265870 additions and 27854 deletions
+250 -57
View File
@@ -3,12 +3,12 @@
* Renders as a sidebar panel (like document editor), not a modal.
*/
import uiModule from './ui.js';
import { spawnConfetti } from './compare/vote.js';
import uiModule from './ui.js?v=20260908weekhoverfix1';
import { spawnConfetti } from './compare/vote.js?v=20260828resendcaldrag1';
import * as Modals from './modalManager.js';
import { attachColorPicker } from './colorPicker.js';
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js';
import { snapModalToZone } from './tileManager.js?v=20260910responsivebounds1';
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -28,6 +28,7 @@ let _searchQuery = '';
let _viewMode = (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list'; // 'list' or 'grid'
let _showingArchived = false;
let _selectMode = false;
let _suppressNextNoteTapId = null;
let _reminderTimer = null;
// Tracks the global keydown listener so closePanel can remove it
// (previously leaked one per openPanel; on multi-open sessions this
@@ -48,6 +49,8 @@ const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight'
// the rail "fired" badge so old reminders don't re-fire on every page reload.
const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at';
const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1';
const NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed';
let _labelsCollapsed = (typeof localStorage !== 'undefined' && localStorage.getItem(NOTES_LABELS_COLLAPSED_KEY) === '1');
function _forceCloseNotesPanel() {
_open = false;
@@ -237,6 +240,15 @@ function _loadActiveHighlights() {
function _saveActiveHighlights(set) {
try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {}
}
function _isCalendarReminderNote(n) {
return n && n.source === 'calendar' && n.label === 'calendar';
}
function _userVisibleNotes() {
return _notes.filter(n => !_isCalendarReminderNote(n));
}
function _clearViewedReminderGlows() {
const active = _loadActiveHighlights();
if (!active.size) return;
@@ -302,8 +314,7 @@ function _flushPendingHighlights() {
// Bring the first one into view so it can't get buried below the fold.
if (firstCard) {
requestAnimationFrame(() => {
try { firstCard.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
catch { firstCard.scrollIntoView(); }
_smoothScrollToNoteCard(firstCard);
});
}
}
@@ -386,6 +397,15 @@ const COLOR_HEX = {
purple: '#e2bcee',
};
// Plain notes get a stable visual variant derived from their identity. This
// adds recognition without changing a color the user explicitly selected.
function _standardVisualVariant(note) {
const source = String(note?.id || note?.title || note?.created_at || 'standard-note');
let hash = 0;
for (let i = 0; i < source.length; i++) hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;
return Math.abs(hash) % 5;
}
// ---- API ----
let _loading = false;
@@ -1103,7 +1123,8 @@ function _updateRailBadge() {
if (!dot) {
dot = document.createElement('span');
dot.className = 'tool-notes-dot';
sidebarBtn.appendChild(dot);
const newNoteBtn = sidebarBtn.querySelector('#notes-new-note-btn');
sidebarBtn.insertBefore(dot, newNoteBtn || null);
}
} else if (dot) {
dot.remove();
@@ -1147,6 +1168,14 @@ export function openPanel() {
_bringNotesToFront();
return;
}
// Notes and the document editor occupy the same primary workspace. Preserve
// an open document as a dock chip instead of allowing the two panes to stack
// into a broken layout (or closing the user's document outright).
try {
if (window.documentModule?.isPanelOpen?.()) {
window.documentModule.closePanel('down');
}
} catch (_) {}
_open = true;
_editingId = null;
_searchQuery = '';
@@ -1209,6 +1238,9 @@ export function openPanel() {
<button id="notes-bulk-delete" class="memory-toolbar-btn danger" disabled>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>Delete
</button>
<button id="notes-bulk-cancel" class="memory-toolbar-btn active" title="Cancel selection" aria-label="Cancel selection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="notes-pane-body"></div>
`;
@@ -1371,6 +1403,7 @@ export function openPanel() {
_renderNotes();
uiModule.showToast(`Deleted ${ids.length}`);
});
document.getElementById('notes-bulk-cancel').addEventListener('click', _exitSelectMode);
// Escape: exit select mode first (if active), otherwise close the panel.
// Skip when the user is editing a form field — those have their own
// ESC-to-cancel handlers and we don't want to nuke the whole panel
@@ -1453,9 +1486,10 @@ function _renderLoadingSkeleton() {
body.appendChild(skel);
}
function _enterSelectMode() {
function _enterSelectMode(initialId = null) {
_selectMode = true;
_selectedIds.clear();
if (initialId) _selectedIds.add(initialId);
const bar = document.getElementById('notes-bulk-bar');
const btn = document.getElementById('notes-select-btn');
if (bar) bar.classList.remove('hidden');
@@ -1528,17 +1562,30 @@ async function _clearPastReminders() {
function _renderLabels(root = document) {
const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar');
if (!bar) return;
if (_labelsCollapsed) {
bar.innerHTML = '';
bar.style.display = 'none';
return;
}
const visibleNotes = _userVisibleNotes();
const labels = new Set();
for (const n of _notes) for (const t of _visibleNoteTags(n)) labels.add(t);
for (const n of visibleNotes) for (const t of _visibleNoteTags(n)) labels.add(t);
const sortedLabels = [...labels].sort();
// Count active reminders (not archived, has datetime due_date)
const reminderCount = _notes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length;
const pastReminderCount = _notes.filter(n => !n.archived && _isPastReminder(n)).length;
const defaultCount = _notes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length;
const reminderCount = visibleNotes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length;
const pastReminderCount = visibleNotes.filter(n => !n.archived && _isPastReminder(n)).length;
const defaultCount = visibleNotes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length;
// Active goals = non-archived goal notes. Today view lists pending steps
// from each, so we surface the count next to the chip.
const goalCount = _notes.filter(n => n.note_type === 'goal' && !n.archived).length;
const todayCount = _notes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length;
const goalCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived).length;
const todayCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length;
const hasUsefulFilters = sortedLabels.length > 0 || reminderCount > 0 || goalCount > 0;
const hasActiveFilter = _activeLabel !== null || _activeFilter !== null;
if (!hasUsefulFilters && !hasActiveFilter) {
bar.innerHTML = '';
bar.style.display = 'none';
return;
}
bar.style.display = '';
const allActive = _activeLabel === null && _activeFilter === null;
let html = `<button class="notes-label-chip${allActive ? ' active' : ''}" data-action="all">All</button>`;
@@ -1606,7 +1653,27 @@ function _renderLabels(root = document) {
function _renderLabelsInto(_body) {
if (!_body) return;
let bar = _body.querySelector(':scope > .notes-labels-bar');
const pane = _body.closest('.notes-pane');
const toggleHost = pane?.querySelector('.notes-search-bar') || _body;
let toggle = toggleHost.querySelector(':scope > .notes-label-toggle');
if (!toggle) {
toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'notes-label-toggle cal-filter-toggle';
toggle.addEventListener('click', () => {
_labelsCollapsed = !_labelsCollapsed;
try { localStorage.setItem(NOTES_LABELS_COLLAPSED_KEY, _labelsCollapsed ? '1' : '0'); } catch {}
_renderNotes();
});
const selectButton = toggleHost.querySelector('#notes-select-btn');
toggleHost.insertBefore(toggle, selectButton || null);
}
toggle.textContent = _labelsCollapsed ? '+ Tags' : '− Tags';
toggle.title = _labelsCollapsed ? 'Show tags' : 'Hide tags';
toggle.setAttribute('aria-expanded', _labelsCollapsed ? 'false' : 'true');
// The shared chip scroller wraps this row once it is mounted.
let bar = _body.querySelector('.notes-labels-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'notes-labels-bar';
@@ -1744,7 +1811,8 @@ function _renderNotes() {
const prevPositions = _captureCardPositions();
const activeReminderHighlights = _loadActiveHighlights();
let filtered = _activeLabel ? _notes.filter(n => _noteTags(n).includes(_activeLabel)) : _notes;
const displayNotes = _userVisibleNotes();
let filtered = _activeLabel ? displayNotes.filter(n => _noteTags(n).includes(_activeLabel)) : displayNotes;
if (_activeFilter === 'reminders') {
filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date));
} else if (_activeFilter === 'no-reminders') {
@@ -1883,6 +1951,8 @@ function _renderNotes() {
const isBg = _isBgImage(note.color);
const cc = (note.color && !isBg) ? ' note-color-' + note.color : '';
const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : '';
const isStandard = !note.note_type || note.note_type === 'note';
const visualClass = isStandard && !note.color ? ` note-standard note-standard-v${_standardVisualVariant(note)}` : '';
const sel = _selectedIds.has(note.id) ? ' note-card-selected' : '';
const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date)
? `<div class="note-card-reminder${overdue ? ' overdue' : ''}">
@@ -1901,7 +1971,8 @@ function _renderNotes() {
Goal${_goalProgress(note)}
</span>`
: '';
html += `<div class="note-card${note.pinned ? ' note-card-pinned' : ''}${cc}${sel}${goalClass}${reminderGlowClass}${_selectMode ? ' note-card-selectmode' : ''}" draggable="${(_selectMode || _isNotesMobileMode()) ? 'false' : 'true'}" data-note-id="${note.id}"${cardStyle}>
const drawingClass = note.note_type === 'draw' ? ' note-card-drawing' : '';
html += `<div class="note-card${note.pinned ? ' note-card-pinned' : ''}${cc}${visualClass}${sel}${goalClass}${drawingClass}${reminderGlowClass}${_selectMode ? ' note-card-selectmode' : ''}" draggable="${(_selectMode || _isNotesMobileMode()) ? 'false' : 'true'}" data-note-id="${note.id}"${cardStyle}>
${_selectMode ? `<input type="checkbox" class="memory-select-cb note-card-cb" data-note-id="${note.id}" ${_selectedIds.has(note.id) ? 'checked' : ''} />` : ''}
${goalPill}
<button class="note-card-pin${note.pinned ? ' active' : ''}" data-note-id="${note.id}" title="${note.pinned ? 'Unpin' : 'Pin'}">
@@ -2000,17 +2071,24 @@ function _applyMasonry(body) {
if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; }
if (!isGrid) {
// Clear any leftover inline spans so list view lays out normally.
body.querySelectorAll('.note-card, .notes-labels-bar, .notes-quick-add, .note-form').forEach(c => { c.style.gridRowEnd = ''; });
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');
if (labelsBar && getComputedStyle(labelsBar).display !== 'none') {
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;
labelsBar.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsBar.scrollHeight) - shave)}`;
labelsLayout.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsLayout.scrollHeight) - shave)}`;
}
if (quickAdd) {
const shave = isMobileGrid ? 4 : 0;
@@ -2048,7 +2126,12 @@ function _applyMasonry(body) {
if (fullRowsChanged) recomputeFullRows();
});
body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c));
body.querySelectorAll('.notes-labels-bar, .notes-quick-add, .note-form').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);
}
}
@@ -2177,8 +2260,22 @@ function _renderQuickAdd(body) {
}
function _bindCardEvents(body) {
// Skip completed leading rows so the first unfinished checklist item is
// visible when a note preview has its own scroll area.
requestAnimationFrame(() => {
body.querySelectorAll('.note-checklist-preview').forEach(preview => {
const firstPending = preview.querySelector('.note-checkbox:not(.done)');
if (firstPending) preview.scrollTop = Math.max(0, firstPending.offsetTop - 4);
});
});
const tapToEditOrSelect = (cardEl) => {
const id = cardEl.dataset.noteId;
// A long-press enters select mode and re-renders the card. Ignore the
// synthetic click that mobile browsers emit when that same touch ends.
if (_suppressNextNoteTapId === id) {
_suppressNextNoteTapId = null;
return;
}
if (_selectMode) {
const cb = cardEl.querySelector('.note-card-cb');
if (cb) {
@@ -2193,9 +2290,9 @@ function _bindCardEvents(body) {
_editNote(id);
}
};
// Mobile: long-press anywhere on a note card → enter drag-to-reorder mode.
// Cancelled by movement (so it doesn't interfere with vertical scrolling)
// or by lifting the finger before the timer fires.
// Mobile: long-press anywhere on a note card → enter Select mode and
// select that card. Cancelled by movement (so it doesn't interfere with
// vertical scrolling) or by lifting the finger before the timer fires.
if (_isNotesMobileMode()) {
body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card));
}
@@ -2204,14 +2301,37 @@ function _bindCardEvents(body) {
});
// Click title — edit, or toggle select in select mode
body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => {
el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); });
el.addEventListener('click', (e) => {
e.stopPropagation();
_smoothScrollToNoteCard(el.closest('.note-card'));
tapToEditOrSelect(el.closest('.note-card'));
});
});
// Click content — edit, or toggle select in select mode
body.querySelectorAll('.note-content-preview').forEach(el => {
el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); });
});
// A saved drawing is the note's primary content. Open its editor directly
// when the preview is clicked, matching title/content preview behavior.
body.querySelectorAll('.note-card-drawing .note-card-image').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
tapToEditOrSelect(el.closest('.note-card'));
});
});
// Click empty area of checklist preview (not on checkbox/X) — edit
body.querySelectorAll('.note-checklist-preview').forEach(el => {
el.addEventListener('wheel', (e) => {
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
const pane = el.closest('.notes-pane-body');
if (pane) {
pane.scrollTop += e.deltaY;
e.preventDefault();
}
}
}, { passive: false });
el.addEventListener('click', (e) => {
if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-checkbox-agent, .note-cl-quickadd, input')) return;
e.stopPropagation();
@@ -2652,6 +2772,7 @@ function _bindCardEvents(body) {
body.querySelectorAll('.note-checkbox').forEach(el => {
el.addEventListener('click', (e) => {
if (_selectMode) return; // let card-level handler take over
e.preventDefault();
e.stopPropagation();
const noteId = el.dataset.noteId;
const idx = parseInt(el.dataset.idx);
@@ -3653,9 +3774,10 @@ function _buildForm(note = null) {
// 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 url = await _uploadCanvasAsPng(canvas);
if (!url) { uiModule.showError('Failed to save drawing'); return; }
payload.image_url = url;
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.
@@ -3728,10 +3850,17 @@ function _buildForm(note = null) {
// 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');
if (_saveLabelEl) _saveLabelEl.textContent = 'Archive';
_saveBtnEl0.title = 'Archive';
const label = _isFinishedTodo() ? 'Finish' : 'Archive';
if (_saveLabelEl) _saveLabelEl.textContent = label;
_saveBtnEl0.title = label;
};
const _enterUpdate = () => {
if (!_saveBtnEl0.classList.contains('archive-mode')) return;
@@ -3742,6 +3871,16 @@ function _buildForm(note = null) {
_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
@@ -3847,6 +3986,9 @@ function _wireRow(row, container) {
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();
@@ -4002,6 +4144,35 @@ function _buildDrawHtml() {
function _wireCanvas(container, initialImageUrl) {
const canvas = container.querySelector('.note-form-canvas');
if (!canvas) return;
const resolveThemeColor = (expression, fallback) => {
const probe = document.createElement('span');
probe.style.color = expression;
probe.style.position = 'fixed';
probe.style.visibility = 'hidden';
document.body.appendChild(probe);
const resolved = getComputedStyle(probe).color || fallback;
probe.remove();
return resolved;
};
const colorToHex = (color, fallback) => {
try {
const probe = document.createElement('canvas');
probe.width = probe.height = 1;
const probeCtx = probe.getContext('2d');
probeCtx.fillStyle = color;
probeCtx.fillRect(0, 0, 1, 1);
const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data;
return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`;
} catch { return fallback; }
};
const surfaceColor = resolveThemeColor('var(--panel, var(--bg))', '#282c34');
const surfaceColorHex = colorToHex(surfaceColor, '#282c34');
const highlightColor = colorToHex(
resolveThemeColor('var(--accent, var(--red))', '#e06c75'),
'#e06c75',
);
const colorInput = container.querySelector('.note-form-draw-color');
if (colorInput) colorInput.value = highlightColor;
// Bump the backing-store resolution for retina displays so strokes stay
// crisp. Set only style.width — leaving style.height to auto so the canvas
// scales uniformly via its intrinsic aspect ratio. If both CSS dimensions
@@ -4022,7 +4193,7 @@ function _wireCanvas(container, initialImageUrl) {
canvas.height = cssH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.fillStyle = '#ffffff';
ctx.fillStyle = surfaceColor;
ctx.fillRect(0, 0, cssW, cssH);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
@@ -4046,7 +4217,7 @@ function _wireCanvas(container, initialImageUrl) {
rm.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
ctx.fillStyle = '#ffffff';
ctx.fillStyle = surfaceColor;
ctx.fillRect(0, 0, cssW, cssH);
rm.remove();
});
@@ -4054,7 +4225,6 @@ function _wireCanvas(container, initialImageUrl) {
}
}
const colorInput = container.querySelector('.note-form-draw-color');
// Swap the native browser color dialog for the in-house HSV picker
// (same one used by Themes + the gallery editor). Existing `input` event
// listeners + .value reads keep working — see colorPicker.js.
@@ -4087,6 +4257,7 @@ function _wireCanvas(container, initialImageUrl) {
// on Undo. Cap to 30 to keep memory bounded.
const _undoStack = [];
const UNDO_LIMIT = 30;
const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true }));
const _snapshot = () => {
try {
const w = canvas.width, h = canvas.height;
@@ -4103,6 +4274,7 @@ function _wireCanvas(container, initialImageUrl) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.putImageData(prev, 0, 0);
ctx.restore();
_markCanvasDirty();
};
const _pos = (e) => {
@@ -4212,6 +4384,7 @@ function _wireCanvas(container, initialImageUrl) {
ctx.textBaseline = 'top';
ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7);
ctx.restore();
_markCanvasDirty();
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (ev) => {
@@ -4250,14 +4423,17 @@ function _wireCanvas(container, initialImageUrl) {
return;
}
const erasing = mode === 'eraser';
ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over';
ctx.strokeStyle = erasing ? 'rgba(0,0,0,1)' : (colorInput?.value || '#222');
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = erasing ? surfaceColor : (colorInput?.value || highlightColor);
ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1);
ctx.lineTo(p.x, p.y);
ctx.stroke();
last = p;
};
const _end = () => { drawing = false; last = null; _shapeSnapshot = null; };
const _end = () => {
if (drawing) _markCanvasDirty();
drawing = false; last = null; _shapeSnapshot = null;
};
canvas.addEventListener('mousedown', _begin);
canvas.addEventListener('mousemove', _move);
@@ -4280,10 +4456,10 @@ function _wireCanvas(container, initialImageUrl) {
const isEraser = next === 'eraser';
const isPen = next === 'pen';
const isText = next.startsWith('text-');
// Swatch: white while erasing, restored as soon as we leave that mode.
// Swatch: theme surface while erasing, restored when leaving that mode.
if (isEraser && !wasEraser && colorInput) {
_preEraseColor = colorInput.value;
colorInput.value = '#ffffff';
colorInput.value = surfaceColorHex;
} else if (!isEraser && wasEraser && colorInput && _preEraseColor) {
colorInput.value = _preEraseColor;
_preEraseColor = null;
@@ -4339,17 +4515,21 @@ function _wireCanvas(container, initialImageUrl) {
// Export the canvas as a PNG dataURL, upload it via the existing /api/upload
// endpoint, and resolve to a persistent URL. Returns null on failure.
async function _uploadCanvasAsPng(canvas) {
async function _uploadCanvasAsPng(canvas, galleryId = null) {
if (!canvas) return null;
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
if (!blob) return null;
const fd = new FormData();
fd.append('files', blob, 'drawing.png');
if (galleryId) fd.append('gallery_id', galleryId);
try {
const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json();
const id = data.files?.[0]?.id;
return id ? `${API_BASE}/api/upload/${id}` : null;
return id ? {
url: `${API_BASE}/api/upload/${id}`,
galleryId: data.files?.[0]?.gallery_id || null,
} : null;
} catch { return null; }
}
@@ -4364,10 +4544,18 @@ function _createNote(type = 'todo') {
const form = _buildForm(_n);
form.classList.add('note-form-new');
body.prepend(form);
form.querySelector('.note-form-title').focus();
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');
}
export function newNote() {
if (!_open) openPanel();
requestAnimationFrame(() => _createNote('note'));
}
// Build the plain-text/markdown form of a note for clipboard copy.
function _serializeNoteForCopy(note) {
const lines = [];
@@ -4821,17 +5009,12 @@ function _openMobileFullscreenEdit(id, fromCard) {
// ISN'T a link flips back to the input for editing.
form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode);
// Move Archive + Delete from the form's footer actions row up into
// the header bar (to the right of the back chevron) so they're
// always reachable without scrolling and free up the footer for
// Cancel/Save only. Handlers stay attached when nodes move.
// 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 && archiveBtn) {
archiveBtn.classList.remove('note-form-collapsible');
headerActions.appendChild(archiveBtn);
}
if (headerActions && deleteBtn) {
deleteBtn.classList.remove('note-form-collapsible');
headerActions.appendChild(deleteBtn);
@@ -4955,7 +5138,7 @@ function _closeMobileFullscreenEdit(opts = {}) {
}, 220);
}
// ── Long-press drag-to-reorder ───────────────────────────────────────
// ── Long-press select ────────────────────────────────────────────────
function _bindLongPressDrag(card) {
let pressTimer = null;
let startX = 0, startY = 0;
@@ -4971,14 +5154,11 @@ function _bindLongPressDrag(card) {
armed = true;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
// Capture the touch object so the timer callback can pass it to
// _enterDragMode → _beginGrab. The finger is still held down, so
// the drag starts the instant the timer fires.
const heldTouch = { clientX: startX, clientY: startY };
pressTimer = setTimeout(() => {
if (!armed) return;
try { navigator.vibrate?.(15); } catch {}
_enterDragMode(card, heldTouch);
_suppressNextNoteTapId = card.dataset.noteId;
_enterSelectMode(card.dataset.noteId);
}, HOLD_MS);
}, { passive: true });
card.addEventListener('touchmove', (e) => {
@@ -5313,6 +5493,19 @@ async function _initReminders() {
// link the agent emits after a manage_notes create. Falls back to
// just opening the panel when the card isn't found (panel still
// loading, note in a different filter, etc.).
function _smoothScrollToNoteCard(card) {
if (!card) return;
const scroller = card.closest('.notes-pane-body');
if (!scroller) {
try { card.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {}
return;
}
const scrollerRect = scroller.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const delta = (cardRect.top - scrollerRect.top) - (scroller.clientHeight - cardRect.height) / 2;
scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta), behavior: 'smooth' });
}
async function openNote(noteId) {
const wasOpen = !!(isPanelOpen && isPanelOpen());
_showingArchived = false;
@@ -5340,7 +5533,7 @@ async function openNote(noteId) {
const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`)
|| document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`);
if (card) {
try { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
_smoothScrollToNoteCard(card);
card.classList.add('note-card-flash');
setTimeout(() => card.classList.remove('note-card-flash'), 1600);
return true;
@@ -5354,7 +5547,7 @@ async function openNote(noteId) {
setTimeout(tryNext, 120);
}
const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNote, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge };
const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNote, newNote, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge };
export default notesModule;
export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen, openNote };
window.notesModule = notesModule;