Merge pull request #1017 from TriliumNext/feature/map_note_type

Map note type
This commit is contained in:
Elian Doran 2025-01-22 23:34:44 +02:00 committed by GitHub
commit 16b5eef650
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 605 additions and 61 deletions

View File

@ -100,7 +100,8 @@ const copy = async () => {
"node_modules/codemirror/keymap/", "node_modules/codemirror/keymap/",
"node_modules/mind-elixir/dist/", "node_modules/mind-elixir/dist/",
"node_modules/@highlightjs/cdn-assets/languages", "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) { for (const folder of nodeModulesFolder) {

17
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@mermaid-js/layout-elk": "0.1.7", "@mermaid-js/layout-elk": "0.1.7",
"@mind-elixir/node-menu": "1.0.3", "@mind-elixir/node-menu": "1.0.3",
"@triliumnext/express-partial-content": "1.0.1", "@triliumnext/express-partial-content": "1.0.1",
"@types/leaflet": "1.9.16",
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.5",
"archiver": "7.0.1", "archiver": "7.0.1",
"async-mutex": "0.5.0", "async-mutex": "0.5.0",
@ -67,6 +68,7 @@
"jsplumb": "2.15.6", "jsplumb": "2.15.6",
"katex": "0.16.21", "katex": "0.16.21",
"knockout": "3.5.1", "knockout": "3.5.1",
"leaflet": "1.9.4",
"mark.js": "8.11.1", "mark.js": "8.11.1",
"marked": "15.0.6", "marked": "15.0.6",
"mermaid": "11.4.1", "mermaid": "11.4.1",
@ -3847,6 +3849,15 @@
"@types/node": "*" "@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": { "node_modules/@types/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@ -11437,6 +11448,12 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/limiter": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",

View File

@ -61,6 +61,7 @@
"@mermaid-js/layout-elk": "0.1.7", "@mermaid-js/layout-elk": "0.1.7",
"@mind-elixir/node-menu": "1.0.3", "@mind-elixir/node-menu": "1.0.3",
"@triliumnext/express-partial-content": "1.0.1", "@triliumnext/express-partial-content": "1.0.1",
"@types/leaflet": "1.9.16",
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.5",
"archiver": "7.0.1", "archiver": "7.0.1",
"async-mutex": "0.5.0", "async-mutex": "0.5.0",
@ -112,6 +113,7 @@
"jsplumb": "2.15.6", "jsplumb": "2.15.6",
"katex": "0.16.21", "katex": "0.16.21",
"knockout": "3.5.1", "knockout": "3.5.1",
"leaflet": "1.9.4",
"mark.js": "8.11.1", "mark.js": "8.11.1",
"marked": "15.0.6", "marked": "15.0.6",
"mermaid": "11.4.1", "mermaid": "11.4.1",

View File

@ -116,7 +116,8 @@ export const ALLOWED_NOTE_TYPES = [
"book", "book",
"webView", "webView",
"code", "code",
"mindMap" "mindMap",
"geoMap"
] as const; ] as const;
export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number]; export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number];

View File

