chore(client/ts): port type_widgets/relation_map

This commit is contained in:
Elian Doran 2025-03-20 19:54:09 +02:00
parent e682f01c47
commit 3047957239
No known key found for this signature in database
8 changed files with 205 additions and 63 deletions

View File

@ -1,9 +1,8 @@
import type { CommandNames } from "../components/app_context.js";
import keyboardActionService from "../services/keyboard_actions.js"; import keyboardActionService from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js"; import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
interface ContextMenuOptions<T extends CommandNames> { interface ContextMenuOptions<T> {
x: number; x: number;
y: number; y: number;
orientation?: "left"; orientation?: "left";
@ -17,7 +16,7 @@ interface MenuSeparatorItem {
title: "----"; title: "----";
} }
export interface MenuCommandItem<T extends CommandNames> { export interface MenuCommandItem<T> {
title: string; title: string;
command?: T; command?: T;
type?: string; type?: string;
@ -30,8 +29,8 @@ export interface MenuCommandItem<T extends CommandNames> {
spellingSuggestion?: string; spellingSuggestion?: string;
} }
export type MenuItem<T extends CommandNames> = MenuCommandItem<T> | MenuSeparatorItem; export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
export type MenuHandler<T extends CommandNames> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void; export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent; export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
class ContextMenu { class ContextMenu {
@ -55,7 +54,7 @@ class ContextMenu {
} }
} }
async show<T extends CommandNames>(options: ContextMenuOptions<T>) { async show<T>(options: ContextMenuOptions<T>) {
this.options = options; this.options = options;
note_tooltip.dismissAllTooltips(); note_tooltip.dismissAllTooltips();

View File

@ -1,5 +1,5 @@
import appContext from "../components/app_context.js"; 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"; import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
async function info(message: string) { async function info(message: string) {
@ -16,7 +16,7 @@ async function confirm(message: string) {
} }
async function confirmDeleteNoteBoxWithNote(title: string) { async function confirmDeleteNoteBoxWithNote(title: string) {
return new Promise((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res })); return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
} }
async function prompt(props: PromptDialogOptions) { async function prompt(props: PromptDialogOptions) {

View File

@ -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 $link = $(evt.target as any).closest("a,.block-link");
const hrefLink = $link.attr("href") || $link.attr("data-href"); const hrefLink = $link.attr("href") || $link.attr("data-href");

View File

@ -7,6 +7,7 @@ import server from "./services/server.ts";
import library_loader, { Library } from "./services/library_loader.ts"; import library_loader, { Library } from "./services/library_loader.ts";
import type { init } from "i18next"; import type { init } from "i18next";
import type { lint } from "./services/eslint.ts"; import type { lint } from "./services/eslint.ts";
import type { RelationType } from "./widgets/type_widgets/relation_map.ts";
interface ElectronProcess { interface ElectronProcess {
type: string; type: string;
@ -363,4 +364,45 @@ declare global {
minimumCharacters: number; 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;
}
} }

View File

@ -28,7 +28,8 @@ const TPL = `
</div> </div>
</div>`; </div>`;
export type ConfirmDialogCallback = (val?: false | ConfirmDialogOptions) => void; export type ConfirmDialogResult = false | ConfirmDialogOptions;
export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void;
export interface ConfirmDialogOptions { export interface ConfirmDialogOptions {
confirmed: boolean; confirmed: boolean;

View File

@ -1,7 +1,7 @@
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import BasicWidget from "./basic_widget.js"; import BasicWidget from "./basic_widget.js";
import contextMenu from "../menus/context_menu.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"; import utils from "../services/utils.js";
const TPL = `<div class="spacer"></div>`; const TPL = `<div class="spacer"></div>`;
@ -26,7 +26,7 @@ export default class SpacerWidget extends BasicWidget {
this.$widget.on("contextmenu", (e) => { this.$widget.on("contextmenu", (e) => {
this.$widget.tooltip("hide"); this.$widget.tooltip("hide");
contextMenu.show({ contextMenu.show<CommandNames>({
x: e.pageX, x: e.pageX,
y: e.pageY, y: e.pageY,
items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (utils.isMobile() ? "bx-mobile" : "bx-sidebar") }], items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (utils.isMobile() ? "bx-mobile" : "bx-sidebar") }],

View File

@ -4,7 +4,7 @@ import BasicWidget from "./basic_widget.js";
import contextMenu from "../menus/context_menu.js"; import contextMenu from "../menus/context_menu.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import keyboardActionService from "../services/keyboard_actions.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 froca from "../services/froca.js";
import attributeService from "../services/attributes.js"; import attributeService from "../services/attributes.js";
import type NoteContext from "../components/note_context.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"); const ntxId = $(e.target).closest(".note-tab").attr("data-ntx-id");
contextMenu.show({ contextMenu.show<CommandNames>({
x: e.pageX, x: e.pageX,
y: e.pageY, y: e.pageY,
items: [ items: [

View File

@ -5,13 +5,14 @@ import contextMenu from "../../menus/context_menu.js";
import toastService from "../../services/toast.js"; import toastService from "../../services/toast.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import TypeWidget from "./type_widget.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 utils from "../../services/utils.js";
import froca from "../../services/froca.js"; import froca from "../../services/froca.js";
import dialogService from "../../services/dialog.js"; import dialogService from "../../services/dialog.js";
import { t } from "../../services/i18n.js"; import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
const uniDirectionalOverlays = [ const uniDirectionalOverlays: OverlaySpec[] = [
[ [
"Arrow", "Arrow",
{ {
@ -92,7 +93,62 @@ const TPL = `
let containerCounter = 1; 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<string, string>;
noteTitles: Record<string, string>;
}
type MenuCommands = "openInNewTab" | "remove" | "editTitle";
export default class RelationMapTypeWidget extends TypeWidget { 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<HTMLElement>;
private $relationMapWrapper!: JQuery<HTMLElement>;
static getType() { static getType() {
return "relationMap"; return "relationMap";
} }
@ -109,7 +165,7 @@ export default class RelationMapTypeWidget extends TypeWidget {
this.$relationMapWrapper = this.$widget.find(".relation-map-wrapper"); this.$relationMapWrapper = this.$widget.find(".relation-map-wrapper");
this.$relationMapWrapper.on("click", (event) => { this.$relationMapWrapper.on("click", (event) => {
if (this.clipboard) { if (this.clipboard && this.mapData) {
let { x, y } = this.getMousePosition(event); let { x, y } = this.getMousePosition(event);
// modifying position so that the cursor is on the top-center of the box // 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.attr("id", "relation-map-container-" + containerCounter++);
this.$relationMapContainer.on("contextmenu", ".note-box", (e) => { this.$relationMapContainer.on("contextmenu", ".note-box", (e) => {
contextMenu.show({ contextMenu.show<MenuCommands>({
x: e.pageX, x: e.pageX,
y: e.pageY, y: e.pageY,
items: [ items: [
@ -151,14 +207,14 @@ export default class RelationMapTypeWidget extends TypeWidget {
this.initialized = new Promise(async (res) => { this.initialized = new Promise(async (res) => {
await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP); await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP);
// TODO: Remove once we port to webpack.
jsPlumb.ready(res); (jsPlumb as unknown as jsPlumbInstance).ready(res);
}); });
super.doRender(); super.doRender();
} }
async contextMenuHandler(command, originalTarget) { async contextMenuHandler(command: MenuCommands | undefined, originalTarget: HTMLElement) {
const $noteBox = $(originalTarget).closest(".note-box"); const $noteBox = $(originalTarget).closest(".note-box");
const $title = $noteBox.find(".title a"); const $title = $noteBox.find(".title a");
const noteId = this.idToNoteId($noteBox.prop("id")); const noteId = this.idToNoteId($noteBox.prop("id"));
@ -168,11 +224,11 @@ export default class RelationMapTypeWidget extends TypeWidget {
} else if (command === "remove") { } else if (command === "remove") {
const result = await dialogService.confirmDeleteNoteBoxWithNote($title.text()); const result = await dialogService.confirmDeleteNoteBoxWithNote($title.text());
if (!result.confirmed) { if (typeof result !== "object" || !result.confirmed) {
return; return;
} }
this.jsPlumbInstance.remove(this.noteIdToId(noteId)); this.jsPlumbInstance?.remove(this.noteIdToId(noteId));
if (result.isDeleteNoteChecked) { if (result.isDeleteNoteChecked) {
const taskId = utils.randomString(10); const taskId = utils.randomString(10);
@ -180,9 +236,13 @@ export default class RelationMapTypeWidget extends TypeWidget {
await server.remove(`notes/${noteId}?taskId=${taskId}&last=true`); 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(); this.saveData();
} else if (command === "editTitle") { } 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 { try {
this.mapData = JSON.parse(blob.content); this.mapData = JSON.parse(blob.content);
} catch (e) { } catch (e) {
@ -227,15 +287,15 @@ export default class RelationMapTypeWidget extends TypeWidget {
} }
} }
noteIdToId(noteId) { noteIdToId(noteId: string) {
return `rel-map-note-${noteId}`; return `rel-map-note-${noteId}`;
} }
idToNoteId(id) { idToNoteId(id: string) {
return id.substr(13); return id.substr(13);
} }
async doRefresh(note) { async doRefresh(note: FNote) {
await this.loadMapData(); await this.loadMapData();
this.initJsPlumbInstance(); this.initJsPlumbInstance();
@ -248,15 +308,19 @@ export default class RelationMapTypeWidget extends TypeWidget {
clearMap() { clearMap() {
// delete all endpoints and connections // delete all endpoints and connections
// this is done at this point (after async operations) to reduce flicker to the minimum // 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 // without this, we still end up with note boxes remaining in the canvas
this.$relationMapContainer.empty(); this.$relationMapContainer.empty();
} }
async loadNotesAndRelations() { async loadNotesAndRelations() {
if (!this.mapData || !this.jsPlumbInstance) {
return;
}
const noteIds = this.mapData.notes.map((note) => note.noteId); 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<RelationMapPostResponse>("relation-map", { noteIds, relationMapNoteId: this.noteId });
this.relations = []; 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.mapData.notes = this.mapData.notes.filter((note) => note.noteId in data.noteTitles);
this.jsPlumbInstance.batch(async () => { this.jsPlumbInstance.batch(async () => {
if (!this.jsPlumbInstance || !this.mapData || !this.relations) {
return;
}
this.clearMap(); this.clearMap();
for (const note of this.mapData.notes) { for (const note of this.mapData.notes) {
@ -301,6 +369,8 @@ export default class RelationMapTypeWidget extends TypeWidget {
type: relation.type type: relation.type
}); });
// TODO: Does this actually do anything.
//@ts-expect-error
connection.id = relation.attributeId; connection.id = relation.attributeId;
if (relation.type === "inverse") { if (relation.type === "inverse") {
@ -331,14 +401,18 @@ export default class RelationMapTypeWidget extends TypeWidget {
} }
}); });
if (!this.pzInstance) {
return;
}
this.pzInstance.on("transform", () => { this.pzInstance.on("transform", () => {
// gets triggered on any transform change // gets triggered on any transform change
this.jsPlumbInstance.setZoom(this.getZoom()); this.jsPlumbInstance?.setZoom(this.getZoom());
this.saveCurrentTransform(); this.saveCurrentTransform();
}); });
if (this.mapData.transform) { if (this.mapData?.transform) {
this.pzInstance.zoomTo(0, 0, this.mapData.transform.scale); this.pzInstance.zoomTo(0, 0, this.mapData.transform.scale);
this.pzInstance.moveTo(this.mapData.transform.x, this.mapData.transform.y); this.pzInstance.moveTo(this.mapData.transform.x, this.mapData.transform.y);
@ -349,9 +423,13 @@ export default class RelationMapTypeWidget extends TypeWidget {
} }
saveCurrentTransform() { saveCurrentTransform() {
if (!this.pzInstance) {
return;
}
const newTransform = this.pzInstance.getTransform(); 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 // clone transform object
this.mapData.transform = JSON.parse(JSON.stringify(newTransform)); this.mapData.transform = JSON.parse(JSON.stringify(newTransform));
@ -385,6 +463,10 @@ export default class RelationMapTypeWidget extends TypeWidget {
Container: this.$relationMapContainer.attr("id") Container: this.$relationMapContainer.attr("id")
}); });
if (!this.jsPlumbInstance) {
return;
}
this.jsPlumbInstance.registerConnectionType("uniDirectional", { anchor: "Continuous", connector: "StateMachine", overlays: uniDirectionalOverlays }); this.jsPlumbInstance.registerConnectionType("uniDirectional", { anchor: "Continuous", connector: "StateMachine", overlays: uniDirectionalOverlays });
this.jsPlumbInstance.registerConnectionType("biDirectional", { anchor: "Continuous", connector: "StateMachine", overlays: biDirectionalOverlays }); 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)); this.jsPlumbInstance.bind("connection", (info, originalEvent) => this.connectionCreatedHandler(info, originalEvent));
} }
async connectionCreatedHandler(info, originalEvent) { async connectionCreatedHandler(info: ConnectionMadeEventInfo, originalEvent: Event) {
const connection = info.connection; const connection = info.connection;
connection.bind("contextmenu", (obj, event) => { connection.bind("contextmenu", (obj: unknown, event: MouseEvent) => {
if (connection.getType().includes("link")) { 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 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) // (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" }], items: [{ title: t("relation_map.remove_relation"), command: "remove", uiIcon: "bx bx-trash" }],
selectMenuItemHandler: async ({ command }) => { selectMenuItemHandler: async ({ command }) => {
if (command === "remove") { 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; return;
} }
const relation = this.relations.find((rel) => rel.attributeId === connection.id); 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); 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 there's no event, then this has been triggered programmatically
if (!originalEvent) { if (!originalEvent || !this.jsPlumbInstance) {
return; return;
} }
let name = await dialogService.prompt({ let name = await dialogService.prompt({
message: t("relation_map.specify_new_relation_name"), message: t("relation_map.specify_new_relation_name"),
shown: ({ $answer }) => { shown: ({ $answer }) => {
if (!$answer) {
return;
}
$answer.on("keyup", () => { $answer.on("keyup", () => {
// invalid characters are simply ignored (from user perspective they are not even entered) // 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); $answer.val(attrName);
}); });
@ -465,7 +553,7 @@ export default class RelationMapTypeWidget extends TypeWidget {
const targetNoteId = this.idToNoteId(connection.target.id); const targetNoteId = this.idToNoteId(connection.target.id);
const sourceNoteId = this.idToNoteId(connection.source.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) { if (relationExists) {
await dialogService.info(t("relation_map.connection_exists", { name })); await dialogService.info(t("relation_map.connection_exists", { name }));
@ -484,11 +572,18 @@ export default class RelationMapTypeWidget extends TypeWidget {
this.spacedUpdate.scheduleUpdate(); 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 }); const $link = await linkService.createLink(noteId, { title });
$link.mousedown((e) => linkService.goToLink(e)); $link.mousedown((e) => linkService.goToLink(e));
const note = await froca.getNote(noteId); const note = await froca.getNote(noteId);
if (!note) {
return;
}
const $noteBox = $("<div>") const $noteBox = $("<div>")
.addClass("note-box") .addClass("note-box")
@ -507,13 +602,14 @@ export default class RelationMapTypeWidget extends TypeWidget {
stop: (params) => { stop: (params) => {
const noteId = this.idToNoteId(params.el.id); 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) { if (!note) {
logError(t("relation_map.note_not_found", { noteId })); logError(t("relation_map.note_not_found", { noteId }));
return; return;
} }
//@ts-expect-error TODO: Check if this is still valid.
[note.x, note.y] = params.finalPos; [note.x, note.y] = params.finalPos;
this.saveData(); this.saveData();
@ -552,25 +648,29 @@ export default class RelationMapTypeWidget extends TypeWidget {
throw new Error(t("relation_map.cannot_match_transform", { transform })); 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(); 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); let { x, y } = this.getMousePosition(ev);
for (const note of notes) { 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) { if (exists) {
toastService.showError(t("relation_map.note_already_in_diagram", { title: note.title })); toastService.showError(t("relation_map.note_already_in_diagram", { title: note.title }));
continue; continue;
} }
this.mapData.notes.push({ noteId: note.noteId, x, y }); this.mapData?.notes.push({ noteId: note.noteId, x, y });
if (x > 1000) { if (x > 1000) {
y += 100; y += 100;
@ -585,14 +685,14 @@ export default class RelationMapTypeWidget extends TypeWidget {
this.loadNotesAndRelations(); this.loadNotesAndRelations();
} }
getMousePosition(evt) { getMousePosition(evt: JQuery.ClickEvent | JQuery.DropEvent) {
const rect = this.$relationMapContainer[0].getBoundingClientRect(); const rect = this.$relationMapContainer[0].getBoundingClientRect();
const zoom = this.getZoom(); const zoom = this.getZoom();
return { return {
x: (evt.clientX - rect.left) / zoom, x: ((evt.clientX ?? 0) - rect.left) / zoom,
y: (evt.clientY - rect.top) / 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)) { if (!this.isNoteContext(ntxId)) {
return; return;
} }
const title = await dialogService.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") }); 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; return;
} }
const { note } = await server.post(`notes/${this.noteId}/children?target=into`, { const { note } = await server.post<PostNoteResponse>(`notes/${this.noteId}/children?target=into`, {
title, title,
content: "", content: "",
type: "text" type: "text"
@ -624,29 +724,29 @@ export default class RelationMapTypeWidget extends TypeWidget {
this.clipboard = { noteId: note.noteId, title }; this.clipboard = { noteId: note.noteId, title };
} }
relationMapResetPanZoomEvent({ ntxId }) { relationMapResetPanZoomEvent({ ntxId }: EventData<"relationMapResetPanZoom">) {
if (!this.isNoteContext(ntxId)) { if (!this.isNoteContext(ntxId)) {
return; return;
} }
// reset to initial pan & zoom state // reset to initial pan & zoom state
this.pzInstance.zoomTo(0, 0, 1 / this.getZoom()); this.pzInstance?.zoomTo(0, 0, 1 / this.getZoom());
this.pzInstance.moveTo(0, 0); this.pzInstance?.moveTo(0, 0);
} }
relationMapResetZoomInEvent({ ntxId }) { relationMapResetZoomInEvent({ ntxId }: EventData<"relationMapResetZoomIn">) {
if (!this.isNoteContext(ntxId)) { if (!this.isNoteContext(ntxId)) {
return; 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)) { if (!this.isNoteContext(ntxId)) {
return; return;
} }
this.pzInstance.zoomTo(0, 0, 0.8); this.pzInstance?.zoomTo(0, 0, 0.8);
} }
} }