font choices, flushSave robustness, nav toggle fix
Some checks failed
Build and push Joplock image / build-and-push (push) Has been cancelled

- Add rounded + humanist font families with expanded fallback stacks
- Refactor flushSave() from minified one-liner: watchdog timeout, mutex
  for encrypted autosave coordination, proper cleanup on all paths
- Fix openNavFolderAndFirstNote so already-expanded folders can collapse
- Service worker cache v46 -> v47
This commit is contained in:
igor 2026-07-10 23:30:05 +12:00
parent 44a71039d7
commit 965e8f9ff4
6 changed files with 132 additions and 21 deletions

View file

@ -217,7 +217,7 @@ const normalizeSettings = settings => ({
autoLogout: !!Number(settings.autoLogout) || settings.autoLogout === true || settings.autoLogout === '1',
autoLogoutMinutes: normalizeInteger(settings.autoLogoutMinutes, defaultSettings.autoLogoutMinutes, 1, 480),
theme: validThemes.includes(settings.theme) ? settings.theme : defaultSettings.theme,
noteFontFamily: (settings.noteFontFamily === 'mono' || settings.noteFontFamily === 'serif') ? settings.noteFontFamily : defaultSettings.noteFontFamily,
noteFontFamily: (['mono', 'serif', 'rounded', 'humanist'].includes(settings.noteFontFamily)) ? settings.noteFontFamily : defaultSettings.noteFontFamily,
newlineBehavior: (settings.newlineBehavior === 'linebreak' || settings.newlineBehavior === 'invert') ? settings.newlineBehavior : defaultSettings.newlineBehavior,
liveSearch: !!Number(settings.liveSearch) || settings.liveSearch === true || settings.liveSearch === '1',
highlightActiveLine: settings.highlightActiveLine !== false && settings.highlightActiveLine !== '0' && settings.highlightActiveLine !== 0,

View file

@ -14,7 +14,11 @@ const ASSET_VERSION = '20260706cm6-markdown2';
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';
return f === 'mono' ? "'Cascadia Mono',monospace" : f === 'serif' ? "Georgia,'Times New Roman',serif" : '';
if (f === 'mono') return "'Cascadia Mono','SF Mono',Consolas,'Liberation Mono',Menlo,monospace";
if (f === 'serif') return "Georgia,'Times New Roman',Times,serif";
if (f === 'rounded') return "'SF Pro Rounded',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif";
if (f === 'humanist') return "Optima,Candara,'Segoe UI',Calibri,sans-serif";
return "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Helvetica Neue',sans-serif";
};
const formatBytes = value => {
@ -56,7 +60,7 @@ const layoutPage = (options = {}) => {
<link rel="stylesheet" href="/styles.css?v=${ASSET_VERSION}" />
<title>Joplock</title>
</head>
<body class="theme-dark-grey${noteFontFamilyCSS(settings)==="'Cascadia Mono',monospace"?' note-body-monospace':''}" style="--font-family-note:${noteFontFamilyCSS(settings)};--font-size-note:${escapeHtml(settings.noteFontSize || 15)}px;--font-size-note-mobile:${escapeHtml(settings.mobileNoteFontSize || ((settings.noteFontSize || 15) + 2))}px;--font-size-code:${escapeHtml(settings.codeFontSize || 12)}px;--font-size-markdown:${escapeHtml(settings.markdownFontSize || 14)}px;">
<body class="theme-dark-grey${noteFontFamilyCSS(settings)==="'Cascadia Mono','SF Mono',Consolas,'Liberation Mono',Menlo,monospace"?' note-body-monospace':''}" style="--font-family-note:${noteFontFamilyCSS(settings)};--font-size-note:${escapeHtml(settings.noteFontSize || 15)}px;--font-size-note-mobile:${escapeHtml(settings.mobileNoteFontSize || ((settings.noteFontSize || 15) + 2))}px;--font-size-code:${escapeHtml(settings.codeFontSize || 12)}px;--font-size-markdown:${escapeHtml(settings.markdownFontSize || 14)}px;">
<script>
(function(){
var keys=['joplock-theme','joplock-nav-collapsed','joplock-nav-folders'];
@ -108,7 +112,7 @@ const layoutPage = (options = {}) => {
<script src="/app.js?v=${ASSET_VERSION}" defer></script>
<title>Joplock</title>
</head>
<body class="app-shell theme-${escapeHtml(settings.theme || 'matrix')}${noteFontFamilyCSS(settings)==="'Cascadia Mono',monospace"?' note-body-monospace':''}${settings.uiMode === 'mobile' ? ' force-mobile' : ''}${settings.uiMode === 'desktop' ? ' force-desktop' : ''}" style="--font-family-note:${noteFontFamilyCSS(settings)};--font-size-note:${escapeHtml(settings.noteFontSize || 15)}px;--font-size-note-mobile:${escapeHtml(settings.mobileNoteFontSize || ((settings.noteFontSize || 15) + 2))}px;--font-size-code:${escapeHtml(settings.codeFontSize || 12)}px;--font-size-markdown:${escapeHtml(settings.markdownFontSize || 14)}px;" data-newline-behavior="${escapeHtml(settings.newlineBehavior || 'linebreak')}">
<body class="app-shell theme-${escapeHtml(settings.theme || 'matrix')}${noteFontFamilyCSS(settings)==="'Cascadia Mono','SF Mono',Consolas,'Liberation Mono',Menlo,monospace"?' note-body-monospace':''}${settings.uiMode === 'mobile' ? ' force-mobile' : ''}${settings.uiMode === 'desktop' ? ' force-desktop' : ''}" style="--font-family-note:${noteFontFamilyCSS(settings)};--font-size-note:${escapeHtml(settings.noteFontSize || 15)}px;--font-size-note-mobile:${escapeHtml(settings.mobileNoteFontSize || ((settings.noteFontSize || 15) + 2))}px;--font-size-code:${escapeHtml(settings.codeFontSize || 12)}px;--font-size-markdown:${escapeHtml(settings.markdownFontSize || 14)}px;" data-newline-behavior="${escapeHtml(settings.newlineBehavior || 'linebreak')}">
<div id="note-loading-overlay" aria-hidden="true">
<div class="note-loading-ring"></div>
<div class="note-loading-label">Loading note…</div>

View file

@ -207,8 +207,10 @@ const settingsPage = (options = {}) => {
<label class="settings-field">
<span>Note font</span>
<select id="settings-note-font-family" class="login-input" onchange="saveSetting('noteFontFamily',this.value)">
<option value="sans"${!settings.noteFontFamily||settings.noteFontFamily==='sans'?' selected':''}>System (sans-serif)</option>
<option value="serif"${settings.noteFontFamily==='serif'?' selected':''}>Serif</option>
<option value="sans"${!settings.noteFontFamily||settings.noteFontFamily==='sans'?' selected':''}>System Sans</option>
<option value="serif"${settings.noteFontFamily==='serif'?' selected':''}>System Serif</option>
<option value="rounded"${settings.noteFontFamily==='rounded'?' selected':''}>Rounded</option>
<option value="humanist"${settings.noteFontFamily==='humanist'?' selected':''}>Humanist</option>
<option value="mono"${settings.noteFontFamily==='mono'?' selected':''}>Monospace (Cascadia Mono)</option>
</select>
</label>
@ -599,7 +601,7 @@ const settingsPage = (options = {}) => {
function dlog(){if(DEBUG){try{console.log.apply(console,['[joplock]'].concat([].slice.call(arguments)))}catch(_){}}}
dlog('settings script loaded');
function bindRange(inputId,valueId,cssVar){var input=document.getElementById(inputId);var value=document.getElementById(valueId);if(!input||!value)return;document.body.style.setProperty(cssVar,input.value+'px');value.textContent=input.value+'px';input.addEventListener('input',function(){document.body.style.setProperty(cssVar,this.value+'px');value.textContent=this.value+'px'})}
function bindFont(){var input=document.getElementById('settings-note-font-family');if(!input)return;function apply(v){document.body.style.setProperty('--font-family-note',v==='mono'?"'Cascadia Mono',monospace":v==='serif'?"Georgia,'Times New Roman',serif":'');document.body.classList.toggle('note-body-monospace',v==='mono')}apply(input.value);input.addEventListener('change',function(){apply(this.value)})}
function bindFont(){var input=document.getElementById('settings-note-font-family');if(!input)return;function apply(v){var stacks={mono:"'Cascadia Mono','SF Mono',Consolas,'Liberation Mono',Menlo,monospace",serif:"Georgia,'Times New Roman',Times,serif",rounded:"'SF Pro Rounded',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif",humanist:"Optima,Candara,'Segoe UI',Calibri,sans-serif",sans:"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Helvetica Neue',sans-serif"};document.body.style.setProperty('--font-family-note',stacks[v]||'');document.body.classList.toggle('note-body-monospace',v==='mono')}apply(input.value);input.addEventListener('change',function(){apply(this.value)})}
bindRange('settings-note-font','settings-note-font-value','--font-size-note');
bindRange('settings-markdown-font','settings-markdown-font-value','--font-size-markdown');
bindRange('settings-mobile-note-font','settings-mobile-note-font-value','--font-size-note-mobile');

View file

@ -348,24 +348,41 @@ function openNavFolderAndFirstNote(id){
if(isMobileShellMode())return;
var el=document.querySelector('.nav-folder[data-folder-id="'+id.replace(/"/g,'\\"')+'"]');
if(!el)return;
var alreadyOpen=!el.classList.contains('collapsed');
toggleNavFolder(id,true);
// After notes are loaded, click the first one
function clickFirst(notesDiv){
var first=notesDiv.querySelector('.notelist-item');
if(first)first.click();
if(!el.classList.contains('collapsed')){
// Folder is already expanded — this must be a real toggle (collapse),
// not another forced-open. Without this branch, toggleNavFolder(id,true)
// below always forces collapsed=false, so an open folder like "Examples"
// could never be closed again via its title text (only the tiny chevron
// button, which calls toggleNavFolder with no force arg, worked).
toggleNavFolder(id,false);
return;
}
var notesDiv=el.querySelector('.nav-folder-notes[data-folder-id]');
// Capture BEFORE toggling: toggleNavFolder() sets data-loaded="1" itself
// (synchronously, before its htmx fetch) whenever it lazy-loads notes, so
// checking data-loaded only after the call below can't distinguish
// "already loaded from a previous expand" from "just started loading now".
var wasLoaded=!!(notesDiv&&notesDiv.getAttribute('data-loaded'));
toggleNavFolder(id,true);
// After notes are loaded, click the first one
function clickFirst(){
var first=notesDiv&&notesDiv.querySelector('.notelist-item');
if(first)first.click();
}
if(!notesDiv)return;
if(alreadyOpen&&notesDiv.getAttribute('data-loaded')){
// Notes already present — click immediately
clickFirst(notesDiv);
if(wasLoaded){
// Notes were already loaded from an earlier expand (e.g. this folder was
// last collapsed via the chevron button rather than reloaded) —
// toggleNavFolder() won't issue an htmx fetch, so no htmx:afterSettle
// will ever fire for this notesDiv. Click immediately instead.
clickFirst();
} else {
// Wait for htmx swap into this notesDiv to settle, then click first note
// First expand: wait for the lazy-load htmx swap into this notesDiv to
// settle, then click first note.
function onSettle(e){
if(e.detail&&e.detail.target===notesDiv){
document.body.removeEventListener('htmx:afterSettle',onSettle);
clickFirst(notesDiv);
clickFirst();
}
}
document.body.addEventListener('htmx:afterSettle',onSettle);
@ -3368,7 +3385,75 @@ document.addEventListener('visibilitychange',function(){if(document.hidden){_log
window.addEventListener('load',function(){if(isMobileShellMode())return;initNavPanel();initEditorPanel()});
window.addEventListener('resize',applyMobileTitleMode);
document.addEventListener('keydown',function(e){var mac=navigator.platform&&navigator.platform.indexOf('Mac')!==-1;var mod=mac?e.metaKey:e.ctrlKey;if(mod&&e.shiftKey&&e.key.toLowerCase()==='z'){e.preventDefault();undoSnapshot()}});
function flushSave(callback){var form=activeEditorForm();if(!form){_log('flushSave skip (no form)');if(callback)callback(true);return}if(_saveTimer){clearTimeout(_saveTimer);_saveTimer=null}if(_saveTitleTimer){clearTimeout(_saveTitleTimer);_saveTitleTimer=null}if(_pvSyncTimer){clearTimeout(_pvSyncTimer);_pvSyncTimer=null;_syncPVInFlight=true;syncPV();_syncPVInFlight=false}else{var pv=getPV();if(pv&&_previewDirty)syncPV();else if(!pv&&_editorMode!=='markdown'&&_editorMode!=='md')tinyMCESyncToTA()}syncTitleToHidden({silent:true});var h=formHash(form);if(h===_savedHash){_log('flushSave skip (hash unchanged)',h);if(callback)callback(true);return}setSaveState('<span class="autosave-saving">Saving...</span>','Saving...');var restoreReq=function(){};buildFlushRequest(form).then(function(req){if(!req){if(callback)callback(true);return}restoreReq=req.restore||restoreReq;_log('flushSave',req.url);return fetch(req.url,{method:'PUT',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:req.body}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.text()}).then(function(html){restoreReq();_log('flushSave ok',html.slice(0,80));snapshotHash();window._mobileNewNoteId=null;setSaveState('<span class="autosave-ok">Saved</span>','Saved');if(callback)callback(true)})}).catch(function(err){restoreReq();_log('flushSave error',err);showOffline();if(callback)callback(false)})}
function flushSave(callback){
var form=activeEditorForm();
if(!form){_log('flushSave skip (no form)');if(callback)callback(true);return}
if(_saveTimer){clearTimeout(_saveTimer);_saveTimer=null}
if(_saveTitleTimer){clearTimeout(_saveTitleTimer);_saveTitleTimer=null}
if(_pvSyncTimer){clearTimeout(_pvSyncTimer);_pvSyncTimer=null;_syncPVInFlight=true;syncPV();_syncPVInFlight=false}else{var pv=getPV();if(pv&&_previewDirty)syncPV();else if(!pv&&_editorMode!=='markdown'&&_editorMode!=='md')tinyMCESyncToTA()}
syncTitleToHidden({silent:true});
var run=function(){
var h=formHash(form);
if(h===_savedHash){_log('flushSave skip (hash unchanged)',h);if(callback)callback(true);return}
setSaveState('<span class="autosave-saving">Saving...</span>','Saving...');
var restoreReq=function(){};
var settled=false;
var p;
// finish(): the one place that clears the watchdog, releases the
// _flushSaveInFlight mutex, and invokes callback exactly once — used
// on success, on error, AND on watchdog timeout. Without this, a
// fetch() that never settles (dropped connection, server hang) would
// (a) leave #note-body's name="body" stripped forever (formHash()
// then silently ignores all future body edits — note stuck "Edited",
// nothing ever saves again) and (b) never invoke callback, which
// permanently freezes the nav-intercept click handler below (clicking
// another note in the list would do nothing).
var finish=function(ok){
if(settled)return;
settled=true;
clearTimeout(watchdog);
if(_flushSaveInFlight===p)_flushSaveInFlight=null;
if(callback)callback(ok);
};
var watchdog=setTimeout(function(){
_log('flushSave watchdog fired, forcing restore');
restoreReq();
showOffline();
finish(false);
},20000);
p=buildFlushRequest(form).then(function(req){
if(settled)return;
if(!req){finish(true);return}
restoreReq=req.restore||restoreReq;
_log('flushSave',req.url);
return fetch(req.url,{method:'PUT',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:req.body}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.text()}).then(function(html){
if(settled)return;
restoreReq();
_log('flushSave ok',html.slice(0,80));
snapshotHash();
window._mobileNewNoteId=null;
setSaveState('<span class="autosave-ok">Saved</span>','Saved');
finish(true);
});
}).catch(function(err){
if(settled)return;
restoreReq();
_log('flushSave error',err);
showOffline();
finish(false);
});
_flushSaveInFlight=p;
};
// If an encrypted autosave already owns the textarea's temporary
// body-swap (_setOneShotEncryptedBody), wait for it instead of racing a
// second swap on the same element — see _encryptedAutosaveInFlight.
if(_encryptedAutosaveInFlight){
_log('flushSave waiting for in-flight encrypted autosave');
_encryptedAutosaveInFlight.then(run,run);
}else{
run();
}
}
function shouldInterceptNavigationClick(target){var navTarget=target&&target.closest?target.closest('.notelist-item,.sidebar-item,.nav-folder-row,[hx-get],[hx-post],[hx-delete]'):null;if(!navTarget)return null;if(navTarget.closest&&navTarget.closest('#note-editor-form'))return null;if(navTarget.closest&&navTarget.closest('#folder-context-menu,#folder-modal,#link-modal,#history-modal,#code-modal,#new-folder-modal,#vault-modal,#empty-trash-modal'))return null;return navTarget}
document.addEventListener('click',function(e){var navTarget=shouldInterceptNavigationClick(e.target);if(!navTarget)return;var form=activeEditorForm();var status=queryActiveEditor('#autosave-status');var dirty=status&&status.querySelector('.autosave-edited');if(!form||!dirty)return;_log('navigation click intercepted, flushing save',navTarget.className||navTarget.id||navTarget.tagName);e.preventDefault();e.stopImmediatePropagation();flushSave(function(saved){if(saved){_log('flushSave done, re-clicking navigation target');navTarget.click()}})},true);
window.joplockLiveSearch=_cfg.liveSearch||false;
@ -4388,6 +4473,18 @@ function _setOneShotEncryptedBody(form,ciphertext){
};
}
// Mutex guarding #note-body's temporary name-swap (_setOneShotEncryptedBody):
// the debounced autosave (_triggerEncryptedSave) and the forced flush
// (flushSave/buildFlushRequest) both rename-then-restore the SAME textarea
// while encrypting. If both ran concurrently, one's restore() could be
// skipped/duplicated and name="body" could be left stripped permanently —
// formHash() only hashes named elements, so body edits would then be
// invisible to it forever ("hash unchanged" on every keystroke, note stuck
// on "Edited", edit silently never saved). These flags let each side wait
// for the other instead of racing it on the same element.
var _encryptedAutosaveInFlight=null; // Promise<boolean> while _triggerEncryptedSave owns the swap
var _flushSaveInFlight=null; // Promise<boolean> while flushSave owns the swap
function _triggerEncryptedSave(form,ciphertext){
if(!form)return;
// Cancel any pending plaintext autosave (e.g. from the change event that
@ -4399,6 +4496,8 @@ function _triggerEncryptedSave(form,ciphertext){
var restore=_setOneShotEncryptedBody(form,ciphertext);
var done=false;
var cleanupTimer=null;
var resolveInFlight=function(){};
_encryptedAutosaveInFlight=new Promise(function(res){resolveInFlight=res});
var cleanup=function(success){
if(done)return;
done=true;
@ -4410,6 +4509,8 @@ function _triggerEncryptedSave(form,ciphertext){
// Re-snapshot the form hash after the textarea regains its name attr,
// so a subsequent debounced scheduleSave compares apples to apples.
if(success)snapshotHash();
_encryptedAutosaveInFlight=null;
resolveInFlight(success);
};
var onDone=function(e){
if(e.target!==form)return;
@ -4462,6 +4563,10 @@ scheduleSave=function(){
_saveTimer=null;
if(_syncPVInFlight||_pvSyncTimer){scheduleSave();return}
if(_anyModalOpen()){scheduleSave();return}
// A forced flushSave() (e.g. navigating to another note) already owns
// the textarea's temporary name-swap — don't race it with a second
// encrypt+swap on the same element. Re-check once it's done.
if(_flushSaveInFlight){_log('encrypted scheduleSave deferred, flush in flight');scheduleSave();return}
if(!form)return;
var h=formHash(form);
if(h===_savedHash){_log('encrypted scheduleSave skip, hash unchanged',h);return}

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'joplock-shell-v46-20260708greytheme3';
const CACHE_NAME = 'joplock-shell-v47-20260708fontchoices';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/hljs.min.js', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png', '/maskable-icon-192.png', '/maskable-icon-512.png', '/apple-touch-icon.png', '/fonts/CascadiaMono-Regular.woff2', '/fonts/CascadiaMono-Bold.woff2'];
self.addEventListener('install', event => {

View file

@ -558,7 +558,7 @@ body.mobile-shell-active {
body.note-body-monospace,
body.note-body-monospace .editor-preview {
font-family: 'Cascadia Mono', monospace;
font-family: 'Cascadia Mono','SF Mono',Consolas,'Liberation Mono',Menlo,monospace;
}
.editor-preview {
font-family: var(--font-family-note, inherit);