This commit is contained in:
vincent
2026-08-17 15:41:27 +00:00
commit c08ff876f7
44 changed files with 16029 additions and 0 deletions

607
static/js/cooking-mode.js Normal file
View File

@@ -0,0 +1,607 @@
// Cooking Mode for CookCLI
(function() {
'use strict';
// ─── Constants ───────────────────────────────────────────────
const WHEEL_THRESHOLD = 80;
const WHEEL_RESET_MS = 200;
const SWIPE_THRESHOLD = 50;
const SWIPE_CLAMP_MAX = 150;
const SWIPE_SCALE_FACTOR = 0.001;
// ─── State ───────────────────────────────────────────────────
const state = {
cards: [],
cardEls: [],
currentIndex: 0,
wakeLock: null,
overlay: null,
touchStartY: 0,
touchCurrentY: 0,
isSwiping: false,
scrolling: false,
canScrollCard: false,
startScrollTop: 0,
wheelAccumulator: 0,
wheelResetTimeout: null,
triggerElement: null
};
// ─── Data Loading ─────────────────────────────────────────────
function loadRecipeData() {
const el = document.getElementById('cooking-mode-data');
if (!el) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
console.error('Failed to parse cooking mode data:', e);
return null;
}
}
function captureStepHTML() {
const stepElements = [];
const sectionEls = document.querySelectorAll('.md\\:col-span-2 ol');
sectionEls.forEach(function(ol) {
ol.querySelectorAll(':scope > li').forEach(function(li) {
const textDiv = li.querySelector('.leading-8');
if (textDiv) {
stepElements.push(textDiv.innerHTML);
}
});
});
return stepElements;
}
// ─── Card Building ────────────────────────────────────────────
function buildCards(data, stepHTMLs) {
const cards = [];
let stepHTMLIndex = 0;
data.sections.forEach(function(section, sectionIndex) {
const sectionName = section.name || (data.sections.length > 1 ? 'Main' : null);
if (section.ingredients.length > 0 || sectionName) {
cards.push({
type: 'section',
sectionIndex: sectionIndex,
name: sectionName || data.name,
ingredients: section.ingredients
});
}
section.steps.forEach(function(step) {
cards.push({
type: 'step',
sectionIndex: sectionIndex,
sectionName: sectionName,
number: step.number,
totalInSection: section.steps.length,
image: step.image,
html: stepHTMLs[stepHTMLIndex] || '',
ingredients: step.ingredients
});
stepHTMLIndex++;
});
});
cards.push({
type: 'done',
sectionIndex: data.sections.length - 1,
name: data.name
});
return cards;
}
// ─── Rendering ────────────────────────────────────────────────
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function renderCard(card) {
const div = document.createElement('div');
div.className = 'cooking-card hidden-card';
if (card.type === 'section') {
div.classList.add('cooking-card-section');
let ingredientsHTML = '';
if (card.ingredients.length > 0) {
const items = card.ingredients.map(function(ing) {
const qty = [ing.quantity, ing.unit].filter(Boolean).join(' ');
const note = ing.note ? '<span class="cooking-mise-note">(' + escapeHTML(ing.note) + ')</span>' : '';
return '<div class="cooking-mise-item">' +
'<span class="cooking-mise-name">' + escapeHTML(ing.name) + ' ' + note + '</span>' +
(qty ? '<span class="cooking-mise-qty">' + escapeHTML(qty) + '</span>' : '') +
'</div>';
}).join('');
ingredientsHTML = '<div class="cooking-mise-grid">' + items + '</div>';
}
div.innerHTML = '<h2>' + escapeHTML(card.name) + '</h2>' + ingredientsHTML;
div.querySelectorAll('.cooking-mise-item').forEach(function(item) {
item.addEventListener('click', function() {
item.classList.toggle('checked');
});
});
}
else if (card.type === 'step') {
div.classList.add('cooking-card-step');
const imageHTML = card.image
? '<img class="cooking-step-image" src="' + escapeHTML(card.image) + '" alt="Step ' + card.number + '" />'
: '';
let ingredientsHTML = '';
if (card.ingredients.length > 0) {
ingredientsHTML = '<div class="cooking-step-ingredients">' +
card.ingredients.map(function(ing) {
const qty = [ing.quantity, ing.unit].filter(Boolean).join(' ');
const note = ing.note ? ' (' + escapeHTML(ing.note) + ')' : '';
return '<span>' + escapeHTML(ing.name) + (qty ? ': ' + escapeHTML(qty) : '') + note + '</span>';
}).join('') + '</div>';
}
div.innerHTML =
imageHTML +
'<div class="cooking-step-text"><span class="cooking-step-number">' + card.number + '</span>' + card.html + '</div>' +
ingredientsHTML;
}
else if (card.type === 'done') {
div.classList.add('cooking-card-done');
div.innerHTML = '<h2>Bon Appetit!</h2>' +
'<button class="cooking-done-close-btn">Close</button>';
div.querySelector('.cooking-done-close-btn').addEventListener('click', closeCookingMode);
}
return div;
}
function renderOverlay(data, cards) {
const overlay = document.createElement('div');
overlay.className = 'cooking-overlay';
overlay.id = 'cooking-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Cooking mode: ' + data.name);
// ARIA live region for step announcements
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.className = 'sr-only';
liveRegion.id = 'cooking-status';
overlay.appendChild(liveRegion);
// Header
const header = document.createElement('div');
header.className = 'cooking-header';
const title = document.createElement('div');
title.className = 'cooking-header-title';
title.textContent = data.name;
const sectionsNav = document.createElement('div');
sectionsNav.className = 'cooking-header-sections';
sectionsNav.id = 'cooking-sections-nav';
data.sections.forEach(function(section, i) {
const pill = document.createElement('button');
pill.className = 'cooking-section-pill';
pill.textContent = section.name || (data.sections.length > 1 ? 'Main' : 'Steps');
pill.dataset.sectionIndex = i;
pill.addEventListener('click', function() { navigateToSection(i); });
sectionsNav.appendChild(pill);
});
const closeBtn = document.createElement('button');
closeBtn.className = 'cooking-close-btn';
closeBtn.setAttribute('aria-label', 'Close cooking mode');
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', closeCookingMode);
header.appendChild(title);
if (data.sections.length > 1 || (data.sections[0] && data.sections[0].name)) {
header.appendChild(sectionsNav);
}
header.appendChild(closeBtn);
// Carousel
const carousel = document.createElement('div');
carousel.className = 'cooking-carousel';
carousel.id = 'cooking-carousel';
const container = document.createElement('div');
container.className = 'cooking-card-container';
container.id = 'cooking-card-container';
cards.forEach(function(card) {
container.appendChild(renderCard(card));
});
// Click on prev/next card to navigate
container.addEventListener('click', function(e) {
const card = e.target.closest('.cooking-card');
if (!card) return;
if (card.classList.contains('prev')) {
navigateTo(state.currentIndex - 1);
} else if (card.classList.contains('next')) {
navigateTo(state.currentIndex + 1);
}
});
carousel.appendChild(container);
// Progress bar
const progress = document.createElement('div');
progress.className = 'cooking-progress';
progress.innerHTML = '<div class="cooking-progress-bar" id="cooking-progress-bar"></div>';
overlay.appendChild(header);
overlay.appendChild(carousel);
overlay.appendChild(progress);
// Cache card element references
state.cardEls = Array.from(container.querySelectorAll('.cooking-card'));
return overlay;
}
// ─── Navigation ───────────────────────────────────────────────
function updateCards() {
const cardEls = state.cardEls;
if (cardEls.length === 0) return;
// Only update cards that need changing: previous current +-1 and new current +-1
const indicesToUpdate = new Set();
for (let i = 0; i < cardEls.length; i++) {
const el = cardEls[i];
if (el.classList.contains('current') || el.classList.contains('prev') || el.classList.contains('next')) {
indicesToUpdate.add(i);
}
}
indicesToUpdate.add(state.currentIndex);
if (state.currentIndex > 0) indicesToUpdate.add(state.currentIndex - 1);
if (state.currentIndex < cardEls.length - 1) indicesToUpdate.add(state.currentIndex + 1);
indicesToUpdate.forEach(function(i) {
const el = cardEls[i];
el.classList.remove('current', 'prev', 'next', 'hidden-card', 'swiping');
el.style.transform = '';
if (i === state.currentIndex) {
el.classList.add('current');
} else if (i === state.currentIndex - 1) {
el.classList.add('prev');
} else if (i === state.currentIndex + 1) {
el.classList.add('next');
} else {
el.classList.add('hidden-card');
}
});
// Update progress bar
const progressBar = document.getElementById('cooking-progress-bar');
if (progressBar) {
const pct = state.cards.length > 1
? (state.currentIndex / (state.cards.length - 1)) * 100
: 100;
progressBar.style.width = pct + '%';
}
// Update section pills
const currentCard = state.cards[state.currentIndex];
if (currentCard) {
const pills = document.querySelectorAll('.cooking-section-pill');
for (let j = 0; j < pills.length; j++) {
const isActive = parseInt(pills[j].dataset.sectionIndex) === currentCard.sectionIndex;
pills[j].classList.toggle('active', isActive);
}
}
// Update ARIA live region
updateAriaStatus();
}
function updateAriaStatus() {
const statusEl = document.getElementById('cooking-status');
if (!statusEl) return;
const currentCard = state.cards[state.currentIndex];
if (!currentCard) return;
let message;
if (currentCard.type === 'section') {
message = 'Ingredients for ' + currentCard.name;
} else if (currentCard.type === 'step') {
message = 'Step ' + currentCard.number + ' of ' + currentCard.totalInSection;
} else if (currentCard.type === 'done') {
message = 'Recipe complete. Bon Appetit!';
}
statusEl.textContent = message;
}
function navigateTo(index) {
if (index < 0 || index >= state.cards.length) return;
state.currentIndex = index;
updateCards();
}
function navigateToSection(sectionIndex) {
for (let i = 0; i < state.cards.length; i++) {
if (state.cards[i].sectionIndex === sectionIndex) {
navigateTo(i);
return;
}
}
}
// ─── Touch Handling ───────────────────────────────────────────
function getCurrentCardEl() {
return state.cardEls[state.currentIndex] || null;
}
function isAtScrollTop(el) {
return el.scrollTop <= 0;
}
function isAtScrollBottom(el) {
return el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
}
function cardCanScroll(el) {
return el.scrollHeight > el.clientHeight + 1;
}
function onTouchStart(e) {
if (e.touches.length !== 1) return;
state.touchStartY = e.touches[0].clientY;
state.touchCurrentY = state.touchStartY;
state.isSwiping = false;
state.scrolling = false;
const currentEl = getCurrentCardEl();
if (currentEl) {
state.startScrollTop = currentEl.scrollTop;
state.canScrollCard = cardCanScroll(currentEl);
} else {
state.canScrollCard = false;
}
}
function onTouchMove(e) {
if (e.touches.length !== 1) return;
state.touchCurrentY = e.touches[0].clientY;
const delta = state.touchCurrentY - state.touchStartY;
const currentEl = getCurrentCardEl();
if (!currentEl) return;
// If card has scrollable content, let it scroll until at boundary
if (state.canScrollCard && !state.isSwiping) {
const swipingUp = delta < 0;
const swipingDown = delta > 0;
const atTop = isAtScrollTop(currentEl);
const atBottom = isAtScrollBottom(currentEl);
// Allow native scroll if not at boundary, or scrolling into content
if ((swipingUp && !atBottom) || (swipingDown && !atTop)) {
state.scrolling = true;
return; // let browser handle scroll
}
// At boundary and swiping away from content — start card swipe
if (!state.scrolling) {
state.isSwiping = true;
state.touchStartY = state.touchCurrentY; // reset start to boundary
} else {
// Was scrolling, now at boundary — wait for a new gesture
return;
}
} else {
state.isSwiping = true;
}
if (state.isSwiping) {
const swipeDelta = state.touchCurrentY - state.touchStartY;
const clampedDelta = Math.max(-SWIPE_CLAMP_MAX, Math.min(SWIPE_CLAMP_MAX, swipeDelta));
currentEl.classList.add('swiping');
currentEl.style.transform = 'translateY(' + clampedDelta + 'px) scale(' + (1 - Math.abs(clampedDelta) * SWIPE_SCALE_FACTOR) + ')';
}
}
function onTouchEnd() {
try {
const currentEl = getCurrentCardEl();
if (state.isSwiping) {
const delta = state.touchCurrentY - state.touchStartY;
if (currentEl) {
currentEl.classList.remove('swiping');
currentEl.style.transform = '';
}
if (delta < -SWIPE_THRESHOLD && state.currentIndex < state.cards.length - 1) {
navigateTo(state.currentIndex + 1);
} else if (delta > SWIPE_THRESHOLD && state.currentIndex > 0) {
navigateTo(state.currentIndex - 1);
} else {
updateCards();
}
}
} finally {
state.isSwiping = false;
state.scrolling = false;
}
}
// ─── Keyboard Handling ────────────────────────────────────────
function onKeyDown(e) {
if (!state.overlay) return;
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
navigateTo(state.currentIndex + 1);
break;
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
navigateTo(state.currentIndex - 1);
break;
case 'Escape':
e.preventDefault();
closeCookingMode();
break;
}
}
// ─── Wheel Handling ─────────────────────────────────────────────
function onWheel(e) {
if (!state.overlay) return;
e.preventDefault();
// Accumulate wheel delta (trackpads send many small events)
state.wheelAccumulator += e.deltaY;
// Reset accumulator after a pause in scrolling
if (state.wheelResetTimeout) clearTimeout(state.wheelResetTimeout);
state.wheelResetTimeout = setTimeout(function() {
state.wheelAccumulator = 0;
state.wheelResetTimeout = null;
}, WHEEL_RESET_MS);
// Navigate only when accumulated delta exceeds threshold
if (state.wheelAccumulator >= WHEEL_THRESHOLD) {
navigateTo(state.currentIndex + 1);
state.wheelAccumulator = 0;
} else if (state.wheelAccumulator <= -WHEEL_THRESHOLD) {
navigateTo(state.currentIndex - 1);
state.wheelAccumulator = 0;
}
}
// ─── Wake Lock ────────────────────────────────────────────────
function acquireWakeLock() {
if (!('wakeLock' in navigator)) return;
navigator.wakeLock.request('screen').then(function(lock) {
state.wakeLock = lock;
lock.addEventListener('release', function() {
state.wakeLock = null;
});
}).catch(function(err) {
console.warn('Wake lock request failed:', err);
});
}
function releaseWakeLock() {
if (state.wakeLock) {
state.wakeLock.release().catch(function() {});
state.wakeLock = null;
}
}
function onVisibilityChange() {
if (state.overlay && document.visibilityState === 'visible' && !state.wakeLock) {
acquireWakeLock();
}
}
function onBeforeUnload() {
releaseWakeLock();
}
// ─── Open / Close ─────────────────────────────────────────────
function startCookingMode() {
const data = loadRecipeData();
if (!data) return;
// Save trigger element for focus restoration
state.triggerElement = document.activeElement;
const stepHTMLs = captureStepHTML();
state.cards = buildCards(data, stepHTMLs);
if (state.cards.length === 0) return;
state.currentIndex = 0;
state.overlay = renderOverlay(data, state.cards);
document.body.appendChild(state.overlay);
document.body.style.overflow = 'hidden';
updateCards();
// Add event listeners
const carousel = document.getElementById('cooking-carousel');
carousel.addEventListener('touchstart', onTouchStart, { passive: true });
carousel.addEventListener('touchmove', onTouchMove, { passive: true });
carousel.addEventListener('touchend', onTouchEnd, { passive: true });
carousel.addEventListener('wheel', onWheel, { passive: false });
document.addEventListener('keydown', onKeyDown);
window.addEventListener('beforeunload', onBeforeUnload);
acquireWakeLock();
document.addEventListener('visibilitychange', onVisibilityChange);
// Move focus to overlay for accessibility
state.overlay.setAttribute('tabindex', '-1');
state.overlay.focus();
}
function closeCookingMode() {
if (!state.overlay) return;
// Remove carousel event listeners
const carousel = document.getElementById('cooking-carousel');
if (carousel) {
carousel.removeEventListener('touchstart', onTouchStart);
carousel.removeEventListener('touchmove', onTouchMove);
carousel.removeEventListener('touchend', onTouchEnd);
carousel.removeEventListener('wheel', onWheel);
}
state.overlay.remove();
state.overlay = null;
document.body.style.overflow = '';
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('beforeunload', onBeforeUnload);
releaseWakeLock();
document.removeEventListener('visibilitychange', onVisibilityChange);
// Clear wheel state
if (state.wheelResetTimeout) {
clearTimeout(state.wheelResetTimeout);
state.wheelResetTimeout = null;
}
state.wheelAccumulator = 0;
// Restore focus to trigger element
if (state.triggerElement && state.triggerElement.focus) {
state.triggerElement.focus();
state.triggerElement = null;
}
state.cards = [];
state.cardEls = [];
state.currentIndex = 0;
}
// ─── Global API ───────────────────────────────────────────────
window.startCookingMode = startCookingMode;
window.closeCookingMode = closeCookingMode;
})();

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,408 @@
/**
* Keyboard shortcuts for Cook CLI web interface
*
* Global shortcuts (available on all pages):
* - / Focus search
* - g h Go to home/recipes
* - g s Go to shopping list
* - g p Go to pantry
* - g x Go to preferences
* - ? Show keyboard shortcuts help
* - Escape Close modals/dropdowns
* - t Toggle theme (dark/light)
*
* Recipe page shortcuts:
* - c Start cooking mode
* - e Edit recipe
* - a Add to shopping list
* - p Print recipe
* - +/- Increase/decrease scale
* - [/] Decrease/increase scale by 0.5
*/
(function() {
'use strict';
// Track pending key sequences (for multi-key shortcuts like "g h")
let pendingKey = null;
let pendingTimeout = null;
// Check if user is typing in a text-entry input field
function isTyping(event) {
const target = event.target;
const tagName = target.tagName.toLowerCase();
if (tagName === 'textarea' || tagName === 'select') {
return true;
}
// Only block for text-entry inputs, not checkboxes/radios/etc.
if (tagName === 'input') {
const type = (target.type || 'text').toLowerCase();
const textTypes = ['text', 'password', 'email', 'number', 'search', 'url', 'tel', 'date', 'time', 'datetime-local'];
return textTypes.includes(type);
}
// Check for contenteditable elements
if (target.isContentEditable) {
return true;
}
// Check for CodeMirror editor
if (target.closest('.cm-editor')) {
return true;
}
return false;
}
// Clear pending key sequence
function clearPendingKey() {
pendingKey = null;
if (pendingTimeout) {
clearTimeout(pendingTimeout);
pendingTimeout = null;
}
}
// Show keyboard shortcuts modal
window.showShortcutsHelp = function() {
const existingModal = document.getElementById('keyboard-shortcuts-modal');
if (existingModal) {
existingModal.classList.remove('hidden');
return;
}
const staticMode = window.__STATIC_MODE__ === true;
const kbd = 'class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono"';
const row = (label, keys) => `
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">${label}</span>
<span>${keys}</span>
</div>`;
const k = (s) => `<kbd ${kbd}>${s}</kbd>`;
const navRows = [
row('Focus search', k('/')),
row('Navigate search results', `${k('&uarr;')} ${k('&darr;')} ${k('Enter')}`),
row('Go to recipes', `${k('g')} ${k('h')}`),
];
if (!staticMode) {
navRows.push(
row('Go to shopping list', `${k('g')} ${k('s')}`),
row('Go to pantry', `${k('g')} ${k('p')}`),
row('Go to preferences', `${k('g')} ${k('x')}`)
);
}
const recipeRows = [row('Start cooking mode', k('c'))];
if (!staticMode) {
recipeRows.push(
row('Edit recipe', k('e')),
row('Add to shopping list', k('a'))
);
}
recipeRows.push(row('Print recipe', k('p')));
if (!staticMode) {
recipeRows.push(
row('Increase scale', k('+')),
row('Decrease scale', k('-'))
);
}
const shoppingSection = staticMode ? '' : `
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">Shopping List</h3>
<div class="space-y-2">
${row('Clear all items', k('c'))}
</div>
</div>`;
const modal = document.createElement('div');
modal.id = 'keyboard-shortcuts-modal';
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50';
modal.innerHTML = `
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Keyboard Shortcuts</h2>
<button onclick="closeShortcutsHelp()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="p-6 overflow-y-auto max-h-[60vh]">
<div class="grid md:grid-cols-2 gap-6">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">Navigation</h3>
<div class="space-y-2">${navRows.join('')}</div>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">General</h3>
<div class="space-y-2">
${row('Toggle theme', k('t'))}
${row('Show shortcuts', k('?'))}
${row('Close modal', k('Esc'))}
</div>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">Recipe Page</h3>
<div class="space-y-2">${recipeRows.join('')}</div>
</div>
${shoppingSection}
</div>
</div>
<div class="p-4 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500 dark:text-gray-400">
Press <kbd class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs font-mono">Esc</kbd> to close
</div>
</div>
`;
document.body.appendChild(modal);
// Close on backdrop click
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeShortcutsHelp();
}
});
}
// Close keyboard shortcuts modal
window.closeShortcutsHelp = function() {
const modal = document.getElementById('keyboard-shortcuts-modal');
if (modal) {
modal.classList.add('hidden');
}
};
// Handle keyboard events
function handleKeydown(event) {
// Don't handle if user is typing
if (isTyping(event)) {
// But still allow Escape to blur inputs
if (event.key === 'Escape') {
event.target.blur();
}
return;
}
// Don't handle if modifier keys are pressed (except Shift for ? and +)
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const key = event.key;
// Handle pending key sequences (like "g h")
if (pendingKey === 'g') {
clearPendingKey();
const pfx = window.__PREFIX__ || '';
const staticMode = window.__STATIC_MODE__ === true;
switch (key) {
case 'h':
case 'r':
event.preventDefault();
window.location.href = pfx + '/';
return;
case 's':
if (staticMode) break;
event.preventDefault();
window.location.href = pfx + '/shopping-list';
return;
case 'p':
if (staticMode) break;
event.preventDefault();
window.location.href = pfx + '/pantry';
return;
case 'x':
if (staticMode) break;
event.preventDefault();
window.location.href = pfx + '/preferences';
return;
}
// If no valid second key, fall through to handle as new key
}
// Global shortcuts
switch (key) {
case '/':
event.preventDefault();
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
return;
case 'g':
event.preventDefault();
pendingKey = 'g';
// Clear pending after 1.5 seconds
pendingTimeout = setTimeout(clearPendingKey, 1500);
return;
case '?':
event.preventDefault();
showShortcutsHelp();
return;
case 'Escape':
event.preventDefault();
// Close shortcuts modal if open
const shortcutsModal = document.getElementById('keyboard-shortcuts-modal');
if (shortcutsModal && !shortcutsModal.classList.contains('hidden')) {
closeShortcutsHelp();
return;
}
// Close search results if open
const searchResults = document.getElementById('search-results');
if (searchResults && !searchResults.classList.contains('hidden')) {
searchResults.classList.add('hidden');
return;
}
// Close any other modals
const modals = document.querySelectorAll('[data-modal]');
modals.forEach(modal => modal.classList.add('hidden'));
return;
case 't':
event.preventDefault();
if (typeof toggleTheme === 'function') {
toggleTheme();
}
return;
}
// Page-specific shortcuts
const path = window.location.pathname;
const pfx2 = window.__PREFIX__ || '';
// Recipe page shortcuts
if (path.startsWith(pfx2 + '/recipe/')) {
handleRecipeShortcuts(event, key);
}
// Shopping list page shortcuts
else if (path === pfx2 + '/shopping-list') {
handleShoppingListShortcuts(event, key);
}
}
// Recipe page specific shortcuts
function handleRecipeShortcuts(event, key) {
const staticMode = window.__STATIC_MODE__ === true;
switch (key) {
case 'c':
event.preventDefault();
if (typeof startCookingMode === 'function') {
startCookingMode();
}
return;
case 'e':
if (staticMode) return;
event.preventDefault();
// Find and click the edit link
const editPfx = (window.__PREFIX__ || '') + '/edit/';
const editLink = document.querySelector(`a[href^="${editPfx}"]`);
if (editLink) {
editLink.click();
}
return;
case 'a':
if (staticMode) return;
event.preventDefault();
// Find and click the add to shopping list button
const addButton = document.querySelector('button[onclick^="addToShoppingList"]');
if (addButton) {
addButton.click();
}
return;
case 'p':
event.preventDefault();
window.print();
return;
case '+':
case '=': // = is on the same key as + without shift
if (staticMode) return;
event.preventDefault();
adjustScale(0.5);
return;
case '-':
case '_':
if (staticMode) return;
event.preventDefault();
adjustScale(-0.5);
return;
case ']':
if (staticMode) return;
event.preventDefault();
adjustScale(1);
return;
case '[':
if (staticMode) return;
event.preventDefault();
adjustScale(-1);
return;
}
}
// Adjust recipe scale
function adjustScale(delta) {
const scaleInput = document.getElementById('scale');
if (!scaleInput) return;
let newValue = parseFloat(scaleInput.value) + delta;
const min = parseFloat(scaleInput.min) || 0.5;
const max = parseFloat(scaleInput.max) || 200;
// Clamp to valid range
newValue = Math.max(min, Math.min(max, newValue));
// Round to avoid floating point issues
newValue = Math.round(newValue * 10) / 10;
if (newValue !== parseFloat(scaleInput.value)) {
scaleInput.value = newValue;
// Trigger the onchange event
scaleInput.dispatchEvent(new Event('change'));
}
}
// Shopping list page specific shortcuts
function handleShoppingListShortcuts(event, key) {
switch (key) {
case 'c':
event.preventDefault();
// Clear the list (if the function exists)
if (typeof clearList === 'function') {
if (confirm('Clear all items from shopping list?')) {
clearList();
}
}
return;
}
}
// Initialize keyboard shortcuts
document.addEventListener('keydown', handleKeydown);
// Add visual indicator for keyboard navigation
document.addEventListener('DOMContentLoaded', function() {
// Add a small hint in the search placeholder about the shortcut
const searchInput = document.getElementById('search-input');
if (searchInput) {
const currentPlaceholder = searchInput.getAttribute('placeholder');
if (currentPlaceholder && !currentPlaceholder.includes('/')) {
// Don't modify placeholder - keep it clean
}
}
});
})();