@ -23,6 +23,7 @@ import type LoadResults from "../services/load_results.js";
import type { Attribute } from "../services/attribute_parser.js"; import type { Attribute } from "../services/attribute_parser.js";
import type NoteTreeWidget from "../widgets/note_tree.js"; import type NoteTreeWidget from "../widgets/note_tree.js";
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js"; import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
import type { ContextMenuEvent } from "../menus/context_menu.js";
interface Layout { interface Layout {
getRootWidget: (appContext: AppContext) => RootWidget; getRootWidget: (appContext: AppContext) => RootWidget;
@ -69,6 +70,7 @@ export interface ExecuteCommandData extends CommandData {
*/ */
export type CommandMappings = { export type CommandMappings = {
"api-log-messages": CommandData; "api-log-messages": CommandData;
focusTree: CommandData,
focusOnDetail: Required<CommandData>; focusOnDetail: Required<CommandData>;
focusOnSearchDefinition: Required<CommandData>; focusOnSearchDefinition: Required<CommandData>;
searchNotes: CommandData & { searchNotes: CommandData & {
@ -193,6 +195,10 @@ export type CommandMappings = {
setZoomFactorAndSave: { setZoomFactorAndSave: {
zoomFactor: string; zoomFactor: string;
} }
// Geomap
deleteFromMap: { noteId: string },
openGeoLocation: { noteId: string, event: JQuery.MouseDownEvent }
}; };
type EventMappings = { type EventMappings = {
@ -227,9 +233,12 @@ type EventMappings = {
activeContextChanged: { activeContextChanged: {
noteContext: NoteContext; noteContext: NoteContext;
}; };
beforeNoteSwitch: {
noteContext: NoteContext;
};
noteSwitched: { noteSwitched: {
noteContext: NoteContext; noteContext: NoteContext;
notePath: string; notePath: string | null;
}; };
noteSwitchedAndActivatedEvent: { noteSwitchedAndActivatedEvent: {
noteContext: NoteContext; noteContext: NoteContext;
@ -248,12 +257,16 @@ type EventMappings = {
noteId: string; noteId: string;
}; };
hoistedNoteChanged: { hoistedNoteChanged: {
ntxId: string; noteId: string;
ntxId: string | null;
}; };
contextsReopenedEvent: { contextsReopenedEvent: {
mainNtxId: string; mainNtxId: string;
tabPosition: number; tabPosition: number;
}; };
noteDetailRefreshed: {
ntxId?: string | null;
};
noteContextReorderEvent: { noteContextReorderEvent: {
oldMainNtxId: string; oldMainNtxId: string;
newMainNtxId: string; newMainNtxId: string;
@ -266,7 +279,13 @@ type EventMappings = {
}; };
exportSvg: { exportSvg: {
ntxId: string; ntxId: string;
} };
geoMapCreateChildNote: {
ntxId: string | null | undefined; // TODO: deduplicate ntxId
};
tabReorder: {
ntxIdsInOrder: string[]
};
}; };
export type EventListener<T extends EventNames> = { export type EventListener<T extends EventNames> = {

View File

@ -61,7 +61,7 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
} }
} }
triggerEvent(name: string, data = {}): Promise<unknown> | undefined | null { triggerEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown> | undefined | null {
return this.parent?.triggerEvent(name, data); return this.parent?.triggerEvent(name, data);
} }

View File

@ -27,7 +27,8 @@ const NOTE_TYPE_ICONS = {
launcher: "bx bx-link", launcher: "bx bx-link",
doc: "bx bxs-file-doc", doc: "bx bxs-file-doc",
contentWidget: "bx bxs-widget", 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 * end user. Those types should be used only for checking against, they are
* not for direct use. * 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 { interface NotePathRecord {
isArchived: boolean; isArchived: boolean;

View File

@ -85,6 +85,7 @@ import ScrollPaddingWidget from "../widgets/scroll_padding.js";
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js"; import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
import options from "../services/options.js"; import options from "../services/options.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
export default class DesktopLayout { export default class DesktopLayout {
constructor(customWidgets) { constructor(customWidgets) {
@ -200,6 +201,7 @@ export default class DesktopLayout {
.child(new ShowHighlightsListWidgetButton()) .child(new ShowHighlightsListWidgetButton())
.child(new CodeButtonsWidget()) .child(new CodeButtonsWidget())
.child(new RelationMapButtons()) .child(new RelationMapButtons())
.child(new GeoMapButtons())
.child(new CopyImageReferenceButton()) .child(new CopyImageReferenceButton())
.child(new SvgExportButton()) .child(new SvgExportButton())
.child(new BacklinksWidget()) .child(new BacklinksWidget())

View File

@ -1,5 +1,6 @@
import type { CommandNames } from "../components/app_context.js"; 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 utils from "../services/utils.js"; import utils from "../services/utils.js";
interface ContextMenuOptions<T extends CommandNames> { interface ContextMenuOptions<T extends CommandNames> {
@ -31,6 +32,7 @@ export interface MenuCommandItem<T extends CommandNames> {
export type MenuItem<T extends CommandNames> = MenuCommandItem<T> | MenuSeparatorItem; 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 MenuHandler<T extends CommandNames> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
class ContextMenu { class ContextMenu {
private $widget: JQuery<HTMLElement>; private $widget: JQuery<HTMLElement>;
@ -56,6 +58,8 @@ class ContextMenu {
async show<T extends CommandNames>(options: ContextMenuOptions<T>) { async show<T extends CommandNames>(options: ContextMenuOptions<T>) {
this.options = options; this.options = options;
note_tooltip.dismissAllTooltips();
if (this.$widget.hasClass("show")) { if (this.$widget.hasClass("show")) {
// The menu is already visible. Hide the menu then open it again // The menu is already visible. Hide the menu then open it again
// at the new location to re-trigger the opening animation. // at the new location to re-trigger the opening animation.

View File

@ -1,18 +1,26 @@
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import contextMenu from "./context_menu.js"; import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
import appContext from "../components/app_context.js"; import appContext, { type CommandNames } from "../components/app_context.js";
import type { ViewScope } from "../services/link.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({ contextMenu.show({
x: e.pageX, x: e.pageX,
y: e.pageY, y: e.pageY,
items: [ items: getItems(),
selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, notePath, viewScope, hoistedNoteId)
});
}
function getItems(): MenuItem<CommandNames>[] {
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_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_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" } { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" }
], ];
selectMenuItemHandler: ({ command }) => { }
function handleLinkContextMenuItem(command: string | undefined, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
if (!hoistedNoteId) { if (!hoistedNoteId) {
hoistedNoteId = appContext.tabManager.getActiveContext().hoistedNoteId; hoistedNoteId = appContext.tabManager.getActiveContext().hoistedNoteId;
} }
@ -28,9 +36,9 @@ function openContextMenu(notePath: string, e: PointerEvent | MouseEvent | JQuery
appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
} }
} }
});
}
export default { export default {
getItems,
handleLinkContextMenuItem,
openContextMenu openContextMenu
}; };

View File

@ -106,6 +106,10 @@ const HIGHLIGHT_JS: Library = {
} }
}; };
const LEAFLET: Library = {
css: [ "node_modules/leaflet/dist/leaflet.css" ],
}
async function requireLibrary(library: Library) { async function requireLibrary(library: Library) {
if (library.css) { if (library.css) {
library.css.map((cssUrl) => requireCss(cssUrl)); library.css.map((cssUrl) => requireCss(cssUrl));
@ -196,5 +200,6 @@ export default {
MERMAID, MERMAID,
MARKJS, MARKJS,
I18NEXT, I18NEXT,
HIGHLIGHT_JS HIGHLIGHT_JS,
LEAFLET
}; };

View File

@ -234,7 +234,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent) {
return goToLinkExt(evt, hrefLink, $link); return goToLinkExt(evt, hrefLink, $link);
} }
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | React.PointerEvent<HTMLCanvasElement>, hrefLink: string | undefined, $link: JQuery<HTMLElement> | null) { function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement>, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
if (hrefLink?.startsWith("data:")) { if (hrefLink?.startsWith("data:")) {
return true; return true;
} }

View File

@ -18,11 +18,11 @@ function setupGlobalTooltip() {
return; return;
} }
cleanUpTooltips(); dismissAllTooltips();
}); });
} }
function cleanUpTooltips() { function dismissAllTooltips() {
$(".note-tooltip").remove(); $(".note-tooltip").remove();
} }
@ -102,12 +102,12 @@ async function mouseEnterHandler(this: HTMLElement) {
customClass: linkId customClass: linkId
}); });
cleanUpTooltips(); dismissAllTooltips();
$(this).tooltip("show"); $(this).tooltip("show");
// Dismiss the tooltip immediately if a link was clicked inside the tooltip. // Dismiss the tooltip immediately if a link was clicked inside the tooltip.
$(`.${tooltipClass} a`).on("click", (e) => { $(`.${tooltipClass} a`).on("click", (e) => {
cleanUpTooltips(); dismissAllTooltips();
}); });
// the purpose of the code below is to: // the purpose of the code below is to:
@ -117,7 +117,7 @@ async function mouseEnterHandler(this: HTMLElement) {
const checkTooltip = () => { const checkTooltip = () => {
if (!$(this).filter(":hover").length && !$(`.${linkId}:hover`).length) { if (!$(this).filter(":hover").length && !$(`.${linkId}:hover`).length) {
// cursor is neither over the link nor over the tooltip, user likely is not interested // cursor is neither over the link nor over the tooltip, user likely is not interested
cleanUpTooltips(); dismissAllTooltips();
} else { } else {
setTimeout(checkTooltip, 1000); setTimeout(checkTooltip, 1000);
} }
@ -172,5 +172,6 @@ function renderFootnote($link: JQuery<HTMLElement>, url: string) {
export default { export default {
setupGlobalTooltip, setupGlobalTooltip,
setupElementTooltip setupElementTooltip,
dismissAllTooltips
}; };

View File

@ -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.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.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.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<string[]>("search-templates"); const templateNoteIds = await server.get<string[]>("search-templates");

View File

@ -154,7 +154,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book"].includes(note.type)); this.toggleDisabled(this.$findInTextButton, ["text", "code", "book"].includes(note.type));
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions); 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)); this.toggleDisabled(this.$printActiveNoteButton, ["text", "code"].includes(note.type));

View File

@ -22,7 +22,7 @@ export default class LeftPaneContainer extends FlexContainer<Component> {
this.toggleInt(visible); this.toggleInt(visible);
if (visible) { if (visible) {
this.triggerEvent("focusTree"); this.triggerEvent("focusTree", {});
} else { } else {
const activeNoteContext = appContext.tabManager.getActiveContext(); const activeNoteContext = appContext.tabManager.getActiveContext();
this.triggerEvent("focusOnDetail", { ntxId: activeNoteContext.ntxId }); this.triggerEvent("focusOnDetail", { ntxId: activeNoteContext.ntxId });

View File

@ -0,0 +1,42 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js"
const TPL = `\
<div class="geo-map-buttons">
<style>
.geo-map-buttons {
display: flex;
gap: 10px;
}
.leaflet-pane {
z-index: 50;
}
.geo-map-buttons {
contain: none;
background: var(--main-background-color);
box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
border-radius: 4px;
}
</style>
<button type="button"
class="geo-map-create-child-note floating-button btn bx bx-folder-plus"
title="${t("geo-map.create-child-note-title")}" />
</div>`;
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 }));
}
}

View File

@ -43,6 +43,7 @@ export default class RelationMapButtons extends NoteContextAwareWidget {
this.$zoomOutButton = this.$widget.find(".relation-map-zoom-out"); this.$zoomOutButton = this.$widget.find(".relation-map-zoom-out");
this.$resetPanZoomButton = this.$widget.find(".relation-map-reset-pan-zoom"); 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.$createChildNote.on("click", () => this.triggerEvent("relationMapCreateChildNote", { ntxId: this.ntxId }));
this.$resetPanZoomButton.on("click", () => this.triggerEvent("relationMapResetPanZoom", { ntxId: this.ntxId })); this.$resetPanZoomButton.on("click", () => this.triggerEvent("relationMapResetPanZoom", { ntxId: this.ntxId }));

View File

@ -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 = `\
<div class="geo-map-widget">
<style>
.note-detail-geo-map,
.geo-map-widget,
.geo-map-container {
height: 100%;
overflow: hidden;
}
</style>
<div class="geo-map-container"></div>
</div>`
export type Leaflet = typeof import("leaflet");
export type InitCallback = ((L: Leaflet) => void);
export default class GeoMapWidget extends NoteContextAwareWidget {
map?: Map;
$container!: JQuery<HTMLElement>;
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
});
}
}

