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 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/components/app_context.ts b/src/public/app/components/app_context.ts index 079e9f737..7063a8de3 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; @@ -364,6 +366,9 @@ type EventMappings = { textTypeWidget: EditableTextTypeWidget; text: string; }; + showIncludeDialog: { + textTypeWidget: EditableTextTypeWidget; + }; openBulkActionsDialog: { selectedOrActiveNoteIds: string[]; }; @@ -399,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/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/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/menus/context_menu.ts b/src/public/app/menus/context_menu.ts index 5752ad648..004c6bc0e 100644 --- a/src/public/app/menus/context_menu.ts +++ b/src/public/app/menus/context_menu.ts @@ -1,9 +1,8 @@ -import type { CommandNames } from "../components/app_context.js"; import keyboardActionService from "../services/keyboard_actions.js"; import note_tooltip from "../services/note_tooltip.js"; import utils from "../services/utils.js"; -interface ContextMenuOptions { +interface ContextMenuOptions { x: number; y: number; orientation?: "left"; @@ -17,7 +16,7 @@ interface MenuSeparatorItem { title: "----"; } -export interface MenuCommandItem { +export interface MenuCommandItem { title: string; command?: T; type?: string; @@ -30,8 +29,8 @@ export interface MenuCommandItem { spellingSuggestion?: string; } -export type MenuItem = MenuCommandItem | MenuSeparatorItem; -export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent) => void; +export type MenuItem = MenuCommandItem | MenuSeparatorItem; +export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent) => void; export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent; class ContextMenu { @@ -55,7 +54,7 @@ class ContextMenu { } } - async show(options: ContextMenuOptions) { + async show(options: ContextMenuOptions) { this.options = options; note_tooltip.dismissAllTooltips(); 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/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/services/dialog.ts b/src/public/app/services/dialog.ts index 99da784e1..e2d93250f 100644 --- a/src/public/app/services/dialog.ts +++ b/src/public/app/services/dialog.ts @@ -1,5 +1,5 @@ import appContext from "../components/app_context.js"; -import type { ConfirmDialogOptions, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js"; +import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js"; import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; async function info(message: string) { @@ -16,7 +16,7 @@ async function confirm(message: string) { } async function confirmDeleteNoteBoxWithNote(title: string) { - return new Promise((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res })); + return new Promise((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res })); } async function prompt(props: PromptDialogOptions) { 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/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/services/link.ts b/src/public/app/services/link.ts index bc3baf924..3d92ac819 100644 --- a/src/public/app/services/link.ts +++ b/src/public/app/services/link.ts @@ -252,7 +252,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) { }; } -function goToLink(evt: MouseEvent | JQuery.ClickEvent) { +function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) { const $link = $(evt.target as any).closest("a,.block-link"); const hrefLink = $link.attr("href") || $link.attr("data-href"); 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 0252c9d37..05b533f73 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -7,6 +7,7 @@ import server from "./services/server.ts"; import library_loader, { Library } from "./services/library_loader.ts"; import type { init } from "i18next"; import type { lint } from "./services/eslint.ts"; +import type { RelationType } from "./widgets/type_widgets/relation_map.ts"; interface ElectronProcess { type: string; @@ -46,6 +47,7 @@ interface CustomGlobals { TRILIUM_SAFE_MODE: boolean; platform?: typeof process.platform; linter: typeof lint; + hasNativeTitleBar: boolean; } type RequireMethod = (moduleName: string) => any; @@ -74,7 +76,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 @@ -95,7 +97,11 @@ declare global { className: string; separateWordSearch: boolean; caseSensitive: boolean; - }) + done?: () => void; + }); + unmark(opts?: { + done: () => void; + }); } interface JQueryStatic { @@ -171,17 +177,51 @@ 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 + } + }); + destroy(); + } + 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: { @@ -221,9 +261,24 @@ declare global { setOption(name: string, value: string); refresh(); 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); + 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: { @@ -232,11 +287,22 @@ declare global { }); } - type TextEditorElement = {}; + interface Range { + toJSON(): object; + getItems(): TextNode[]; + } interface Writer { - setAttribute(name: string, value: string, el: TextEditorElement); - createPositionAt(el: TextEditorElement, opt?: "end"); - setSelection(pos: number); + setAttribute(name: string, value: string, el: CKNode); + createPositionAt(el: CKNode, opt?: "end" | number); + setSelection(pos: number, pos?: number); + 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; @@ -252,29 +318,98 @@ declare global { interface TextPosition { textNode: TextNode; 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 CKEvent { + stop(): void; + } + + interface PluginEventData { + title: string; + message: { + message: 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); - getRoot(): TextEditorElement; + getRoot(): CKNode; + registerPostFixer(callback: (writer: Writer) => boolean); selection: { getFirstPosition(): undefined | TextPosition; + getLastPosition(): undefined | TextPosition; + getSelectedElement(): CKNode; + hasAttribute(attribute: string): boolean; + getAttribute(attribute: string): string; + getFirstRange(): Range; + isCollapsed: boolean; + }; + differ: { + getChanges(): { + type: string; + name: string; + position: { + nodeAfter: CKNode; + parent: CKNode; + toJSON(): Object; + } + }[]; } }, + 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" }); - getRoot(): TextEditorElement + getRoot(): CKNode }, domRoots: { values: () => { @@ -283,16 +418,55 @@ declare global { } }; } - change(cb: (writer: Writer) => void) + change(cb: (writer: Writer) => void); + scrollToTheSelection(): void; + focus(): void; } }, + plugins: { + get(command: string) + }, + data: { + processor: { + toView(html: string); + }; + toModel(viewFeragment: any); + }, + conversion: { + for(filter: string): { + markerToHighlight(data: { + model: string; + view: (data: { + markerName: string; + }) => void; + }) + } + } getData(): string; setData(data: string): void; getSelectedHtml(): string; removeSelection(): 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; @@ -313,4 +487,23 @@ declare global { minimumCharacters: number; }[]; } + + /* + * Panzoom + */ + + function panzoom(el: HTMLElement, opts: { + maxZoom: number, + minZoom: number, + smoothScroll: false, + filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void; + }); + + interface PanZoom { + zoomTo(x: number, y: number, scale: number); + moveTo(x: number, y: number); + on(event: string, callback: () => void); + getTransform(): unknown; + dispose(): void; + } } 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/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); 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/confirm.ts b/src/public/app/widgets/dialogs/confirm.ts index 83c4c43d2..b111e4b75 100644 --- a/src/public/app/widgets/dialogs/confirm.ts +++ b/src/public/app/widgets/dialogs/confirm.ts @@ -28,7 +28,8 @@ const TPL = ` `; -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/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), diff --git a/src/public/app/widgets/dialogs/include_note.js b/src/public/app/widgets/dialogs/include_note.ts similarity index 83% rename from src/public/app/widgets/dialogs/include_note.js rename to src/public/app/widgets/dialogs/include_note.ts index 94d813413..45d4a0f8a 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(); + const boxSize = $("input[name='include-note-box-size']:checked").val() as string; - 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); } } } 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}`); } } diff --git a/src/public/app/widgets/find.js b/src/public/app/widgets/find.ts similarity index 78% rename from src/public/app/widgets/find.js rename to src/public/app/widgets/find.ts index c85d0f5f7..2505c5a85 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,16 +295,19 @@ 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"); + if (!this.handler) { + return; + } const { totalFound, currentFound } = await this.handler.performFind(searchTerm, matchCase, wholeWord); this.$totalFound.text(totalFound); @@ -297,28 +327,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_code.js b/src/public/app/widgets/find_in_code.ts similarity index 79% rename from src/public/app/widgets/find_in_code.js rename to src/public/app/widgets/find_in_code.ts index da3476722..63081bb0b 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 { totalFound: 0, currentFound: 0 }; + } + 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 67% rename from src/public/app/widgets/find_in_html.js rename to src/public/app/widgets/find_in_html.ts index 1c4b80971..d1e9a93db 100644 --- a/src/public/app/widgets/find_in_html.js +++ b/src/public/app/widgets/find_in_html.ts @@ -4,28 +4,34 @@ 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"; 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({ + return new Promise((res) => { + $content?.unmark({ done: () => { $content.markRegExp(regExp, { element: "span", @@ -48,8 +54,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 +70,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 +87,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 70% rename from src/public/app/widgets/find_in_text.js rename to src/public/app/widgets/find_in_text.ts index 9ce9c7c40..b5fa3a02c 100644 --- a/src/public/app/widgets/find_in_text.js +++ b/src/public/app/widgets/find_in_text.ts @@ -1,17 +1,38 @@ +import type { FindResult } from "./find.js"; +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?: CKFindResult | null; + private editingState?: EditingState; + + 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): 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; @@ -31,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; } @@ -54,7 +75,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 +85,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 +93,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 @@ -93,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(); } @@ -104,17 +130,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)) { 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); 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); } } 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(); } 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 = $('