110
static/js/search.js Normal file
View File

@@ -0,0 +1,110 @@
(function () {
var prefix = window.__PREFIX__ || ".";
var input = document.getElementById("search-input");
var results = document.getElementById("search-results");
if (!input || !results) return;
var index = null;
var selectedIndex = -1;
function loadIndex() {
if (index !== null) return Promise.resolve(index);
return fetch(prefix + "/static/search-index.json")
.then(function (r) { return r.json(); })
.then(function (data) {
index = data;
return data;
})
.catch(function (e) {
console.error("search-index load failed", e);
index = [];
return index;
});
}
function score(entry, q) {
var ql = q.toLowerCase();
if (entry.title.toLowerCase().indexOf(ql) !== -1) return 3;
if (entry.tags.some(function (t) { return t.toLowerCase().indexOf(ql) !== -1; })) return 2;
if (entry.ingredients.some(function (i) { return i.toLowerCase().indexOf(ql) !== -1; })) return 1;
return 0;
}
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function render(matches) {
if (matches.length === 0) {
results.innerHTML = '<div class="p-4 text-gray-500 text-center">No recipes found</div>';
} else {
results.innerHTML = matches.map(function (m) {
var href = prefix + "/" + m.path;
return '<a href="' + escapeHtml(href) + '" class="search-result block px-4 py-3 hover:bg-gradient-to-r hover:from-purple-50 hover:to-pink-50 transition-colors border-b border-gray-100 last:border-b-0">' +
'<div class="font-medium text-gray-800">' + escapeHtml(m.title) + '</div>' +
'</a>';
}).join("");
}
results.classList.remove("hidden");
}
function updateSearchSelection() {
var items = results.querySelectorAll("a");
items.forEach(function (item, i) {
if (i === selectedIndex) {
item.classList.add("search-selected");
item.scrollIntoView({ block: "nearest" });
} else {
item.classList.remove("search-selected");
}
});
}
var timeout;
input.addEventListener("input", function () {
clearTimeout(timeout);
var q = this.value.trim();
selectedIndex = -1;
if (q.length < 2) {
results.classList.add("hidden");
return;
}
timeout = setTimeout(function () {
loadIndex().then(function (idx) {
var matches = idx
.map(function (e) { return { e: e, s: score(e, q) }; })
.filter(function (x) { return x.s > 0; })
.sort(function (a, b) { return b.s - a.s; })
.slice(0, 20)
.map(function (x) { return x.e; });
render(matches);
});
}, 150);
});
input.addEventListener("keydown", function (e) {
var items = results.querySelectorAll("a");
if (items.length === 0 || results.classList.contains("hidden")) return;
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
updateSearchSelection();
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSearchSelection();
} else if (e.key === "Enter") {
if (selectedIndex >= 0 && selectedIndex < items.length) {
e.preventDefault();
items[selectedIndex].click();
}
}
});
document.addEventListener("click", function (e) {
if (!input.contains(e.target) && !results.contains(e.target)) {
results.classList.add("hidden");
selectedIndex = -1;
}
});
})();

