mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 02:09:26 +00:00
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
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run
This commit is contained in:
parent
4ac4d167e8
commit
598a0333fe
5 changed files with 383 additions and 8 deletions
86
playwright-tests/search-esc.spec.js
Normal file
86
playwright-tests/search-esc.spec.js
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
111
playwright-tests/search-highlight.spec.js
Normal file
111
playwright-tests/search-highlight.spec.js
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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()&¤tListSearchInput().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&¤tNoteId&¤tNoteId===_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)}});
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
152
tests/searchHighlight.test.js
Normal file
152
tests/searchHighlight.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue