From 3047957239e53108840277ffcbb0fea160b17659 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 20 Mar 2025 19:54:09 +0200 Subject: [PATCH] chore(client/ts): port type_widgets/relation_map --- src/public/app/menus/context_menu.ts | 11 +- src/public/app/services/dialog.ts | 4 +- src/public/app/services/link.ts | 2 +- src/public/app/types.d.ts | 42 ++++ src/public/app/widgets/dialogs/confirm.ts | 3 +- src/public/app/widgets/spacer.ts | 4 +- src/public/app/widgets/tab_row.ts | 4 +- .../{relation_map.js => relation_map.ts} | 198 +++++++++++++----- 8 files changed, 205 insertions(+), 63 deletions(-) rename src/public/app/widgets/type_widgets/{relation_map.js => relation_map.ts} (76%) 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/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/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/types.d.ts b/src/public/app/types.d.ts index dd618c129..368f81420 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; @@ -363,4 +364,45 @@ declare global { minimumCharacters: number; }[]; } + + /* + * 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 + */ + + 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; + } +} + +module "jsplumb" { + interface Connection { + canvas: HTMLCanvasElement; + } + + interface Overlay { + setLabel(label: string); + } + + interface ConnectParams { + type: RelationType; + } } 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/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); } }