View File

@@ -0,0 +1,176 @@
import { StreamLanguage } from "@codemirror/language";
// Cooklang syntax highlighting mode for CodeMirror 6
// Ported from cooklang-obsidian/src/mode/cook/cook.ts
export const cooklang = StreamLanguage.define({
name: "cooklang",
startState() {
return {
formatting: false,
nextMultiline: false,
inMultiline: false,
afterSection: false,
position: null,
inFrontmatter: false,
inMetadata: false,
inNote: false,
inComment: false,
afterAmount: false // Track if we just closed a {} to look for (prep)
};
},
token(stream, state) {
const sol = stream.sol() || state.afterSection;
const eol = stream.eol();
state.afterSection = false;
if (sol) {
// Reset single-line states at start of new line
state.inNote = false;
state.inMetadata = false;
if (state.nextMultiline) {
state.inMultiline = true;
state.nextMultiline = false;
} else {
state.position = null;
}
}
if (eol && !state.nextMultiline) {
state.inMultiline = false;
state.position = null;
}
if (sol) {
while (stream.eatSpace()) {}
}
// Frontmatter delimiters (---)
if (sol && stream.match(/^---\s*$/)) {
state.inFrontmatter = !state.inFrontmatter;
return "meta";
}
// Inside frontmatter
if (state.inFrontmatter) {
stream.skipToEnd();
return "meta";
}
// Sections (= Section Name, == Section ==, etc.)
if (sol && stream.match(/^=+/)) {
stream.skipToEnd();
return "heading";
}
// Line comments (-- comment)
if (sol && stream.match(/^--/)) {
stream.skipToEnd();
return "comment";
}
// Block comments ([- comment -])
if (stream.match(/^\[-/)) {
state.inComment = true;
return "comment";
}
if (state.inComment) {
if (stream.match(/-]/)) {
state.inComment = false;
return "comment";
}
stream.skipToEnd();
return "comment";
}
// Metadata (>> key: value)
if (sol && stream.match(/^>>/)) {
state.inMetadata = true;
state.position = "metadata-key";
return "meta";
}
if (state.inMetadata) {
if (state.position === "metadata-key") {
if (stream.match(/^[^:]+:/)) {
state.position = "metadata-value";
return "meta";
}
stream.skipToEnd();
return "meta";
} else if (state.position === "metadata-value") {
stream.skipToEnd();
return "meta";
}
}
// Notes (lines starting with >)
if (sol && stream.match(/^>/)) {
state.inNote = true;
return "comment";
}
if (state.inNote) {
stream.skipToEnd();
return "comment";
}
// Shorthand preparations (prep) after ingredient amounts like @onion{1}(chopped)
if (state.afterAmount && stream.match(/^\([^)]*\)/)) {
state.afterAmount = false;
return "string"; // Use string style for prep instructions
}
// Reset afterAmount if we see anything else
if (state.afterAmount && !stream.match(/^\s*(?=\()/, false)) {
state.afterAmount = false;
}
// Ingredients (@ingredient{amount})
if (stream.match(/^@([^@#~]+?(?={))/)) {
return "variableName";
} else if (stream.match(/^@(.+?\b)/)) {
return "variableName";
}
// Cookware (#cookware{amount})
if (stream.match(/^#([^@#~]+?(?={))/)) {
return "keyword";
} else if (stream.match(/^#(.+?\b)/)) {
return "keyword";
}
// Timers (~timer{amount})
if (stream.match(/^~([^@#~]+?(?={))/)) {
return "number";
} else if (stream.match(/^~(.+?\b)/)) {
return "number";
}
// Amounts in curly braces
const ch = stream.next();
if (!ch) return null;
if (ch === '{') {
if (state.position !== "timer") state.position = "measurement";
return null;
}
if (ch === '}') {
state.position = null;
state.afterAmount = true; // Look for (prep) after closing brace
return null;
}
if (ch === '%' && (state.position === "measurement" || state.position === "timer")) {
state.position = "unit";
return null;
}
return state.position;
}
});

209
static/js/src/editor.js Normal file
View File

@@ -0,0 +1,209 @@
import { EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, HighlightStyle, bracketMatching } from "@codemirror/language";
import { searchKeymap, highlightSelectionMatches } from "@codemirror/search";
import { linter } from "@codemirror/lint";
import { autocompletion, snippet } from "@codemirror/autocomplete";
import { tags as t } from "@lezer/highlight";
import { cooklang } from "./cooklang-mode.js";
// Diagnostics support
let currentDiagnostics = [];
export function setDiagnostics(view, diagnostics) {
currentDiagnostics = diagnostics;
// Trigger a state update to rerun the linter
if (view) {
view.dispatch({
effects: []
});
}
}
// Linter that returns current diagnostics
const cooklangLinter = linter((view) => currentDiagnostics, { delay: 0 });
// Completion support
let completionResolver = null;
export function setCompletionResolver(resolver) {
completionResolver = resolver;
}
// Async completion source for Cooklang
async function cooklangCompletions(context) {
if (!completionResolver) return null;
const pos = context.pos;
const line = context.state.doc.lineAt(pos);
const textBefore = line.text.slice(0, pos - line.from);
// Check for trigger characters (@, #, ~)
// Include . / - in character class so @./path file references trigger completion
const match = textBefore.match(/[@#~]([a-zA-Z0-9_./\-]*)$/);
if (!match) return null;
const prefix = match[1];
const from = pos - prefix.length;
const triggerChar = match[0][0];
try {
const items = await completionResolver(line.number - 1, pos - line.from);
if (!items || items.length === 0) return null;
return {
from: from,
// Keep completion open while user types path chars; CodeMirror's
// built-in FuzzyMatcher handles client-side narrowing.
validFor: /^[a-zA-Z0-9_./\-]*$/,
options: items.map(item => {
const type = item.kind === 17 ? 'file' : item.kind === 6 ? 'variable' : item.kind === 14 ? 'keyword' : 'text';
// Prefer textEdit.newText (used by recipe references with explicit ranges),
// then insertText, then label
const text = item.textEdit?.newText || item.insertText || item.label;
// Check if LSP sent a snippet (insertTextFormat === 2)
if (item.insertTextFormat === 2 && text.includes('$')) {
// Convert LSP snippet syntax to CodeMirror: $0 -> ${}, $1 -> ${1}
const cmSnippet = text.replace(/\$0/g, '${}').replace(/\$(\d+)/g, '${$1}');
return {
label: item.label,
type: type,
detail: item.detail || '',
apply: snippet(cmSnippet)
};
}
return {
label: item.label,
type: type,
detail: item.detail || '',
apply: text
};
})
};
} catch (e) {
console.error('Completion error:', e);
return null;
}
}
// Custom highlight style for Cooklang syntax
const cooklangHighlightStyle = HighlightStyle.define([
{ tag: t.variableName, color: "#ea580c", fontWeight: "600" }, // Ingredients (orange)
{ tag: t.keyword, color: "#16a34a", fontWeight: "600" }, // Cookware (green)
{ tag: t.number, color: "#dc2626", fontWeight: "600" }, // Timers (red)
{ tag: t.comment, color: "#9ca3af", fontStyle: "italic" }, // Comments
{ tag: t.meta, color: "#8b5cf6" }, // Metadata
{ tag: t.unit, color: "#6366f1" }, // Units
{ tag: t.heading, color: "#0891b2", fontWeight: "700", fontSize: "1.1em" }, // Sections (cyan)
{ tag: t.string, color: "#d97706", fontStyle: "italic" } // Prep instructions (amber italic)
]);
// Editor base theme for layout
const cooklangBaseTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "14px"
},
".cm-scroller": {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
overflow: "auto"
},
".cm-content": {
padding: "1rem"
},
".cm-line": {
padding: "0 0.5rem"
}
});
// Cursor position callback
let cursorPositionCallback = null;
export function setCursorPositionCallback(callback) {
cursorPositionCallback = callback;
}
// Initialize editor
export function initEditor(container, initialContent, onChange) {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && onChange) {
onChange(update.state.doc.toString());
}
// Always update cursor position on any update
const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos);
const col = pos - line.from + 1;
if (cursorPositionCallback) {
cursorPositionCallback(line.number, col);
}
});
const state = EditorState.create({
doc: initialContent,
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
history(),
bracketMatching(),
highlightSelectionMatches(),
cooklang,
syntaxHighlighting(cooklangHighlightStyle),
cooklangBaseTheme,
cooklangLinter,
autocompletion({
override: [cooklangCompletions],
activateOnTyping: true
}),
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap
]),
updateListener,
EditorView.lineWrapping
]
});
const view = new EditorView({
state,
parent: container
});
// Set cursor to beginning of document (line 1, col 1)
view.dispatch({
selection: { anchor: 0 }
});
view.focus();
return view;
}
// Get editor content
export function getContent(view) {
return view.state.doc.toString();
}
// Set editor content
export function setContent(view, content) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: content
}
});
}
// Export for global access
window.CooklangEditor = {
initEditor,
getContent,
setContent,
setDiagnostics,
setCompletionResolver,
setCursorPositionCallback
};