fix(search): restore in-note search highlight for rendered (TinyMCE) mode + two-stage Esc
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run

This commit is contained in:
igor 2026-07-08 09:47:00 +12:00
parent 4ac4d167e8
commit 598a0333fe
5 changed files with 383 additions and 8 deletions

View file

@ -0,0 +1,86 @@
'use strict';
/**
* E2E: two-stage Escape for search.
*
* 1st Esc -> exit the in-note search (dismiss the find-nav bar, strip marks),
* leaving the note-list search results intact.
* 2nd Esc -> exit the note-listing search results and clear the search field.
*/
const { test, expect } = require('@playwright/test');
const {
login,
createNotebook,
deleteNotebook,
createDesktopNote,
setNoteTitle,
waitForSaved,
acceptDialogs,
hasAdminCredentials,
slug,
} = require('./helpers');
test.describe('two-stage Escape for search', () => {
test.skip(!hasAdminCredentials(), 'requires admin credentials');
test('first Esc exits in-note search, second Esc clears the note list search', async ({ page }) => {
test.setTimeout(90000);
acceptDialogs(page);
await login(page);
const notebook = slug('esc-nb');
const term = `zqe${Date.now().toString(36)}mk`;
const noteTitle = slug('Esc-note');
await createNotebook(page, notebook);
try {
await createDesktopNote(page, notebook);
await setNoteTitle(page, noteTitle);
await page.waitForFunction(() => !!(window.tinymce && window.tinymce.activeEditor && window.tinymce.activeEditor.initialized), null, { timeout: 15000 });
const editArea = page.locator('#tinymce-host iframe').contentFrame().locator('body#tinymce');
await expect(editArea).toBeVisible({ timeout: 15000 });
await editArea.click();
await page.keyboard.type(`Body with ${term} and again ${term} here.`);
await waitForSaved(page);
await expect(editArea).toContainText(term, { timeout: 15000 });
// Reload so the note is cleanly searchable from the server.
await page.goto('/');
await expect(page.locator('body.app-shell')).toBeVisible();
// Search, then open the matching result to enter in-note find.
const nav = page.locator('#nav-search');
await nav.fill(term);
await nav.press('Enter');
const results = page.locator('[data-folder-id="__search_results__"] .notelist-item');
await expect(results.first()).toBeVisible({ timeout: 15000 });
await results.first().click();
// In-note find is active: marks + find-nav bar visible.
const markCount = async () => page.evaluate(() => {
const ed = window.tinymce && window.tinymce.activeEditor;
return ed && ed.getBody ? ed.getBody().querySelectorAll('mark.search-highlight').length : -1;
});
await expect.poll(markCount, { timeout: 15000 }).toBeGreaterThanOrEqual(2);
const navBar = page.locator('#editor-panel #search-nav-bar');
await expect(navBar).toBeVisible();
// --- 1st Esc: exit in-note search only ---
await page.keyboard.press('Escape');
await expect(navBar).toBeHidden();
await expect.poll(markCount, { timeout: 5000 }).toBe(0);
// Note-list search results and the search field must remain.
await expect(page.locator('[data-folder-id="__search_results__"]')).toBeVisible();
await expect(nav).toHaveValue(term);
// --- 2nd Esc: exit the note-list search results + clear the field ---
await page.keyboard.press('Escape');
await expect(nav).toHaveValue('');
await expect(page.locator('[data-folder-id="__search_results__"]')).toHaveCount(0, { timeout: 10000 });
} finally {
await page.goto('/');
await deleteNotebook(page, notebook).catch(() => {});
}
});
});

View file

