diff --git a/playwright-tests/search-esc.spec.js b/playwright-tests/search-esc.spec.js new file mode 100644 index 0000000..220a211 --- /dev/null +++ b/playwright-tests/search-esc.spec.js @@ -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(() => {}); + } + }); +}); diff --git a/playwright-tests/search-highlight.spec.js b/playwright-tests/search-highlight.spec.js new file mode 100644 index 0000000..625c579 --- /dev/null +++ b/playwright-tests/search-highlight.spec.js @@ -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 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(() => {}); + } + }); +}); diff --git a/public/app.js b/public/app.js index 1cf6d6c..592e0a9 100644 --- a/public/app.js +++ b/public/app.js @@ -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='
';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(lastlast)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(last40){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)}}); diff --git a/public/service-worker.js b/public/service-worker.js index 81d8ed6..1e6c9f0 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -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 => { diff --git a/tests/searchHighlight.test.js b/tests/searchHighlight.test.js new file mode 100644 index 0000000..880b3ea --- /dev/null +++ b/tests/searchHighlight.test.js @@ -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('', { 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 = '

Alpha beta ALPHA gamma alpha

'; + 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 = '

needle here

and needle there

'; + 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 = '

term

term'; + dom.window.document.body.appendChild(root); + vm.runInContext('highlightInPreview(root, term);', Object.assign(ctx, { root, term: 'term' })); + // Only the plain

text should be wrapped: the