diff --git a/bin/copy-dist.ts b/bin/copy-dist.ts index 39ddd6cdf..7203b3496 100644 --- a/bin/copy-dist.ts +++ b/bin/copy-dist.ts @@ -100,7 +100,8 @@ const copy = async () => { "node_modules/codemirror/keymap/", "node_modules/mind-elixir/dist/", "node_modules/@highlightjs/cdn-assets/languages", - "node_modules/@highlightjs/cdn-assets/styles" + "node_modules/@highlightjs/cdn-assets/styles", + "node_modules/leaflet/dist" ]; for (const folder of nodeModulesFolder) { diff --git a/package-lock.json b/package-lock.json index 428c93da9..fa54b38b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@mermaid-js/layout-elk": "0.1.7", "@mind-elixir/node-menu": "1.0.3", "@triliumnext/express-partial-content": "1.0.1", + "@types/leaflet": "1.9.16", "@types/react-dom": "18.3.5", "archiver": "7.0.1", "async-mutex": "0.5.0", @@ -67,6 +68,7 @@ "jsplumb": "2.15.6", "katex": "0.16.21", "knockout": "3.5.1", + "leaflet": "1.9.4", "mark.js": "8.11.1", "marked": "15.0.6", "mermaid": "11.4.1", @@ -3847,6 +3849,15 @@ "@types/node": "*" } }, + "node_modules/@types/leaflet": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.16.tgz", + "integrity": "sha512-wzZoyySUxkgMZ0ihJ7IaUIblG8Rdc8AbbZKLneyn+QjYsj5q1QU7TEKYqwTr10BGSzY5LI7tJk9Ifo+mEjdFRw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -11437,6 +11448,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", diff --git a/package.json b/package.json index a492db6fb..0f27b3582 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@mermaid-js/layout-elk": "0.1.7", "@mind-elixir/node-menu": "1.0.3", "@triliumnext/express-partial-content": "1.0.1", + "@types/leaflet": "1.9.16", "@types/react-dom": "18.3.5", "archiver": "7.0.1", "async-mutex": "0.5.0", @@ -112,6 +113,7 @@ "jsplumb": "2.15.6", "katex": "0.16.21", "knockout": "3.5.1", + "leaflet": "1.9.4", "mark.js": "8.11.1", "marked": "15.0.6", "mermaid": "11.4.1", diff --git a/src/becca/entities/rows.ts b/src/becca/entities/rows.ts index d2d327541..89fa36953 100644 --- a/src/becca/entities/rows.ts +++ b/src/becca/entities/rows.ts @@ -116,7 +116,8 @@ export const ALLOWED_NOTE_TYPES = [ "book", "webView", "code", - "mindMap" + "mindMap", + "geoMap" ] as const; export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number]; diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 80ca032d0..992b6a2a0 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -23,6 +23,7 @@ import type LoadResults from "../services/load_results.js"; import type { Attribute } from "../services/attribute_parser.js"; import type NoteTreeWidget from "../widgets/note_tree.js"; import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js"; +import type { ContextMenuEvent } from "../menus/context_menu.js"; interface Layout { getRootWidget: (appContext: AppContext) => RootWidget; @@ -69,6 +70,7 @@ export interface ExecuteCommandData extends CommandData { */ export type CommandMappings = { "api-log-messages": CommandData; + focusTree: CommandData, focusOnDetail: Required; focusOnSearchDefinition: Required; searchNotes: CommandData & { @@ -193,6 +195,10 @@ export type CommandMappings = { setZoomFactorAndSave: { zoomFactor: string; } + + // Geomap + deleteFromMap: { noteId: string }, + openGeoLocation: { noteId: string, event: JQuery.MouseDownEvent } }; type EventMappings = { @@ -227,9 +233,12 @@ type EventMappings = { activeContextChanged: { noteContext: NoteContext; }; + beforeNoteSwitch: { + noteContext: NoteContext; + }; noteSwitched: { noteContext: NoteContext; - notePath: string; + notePath: string | null; }; noteSwitchedAndActivatedEvent: { noteContext: NoteContext; @@ -248,12 +257,16 @@ type EventMappings = { noteId: string; }; hoistedNoteChanged: { - ntxId: string; + noteId: string; + ntxId: string | null; }; contextsReopenedEvent: { mainNtxId: string; tabPosition: number; }; + noteDetailRefreshed: { + ntxId?: string | null; + }; noteContextReorderEvent: { oldMainNtxId: string; newMainNtxId: string; @@ -266,7 +279,13 @@ type EventMappings = { }; exportSvg: { ntxId: string; - } + }; + geoMapCreateChildNote: { + ntxId: string | null | undefined; // TODO: deduplicate ntxId + }; + tabReorder: { + ntxIdsInOrder: string[] + }; }; export type EventListener = { diff --git a/src/public/app/components/component.ts b/src/public/app/components/component.ts index 2db9f96a4..3bb389589 100644 --- a/src/public/app/components/component.ts +++ b/src/public/app/components/component.ts @@ -61,7 +61,7 @@ export class TypedComponent> { } } - triggerEvent(name: string, data = {}): Promise | undefined | null { + triggerEvent(name: T, data: EventData): Promise | undefined | null { return this.parent?.triggerEvent(name, data); } diff --git a/src/public/app/entities/fnote.ts b/src/public/app/entities/fnote.ts index 4da479d16..053ac6be5 100644 --- a/src/public/app/entities/fnote.ts +++ b/src/public/app/entities/fnote.ts @@ -27,7 +27,8 @@ const NOTE_TYPE_ICONS = { launcher: "bx bx-link", doc: "bx bxs-file-doc", contentWidget: "bx bxs-widget", - mindMap: "bx bx-sitemap" + mindMap: "bx bx-sitemap", + geoMap: "bx bx-map-alt" }; /** @@ -35,7 +36,7 @@ const NOTE_TYPE_ICONS = { * end user. Those types should be used only for checking against, they are * not for direct use. */ -type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap"; +type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "geoMap"; interface NotePathRecord { isArchived: boolean; diff --git a/src/public/app/layouts/desktop_layout.js b/src/public/app/layouts/desktop_layout.js index a50dace76..c5d14ffe9 100644 --- a/src/public/app/layouts/desktop_layout.js +++ b/src/public/app/layouts/desktop_layout.js @@ -85,6 +85,7 @@ import ScrollPaddingWidget from "../widgets/scroll_padding.js"; import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js"; import options from "../services/options.js"; import utils from "../services/utils.js"; +import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js"; export default class DesktopLayout { constructor(customWidgets) { @@ -200,6 +201,7 @@ export default class DesktopLayout { .child(new ShowHighlightsListWidgetButton()) .child(new CodeButtonsWidget()) .child(new RelationMapButtons()) + .child(new GeoMapButtons()) .child(new CopyImageReferenceButton()) .child(new SvgExportButton()) .child(new BacklinksWidget()) diff --git a/src/public/app/menus/context_menu.ts b/src/public/app/menus/context_menu.ts index 058178afc..5752ad648 100644 --- a/src/public/app/menus/context_menu.ts +++ b/src/public/app/menus/context_menu.ts @@ -1,5 +1,6 @@ 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 { @@ -31,6 +32,7 @@ export interface MenuCommandItem { export type MenuItem = MenuCommandItem | MenuSeparatorItem; export type MenuHandler = (item: MenuCommandItem, e: JQuery.MouseDownEvent) => void; +export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent; class ContextMenu { private $widget: JQuery; @@ -56,6 +58,8 @@ class ContextMenu { async show(options: ContextMenuOptions) { this.options = options; + note_tooltip.dismissAllTooltips(); + if (this.$widget.hasClass("show")) { // The menu is already visible. Hide the menu then open it again // at the new location to re-trigger the opening animation. diff --git a/src/public/app/menus/link_context_menu.ts b/src/public/app/menus/link_context_menu.ts index 3343ec85e..6456c6519 100644 --- a/src/public/app/menus/link_context_menu.ts +++ b/src/public/app/menus/link_context_menu.ts @@ -1,36 +1,44 @@ import { t } from "../services/i18n.js"; -import contextMenu from "./context_menu.js"; -import appContext from "../components/app_context.js"; +import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js"; +import appContext, { type CommandNames } from "../components/app_context.js"; import type { ViewScope } from "../services/link.js"; -function openContextMenu(notePath: string, e: PointerEvent | MouseEvent | JQuery.ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) { +function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) { contextMenu.show({ x: e.pageX, y: e.pageY, - items: [ - { title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" }, - { title: t("link_context_menu.open_note_in_new_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" }, - { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" } - ], - selectMenuItemHandler: ({ command }) => { - if (!hoistedNoteId) { - hoistedNoteId = appContext.tabManager.getActiveContext().hoistedNoteId; - } + items: getItems(), + selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, notePath, viewScope, hoistedNoteId) + }); +} + +function getItems(): MenuItem[] { + return [ + { title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" }, + { title: t("link_context_menu.open_note_in_new_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" }, + { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" } + ]; +} - if (command === "openNoteInNewTab") { - appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope }); - } else if (command === "openNoteInNewSplit") { - const subContexts = appContext.tabManager.getActiveContext().getSubContexts(); - const { ntxId } = subContexts[subContexts.length - 1]; +function handleLinkContextMenuItem(command: string | undefined, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) { + if (!hoistedNoteId) { + hoistedNoteId = appContext.tabManager.getActiveContext().hoistedNoteId; + } - appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope }); - } else if (command === "openNoteInNewWindow") { - appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); - } - } - }); + if (command === "openNoteInNewTab") { + appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope }); + } else if (command === "openNoteInNewSplit") { + const subContexts = appContext.tabManager.getActiveContext().getSubContexts(); + const { ntxId } = subContexts[subContexts.length - 1]; + + appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope }); + } else if (command === "openNoteInNewWindow") { + appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); + } } export default { + getItems, + handleLinkContextMenuItem, openContextMenu }; diff --git a/src/public/app/services/library_loader.ts b/src/public/app/services/library_loader.ts index 912cb652b..f8b363302 100644 --- a/src/public/app/services/library_loader.ts +++ b/src/public/app/services/library_loader.ts @@ -106,6 +106,10 @@ const HIGHLIGHT_JS: Library = { } }; +const LEAFLET: Library = { + css: [ "node_modules/leaflet/dist/leaflet.css" ], +} + async function requireLibrary(library: Library) { if (library.css) { library.css.map((cssUrl) => requireCss(cssUrl)); @@ -196,5 +200,6 @@ export default { MERMAID, MARKJS, I18NEXT, - HIGHLIGHT_JS + HIGHLIGHT_JS, + LEAFLET }; diff --git a/src/public/app/services/link.ts b/src/public/app/services/link.ts index 3e9d4f310..f80a3c10a 100644 --- a/src/public/app/services/link.ts +++ b/src/public/app/services/link.ts @@ -234,7 +234,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent) { return goToLinkExt(evt, hrefLink, $link); } -function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | React.PointerEvent, hrefLink: string | undefined, $link: JQuery | null) { +function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent, hrefLink: string | undefined, $link?: JQuery | null) { if (hrefLink?.startsWith("data:")) { return true; } diff --git a/src/public/app/services/note_tooltip.ts b/src/public/app/services/note_tooltip.ts index 32f79c73d..179ded65a 100644 --- a/src/public/app/services/note_tooltip.ts +++ b/src/public/app/services/note_tooltip.ts @@ -18,11 +18,11 @@ function setupGlobalTooltip() { return; } - cleanUpTooltips(); + dismissAllTooltips(); }); } -function cleanUpTooltips() { +function dismissAllTooltips() { $(".note-tooltip").remove(); } @@ -102,12 +102,12 @@ async function mouseEnterHandler(this: HTMLElement) { customClass: linkId }); - cleanUpTooltips(); + dismissAllTooltips(); $(this).tooltip("show"); // Dismiss the tooltip immediately if a link was clicked inside the tooltip. $(`.${tooltipClass} a`).on("click", (e) => { - cleanUpTooltips(); + dismissAllTooltips(); }); // the purpose of the code below is to: @@ -117,7 +117,7 @@ async function mouseEnterHandler(this: HTMLElement) { const checkTooltip = () => { if (!$(this).filter(":hover").length && !$(`.${linkId}:hover`).length) { // cursor is neither over the link nor over the tooltip, user likely is not interested - cleanUpTooltips(); + dismissAllTooltips(); } else { setTimeout(checkTooltip, 1000); } @@ -172,5 +172,6 @@ function renderFootnote($link: JQuery, url: string) { export default { setupGlobalTooltip, - setupElementTooltip + setupElementTooltip, + dismissAllTooltips }; diff --git a/src/public/app/services/note_types.ts b/src/public/app/services/note_types.ts index 433931ae0..7aebd48ff 100644 --- a/src/public/app/services/note_types.ts +++ b/src/public/app/services/note_types.ts @@ -18,7 +18,8 @@ async function getNoteTypeItems(command?: NoteTypeCommandNames) { { title: t("note_types.mermaid-diagram"), command, type: "mermaid", uiIcon: "bx bx-selection" }, { title: t("note_types.canvas"), command, type: "canvas", uiIcon: "bx bx-pen" }, { title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" }, - { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" } + { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" }, + { title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" }, ]; const templateNoteIds = await server.get("search-templates"); diff --git a/src/public/app/widgets/buttons/note_actions.js b/src/public/app/widgets/buttons/note_actions.js index e2efce034..c5c1587ad 100644 --- a/src/public/app/widgets/buttons/note_actions.js +++ b/src/public/app/widgets/buttons/note_actions.js @@ -42,7 +42,7 @@ const TPL = ` - + @@ -54,15 +54,15 @@ const TPL = ` - + - + - + @@ -79,7 +79,7 @@ const TPL = ` ${t("note_actions.note_source")} - + @@ -89,10 +89,10 @@ const TPL = ` - + - + @@ -154,7 +154,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget { this.toggleDisabled(this.$findInTextButton, ["text", "code", "book"].includes(note.type)); this.toggleDisabled(this.$showAttachmentsButton, !isInOptions); - this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type)); + this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "geoMap"].includes(note.type)); this.toggleDisabled(this.$printActiveNoteButton, ["text", "code"].includes(note.type)); diff --git a/src/public/app/widgets/containers/left_pane_container.ts b/src/public/app/widgets/containers/left_pane_container.ts index f5090b4a8..bbfedaa2a 100644 --- a/src/public/app/widgets/containers/left_pane_container.ts +++ b/src/public/app/widgets/containers/left_pane_container.ts @@ -22,7 +22,7 @@ export default class LeftPaneContainer extends FlexContainer { this.toggleInt(visible); if (visible) { - this.triggerEvent("focusTree"); + this.triggerEvent("focusTree", {}); } else { const activeNoteContext = appContext.tabManager.getActiveContext(); this.triggerEvent("focusOnDetail", { ntxId: activeNoteContext.ntxId }); diff --git a/src/public/app/widgets/floating_buttons/geo_map_button.ts b/src/public/app/widgets/floating_buttons/geo_map_button.ts new file mode 100644 index 000000000..5fd9a341e --- /dev/null +++ b/src/public/app/widgets/floating_buttons/geo_map_button.ts @@ -0,0 +1,42 @@ +import { t } from "../../services/i18n.js"; +import NoteContextAwareWidget from "../note_context_aware_widget.js" + +const TPL = `\ +
+ + +
`; + +export default class GeoMapButtons extends NoteContextAwareWidget { + + isEnabled() { + return super.isEnabled() && this.note?.type === "geoMap"; + } + + doRender() { + super.doRender(); + + this.$widget = $(TPL); + this.$widget.find(".geo-map-create-child-note").on("click", () => this.triggerEvent("geoMapCreateChildNote", { ntxId: this.ntxId })); + } + +} diff --git a/src/public/app/widgets/floating_buttons/relation_map_buttons.js b/src/public/app/widgets/floating_buttons/relation_map_buttons.js index 74de1852b..78c3faa1a 100644 --- a/src/public/app/widgets/floating_buttons/relation_map_buttons.js +++ b/src/public/app/widgets/floating_buttons/relation_map_buttons.js @@ -13,16 +13,16 @@ const TPL = ` - + - +
- + @@ -43,6 +43,7 @@ export default class RelationMapButtons extends NoteContextAwareWidget { this.$zoomOutButton = this.$widget.find(".relation-map-zoom-out"); this.$resetPanZoomButton = this.$widget.find(".relation-map-reset-pan-zoom"); + // TODO: Deduplicate object creation here. this.$createChildNote.on("click", () => this.triggerEvent("relationMapCreateChildNote", { ntxId: this.ntxId })); this.$resetPanZoomButton.on("click", () => this.triggerEvent("relationMapResetPanZoom", { ntxId: this.ntxId })); diff --git a/src/public/app/widgets/geo_map.ts b/src/public/app/widgets/geo_map.ts new file mode 100644 index 000000000..46fb2e414 --- /dev/null +++ b/src/public/app/widgets/geo_map.ts @@ -0,0 +1,57 @@ +import type { Map } from "leaflet"; +import library_loader from "../services/library_loader.js"; +import NoteContextAwareWidget from "./note_context_aware_widget.js"; + +const TPL = `\ +
+ + +
+
` + +export type Leaflet = typeof import("leaflet"); +export type InitCallback = ((L: Leaflet) => void); + +export default class GeoMapWidget extends NoteContextAwareWidget { + + map?: Map; + $container!: JQuery; + private initCallback?: InitCallback; + + constructor(widgetMode: "type", initCallback?: InitCallback) { + super(); + this.initCallback = initCallback; + } + + doRender() { + this.$widget = $(TPL); + + this.$container = this.$widget.find(".geo-map-container"); + + library_loader.requireLibrary(library_loader.LEAFLET) + .then(async () => { + const L = (await import("leaflet")).default; + + const map = L.map(this.$container[0], { + + }); + + this.map = map; + if (this.initCallback) { + this.initCallback(L); + } + + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(map); + }); + } + +} diff --git a/src/public/app/widgets/note_detail.js b/src/public/app/widgets/note_detail.js index 22850c35e..6ffed4514 100644 --- a/src/public/app/widgets/note_detail.js +++ b/src/public/app/widgets/note_detail.js @@ -31,6 +31,7 @@ import AttachmentListTypeWidget from "./type_widgets/attachment_list.js"; import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js"; import MindMapWidget from "./type_widgets/mind_map.js"; import { getStylesheetUrl, isSyntaxHighlightEnabled } from "../services/syntax_highlight.js"; +import GeoMapTypeWidget from "./type_widgets/geo_map.js"; const TPL = `
@@ -39,7 +40,7 @@ const TPL = ` font-family: var(--detail-font-family); font-size: var(--detail-font-size); } - + .note-detail.full-height { height: 100%; } @@ -67,7 +68,8 @@ const typeWidgetClasses = { contentWidget: ContentWidgetTypeWidget, attachmentDetail: AttachmentDetailTypeWidget, attachmentList: AttachmentListTypeWidget, - mindMap: MindMapWidget + mindMap: MindMapWidget, + geoMap: GeoMapTypeWidget }; export default class NoteDetailWidget extends NoteContextAwareWidget { @@ -147,7 +149,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget { // https://github.com/zadam/trilium/issues/2522 this.$widget.toggleClass( "full-height", - (!this.noteContext.hasNoteList() && ["canvas", "webView", "noteMap", "mindMap"].includes(this.type) && this.mime !== "text/x-sqlite;schema=trilium") || + (!this.noteContext.hasNoteList() && ["canvas", "webView", "noteMap", "mindMap", "geoMap"].includes(this.type) && this.mime !== "text/x-sqlite;schema=trilium") || this.noteContext.viewScope.viewMode === "attachments" ); } @@ -276,7 +278,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget { `, diff --git a/src/public/app/widgets/note_wrapper.js b/src/public/app/widgets/note_wrapper.js index baf444e61..9f045675c 100644 --- a/src/public/app/widgets/note_wrapper.js +++ b/src/public/app/widgets/note_wrapper.js @@ -41,7 +41,7 @@ export default class NoteWrapperWidget extends FlexContainer { return; } - this.$widget.toggleClass("full-content-width", ["image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type) || !!note?.isLabelTruthy("fullContentWidth")); + this.$widget.toggleClass("full-content-width", ["image", "mermaid", "book", "render", "canvas", "webView", "mindMap", "geoMap"].includes(note.type) || !!note?.isLabelTruthy("fullContentWidth")); this.$widget.addClass(note.getCssClass()); diff --git a/src/public/app/widgets/type_widgets/geo_map.ts b/src/public/app/widgets/type_widgets/geo_map.ts new file mode 100644 index 000000000..60166cd41 --- /dev/null +++ b/src/public/app/widgets/type_widgets/geo_map.ts @@ -0,0 +1,323 @@ +import { Marker, type LatLng, type LeafletMouseEvent } from "leaflet"; +import type FNote from "../../entities/fnote.js"; +import GeoMapWidget, { type InitCallback, type Leaflet } from "../geo_map.js"; +import TypeWidget from "./type_widget.js" +import server from "../../services/server.js"; +import toastService from "../../services/toast.js"; +import dialogService from "../../services/dialog.js"; +import type { EventData } from "../../components/app_context.js"; +import { t } from "../../services/i18n.js"; +import attributes from "../../services/attributes.js"; +import asset_path from "../../../../services/asset_path.js"; +import openContextMenu from "./geo_map_context_menu.js"; +import link from "../../services/link.js"; +import note_tooltip from "../../services/note_tooltip.js"; + +const TPL = `\ +
+ +
`; + +const LOCATION_ATTRIBUTE = "geolocation"; +const CHILD_NOTE_ICON = "bx bx-pin"; +const DEFAULT_COORDINATES: [ number, number ] = [ 3.878638227135724, 446.6630455551659 ]; +const DEFAULT_ZOOM = 2; + +interface MapData { + view?: { + center?: LatLng | [ number, number ]; + zoom?: number; + } +} + +// TODO: Deduplicate +interface CreateChildResponse { + note: { + noteId: string; + } +} + +type MarkerData = Record; + +enum State { + Normal, + NewNote +} + +export default class GeoMapTypeWidget extends TypeWidget { + + private geoMapWidget: GeoMapWidget; + private _state: State; + private L!: Leaflet; + private currentMarkerData: MarkerData; + + static getType() { + return "geoMap"; + } + + constructor() { + super(); + + this.geoMapWidget = new GeoMapWidget("type", (L: Leaflet) => this.#onMapInitialized(L)); + this.currentMarkerData = {}; + this._state = State.Normal; + + this.child(this.geoMapWidget); + } + + doRender() { + this.$widget = $(TPL); + this.$widget.append(this.geoMapWidget.render()); + + super.doRender(); + } + + async #onMapInitialized(L: Leaflet) { + this.L = L; + const map = this.geoMapWidget.map; + if (!map) { + throw new Error(t("geo-map.unable-to-load-map")); + } + + if (!this.note) { + return; + } + + const blob = await this.note.getBlob(); + + let parsedContent: MapData = {}; + if (blob && blob.content) { + parsedContent = JSON.parse(blob.content); + } + + // Restore viewport position & zoom + const center = parsedContent.view?.center ?? DEFAULT_COORDINATES; + const zoom = parsedContent.view?.zoom ?? DEFAULT_ZOOM; + map.setView(center, zoom); + + // Restore markers. + await this.#reloadMarkers(); + + const updateFn = () => this.spacedUpdate.scheduleUpdate(); + map.on("moveend", updateFn); + map.on("zoomend", updateFn); + map.on("click", (e) => this.#onMapClicked(e)); + } + + async #reloadMarkers() { + const map = this.geoMapWidget.map; + + if (!this.note || !map) { + return; + } + + // Delete all existing markers + for (const marker of Object.values(this.currentMarkerData)) { + marker.remove(); + } + + // Add the new markers. + this.currentMarkerData = {}; + const childNotes = await this.note.getChildNotes(); + const L = this.L; + for (const childNote of childNotes) { + const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE); + if (!latLng) { + continue; + } + + const [ lat, lng ] = latLng.split(",", 2).map((el) => parseFloat(el)); + const icon = L.divIcon({ + html: `\ + + + + ${childNote.title}`, + iconSize: [ 25, 41 ], + iconAnchor: [ 12, 41 ] + }) + + const marker = L.marker(L.latLng(lat, lng), { + icon, + draggable: true, + autoPan: true, + autoPanSpeed: 5, + }) + .addTo(map) + .on("moveend", e => { + this.moveMarker(childNote.noteId, (e.target as Marker).getLatLng()); + }); + + marker.on("contextmenu", (e) => { + openContextMenu(childNote.noteId, e.originalEvent); + }); + + const el = marker.getElement(); + if (el) { + const $el = $(el); + $el.attr("data-href", `#${childNote.noteId}`); + note_tooltip.setupElementTooltip($($el)); + } + + this.currentMarkerData[childNote.noteId] = marker; + } + } + + #changeState(newState: State) { + this._state = newState; + this.geoMapWidget.$container.toggleClass("placing-note", newState === State.NewNote); + } + + async #onMapClicked(e: LeafletMouseEvent) { + if (this._state !== State.NewNote) { + return; + } + + toastService.closePersistent("geo-new-note"); + 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()) { + const { note } = await server.post(`notes/${this.noteId}/children?target=into`, { + title, + content: "", + type: "text" + }); + attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON); + this.moveMarker(note.noteId, e.latlng); + } + + this.#changeState(State.Normal); + } + + async moveMarker(noteId: string, latLng: LatLng | null) { + const value = (latLng ? [latLng.lat, latLng.lng].join(",") : ""); + await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value); + } + + getData(): any { + const map = this.geoMapWidget.map; + if (!map) { + return; + } + + const data: MapData = { + view: { + center: map.getBounds().getCenter(), + zoom: map.getZoom() + } + }; + + return { + content: JSON.stringify(data) + }; + } + + async geoMapCreateChildNoteEvent({ ntxId }: EventData<"geoMapCreateChildNote">) { + if (!this.isNoteContext(ntxId)) { + return; + } + + toastService.showPersistent({ + icon: "plus", + id: "geo-new-note", + title: "New note", + message: t("geo-map.create-child-note-instruction") + }); + + this.#changeState(State.NewNote); + + const globalKeyListener: (this: Window, ev: KeyboardEvent) => any = (e) => { + if (e.key !== "Escape") { + return; + } + + this.#changeState(State.Normal); + + window.removeEventListener("keydown", globalKeyListener); + toastService.closePersistent("geo-new-note"); + }; + window.addEventListener("keydown", globalKeyListener); + } + + async doRefresh(note: FNote) { + await this.geoMapWidget.refresh(); + await this.#reloadMarkers(); + } + + entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { + const attributeRows = loadResults.getAttributeRows(); + if (attributeRows.find((at) => at.name === LOCATION_ATTRIBUTE)) { + this.#reloadMarkers(); + } + } + + openGeoLocationEvent({ noteId, event }: EventData<"openGeoLocation">) { + const marker = this.currentMarkerData[noteId]; + if (!marker) { + return; + } + + const latLng = this.currentMarkerData[noteId].getLatLng(); + const url = `geo:${latLng.lat},${latLng.lng}`; + link.goToLinkExt(event, url); + } + + deleteFromMapEvent({ noteId }: EventData<"deleteFromMap">) { + this.moveMarker(noteId, null); + } + +} diff --git a/src/public/app/widgets/type_widgets/geo_map_context_menu.ts b/src/public/app/widgets/type_widgets/geo_map_context_menu.ts new file mode 100644 index 000000000..f19d655c4 --- /dev/null +++ b/src/public/app/widgets/type_widgets/geo_map_context_menu.ts @@ -0,0 +1,32 @@ +import appContext from "../../components/app_context.js"; +import type { ContextMenuEvent } from "../../menus/context_menu.js"; +import contextMenu from "../../menus/context_menu.js"; +import linkContextMenu from "../../menus/link_context_menu.js"; +import { t } from "../../services/i18n.js"; + +export default function openContextMenu(noteId: string, e: ContextMenuEvent) { + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items: [ + ...linkContextMenu.getItems(), + { title: t("geo-map-context.open-location"), command: "openGeoLocation", uiIcon: "bx bx-map-alt" }, + { title: "----" }, + { title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" } + ], + selectMenuItemHandler: ({ command }, e) => { + if (command === "deleteFromMap") { + appContext.triggerCommand(command, { noteId }); + return; + } + + if (command === "openGeoLocation") { + appContext.triggerCommand(command, { noteId, event: e }); + return; + } + + // Pass the events to the link context menu + linkContextMenu.handleLinkContextMenuItem(command, noteId); + } + }); +} diff --git a/src/public/translations/en/translation.json b/src/public/translations/en/translation.json index 1c65b78fa..b49188285 100644 --- a/src/public/translations/en/translation.json +++ b/src/public/translations/en/translation.json @@ -1409,7 +1409,8 @@ "launcher": "Launcher", "doc": "Doc", "widget": "Widget", - "confirm-change": "It is not recommended to change note type when note content is not empty. Do you want to continue anyway?" + "confirm-change": "It is not recommended to change note type when note content is not empty. Do you want to continue anyway?", + "geo-map": "Geo Map (beta)" }, "protect_note": { "toggle-on": "Protect the note", @@ -1629,5 +1630,14 @@ }, "note_tooltip": { "note-has-been-deleted": "Note has been deleted." + }, + "geo-map": { + "create-child-note-title": "Create a new child note and add it to the map", + "create-child-note-instruction": "Click on the map to create a new note at that location or press Escape to dismiss.", + "unable-to-load-map": "Unable to load map." + }, + "geo-map-context": { + "open-location": "Open location", + "remove-from-map": "Remove from map" } } diff --git a/src/public/translations/ro/translation.json b/src/public/translations/ro/translation.json index cf2ecfb7d..9ffb158fd 100644 --- a/src/public/translations/ro/translation.json +++ b/src/public/translations/ro/translation.json @@ -1379,7 +1379,8 @@ "image": "Imagine", "launcher": "Scurtătură", "widget": "Widget", - "confirm-change": "Nu se recomandă schimbarea tipului notiței atunci când ea are un conținut. Procedați oricum?" + "confirm-change": "Nu se recomandă schimbarea tipului notiței atunci când ea are un conținut. Procedați oricum?", + "geo-map": "Hartă geografică (beta)" }, "protect_note": { "toggle-off": "Deprotejează notița", @@ -1633,5 +1634,13 @@ "notes": { "duplicate-note-suffix": "(dupl.)", "duplicate-note-title": "{{ noteTitle }} {{ duplicateNoteSuffix }}" + }, + "geo-map-context": { + "open-location": "Deschide locația", + "remove-from-map": "Înlătură de pe hartă" + }, + "geo-map": { + "create-child-note-title": "Crează o notiță nouă și adaug-o pe hartă", + "unable-to-load-map": "Nu s-a putut încărca harta." } } diff --git a/src/routes/assets.ts b/src/routes/assets.ts index d70bbade5..a46801559 100644 --- a/src/routes/assets.ts +++ b/src/routes/assets.ts @@ -105,6 +105,8 @@ async function register(app: express.Application) { app.use(`/${assetPath}/node_modules/mind-elixir/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/mind-elixir/dist/"))); app.use(`/${assetPath}/node_modules/@mind-elixir/node-menu/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/@mind-elixir/node-menu/dist/"))); app.use(`/${assetPath}/node_modules/@highlightjs/cdn-assets/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/@highlightjs/cdn-assets/"))); + + app.use(`/${assetPath}/node_modules/leaflet/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/leaflet/dist/"))); } export default { diff --git a/src/services/note_types.ts b/src/services/note_types.ts index 4a810b6a2..3e086acf4 100644 --- a/src/services/note_types.ts +++ b/src/services/note_types.ts @@ -14,7 +14,8 @@ const noteTypes = [ { type: "launcher", defaultMime: "" }, { type: "doc", defaultMime: "" }, { type: "contentWidget", defaultMime: "" }, - { type: "mindMap", defaultMime: "application/json" } + { type: "mindMap", defaultMime: "application/json" }, + { type: "geoMap", defaultMime: "application/json" } ]; function getDefaultMimeForNoteType(typeName: string) { diff --git a/translations/ro/server.json b/translations/ro/server.json index 2f25db39d..bc6e7519a 100644 --- a/translations/ro/server.json +++ b/translations/ro/server.json @@ -248,5 +248,8 @@ "backend_log": { "log-does-not-exist": "Fișierul de loguri de backend „{{ fileName }}” nu există (încă).", "reading-log-failed": "Nu s-a putut citi fișierul de loguri de backend „{{fileName}}”." + }, + "geo-map": { + "create-child-note-instruction": "Clic pe hartă pentru a crea o nouă notiță la acea poziție sau apăsați Escape pentru a renunța." } }