From f68347f92c9ea10bd9e26f50903ac40785c78ba8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 17 Mar 2025 22:46:00 +0200 Subject: [PATCH 01/25] client: port ts --- src/public/app/types.d.ts | 26 +++++- .../{find_in_code.js => find_in_code.ts} | 82 +++++++++++++------ .../{find_in_html.js => find_in_html.ts} | 38 +++++---- .../{find_in_text.js => find_in_text.ts} | 46 +++++++---- .../app/widgets/note_context_aware_widget.ts | 3 +- 5 files changed, 138 insertions(+), 57 deletions(-) rename src/public/app/widgets/{find_in_code.js => find_in_code.ts} (80%) rename src/public/app/widgets/{find_in_html.js => find_in_html.ts} (69%) rename src/public/app/widgets/{find_in_text.js => find_in_text.ts} (78%) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 0252c9d37..63c9acdde 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -95,7 +95,11 @@ declare global { className: string; separateWordSearch: boolean; caseSensitive: boolean; - }) + done: () => void; + }); + unmark(opts?: { + done: () => void; + }); } interface JQueryStatic { @@ -221,9 +225,23 @@ declare global { setOption(name: string, value: string); refresh(); focus(); + getCursor(): { line: number, col: number, ch: number }; setCursor(line: number, col: number); lineCount(): number; on(event: string, callback: () => void); + operation(callback: () => void); + scrollIntoView(pos: number); + doc: { + getValue(): string; + markText( + from: { line: number, ch: number } | number, + to: { line: number, ch: number } | number, + opts: { + className: string + }); + setSelection(from: number, to: number); + replaceRange(text: string, from: number, to: number); + } } var katex: { @@ -260,6 +278,7 @@ declare global { getRoot(): TextEditorElement; selection: { getFirstPosition(): undefined | TextPosition; + getLastPosition(): undefined | TextPosition; } }, change(cb: (writer: Writer) => void) @@ -283,13 +302,16 @@ declare global { } }; } - change(cb: (writer: Writer) => void) + change(cb: (writer: Writer) => void); + scrollToTheSelection(): void; } }, getData(): string; setData(data: string): void; getSelectedHtml(): string; removeSelection(): void; + execute(action: string, ...args: unknown[]): void; + focus(): void; sourceElement: HTMLElement; } diff --git a/src/public/app/widgets/find_in_code.js b/src/public/app/widgets/find_in_code.ts similarity index 80% rename from src/public/app/widgets/find_in_code.js rename to src/public/app/widgets/find_in_code.ts index da3476722..d29cd67ce 100644 --- a/src/public/app/widgets/find_in_code.js +++ b/src/public/app/widgets/find_in_code.ts @@ -2,35 +2,54 @@ // uses for highlighting matches, use the same one on CodeMirror // for consistency import utils from "../services/utils.js"; +import type FindWidget from "./find.js"; const FIND_RESULT_SELECTED_CSS_CLASSNAME = "ck-find-result_selected"; const FIND_RESULT_CSS_CLASSNAME = "ck-find-result"; +// TODO: Deduplicate. +interface Match { + className: string; + clear(): void; + find(): { + from: number; + to: number; + }; +} + export default class FindInCode { - constructor(parent) { - /** @property {FindWidget} */ + + private parent: FindWidget; + private findResult?: Match[] | null; + + constructor(parent: FindWidget) { this.parent = parent; } async getCodeEditor() { - return this.parent.noteContext.getCodeEditor(); + return this.parent.noteContext?.getCodeEditor(); } - async performFind(searchTerm, matchCase, wholeWord) { - let findResult = null; + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { + let findResult: Match[] | null = null; let totalFound = 0; let currentFound = -1; // See https://codemirror.net/addon/search/searchcursor.js for tips const codeEditor = await this.getCodeEditor(); + if (!codeEditor) { + return; + } + const doc = codeEditor.doc; const text = doc.getValue(); // Clear all markers - if (this.findResult != null) { + if (this.findResult) { codeEditor.operation(() => { - for (let i = 0; i < this.findResult.length; ++i) { - const marker = this.findResult[i]; + const findResult = this.findResult as Match[]; + for (let i = 0; i < findResult.length; ++i) { + const marker = findResult[i]; marker.clear(); } }); @@ -49,7 +68,7 @@ export default class FindInCode { const re = new RegExp(wholeWordChar + searchTerm + wholeWordChar, "g" + (matchCase ? "" : "i")); let curLine = 0; let curChar = 0; - let curMatch = null; + let curMatch: RegExpExecArray | null = null; findResult = []; // All those markText take several seconds on e.g., this ~500-line // script, batch them inside an operation, so they become @@ -73,7 +92,7 @@ export default class FindInCode { let toPos = { line: curLine, ch: curChar + curMatch[0].length }; // or css = "color: #f3" let marker = doc.markText(fromPos, toPos, { className: FIND_RESULT_CSS_CLASSNAME }); - findResult.push(marker); + findResult?.push(marker); // Set the first match beyond the cursor as the current match if (currentFound === -1) { @@ -99,7 +118,7 @@ export default class FindInCode { this.findResult = findResult; // Calculate curfound if not already, highlight it as selected - if (totalFound > 0) { + if (findResult && totalFound > 0) { currentFound = Math.max(0, currentFound); let marker = findResult[currentFound]; let pos = marker.find(); @@ -114,8 +133,12 @@ export default class FindInCode { }; } - async findNext(direction, currentFound, nextFound) { + async findNext(direction: number, currentFound: number, nextFound: number) { const codeEditor = await this.getCodeEditor(); + if (!codeEditor || !this.findResult) { + return; + } + const doc = codeEditor.doc; // @@ -137,18 +160,23 @@ export default class FindInCode { codeEditor.scrollIntoView(pos.from); } - async findBoxClosed(totalFound, currentFound) { + async findBoxClosed(totalFound: number, currentFound: number) { const codeEditor = await this.getCodeEditor(); - if (totalFound > 0) { + if (codeEditor && totalFound > 0) { const doc = codeEditor.doc; - const pos = this.findResult[currentFound].find(); + const pos = this.findResult?.[currentFound].find(); // Note setting the selection sets the cursor to // the end of the selection and scrolls it into // view - doc.setSelection(pos.from, pos.to); + if (pos) { + doc.setSelection(pos.from, pos.to); + } // Clear all markers codeEditor.operation(() => { + if (!this.findResult) { + return; + } for (let i = 0; i < this.findResult.length; ++i) { let marker = this.findResult[i]; marker.clear(); @@ -157,9 +185,9 @@ export default class FindInCode { } this.findResult = null; - codeEditor.focus(); + codeEditor?.focus(); } - async replace(replaceText) { + async replace(replaceText: string) { // this.findResult may be undefined and null if (!this.findResult || this.findResult.length === 0) { return; @@ -178,8 +206,10 @@ export default class FindInCode { let marker = this.findResult[currentFound]; let pos = marker.find(); const codeEditor = await this.getCodeEditor(); - const doc = codeEditor.doc; - doc.replaceRange(replaceText, pos.from, pos.to); + const doc = codeEditor?.doc; + if (doc) { + doc.replaceRange(replaceText, pos.from, pos.to); + } marker.clear(); let nextFound; @@ -194,17 +224,21 @@ export default class FindInCode { } } } - async replaceAll(replaceText) { + async replaceAll(replaceText: string) { if (!this.findResult || this.findResult.length === 0) { return; } const codeEditor = await this.getCodeEditor(); - const doc = codeEditor.doc; - codeEditor.operation(() => { + const doc = codeEditor?.doc; + codeEditor?.operation(() => { + if (!this.findResult) { + return; + } + for (let currentFound = 0; currentFound < this.findResult.length; currentFound++) { let marker = this.findResult[currentFound]; let pos = marker.find(); - doc.replaceRange(replaceText, pos.from, pos.to); + doc?.replaceRange(replaceText, pos.from, pos.to); marker.clear(); } }); diff --git a/src/public/app/widgets/find_in_html.js b/src/public/app/widgets/find_in_html.ts similarity index 69% rename from src/public/app/widgets/find_in_html.js rename to src/public/app/widgets/find_in_html.ts index 1c4b80971..4940d80eb 100644 --- a/src/public/app/widgets/find_in_html.js +++ b/src/public/app/widgets/find_in_html.ts @@ -4,28 +4,33 @@ 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"; const FIND_RESULT_SELECTED_CSS_CLASSNAME = "ck-find-result_selected"; const FIND_RESULT_CSS_CLASSNAME = "ck-find-result"; export default class FindInHtml { - constructor(parent) { - /** @property {FindWidget} */ + + private parent: FindWidget; + private currentIndex: number; + private $results: JQuery | null; + + constructor(parent: FindWidget) { this.parent = parent; this.currentIndex = 0; this.$results = null; } - async performFind(searchTerm, matchCase, wholeWord) { + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { await libraryLoader.requireLibrary(libraryLoader.MARKJS); - const $content = await this.parent.noteContext.getContentElement(); + const $content = await this.parent?.noteContext?.getContentElement(); const wholeWordChar = wholeWord ? "\\b" : ""; const regExp = new RegExp(wholeWordChar + utils.escapeRegExp(searchTerm) + wholeWordChar, matchCase ? "g" : "gi"); return new Promise((res) => { - $content.unmark({ + $content?.unmark({ done: () => { $content.markRegExp(regExp, { element: "span", @@ -48,8 +53,8 @@ export default class FindInHtml { }); } - async findNext(direction, currentFound, nextFound) { - if (this.$results.length) { + async findNext(direction: -1 | 1, currentFound: number, nextFound: number) { + if (this.$results?.length) { this.currentIndex += direction; if (this.currentIndex < 0) { @@ -64,13 +69,15 @@ export default class FindInHtml { } } - async findBoxClosed(totalFound, currentFound) { - const $content = await this.parent.noteContext.getContentElement(); - $content.unmark(); + async findBoxClosed(totalFound: number, currentFound: number) { + const $content = await this.parent?.noteContext?.getContentElement(); + if ($content) { + $content.unmark(); + } } async jumpTo() { - if (this.$results.length) { + if (this.$results?.length) { const offsetTop = 100; const $current = this.$results.eq(this.currentIndex); this.$results.removeClass(FIND_RESULT_SELECTED_CSS_CLASSNAME); @@ -79,10 +86,11 @@ export default class FindInHtml { $current.addClass(FIND_RESULT_SELECTED_CSS_CLASSNAME); const position = $current.position().top - offsetTop; - const $content = await this.parent.noteContext.getContentElement(); - const $contentWiget = appContext.getComponentByEl($content); - - $contentWiget.triggerCommand("scrollContainerTo", { position }); + const $content = await this.parent.noteContext?.getContentElement(); + if ($content) { + const $contentWidget = appContext.getComponentByEl($content[0]); + $contentWidget.triggerCommand("scrollContainerTo", { position }); + } } } } diff --git a/src/public/app/widgets/find_in_text.js b/src/public/app/widgets/find_in_text.ts similarity index 78% rename from src/public/app/widgets/find_in_text.js rename to src/public/app/widgets/find_in_text.ts index 9ce9c7c40..003f11be8 100644 --- a/src/public/app/widgets/find_in_text.js +++ b/src/public/app/widgets/find_in_text.ts @@ -1,14 +1,29 @@ +import type FindWidget from "./find.js"; + +// TODO: Deduplicate. +interface Match { + className: string; + clear(): void; + find(): { + from: number; + to: number; + }; +} + export default class FindInText { - constructor(parent) { - /** @property {FindWidget} */ + + private parent: FindWidget; + private findResult?: Match[] | null; + + constructor(parent: FindWidget) { this.parent = parent; } async getTextEditor() { - return this.parent.noteContext.getTextEditor(); + return this.parent?.noteContext?.getTextEditor(); } - async performFind(searchTerm, matchCase, wholeWord) { + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { // Do this even if the searchTerm is empty so the markers are cleared and // the counters updated const textEditor = await this.getTextEditor(); @@ -54,7 +69,7 @@ export default class FindInText { // 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 < currentFound; ++i) { - textEditor.execute("findNext", searchTerm); + textEditor?.execute("findNext", searchTerm); } } @@ -64,7 +79,7 @@ export default class FindInText { }; } - async findNext(direction, currentFound, nextFound) { + async findNext(direction: number, currentFound: number, nextFound: number) { const textEditor = await this.getTextEditor(); // There are no parameters for findNext/findPrev @@ -72,20 +87,23 @@ export default class FindInText { // curFound wrap around above assumes findNext and // findPrevious wraparound, which is what they do if (direction > 0) { - textEditor.execute("findNext"); + textEditor?.execute("findNext"); } else { - textEditor.execute("findPrevious"); + textEditor?.execute("findPrevious"); } } - async findBoxClosed(totalFound, currentFound) { + async findBoxClosed(totalFound: number, currentFound: number) { const textEditor = await this.getTextEditor(); + if (!textEditor) { + return; + } if (totalFound > 0) { // Clear the markers and set the caret to the // current occurrence const model = textEditor.model; - const range = this.findResult.results.get(currentFound).marker.getRange(); + const range = this.findResult?.results?.get(currentFound).marker.getRange(); // 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 @@ -104,17 +122,17 @@ export default class FindInText { textEditor.focus(); } - async replace(replaceText) { + async replace(replaceText: string) { if (this.editingState !== undefined && this.editingState.highlightedResult !== null) { const textEditor = await this.getTextEditor(); - textEditor.execute("replace", replaceText, this.editingState.highlightedResult); + textEditor?.execute("replace", replaceText, this.editingState.highlightedResult); } } - async replaceAll(replaceText) { + async replaceAll(replaceText: string) { if (this.editingState !== undefined && this.editingState.results.length > 0) { const textEditor = await this.getTextEditor(); - textEditor.execute("replaceAll", replaceText, this.editingState.results); + textEditor?.execute("replaceAll", replaceText, this.editingState.results); } } } diff --git a/src/public/app/widgets/note_context_aware_widget.ts b/src/public/app/widgets/note_context_aware_widget.ts index 3c1153c2f..620600f9a 100644 --- a/src/public/app/widgets/note_context_aware_widget.ts +++ b/src/public/app/widgets/note_context_aware_widget.ts @@ -5,10 +5,9 @@ import type NoteContext from "../components/note_context.js"; /** * This widget allows for changing and updating depending on the active note. - * @extends {BasicWidget} */ class NoteContextAwareWidget extends BasicWidget { - protected noteContext?: NoteContext; + noteContext?: NoteContext; isNoteContext(ntxId: string | string[] | null | undefined) { if (Array.isArray(ntxId)) { From 89d51a9c4ff2a0a3de8909fa9fb04a26bef996e9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 22:00:41 +0200 Subject: [PATCH 02/25] 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(); } From 119bb38062e3fe754d086ae9481b2d9cf3f93555 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 22:39:49 +0200 Subject: [PATCH 03/25] chore(client/ts): fix build errors --- src/public/app/types.d.ts | 2 +- src/public/app/widgets/find.ts | 5 ++++- src/public/app/widgets/find_in_code.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 774985177..cab82d103 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -95,7 +95,7 @@ declare global { className: string; separateWordSearch: boolean; caseSensitive: boolean; - done: () => void; + done?: () => void; }); unmark(opts?: { done: () => void; diff --git a/src/public/app/widgets/find.ts b/src/public/app/widgets/find.ts index c208128fb..2505c5a85 100644 --- a/src/public/app/widgets/find.ts +++ b/src/public/app/widgets/find.ts @@ -305,7 +305,10 @@ export default class FindWidget extends NoteContextAwareWidget { const matchCase = this.$caseSensitiveCheckbox.prop("checked"); const wholeWord = this.$matchWordsCheckbox.prop("checked"); - const { totalFound, currentFound } = await this.handler?.performFind(searchTerm, matchCase, wholeWord); + if (!this.handler) { + return; + } + const { totalFound, currentFound } = await this.handler.performFind(searchTerm, matchCase, wholeWord); this.$totalFound.text(totalFound); this.$currentFound.text(currentFound); diff --git a/src/public/app/widgets/find_in_code.ts b/src/public/app/widgets/find_in_code.ts index d29cd67ce..63081bb0b 100644 --- a/src/public/app/widgets/find_in_code.ts +++ b/src/public/app/widgets/find_in_code.ts @@ -38,7 +38,7 @@ export default class FindInCode { // See https://codemirror.net/addon/search/searchcursor.js for tips const codeEditor = await this.getCodeEditor(); if (!codeEditor) { - return; + return { totalFound: 0, currentFound: 0 }; } const doc = codeEditor.doc; From d0e33f8aaacc8f9bdecd306540ed44df23c68477 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 23:06:16 +0200 Subject: [PATCH 04/25] chore(client/ts): port more dialogs --- src/public/app/types.d.ts | 7 ++++ .../{jump_to_note.js => jump_to_note.ts} | 26 ++++++++------ ...{markdown_import.js => markdown_import.ts} | 23 ++++++++++--- .../dialogs/{move_to.js => move_to.ts} | 34 +++++++++++++++---- 4 files changed, 69 insertions(+), 21 deletions(-) rename src/public/app/widgets/dialogs/{jump_to_note.js => jump_to_note.ts} (82%) rename src/public/app/widgets/dialogs/{markdown_import.js => markdown_import.ts} (82%) rename src/public/app/widgets/dialogs/{move_to.js => move_to.ts} (76%) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index cab82d103..942d10f4b 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -283,6 +283,7 @@ declare global { getLastPosition(): undefined | TextPosition; } }, + insertContent(modelFragment: any, selection: any); change(cb: (writer: Writer) => void) }, editing: { @@ -311,6 +312,12 @@ declare global { plugins: { get(command: string) }, + data: { + processor: { + toView(html: string); + }; + toModel(viewFeragment: any); + }, getData(): string; setData(data: string): void; getSelectedHtml(): string; diff --git a/src/public/app/widgets/dialogs/jump_to_note.js b/src/public/app/widgets/dialogs/jump_to_note.ts similarity index 82% rename from src/public/app/widgets/dialogs/jump_to_note.js rename to src/public/app/widgets/dialogs/jump_to_note.ts index f57acb4f7..1fbb67a57 100644 --- a/src/public/app/widgets/dialogs/jump_to_note.js +++ b/src/public/app/widgets/dialogs/jump_to_note.ts @@ -28,6 +28,13 @@ const TPL = ` `; +interface RenderMarkdownResponse { + htmlContent: string; +} + export default class MarkdownImportDialog extends BasicWidget { + + private lastOpenedTs: number; + private modal!: bootstrap.Modal; + private $importTextarea!: JQuery; + private $importButton!: JQuery; + constructor() { super(); @@ -36,7 +46,7 @@ export default class MarkdownImportDialog extends BasicWidget { doRender() { this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget); + this.modal = Modal.getOrCreateInstance(this.$widget[0]); this.$importTextarea = this.$widget.find(".markdown-import-textarea"); this.$importButton = this.$widget.find(".markdown-import-button"); @@ -47,10 +57,13 @@ export default class MarkdownImportDialog extends BasicWidget { shortcutService.bindElShortcut(this.$widget, "ctrl+return", () => this.sendForm()); } - async convertMarkdownToHtml(markdownContent) { - const { htmlContent } = await server.post("other/render-markdown", { markdownContent }); + async convertMarkdownToHtml(markdownContent: string) { + const { htmlContent } = await server.post("other/render-markdown", { markdownContent }); - const textEditor = await appContext.tabManager.getActiveContext().getTextEditor(); + const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor(); + if (!textEditor) { + return; + } const viewFragment = textEditor.data.processor.toView(htmlContent); const modelFragment = textEditor.data.toModel(viewFragment); @@ -80,7 +93,7 @@ export default class MarkdownImportDialog extends BasicWidget { } async sendForm() { - const text = this.$importTextarea.val(); + const text = String(this.$importTextarea.val()); this.modal.hide(); diff --git a/src/public/app/widgets/dialogs/move_to.js b/src/public/app/widgets/dialogs/move_to.ts similarity index 76% rename from src/public/app/widgets/dialogs/move_to.js rename to src/public/app/widgets/dialogs/move_to.ts index 061af25db..af4adfe1d 100644 --- a/src/public/app/widgets/dialogs/move_to.js +++ b/src/public/app/widgets/dialogs/move_to.ts @@ -6,6 +6,7 @@ import branchService from "../../services/branches.js"; import treeService from "../../services/tree.js"; import BasicWidget from "../basic_widget.js"; import { t } from "../../services/i18n.js"; +import type { EventData } from "../../components/app_context.js"; const TPL = ` `; export default class MoveToDialog extends BasicWidget { + + private movedBranchIds: string[] | null; + private $form!: JQuery; + private $noteAutoComplete!: JQuery; + private $noteList!: JQuery; + constructor() { super(); @@ -58,7 +65,13 @@ export default class MoveToDialog extends BasicWidget { this.$widget.modal("hide"); const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath); - froca.getBranchId(parentNoteId, noteId).then((branchId) => this.moveNotesTo(branchId)); + if (parentNoteId) { + froca.getBranchId(parentNoteId, noteId).then((branchId) => { + if (branchId) { + this.moveNotesTo(branchId); + } + }); + } } else { logError(t("move_to.error_no_path")); } @@ -67,7 +80,7 @@ export default class MoveToDialog extends BasicWidget { }); } - async moveBranchIdsToEvent({ branchIds }) { + async moveBranchIdsToEvent({ branchIds }: EventData<"moveBranchIdsTo">) { this.movedBranchIds = branchIds; utils.openDialog(this.$widget); @@ -78,7 +91,14 @@ export default class MoveToDialog extends BasicWidget { for (const branchId of this.movedBranchIds) { const branch = froca.getBranch(branchId); + if (!branch) { + continue; + } + const note = await froca.getNote(branch.noteId); + if (!note) { + continue; + } this.$noteList.append($("
  • ").text(note.title)); } @@ -87,12 +107,14 @@ export default class MoveToDialog extends BasicWidget { noteAutocompleteService.showRecentNotes(this.$noteAutoComplete); } - async moveNotesTo(parentBranchId) { - await branchService.moveToParentNote(this.movedBranchIds, parentBranchId); + async moveNotesTo(parentBranchId: string) { + if (this.movedBranchIds) { + await branchService.moveToParentNote(this.movedBranchIds, parentBranchId); + } const parentBranch = froca.getBranch(parentBranchId); - const parentNote = await parentBranch.getNote(); + const parentNote = await parentBranch?.getNote(); - toastService.showMessage(`${t("move_to.move_success_message")} ${parentNote.title}`); + toastService.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`); } } From 9eec7237de849ab92b59bf13f21c13585d697b7c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 23:27:52 +0200 Subject: [PATCH 05/25] chore(client/ts): port include dialog --- src/public/app/components/app_context.ts | 3 +++ .../{include_note.js => include_note.ts} | 25 +++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) rename src/public/app/widgets/dialogs/{include_note.js => include_note.ts} (84%) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 079e9f737..367d99e8b 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -364,6 +364,9 @@ type EventMappings = { textTypeWidget: EditableTextTypeWidget; text: string; }; + showIncludeDialog: { + textTypeWidget: EditableTextTypeWidget; + }; openBulkActionsDialog: { selectedOrActiveNoteIds: string[]; }; diff --git a/src/public/app/widgets/dialogs/include_note.js b/src/public/app/widgets/dialogs/include_note.ts similarity index 84% rename from src/public/app/widgets/dialogs/include_note.js rename to src/public/app/widgets/dialogs/include_note.ts index 94d813413..839d068dd 100644 --- a/src/public/app/widgets/dialogs/include_note.js +++ b/src/public/app/widgets/dialogs/include_note.ts @@ -5,6 +5,8 @@ import utils from "../../services/utils.js"; import froca from "../../services/froca.js"; import BasicWidget from "../basic_widget.js"; import { Modal } from "bootstrap"; +import type { EventData } from "../../components/app_context.js"; +import type EditableTextTypeWidget from "../type_widgets/editable_text.js"; const TPL = ` `; export default class IncludeNoteDialog extends BasicWidget { + + private modal!: bootstrap.Modal; + private $form!: JQuery; + private $autoComplete!: JQuery; + private textTypeWidget?: EditableTextTypeWidget; + doRender() { this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget); + this.modal = Modal.getOrCreateInstance(this.$widget[0]); this.$form = this.$widget.find(".include-note-form"); this.$autoComplete = this.$widget.find(".include-note-autocomplete"); this.$form.on("submit", () => { @@ -72,7 +80,7 @@ export default class IncludeNoteDialog extends BasicWidget { }); } - async showIncludeNoteDialogEvent({ textTypeWidget }) { + async showIncludeNoteDialogEvent({ textTypeWidget }: EventData<"showIncludeDialog">) { this.textTypeWidget = textTypeWidget; await this.refresh(); utils.openDialog(this.$widget); @@ -80,7 +88,7 @@ export default class IncludeNoteDialog extends BasicWidget { this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text } - async refresh(widget) { + async refresh() { this.$autoComplete.val(""); noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, { hideGoToSelectedNoteButton: true, @@ -89,17 +97,20 @@ export default class IncludeNoteDialog extends BasicWidget { noteAutocompleteService.showRecentNotes(this.$autoComplete); } - async includeNote(notePath) { + async includeNote(notePath: string) { const noteId = treeService.getNoteIdFromUrl(notePath); + if (!noteId) { + return; + } const note = await froca.getNote(noteId); const boxSize = $("input[name='include-note-box-size']:checked").val(); - if (["image", "canvas", "mermaid"].includes(note.type)) { + if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) { // there's no benefit to use insert note functionlity for images, // so we'll just add an IMG tag - this.textTypeWidget.addImage(noteId); + this.textTypeWidget?.addImage(noteId); } else { - this.textTypeWidget.addIncludeNote(noteId, boxSize); + this.textTypeWidget?.addIncludeNote(noteId, boxSize); } } } From ef59b636b1af1de5df11f7fae04c409636bdfeea Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 17 Mar 2025 22:46:00 +0200 Subject: [PATCH 06/25] client: port ts --- src/public/app/types.d.ts | 26 +++++- .../{find_in_code.js => find_in_code.ts} | 82 +++++++++++++------ .../{find_in_html.js => find_in_html.ts} | 38 +++++---- .../{find_in_text.js => find_in_text.ts} | 46 +++++++---- .../app/widgets/note_context_aware_widget.ts | 3 +- 5 files changed, 138 insertions(+), 57 deletions(-) rename src/public/app/widgets/{find_in_code.js => find_in_code.ts} (80%) rename src/public/app/widgets/{find_in_html.js => find_in_html.ts} (69%) rename src/public/app/widgets/{find_in_text.js => find_in_text.ts} (78%) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 0252c9d37..63c9acdde 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -95,7 +95,11 @@ declare global { className: string; separateWordSearch: boolean; caseSensitive: boolean; - }) + done: () => void; + }); + unmark(opts?: { + done: () => void; + }); } interface JQueryStatic { @@ -221,9 +225,23 @@ declare global { setOption(name: string, value: string); refresh(); focus(); + getCursor(): { line: number, col: number, ch: number }; setCursor(line: number, col: number); lineCount(): number; on(event: string, callback: () => void); + operation(callback: () => void); + scrollIntoView(pos: number); + doc: { + getValue(): string; + markText( + from: { line: number, ch: number } | number, + to: { line: number, ch: number } | number, + opts: { + className: string + }); + setSelection(from: number, to: number); + replaceRange(text: string, from: number, to: number); + } } var katex: { @@ -260,6 +278,7 @@ declare global { getRoot(): TextEditorElement; selection: { getFirstPosition(): undefined | TextPosition; + getLastPosition(): undefined | TextPosition; } }, change(cb: (writer: Writer) => void) @@ -283,13 +302,16 @@ declare global { } }; } - change(cb: (writer: Writer) => void) + change(cb: (writer: Writer) => void); + scrollToTheSelection(): void; } }, getData(): string; setData(data: string): void; getSelectedHtml(): string; removeSelection(): void; + execute(action: string, ...args: unknown[]): void; + focus(): void; sourceElement: HTMLElement; } diff --git a/src/public/app/widgets/find_in_code.js b/src/public/app/widgets/find_in_code.ts similarity index 80% rename from src/public/app/widgets/find_in_code.js rename to src/public/app/widgets/find_in_code.ts index da3476722..d29cd67ce 100644 --- a/src/public/app/widgets/find_in_code.js +++ b/src/public/app/widgets/find_in_code.ts @@ -2,35 +2,54 @@ // uses for highlighting matches, use the same one on CodeMirror // for consistency import utils from "../services/utils.js"; +import type FindWidget from "./find.js"; const FIND_RESULT_SELECTED_CSS_CLASSNAME = "ck-find-result_selected"; const FIND_RESULT_CSS_CLASSNAME = "ck-find-result"; +// TODO: Deduplicate. +interface Match { + className: string; + clear(): void; + find(): { + from: number; + to: number; + }; +} + export default class FindInCode { - constructor(parent) { - /** @property {FindWidget} */ + + private parent: FindWidget; + private findResult?: Match[] | null; + + constructor(parent: FindWidget) { this.parent = parent; } async getCodeEditor() { - return this.parent.noteContext.getCodeEditor(); + return this.parent.noteContext?.getCodeEditor(); } - async performFind(searchTerm, matchCase, wholeWord) { - let findResult = null; + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { + let findResult: Match[] | null = null; let totalFound = 0; let currentFound = -1; // See https://codemirror.net/addon/search/searchcursor.js for tips const codeEditor = await this.getCodeEditor(); + if (!codeEditor) { + return; + } + const doc = codeEditor.doc; const text = doc.getValue(); // Clear all markers - if (this.findResult != null) { + if (this.findResult) { codeEditor.operation(() => { - for (let i = 0; i < this.findResult.length; ++i) { - const marker = this.findResult[i]; + const findResult = this.findResult as Match[]; + for (let i = 0; i < findResult.length; ++i) { + const marker = findResult[i]; marker.clear(); } }); @@ -49,7 +68,7 @@ export default class FindInCode { const re = new RegExp(wholeWordChar + searchTerm + wholeWordChar, "g" + (matchCase ? "" : "i")); let curLine = 0; let curChar = 0; - let curMatch = null; + let curMatch: RegExpExecArray | null = null; findResult = []; // All those markText take several seconds on e.g., this ~500-line // script, batch them inside an operation, so they become @@ -73,7 +92,7 @@ export default class FindInCode { let toPos = { line: curLine, ch: curChar + curMatch[0].length }; // or css = "color: #f3" let marker = doc.markText(fromPos, toPos, { className: FIND_RESULT_CSS_CLASSNAME }); - findResult.push(marker); + findResult?.push(marker); // Set the first match beyond the cursor as the current match if (currentFound === -1) { @@ -99,7 +118,7 @@ export default class FindInCode { this.findResult = findResult; // Calculate curfound if not already, highlight it as selected - if (totalFound > 0) { + if (findResult && totalFound > 0) { currentFound = Math.max(0, currentFound); let marker = findResult[currentFound]; let pos = marker.find(); @@ -114,8 +133,12 @@ export default class FindInCode { }; } - async findNext(direction, currentFound, nextFound) { + async findNext(direction: number, currentFound: number, nextFound: number) { const codeEditor = await this.getCodeEditor(); + if (!codeEditor || !this.findResult) { + return; + } + const doc = codeEditor.doc; // @@ -137,18 +160,23 @@ export default class FindInCode { codeEditor.scrollIntoView(pos.from); } - async findBoxClosed(totalFound, currentFound) { + async findBoxClosed(totalFound: number, currentFound: number) { const codeEditor = await this.getCodeEditor(); - if (totalFound > 0) { + if (codeEditor && totalFound > 0) { const doc = codeEditor.doc; - const pos = this.findResult[currentFound].find(); + const pos = this.findResult?.[currentFound].find(); // Note setting the selection sets the cursor to // the end of the selection and scrolls it into // view - doc.setSelection(pos.from, pos.to); + if (pos) { + doc.setSelection(pos.from, pos.to); + } // Clear all markers codeEditor.operation(() => { + if (!this.findResult) { + return; + } for (let i = 0; i < this.findResult.length; ++i) { let marker = this.findResult[i]; marker.clear(); @@ -157,9 +185,9 @@ export default class FindInCode { } this.findResult = null; - codeEditor.focus(); + codeEditor?.focus(); } - async replace(replaceText) { + async replace(replaceText: string) { // this.findResult may be undefined and null if (!this.findResult || this.findResult.length === 0) { return; @@ -178,8 +206,10 @@ export default class FindInCode { let marker = this.findResult[currentFound]; let pos = marker.find(); const codeEditor = await this.getCodeEditor(); - const doc = codeEditor.doc; - doc.replaceRange(replaceText, pos.from, pos.to); + const doc = codeEditor?.doc; + if (doc) { + doc.replaceRange(replaceText, pos.from, pos.to); + } marker.clear(); let nextFound; @@ -194,17 +224,21 @@ export default class FindInCode { } } } - async replaceAll(replaceText) { + async replaceAll(replaceText: string) { if (!this.findResult || this.findResult.length === 0) { return; } const codeEditor = await this.getCodeEditor(); - const doc = codeEditor.doc; - codeEditor.operation(() => { + const doc = codeEditor?.doc; + codeEditor?.operation(() => { + if (!this.findResult) { + return; + } + for (let currentFound = 0; currentFound < this.findResult.length; currentFound++) { let marker = this.findResult[currentFound]; let pos = marker.find(); - doc.replaceRange(replaceText, pos.from, pos.to); + doc?.replaceRange(replaceText, pos.from, pos.to); marker.clear(); } }); diff --git a/src/public/app/widgets/find_in_html.js b/src/public/app/widgets/find_in_html.ts similarity index 69% rename from src/public/app/widgets/find_in_html.js rename to src/public/app/widgets/find_in_html.ts index 1c4b80971..4940d80eb 100644 --- a/src/public/app/widgets/find_in_html.js +++ b/src/public/app/widgets/find_in_html.ts @@ -4,28 +4,33 @@ 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"; const FIND_RESULT_SELECTED_CSS_CLASSNAME = "ck-find-result_selected"; const FIND_RESULT_CSS_CLASSNAME = "ck-find-result"; export default class FindInHtml { - constructor(parent) { - /** @property {FindWidget} */ + + private parent: FindWidget; + private currentIndex: number; + private $results: JQuery | null; + + constructor(parent: FindWidget) { this.parent = parent; this.currentIndex = 0; this.$results = null; } - async performFind(searchTerm, matchCase, wholeWord) { + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { await libraryLoader.requireLibrary(libraryLoader.MARKJS); - const $content = await this.parent.noteContext.getContentElement(); + const $content = await this.parent?.noteContext?.getContentElement(); const wholeWordChar = wholeWord ? "\\b" : ""; const regExp = new RegExp(wholeWordChar + utils.escapeRegExp(searchTerm) + wholeWordChar, matchCase ? "g" : "gi"); return new Promise((res) => { - $content.unmark({ + $content?.unmark({ done: () => { $content.markRegExp(regExp, { element: "span", @@ -48,8 +53,8 @@ export default class FindInHtml { }); } - async findNext(direction, currentFound, nextFound) { - if (this.$results.length) { + async findNext(direction: -1 | 1, currentFound: number, nextFound: number) { + if (this.$results?.length) { this.currentIndex += direction; if (this.currentIndex < 0) { @@ -64,13 +69,15 @@ export default class FindInHtml { } } - async findBoxClosed(totalFound, currentFound) { - const $content = await this.parent.noteContext.getContentElement(); - $content.unmark(); + async findBoxClosed(totalFound: number, currentFound: number) { + const $content = await this.parent?.noteContext?.getContentElement(); + if ($content) { + $content.unmark(); + } } async jumpTo() { - if (this.$results.length) { + if (this.$results?.length) { const offsetTop = 100; const $current = this.$results.eq(this.currentIndex); this.$results.removeClass(FIND_RESULT_SELECTED_CSS_CLASSNAME); @@ -79,10 +86,11 @@ export default class FindInHtml { $current.addClass(FIND_RESULT_SELECTED_CSS_CLASSNAME); const position = $current.position().top - offsetTop; - const $content = await this.parent.noteContext.getContentElement(); - const $contentWiget = appContext.getComponentByEl($content); - - $contentWiget.triggerCommand("scrollContainerTo", { position }); + const $content = await this.parent.noteContext?.getContentElement(); + if ($content) { + const $contentWidget = appContext.getComponentByEl($content[0]); + $contentWidget.triggerCommand("scrollContainerTo", { position }); + } } } } diff --git a/src/public/app/widgets/find_in_text.js b/src/public/app/widgets/find_in_text.ts similarity index 78% rename from src/public/app/widgets/find_in_text.js rename to src/public/app/widgets/find_in_text.ts index 9ce9c7c40..003f11be8 100644 --- a/src/public/app/widgets/find_in_text.js +++ b/src/public/app/widgets/find_in_text.ts @@ -1,14 +1,29 @@ +import type FindWidget from "./find.js"; + +// TODO: Deduplicate. +interface Match { + className: string; + clear(): void; + find(): { + from: number; + to: number; + }; +} + export default class FindInText { - constructor(parent) { - /** @property {FindWidget} */ + + private parent: FindWidget; + private findResult?: Match[] | null; + + constructor(parent: FindWidget) { this.parent = parent; } async getTextEditor() { - return this.parent.noteContext.getTextEditor(); + return this.parent?.noteContext?.getTextEditor(); } - async performFind(searchTerm, matchCase, wholeWord) { + async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) { // Do this even if the searchTerm is empty so the markers are cleared and // the counters updated const textEditor = await this.getTextEditor(); @@ -54,7 +69,7 @@ export default class FindInText { // 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 < currentFound; ++i) { - textEditor.execute("findNext", searchTerm); + textEditor?.execute("findNext", searchTerm); } } @@ -64,7 +79,7 @@ export default class FindInText { }; } - async findNext(direction, currentFound, nextFound) { + async findNext(direction: number, currentFound: number, nextFound: number) { const textEditor = await this.getTextEditor(); // There are no parameters for findNext/findPrev @@ -72,20 +87,23 @@ export default class FindInText { // curFound wrap around above assumes findNext and // findPrevious wraparound, which is what they do if (direction > 0) { - textEditor.execute("findNext"); + textEditor?.execute("findNext"); } else { - textEditor.execute("findPrevious"); + textEditor?.execute("findPrevious"); } } - async findBoxClosed(totalFound, currentFound) { + async findBoxClosed(totalFound: number, currentFound: number) { const textEditor = await this.getTextEditor(); + if (!textEditor) { + return; + } if (totalFound > 0) { // Clear the markers and set the caret to the // current occurrence const model = textEditor.model; - const range = this.findResult.results.get(currentFound).marker.getRange(); + const range = this.findResult?.results?.get(currentFound).marker.getRange(); // 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 @@ -104,17 +122,17 @@ export default class FindInText { textEditor.focus(); } - async replace(replaceText) { + async replace(replaceText: string) { if (this.editingState !== undefined && this.editingState.highlightedResult !== null) { const textEditor = await this.getTextEditor(); - textEditor.execute("replace", replaceText, this.editingState.highlightedResult); + textEditor?.execute("replace", replaceText, this.editingState.highlightedResult); } } - async replaceAll(replaceText) { + async replaceAll(replaceText: string) { if (this.editingState !== undefined && this.editingState.results.length > 0) { const textEditor = await this.getTextEditor(); - textEditor.execute("replaceAll", replaceText, this.editingState.results); + textEditor?.execute("replaceAll", replaceText, this.editingState.results); } } } diff --git a/src/public/app/widgets/note_context_aware_widget.ts b/src/public/app/widgets/note_context_aware_widget.ts index f43030ed8..cadd77c49 100644 --- a/src/public/app/widgets/note_context_aware_widget.ts +++ b/src/public/app/widgets/note_context_aware_widget.ts @@ -5,10 +5,9 @@ import type NoteContext from "../components/note_context.js"; /** * This widget allows for changing and updating depending on the active note. - * @extends {BasicWidget} */ class NoteContextAwareWidget extends BasicWidget { - protected noteContext?: NoteContext; + noteContext?: NoteContext; isNoteContext(ntxId: string | string[] | null | undefined) { if (Array.isArray(ntxId)) { From 7c805eb4272c7a8d1959e3e12c5e8b9ecc94a872 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 22:00:41 +0200 Subject: [PATCH 07/25] 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(); } From ffa463f1fce6460e12a54114100b3bb5ed5beb38 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 22:39:49 +0200 Subject: [PATCH 08/25] chore(client/ts): fix build errors --- src/public/app/types.d.ts | 2 +- src/public/app/widgets/find.ts | 5 ++++- src/public/app/widgets/find_in_code.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 774985177..cab82d103 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -95,7 +95,7 @@ declare global { className: string; separateWordSearch: boolean; caseSensitive: boolean; - done: () => void; + done?: () => void; }); unmark(opts?: { done: () => void; diff --git a/src/public/app/widgets/find.ts b/src/public/app/widgets/find.ts index c208128fb..2505c5a85 100644 --- a/src/public/app/widgets/find.ts +++ b/src/public/app/widgets/find.ts @@ -305,7 +305,10 @@ export default class FindWidget extends NoteContextAwareWidget { const matchCase = this.$caseSensitiveCheckbox.prop("checked"); const wholeWord = this.$matchWordsCheckbox.prop("checked"); - const { totalFound, currentFound } = await this.handler?.performFind(searchTerm, matchCase, wholeWord); + if (!this.handler) { + return; + } + const { totalFound, currentFound } = await this.handler.performFind(searchTerm, matchCase, wholeWord); this.$totalFound.text(totalFound); this.$currentFound.text(currentFound); diff --git a/src/public/app/widgets/find_in_code.ts b/src/public/app/widgets/find_in_code.ts index d29cd67ce..63081bb0b 100644 --- a/src/public/app/widgets/find_in_code.ts +++ b/src/public/app/widgets/find_in_code.ts @@ -38,7 +38,7 @@ export default class FindInCode { // See https://codemirror.net/addon/search/searchcursor.js for tips const codeEditor = await this.getCodeEditor(); if (!codeEditor) { - return; + return { totalFound: 0, currentFound: 0 }; } const doc = codeEditor.doc; From 8d14092a917b5bc7e2f5a88ddbbfa63328f932b1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 23:06:16 +0200 Subject: [PATCH 09/25] chore(client/ts): port more dialogs --- src/public/app/types.d.ts | 7 ++++ .../{jump_to_note.js => jump_to_note.ts} | 26 ++++++++------ ...{markdown_import.js => markdown_import.ts} | 23 ++++++++++--- .../dialogs/{move_to.js => move_to.ts} | 34 +++++++++++++++---- 4 files changed, 69 insertions(+), 21 deletions(-) rename src/public/app/widgets/dialogs/{jump_to_note.js => jump_to_note.ts} (82%) rename src/public/app/widgets/dialogs/{markdown_import.js => markdown_import.ts} (82%) rename src/public/app/widgets/dialogs/{move_to.js => move_to.ts} (76%) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index cab82d103..942d10f4b 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -283,6 +283,7 @@ declare global { getLastPosition(): undefined | TextPosition; } }, + insertContent(modelFragment: any, selection: any); change(cb: (writer: Writer) => void) }, editing: { @@ -311,6 +312,12 @@ declare global { plugins: { get(command: string) }, + data: { + processor: { + toView(html: string); + }; + toModel(viewFeragment: any); + }, getData(): string; setData(data: string): void; getSelectedHtml(): string; diff --git a/src/public/app/widgets/dialogs/jump_to_note.js b/src/public/app/widgets/dialogs/jump_to_note.ts similarity index 82% rename from src/public/app/widgets/dialogs/jump_to_note.js rename to src/public/app/widgets/dialogs/jump_to_note.ts index f57acb4f7..1fbb67a57 100644 --- a/src/public/app/widgets/dialogs/jump_to_note.js +++ b/src/public/app/widgets/dialogs/jump_to_note.ts @@ -28,6 +28,13 @@ const TPL = ` `; +interface RenderMarkdownResponse { + htmlContent: string; +} + export default class MarkdownImportDialog extends BasicWidget { + + private lastOpenedTs: number; + private modal!: bootstrap.Modal; + private $importTextarea!: JQuery; + private $importButton!: JQuery; + constructor() { super(); @@ -36,7 +46,7 @@ export default class MarkdownImportDialog extends BasicWidget { doRender() { this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget); + this.modal = Modal.getOrCreateInstance(this.$widget[0]); this.$importTextarea = this.$widget.find(".markdown-import-textarea"); this.$importButton = this.$widget.find(".markdown-import-button"); @@ -47,10 +57,13 @@ export default class MarkdownImportDialog extends BasicWidget { shortcutService.bindElShortcut(this.$widget, "ctrl+return", () => this.sendForm()); } - async convertMarkdownToHtml(markdownContent) { - const { htmlContent } = await server.post("other/render-markdown", { markdownContent }); + async convertMarkdownToHtml(markdownContent: string) { + const { htmlContent } = await server.post("other/render-markdown", { markdownContent }); - const textEditor = await appContext.tabManager.getActiveContext().getTextEditor(); + const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor(); + if (!textEditor) { + return; + } const viewFragment = textEditor.data.processor.toView(htmlContent); const modelFragment = textEditor.data.toModel(viewFragment); @@ -80,7 +93,7 @@ export default class MarkdownImportDialog extends BasicWidget { } async sendForm() { - const text = this.$importTextarea.val(); + const text = String(this.$importTextarea.val()); this.modal.hide(); diff --git a/src/public/app/widgets/dialogs/move_to.js b/src/public/app/widgets/dialogs/move_to.ts similarity index 76% rename from src/public/app/widgets/dialogs/move_to.js rename to src/public/app/widgets/dialogs/move_to.ts index 061af25db..af4adfe1d 100644 --- a/src/public/app/widgets/dialogs/move_to.js +++ b/src/public/app/widgets/dialogs/move_to.ts @@ -6,6 +6,7 @@ import branchService from "../../services/branches.js"; import treeService from "../../services/tree.js"; import BasicWidget from "../basic_widget.js"; import { t } from "../../services/i18n.js"; +import type { EventData } from "../../components/app_context.js"; const TPL = ` `; export default class MoveToDialog extends BasicWidget { + + private movedBranchIds: string[] | null; + private $form!: JQuery; + private $noteAutoComplete!: JQuery; + private $noteList!: JQuery; + constructor() { super(); @@ -58,7 +65,13 @@ export default class MoveToDialog extends BasicWidget { this.$widget.modal("hide"); const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath); - froca.getBranchId(parentNoteId, noteId).then((branchId) => this.moveNotesTo(branchId)); + if (parentNoteId) { + froca.getBranchId(parentNoteId, noteId).then((branchId) => { + if (branchId) { + this.moveNotesTo(branchId); + } + }); + } } else { logError(t("move_to.error_no_path")); } @@ -67,7 +80,7 @@ export default class MoveToDialog extends BasicWidget { }); } - async moveBranchIdsToEvent({ branchIds }) { + async moveBranchIdsToEvent({ branchIds }: EventData<"moveBranchIdsTo">) { this.movedBranchIds = branchIds; utils.openDialog(this.$widget); @@ -78,7 +91,14 @@ export default class MoveToDialog extends BasicWidget { for (const branchId of this.movedBranchIds) { const branch = froca.getBranch(branchId); + if (!branch) { + continue; + } + const note = await froca.getNote(branch.noteId); + if (!note) { + continue; + } this.$noteList.append($("
  • ").text(note.title)); } @@ -87,12 +107,14 @@ export default class MoveToDialog extends BasicWidget { noteAutocompleteService.showRecentNotes(this.$noteAutoComplete); } - async moveNotesTo(parentBranchId) { - await branchService.moveToParentNote(this.movedBranchIds, parentBranchId); + async moveNotesTo(parentBranchId: string) { + if (this.movedBranchIds) { + await branchService.moveToParentNote(this.movedBranchIds, parentBranchId); + } const parentBranch = froca.getBranch(parentBranchId); - const parentNote = await parentBranch.getNote(); + const parentNote = await parentBranch?.getNote(); - toastService.showMessage(`${t("move_to.move_success_message")} ${parentNote.title}`); + toastService.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`); } } From 3527ab2c5dc98a45a5e89a9a199b8f59e30a0436 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 19 Mar 2025 23:27:52 +0200 Subject: [PATCH 10/25] chore(client/ts): port include dialog --- src/public/app/components/app_context.ts | 3 +++ .../{include_note.js => include_note.ts} | 25 +++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) rename src/public/app/widgets/dialogs/{include_note.js => include_note.ts} (84%) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 079e9f737..367d99e8b 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -364,6 +364,9 @@ type EventMappings = { textTypeWidget: EditableTextTypeWidget; text: string; }; + showIncludeDialog: { + textTypeWidget: EditableTextTypeWidget; + }; openBulkActionsDialog: { selectedOrActiveNoteIds: string[]; }; diff --git a/src/public/app/widgets/dialogs/include_note.js b/src/public/app/widgets/dialogs/include_note.ts similarity index 84% rename from src/public/app/widgets/dialogs/include_note.js rename to src/public/app/widgets/dialogs/include_note.ts index 94d813413..839d068dd 100644 --- a/src/public/app/widgets/dialogs/include_note.js +++ b/src/public/app/widgets/dialogs/include_note.ts @@ -5,6 +5,8 @@ import utils from "../../services/utils.js"; import froca from "../../services/froca.js"; import BasicWidget from "../basic_widget.js"; import { Modal } from "bootstrap"; +import type { EventData } from "../../components/app_context.js"; +import type EditableTextTypeWidget from "../type_widgets/editable_text.js"; const TPL = ` `; export default class IncludeNoteDialog extends BasicWidget { + + private modal!: bootstrap.Modal; + private $form!: JQuery; + private $autoComplete!: JQuery; + private textTypeWidget?: EditableTextTypeWidget; + doRender() { this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget); + this.modal = Modal.getOrCreateInstance(this.$widget[0]); this.$form = this.$widget.find(".include-note-form"); this.$autoComplete = this.$widget.find(".include-note-autocomplete"); this.$form.on("submit", () => { @@ -72,7 +80,7 @@ export default class IncludeNoteDialog extends BasicWidget { }); } - async showIncludeNoteDialogEvent({ textTypeWidget }) { + async showIncludeNoteDialogEvent({ textTypeWidget }: EventData<"showIncludeDialog">) { this.textTypeWidget = textTypeWidget; await this.refresh(); utils.openDialog(this.$widget); @@ -80,7 +88,7 @@ export default class IncludeNoteDialog extends BasicWidget { this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text } - async refresh(widget) { + async refresh() { this.$autoComplete.val(""); noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, { hideGoToSelectedNoteButton: true, @@ -89,17 +97,20 @@ export default class IncludeNoteDialog extends BasicWidget { noteAutocompleteService.showRecentNotes(this.$autoComplete); } - async includeNote(notePath) { + async includeNote(notePath: string) { const noteId = treeService.getNoteIdFromUrl(notePath); + if (!noteId) { + return; + } const note = await froca.getNote(noteId); const boxSize = $("input[name='include-note-box-size']:checked").val(); - if (["image", "canvas", "mermaid"].includes(note.type)) { + if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) { // there's no benefit to use insert note functionlity for images, // so we'll just add an IMG tag - this.textTypeWidget.addImage(noteId); + this.textTypeWidget?.addImage(noteId); } else { - this.textTypeWidget.addIncludeNote(noteId, boxSize); + this.textTypeWidget?.addIncludeNote(noteId, boxSize); } } } From 8f6fcee67de35b04924efa9da8117782b7d0962b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 10:38:45 +0200 Subject: [PATCH 11/25] chore(client/ts): port import dialog --- src/public/app/services/import.ts | 16 +++++---- .../widgets/dialogs/{import.js => import.ts} | 33 ++++++++++++++----- 2 files changed, 34 insertions(+), 15 deletions(-) rename src/public/app/widgets/dialogs/{import.js => import.ts} (84%) diff --git a/src/public/app/services/import.ts b/src/public/app/services/import.ts index d33ad09fb..035bed6a6 100644 --- a/src/public/app/services/import.ts +++ b/src/public/app/services/import.ts @@ -5,13 +5,15 @@ import utils from "./utils.js"; import appContext from "../components/app_context.js"; import { t } from "./i18n.js"; -interface UploadFilesOptions { - safeImport?: boolean; - shrinkImages: boolean | "true" | "false"; - textImportedAsText?: boolean; - codeImportedAsCode?: boolean; - explodeArchives?: boolean; - replaceUnderscoresWithSpaces?: boolean; +type BooleanLike = boolean | "true" | "false"; + +export interface UploadFilesOptions { + safeImport?: BooleanLike; + shrinkImages: BooleanLike; + textImportedAsText?: BooleanLike; + codeImportedAsCode?: BooleanLike; + explodeArchives?: BooleanLike; + replaceUnderscoresWithSpaces?: BooleanLike; } export async function uploadFiles(entityType: string, parentNoteId: string, files: string[] | File[], options: UploadFilesOptions) { diff --git a/src/public/app/widgets/dialogs/import.js b/src/public/app/widgets/dialogs/import.ts similarity index 84% rename from src/public/app/widgets/dialogs/import.js rename to src/public/app/widgets/dialogs/import.ts index 4f06208f5..558f2084c 100644 --- a/src/public/app/widgets/dialogs/import.js +++ b/src/public/app/widgets/dialogs/import.ts @@ -1,10 +1,11 @@ import utils, { escapeQuotes } from "../../services/utils.js"; import treeService from "../../services/tree.js"; -import importService from "../../services/import.js"; +import importService, { type UploadFilesOptions } from "../../services/import.js"; import options from "../../services/options.js"; import BasicWidget from "../basic_widget.js"; import { t } from "../../services/i18n.js"; import { Modal, Tooltip } from "bootstrap"; +import type { EventData } from "../../components/app_context.js"; const TPL = ` `; export default class ImportDialog extends BasicWidget { + + private parentNoteId: string | null; + + private $form!: JQuery; + private $noteTitle!: JQuery; + private $fileUploadInput!: JQuery; + private $importButton!: JQuery; + private $safeImportCheckbox!: JQuery; + private $shrinkImagesCheckbox!: JQuery; + private $textImportedAsTextCheckbox!: JQuery; + private $codeImportedAsCodeCheckbox!: JQuery; + private $explodeArchivesCheckbox!: JQuery; + private $replaceUnderscoresWithSpacesCheckbox!: JQuery; + constructor() { super(); @@ -87,7 +102,7 @@ export default class ImportDialog extends BasicWidget { doRender() { this.$widget = $(TPL); - Modal.getOrCreateInstance(this.$widget); + Modal.getOrCreateInstance(this.$widget[0]); this.$form = this.$widget.find(".import-form"); this.$noteTitle = this.$widget.find(".import-note-title"); @@ -104,7 +119,9 @@ export default class ImportDialog extends BasicWidget { // disabling so that import is not triggered again. this.$importButton.attr("disabled", "disabled"); - this.importIntoNote(this.parentNoteId); + if (this.parentNoteId) { + this.importIntoNote(this.parentNoteId); + } return false; }); @@ -124,7 +141,7 @@ export default class ImportDialog extends BasicWidget { }); } - async showImportDialogEvent({ noteId }) { + async showImportDialogEvent({ noteId }: EventData<"showImportDialog">) { this.parentNoteId = noteId; this.$fileUploadInput.val("").trigger("change"); // to trigger Import button disabling listener below @@ -141,12 +158,12 @@ export default class ImportDialog extends BasicWidget { utils.openDialog(this.$widget); } - async importIntoNote(parentNoteId) { - const files = Array.from(this.$fileUploadInput[0].files); // shallow copy since we're resetting the upload button below + async importIntoNote(parentNoteId: string) { + const files = Array.from(this.$fileUploadInput[0].files ?? []); // shallow copy since we're resetting the upload button below - const boolToString = ($el) => ($el.is(":checked") ? "true" : "false"); + const boolToString = ($el: JQuery) => ($el.is(":checked") ? "true" : "false"); - const options = { + const options: UploadFilesOptions = { safeImport: boolToString(this.$safeImportCheckbox), shrinkImages: boolToString(this.$shrinkImagesCheckbox), textImportedAsText: boolToString(this.$textImportedAsTextCheckbox), From a2b6bb7ecf1141654e44ddabe71da418cf50f4dd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 18:22:42 +0200 Subject: [PATCH 12/25] chore(client/ts): port file_properties --- ...{file_properties.js => file_properties.ts} | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) rename src/public/app/widgets/ribbon_widgets/{file_properties.js => file_properties.ts} (84%) diff --git a/src/public/app/widgets/ribbon_widgets/file_properties.js b/src/public/app/widgets/ribbon_widgets/file_properties.ts similarity index 84% rename from src/public/app/widgets/ribbon_widgets/file_properties.js rename to src/public/app/widgets/ribbon_widgets/file_properties.ts index 358f0987b..5716f0b05 100644 --- a/src/public/app/widgets/ribbon_widgets/file_properties.js +++ b/src/public/app/widgets/ribbon_widgets/file_properties.ts @@ -5,6 +5,7 @@ import openService from "../../services/open.js"; import utils from "../../services/utils.js"; import protectedSessionHolder from "../../services/protected_session_holder.js"; import { t } from "../../services/i18n.js"; +import type FNote from "../../entities/fnote.js"; const TPL = `
    @@ -66,6 +67,16 @@ const TPL = `
    `; export default class FilePropertiesWidget extends NoteContextAwareWidget { + + private $fileNoteId!: JQuery; + private $fileName!: JQuery; + private $fileType!: JQuery; + private $fileSize!: JQuery; + private $downloadButton!: JQuery; + private $openButton!: JQuery; + private $uploadNewRevisionButton!: JQuery; + private $uploadNewRevisionInput!: JQuery; + get name() { return "fileProperties"; } @@ -99,8 +110,8 @@ export default class FilePropertiesWidget extends NoteContextAwareWidget { this.$uploadNewRevisionButton = this.$widget.find(".file-upload-new-revision"); this.$uploadNewRevisionInput = this.$widget.find(".file-upload-new-revision-input"); - this.$downloadButton.on("click", () => openService.downloadFileNote(this.noteId)); - this.$openButton.on("click", () => openService.openNoteExternally(this.noteId, this.note.mime)); + this.$downloadButton.on("click", () => this.noteId && openService.downloadFileNote(this.noteId)); + this.$openButton.on("click", () => this.noteId && this.note && openService.openNoteExternally(this.noteId, this.note.mime)); this.$uploadNewRevisionButton.on("click", () => { this.$uploadNewRevisionInput.trigger("click"); @@ -122,16 +133,20 @@ export default class FilePropertiesWidget extends NoteContextAwareWidget { }); } - async refreshWithNote(note) { + async refreshWithNote(note: FNote) { this.$widget.show(); + if (!this.note) { + return; + } + this.$fileNoteId.text(note.noteId); this.$fileName.text(note.getLabelValue("originalFileName") || "?"); this.$fileType.text(note.mime); const blob = await this.note.getBlob(); - this.$fileSize.text(utils.formatSize(blob.contentLength)); + this.$fileSize.text(utils.formatSize(blob?.contentLength ?? 0)); // open doesn't work for protected notes since it works through a browser which isn't in protected session this.$openButton.toggle(!note.isProtected); From bd06d1d7b25b7e3f814205b4c7fc28ef2fc85858 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 18:25:17 +0200 Subject: [PATCH 13/25] chore(client/ts): port image_properties --- ...mage_properties.js => image_properties.ts} | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) rename src/public/app/widgets/ribbon_widgets/{image_properties.js => image_properties.ts} (82%) diff --git a/src/public/app/widgets/ribbon_widgets/image_properties.js b/src/public/app/widgets/ribbon_widgets/image_properties.ts similarity index 82% rename from src/public/app/widgets/ribbon_widgets/image_properties.js rename to src/public/app/widgets/ribbon_widgets/image_properties.ts index 4a6f3e2da..a3a21b590 100644 --- a/src/public/app/widgets/ribbon_widgets/image_properties.js +++ b/src/public/app/widgets/ribbon_widgets/image_properties.ts @@ -4,6 +4,7 @@ import toastService from "../../services/toast.js"; import openService from "../../services/open.js"; import utils from "../../services/utils.js"; import { t } from "../../services/i18n.js"; +import type FNote from "../../entities/fnote.js"; const TPL = `
    @@ -50,6 +51,16 @@ const TPL = `
    `; export default class ImagePropertiesWidget extends NoteContextAwareWidget { + + private $copyReferenceToClipboardButton!: JQuery; + private $uploadNewRevisionButton!: JQuery; + private $uploadNewRevisionInput!: JQuery; + private $fileName!: JQuery; + private $fileType!: JQuery; + private $fileSize!: JQuery; + private $openButton!: JQuery; + private $imageDownloadButton!: JQuery; + get name() { return "imageProperties"; } @@ -76,7 +87,7 @@ export default class ImagePropertiesWidget extends NoteContextAwareWidget { this.contentSized(); this.$copyReferenceToClipboardButton = this.$widget.find(".image-copy-reference-to-clipboard"); - this.$copyReferenceToClipboardButton.on("click", () => this.triggerEvent(`copyImageReferenceToClipboard`, { ntxId: this.noteContext.ntxId })); + this.$copyReferenceToClipboardButton.on("click", () => this.triggerEvent(`copyImageReferenceToClipboard`, { ntxId: this.noteContext?.ntxId })); this.$uploadNewRevisionButton = this.$widget.find(".image-upload-new-revision"); this.$uploadNewRevisionInput = this.$widget.find(".image-upload-new-revision-input"); @@ -86,10 +97,10 @@ export default class ImagePropertiesWidget extends NoteContextAwareWidget { this.$fileSize = this.$widget.find(".image-filesize"); this.$openButton = this.$widget.find(".image-open"); - this.$openButton.on("click", () => openService.openNoteExternally(this.noteId, this.note.mime)); + this.$openButton.on("click", () => this.noteId && this.note && openService.openNoteExternally(this.noteId, this.note.mime)); this.$imageDownloadButton = this.$widget.find(".image-download"); - this.$imageDownloadButton.on("click", () => openService.downloadFileNote(this.noteId)); + this.$imageDownloadButton.on("click", () => this.noteId && openService.downloadFileNote(this.noteId)); this.$uploadNewRevisionButton.on("click", () => { this.$uploadNewRevisionInput.trigger("click"); @@ -113,13 +124,13 @@ export default class ImagePropertiesWidget extends NoteContextAwareWidget { }); } - async refreshWithNote(note) { + async refreshWithNote(note: FNote) { this.$widget.show(); - const blob = await this.note.getBlob(); + const blob = await this.note?.getBlob(); this.$fileName.text(note.getLabelValue("originalFileName") || "?"); - this.$fileSize.text(utils.formatSize(blob.contentLength)); + this.$fileSize.text(utils.formatSize(blob?.contentLength ?? 0)); this.$fileType.text(note.mime); } } From c27d5afdf257fa040637506790f6ae8b2a62354b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 18:28:37 +0200 Subject: [PATCH 14/25] chore(client/ts): port inherited_attribute_list --- .../attribute_widgets/attribute_detail.ts | 10 +++++----- ...ute_list.js => inherited_attribute_list.ts} | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 11 deletions(-) rename src/public/app/widgets/ribbon_widgets/{inherited_attribute_list.js => inherited_attribute_list.ts} (88%) diff --git a/src/public/app/widgets/attribute_widgets/attribute_detail.ts b/src/public/app/widgets/attribute_widgets/attribute_detail.ts index 866cff805..2a3f3e2b4 100644 --- a/src/public/app/widgets/attribute_widgets/attribute_detail.ts +++ b/src/public/app/widgets/attribute_widgets/attribute_detail.ts @@ -288,7 +288,7 @@ const ATTR_HELP: Record> = { }; interface AttributeDetailOpts { - allAttributes: Attribute[]; + allAttributes?: Attribute[]; attribute: Attribute; isOwned: boolean; x: number; @@ -338,7 +338,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { private relatedNotesSpacedUpdate!: SpacedUpdate; private attribute!: Attribute; - private allAttributes!: Attribute[]; + private allAttributes?: Attribute[]; private attrType!: ReturnType; async refresh() { @@ -434,7 +434,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { this.attribute.value = pathChunks[pathChunks.length - 1]; // noteId - this.triggerCommand("updateAttributeList", { attributes: this.allAttributes }); + this.triggerCommand("updateAttributeList", { attributes: this.allAttributes ?? [] }); this.updateRelatedNotes(); }); @@ -454,7 +454,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { this.$deleteButton = this.$widget.find(".attr-delete-button"); this.$deleteButton.on("click", async () => { await this.triggerCommand("updateAttributeList", { - attributes: this.allAttributes.filter((attr) => attr !== this.attribute) + attributes: (this.allAttributes || []).filter((attr) => attr !== this.attribute) }); await this.triggerCommand("saveAttributes"); @@ -714,7 +714,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { this.attribute.value = String(this.$inputValue.val()); } - this.triggerCommand("updateAttributeList", { attributes: this.allAttributes }); + this.triggerCommand("updateAttributeList", { attributes: this.allAttributes ?? [] }); } buildDefinitionValue() { diff --git a/src/public/app/widgets/ribbon_widgets/inherited_attribute_list.js b/src/public/app/widgets/ribbon_widgets/inherited_attribute_list.ts similarity index 88% rename from src/public/app/widgets/ribbon_widgets/inherited_attribute_list.js rename to src/public/app/widgets/ribbon_widgets/inherited_attribute_list.ts index 6e6f435ed..667f12753 100644 --- a/src/public/app/widgets/ribbon_widgets/inherited_attribute_list.js +++ b/src/public/app/widgets/ribbon_widgets/inherited_attribute_list.ts @@ -3,6 +3,8 @@ import AttributeDetailWidget from "../attribute_widgets/attribute_detail.js"; import attributeRenderer from "../../services/attribute_renderer.js"; import attributeService from "../../services/attributes.js"; import { t } from "../../services/i18n.js"; +import type FNote from "../../entities/fnote.js"; +import type { EventData } from "../../components/app_context.js"; const TPL = `
    @@ -10,7 +12,7 @@ const TPL = ` .inherited-attributes-widget { position: relative; } - + .inherited-attributes-container { color: var(--muted-text-color); max-height: 200px; @@ -23,6 +25,11 @@ const TPL = `
    `; export default class InheritedAttributesWidget extends NoteContextAwareWidget { + + private attributeDetailWidget: AttributeDetailWidget; + + private $container!: JQuery; + get name() { return "inheritedAttributes"; } @@ -34,7 +41,6 @@ export default class InheritedAttributesWidget extends NoteContextAwareWidget { constructor() { super(); - /** @type {AttributeDetailWidget} */ this.attributeDetailWidget = new AttributeDetailWidget().contentSized().setParent(this); this.child(this.attributeDetailWidget); @@ -42,7 +48,7 @@ export default class InheritedAttributesWidget extends NoteContextAwareWidget { getTitle() { return { - show: !this.note.isLaunchBarConfig(), + show: !this.note?.isLaunchBarConfig(), title: t("inherited_attribute_list.title"), icon: "bx bx-list-plus" }; @@ -56,7 +62,7 @@ export default class InheritedAttributesWidget extends NoteContextAwareWidget { this.$widget.append(this.attributeDetailWidget.render()); } - async refreshWithNote(note) { + async refreshWithNote(note: FNote) { this.$container.empty(); const inheritedAttributes = this.getInheritedAttributes(note); @@ -90,7 +96,7 @@ export default class InheritedAttributesWidget extends NoteContextAwareWidget { } } - getInheritedAttributes(note) { + getInheritedAttributes(note: FNote) { const attrs = note.getAttributes().filter((attr) => attr.noteId !== this.noteId); attrs.sort((a, b) => { @@ -105,7 +111,7 @@ export default class InheritedAttributesWidget extends NoteContextAwareWidget { return attrs; } - entitiesReloadedEvent({ loadResults }) { + entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) { this.refresh(); } From e682f01c470649bf26d0df65eb1d5413d01b05f1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 18:39:04 +0200 Subject: [PATCH 15/25] chore(client/ts): port promoted_attributes --- src/public/app/services/attribute_parser.ts | 3 +- .../promoted_attribute_definition_parser.ts | 2 +- src/public/app/types.d.ts | 2 +- ...d_attributes.js => promoted_attributes.ts} | 54 ++++++++++++------- 4 files changed, 40 insertions(+), 21 deletions(-) rename src/public/app/widgets/ribbon_widgets/{promoted_attributes.js => promoted_attributes.ts} (88%) diff --git a/src/public/app/services/attribute_parser.ts b/src/public/app/services/attribute_parser.ts index 7fa442cf4..8797769d8 100644 --- a/src/public/app/services/attribute_parser.ts +++ b/src/public/app/services/attribute_parser.ts @@ -8,9 +8,10 @@ interface Token { } export interface Attribute { + attributeId?: string; type: AttributeType; name: string; - isInheritable: boolean; + isInheritable?: boolean; value?: string; startIndex?: number; endIndex?: number; diff --git a/src/public/app/services/promoted_attribute_definition_parser.ts b/src/public/app/services/promoted_attribute_definition_parser.ts index ca0095f60..e40c24bbc 100644 --- a/src/public/app/services/promoted_attribute_definition_parser.ts +++ b/src/public/app/services/promoted_attribute_definition_parser.ts @@ -1,4 +1,4 @@ -type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "url"; +type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url"; type Multiplicity = "single" | "multi"; interface DefinitionObject { diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 942d10f4b..dd618c129 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -74,7 +74,7 @@ declare global { interface AutoCompleteArg { displayKey: "name" | "value" | "notePathTitle"; - cache: boolean; + cache?: boolean; source: (term: string, cb: AutoCompleteCallback) => void, templates?: { suggestion: (suggestion: Suggestion) => string | undefined diff --git a/src/public/app/widgets/ribbon_widgets/promoted_attributes.js b/src/public/app/widgets/ribbon_widgets/promoted_attributes.ts similarity index 88% rename from src/public/app/widgets/ribbon_widgets/promoted_attributes.js rename to src/public/app/widgets/ribbon_widgets/promoted_attributes.ts index 3a298047e..d388835d5 100644 --- a/src/public/app/widgets/ribbon_widgets/promoted_attributes.js +++ b/src/public/app/widgets/ribbon_widgets/promoted_attributes.ts @@ -7,6 +7,10 @@ import NoteContextAwareWidget from "../note_context_aware_widget.js"; import attributeService from "../../services/attributes.js"; import options from "../../services/options.js"; import utils from "../../services/utils.js"; +import type FNote from "../../entities/fnote.js"; +import type { Attribute } from "../../services/attribute_parser.js"; +import type FAttribute from "../../entities/fattribute.js"; +import type { EventData } from "../../components/app_context.js"; const TPL = ` `; +// TODO: Deduplicate +interface AttributeResult { + attributeId: string; +} + /** * This widget is quite special because it's used in the desktop ribbon, but in mobile outside of ribbon. * This works without many issues (apart from autocomplete), but it should be kept in mind when changing things * and testing. */ export default class PromotedAttributesWidget extends NoteContextAwareWidget { + + private $container!: JQuery; + get name() { return "promotedAttributes"; } @@ -80,7 +92,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget { this.$container = this.$widget.find(".promoted-attributes-container"); } - getTitle(note) { + getTitle(note: FNote) { const promotedDefAttrs = note.getPromotedDefinitionAttributes(); if (promotedDefAttrs.length === 0) { @@ -95,7 +107,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget { }; } - async refreshWithNote(note) { + async refreshWithNote(note: FNote) { this.$container.empty(); const promotedDefAttrs = note.getPromotedDefinitionAttributes(); @@ -116,7 +128,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget { const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation"; const valueName = definitionAttr.name.substr(valueType.length + 1); - let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType); + let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[]; if (valueAttrs.length === 0) { valueAttrs.push({ @@ -134,7 +146,9 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget { for (const valueAttr of valueAttrs) { const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName); - $cells.push($cell); + if ($cell) { + $cells.push($cell); + } } } @@ -144,14 +158,14 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget { this.toggleInt(true); } - async createPromotedAttributeCell(definitionAttr, valueAttr, valueName) { + async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) { const definition = definitionAttr.getDefinition(); const id = `value-${valueAttr.attributeId}`; const $input = $("") .prop("tabindex", 200 + definitionAttr.position) .prop("id", id) - .attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one + .attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one .attr("data-attribute-type", valueAttr.type) .attr("data-attribute-name", valueAttr.name) .prop("value", valueAttr.value) @@ -161,7 +175,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget { .on("change", (event) => this.promotedAttributeChanged(event)); const $actionCell = $("
    "); - const $multiplicityCell = $("").addClass("multiplicity").attr("nowrap", true); + const $multiplicityCell = $("").addClass("multiplicity").attr("nowrap", "true"); const $wrapper = $('
    `; -export type ConfirmDialogCallback = (val?: false | ConfirmDialogOptions) => void; +export type ConfirmDialogResult = false | ConfirmDialogOptions; +export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void; export interface ConfirmDialogOptions { confirmed: boolean; diff --git a/src/public/app/widgets/spacer.ts b/src/public/app/widgets/spacer.ts index 83cf23bf8..f6ce560e1 100644 --- a/src/public/app/widgets/spacer.ts +++ b/src/public/app/widgets/spacer.ts @@ -1,7 +1,7 @@ import { t } from "../services/i18n.js"; import BasicWidget from "./basic_widget.js"; import contextMenu from "../menus/context_menu.js"; -import appContext from "../components/app_context.js"; +import appContext, { type CommandNames } from "../components/app_context.js"; import utils from "../services/utils.js"; const TPL = `
    `; @@ -26,7 +26,7 @@ export default class SpacerWidget extends BasicWidget { this.$widget.on("contextmenu", (e) => { this.$widget.tooltip("hide"); - contextMenu.show({ + contextMenu.show({ x: e.pageX, y: e.pageY, items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (utils.isMobile() ? "bx-mobile" : "bx-sidebar") }], diff --git a/src/public/app/widgets/tab_row.ts b/src/public/app/widgets/tab_row.ts index 0f60a9c2e..4de766357 100644 --- a/src/public/app/widgets/tab_row.ts +++ b/src/public/app/widgets/tab_row.ts @@ -4,7 +4,7 @@ import BasicWidget from "./basic_widget.js"; import contextMenu from "../menus/context_menu.js"; import utils from "../services/utils.js"; import keyboardActionService from "../services/keyboard_actions.js"; -import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js"; +import appContext, { type CommandNames, type CommandListenerData, type EventData } from "../components/app_context.js"; import froca from "../services/froca.js"; import attributeService from "../services/attributes.js"; import type NoteContext from "../components/note_context.js"; @@ -268,7 +268,7 @@ export default class TabRowWidget extends BasicWidget { const ntxId = $(e.target).closest(".note-tab").attr("data-ntx-id"); - contextMenu.show({ + contextMenu.show({ x: e.pageX, y: e.pageY, items: [ diff --git a/src/public/app/widgets/type_widgets/relation_map.js b/src/public/app/widgets/type_widgets/relation_map.ts similarity index 76% rename from src/public/app/widgets/type_widgets/relation_map.js rename to src/public/app/widgets/type_widgets/relation_map.ts index 9dbeb2d45..a539f4485 100644 --- a/src/public/app/widgets/type_widgets/relation_map.js +++ b/src/public/app/widgets/type_widgets/relation_map.ts @@ -5,13 +5,14 @@ import contextMenu from "../../menus/context_menu.js"; import toastService from "../../services/toast.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; import TypeWidget from "./type_widget.js"; -import appContext from "../../components/app_context.js"; +import appContext, { type EventData } from "../../components/app_context.js"; import utils from "../../services/utils.js"; import froca from "../../services/froca.js"; import dialogService from "../../services/dialog.js"; import { t } from "../../services/i18n.js"; +import type FNote from "../../entities/fnote.js"; -const uniDirectionalOverlays = [ +const uniDirectionalOverlays: OverlaySpec[] = [ [ "Arrow", { @@ -92,7 +93,62 @@ const TPL = ` let containerCounter = 1; +interface Clipboard { + noteId: string; + title: string; +} + +interface MapData { + notes: { + noteId: string; + x: number; + y: number; + }[]; + transform: { + x: number, + y: number, + scale: number + } +} + +export type RelationType = "uniDirectional" | "biDirectional" | "inverse"; + +interface Relation { + name: string; + attributeId: string; + sourceNoteId: string; + targetNoteId: string; + type: RelationType; + render: boolean; +} + +// TODO: Deduplicate. +interface PostNoteResponse { + note: { + noteId: string; + }; +} + +// TODO: Deduplicate. +interface RelationMapPostResponse { + relations: Relation[]; + inverseRelations: Record; + noteTitles: Record; +} + +type MenuCommands = "openInNewTab" | "remove" | "editTitle"; + export default class RelationMapTypeWidget extends TypeWidget { + + private clipboard?: Clipboard | null; + private jsPlumbInstance?: import("jsplumb").jsPlumbInstance | null; + private pzInstance?: PanZoom | null; + private mapData?: MapData | null; + private relations?: Relation[] | null; + + private $relationMapContainer!: JQuery; + private $relationMapWrapper!: JQuery; + static getType() { return "relationMap"; } @@ -109,7 +165,7 @@ export default class RelationMapTypeWidget extends TypeWidget { this.$relationMapWrapper = this.$widget.find(".relation-map-wrapper"); this.$relationMapWrapper.on("click", (event) => { - if (this.clipboard) { + if (this.clipboard && this.mapData) { let { x, y } = this.getMousePosition(event); // modifying position so that the cursor is on the top-center of the box @@ -130,7 +186,7 @@ export default class RelationMapTypeWidget extends TypeWidget { this.$relationMapContainer.attr("id", "relation-map-container-" + containerCounter++); this.$relationMapContainer.on("contextmenu", ".note-box", (e) => { - contextMenu.show({ + contextMenu.show({ x: e.pageX, y: e.pageY, items: [ @@ -151,14 +207,14 @@ export default class RelationMapTypeWidget extends TypeWidget { this.initialized = new Promise(async (res) => { await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP); - - jsPlumb.ready(res); + // TODO: Remove once we port to webpack. + (jsPlumb as unknown as jsPlumbInstance).ready(res); }); super.doRender(); } - async contextMenuHandler(command, originalTarget) { + async contextMenuHandler(command: MenuCommands | undefined, originalTarget: HTMLElement) { const $noteBox = $(originalTarget).closest(".note-box"); const $title = $noteBox.find(".title a"); const noteId = this.idToNoteId($noteBox.prop("id")); @@ -168,11 +224,11 @@ export default class RelationMapTypeWidget extends TypeWidget { } else if (command === "remove") { const result = await dialogService.confirmDeleteNoteBoxWithNote($title.text()); - if (!result.confirmed) { + if (typeof result !== "object" || !result.confirmed) { return; } - this.jsPlumbInstance.remove(this.noteIdToId(noteId)); + this.jsPlumbInstance?.remove(this.noteIdToId(noteId)); if (result.isDeleteNoteChecked) { const taskId = utils.randomString(10); @@ -180,9 +236,13 @@ export default class RelationMapTypeWidget extends TypeWidget { await server.remove(`notes/${noteId}?taskId=${taskId}&last=true`); } - this.mapData.notes = this.mapData.notes.filter((note) => note.noteId !== noteId); + if (this.mapData) { + this.mapData.notes = this.mapData.notes.filter((note) => note.noteId !== noteId); + } - this.relations = this.relations.filter((relation) => relation.sourceNoteId !== noteId && relation.targetNoteId !== noteId); + if (this.relations) { + this.relations = this.relations.filter((relation) => relation.sourceNoteId !== noteId && relation.targetNoteId !== noteId); + } this.saveData(); } else if (command === "editTitle") { @@ -216,9 +276,9 @@ export default class RelationMapTypeWidget extends TypeWidget { } }; - const blob = await this.note.getBlob(); + const blob = await this.note?.getBlob(); - if (blob.content) { + if (blob?.content) { try { this.mapData = JSON.parse(blob.content); } catch (e) { @@ -227,15 +287,15 @@ export default class RelationMapTypeWidget extends TypeWidget { } } - noteIdToId(noteId) { + noteIdToId(noteId: string) { return `rel-map-note-${noteId}`; } - idToNoteId(id) { + idToNoteId(id: string) { return id.substr(13); } - async doRefresh(note) { + async doRefresh(note: FNote) { await this.loadMapData(); this.initJsPlumbInstance(); @@ -248,15 +308,19 @@ export default class RelationMapTypeWidget extends TypeWidget { clearMap() { // delete all endpoints and connections // this is done at this point (after async operations) to reduce flicker to the minimum - this.jsPlumbInstance.deleteEveryEndpoint(); + this.jsPlumbInstance?.deleteEveryEndpoint(); // without this, we still end up with note boxes remaining in the canvas this.$relationMapContainer.empty(); } async loadNotesAndRelations() { + if (!this.mapData || !this.jsPlumbInstance) { + return; + } + const noteIds = this.mapData.notes.map((note) => note.noteId); - const data = await server.post("relation-map", { noteIds, relationMapNoteId: this.noteId }); + const data = await server.post("relation-map", { noteIds, relationMapNoteId: this.noteId }); this.relations = []; @@ -282,6 +346,10 @@ export default class RelationMapTypeWidget extends TypeWidget { this.mapData.notes = this.mapData.notes.filter((note) => note.noteId in data.noteTitles); this.jsPlumbInstance.batch(async () => { + if (!this.jsPlumbInstance || !this.mapData || !this.relations) { + return; + } + this.clearMap(); for (const note of this.mapData.notes) { @@ -301,6 +369,8 @@ export default class RelationMapTypeWidget extends TypeWidget { type: relation.type }); + // TODO: Does this actually do anything. + //@ts-expect-error connection.id = relation.attributeId; if (relation.type === "inverse") { @@ -331,14 +401,18 @@ export default class RelationMapTypeWidget extends TypeWidget { } }); + if (!this.pzInstance) { + return; + } + this.pzInstance.on("transform", () => { // gets triggered on any transform change - this.jsPlumbInstance.setZoom(this.getZoom()); + this.jsPlumbInstance?.setZoom(this.getZoom()); this.saveCurrentTransform(); }); - if (this.mapData.transform) { + if (this.mapData?.transform) { this.pzInstance.zoomTo(0, 0, this.mapData.transform.scale); this.pzInstance.moveTo(this.mapData.transform.x, this.mapData.transform.y); @@ -349,9 +423,13 @@ export default class RelationMapTypeWidget extends TypeWidget { } saveCurrentTransform() { + if (!this.pzInstance) { + return; + } + const newTransform = this.pzInstance.getTransform(); - if (JSON.stringify(newTransform) !== JSON.stringify(this.mapData.transform)) { + if (this.mapData && JSON.stringify(newTransform) !== JSON.stringify(this.mapData.transform)) { // clone transform object this.mapData.transform = JSON.parse(JSON.stringify(newTransform)); @@ -385,6 +463,10 @@ export default class RelationMapTypeWidget extends TypeWidget { Container: this.$relationMapContainer.attr("id") }); + if (!this.jsPlumbInstance) { + return; + } + this.jsPlumbInstance.registerConnectionType("uniDirectional", { anchor: "Continuous", connector: "StateMachine", overlays: uniDirectionalOverlays }); this.jsPlumbInstance.registerConnectionType("biDirectional", { anchor: "Continuous", connector: "StateMachine", overlays: biDirectionalOverlays }); @@ -396,10 +478,10 @@ export default class RelationMapTypeWidget extends TypeWidget { this.jsPlumbInstance.bind("connection", (info, originalEvent) => this.connectionCreatedHandler(info, originalEvent)); } - async connectionCreatedHandler(info, originalEvent) { + async connectionCreatedHandler(info: ConnectionMadeEventInfo, originalEvent: Event) { const connection = info.connection; - connection.bind("contextmenu", (obj, event) => { + connection.bind("contextmenu", (obj: unknown, event: MouseEvent) => { if (connection.getType().includes("link")) { // don't create context menu if it's a link since there's nothing to do with link from relation map // (don't open browser menu either) @@ -414,15 +496,17 @@ export default class RelationMapTypeWidget extends TypeWidget { items: [{ title: t("relation_map.remove_relation"), command: "remove", uiIcon: "bx bx-trash" }], selectMenuItemHandler: async ({ command }) => { if (command === "remove") { - if (!(await dialogService.confirm(t("relation_map.confirm_remove_relation")))) { + if (!(await dialogService.confirm(t("relation_map.confirm_remove_relation"))) || !this.relations) { return; } const relation = this.relations.find((rel) => rel.attributeId === connection.id); - await server.remove(`notes/${relation.sourceNoteId}/relations/${relation.name}/to/${relation.targetNoteId}`); + if (relation) { + await server.remove(`notes/${relation.sourceNoteId}/relations/${relation.name}/to/${relation.targetNoteId}`); + } - this.jsPlumbInstance.deleteConnection(connection); + this.jsPlumbInstance?.deleteConnection(connection); this.relations = this.relations.filter((relation) => relation.attributeId !== connection.id); } @@ -432,16 +516,20 @@ export default class RelationMapTypeWidget extends TypeWidget { }); // if there's no event, then this has been triggered programmatically - if (!originalEvent) { + if (!originalEvent || !this.jsPlumbInstance) { return; } let name = await dialogService.prompt({ message: t("relation_map.specify_new_relation_name"), shown: ({ $answer }) => { + if (!$answer) { + return; + } + $answer.on("keyup", () => { // invalid characters are simply ignored (from user perspective they are not even entered) - const attrName = utils.filterAttributeName($answer.val()); + const attrName = utils.filterAttributeName($answer.val() as string); $answer.val(attrName); }); @@ -465,7 +553,7 @@ export default class RelationMapTypeWidget extends TypeWidget { const targetNoteId = this.idToNoteId(connection.target.id); const sourceNoteId = this.idToNoteId(connection.source.id); - const relationExists = this.relations.some((rel) => rel.targetNoteId === targetNoteId && rel.sourceNoteId === sourceNoteId && rel.name === name); + const relationExists = this.relations?.some((rel) => rel.targetNoteId === targetNoteId && rel.sourceNoteId === sourceNoteId && rel.name === name); if (relationExists) { await dialogService.info(t("relation_map.connection_exists", { name })); @@ -484,11 +572,18 @@ export default class RelationMapTypeWidget extends TypeWidget { this.spacedUpdate.scheduleUpdate(); } - async createNoteBox(noteId, title, x, y) { + async createNoteBox(noteId: string, title: string, x: number, y: number) { + if (!this.jsPlumbInstance) { + return; + } + const $link = await linkService.createLink(noteId, { title }); $link.mousedown((e) => linkService.goToLink(e)); const note = await froca.getNote(noteId); + if (!note) { + return; + } const $noteBox = $("
    ") .addClass("note-box") @@ -507,13 +602,14 @@ export default class RelationMapTypeWidget extends TypeWidget { stop: (params) => { const noteId = this.idToNoteId(params.el.id); - const note = this.mapData.notes.find((note) => note.noteId === noteId); + const note = this.mapData?.notes.find((note) => note.noteId === noteId); if (!note) { logError(t("relation_map.note_not_found", { noteId })); return; } + //@ts-expect-error TODO: Check if this is still valid. [note.x, note.y] = params.finalPos; this.saveData(); @@ -552,25 +648,29 @@ export default class RelationMapTypeWidget extends TypeWidget { throw new Error(t("relation_map.cannot_match_transform", { transform })); } - return matches[1]; + return parseFloat(matches[1]); } - async dropNoteOntoRelationMapHandler(ev) { + async dropNoteOntoRelationMapHandler(ev: JQuery.DropEvent) { ev.preventDefault(); - const notes = JSON.parse(ev.originalEvent.dataTransfer.getData("text")); + const dragData = ev.originalEvent?.dataTransfer?.getData("text"); + if (!dragData) { + return; + } + const notes = JSON.parse(dragData); let { x, y } = this.getMousePosition(ev); for (const note of notes) { - const exists = this.mapData.notes.some((n) => n.noteId === note.noteId); + const exists = this.mapData?.notes.some((n) => n.noteId === note.noteId); if (exists) { toastService.showError(t("relation_map.note_already_in_diagram", { title: note.title })); continue; } - this.mapData.notes.push({ noteId: note.noteId, x, y }); + this.mapData?.notes.push({ noteId: note.noteId, x, y }); if (x > 1000) { y += 100; @@ -585,14 +685,14 @@ export default class RelationMapTypeWidget extends TypeWidget { this.loadNotesAndRelations(); } - getMousePosition(evt) { + getMousePosition(evt: JQuery.ClickEvent | JQuery.DropEvent) { const rect = this.$relationMapContainer[0].getBoundingClientRect(); const zoom = this.getZoom(); return { - x: (evt.clientX - rect.left) / zoom, - y: (evt.clientY - rect.top) / zoom + x: ((evt.clientX ?? 0) - rect.left) / zoom, + y: ((evt.clientY ?? 0) - rect.top) / zoom }; } @@ -602,18 +702,18 @@ export default class RelationMapTypeWidget extends TypeWidget { }; } - async relationMapCreateChildNoteEvent({ ntxId }) { + async relationMapCreateChildNoteEvent({ ntxId }: EventData<"relationMapCreateChildNote">) { if (!this.isNoteContext(ntxId)) { return; } const title = await dialogService.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") }); - if (!title.trim()) { + if (!title?.trim()) { return; } - const { note } = await server.post(`notes/${this.noteId}/children?target=into`, { + const { note } = await server.post(`notes/${this.noteId}/children?target=into`, { title, content: "", type: "text" @@ -624,29 +724,29 @@ export default class RelationMapTypeWidget extends TypeWidget { this.clipboard = { noteId: note.noteId, title }; } - relationMapResetPanZoomEvent({ ntxId }) { + relationMapResetPanZoomEvent({ ntxId }: EventData<"relationMapResetPanZoom">) { if (!this.isNoteContext(ntxId)) { return; } // reset to initial pan & zoom state - this.pzInstance.zoomTo(0, 0, 1 / this.getZoom()); - this.pzInstance.moveTo(0, 0); + this.pzInstance?.zoomTo(0, 0, 1 / this.getZoom()); + this.pzInstance?.moveTo(0, 0); } - relationMapResetZoomInEvent({ ntxId }) { + relationMapResetZoomInEvent({ ntxId }: EventData<"relationMapResetZoomIn">) { if (!this.isNoteContext(ntxId)) { return; } - this.pzInstance.zoomTo(0, 0, 1.2); + this.pzInstance?.zoomTo(0, 0, 1.2); } - relationMapResetZoomOutEvent({ ntxId }) { + relationMapResetZoomOutEvent({ ntxId }: EventData<"relationMapResetZoomOut">) { if (!this.isNoteContext(ntxId)) { return; } - this.pzInstance.zoomTo(0, 0, 0.8); + this.pzInstance?.zoomTo(0, 0, 0.8); } } From b44bb4053ce78d443f596f3dd706cd36dd32a349 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 21:51:03 +0200 Subject: [PATCH 17/25] refactor(deps): use webpack for jsplumb & panzoom --- bin/copy-dist.ts | 2 - src/public/app/services/library_loader.ts | 6 --- src/public/app/types.d.ts | 22 ---------- .../app/widgets/type_widgets/relation_map.ts | 40 ++++++++++++++----- src/routes/assets.ts | 4 -- 5 files changed, 31 insertions(+), 43 deletions(-) diff --git a/bin/copy-dist.ts b/bin/copy-dist.ts index 3b09fec27..3e138fed1 100644 --- a/bin/copy-dist.ts +++ b/bin/copy-dist.ts @@ -80,10 +80,8 @@ try { "node_modules/jquery/dist/", "node_modules/jquery-hotkeys/", "node_modules/split.js/dist/", - "node_modules/panzoom/dist/", "node_modules/i18next/", "node_modules/i18next-http-backend/", - "node_modules/jsplumb/dist/", "node_modules/vanilla-js-wheel-zoom/dist/", "node_modules/mark.js/dist/", "node_modules/normalize.css/", diff --git a/src/public/app/services/library_loader.ts b/src/public/app/services/library_loader.ts index ddce02301..8dca65060 100644 --- a/src/public/app/services/library_loader.ts +++ b/src/public/app/services/library_loader.ts @@ -42,11 +42,6 @@ const CODE_MIRROR: Library = { css: ["node_modules/codemirror/lib/codemirror.css", "node_modules/codemirror/addon/lint/lint.css"] }; -const RELATION_MAP: Library = { - js: ["node_modules/jsplumb/dist/js/jsplumb.min.js", "node_modules/panzoom/dist/panzoom.min.js"], - css: ["stylesheets/relation_map.css"] -}; - const CALENDAR_WIDGET: Library = { css: ["stylesheets/calendar.css"] }; @@ -183,7 +178,6 @@ export default { loadHighlightingTheme, CKEDITOR, CODE_MIRROR, - RELATION_MAP, CALENDAR_WIDGET, KATEX, WHEEL_ZOOM, diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 368f81420..6e9e514cf 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -365,14 +365,6 @@ declare global { }[]; } - /* - * jsPlumb - */ - var jsPlumb: typeof import("jsplumb").jsPlumb; - type jsPlumbInstance = import("jsplumb").jsPlumbInstance; - type OverlaySpec = typeof import("jsplumb").OverlaySpec; - type ConnectionMadeEventInfo = typeof import("jsplumb").ConnectionMadeEventInfo; - /* * Panzoom */ @@ -392,17 +384,3 @@ declare global { dispose(): void; } } - -module "jsplumb" { - interface Connection { - canvas: HTMLCanvasElement; - } - - interface Overlay { - setLabel(label: string); - } - - interface ConnectParams { - type: RelationType; - } -} diff --git a/src/public/app/widgets/type_widgets/relation_map.ts b/src/public/app/widgets/type_widgets/relation_map.ts index a539f4485..fcc2c48cd 100644 --- a/src/public/app/widgets/type_widgets/relation_map.ts +++ b/src/public/app/widgets/type_widgets/relation_map.ts @@ -1,6 +1,5 @@ import server from "../../services/server.js"; import linkService from "../../services/link.js"; -import libraryLoader from "../../services/library_loader.js"; import contextMenu from "../../menus/context_menu.js"; import toastService from "../../services/toast.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; @@ -11,6 +10,25 @@ import froca from "../../services/froca.js"; import dialogService from "../../services/dialog.js"; import { t } from "../../services/i18n.js"; import type FNote from "../../entities/fnote.js"; +import type { ConnectionMadeEventInfo, jsPlumbInstance, OverlaySpec } from "jsplumb"; +import "../../../stylesheets/relation_map.css"; + +declare module "jsplumb" { + + interface Connection { + canvas: HTMLCanvasElement; + getType(): string; + bind(event: string, callback: (obj: unknown, event: MouseEvent) => void): void; + } + + interface Overlay { + setLabel(label: string): void; + } + + interface ConnectParams { + type: RelationType; + } +} const uniDirectionalOverlays: OverlaySpec[] = [ [ @@ -206,9 +224,9 @@ export default class RelationMapTypeWidget extends TypeWidget { this.$widget.on("dragover", (ev) => ev.preventDefault()); this.initialized = new Promise(async (res) => { - await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP); - // TODO: Remove once we port to webpack. - (jsPlumb as unknown as jsPlumbInstance).ready(res); + // Weird typecast is needed probably due to bad typings in the module itself. + const jsPlumb = (await import("jsplumb")).default.jsPlumb as unknown as jsPlumbInstance; + jsPlumb.ready(res); }); super.doRender(); @@ -298,9 +316,9 @@ export default class RelationMapTypeWidget extends TypeWidget { async doRefresh(note: FNote) { await this.loadMapData(); - this.initJsPlumbInstance(); + await this.initJsPlumbInstance(); - this.initPanZoom(); + await this.initPanZoom(); this.loadNotesAndRelations(); } @@ -385,16 +403,19 @@ export default class RelationMapTypeWidget extends TypeWidget { }); } - initPanZoom() { + async initPanZoom() { if (this.pzInstance) { return; } + const panzoom = (await import("panzoom")).default; this.pzInstance = panzoom(this.$relationMapContainer[0], { maxZoom: 2, minZoom: 0.3, smoothScroll: false, - filterKey: function (e, dx, dy, dz) { + + //@ts-expect-error Upstream incorrectly mentions no arguments. + filterKey: function (e: KeyboardEvent) { // if ALT is pressed, then panzoom should bubble the event up // this is to preserve ALT-LEFT, ALT-RIGHT navigation working return e.altKey; @@ -448,13 +469,14 @@ export default class RelationMapTypeWidget extends TypeWidget { } } - initJsPlumbInstance() { + async initJsPlumbInstance() { if (this.jsPlumbInstance) { this.cleanup(); return; } + const jsPlumb = (await import("jsplumb")).default.jsPlumb; this.jsPlumbInstance = jsPlumb.getInstance({ Endpoint: ["Dot", { radius: 2 }], Connector: "StateMachine", diff --git a/src/routes/assets.ts b/src/routes/assets.ts index d2bf47c42..1fa52dff7 100644 --- a/src/routes/assets.ts +++ b/src/routes/assets.ts @@ -70,15 +70,11 @@ async function register(app: express.Application) { app.use(`/${assetPath}/node_modules/jquery-hotkeys/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/jquery-hotkeys/"))); - app.use(`/${assetPath}/node_modules/panzoom/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/panzoom/dist/"))); - // i18n app.use(`/${assetPath}/translations/`, persistentCacheStatic(path.join(srcRoot, "public", "translations/"))); app.use(`/${assetPath}/node_modules/eslint/bin/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/eslint/bin/"))); - app.use(`/${assetPath}/node_modules/jsplumb/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/jsplumb/dist/"))); - app.use(`/${assetPath}/node_modules/vanilla-js-wheel-zoom/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/vanilla-js-wheel-zoom/dist/"))); app.use(`/${assetPath}/node_modules/mark.js/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/mark.js/dist/"))); From 1f69259a9380596ec0a098e4b413aa817fa0ce21 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 23:09:06 +0200 Subject: [PATCH 18/25] chore(client/ts): port abstract_text_type_widget --- ...widget.js => abstract_text_type_widget.ts} | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) rename src/public/app/widgets/type_widgets/{abstract_text_type_widget.js => abstract_text_type_widget.ts} (75%) diff --git a/src/public/app/widgets/type_widgets/abstract_text_type_widget.js b/src/public/app/widgets/type_widgets/abstract_text_type_widget.ts similarity index 75% rename from src/public/app/widgets/type_widgets/abstract_text_type_widget.js rename to src/public/app/widgets/type_widgets/abstract_text_type_widget.ts index f542cdccb..2ff3e02bb 100644 --- a/src/public/app/widgets/type_widgets/abstract_text_type_widget.js +++ b/src/public/app/widgets/type_widgets/abstract_text_type_widget.ts @@ -1,5 +1,5 @@ import TypeWidget from "./type_widget.js"; -import appContext from "../../components/app_context.js"; +import appContext, { type EventData } from "../../components/app_context.js"; import froca from "../../services/froca.js"; import linkService from "../../services/link.js"; import contentRenderer from "../../services/content_renderer.js"; @@ -13,7 +13,7 @@ export default class AbstractTextTypeWidget extends TypeWidget { this.refreshCodeBlockOptions(); } - setupImageOpening(singleClickOpens) { + setupImageOpening(singleClickOpens: boolean) { this.$widget.on("dblclick", "img", (e) => this.openImageInCurrentTab($(e.target))); this.$widget.on("click", "img", (e) => { @@ -29,27 +29,27 @@ export default class AbstractTextTypeWidget extends TypeWidget { }); } - async openImageInCurrentTab($img) { - const { noteId, viewScope } = await this.parseFromImage($img); + async openImageInCurrentTab($img: JQuery) { + const parsedImage = await this.parseFromImage($img); - if (noteId) { - appContext.tabManager.getActiveContext().setNote(noteId, { viewScope }); + if (parsedImage) { + appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope }); } else { window.open($img.prop("src"), "_blank"); } } - async openImageInNewTab($img) { - const { noteId, viewScope } = await this.parseFromImage($img); + async openImageInNewTab($img: JQuery) { + const parsedImage = await this.parseFromImage($img); - if (noteId) { - appContext.tabManager.openTabWithNoteWithHoisting(noteId, { viewScope }); + if (parsedImage) { + appContext.tabManager.openTabWithNoteWithHoisting(parsedImage.noteId, { viewScope: parsedImage.viewScope }); } else { window.open($img.prop("src"), "_blank"); } } - async parseFromImage($img) { + async parseFromImage($img: JQuery) { const imgSrc = $img.prop("src"); const imageNoteMatch = imgSrc.match(/\/api\/images\/([A-Za-z0-9_]+)\//); @@ -66,7 +66,7 @@ export default class AbstractTextTypeWidget extends TypeWidget { const attachment = await froca.getAttachment(attachmentId); return { - noteId: attachment.ownerId, + noteId: attachment?.ownerId, viewScope: { viewMode: "attachments", attachmentId: attachmentId @@ -77,7 +77,7 @@ export default class AbstractTextTypeWidget extends TypeWidget { return null; } - async loadIncludedNote(noteId, $el) { + async loadIncludedNote(noteId: string, $el: JQuery) { const note = await froca.getNote(noteId); if (note) { @@ -97,11 +97,11 @@ export default class AbstractTextTypeWidget extends TypeWidget { } } - async loadReferenceLinkTitle($el, href = null) { + async loadReferenceLinkTitle($el: JQuery, href: string | null = null) { await linkService.loadReferenceLinkTitle($el, href); } - refreshIncludedNote($container, noteId) { + refreshIncludedNote($container: JQuery, noteId: string) { if ($container) { $container.find(`section[data-note-id="${noteId}"]`).each((_, el) => { this.loadIncludedNote(noteId, $(el)); @@ -114,7 +114,7 @@ export default class AbstractTextTypeWidget extends TypeWidget { this.$widget.toggleClass("word-wrap", wordWrap); } - async entitiesReloadedEvent({ loadResults }) { + async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { if (loadResults.isOptionReloaded("codeBlockWordWrap")) { this.refreshCodeBlockOptions(); } From 5b82b750dc08b805f817cf288ccaebeb4725d95a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 23:42:32 +0200 Subject: [PATCH 19/25] chore(client/ts): port editable_text --- src/public/app/types.d.ts | 64 ++++++++++++++++--- .../{editable_text.js => editable_text.ts} | 19 ++++-- 2 files changed, 67 insertions(+), 16 deletions(-) rename src/public/app/widgets/type_widgets/{editable_text.js => editable_text.ts} (97%) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 6e9e514cf..fa06ec614 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -176,17 +176,50 @@ declare global { }> }; + interface CKCodeBlockLanguage { + language: string; + label: string; + } + + interface CKWatchdog { + constructor(editorClass: CKEditorInstance, opts: { + minimumNonErrorTimePeriod: number; + crashNumberLimit: number, + saveInterval: number + }); + on(event: string, callback: () => void); + state: string; + crashes: unknown[]; + editor: TextEditor; + setCreator(callback: (elementOrData, editorConfig) => void); + create(el: HTMLElement, opts: { + placeholder: string, + mention: MentionConfig, + codeBlock: { + languages: CKCodeBlockLanguage[] + }, + math: { + engine: string, + outputType: string, + lazyLoad: () => Promise, + forceOutputType: boolean, + enablePreview: boolean + }, + mermaid: { + lazyLoad: () => Promise, + config: MermaidConfig + } + }); + } + var CKEditor: { - BalloonEditor: { - create(el: HTMLElement, config: { - removePlugins?: string[]; - toolbar: { - items: any[]; - }, - placeholder: string; - mention: MentionConfig - }) - } + BalloonEditor: CKEditorInstance; + DecoupledEditor: CKEditorInstance; + EditorWatchdog: typeof CKWatchdog; + }; + + var CKEditorInspector: { + attach(editor: TextEditor); }; var CodeMirror: { @@ -257,6 +290,7 @@ declare global { setAttribute(name: string, value: string, el: TextEditorElement); createPositionAt(el: TextEditorElement, opt?: "end"); setSelection(pos: number, pos?: number); + insertText(text: string, opts: Record | undefined, position?: TextPosition); } interface TextNode { previousSibling?: TextNode; @@ -275,6 +309,15 @@ declare global { compareWith(pos: TextPosition): string; } interface TextEditor { + create(el: HTMLElement, config: { + removePlugins?: string[]; + toolbar: { + items: any[]; + }, + placeholder: string; + mention: MentionConfig + }); + enableReadOnlyMode(reason: string); model: { document: { on(event: string, cb: () => void); @@ -308,6 +351,7 @@ declare global { } change(cb: (writer: Writer) => void); scrollToTheSelection(): void; + focus(): void; } }, plugins: { diff --git a/src/public/app/widgets/type_widgets/editable_text.js b/src/public/app/widgets/type_widgets/editable_text.ts similarity index 97% rename from src/public/app/widgets/type_widgets/editable_text.js rename to src/public/app/widgets/type_widgets/editable_text.ts index 435d3a127..2863b025a 100644 --- a/src/public/app/widgets/type_widgets/editable_text.js +++ b/src/public/app/widgets/type_widgets/editable_text.ts @@ -16,14 +16,15 @@ import toast from "../../services/toast.js"; import { getMermaidConfig } from "../mermaid.js"; import { normalizeMimeTypeForCKEditor } from "../../services/mime_type_definitions.js"; import { buildConfig, buildToolbarConfig } from "./ckeditor/config.js"; +import type FNote from "../../entities/fnote.js"; const ENABLE_INSPECTOR = false; -const mentionSetup = { +const mentionSetup: MentionConfig = { feeds: [ { marker: "@", - feed: (queryText) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), + feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), itemRenderer: (item) => { const itemElement = document.createElement("button"); @@ -118,6 +119,12 @@ function buildListOfLanguages() { * - Decoupled mode, in which the editing toolbar is actually added on the client side (in {@link ClassicEditorToolbar}), see https://ckeditor.com/docs/ckeditor5/latest/examples/framework/bottom-toolbar-editor.html for an example on how the decoupled editor works. */ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { + + private contentLanguage?: string | null; + private watchdog!: CKWatchdog; + + private $editor!: JQuery; + static getType() { return "editableText"; } @@ -195,7 +202,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { } }; - const contentLanguage = this.note.getLabelValue("language"); + const contentLanguage = this.note?.getLabelValue("language"); if (contentLanguage) { finalConfig.language = { ui: (typeof finalConfig.language === "string" ? finalConfig.language : "en"), @@ -277,7 +284,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { }); } - async doRefresh(note) { + async doRefresh(note: FNote) { const blob = await note.getBlob(); await this.spacedUpdate.allowUpdateWithoutChange(async () => { @@ -334,7 +341,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { this.addTextToEditor(dateString); } - async addLinkToEditor(linkHref, linkTitle) { + async addLinkToEditor(linkHref: string, linkTitle: string) { await this.initialized; this.watchdog.editor.model.change((writer) => { @@ -343,7 +350,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { }); } - async addTextToEditor(text) { + async addTextToEditor(text: string) { await this.initialized; this.watchdog.editor.model.change((writer) => { From ebbf29b1a5ece49c19ed05d1cac28c798b52d8b2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 21 Mar 2025 15:50:53 +0200 Subject: [PATCH 20/25] chore(client/ts): port syntax_highlight --- src/public/app/types.d.ts | 71 +++++++++++++++++-- ...yntax_highlight.js => syntax_highlight.ts} | 29 ++++---- 2 files changed, 80 insertions(+), 20 deletions(-) rename src/public/app/widgets/type_widgets/ckeditor/{syntax_highlight.js => syntax_highlight.ts} (94%) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index fa06ec614..8c70dc661 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -285,12 +285,20 @@ declare global { }); } - type TextEditorElement = {}; + interface Range { + toJSON(): object; + } interface Writer { - setAttribute(name: string, value: string, el: TextEditorElement); - createPositionAt(el: TextEditorElement, opt?: "end"); + setAttribute(name: string, value: string, el: CKNode); + createPositionAt(el: CKNode, opt?: "end" | number); setSelection(pos: number, pos?: number); insertText(text: string, opts: Record | undefined, position?: TextPosition); + addMarker(name: string, opts: { + range: Range; + usingOperation: boolean; + }); + removeMarker(name: string); + createRange(start: number, end: number): Range; } interface TextNode { previousSibling?: TextNode; @@ -308,6 +316,37 @@ declare global { offset: number; compareWith(pos: TextPosition): string; } + + interface TextRange { + + } + + interface Marker { + name: string; + } + + interface CKNode { + name: string; + childCount: number; + isEmpty: boolean; + toJSON(): object; + is(type: string, name?: string); + getAttribute(name: string): string; + getChild(index: number): CKNode; + data: string; + startOffset: number; + root: { + document: { + model: { + createRangeIn(el: CKNode): TextRange; + markers: { + getMarkersIntersectingRange(range: TextRange): Marker[]; + } + } + } + }; + } + interface TextEditor { create(el: HTMLElement, config: { removePlugins?: string[]; @@ -321,10 +360,22 @@ declare global { model: { document: { on(event: string, cb: () => void); - getRoot(): TextEditorElement; + getRoot(): CKNode; + registerPostFixer(callback: (writer: Writer) => boolean); selection: { getFirstPosition(): undefined | TextPosition; getLastPosition(): undefined | TextPosition; + }; + differ: { + getChanges(): { + type: string; + name: string; + position: { + nodeAfter: CKNode; + parent: CKNode; + toJSON(): Object; + } + }[]; } }, insertContent(modelFragment: any, selection: any); @@ -340,7 +391,7 @@ declare global { }) => void, opts?: { priority: "high" }); - getRoot(): TextEditorElement + getRoot(): CKNode }, domRoots: { values: () => { @@ -363,6 +414,16 @@ declare global { }; toModel(viewFeragment: any); }, + conversion: { + for(filter: string): { + markerToHighlight(data: { + model: string; + view: (data: { + markerName: string; + }) => void; + }) + } + } getData(): string; setData(data: string): void; getSelectedHtml(): string; diff --git a/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.js b/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.ts similarity index 94% rename from src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.js rename to src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.ts index ab6dd562e..36d7f0f40 100644 --- a/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.js +++ b/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.ts @@ -12,7 +12,7 @@ import library_loader from "../../../services/library_loader.js"; import mime_types from "../../../services/mime_types.js"; import { isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js"; -export async function initSyntaxHighlighting(editor) { +export async function initSyntaxHighlighting(editor: TextEditor) { if (!isSyntaxHighlightEnabled) { return; } @@ -25,39 +25,38 @@ const HIGHLIGHT_MAX_BLOCK_COUNT = 500; const tag = "SyntaxHighlightWidget"; const debugLevels = ["error", "warn", "info", "log", "debug"]; -const debugLevel = "debug"; +const debugLevel = debugLevels.indexOf("debug"); -let warn = function () {}; +let warn = function (...args: unknown[]) {}; if (debugLevel >= debugLevels.indexOf("warn")) { warn = console.warn.bind(console, tag + ": "); } -let info = function () {}; +let info = function (...args: unknown[]) {}; if (debugLevel >= debugLevels.indexOf("info")) { info = console.info.bind(console, tag + ": "); } -let log = function () {}; +let log = function (...args: unknown[]) {}; if (debugLevel >= debugLevels.indexOf("log")) { log = console.log.bind(console, tag + ": "); } -let dbg = function () {}; +let dbg = function (...args: unknown[]) {}; if (debugLevel >= debugLevels.indexOf("debug")) { dbg = console.debug.bind(console, tag + ": "); } -function assert(e, msg) { +function assert(e: boolean, msg?: string) { console.assert(e, tag + ": " + msg); } // TODO: Should this be scoped to note? let markerCounter = 0; -function initTextEditor(textEditor) { +function initTextEditor(textEditor: TextEditor) { log("initTextEditor"); - let widget = this; const document = textEditor.model.document; // Create a conversion from model to view that converts @@ -100,7 +99,7 @@ function initTextEditor(textEditor) { // See // https://github.com/ckeditor/ckeditor5/blob/b53d2a4b49679b072f4ae781ac094e7e831cfb14/packages/ckeditor5-block-quote/src/blockquoteediting.js#L54 const changes = document.differ.getChanges(); - let dirtyCodeBlocks = new Set(); + let dirtyCodeBlocks = new Set(); for (const change of changes) { dbg("change " + JSON.stringify(change)); @@ -151,7 +150,7 @@ function initTextEditor(textEditor) { * the formatting would be stored with the note and it would need a * way to remove that formatting when editing back the note. */ -function highlightCodeBlock(codeBlock, writer) { +function highlightCodeBlock(codeBlock: CKNode, writer: Writer) { log("highlighting codeblock " + JSON.stringify(codeBlock.toJSON())); const model = codeBlock.root.document.model; @@ -291,16 +290,16 @@ function highlightCodeBlock(codeBlock, writer) { iHtml = html.indexOf(">", iHtml) + 1; // push the span - let posStart = writer.createPositionAt(codeBlock, child.startOffset + iChildText); + let posStart = writer.createPositionAt(codeBlock, (child?.startOffset ?? 0) + iChildText); spanStack.push({ className: className, posStart: posStart }); } else if (html[iHtml] == "<" && html[iHtml + 1] == "/") { // Done with this span, pop the span and mark the range iHtml = html.indexOf(">", iHtml + 1) + 1; let stackTop = spanStack.pop(); - let posStart = stackTop.posStart; - let className = stackTop.className; - let posEnd = writer.createPositionAt(codeBlock, child.startOffset + iChildText); + let posStart = stackTop?.posStart; + let className = stackTop?.className; + let posEnd = writer.createPositionAt(codeBlock, (child?.startOffset ?? 0) + iChildText); let range = writer.createRange(posStart, posEnd); let markerName = "hljs:" + className + ":" + markerCounter; // Use an incrementing number for the uniqueId, random of From 1ab87be0e66d19eaa32a1e84fecb96598bcf7539 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 21 Mar 2025 17:34:39 +0200 Subject: [PATCH 21/25] chore(client/ts): fix errors in editable_text --- src/public/app/components/app_context.ts | 2 + src/public/app/components/note_context.ts | 2 +- src/public/app/types.d.ts | 27 ++++++++++--- .../app/widgets/type_widgets/editable_text.ts | 38 +++++++++++-------- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 367d99e8b..2d95e8632 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -193,6 +193,8 @@ export type CommandMappings = { showPasswordNotSet: CommandData; showProtectedSessionPasswordDialog: CommandData; showUploadAttachmentsDialog: CommandData & { noteId: string }; + showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; + showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string }; closeProtectedSessionPasswordDialog: CommandData; copyImageReferenceToClipboard: CommandData; copyImageToClipboard: CommandData; diff --git a/src/public/app/components/note_context.ts b/src/public/app/components/note_context.ts index 7d9f3d246..6c93fcc5b 100644 --- a/src/public/app/components/note_context.ts +++ b/src/public/app/components/note_context.ts @@ -16,7 +16,7 @@ export interface SetNoteOpts { viewScope?: ViewScope; } -export type GetTextEditorCallback = () => void; +export type GetTextEditorCallback = (editor: TextEditor) => void; class NoteContext extends Component implements EventListener<"entitiesReloaded"> { ntxId: string | null; diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 8c70dc661..01b5c6812 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -210,6 +210,7 @@ declare global { config: MermaidConfig } }); + destroy(); } var CKEditor: { @@ -287,18 +288,20 @@ declare global { interface Range { toJSON(): object; + getItems(): TextNode[]; } interface Writer { setAttribute(name: string, value: string, el: CKNode); createPositionAt(el: CKNode, opt?: "end" | number); setSelection(pos: number, pos?: number); - insertText(text: string, opts: Record | undefined, position?: TextPosition); + insertText(text: string, opts: Record | undefined | TextPosition, position?: TextPosition); addMarker(name: string, opts: { range: Range; usingOperation: boolean; }); removeMarker(name: string); createRange(start: number, end: number): Range; + createElement(type: string, opts: Record); } interface TextNode { previousSibling?: TextNode; @@ -347,6 +350,17 @@ declare global { }; } + interface CKEvent { + stop(): void; + } + + interface PluginEventData { + title: string; + message: { + message: string; + }; + } + interface TextEditor { create(el: HTMLElement, config: { removePlugins?: string[]; @@ -365,6 +379,11 @@ declare global { selection: { getFirstPosition(): undefined | TextPosition; getLastPosition(): undefined | TextPosition; + getSelectedElement(): CKNode; + hasAttribute(attribute: string): boolean; + getAttribute(attribute: string): string; + getFirstRange(): Range; + isCollapsed: boolean; }; differ: { getChanges(): { @@ -378,15 +397,13 @@ declare global { }[]; } }, - insertContent(modelFragment: any, selection: any); + insertContent(modelFragment: any, selection?: any); change(cb: (writer: Writer) => void) }, editing: { view: { document: { - on(event: string, cb: (event: { - stop(); - }, data: { + on(event: string, cb: (event: CKEvent, data: { preventDefault(); }) => void, opts?: { priority: "high" diff --git a/src/public/app/widgets/type_widgets/editable_text.ts b/src/public/app/widgets/type_widgets/editable_text.ts index 2863b025a..9f4278197 100644 --- a/src/public/app/widgets/type_widgets/editable_text.ts +++ b/src/public/app/widgets/type_widgets/editable_text.ts @@ -8,7 +8,7 @@ import froca from "../../services/froca.js"; import noteCreateService from "../../services/note_create.js"; import AbstractTextTypeWidget from "./abstract_text_type_widget.js"; import link from "../../services/link.js"; -import appContext from "../../components/app_context.js"; +import appContext, { type EventData } from "../../components/app_context.js"; import dialogService from "../../services/dialog.js"; import { initSyntaxHighlighting } from "./ckeditor/syntax_highlight.js"; import options from "../../services/options.js"; @@ -216,7 +216,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const editor = await editorClass.create(elementOrData, finalConfig); const notificationsPlugin = editor.plugins.get("Notification"); - notificationsPlugin.on("show:warning", (evt, data) => { + notificationsPlugin.on("show:warning", (evt: CKEvent, data: PluginEventData) => { const title = data.title; const message = data.message.message; @@ -253,6 +253,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { editor.model.document.on("change:data", () => this.spacedUpdate.scheduleUpdate()); if (glob.isDev && ENABLE_INSPECTOR) { + //@ts-expect-error TODO: Check if this still works. await import(/* webpackIgnore: true */ "../../../libraries/ckeditor/inspector.js"); CKEditorInspector.attach(editor); } @@ -288,8 +289,8 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const blob = await note.getBlob(); await this.spacedUpdate.allowUpdateWithoutChange(async () => { - const data = blob.content || ""; - const newContentLanguage = this.note.getLabelValue("language"); + const data = blob?.content || ""; + const newContentLanguage = this.note?.getLabelValue("language"); if (this.contentLanguage !== newContentLanguage) { await this.reinitialize(data); } else { @@ -359,7 +360,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { }); } - addTextToActiveEditorEvent({ text }) { + addTextToActiveEditorEvent({ text }: EventData<"addTextToActiveEditor">) { if (!this.isActive()) { return; } @@ -367,7 +368,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { this.addTextToEditor(text); } - async addLink(notePath, linkTitle, externalLink = false) { + async addLink(notePath: string, linkTitle: string, externalLink: boolean = false) { await this.initialized; if (linkTitle) { @@ -391,7 +392,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { return !selection.isCollapsed; } - async executeWithTextEditorEvent({ callback, resolve, ntxId }) { + async executeWithTextEditorEvent({ callback, resolve, ntxId }: EventData<"executeWithTextEditor">) { if (!this.isNoteContext(ntxId)) { return; } @@ -435,7 +436,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const notePath = selectedElement.getAttribute("notePath"); if (notePath) { - await appContext.tabManager.getActiveContext().setNote(notePath); + await appContext.tabManager.getActiveContext()?.setNote(notePath); return; } } @@ -448,7 +449,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const notePath = link.getNotePathFromUrl(selectedLinkUrl); if (notePath) { - await appContext.tabManager.getActiveContext().setNote(notePath); + await appContext.tabManager.getActiveContext()?.setNote(notePath); } else { window.open(selectedLinkUrl, "_blank"); } @@ -458,7 +459,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { this.triggerCommand("showIncludeNoteDialog", { textTypeWidget: this }); } - addIncludeNote(noteId, boxSize) { + addIncludeNote(noteId: string, boxSize: string) { this.watchdog.editor.model.change((writer) => { // Insert * at the current selection position // in a way that will result in creating a valid model structure @@ -471,8 +472,11 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { }); } - async addImage(noteId) { + async addImage(noteId: string) { const note = await froca.getNote(noteId); + if (!note) { + return; + } this.watchdog.editor.model.change((writer) => { const encodedTitle = encodeURIComponent(note.title); @@ -482,24 +486,28 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { }); } - async createNoteForReferenceLink(title) { + async createNoteForReferenceLink(title: string) { + if (!this.notePath) { + return; + } + const resp = await noteCreateService.createNoteWithTypePrompt(this.notePath, { activate: false, title: title }); - if (!resp) { + if (!resp || !resp.note) { return; } return resp.note.getBestNotePathString(); } - async refreshIncludedNoteEvent({ noteId }) { + async refreshIncludedNoteEvent({ noteId }: EventData<"refreshIncludedNote">) { this.refreshIncludedNote(this.$editor, noteId); } - async reinitialize(data) { + async reinitialize(data: string) { if (!this.watchdog) { return; } From b876f98d697a76b5fcb9d6792245e88c392e359a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 21 Mar 2025 17:39:18 +0200 Subject: [PATCH 22/25] fix(client/ts): fix rest of build errors --- src/public/app/types.d.ts | 2 +- src/public/app/widgets/dialogs/add_link.ts | 4 ++-- src/public/app/widgets/dialogs/include_note.ts | 2 +- src/public/app/widgets/type_widgets/editable_text.ts | 4 ++-- src/public/app/widgets/type_widgets/read_only_text.ts | 4 +++- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 01b5c6812..3c672e7a3 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -301,7 +301,7 @@ declare global { }); removeMarker(name: string); createRange(start: number, end: number): Range; - createElement(type: string, opts: Record); + createElement(type: string, opts: Record); } interface TextNode { previousSibling?: TextNode; diff --git a/src/public/app/widgets/dialogs/add_link.ts b/src/public/app/widgets/dialogs/add_link.ts index 2a987d3ff..79d0fc74a 100644 --- a/src/public/app/widgets/dialogs/add_link.ts +++ b/src/public/app/widgets/dialogs/add_link.ts @@ -80,13 +80,13 @@ export default class AddLinkDialog extends BasicWidget { if (this.$autoComplete.getSelectedNotePath()) { this.$widget.modal("hide"); - const linkTitle = this.getLinkType() === "reference-link" ? null : this.$linkTitle.val(); + const linkTitle = this.getLinkType() === "reference-link" ? null : this.$linkTitle.val() as string; this.textTypeWidget?.addLink(this.$autoComplete.getSelectedNotePath()!, linkTitle); } else if (this.$autoComplete.getSelectedExternalLink()) { this.$widget.modal("hide"); - this.textTypeWidget?.addLink(this.$autoComplete.getSelectedExternalLink()!, this.$linkTitle.val(), true); + this.textTypeWidget?.addLink(this.$autoComplete.getSelectedExternalLink()!, this.$linkTitle.val() as string, true); } else { logError("No link to add."); } diff --git a/src/public/app/widgets/dialogs/include_note.ts b/src/public/app/widgets/dialogs/include_note.ts index 839d068dd..45d4a0f8a 100644 --- a/src/public/app/widgets/dialogs/include_note.ts +++ b/src/public/app/widgets/dialogs/include_note.ts @@ -103,7 +103,7 @@ export default class IncludeNoteDialog extends BasicWidget { return; } const note = await froca.getNote(noteId); - const boxSize = $("input[name='include-note-box-size']:checked").val(); + const boxSize = $("input[name='include-note-box-size']:checked").val() as string; if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) { // there's no benefit to use insert note functionlity for images, diff --git a/src/public/app/widgets/type_widgets/editable_text.ts b/src/public/app/widgets/type_widgets/editable_text.ts index 9f4278197..28eccfdbf 100644 --- a/src/public/app/widgets/type_widgets/editable_text.ts +++ b/src/public/app/widgets/type_widgets/editable_text.ts @@ -368,7 +368,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { this.addTextToEditor(text); } - async addLink(notePath: string, linkTitle: string, externalLink: boolean = false) { + async addLink(notePath: string, linkTitle: string | null, externalLink: boolean = false) { await this.initialized; if (linkTitle) { @@ -459,7 +459,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { this.triggerCommand("showIncludeNoteDialog", { textTypeWidget: this }); } - addIncludeNote(noteId: string, boxSize: string) { + addIncludeNote(noteId: string, boxSize?: string) { this.watchdog.editor.model.change((writer) => { // Insert * at the current selection position // in a way that will result in creating a valid model structure diff --git a/src/public/app/widgets/type_widgets/read_only_text.ts b/src/public/app/widgets/type_widgets/read_only_text.ts index 8d788d6bd..8791f8f27 100644 --- a/src/public/app/widgets/type_widgets/read_only_text.ts +++ b/src/public/app/widgets/type_widgets/read_only_text.ts @@ -114,7 +114,9 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget { this.$content.find("section").each((_, el) => { const noteId = $(el).attr("data-note-id"); - this.loadIncludedNote(noteId, $(el)); + if (noteId) { + this.loadIncludedNote(noteId, $(el)); + } }); if (this.$content.find("span.math-tex").length > 0) { From 8e3a75ad57321b3351a59a0e8bea49ce5837246e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 21 Mar 2025 17:48:54 +0200 Subject: [PATCH 23/25] chore(client/ts): reduce log level for syntax highlight widget --- .../app/widgets/type_widgets/ckeditor/syntax_highlight.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.ts b/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.ts index 36d7f0f40..7ad0424ac 100644 --- a/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.ts +++ b/src/public/app/widgets/type_widgets/ckeditor/syntax_highlight.ts @@ -25,7 +25,7 @@ const HIGHLIGHT_MAX_BLOCK_COUNT = 500; const tag = "SyntaxHighlightWidget"; const debugLevels = ["error", "warn", "info", "log", "debug"]; -const debugLevel = debugLevels.indexOf("debug"); +const debugLevel = debugLevels.indexOf("warn"); let warn = function (...args: unknown[]) {}; if (debugLevel >= debugLevels.indexOf("warn")) { From c2a7b92660a6baa6ea98646c894d5acdb9be91cc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 21 Mar 2025 18:14:37 +0200 Subject: [PATCH 24/25] chore(client/ts): port desktop_layout --- src/public/app/components/app_context.ts | 2 +- .../layouts/{desktop_layout.js => desktop_layout.ts} | 11 ++++++++--- src/public/app/services/bundle.ts | 2 +- src/public/app/types.d.ts | 1 + src/public/app/widgets/containers/ribbon_container.ts | 7 +++++-- 5 files changed, 16 insertions(+), 7 deletions(-) rename src/public/app/layouts/{desktop_layout.js => desktop_layout.ts} (98%) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 2d95e8632..7063a8de3 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -404,7 +404,7 @@ type FilterByValueType = { [K in keyof T]: T[K] extends ValueType */ export type FilteredCommandNames = keyof Pick>; -class AppContext extends Component { +export class AppContext extends Component { isMainWindow: boolean; components: Component[]; beforeUnloadListeners: WeakRef[]; diff --git a/src/public/app/layouts/desktop_layout.js b/src/public/app/layouts/desktop_layout.ts similarity index 98% rename from src/public/app/layouts/desktop_layout.js rename to src/public/app/layouts/desktop_layout.ts index ecb97fe5f..129173838 100644 --- a/src/public/app/layouts/desktop_layout.js +++ b/src/public/app/layouts/desktop_layout.ts @@ -88,13 +88,18 @@ import utils from "../services/utils.js"; import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js"; import ContextualHelpButton from "../widgets/floating_buttons/help_button.js"; import CloseZenButton from "../widgets/close_zen_button.js"; +import type { AppContext } from "./../components/app_context.js"; +import type { WidgetsByParent } from "../services/bundle.js"; export default class DesktopLayout { - constructor(customWidgets) { + + private customWidgets: WidgetsByParent; + + constructor(customWidgets: WidgetsByParent) { this.customWidgets = customWidgets; } - getRootWidget(appContext) { + getRootWidget(appContext: AppContext) { appContext.noteTreeWidget = new NoteTreeWidget(); const launcherPaneIsHorizontal = options.get("layoutOrientation") === "horizontal"; @@ -267,7 +272,7 @@ export default class DesktopLayout { .child(new CloseZenButton()); } - #buildLauncherPane(isHorizontal) { + #buildLauncherPane(isHorizontal: boolean) { let launcherPane; if (isHorizontal) { diff --git a/src/public/app/services/bundle.ts b/src/public/app/services/bundle.ts index e4fc31de2..e6eea7ef1 100644 --- a/src/public/app/services/bundle.ts +++ b/src/public/app/services/bundle.ts @@ -50,7 +50,7 @@ async function executeStartupBundles() { } } -class WidgetsByParent { +export class WidgetsByParent { private byParent: Record; constructor() { diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 3c672e7a3..05b533f73 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -47,6 +47,7 @@ interface CustomGlobals { TRILIUM_SAFE_MODE: boolean; platform?: typeof process.platform; linter: typeof lint; + hasNativeTitleBar: boolean; } type RequireMethod = (moduleName: string) => any; diff --git a/src/public/app/widgets/containers/ribbon_container.ts b/src/public/app/widgets/containers/ribbon_container.ts index cd1cebdcf..e4e78b43f 100644 --- a/src/public/app/widgets/containers/ribbon_container.ts +++ b/src/public/app/widgets/containers/ribbon_container.ts @@ -5,6 +5,7 @@ import type CommandButtonWidget from "../buttons/command_button.js"; import type FNote from "../../entities/fnote.js"; import type { NoteType } from "../../entities/fnote.js"; import type { EventData, EventNames } from "../../components/app_context.js"; +import type NoteActionsWidget from "../buttons/note_actions.js"; const TPL = `
    @@ -116,13 +117,15 @@ const TPL = `
    `; +type ButtonWidget = (CommandButtonWidget | NoteActionsWidget); + export default class RibbonContainer extends NoteContextAwareWidget { private lastActiveComponentId?: string | null; private lastNoteType?: NoteType; private ribbonWidgets: NoteContextAwareWidget[]; - private buttonWidgets: CommandButtonWidget[]; + private buttonWidgets: ButtonWidget[]; private $tabContainer!: JQuery; private $buttonContainer!: JQuery; private $bodyContainer!: JQuery; @@ -148,7 +151,7 @@ export default class RibbonContainer extends NoteContextAwareWidget { return this; } - button(widget: CommandButtonWidget) { + button(widget: ButtonWidget) { super.child(widget); this.buttonWidgets.push(widget); From 7008acf5110153c7faa895c6a94043e09415d3a2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 21 Mar 2025 18:16:37 +0200 Subject: [PATCH 25/25] chore(client/ts): remove check_ts_progress script --- _check_ts_progress.sh | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100755 _check_ts_progress.sh diff --git a/_check_ts_progress.sh b/_check_ts_progress.sh deleted file mode 100755 index 7332a6054..000000000 --- a/_check_ts_progress.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -cd src/public -echo Summary -cloc HEAD \ - --git --md \ - --include-lang=javascript,typescript - -echo By file -cloc HEAD \ - --git --md \ - --include-lang=javascript,typescript \ - --by-file | grep \.js\| \ No newline at end of file