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 note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
interface ContextMenuOptions<T extends CommandNames> {
interface ContextMenuOptions<T> {
x: number;
y: number;
orientation?: "left";
@ -17,7 +16,7 @@ interface MenuSeparatorItem {
title: "----";
}
export interface MenuCommandItem<T extends CommandNames> {
export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
@ -30,8 +29,8 @@ export interface MenuCommandItem<T extends CommandNames> {
spellingSuggestion?: string;
}
export type MenuItem<T extends CommandNames> = MenuCommandItem<T> | MenuSeparatorItem;
export type MenuHandler<T extends CommandNames> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
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;
note_tooltip.dismissAllTooltips();

View File

@ -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<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
}
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 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 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;
}
}

View File

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

View File

@ -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 = `<div class="spacer"></div>`;
@ -26,7 +26,7 @@ export default class SpacerWidget extends BasicWidget {
this.$widget.on("contextmenu", (e) => {
this.$widget.tooltip("hide");
contextMenu.show({
contextMenu.show<CommandNames>({
x: e.pageX,
y: e.pageY,
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 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<CommandNames>({
x: e.pageX,
y: e.pageY,
items: [

View File

@ -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<string, string>;
noteTitles: Record<string, string>;
}
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<HTMLElement>;
private $relationMapWrapper!: JQuery<HTMLElement>;
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<MenuCommands>({
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<RelationMapPostResponse>("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 = $("<div>")
.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<PostNoteResponse>(`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);
}
}