View File

@ -31,6 +31,7 @@ import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js"; import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
import MindMapWidget from "./type_widgets/mind_map.js"; import MindMapWidget from "./type_widgets/mind_map.js";
import { getStylesheetUrl, isSyntaxHighlightEnabled } from "../services/syntax_highlight.js"; import { getStylesheetUrl, isSyntaxHighlightEnabled } from "../services/syntax_highlight.js";
import GeoMapTypeWidget from "./type_widgets/geo_map.js";
const TPL = ` const TPL = `
<div class="note-detail"> <div class="note-detail">
@ -67,7 +68,8 @@ const typeWidgetClasses = {
contentWidget: ContentWidgetTypeWidget, contentWidget: ContentWidgetTypeWidget,
attachmentDetail: AttachmentDetailTypeWidget, attachmentDetail: AttachmentDetailTypeWidget,
attachmentList: AttachmentListTypeWidget, attachmentList: AttachmentListTypeWidget,
mindMap: MindMapWidget mindMap: MindMapWidget,
geoMap: GeoMapTypeWidget
}; };
export default class NoteDetailWidget extends NoteContextAwareWidget { export default class NoteDetailWidget extends NoteContextAwareWidget {
@ -147,7 +149,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
// https://github.com/zadam/trilium/issues/2522 // https://github.com/zadam/trilium/issues/2522
this.$widget.toggleClass( this.$widget.toggleClass(
"full-height", "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" this.noteContext.viewScope.viewMode === "attachments"
); );
} }

