/** * (c) Antonio Tejada 2022 * https://github.com/antoniotejada/Trilium-FindWidget */ import { t } from "../services/i18n.js"; import NoteContextAwareWidget from "./note_context_aware_widget.js"; import attributeService from "../services/attributes.js"; import FindInText from "./find_in_text.js"; import FindInCode from "./find_in_code.js"; import FindInHtml from "./find_in_html.js"; const findWidgetDelayMillis = 200; const waitForEnter = findWidgetDelayMillis < 0; // 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 // of undefined. It's -1 instead of > 0, so they don't tabstop const TPL = `
0 / 0
`; export default class FindWidget extends NoteContextAwareWidget { constructor() { super(); this.searchTerm = null; this.textHandler = new FindInText(this); this.codeHandler = new FindInCode(this); this.htmlHandler = new FindInHtml(this); } async noteSwitched() { await super.noteSwitched(); await this.closeSearch(); } doRender() { this.$widget = $(TPL); this.$widget.hide(); this.$input = this.$widget.find(".find-widget-search-term-input"); this.$currentFound = this.$widget.find(".find-widget-current-found"); this.$totalFound = this.$widget.find(".find-widget-total-found"); this.$caseSensitiveCheckbox = this.$widget.find(".find-widget-case-sensitive-checkbox"); this.$caseSensitiveCheckbox.change(() => this.performFind()); this.$matchWordsCheckbox = this.$widget.find(".find-widget-match-words-checkbox"); this.$matchWordsCheckbox.change(() => this.performFind()); this.$previousButton = this.$widget.find(".find-widget-previous-button"); this.$previousButton.on("click", () => this.findNext(-1)); this.$nextButton = this.$widget.find(".find-widget-next-button"); this.$nextButton.on("click", () => this.findNext(1)); this.$closeButton = this.$widget.find(".find-widget-close-button"); this.$closeButton.on("click", () => this.closeSearch()); this.$replaceWidgetBox = this.$widget.find(".replace-widget-box"); this.$replaceTextInput = this.$widget.find(".replace-widget-replacetext-input"); this.$replaceAllButton = this.$widget.find(".replace-widget-replaceall-button"); this.$replaceAllButton.on("click", () => this.replaceAll()); this.$replaceButton = this.$widget.find(".replace-widget-replace-button"); this.$replaceButton.on("click", () => this.replace()); this.$input.keydown(async (e) => { if ((e.metaKey || e.ctrlKey) && (e.key === "F" || e.key === "f")) { // If ctrl+f is pressed when the findbox is shown, select the // whole input to find this.$input.select(); } else if (e.key === "Enter" || e.key === "F3") { await this.findNext(e?.shiftKey ? -1 : 1); e.preventDefault(); return false; } }); this.$widget.keydown(async (e) => { if (e.key === "Escape") { await this.closeSearch(); } }); this.$input.on("input", () => this.startSearch()); return this.$widget; } async findInTextEvent() { if (!this.isActiveNoteContext()) { return; } if (!["text", "code", "render"].includes(this.note.type)) { return; } this.handler = await this.getHandler(); const isReadOnly = await this.noteContext.isReadOnly(); let selectedText = ""; if (this.note.type === "code" && !isReadOnly) { const codeEditor = await this.noteContext.getCodeEditor(); selectedText = codeEditor.getSelection(); } else { selectedText = window.getSelection().toString() || ""; } this.$widget.show(); this.$input.focus(); if (["text", "code"].includes(this.note.type) && !isReadOnly) { this.$replaceWidgetBox.show(); } else { this.$replaceWidgetBox.hide(); } const isAlreadyVisible = this.$widget.is(":visible"); if (isAlreadyVisible) { if (selectedText) { this.$input.val(selectedText); } if (this.$input.val()) { await this.performFind(); } this.$input.select(); } else { this.$totalFound.text(0); this.$currentFound.text(0); this.$input.val(selectedText); if (selectedText) { this.$input.select(); await this.performFind(); } } } async getHandler() { if (this.note.type === "render") { return this.htmlHandler; } const readOnly = await this.noteContext.isReadOnly(); if (readOnly) { return this.htmlHandler; } else { return this.note.type === "code" ? this.codeHandler : this.textHandler; } } startSearch() { // 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 the non waitforenter case) if (!waitForEnter) { // Clear the previous timeout if any, it's ok if timeoutId is // null or undefined clearTimeout(this.timeoutId); // 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 this.timeoutId = setTimeout(async () => { this.timeoutId = null; await this.performFind(); }, findWidgetDelayMillis); } } /** * @param direction +1 for next, -1 for previous * @returns {Promise} */ async findNext(direction) { if (this.$totalFound.text() == "?") { await this.performFind(); return; } const searchTerm = this.$input.val(); if (waitForEnter && this.searchTerm !== searchTerm) { await this.performFind(); } const totalFound = parseInt(this.$totalFound.text()); const currentFound = parseInt(this.$currentFound.text()) - 1; if (totalFound > 0) { let nextFound = currentFound + direction; // Wrap around if (nextFound > totalFound - 1) { nextFound = 0; } else if (nextFound < 0) { nextFound = totalFound - 1; } this.$currentFound.text(nextFound + 1); await this.handler.findNext(direction, currentFound, nextFound); } } /** Perform the find and highlight the find results. */ async performFind() { const searchTerm = this.$input.val(); const matchCase = this.$caseSensitiveCheckbox.prop("checked"); const wholeWord = this.$matchWordsCheckbox.prop("checked"); const { totalFound, currentFound } = await this.handler.performFind(searchTerm, matchCase, wholeWord); this.$totalFound.text(totalFound); this.$currentFound.text(currentFound); this.searchTerm = searchTerm; } async closeSearch() { if (this.$widget.is(":visible")) { this.$widget.hide(); // Restore any state, if there's a current occurrence clear markers // and scroll to and select the last occurrence const totalFound = parseInt(this.$totalFound.text()); const currentFound = parseInt(this.$currentFound.text()) - 1; this.searchTerm = null; await this.handler.findBoxClosed(totalFound, currentFound); } } async replace() { const replaceText = this.$replaceTextInput.val(); await this.handler.replace(replaceText); } async replaceAll() { const replaceText = this.$replaceTextInput.val(); await this.handler.replaceAll(replaceText); } isEnabled() { return super.isEnabled() && ["text", "code", "render"].includes(this.note.type); } async entitiesReloadedEvent({ loadResults }) { if (loadResults.isNoteContentReloaded(this.noteId)) { this.$totalFound.text("?"); } else if (loadResults.getAttributeRows().find((attr) => attr.type === "label" && attr.name.toLowerCase().includes("readonly") && attributeService.isAffecting(attr, this.note))) { this.closeSearch(); } } }