493 lines
18 KiB
JavaScript
493 lines
18 KiB
JavaScript
function app() {
|
|
return {
|
|
previewHtml: window.__INITIAL_PREVIEW__ || '',
|
|
previewVisible: true,
|
|
editorVisible: true,
|
|
tab: window.__DEFAULT_TAB__ || 'notes',
|
|
// Only one menu can be open at a time. Keeping a single name here (rather
|
|
// than a set of booleans) makes mutual exclusion automatic: opening Theme
|
|
// while Help is open reassigns this value, so Help's dropdown is hidden.
|
|
openMenu: null,
|
|
openSubmenu: null,
|
|
_previewTimeout: null,
|
|
// Id of the note currently being dragged, or null. These live on the
|
|
// component rather than in module-level variables so they are scoped to the
|
|
// single Alpine instance and torn down with it.
|
|
_dragNoteId: null,
|
|
// The nav item currently highlighted as a drop target, or null. Tracked so
|
|
// the highlight follows the pointer instead of accumulating on every item
|
|
// the cursor sweeps across.
|
|
_dropTarget: null,
|
|
// Whether the folder navigation is collapsed. Shared between the mobile
|
|
// pull-tab button and the View menu toggle so they stay in sync.
|
|
folderNavCollapsed: false,
|
|
|
|
init() {
|
|
// Close menus on any click outside the menu bar. Alpine's `.outside`
|
|
// modifier cannot be used here because Maud treats `.` in attribute
|
|
// names as CSS class shorthand, so we bind it manually.
|
|
this._onDocClick = (event) => {
|
|
const menuBar = document.querySelector('.menu-bar');
|
|
if (this.openMenu && menuBar && !menuBar.contains(event.target)) {
|
|
this.closeMenus();
|
|
}
|
|
};
|
|
document.addEventListener('click', this._onDocClick);
|
|
|
|
// Restore the persisted folder-nav preference. If the nav was hidden
|
|
// last time, refresh the sidebar to All Notes so the state is consistent.
|
|
this.folderNavCollapsed = this._loadFolderNavPreference();
|
|
if (this.folderNavCollapsed) {
|
|
this.refreshSidebarToAllNotes();
|
|
}
|
|
|
|
this.initNoteDragging();
|
|
this.initQuoteRotation();
|
|
},
|
|
|
|
/// Wires up dragging a sidebar note onto a folder nav item to re-file it.
|
|
///
|
|
/// Bound on `document` rather than on the elements themselves: the
|
|
/// listeners then survive any re-render of the sidebar, and one `dragover`
|
|
/// handler can serve every drop target.
|
|
initNoteDragging() {
|
|
this._onDragStart = (event) => {
|
|
const item = event.target.closest('.note-item');
|
|
if (!item) return;
|
|
|
|
// Dragging a text selection out of the preview should stay a text
|
|
// drag. Without this check, selecting a snippet with the mouse and
|
|
// dragging it would hijack the gesture into a re-file — surprising,
|
|
// and destructive because the drop would be hard to undo.
|
|
//
|
|
// Two conditions, both load-bearing:
|
|
//
|
|
// `hover: hover` gates the whole check. On a touch screen the move
|
|
// gesture is a *long press*, and the press creates a text selection
|
|
// before the drag begins — so testing "is anything selected" would
|
|
// cancel the gesture that mobile users depend on. This guard was
|
|
// written assuming drag was desktop-only and made the long-press
|
|
// re-file fail whenever the press landed on selectable text.
|
|
//
|
|
// `item.contains(...)` scopes it to the item being dragged. The
|
|
// previous page-wide version also refused the drag whenever text was
|
|
// selected anywhere else, including in the editor, which has nothing
|
|
// to do with dragging a sidebar row.
|
|
if (window.matchMedia && window.matchMedia('(hover: hover)').matches) {
|
|
const selection = window.getSelection();
|
|
if (
|
|
selection &&
|
|
!selection.isCollapsed &&
|
|
selection.anchorNode &&
|
|
item.contains(selection.anchorNode)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const id = item.dataset.noteId;
|
|
if (!id) return;
|
|
|
|
this._dragNoteId = id;
|
|
item.classList.add('dragging');
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', id);
|
|
};
|
|
|
|
this._onDragOver = (event) => {
|
|
if (!this._dragNoteId) return;
|
|
// Only folder nav items accept a note. Everywhere else the default is
|
|
// left alone, so dropping selected text into the editor still works.
|
|
const target = this.dropFolderAt(event.target);
|
|
if (!target) return;
|
|
// Required, or the drop is rejected and never fires `drop`.
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
if (this._dropTarget !== target) {
|
|
if (this._dropTarget) this._dropTarget.classList.remove('drop-target');
|
|
target.classList.add('drop-target');
|
|
this._dropTarget = target;
|
|
}
|
|
};
|
|
|
|
this._onDragLeave = (event) => {
|
|
if (!this._dragNoteId) return;
|
|
const item = this.dropFolderAt(event.target);
|
|
if (item && item === this._dropTarget) {
|
|
item.classList.remove('drop-target');
|
|
this._dropTarget = null;
|
|
}
|
|
};
|
|
|
|
this._onDrop = (event) => {
|
|
if (!this._dragNoteId) return;
|
|
const target = this.dropFolderAt(event.target);
|
|
if (!target) return;
|
|
|
|
// Required, or the browser navigates to the dropped text as a URL and
|
|
// the app is replaced by a 404 page.
|
|
event.preventDefault();
|
|
|
|
const id = this._dragNoteId;
|
|
const folder = target.dataset.dropFolder || '';
|
|
this.endNoteDrag();
|
|
this.moveNote(id, folder);
|
|
};
|
|
|
|
this._onDragEnd = () => this.endNoteDrag();
|
|
|
|
document.addEventListener('dragstart', this._onDragStart);
|
|
document.addEventListener('dragover', this._onDragOver);
|
|
document.addEventListener('dragleave', this._onDragLeave);
|
|
document.addEventListener('drop', this._onDrop);
|
|
document.addEventListener('dragend', this._onDragEnd);
|
|
},
|
|
|
|
destroy() {
|
|
if (this._onDocClick) {
|
|
document.removeEventListener('click', this._onDocClick);
|
|
}
|
|
if (this._quoteTimer) {
|
|
clearInterval(this._quoteTimer);
|
|
this._quoteTimer = null;
|
|
}
|
|
if (this._onDragStart) {
|
|
document.removeEventListener('dragstart', this._onDragStart);
|
|
document.removeEventListener('dragover', this._onDragOver);
|
|
document.removeEventListener('dragleave', this._onDragLeave);
|
|
document.removeEventListener('drop', this._onDrop);
|
|
document.removeEventListener('dragend', this._onDragEnd);
|
|
}
|
|
},
|
|
|
|
/// The folder nav item under `node`, or null if the pointer is elsewhere.
|
|
///
|
|
/// `closest` rather than `matches`, because the pointer is usually over
|
|
/// the `<a>` or a `<span>` inside the item rather than the `<li>` itself.
|
|
dropFolderAt(node) {
|
|
if (!node || !node.closest) return null;
|
|
return node.closest('[data-drop-folder]');
|
|
},
|
|
|
|
/// Clears every trace of an in-flight drag.
|
|
///
|
|
/// Runs on `drop` and on `dragend`. `dragend` is the one that matters: it
|
|
/// fires when a drag is abandoned (Escape, dropped outside any target), and
|
|
/// without it the dragged item would stay dimmed and the folder highlighted
|
|
/// for the rest of the session.
|
|
endNoteDrag() {
|
|
this._dragNoteId = null;
|
|
if (this._dropTarget) {
|
|
this._dropTarget.classList.remove('drop-target');
|
|
this._dropTarget = null;
|
|
}
|
|
document.querySelectorAll('.note-item.dragging').forEach((el) => {
|
|
el.classList.remove('dragging');
|
|
});
|
|
},
|
|
|
|
/// Posts a re-file for `id` and reloads so the sidebar reflects the move.
|
|
moveNote(id, folder) {
|
|
const base = window.__BASE_PATH__ || '';
|
|
const body = new URLSearchParams();
|
|
body.append('folder', folder);
|
|
|
|
fetch(base + '/notes/' + encodeURIComponent(id) + '/move', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString()
|
|
})
|
|
.then(() => {
|
|
// Reload rather than following the redirect: if the note was just
|
|
// moved out of the folder being viewed, reloading keeps the user in
|
|
// that folder's now-correct listing instead of yanking them to the
|
|
// note page.
|
|
window.location.reload();
|
|
})
|
|
.catch((err) => {
|
|
console.error('Move failed:', err);
|
|
window.location.reload();
|
|
});
|
|
},
|
|
|
|
toggleMenu(name) {
|
|
this.openMenu = this.openMenu === name ? null : name;
|
|
this.openSubmenu = null;
|
|
},
|
|
|
|
toggleSubmenu(name, event) {
|
|
// Stop this click before it reaches the File menu's trigger, which would
|
|
// otherwise close the whole menu as the submenu opens.
|
|
event.stopPropagation();
|
|
this.openSubmenu = this.openSubmenu === name ? null : name;
|
|
},
|
|
|
|
closeMenus() {
|
|
this.openMenu = null;
|
|
this.openSubmenu = null;
|
|
},
|
|
|
|
setTab(name) {
|
|
this.tab = name;
|
|
// Selecting a tab should always reveal its pane, even if that pane had
|
|
// been collapsed from the View menu.
|
|
if (name === 'preview') this.previewVisible = true;
|
|
if (name === 'write') this.editorVisible = true;
|
|
},
|
|
|
|
updatePreview(content) {
|
|
if (this._previewTimeout) {
|
|
clearTimeout(this._previewTimeout);
|
|
}
|
|
this._previewTimeout = setTimeout(() => {
|
|
const formData = new URLSearchParams();
|
|
formData.append('content', content);
|
|
|
|
// The base path has to be prepended here. `app.js` is inlined into
|
|
// the page, so it cannot call the server-side `base.url()` helper
|
|
// that every form action uses — and an origin-absolute `/preview`
|
|
// resolves against the site root, which behind a reverse proxy
|
|
// mounted at a subdirectory never reaches the app. The response body
|
|
// would then be the proxy's own 404, swapped straight into the
|
|
// preview pane.
|
|
const base = window.__BASE_PATH__ || '';
|
|
|
|
fetch(base + '/preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: formData.toString()
|
|
})
|
|
.then(r => r.text())
|
|
.then(html => {
|
|
this.previewHtml = html;
|
|
})
|
|
.catch(err => console.error('Preview update failed:', err));
|
|
}, 300);
|
|
},
|
|
|
|
togglePreview() {
|
|
this.previewVisible = !this.previewVisible;
|
|
},
|
|
|
|
toggleEditor() {
|
|
this.editorVisible = !this.editorVisible;
|
|
},
|
|
|
|
toggleFolderNav() {
|
|
this.folderNavCollapsed = !this.folderNavCollapsed;
|
|
this._saveFolderNavPreference(this.folderNavCollapsed);
|
|
if (this.folderNavCollapsed) {
|
|
this.refreshSidebarToAllNotes();
|
|
}
|
|
},
|
|
|
|
/// localStorage key for the folder-nav preference, scoped by base path
|
|
/// so separate app installs on one host do not share the setting.
|
|
_folderNavStorageKey() {
|
|
const base = window.__BASE_PATH__ || '';
|
|
return 'eternalibre:folderNavCollapsed:' + base;
|
|
},
|
|
|
|
_loadFolderNavPreference() {
|
|
try {
|
|
return localStorage.getItem(this._folderNavStorageKey()) === 'true';
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
_saveFolderNavPreference(collapsed) {
|
|
try {
|
|
localStorage.setItem(this._folderNavStorageKey(), collapsed ? 'true' : 'false');
|
|
} catch (e) {
|
|
// Storage unavailable (private mode, quota, etc.) — the toggle still
|
|
// works for this session, it just won't persist.
|
|
}
|
|
},
|
|
|
|
/// Refresh the sidebar to the All Notes view without navigating away.
|
|
///
|
|
/// Fetches the server-rendered index page and swaps in the folder nav,
|
|
/// note list, toolbar folder field, and status count. The editor pane and
|
|
/// note form are untouched, so unsaved edits survive.
|
|
refreshSidebarToAllNotes() {
|
|
const base = window.__BASE_PATH__ || '';
|
|
const searchInput = document.querySelector('.search-form input[name="q"]');
|
|
const query = searchInput ? searchInput.value.trim() : '';
|
|
const url = base + '/' + (query ? '?q=' + encodeURIComponent(query) : '');
|
|
|
|
fetch(url)
|
|
.then(r => r.text())
|
|
.then(html => {
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
if (!doc.querySelector('.sidebar')) return; // not the app page
|
|
|
|
// Swap the folder nav list
|
|
const newNav = doc.querySelector('#folder-nav-list');
|
|
const oldNav = document.querySelector('#folder-nav-list');
|
|
if (newNav && oldNav) {
|
|
oldNav.innerHTML = newNav.innerHTML;
|
|
}
|
|
|
|
// Swap the note list
|
|
const newList = doc.querySelector('.note-list');
|
|
const oldList = document.querySelector('.note-list');
|
|
if (newList && oldList) {
|
|
oldList.innerHTML = newList.innerHTML;
|
|
}
|
|
|
|
// Update the toolbar's hidden folder field
|
|
const newFolderInput = doc.querySelector('.toolbar input[name="folder"]');
|
|
const oldFolderInput = document.querySelector('.toolbar input[name="folder"]');
|
|
if (newFolderInput && oldFolderInput) {
|
|
oldFolderInput.value = newFolderInput.value;
|
|
}
|
|
|
|
// Update the status bar note count
|
|
const newCount = doc.querySelector('.status-bar span');
|
|
const oldCount = document.querySelector('.status-bar span');
|
|
if (newCount && oldCount) {
|
|
oldCount.textContent = newCount.textContent;
|
|
}
|
|
})
|
|
.catch(err => console.error('Sidebar refresh failed:', err));
|
|
},
|
|
|
|
focusEditor() {
|
|
const editor = document.getElementById('editor');
|
|
if (editor) editor.focus();
|
|
},
|
|
|
|
/// Rotates the welcome quote beneath the tagline.
|
|
///
|
|
/// The first quote is server-rendered, so without JS the block simply
|
|
/// stays put. Rotation is a progressive enhancement for sighted mouse
|
|
/// and touch users: it pauses on hover/focus, honours
|
|
/// `prefers-reduced-motion`, and exposes an explicit Pause control.
|
|
initQuoteRotation() {
|
|
const quotes = window.__WELCOME_QUOTES__ || [];
|
|
const textEl = document.getElementById('welcome-quote-text');
|
|
const authorEl = document.getElementById('welcome-quote-author');
|
|
if (!quotes.length || quotes.length < 2 || !textEl || !authorEl) return;
|
|
|
|
this._quotes = quotes;
|
|
this._quoteIndex = 0;
|
|
this._quotesPaused = false;
|
|
|
|
const show = (index) => {
|
|
const quote = this._quotes[index];
|
|
if (!quote) return;
|
|
textEl.textContent = quote.text || '';
|
|
authorEl.textContent =
|
|
quote.author && quote.author.trim() ? quote.author : 'Anonymous';
|
|
};
|
|
|
|
const next = () => {
|
|
this._quoteIndex = (this._quoteIndex + 1) % this._quotes.length;
|
|
show(this._quoteIndex);
|
|
};
|
|
|
|
const prev = () => {
|
|
this._quoteIndex =
|
|
(this._quoteIndex - 1 + this._quotes.length) % this._quotes.length;
|
|
show(this._quoteIndex);
|
|
};
|
|
|
|
const pauseBtn = document.getElementById('quote-pause');
|
|
const nextBtn = document.getElementById('quote-next');
|
|
const prevBtn = document.getElementById('quote-prev');
|
|
const quoteEl = document.getElementById('welcome-quote');
|
|
|
|
const stop = () => {
|
|
if (this._quoteTimer) {
|
|
clearInterval(this._quoteTimer);
|
|
this._quoteTimer = null;
|
|
}
|
|
};
|
|
|
|
const start = () => {
|
|
stop();
|
|
// Auto-rotation is a motion affordance: when the OS asks for reduced
|
|
// motion, leave the first server-rendered quote in place instead.
|
|
const reduced =
|
|
window.matchMedia &&
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
if (!reduced && !this._quotesPaused) {
|
|
this._quoteTimer = setInterval(next, 8000);
|
|
}
|
|
};
|
|
|
|
if (nextBtn) {
|
|
nextBtn.addEventListener('click', () => {
|
|
next();
|
|
start();
|
|
});
|
|
}
|
|
if (prevBtn) {
|
|
prevBtn.addEventListener('click', () => {
|
|
prev();
|
|
start();
|
|
});
|
|
}
|
|
if (pauseBtn) {
|
|
pauseBtn.addEventListener('click', () => {
|
|
this._quotesPaused = !this._quotesPaused;
|
|
pauseBtn.textContent = this._quotesPaused ? 'Play' : 'Pause';
|
|
pauseBtn.setAttribute('aria-pressed', String(this._quotesPaused));
|
|
if (this._quotesPaused) {
|
|
stop();
|
|
} else {
|
|
start();
|
|
}
|
|
});
|
|
}
|
|
if (quoteEl) {
|
|
quoteEl.addEventListener('mouseenter', stop);
|
|
quoteEl.addEventListener('mouseleave', () => {
|
|
if (!this._quotesPaused) start();
|
|
});
|
|
quoteEl.addEventListener('focusin', stop);
|
|
quoteEl.addEventListener('focusout', () => {
|
|
if (!this._quotesPaused) start();
|
|
});
|
|
}
|
|
|
|
start();
|
|
},
|
|
|
|
saveCurrentNote() {
|
|
const form = document.getElementById('note-form');
|
|
if (form) form.requestSubmit();
|
|
},
|
|
|
|
filterNotes(query) {
|
|
const term = query.toLowerCase();
|
|
document.querySelectorAll('.note-item').forEach(item => {
|
|
const title = item.dataset.title || '';
|
|
const content = item.dataset.content || '';
|
|
item.style.display = (title.includes(term) || content.includes(term)) ? '' : 'none';
|
|
});
|
|
},
|
|
|
|
handleKeydown(event) {
|
|
if (event.key === 'Escape' && this.openMenu) {
|
|
this.closeMenus();
|
|
return;
|
|
}
|
|
if (event.ctrlKey || event.metaKey) {
|
|
if (event.key.toLowerCase() === 's') {
|
|
event.preventDefault();
|
|
this.saveCurrentNote();
|
|
} else if (event.key.toLowerCase() === 'n') {
|
|
event.preventDefault();
|
|
// Match on the path ending in /notes rather than an exact action,
|
|
// so this still works when the app is mounted under a base path.
|
|
const newNoteForm = document.querySelector(
|
|
'form[action$="/notes"], form[action$="/notes/"]'
|
|
);
|
|
if (newNoteForm) newNoteForm.submit();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|