View File

@ -41,7 +41,7 @@ export default class NoteWrapperWidget extends FlexContainer {
return; 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()); this.$widget.addClass(note.getCssClass());

View File

@ -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 = `\
<div class="note-detail-geo-map note-detail-printable">
<style>
.leaflet-pane {
z-index: 1;
}
.geo-map-container.placing-note {
cursor: crosshair;
}
.geo-map-container .marker-pin {
position: relative;
}
.geo-map-container .leaflet-div-icon {
position: relative;
background: transparent;
border: 0;
overflow: visible;
}
.geo-map-container .leaflet-div-icon .icon-shadow {
position: absolute;
top: 0;
left: 0;
z-index: -1;
}
.geo-map-container .leaflet-div-icon .bx {
position: absolute;
top: 3px;
left: 2px;
background-color: white;
color: black;
padding: 2px;
border-radius: 50%;
font-size: 17px;
}
.geo-map-container .leaflet-div-icon .title-label {
display: block;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
height: 1rem;
color: black;
width: 100px;
text-align: center;
text-overflow: ellipsis;
text-shadow: -1px -1px 0 white, 1px -1px 0 white, -1px 1px 0 white, 1px 1px 0 white;
white-space: no-wrap;
overflow: hidden;
}
</style>
</div>`;
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<string, Marker>;
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: `\
<img class="icon" src="${asset_path}/node_modules/leaflet/dist/images/marker-icon.png" />
<img class="icon-shadow" src="${asset_path}/node_modules/leaflet/dist/images/marker-shadow.png" />
<span class="bx ${childNote.getIcon()}"></span>
<span class="title-label">${childNote.title}</span>`,
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<CreateChildResponse>(`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);
}
}

View File

@ -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);
}
});
}

View File

@ -1409,7 +1409,8 @@
"launcher": "Launcher", "launcher": "Launcher",
"doc": "Doc", "doc": "Doc",
"widget": "Widget", "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": { "protect_note": {
"toggle-on": "Protect the note", "toggle-on": "Protect the note",
@ -1629,5 +1630,14 @@
}, },
"note_tooltip": { "note_tooltip": {
"note-has-been-deleted": "Note has been deleted." "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"
} }
} }

View File

@ -1379,7 +1379,8 @@
"image": "Imagine", "image": "Imagine",
"launcher": "Scurtătură", "launcher": "Scurtătură",
"widget": "Widget", "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": { "protect_note": {
"toggle-off": "Deprotejează notița", "toggle-off": "Deprotejează notița",
@ -1633,5 +1634,13 @@
"notes": { "notes": {
"duplicate-note-suffix": "(dupl.)", "duplicate-note-suffix": "(dupl.)",
"duplicate-note-title": "{{ noteTitle }} {{ duplicateNoteSuffix }}" "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."
} }
} }

View File

@ -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/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/@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/@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 { export default {

View File

@ -14,7 +14,8 @@ const noteTypes = [
{ type: "launcher", defaultMime: "" }, { type: "launcher", defaultMime: "" },
{ type: "doc", defaultMime: "" }, { type: "doc", defaultMime: "" },
{ type: "contentWidget", defaultMime: "" }, { type: "contentWidget", defaultMime: "" },
{ type: "mindMap", defaultMime: "application/json" } { type: "mindMap", defaultMime: "application/json" },
{ type: "geoMap", defaultMime: "application/json" }
]; ];
function getDefaultMimeForNoteType(typeName: string) { function getDefaultMimeForNoteType(typeName: string) {

View File

@ -248,5 +248,8 @@
"backend_log": { "backend_log": {
"log-does-not-exist": "Fișierul de loguri de backend „{{ fileName }}” nu există (încă).", "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}}”." "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."
} }
} }