@ -0,0 +1,111 @@
'use strict';
/**
* E2E: in-note find/highlight in rendered (TinyMCE) mode.
*
* Regression: after the TinyMCE migration, searching from the note list and
* opening the matching note stopped highlighting the term in the body, because
* applySearchHighlight() only handled the removed #note-preview contenteditable
* and had no rendered-mode (TinyMCE iframe) branch. This drives the real desktop
* flow — type in the nav search, open the matching result, and assert the term
* is wrapped in <mark class="search-highlight"> inside the live TinyMCE iframe
* with the find-nav bar showing a match count.
*/
const { test, expect } = require('@playwright/test');
const {
login,
createNotebook,
deleteNotebook,
createDesktopNote,
setNoteTitle,
waitForSaved,
acceptDialogs,
hasAdminCredentials,
slug,
} = require('./helpers');
test.describe('rendered-mode in-note search highlight', () => {
test.skip(!hasAdminCredentials(), 'requires admin credentials');
test('search opens the note and highlights the term inside TinyMCE', async ({ page }) => {
test.setTimeout(90000);
acceptDialogs(page);
await login(page);
const notebook = slug('search-nb');
// Unique token so this run's note is the only search hit.
const term = `zqx${Date.now().toString(36)}mk`;
const noteTitle = slug('Findable-note');
await createNotebook(page, notebook);
try {
await createDesktopNote(page, notebook);
await setNoteTitle(page, noteTitle);
// The note opens in rendered (TinyMCE) mode by default. Type the body
// directly into the persistent TinyMCE editor so it is real rendered
// content. The persistent iframe lives under #tinymce-host (repositioned
// over the editor slot), so drive/read it via the tinymce API to avoid
// brittle iframe selectors across htmx swaps.
await page.waitForFunction(() => !!(window.tinymce && window.tinymce.activeEditor && window.tinymce.activeEditor.initialized), null, { timeout: 15000 });
const editArea = page.locator('#tinymce-host iframe').contentFrame().locator('body#tinymce');
await expect(editArea).toBeVisible({ timeout: 15000 });
await editArea.click();
await page.keyboard.type(`This paragraph contains the ${term} token to find.`);
await page.keyboard.press('Enter');
await page.keyboard.type(`Another ${term} appears here too.`);
await waitForSaved(page);
await expect(editArea).toContainText(term, { timeout: 15000 });
// Reload so the note exists cleanly server-side before searching — this
// mirrors real usage (finding a term in a note that already exists) and
// avoids the write→read propagation lag of a just-created note.
await page.goto('/');
await expect(page.locator('body.app-shell')).toBeVisible();
// Search from the nav search box.
const nav = page.locator('#nav-search');
await nav.fill(term);
await nav.press('Enter');
const results = page.locator('[data-folder-id="__search_results__"] .notelist-item');
await expect(results.first()).toBeVisible({ timeout: 15000 });
// Open the matching result — this seeds the pending search term and
// applySearchHighlight() runs after the editor settles.
await results.first().click();
// Rendered-mode highlight: the term must be wrapped in
// mark.search-highlight inside the live TinyMCE body. Read via the
// tinymce API (the persistent editor), which is what the app touches.
const markCount = async () => page.evaluate(() => {
const ed = window.tinymce && window.tinymce.activeEditor;
if (!ed || !ed.getBody) return -1;
return ed.getBody().querySelectorAll('mark.search-highlight').length;
});
await expect.poll(markCount, { timeout: 15000 }).toBeGreaterThanOrEqual(2);
const activeCount = async () => page.evaluate(() => {
const ed = window.tinymce && window.tinymce.activeEditor;
if (!ed || !ed.getBody) return -1;
return ed.getBody().querySelectorAll('mark.search-highlight-active').length;
});
await expect.poll(activeCount, { timeout: 5000 }).toBeGreaterThanOrEqual(1);
// The desktop find-nav bar shows the match counter.
const navBar = page.locator('#editor-panel #search-nav-bar');
await expect(navBar).toBeVisible({ timeout: 15000 });
await expect(page.locator('#editor-panel #search-nav-counter')).toContainText('/');
// Stepping keeps exactly one active highlight.
await page.locator('#editor-panel #search-nav-bar .search-nav-btn').nth(1).click();
await expect.poll(activeCount, { timeout: 5000 }).toBe(1);
// Dismiss strips every mark (nothing leaks into the saved markdown).
await page.locator('#editor-panel #search-nav-bar .search-nav-close').click();
await expect(navBar).toBeHidden();
await expect.poll(markCount, { timeout: 5000 }).toBe(0);
} finally {
await page.goto('/');
await deleteNotebook(page, notebook).catch(() => {});
}
});
});

View file

