From 89d51a9c4ff2a0a3de8909fa9fb04a26bef996e9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 22:00:41 +0200 Subject: [PATCH] chore(client/ts): port find_in_html/text --- src/public/app/types.d.ts | 25 ++++++- src/public/app/widgets/{find.js => find.ts} | 81 +++++++++++++++------ src/public/app/widgets/find_in_html.ts | 3 +- src/public/app/widgets/find_in_text.ts | 22 ++++-- 4 files changed, 97 insertions(+), 34 deletions(-) rename src/public/app/widgets/{find.js => find.ts} (77%) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 63c9acdde..774985177 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -227,6 +227,7 @@ declare global { focus(); getCursor(): { line: number, col: number, ch: number }; setCursor(line: number, col: number); + getSelection(): string; lineCount(): number; on(event: string, callback: () => void); operation(callback: () => void); @@ -254,7 +255,7 @@ declare global { interface Writer { setAttribute(name: string, value: string, el: TextEditorElement); createPositionAt(el: TextEditorElement, opt?: "end"); - setSelection(pos: number); + setSelection(pos: number, pos?: number); } interface TextNode { previousSibling?: TextNode; @@ -270,6 +271,7 @@ declare global { interface TextPosition { textNode: TextNode; offset: number; + compareWith(pos: TextPosition): string; } interface TextEditor { model: { @@ -306,15 +308,34 @@ declare global { scrollToTheSelection(): void; } }, + plugins: { + get(command: string) + }, getData(): string; setData(data: string): void; getSelectedHtml(): string; removeSelection(): void; - execute(action: string, ...args: unknown[]): void; + execute(action: string, ...args: unknown[]): T; focus(): void; sourceElement: HTMLElement; } + interface EditingState { + highlightedResult: string; + results: unknown[]; + } + + interface CKFindResult { + results: { + get(number): { + marker: { + getStart(): TextPosition; + getRange(): number; + }; + } + } & []; + } + interface MentionItem { action?: string; noteTitle?: string; diff --git a/src/public/app/widgets/find.js b/src/public/app/widgets/find.ts similarity index 77% rename from src/public/app/widgets/find.js rename to src/public/app/widgets/find.ts index c85d0f5f7..c208128fb 100644 --- a/src/public/app/widgets/find.js +++ b/src/public/app/widgets/find.ts @@ -9,10 +9,16 @@ 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"; +import type { EventData } from "../components/app_context.js"; const findWidgetDelayMillis = 200; const waitForEnter = findWidgetDelayMillis < 0; +export interface FindResult { + totalFound: number; + currentFound: number; +} + // 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 @@ -92,6 +98,28 @@ const TPL = ` `; export default class FindWidget extends NoteContextAwareWidget { + + private searchTerm: string | null; + + private textHandler: FindInText; + private codeHandler: FindInCode; + private htmlHandler: FindInHtml; + private handler?: FindInText | FindInCode | FindInHtml; + private timeoutId?: number | null; + + private $input!: JQuery; + private $currentFound!: JQuery; + private $totalFound!: JQuery; + private $caseSensitiveCheckbox!: JQuery; + private $matchWordsCheckbox!: JQuery; + private $previousButton!: JQuery; + private $nextButton!: JQuery; + private $closeButton!: JQuery; + private $replaceWidgetBox!: JQuery; + private $replaceTextInput!: JQuery; + private $replaceAllButton!: JQuery; + private $replaceButton!: JQuery; + constructor() { super(); @@ -160,24 +188,24 @@ export default class FindWidget extends NoteContextAwareWidget { return; } - if (!["text", "code", "render"].includes(this.note.type)) { + if (!["text", "code", "render"].includes(this.note?.type ?? "")) { return; } this.handler = await this.getHandler(); - const isReadOnly = await this.noteContext.isReadOnly(); + const isReadOnly = await this.noteContext?.isReadOnly(); let selectedText = ""; - if (this.note.type === "code" && !isReadOnly) { + if (this.note?.type === "code" && !isReadOnly && this.noteContext) { const codeEditor = await this.noteContext.getCodeEditor(); selectedText = codeEditor.getSelection(); } else { - selectedText = window.getSelection().toString() || ""; + selectedText = window.getSelection()?.toString() || ""; } this.$widget.show(); this.$input.focus(); - if (["text", "code"].includes(this.note.type) && !isReadOnly) { + if (["text", "code"].includes(this.note?.type ?? "") && !isReadOnly) { this.$replaceWidgetBox.show(); } else { this.$replaceWidgetBox.hide(); @@ -208,16 +236,16 @@ export default class FindWidget extends NoteContextAwareWidget { } async getHandler() { - if (this.note.type === "render") { + if (this.note?.type === "render") { return this.htmlHandler; } - const readOnly = await this.noteContext.isReadOnly(); + const readOnly = await this.noteContext?.isReadOnly(); if (readOnly) { return this.htmlHandler; } else { - return this.note.type === "code" ? this.codeHandler : this.textHandler; + return this.note?.type === "code" ? this.codeHandler : this.textHandler; } } @@ -228,7 +256,7 @@ export default class FindWidget extends NoteContextAwareWidget { if (!waitForEnter) { // Clear the previous timeout if any, it's ok if timeoutId is // null or undefined - clearTimeout(this.timeoutId); + clearTimeout(this.timeoutId as unknown as NodeJS.Timeout); // TODO: Fix once client is separated from Node.js types. // Defer the search a few millis so the search doesn't start // immediately, as this can cause search word typing lag with @@ -237,15 +265,14 @@ export default class FindWidget extends NoteContextAwareWidget { this.timeoutId = setTimeout(async () => { this.timeoutId = null; await this.performFind(); - }, findWidgetDelayMillis); + }, findWidgetDelayMillis) as unknown as number; // TODO: Fix once client is separated from Node.js types. } } /** * @param direction +1 for next, -1 for previous - * @returns {Promise} */ - async findNext(direction) { + async findNext(direction: 1 | -1) { if (this.$totalFound.text() == "?") { await this.performFind(); return; @@ -268,17 +295,17 @@ export default class FindWidget extends NoteContextAwareWidget { this.$currentFound.text(nextFound + 1); - await this.handler.findNext(direction, currentFound, nextFound); + await this.handler?.findNext(direction, currentFound, nextFound); } } /** Perform the find and highlight the find results. */ async performFind() { - const searchTerm = this.$input.val(); + const searchTerm = String(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); + const { totalFound, currentFound } = await this.handler?.performFind(searchTerm, matchCase, wholeWord); this.$totalFound.text(totalFound); this.$currentFound.text(currentFound); @@ -297,28 +324,34 @@ export default class FindWidget extends NoteContextAwareWidget { this.searchTerm = null; - await this.handler.findBoxClosed(totalFound, currentFound); + await this.handler?.findBoxClosed(totalFound, currentFound); } } async replace() { - const replaceText = this.$replaceTextInput.val(); - await this.handler.replace(replaceText); + const replaceText = String(this.$replaceTextInput.val()); + if (this.handler && "replace" in this.handler) { + await this.handler.replace(replaceText); + } } async replaceAll() { - const replaceText = this.$replaceTextInput.val(); - await this.handler.replaceAll(replaceText); + const replaceText = String(this.$replaceTextInput.val()); + if (this.handler && "replace" in this.handler) { + await this.handler.replaceAll(replaceText); + } } isEnabled() { - return super.isEnabled() && ["text", "code", "render"].includes(this.note.type); + return super.isEnabled() && ["text", "code", "render"].includes(this.note?.type ?? ""); } - async entitiesReloadedEvent({ loadResults }) { - if (loadResults.isNoteContentReloaded(this.noteId)) { + async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { + if (this.noteId && 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))) { + } else if (loadResults.getAttributeRows().find((attr) => attr.type === "label" + && (attr.name?.toLowerCase() ?? "").includes("readonly") + && attributeService.isAffecting(attr, this.note))) { this.closeSearch(); } } diff --git a/src/public/app/widgets/find_in_html.ts b/src/public/app/widgets/find_in_html.ts index 4940d80eb..d1e9a93db 100644 --- a/src/public/app/widgets/find_in_html.ts +++ b/src/public/app/widgets/find_in_html.ts @@ -5,6 +5,7 @@ import libraryLoader from "../services/library_loader.js"; import utils from "../services/utils.js"; import appContext from "../components/app_context.js"; import type FindWidget from "./find.js"; +import type { FindResult } from "./find.js"; const FIND_RESULT_SELECTED_CSS_CLASSNAME = "ck-find-result_selected"; const FIND_RESULT_CSS_CLASSNAME = "ck-find-result"; @@ -29,7 +30,7 @@ export default class FindInHtml { const wholeWordChar = wholeWord ? "\\b" : ""; const regExp = new RegExp(wholeWordChar + utils.escapeRegExp(searchTerm) + wholeWordChar, matchCase ? "g" : "gi"); - return new Promise((res) => { + return new Promise((res) => { $content?.unmark({ done: () => { $content.markRegExp(regExp, { diff --git a/src/public/app/widgets/find_in_text.ts b/src/public/app/widgets/find_in_text.ts index 003f11be8..b5fa3a02c 100644 --- a/src/public/app/widgets/find_in_text.ts +++ b/src/public/app/widgets/find_in_text.ts @@ -1,3 +1,4 @@ +import type { FindResult } from "./find.js"; import type FindWidget from "./find.js"; // TODO: Deduplicate. @@ -13,7 +14,8 @@ interface Match { export default class FindInText { private parent: FindWidget; - private findResult?: Match[] | null; + private findResult?: CKFindResult | null; + private editingState?: EditingState; constructor(parent: FindWidget) { this.parent = parent; @@ -23,10 +25,14 @@ export default class FindInText { return this.parent?.noteContext?.getTextEditor(); } - async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean): Promise { // Do this even if the searchTerm is empty so the markers are cleared and // the counters updated const textEditor = await this.getTextEditor(); + if (!textEditor) { + return { currentFound: 0, totalFound: 0 }; + } + const model = textEditor.model; let findResult = null; let totalFound = 0; @@ -46,14 +52,14 @@ export default class FindInText { // let m = text.match(re); // totalFound = m ? m.length : 0; const options = { matchCase: matchCase, wholeWords: wholeWord }; - findResult = textEditor.execute("find", searchTerm, options); + findResult = textEditor.execute("find", searchTerm, options); totalFound = findResult.results.length; // Find the result beyond the cursor const cursorPos = model.document.selection.getLastPosition(); for (let i = 0; i < findResult.results.length; ++i) { const marker = findResult.results.get(i).marker; const fromPos = marker.getStart(); - if (fromPos.compareWith(cursorPos) !== "before") { + if (cursorPos && fromPos.compareWith(cursorPos) !== "before") { currentFound = i; break; } @@ -111,9 +117,11 @@ export default class FindInText { let findAndReplaceEditing = textEditor.plugins.get("FindAndReplaceEditing"); findAndReplaceEditing.state.clear(model); findAndReplaceEditing.stop(); - model.change((writer) => { - writer.setSelection(range, 0); - }); + if (range) { + model.change((writer) => { + writer.setSelection(range, 0); + }); + } textEditor.editing.view.scrollToTheSelection(); }