2022-05-09 23:13:34 +02:00
/ * *
* ( c ) Antonio Tejada 2022
2022-05-14 22:33:45 +02:00
* https : //github.com/antoniotejada/Trilium-FindWidget
2022-05-09 23:13:34 +02:00
* /
import NoteContextAwareWidget from "./note_context_aware_widget.js" ;
import appContext from "../services/app_context.js" ;
const findWidgetDelayMillis = 200 ;
const waitForEnter = ( findWidgetDelayMillis < 0 ) ;
2022-05-14 21:06:14 +02:00
// tabIndex=-1 on the checkbox labels is necessary so when clicking on the label
// the focusout handler is called with relatedTarget equal to the label instead
2022-05-14 22:33:45 +02:00
// of undefined. It's -1 instead of > 0, so they don't tabstop
2022-05-15 12:09:30 +02:00
const TPL = `
2022-05-14 22:33:45 +02:00
< div style = "contain: none;" >
< div id = "findBox" style = "padding: 10px; border-top: 1px solid var(--main-border-color); " >
< input type = "text" id = "input" >
< label tabIndex = "-1" id = "caseLabel" > < input type = "checkbox" id = "caseCheck" > case sensitive < / l a b e l >
< label tabIndex = "-1" id = "wordLabel" > < input type = "checkbox" id = "wordCheck" > match words < / l a b e l >
< span style = "font-weight: bold;" id = "curFound" > 0 < /span>/ < span style = "font-weight: bold;" id = "numFound" > 0 < / s p a n >
< / d i v >
2022-05-09 23:13:34 +02:00
< / d i v > ` ;
function escapeRegExp ( string ) {
return string . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ;
}
2022-05-14 22:33:45 +02:00
const getActiveContextCodeEditor = async ( ) => await appContext . tabManager . getActiveContextCodeEditor ( ) ;
const getActiveContextTextEditor = async ( ) => await appContext . tabManager . getActiveContextTextEditor ( ) ;
2022-05-09 23:13:34 +02:00
// ck-find-result and ck-find-result_selected are the styles ck-editor
// uses for highlighting matches, use the same one on CodeMirror
// for consistency
const FIND _RESULT _SELECTED _CSS _CLASSNAME = "ck-find-result_selected" ;
const FIND _RESULT _CSS _CLASSNAME = "ck-find-result" ;
export default class FindWidget extends NoteContextAwareWidget {
2022-05-15 12:09:30 +02:00
doRender ( ) {
this . $widget = $ ( TPL ) ;
2022-05-09 23:13:34 +02:00
this . $findBox = this . $widget . find ( '#findBox' ) ;
2022-05-15 12:09:30 +02:00
this . $findBox . hide ( ) ;
2022-05-09 23:13:34 +02:00
this . $input = this . $widget . find ( '#input' ) ;
this . $curFound = this . $widget . find ( '#curFound' ) ;
this . $numFound = this . $widget . find ( '#numFound' ) ;
2022-05-14 21:06:14 +02:00
this . $caseCheck = this . $widget . find ( "#caseCheck" ) ;
this . $wordCheck = this . $widget . find ( "#wordCheck" ) ;
2022-05-09 23:13:34 +02:00
this . findResult = null ;
2022-05-14 21:06:14 +02:00
this . needle = null ;
2022-05-09 23:13:34 +02:00
2022-05-15 12:09:30 +02:00
this . $input . keydown ( async e => {
2022-05-14 22:33:45 +02:00
if ( ( e . metaKey || e . ctrlKey ) && ( ( e . key === 'F' ) || ( e . key === 'f' ) ) ) {
2022-05-09 23:13:34 +02:00
// If ctrl+f is pressed when the findbox is shown, select the
// whole input to find
2022-05-15 12:09:30 +02:00
this . $input . select ( ) ;
2022-05-14 22:33:45 +02:00
} else if ( ( e . key === 'Enter' ) || ( e . key === 'F3' ) ) {
2022-05-15 12:09:30 +02:00
const needle = this . $input . val ( ) ;
if ( waitForEnter && ( this . needle !== needle ) ) {
await this . performFind ( needle ) ;
2022-05-09 23:13:34 +02:00
}
2022-05-15 12:09:30 +02:00
const numFound = parseInt ( this . $numFound . text ( ) ) ;
const curFound = parseInt ( this . $curFound . text ( ) ) - 1 ;
2022-05-14 22:33:45 +02:00
2022-05-09 23:13:34 +02:00
if ( numFound > 0 ) {
let delta = e . shiftKey ? - 1 : 1 ;
let nextFound = curFound + delta ;
// Wrap around
if ( nextFound > numFound - 1 ) {
nextFound = 0 ;
} if ( nextFound < 0 ) {
nextFound = numFound - 1 ;
}
2022-05-15 12:09:30 +02:00
let needle = this . $input . val ( ) ;
this . $curFound . text ( nextFound + 1 ) ;
2022-05-09 23:13:34 +02:00
const note = appContext . tabManager . getActiveContextNote ( ) ;
2022-05-14 22:33:45 +02:00
if ( note . type === "code" ) {
2022-05-15 21:03:51 +02:00
const codeEditor = await getActiveContextCodeEditor ( ) ;
const doc = codeEditor . doc ;
2022-05-09 23:13:34 +02:00
//
// Dehighlight current, highlight & scrollIntoView next
//
2022-05-15 12:09:30 +02:00
let marker = this . findResult [ curFound ] ;
2022-05-09 23:13:34 +02:00
let pos = marker . find ( ) ;
marker . clear ( ) ;
marker = doc . markText (
pos . from , pos . to ,
{ "className" : FIND _RESULT _CSS _CLASSNAME }
) ;
2022-05-15 12:09:30 +02:00
this . findResult [ curFound ] = marker ;
2022-05-09 23:13:34 +02:00
2022-05-15 12:09:30 +02:00
marker = this . findResult [ nextFound ] ;
2022-05-09 23:13:34 +02:00
pos = marker . find ( ) ;
marker . clear ( ) ;
marker = doc . markText (
pos . from , pos . to ,
{ "className" : FIND _RESULT _SELECTED _CSS _CLASSNAME }
) ;
2022-05-15 12:09:30 +02:00
this . findResult [ nextFound ] = marker ;
2022-05-09 23:13:34 +02:00
codeEditor . scrollIntoView ( pos . from ) ;
} else {
const textEditor = await getActiveContextTextEditor ( ) ;
2022-05-14 21:06:14 +02:00
// There are no parameters for findNext/findPrev
// See https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findnextcommand.js#L57
// curFound wrap around above assumes findNext and
// findPrevious wraparound, which is what they do
2022-05-09 23:13:34 +02:00
if ( delta > 0 ) {
2022-05-14 21:06:14 +02:00
textEditor . execute ( 'findNext' ) ;
2022-05-09 23:13:34 +02:00
} else {
2022-05-14 21:06:14 +02:00
textEditor . execute ( 'findPrevious' ) ;
2022-05-09 23:13:34 +02:00
}
}
}
e . preventDefault ( ) ;
return false ;
2022-05-14 22:33:45 +02:00
} else if ( e . key === 'Escape' ) {
2022-05-09 23:13:34 +02:00
const note = appContext . tabManager . getActiveContextNote ( ) ;
2022-05-14 22:33:45 +02:00
if ( note . type === "code" ) {
2022-05-15 21:03:51 +02:00
const codeEditor = await getActiveContextCodeEditor ( ) ;
2022-05-09 23:13:34 +02:00
codeEditor . focus ( ) ;
} else {
const textEditor = await getActiveContextTextEditor ( ) ;
textEditor . focus ( ) ;
}
}
} ) ;
2022-05-15 12:09:30 +02:00
this . $input . on ( 'input' , ( ) => {
2022-05-09 23:13:34 +02:00
// XXX This should clear the previous search immediately in all cases
// (the search is stale when waitforenter but also while the
// delay is running for non waitforenter case)
if ( ! waitForEnter ) {
// Clear the previous timeout if any, it's ok if timeoutId is
// null or undefined
2022-05-15 12:09:30 +02:00
clearTimeout ( this . timeoutId ) ;
2022-05-09 23:13:34 +02:00
// Defer the search a few millis so the search doesn't start
// immediately, as this can cause search word typing lag with
// one or two-char searchwords and long notes
// See https://github.com/antoniotejada/Trilium-FindWidget/issues/1
2022-05-15 12:09:30 +02:00
const needle = this . $input . val ( ) ;
const matchCase = this . $caseCheck . prop ( "checked" ) ;
const wholeWord = this . $wordCheck . prop ( "checked" ) ;
this . timeoutId = setTimeout ( async ( ) => {
this . timeoutId = null ;
await this . performFind ( needle , matchCase , wholeWord ) ;
2022-05-09 23:13:34 +02:00
} , findWidgetDelayMillis ) ;
}
} ) ;
2022-05-15 12:09:30 +02:00
this . $caseCheck . change ( ( ) => this . performFind ( ) ) ;
this . $wordCheck . change ( ( ) => this . performFind ( ) ) ;
2022-05-14 21:06:14 +02:00
// Note blur doesn't bubble to parent div, but the parent div needs to
// detect when any of the children are not focused and hide. Use
// focusout instead which does bubble to the parent div.
2022-05-15 12:09:30 +02:00
this . $findBox . focusout ( async ( e ) => {
2022-05-14 21:06:14 +02:00
// e.relatedTarget is the new focused element, note it can be null
// if nothing is being focused
2022-05-15 12:09:30 +02:00
if ( this . $findBox [ 0 ] . contains ( e . relatedTarget ) ) {
2022-05-14 21:06:14 +02:00
// The focused element is inside this div, ignore
return ;
}
2022-05-15 12:09:30 +02:00
this . $findBox . hide ( ) ;
2022-05-09 23:13:34 +02:00
// Restore any state, if there's a current occurrence clear markers
// and scroll to and select the last occurrence
// XXX Switching to a different tab with crl+tab doesn't invoke
// blur and leaves a stale search which then breaks when
// navigating it
2022-05-15 21:03:51 +02:00
const numFound = parseInt ( this . $numFound . text ( ) ) ;
const curFound = parseInt ( this . $curFound . text ( ) ) - 1 ;
2022-05-09 23:13:34 +02:00
const note = appContext . tabManager . getActiveContextNote ( ) ;
2022-05-14 22:33:45 +02:00
if ( note . type === "code" ) {
2022-05-15 21:03:51 +02:00
const codeEditor = await getActiveContextCodeEditor ( ) ;
2022-05-09 23:13:34 +02:00
if ( numFound > 0 ) {
2022-05-15 21:03:51 +02:00
const doc = codeEditor . doc ;
const pos = this . findResult [ curFound ] . find ( ) ;
2022-05-09 23:13:34 +02:00
// Note setting the selection sets the cursor to
// the end of the selection and scrolls it into
// view
doc . setSelection ( pos . from , pos . to ) ;
// Clear all markers
2022-05-15 12:09:30 +02:00
codeEditor . operation ( ( ) => {
for ( let i = 0 ; i < this . findResult . length ; ++ i ) {
let marker = this . findResult [ i ] ;
2022-05-09 23:13:34 +02:00
marker . clear ( ) ;
}
} ) ;
}
// Restore the highlightSelectionMatches setting
2022-05-15 12:09:30 +02:00
codeEditor . setOption ( "highlightSelectionMatches" , this . oldHighlightSelectionMatches ) ;
this . findResult = null ;
this . needle = null ;
2022-05-09 23:13:34 +02:00
} else {
if ( numFound > 0 ) {
const textEditor = await getActiveContextTextEditor ( ) ;
// Clear the markers and set the caret to the
// current occurrence
const model = textEditor . model ;
2022-05-15 21:03:51 +02:00
const range = this . findResult . results . get ( curFound ) . marker . getRange ( ) ;
2022-05-09 23:13:34 +02:00
// From
// https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findandreplace.js#L92
// XXX Roll our own since already done for codeEditor and
// will probably allow more refactoring?
let findAndReplaceEditing = textEditor . plugins . get ( 'FindAndReplaceEditing' ) ;
findAndReplaceEditing . state . clear ( model ) ;
findAndReplaceEditing . stop ( ) ;
model . change ( writer => {
writer . setSelection ( range , 0 ) ;
} ) ;
textEditor . editing . view . scrollToTheSelection ( ) ;
2022-05-15 12:09:30 +02:00
this . findResult = null ;
this . needle = null ;
2022-05-09 23:13:34 +02:00
} else {
2022-05-15 12:09:30 +02:00
this . findResult = null ;
this . needle = null ;
2022-05-09 23:13:34 +02:00
}
}
} ) ;
2022-05-15 12:09:30 +02:00
return this . $widget ;
2022-05-09 23:13:34 +02:00
}
2022-05-15 21:03:51 +02:00
async findInTextEvent ( ) {
const note = appContext . tabManager . getActiveContextNote ( ) ;
// Only writeable text and code supported
const readOnly = note . getAttribute ( "label" , "readOnly" ) ;
if ( ! readOnly && ( note . type === "code" || note . type === "text" ) ) {
if ( this . $findBox . is ( ":hidden" ) ) {
this . $findBox . show ( ) ;
this . $input . focus ( ) ;
this . $numFound . text ( 0 ) ;
this . $curFound . text ( 0 ) ;
// Initialize the input field to the text selection, if any
if ( note . type === "code" ) {
const codeEditor = await getActiveContextCodeEditor ( ) ;
// highlightSelectionMatches is the overlay that highlights
// the words under the cursor. This occludes the search
// markers style, save it, disable it. Will be restored when
// the focus is back into the note
this . oldHighlightSelectionMatches = codeEditor . getOption ( "highlightSelectionMatches" ) ;
codeEditor . setOption ( "highlightSelectionMatches" , false ) ;
// Fill in the findbox with the current selection if any
const selectedText = codeEditor . getSelection ( )
if ( selectedText !== "" ) {
this . $input . val ( selectedText ) ;
}
// Directly perform the search if there's some text to find,
// without delaying or waiting for enter
const needle = this . $input . val ( ) ;
if ( needle !== "" ) {
this . $input . select ( ) ;
await this . performFind ( needle ) ;
}
} else {
const textEditor = await getActiveContextTextEditor ( ) ;
const selection = textEditor . model . document . selection ;
const range = selection . getFirstRange ( ) ;
for ( const item of range . getItems ( ) ) {
// Fill in the findbox with the current selection if
// any
this . $input . val ( item . data ) ;
break ;
}
// Directly perform the search if there's some text to
// find, without delaying or waiting for enter
const needle = this . $input . val ( ) ;
if ( needle !== "" ) {
this . $input . select ( ) ;
await this . performFind ( needle ) ;
}
}
}
}
}
2022-05-14 21:06:14 +02:00
async performTextNoteFind ( needle , matchCase , wholeWord ) {
2022-05-09 23:13:34 +02:00
// Do this even if the needle is empty so the markers are cleared and
// the counters updated
const textEditor = await getActiveContextTextEditor ( ) ;
const model = textEditor . model ;
let findResult = null ;
let numFound = 0 ;
let curFound = - 1 ;
// Clear
2022-05-14 22:33:45 +02:00
const findAndReplaceEditing = textEditor . plugins . get ( 'FindAndReplaceEditing' ) ;
2022-05-09 23:13:34 +02:00
findAndReplaceEditing . state . clear ( model ) ;
findAndReplaceEditing . stop ( ) ;
2022-05-14 22:33:45 +02:00
if ( needle !== "" ) {
2022-05-09 23:13:34 +02:00
// Parameters are callback/text, options.matchCase=false, options.wholeWords=false
// See https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findcommand.js#L44
// XXX Need to use the callback version for regexp
// needle = escapeRegExp(needle);
// let re = new RegExp(needle, 'gi');
// let m = text.match(re);
// numFound = m ? m.length : 0;
2022-05-14 21:06:14 +02:00
const options = { "matchCase" : matchCase , "wholeWords" : wholeWord } ;
findResult = textEditor . execute ( 'find' , needle , options ) ;
2022-05-09 23:13:34 +02:00
numFound = findResult . results . length ;
// Find the result beyond the cursor
2022-05-14 22:33:45 +02:00
const cursorPos = model . document . selection . getLastPosition ( ) ;
2022-05-09 23:13:34 +02:00
for ( let i = 0 ; i < findResult . results . length ; ++ i ) {
2022-05-14 22:33:45 +02:00
const marker = findResult . results . get ( i ) . marker ;
const fromPos = marker . getStart ( ) ;
if ( fromPos . compareWith ( cursorPos ) !== "before" ) {
2022-05-09 23:13:34 +02:00
curFound = i ;
break ;
}
}
}
this . findResult = findResult ;
this . $numFound . text ( numFound ) ;
// Calculate curfound if not already, highlight it as
// selected
if ( numFound > 0 ) {
curFound = Math . max ( 0 , curFound ) ;
// XXX Do this accessing the private data?
// See
// https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findnextcommand.js
for ( let i = 0 ; i < curFound ; ++ i ) {
textEditor . execute ( 'findNext' , needle ) ;
}
}
this . $curFound . text ( curFound + 1 ) ;
this . needle = needle ;
}
2022-05-14 21:06:14 +02:00
async performCodeNoteFind ( needle , matchCase , wholeWord ) {
2022-05-09 23:13:34 +02:00
let findResult = null ;
let numFound = 0 ;
let curFound = - 1 ;
// See https://codemirror.net/addon/search/searchcursor.js for tips
2022-05-14 22:33:45 +02:00
const codeEditor = await getActiveContextCodeEditor ( ) ;
const doc = codeEditor . doc ;
const text = doc . getValue ( ) ;
2022-05-09 23:13:34 +02:00
// Clear all markers
if ( this . findResult != null ) {
const findWidget = this ;
2022-05-15 12:09:30 +02:00
codeEditor . operation ( ( ) => {
for ( let i = 0 ; i < this . findResult . length ; ++ i ) {
const marker = this . findResult [ i ] ;
2022-05-09 23:13:34 +02:00
marker . clear ( ) ;
}
} ) ;
}
2022-05-15 21:03:51 +02:00
if ( needle !== "" ) {
2022-05-09 23:13:34 +02:00
needle = escapeRegExp ( needle ) ;
// Find and highlight matches
2022-05-14 21:06:14 +02:00
// Find and highlight matches
// XXX Using \\b and not using the unicode flag probably doesn't
// work with non ascii alphabets, findAndReplace uses a more
// complicated regexp, see
// https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/utils.js#L145
const wholeWordChar = wholeWord ? "\\b" : "" ;
2022-05-14 22:33:45 +02:00
const re = new RegExp ( wholeWordChar + needle + wholeWordChar ,
2022-05-14 21:06:14 +02:00
'g' + ( matchCase ? '' : 'i' ) ) ;
2022-05-09 23:13:34 +02:00
let curLine = 0 ;
let curChar = 0 ;
let curMatch = null ;
findResult = [ ] ;
// All those markText take several seconds on eg this ~500-line
// script, batch them inside an operation so they become
// unnoticeable. Alternatively, an overlay could be used, see
// https://codemirror.net/addon/search/match-highlighter.js ?
2022-05-15 12:09:30 +02:00
codeEditor . operation ( ( ) => {
2022-05-09 23:13:34 +02:00
for ( let i = 0 ; i < text . length ; ++ i ) {
// Fetch next match if it's the first time or
// if past the current match start
if ( ( curMatch == null ) || ( curMatch . index < i ) ) {
curMatch = re . exec ( text ) ;
if ( curMatch == null ) {
// No more matches
break ;
}
}
// Create a non-selected highlight marker for the match, the
// selected marker highlight will be done later
2022-05-14 22:33:45 +02:00
if ( i === curMatch . index ) {
2022-05-09 23:13:34 +02:00
let fromPos = { "line" : curLine , "ch" : curChar } ;
// XXX If multiline is supported, this needs to
// recalculate curLine since the match may span
// lines
let toPos = { "line" : curLine , "ch" : curChar + curMatch [ 0 ] . length } ;
// XXX or css = "color: #f3"
let marker = doc . markText ( fromPos , toPos , { "className" : FIND _RESULT _CSS _CLASSNAME } ) ;
findResult . push ( marker ) ;
// Set the first match beyond the cursor as current
// match
2022-05-14 22:33:45 +02:00
if ( curFound === - 1 ) {
const cursorPos = codeEditor . getCursor ( ) ;
2022-05-09 23:13:34 +02:00
if ( ( fromPos . line > cursorPos . line ) ||
2022-05-15 21:03:51 +02:00
( ( fromPos . line === cursorPos . line ) &&
2022-05-09 23:13:34 +02:00
( fromPos . ch >= cursorPos . ch ) ) ) {
curFound = numFound ;
}
}
numFound ++ ;
}
// Do line and char position tracking
2022-05-14 22:33:45 +02:00
if ( text [ i ] === "\n" ) {
2022-05-09 23:13:34 +02:00
curLine ++ ;
curChar = 0 ;
} else {
curChar ++ ;
}
}
} ) ;
}
this . findResult = findResult ;
this . $numFound . text ( numFound ) ;
// Calculate curfound if not already, highlight it as selected
if ( numFound > 0 ) {
curFound = Math . max ( 0 , curFound )
let marker = findResult [ curFound ] ;
let pos = marker . find ( ) ;
codeEditor . scrollIntoView ( pos . to ) ;
marker . clear ( ) ;
findResult [ curFound ] = doc . markText ( pos . from , pos . to ,
{ "className" : FIND _RESULT _SELECTED _CSS _CLASSNAME }
) ;
}
this . $curFound . text ( curFound + 1 ) ;
this . needle = needle ;
}
2022-05-14 21:06:14 +02:00
/ * *
* Perform the find and highlight the find results .
*
* @ param needle { string } optional parameter , taken from the input box if
* missing .
* @ param matchCase { boolean } optional parameter , taken from the checkbox
* state if missing .
* @ param wholeWord { boolean } optional parameter , taken from the checkbox
* state if missing .
* /
async performFind ( needle , matchCase , wholeWord ) {
2022-05-14 22:33:45 +02:00
needle = ( needle === undefined ) ? this . $input . val ( ) : needle ;
2022-05-14 21:06:14 +02:00
matchCase = ( matchCase === undefined ) ? this . $caseCheck . prop ( "checked" ) : matchCase ;
wholeWord = ( wholeWord === undefined ) ? this . $wordCheck . prop ( "checked" ) : wholeWord ;
2022-05-09 23:13:34 +02:00
const note = appContext . tabManager . getActiveContextNote ( ) ;
2022-05-14 22:33:45 +02:00
if ( note . type === "code" ) {
2022-05-14 21:06:14 +02:00
await this . performCodeNoteFind ( needle , matchCase , wholeWord ) ;
2022-05-09 23:13:34 +02:00
} else {
2022-05-14 21:06:14 +02:00
await this . performTextNoteFind ( needle , matchCase , wholeWord ) ;
2022-05-09 23:13:34 +02:00
}
}
isEnabled ( ) {
2022-05-15 21:03:51 +02:00
return super . isEnabled ( ) && ( this . note . type === 'text' || this . note . type === 'code' ) ;
2022-05-09 23:13:34 +02:00
}
async entitiesReloadedEvent ( { loadResults } ) {
if ( loadResults . isNoteContentReloaded ( this . noteId ) ) {
this . refresh ( ) ;
}
}
}