@ -564,6 +564,7 @@ var _tinymceInitPromise=null;
// When true, TinyMCE input/change events are ignored (used to suppress spurious
// save-triggers when we call setContent programmatically on note switch / unlock).
var _tinymceSuppressEdits=false;
var _pendingSearchHighlight=false;
var _tinymceShowRequested=false;
function _setTinyMCEContent(html){
if(!_tinymceEditor)return;
@ -578,6 +579,13 @@ function _setTinyMCEContent(html){
ensureTinyMCEEditableAfterPre(_tinymceEditor);
initTinyMCECodeCopyButtons(_tinymceEditor);
_applyTinyMCESpellcheck(_tinymceEditor);
// Rendered-mode find: content just (re)loaded into the TinyMCE body.
// Re-apply the search highlight now that there is text to mark. This
// covers both the sync (server-rendered slot) and async (/fragments/
// preview fetch) content paths without guessing a delay. Only consume
// the pending flag once the body actually has text, so an earlier
// empty setContent (first init) doesn't swallow the highlight.
if(_pendingSearchHighlight){var _bd=_tinymceEditor&&_tinymceEditor.getBody&&_tinymceEditor.getBody();var _hasText=!!(_bd&&(_bd.textContent||'').trim());var _term=activeSearchTerm();if(_term&&_term.trim()&&_hasText){_pendingSearchHighlight=false;applySearchHighlight()}else if(!(_term&&_term.trim())){_pendingSearchHighlight=false}}
},0);
}
}
@ -613,7 +621,9 @@ function _tinyMCEContentFontStyle(){
+'img{max-width:100%;height:auto;border-radius:6px}'
+'.md-checkbox{display:flex;align-items:baseline;gap:0.35em;margin:0.25em 0;padding-left:24px}'
+'.md-checkbox::before{content:"";display:inline-block;width:16px;height:16px;border:1.5px solid var(--accent);border-radius:3px;flex-shrink:0;background:transparent;box-sizing:border-box}'
+'.md-checkbox.checked::before{background:var(--accent);border-color:var(--accent);content:"\\2713";color:var(--bg);font-size:11px;font-weight:bold;line-height:16px;text-align:center}';
+'.md-checkbox.checked::before{background:var(--accent);border-color:var(--accent);content:"\\2713";color:var(--bg);font-size:11px;font-weight:bold;line-height:16px;text-align:center}'
+'mark.search-highlight{background:#ffe066;color:#111;border-radius:2px;padding:0 1px}'
+'mark.search-highlight-active{background:#ff9800;color:#111;border-radius:2px;padding:0 1px}';
}
function _syncTinyMCEThemeVars(){
if(!_tinymceEditor||!_tinymceEditor.getDoc)return;
@ -2804,6 +2814,7 @@ function setEditorMode(mode){
mountMarkdownEditor(ta?ta.value:'');
_editorMode='markdown';
syncEditorModeButtons();
if(_searchSessionActive())setTimeout(applySearchHighlight,0);
return;
}
// Rich mode: sync CM6 content back to the textarea, hide markdown editor, show TinyMCE.
@ -2818,6 +2829,7 @@ function setEditorMode(mode){
return;
}
// Re-render current textarea content and push into the persistent editor.
if(_searchSessionActive())_pendingSearchHighlight=true;
fetch('/fragments/preview',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'body='+encodeURIComponent(mdVal)}).then(function(r){return r.text()}).then(function(h){
if(!_tinymceEditor)return;
_setTinyMCEContent(h);
@ -2845,7 +2857,7 @@ function mountMarkdownEditor(content){
if(_cmView.focus)_cmView.focus();
}
}
document.addEventListener('keydown',function(e){if(e.key==='Escape'){var codeModal=document.getElementById('code-modal');if(codeModal&&!codeModal.hidden){closeCodeModal();return}closeFolderContextMenu();closeFolderModal();closeLinkModal();closeNewFolderModal();closeVaultModal();closeHistoryModal();closeEmptyTrashModal();var bar=document.getElementById('search-nav-bar');if(bar&&!bar.hidden){searchNavDismiss();return}}if(!getTA()&&!getPV()&&!getCM())return;if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();requestManualProseCompletion();return}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('*','*')}if((e.ctrlKey||e.metaKey)&&e.key==='f'){e.preventDefault();if(_editorMode==='preview'&&_searchMarks.length){searchNavStep(1)}else{applySearchHighlight()}}});
document.addEventListener('keydown',function(e){if(e.key==='Escape'){var codeModal=document.getElementById('code-modal');if(codeModal&&!codeModal.hidden){closeCodeModal();return}closeFolderContextMenu();closeFolderModal();closeLinkModal();closeNewFolderModal();closeVaultModal();closeHistoryModal();closeEmptyTrashModal();var bar=document.getElementById('search-nav-bar');if(bar&&!bar.hidden){searchNavDismiss();return}var navSearch=document.getElementById('nav-search');if(navSearch&&navSearch.value){navSearch.value='';htmx.trigger(navSearch,'search-submit');return}}if(!getTA()&&!getPV()&&!getCM())return;if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();requestManualProseCompletion();return}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('*','*')}if((e.ctrlKey||e.metaKey)&&e.key==='f'){e.preventDefault();if(_editorMode==='preview'&&_searchMarks.length){searchNavStep(1)}else{applySearchHighlight()}}});
document.addEventListener('click',function(e){var menu=document.getElementById('folder-context-menu');if(menu&&!menu.hidden&&!menu.contains(e.target))closeFolderContextMenu()});
function highlightCodeBlocks(container){if(!window.hljs||!container)return;container.querySelectorAll('pre code[class*="language-"]').forEach(function(el){if(el.dataset.highlighted)return;window.hljs.highlightElement(el)})}
function ensureEditableAfterPre(pv){if(!pv)return;var pres=pv.querySelectorAll('pre');pres.forEach(function(pre){var next=pre.nextElementSibling;if(!next){var p=document.createElement('p');p.innerHTML='<br>';p.dataset.pvTrail='1';pv.appendChild(p)}})}
@ -3074,23 +3086,37 @@ function snapshotHash(){var form=activeEditorForm();_savedHash=formHash(form);_l
function _isLockedOverlayEventTarget(target){return !!(target&&target.closest&&target.closest('#editor-locked'))}
function initEditorPanel(){var form=activeEditorForm();if(!form||form.dataset.editorInit)return;form.dataset.editorInit='1';_resetRingBuffer('note-switch');_log('initEditorPanel',form.getAttribute('hx-put'));if(isMobileShellMode())closeNav();_previewDirty=false;setSaveState('','');snapshotHash();_snapshots=[];var undoBtn=queryActiveEditor('#undo-save-btn');if(undoBtn)undoBtn.hidden=true;pushSnapshot();form.addEventListener('input',function(e){if(_isLockedOverlayEventTarget(e.target))return;markEdited();scheduleSave()});form.addEventListener('change',function(e){if(_isLockedOverlayEventTarget(e.target))return;markEdited();scheduleSave()});initAutoTitle();applyMobileTitleMode();renderNoteMeta(); var ta=getTA();if(ta){ta.addEventListener('input',function(){autoTitle()});ta.addEventListener('keydown',function(e){if(_editorMode!=='markdown'&&_editorMode!=='md')return;if(e.key!=='Enter')return;var mac=navigator.platform&&navigator.platform.indexOf('Mac')!==-1;var mod=mac?e.metaKey:e.ctrlKey;if(mod){// Ctrl/Cmd+Enter = soft break (\n, same paragraph)
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+1;ta.dispatchEvent(new Event('input',{bubbles:true}))}else{// Enter = new paragraph (\n\n)
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+2;ta.dispatchEvent(new Event('input',{bubbles:true}))}})}var pendingSearch=(window._pendingNoteSearchTerm||'').trim();var mobileEditor=inMobileEditor();if(mobileEditor&&pendingSearch){var header=document.getElementById('mobile-editor-header');var searchHeader=document.getElementById('mobile-editor-search-header');if(header)header.style.display='none';if(searchHeader)searchHeader.style.display=''}var searchInput=activeSearchInput();if(searchInput&&pendingSearch&&!searchInput.value)searchInput.value=pendingSearch;window._pendingNoteSearchTerm='';/* Persistent TinyMCE: refresh content for this note (skip locked encrypted notes) */if(form.dataset.encrypted!=='1'){var _mobileRO=_tinymceReadonlyDefault();_editorMode=(_mobileRO?false:_defaultNoteOpenMode==='markdown')?'markdown':'rich';_tinymceReadonly=_mobileRO;syncEditorModeButtons();if(_editorMode==='markdown'){hideTinyMCEHost();applyEditorModeVisibility('markdown');var mdta=getTA();mountMarkdownEditor(mdta?mdta.value:'');initPersistentTinyMCE()}else{initPersistentTinyMCE();refreshTinyMCEForActiveNote()}}else{hideTinyMCEHost()}}
function applySearchHighlight(){var term=activeSearchTerm();var bar=document.getElementById('search-nav-bar');if(bar)bar.hidden=true;_searchMarks=[];_searchMarkIdx=0;var pv=queryActiveEditor('#note-preview');if(pv)clearPreviewSearchMarks(pv);if(!term||!term.trim()){clearCodeMirrorSearch();return}term=term.trim();if(_editorMode==='preview'&&pv){clearCodeMirrorSearch();var savedHandler=pv.oninput;pv.oninput=null;highlightInPreview(pv,term);pv.oninput=savedHandler}else if(_editorMode==='markdown'&&_cmView&&window.CM&&window.CM.SearchQuery&&window.CM.setSearchQuery){ window.CM.openSearchPanel(_cmView);var q=new window.CM.SearchQuery({search:term,caseSensitive:false});_cmView.dispatch({effects:window.CM.setSearchQuery.of(q)});_cmSearchMatches=collectCodeMirrorSearchMatches(q);if(_cmSearchMatches.length)setCodeMirrorSearchActive(0);else searchNavShow(0,0)}}
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+2;ta.dispatchEvent(new Event('input',{bubbles:true}))}})}var pendingSearch=(window._pendingNoteSearchTerm||'').trim();var mobileEditor=inMobileEditor();if(mobileEditor&&pendingSearch){var header=document.getElementById('mobile-editor-header');var searchHeader=document.getElementById('mobile-editor-search-header');if(header)header.style.display='none';if(searchHeader)searchHeader.style.display=''}var searchInput=activeSearchInput();if(searchInput&&pendingSearch&&!searchInput.value)searchInput.value=pendingSearch;window._pendingNoteSearchTerm='';/* Persistent TinyMCE: refresh content for this note (skip locked encrypted notes) */if(form.dataset.encrypted!=='1'){var _mobileRO=_tinymceReadonlyDefault();_editorMode=(_mobileRO?false:_defaultNoteOpenMode==='markdown')?'markdown':'rich';_tinymceReadonly=_mobileRO;syncEditorModeButtons();if(_editorMode==='markdown'){hideTinyMCEHost();applyEditorModeVisibility('markdown');var mdta=getTA();mountMarkdownEditor(mdta?mdta.value:'');initPersistentTinyMCE()}else{initPersistentTinyMCE();refreshTinyMCEForActiveNote()}if(pendingSearch){var _pendTerm=pendingSearch;if(_editorMode==='markdown'){setTimeout(function(){var si=activeSearchInput();if(si&&!si.value)si.value=_pendTerm;applySearchHighlight()},0)}else{/* rich: highlight is (re)applied by _setTinyMCEContent once the body is painted (covers sync + async render) */var si2=activeSearchInput();if(si2&&!si2.value)si2.value=_pendTerm;_pendingSearchHighlight=true;/* Fallback: if no setContent fires (e.g. same-note reopen with body already loaded), apply once the body has text. */var _tries=0;var _fb=setInterval(function(){_tries++;if(!_pendingSearchHighlight||_tries>20){clearInterval(_fb);return}var _b=_tinymceEditor&&_tinymceEditor.getBody&&_tinymceEditor.getBody();if(_b&&(_b.textContent||'').trim()&&activeSearchTerm()&&activeSearchTerm().trim()){_pendingSearchHighlight=false;clearInterval(_fb);applySearchHighlight()}},50)}}}else{hideTinyMCEHost()}}
function applySearchHighlight(){var term=activeSearchTerm();var bar=document.getElementById('search-nav-bar');if(bar)bar.hidden=true;_searchMarks=[];_searchMarkIdx=0;var pv=queryActiveEditor('#note-preview');if(pv)clearPreviewSearchMarks(pv);clearTinyMCESearchMarks();if(!term||!term.trim()){clearCodeMirrorSearch();return}term=term.trim();if(_editorMode==='markdown'||_editorMode==='md'){if(_cmView&&window.CM&&window.CM.SearchQuery&&window.CM.setSearchQuery){window.CM.openSearchPanel(_cmView);var q=new window.CM.SearchQuery({search:term,caseSensitive:false});_cmView.dispatch({effects:window.CM.setSearchQuery.of(q)});_cmSearchMatches=collectCodeMirrorSearchMatches(q);if(_cmSearchMatches.length)setCodeMirrorSearchActive(0);else searchNavShow(0,0)}}else if(_editorMode==='preview'&&pv){clearCodeMirrorSearch();var savedHandler=pv.oninput;pv.oninput=null;highlightInPreview(pv,term);pv.oninput=savedHandler}else{/* rich (TinyMCE) mode */clearCodeMirrorSearch();highlightInTinyMCE(term)}}
function escapeRegex(s){var specials=['.','+','*','?','^','$','(',')','{','}','[',']','|','\\'];return s.split('').map(function(c){return specials.indexOf(c)>=0?'\\'+c:c}).join('')}
var _searchMarks=[];var _searchMarkIdx=0;
function _searchSessionActive(){var term=activeSearchTerm();if(!term||!term.trim())return false;var bar=document.getElementById('search-nav-bar');if(bar&&!bar.hidden)return true;var mc=document.getElementById('mobile-search-nav-counter');if(mc&&!mc.hidden)return true;return _searchMarks.length>0||(_cmSearchMatches&&_cmSearchMatches.length>0)}
function searchNavShow(total,idx){var bar=document.getElementById('search-nav-bar');var counter=document.getElementById('search-nav-counter');if(bar){if(total===0){bar.hidden=true}else{bar.hidden=false;if(counter)counter.textContent=(idx+1)+' / '+total}}var mobileCounter=document.getElementById('mobile-search-nav-counter');var mobilePrev=document.getElementById('mobile-search-prev-btn');var mobileNext=document.getElementById('mobile-search-next-btn');if(mobileCounter){mobileCounter.hidden=total===0;if(total>0)mobileCounter.textContent=(idx+1)+' / '+total}if(mobilePrev)mobilePrev.hidden=total===0;if(mobileNext)mobileNext.hidden=total===0}
function searchNavSetActive(idx){_searchMarks.forEach(function(m,i){m.classList.toggle('search-highlight-active',i===idx)});var m=_searchMarks[idx];if(m)m.scrollIntoView({block:'center',behavior:'smooth'})}
function searchNavStep(dir){if(_editorMode==='markdown'&&_cmSearchMatches.length){setCodeMirrorSearchActive(_searchMarkIdx+dir);return}if(!_searchMarks.length)return;_searchMarkIdx=(_searchMarkIdx+dir+_searchMarks.length)%_searchMarks.length;searchNavSetActive(_searchMarkIdx);searchNavShow(_searchMarks.length,_searchMarkIdx)}
function searchNavDismiss(){var bar=document.getElementById('search-nav-bar');var mobileCounter=document.getElementById('mobile-search-nav-counter');var mobilePrev=document.getElementById('mobile-search-prev-btn');var mobileNext=document.getElementById('mobile-search-next-btn');if(bar)bar.hidden=true;if(mobileCounter)mobileCounter.hidden=true;if(mobilePrev)mobilePrev.hidden=true;if(mobileNext)mobileNext.hidden=true;var pv=queryActiveEditor('#note-preview');if(pv)clearPreviewSearchMarks(pv);_searchMarks=[];_searchMarkIdx=0;clearCodeMirrorSearch()}
function highlightInPreview(pv,term){if(!pv||!term)return;_searchMarks=[];_searchMarkIdx=0;var walker=document.createTreeWalker(pv,NodeFilter.SHOW_TEXT,{acceptNode:function(n){return n.parentElement&&n.parentElement.closest('script,style,mark')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}},false);var nodes=[];var node;while((node=walker.nextNode()))nodes.push(node);var re=new RegExp(escapeRegex(term),'gi');nodes.forEach(function(n){var matches=[];var m;re.lastIndex=0;while((m=re.exec(n.textContent))!==null)matches.push({start:m.index,end:m.index+m[0].length});if(!matches.length)return;var frag=document.createDocumentFragment();var last=0;matches.forEach(function(r){if(r.start>last)frag.appendChild(document.createTextNode(n.textContent.slice(last,r.start)));var mark=document.createElement('mark');mark.className='search-highlight';mark.textContent=n.textContent.slice(r.start,r.end);_searchMarks.push(mark);frag.appendChild(mark);last=r.end});if(last<n.textContent.length)frag.appendChild(document.createTextNode(n.textContent.slice(last)));n.parentNode.replaceChild(frag,n)});if(_searchMarks.length){searchNavSetActive(0);searchNavShow(_searchMarks.length,0)}else{searchNavShow(0,0)}}
function searchNavDismiss(){var bar=document.getElementById('search-nav-bar');var mobileCounter=document.getElementById('mobile-search-nav-counter');var mobilePrev=document.getElementById('mobile-search-prev-btn');var mobileNext=document.getElementById('mobile-search-next-btn');if(bar)bar.hidden=true;if(mobileCounter)mobileCounter.hidden=true;if(mobilePrev)mobilePrev.hidden=true;if(mobileNext)mobileNext.hidden=true;var pv=queryActiveEditor('#note-preview');if(pv)clearPreviewSearchMarks(pv);clearTinyMCESearchMarks();_searchMarks=[];_searchMarkIdx=0;clearCodeMirrorSearch()}
function highlightInPreview(pv,term){if(!pv||!term)return;_searchMarks=[];_searchMarkIdx=0;var doc=pv.ownerDocument||document;var walker=doc.createTreeWalker(pv,NodeFilter.SHOW_TEXT,{acceptNode:function(n){return n.parentElement&&n.parentElement.closest('script,style,mark')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}},false);var nodes=[];var node;while((node=walker.nextNode()))nodes.push(node);var re=new RegExp(escapeRegex(term),'gi');nodes.forEach(function(n){var matches=[];var m;re.lastIndex=0;while((m=re.exec(n.textContent))!==null)matches.push({start:m.index,end:m.index+m[0].length});if(!matches.length)return;var frag=doc.createDocumentFragment();var last=0;matches.forEach(function(r){if(r.start>last)frag.appendChild(doc.createTextNode(n.textContent.slice(last,r.start)));var mark=doc.createElement('mark');mark.className='search-highlight';mark.textContent=n.textContent.slice(r.start,r.end);_searchMarks.push(mark);frag.appendChild(mark);last=r.end});if(last<n.textContent.length)frag.appendChild(doc.createTextNode(n.textContent.slice(last)));n.parentNode.replaceChild(frag,n)});if(_searchMarks.length){searchNavSetActive(0);searchNavShow(_searchMarks.length,0)}else{searchNavShow(0,0)}}
// Rendered mode (TinyMCE): highlight the search term inside the iframe body.
// Marks are inserted with edits suppressed so autosave/markdown-sync never fire
// mid-highlight, and are always stripped again by clearTinyMCESearchMarks().
function tinymceSearchBody(){var ed=getTinyMCE();if(!ed||!ed.getBody)return null;try{return ed.getBody()}catch(e){return null}}
function clearTinyMCESearchMarks(){var body=tinymceSearchBody();if(!body)return;var marks=body.querySelectorAll('mark.search-highlight');if(!marks.length)return;var prev=_tinymceSuppressEdits;_tinymceSuppressEdits=true;try{marks.forEach(function(m){var text=(m.ownerDocument||document).createTextNode(m.textContent);m.parentNode.replaceChild(text,m)});body.normalize()}finally{_tinymceSuppressEdits=prev}}
function highlightInTinyMCE(term){var body=tinymceSearchBody();if(!body||!term){searchNavShow(0,0);return}var prev=_tinymceSuppressEdits;_tinymceSuppressEdits=true;try{highlightInPreview(body,term)}finally{_tinymceSuppressEdits=prev}}
function initNavPanel(){_log('initNavPanel');var state=navFolderState();var selectedEl=document.querySelector('.nav-folder[data-selected="1"]');var hasSelected=!!selectedEl;var selectedId=selectedEl?selectedEl.getAttribute('data-folder-id'):'';document.querySelectorAll('.nav-folder').forEach(function(el){var id=el.getAttribute('data-folder-id');var selected=el.getAttribute('data-selected')==='1';var isAllNotes=el.getAttribute('data-all-notes')==='1';var open=state[id]===true||state[id]==='1'||state[id]===1;if(state[id]===undefined){// No explicit state: use heuristics
if(isAllNotes)open=!hasSelected||(selectedId===id);else if(selected)open=true;else open=false;}// Always trust explicit localStorage state — do not override with data-selected or all-notes heuristics
el.classList.toggle('collapsed',!open);// Lazy-load if expanded and not yet loaded
if(open){var notesDiv=el.querySelector('.nav-folder-notes[data-folder-id]');if(notesDiv&&!notesDiv.getAttribute('data-loaded')){notesDiv.setAttribute('data-loaded','1');var folderId=notesDiv.getAttribute('data-folder-id');htmx.ajax('GET','/fragments/folder-notes?folderId='+encodeURIComponent(folderId),{target:notesDiv,swap:'innerHTML'})}}})}
var _folderSelectValue=null;var _folderSelectNoteId=null;
document.body.addEventListener('htmx:beforeSwap',function(e){var sel=document.getElementById('editor-folder-select');var form=document.getElementById('note-editor-form');if(sel){_folderSelectValue=sel.value;_folderSelectNoteId=form?form.getAttribute('hx-put'):''}var target=e.detail&&e.detail.target;if(target&&(target.id==='editor-panel'||target.id==='mobile-editor-body')){hideTinyMCEHost()}});
var _lastSwapWasEditor=false;var _searchHlTerm='';
document.body.addEventListener('htmx:beforeSwap',function(e){var sel=document.getElementById('editor-folder-select');var form=document.getElementById('note-editor-form');if(sel){_folderSelectValue=sel.value;_folderSelectNoteId=form?form.getAttribute('hx-put'):''}var target=e.detail&&e.detail.target;_lastSwapWasEditor=!!(target&&(target.id==='editor-panel'||target.id==='mobile-editor-body'));if(_lastSwapWasEditor){/* Capture any pending in-note search term now: initEditorPanel clears it, and it may not re-run on same-note reopen. */var pt=(window._pendingNoteSearchTerm||'').trim();var navTerm=(currentListSearchInput()&&currentListSearchInput().value||'').trim();_searchHlTerm=pt||navTerm||'';hideTinyMCEHost()}});
document.body.addEventListener('htmx:afterSettle',function(){initNavPanel();initEditorPanel();refreshAllVaultIcons();positionTinyMCEHost();
if(_lastSwapWasEditor){_lastSwapWasEditor=false;maybeHighlightOpenedNote(_searchHlTerm);_searchHlTerm=''}
if(_folderSelectValue){var sel=document.getElementById('editor-folder-select');var form=document.getElementById('note-editor-form');var currentNoteId=form?form.getAttribute('hx-put'):'';if(sel&&currentNoteId&&currentNoteId===_folderSelectNoteId){sel.value=_folderSelectValue}_folderSelectValue=null;_folderSelectNoteId=null}});
// After an editor-panel swap that came from a search result, (re)apply the
// in-note find highlight once the visible editor has content. This does not
// depend on initEditorPanel re-running (it may not on a same-note reopen) and
// works for both markdown (CM6) and rendered (TinyMCE) modes.
function maybeHighlightOpenedNote(term){term=(term||'').trim();if(!term)return;var form=activeEditorForm();if(!form||form.dataset.encrypted==='1')return;var si=activeSearchInput();if(si&&!si.value)si.value=term;if(_editorMode==='markdown'||_editorMode==='md'){setTimeout(applySearchHighlight,0);return}/* rich: wait for TinyMCE body text, then highlight */_pendingSearchHighlight=true;var tries=0;var iv=setInterval(function(){tries++;if(!_pendingSearchHighlight||tries>40){clearInterval(iv);return}var b=_tinymceEditor&&_tinymceEditor.getBody&&_tinymceEditor.getBody();if(b&&(b.textContent||'').trim()&&activeSearchTerm()&&activeSearchTerm().trim()){_pendingSearchHighlight=false;clearInterval(iv);applySearchHighlight()}},50)}
// Also refresh on initial SSR page load (htmx:afterSettle only fires after htmx swaps)
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){refreshAllVaultIcons();initPersistentTinyMCE();setTimeout(positionTinyMCEHost,0)})}else{refreshAllVaultIcons();initPersistentTinyMCE();setTimeout(positionTinyMCEHost,0)}
document.body.addEventListener('htmx:confirm',function(e){var elt=e.detail&&e.detail.elt;if(!elt)return;var msg=elt.getAttribute('data-confirm-trash');if(msg){e.preventDefault();if(_cfg.confirmTrash===false){e.detail.issueRequest(true);return}if(confirm(msg))e.detail.issueRequest(true)}});

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'joplock-shell-v40-20260706checkboxindent2';
const CACHE_NAME = 'joplock-shell-v42-20260707escsearch';
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

@ -0,0 +1,152 @@
/**
* Runtime tests for the in-note find/highlight logic in public/app.js.
*
* Regression target: after the TinyMCE migration, searching from the note list
* and opening a note stopped highlighting / scrolling to the term in rendered
* (TinyMCE) mode because applySearchHighlight() only handled the dead
* #note-preview contenteditable and CodeMirror. These tests exercise the
* DOM-walking highlighter that both preview- and TinyMCE-mode paths use, plus
* the cross-document (iframe body) path that rendered mode relies on.
*
* Strategy mirrors appRuntime.test.js: extract the exact minified function
* source from app.js and run it in a JSDOM-backed vm context.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in app.js`);
let depth = 0;
let i = start;
while (i < appSrc.length) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
i++;
}
throw new Error(`Could not find closing brace for function ${name}`);
}
// Build a JSDOM context with the globals the highlight helpers touch.
// scrollIntoView is not implemented by JSDOM, so stub it (we assert it fires).
function makeCtx() {
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: 'https://joplock.test' });
const scrolled = [];
dom.window.Element.prototype.scrollIntoView = function () { scrolled.push(this); };
const ctx = vm.createContext({
document: dom.window.document,
window: dom.window,
NodeFilter: dom.window.NodeFilter,
_searchMarks: [],
_searchMarkIdx: 0,
_scrolled: scrolled,
// searchNavShow reads these DOM ids; provide the desktop nav bar bits.
console,
});
return { ctx, dom, scrolled };
}
function loadFns(ctx, ...fns) {
for (const fn of fns) vm.runInContext(extractFn(fn), ctx);
}
// The highlighter calls searchNavShow()/searchNavSetActive(); load real ones so
// counter/scroll wiring is covered end to end.
const HIGHLIGHT_DEPS = ['escapeRegex', 'searchNavShow', 'searchNavSetActive', 'highlightInPreview'];
test('highlightInPreview wraps every case-insensitive match in a mark.search-highlight', () => {
const { ctx, dom } = makeCtx();
loadFns(ctx, ...HIGHLIGHT_DEPS);
const root = dom.window.document.createElement('div');
root.innerHTML = '<p>Alpha beta ALPHA gamma alpha</p>';
dom.window.document.body.appendChild(root);
vm.runInContext('highlightInPreview(root, term);', Object.assign(ctx, {
root, term: 'alpha',
}));
const marks = root.querySelectorAll('mark.search-highlight');
assert.equal(marks.length, 3, 'all three case variants of "alpha" highlighted');
assert.equal(ctx._searchMarks.length, 3, '_searchMarks tracks each hit');
// Original text preserved (case kept per match).
assert.equal(root.textContent, 'Alpha beta ALPHA gamma alpha');
});
test('highlightInPreview marks the first hit active and scrolls to it', () => {
const { ctx, dom, scrolled } = makeCtx();
loadFns(ctx, ...HIGHLIGHT_DEPS);
const root = dom.window.document.createElement('div');
root.innerHTML = '<p>needle here</p><p>and needle there</p>';
dom.window.document.body.appendChild(root);
vm.runInContext('highlightInPreview(root, term);', Object.assign(ctx, { root, term: 'needle' }));
const marks = root.querySelectorAll('mark.search-highlight');
assert.equal(marks.length, 2);
assert.ok(marks[0].classList.contains('search-highlight-active'), 'first hit active');
assert.ok(!marks[1].classList.contains('search-highlight-active'), 'second hit not active');
assert.equal(scrolled.length, 1, 'scrollIntoView fired once');
assert.equal(scrolled[0], marks[0], 'scrolled to the active mark');
});
test('highlightInPreview does not descend into script/style/existing mark nodes', () => {
const { ctx, dom } = makeCtx();
loadFns(ctx, ...HIGHLIGHT_DEPS);
const root = dom.window.document.createElement('div');
root.innerHTML = '<style>term{}</style><p>term</p><mark>term</mark>';
dom.window.document.body.appendChild(root);
vm.runInContext('highlightInPreview(root, term);', Object.assign(ctx, { root, term: 'term' }));
// Only the plain <p> text should be wrapped: the <style> and pre-existing
// <mark> contents are skipped by the tree-walker filter.
assert.equal(root.querySelectorAll('mark.search-highlight').length, 1);
});
test('highlightInPreview works on a cross-document (iframe body) root — rendered/TinyMCE path', () => {
// Rendered mode inserts marks into the TinyMCE iframe body. That body lives
// in a different document, so the highlighter must use node.ownerDocument
// (not the outer document) to create nodes, or replaceChild throws /
// silently mis-parents. This asserts the ownerDocument-safe behavior.
const { ctx } = makeCtx();
loadFns(ctx, ...HIGHLIGHT_DEPS);
const iframeDom = new JSDOM('<!DOCTYPE html><body><p>find the WORD word here</p></body>', { url: 'https://joplock.test' });
iframeDom.window.Element.prototype.scrollIntoView = function () {};
const body = iframeDom.window.document.body;
vm.runInContext('highlightInPreview(body, term);', Object.assign(ctx, { body, term: 'word' }));
const marks = body.querySelectorAll('mark.search-highlight');
assert.equal(marks.length, 2, 'both "word" occurrences highlighted inside the iframe body');
marks.forEach((m) => {
assert.equal(m.ownerDocument, iframeDom.window.document, 'mark created in the iframe document');
});
assert.equal(body.textContent, 'find the WORD word here', 'iframe text content intact');
});
test('escapeRegex neutralizes regex metacharacters so literal terms match', () => {
const { ctx, dom } = makeCtx();
loadFns(ctx, ...HIGHLIGHT_DEPS);
const root = dom.window.document.createElement('div');
root.innerHTML = '<p>cost is $5 (approx.) a+b</p>';
dom.window.document.body.appendChild(root);
vm.runInContext('highlightInPreview(root, term);', Object.assign(ctx, { root, term: 'a+b' }));
const marks = root.querySelectorAll('mark.search-highlight');
assert.equal(marks.length, 1, 'literal "a+b" matched, not treated as regex');
assert.equal(marks[0].textContent, 'a+b');
});
test('applySearchHighlight source routes rendered mode through the TinyMCE highlighter', () => {
// Guard the wiring: the rich/rendered branch must call highlightInTinyMCE
// and the markdown branch must not be the only non-preview path. This is a
// source-level assertion so a future refactor can't silently drop the
// rendered-mode branch again (the original regression).
const fnSrc = extractFn('applySearchHighlight');
assert.match(fnSrc, /highlightInTinyMCE\(/, 'applySearchHighlight calls highlightInTinyMCE for rendered mode');
assert.match(fnSrc, /clearTinyMCESearchMarks\(\)/, 'applySearchHighlight clears stale TinyMCE marks first');
// highlightInTinyMCE must suppress edits so autosave/markdown-sync never
// fires mid-highlight (otherwise <mark> nodes could leak into a save).
const hl = extractFn('highlightInTinyMCE');
assert.match(hl, /_tinymceSuppressEdits=true/, 'highlightInTinyMCE suppresses edits while inserting marks');
// Dismiss must strip TinyMCE marks.
const dismiss = extractFn('searchNavDismiss');
assert.match(dismiss, /clearTinyMCESearchMarks\(\)/, 'searchNavDismiss clears TinyMCE marks');
});