Merge branch 'feature/geomap_collection' of https://github.com/TriliumNext/trilium into feature/geomap_collection

This commit is contained in:
Elian Doran 2025-07-07 19:20:24 +03:00
commit 9df7d6227e
No known key found for this signature in database
16 changed files with 252 additions and 88 deletions

View File

@ -261,7 +261,6 @@ export type CommandMappings = {
// Geomap
deleteFromMap: { noteId: string };
openGeoLocation: { noteId: string; event: JQuery.MouseDownEvent };
toggleZenMode: CommandData;

View File

@ -277,13 +277,13 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
return goToLinkExt(evt, hrefLink, $link);
}
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement>, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
if (hrefLink?.startsWith("data:")) {
return true;
}
evt.preventDefault();
evt.stopPropagation();
evt?.preventDefault();
evt?.stopPropagation();
if (hrefLink && hrefLink.startsWith("#") && !hrefLink.startsWith("#root/") && $link) {
if (handleAnchor(hrefLink, $link)) {
@ -293,14 +293,14 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
const { notePath, viewScope } = parseNavigationStateFromUrl(hrefLink);
const ctrlKey = utils.isCtrlKey(evt);
const shiftKey = evt.shiftKey;
const isLeftClick = "which" in evt && evt.which === 1;
const isMiddleClick = "which" in evt && evt.which === 2;
const ctrlKey = evt && utils.isCtrlKey(evt);
const shiftKey = evt?.shiftKey;
const isLeftClick = !evt || ("which" in evt && evt.which === 1);
const isMiddleClick = evt && "which" in evt && evt.which === 2;
const targetIsBlank = ($link?.attr("target") === "_blank");
const openInNewTab = (isLeftClick && ctrlKey) || isMiddleClick || targetIsBlank;
const activate = (isLeftClick && ctrlKey && shiftKey) || (isMiddleClick && shiftKey);
const openInNewWindow = isLeftClick && evt.shiftKey && !ctrlKey;
const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey;
if (notePath) {
if (openInNewWindow) {
@ -311,7 +311,7 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent
viewScope
});
} else if (isLeftClick) {
const ntxId = $(evt.target as any)
const ntxId = $(evt?.target as any)
.closest("[data-ntx-id]")
.attr("data-ntx-id");

View File

@ -1860,7 +1860,8 @@
},
"geo-map-context": {
"open-location": "Open location",
"remove-from-map": "Remove from map"
"remove-from-map": "Remove from map",
"add-note": "Add a marker at this location"
},
"help-button": {
"title": "Open the relevant help page"

View File

@ -23,7 +23,9 @@ const TPL = /*html*/`\
export default class GeoMapButtons extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled() && this.note?.getLabelValue("viewType") === "geoMap";
return super.isEnabled()
&& this.note?.getLabelValue("viewType") === "geoMap"
&& !this.note.hasLabel("readOnly");
}
doRender() {

View File

@ -17,7 +17,6 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
contentWidget: null,
doc: null,
file: null,
geoMap: "81SGnPGMk7Xc",
image: null,
launcher: null,
mermaid: null,
@ -35,7 +34,8 @@ export const byBookType: Record<ViewTypeOptions, string | null> = {
list: null,
grid: null,
calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29"
table: "2FvYrpmOXm29",
geoMap: "81SGnPGMk7Xc"
};
export default class ContextualHelpButton extends NoteContextAwareWidget {

View File

@ -39,10 +39,20 @@ export default class ToggleReadOnlyButton extends OnClickButtonWidget {
}
isEnabled() {
return super.isEnabled()
&& this.note?.type === "mermaid"
&& this.note?.isContentAvailable()
&& this.noteContext?.viewScope?.viewMode === "default";
if (!super.isEnabled()) {
return false;
}
if (!this?.note?.isContentAvailable()) {
return false;
}
if (this.noteContext?.viewScope?.viewMode !== "default") {
return false;
}
return this.note.type === "mermaid" ||
(this.note.getLabelValue("viewType") === "geoMap");
}
}

View File

@ -186,6 +186,12 @@ interface RefreshContext {
noteIdsToReload: Set<string>;
}
export interface DragData {
noteId: string;
branchId: string;
title: string;
}
export default class NoteTreeWidget extends NoteContextAwareWidget {
private $tree!: JQuery<HTMLElement>;
private $treeActions!: JQuery<HTMLElement>;

View File

@ -127,7 +127,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
return;
}
if (!["list", "grid", "calendar", "table"].includes(type)) {
if (!["list", "grid", "calendar", "table", "geoMap"].includes(type)) {
throw new Error(t("book_properties.invalid_view_type", { type }));
}

View File

@ -1,32 +1,85 @@
import appContext from "../../../components/app_context.js";
import type { ContextMenuEvent } from "../../../menus/context_menu.js";
import contextMenu from "../../../menus/context_menu.js";
import type { LatLng, LeafletMouseEvent } from "leaflet";
import appContext, { type CommandMappings } from "../../../components/app_context.js";
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
import linkContextMenu from "../../../menus/link_context_menu.js";
import { t } from "../../../services/i18n.js";
import { createNewNote } from "./editing.js";
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import link from "../../../services/link.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" },
export default function openContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
let items: MenuItem<keyof CommandMappings>[] = [
...buildGeoLocationItem(e),
{ title: "----" },
...linkContextMenu.getItems(),
];
if (isEditable) {
items = [
...items,
{ title: "----" },
{ title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" }
],
];
}
contextMenu.show({
x: e.originalEvent.pageX,
y: e.originalEvent.pageY,
items,
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);
}
});
}
export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
let items: MenuItem<keyof CommandMappings>[] = [
...buildGeoLocationItem(e)
];
if (isEditable) {
items = [
...items,
{ title: "----" },
{
title: t("geo-map-context.add-note"),
handler: () => createNewNote(noteId, e),
uiIcon: "bx bx-plus"
}
]
}
contextMenu.show({
x: e.originalEvent.pageX,
y: e.originalEvent.pageY,
items,
selectMenuItemHandler: () => {
// Nothing to do, as the commands handle themselves.
}
});
}
function buildGeoLocationItem(e: LeafletMouseEvent) {
function formatGeoLocation(latlng: LatLng, precision: number = 6) {
return `${latlng.lat.toFixed(precision)}, ${latlng.lng.toFixed(precision)}`;
}
return [
{
title: formatGeoLocation(e.latlng),
uiIcon: "bx bx-current-location",
handler: () => copyTextWithToast(formatGeoLocation(e.latlng, 15))
},
{
title: t("geo-map-context.open-location"),
uiIcon: "bx bx-map-alt",
handler: () => link.goToLinkExt(null, `geo:${e.latlng.lat},${e.latlng.lng}`)
}
];
}

View File

@ -1,8 +1,80 @@
import { LatLng } from "leaflet";
import { LatLng, LeafletMouseEvent } from "leaflet";
import attributes from "../../../services/attributes";
import { LOCATION_ATTRIBUTE } from "./index.js";
import dialog from "../../../services/dialog";
import server from "../../../services/server";
import { t } from "../../../services/i18n";
import type { Map } from "leaflet";
import type { DragData } from "../../note_tree.js";
import froca from "../../../services/froca.js";
import branches from "../../../services/branches.js";
const CHILD_NOTE_ICON = "bx bx-pin";
// TODO: Deduplicate
interface CreateChildResponse {
note: {
noteId: string;
};
}
export async function moveMarker(noteId: string, latLng: LatLng | null) {
const value = latLng ? [latLng.lat, latLng.lng].join(",") : "";
await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
}
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
const title = await dialog.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/${noteId}/children?target=into`, {
title,
content: "",
type: "text"
});
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
moveMarker(note.noteId, e.latlng);
}
}
export function setupDragging($container: JQuery<HTMLElement>, map: Map, mapNoteId: string) {
$container.on("dragover", (e) => {
// Allow drag.
e.preventDefault();
});
$container.on("drop", async (e) => {
if (!e.originalEvent) {
return;
}
const data = e.originalEvent.dataTransfer?.getData('text');
if (!data) {
return;
}
try {
const parsedData = JSON.parse(data) as DragData[];
if (!parsedData.length) {
return;
}
const { noteId } = parsedData[0];
const offset = $container.offset();
const x = e.originalEvent.clientX - (offset?.left ?? 0);
const y = e.originalEvent.clientY - (offset?.top ?? 0);
const latlng = map.containerPointToLatLng([ x, y ]);
const note = await froca.getNote(noteId, true);
const parents = note?.getParentNoteIds();
if (parents?.includes(mapNoteId)) {
await moveMarker(noteId, latlng);
} else {
await branches.cloneNoteToParentNote(noteId, mapNoteId);
await moveMarker(noteId, latlng);
}
} catch (e) {
console.warn(e);
}
});
}

View File

@ -8,18 +8,8 @@ import processNoteWithMarker, { processNoteWithGpxTrack } from "./markers.js";
import { hasTouchBar } from "../../../services/utils.js";
import toast from "../../../services/toast.js";
import { CommandListenerData, EventData } from "../../../components/app_context.js";
import dialog from "../../../services/dialog.js";
import server from "../../../services/server.js";
import attributes from "../../../services/attributes.js";
import { moveMarker } from "./editing.js";
import link from "../../../services/link.js";
// TODO: Deduplicate
interface CreateChildResponse {
note: {
noteId: string;
};
}
import { createNewNote, moveMarker, setupDragging } from "./editing.js";
import { openMapContextMenu } from "./context_menu.js";
const TPL = /*html*/`
<div class="geo-view">
@ -103,7 +93,6 @@ interface MapData {
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
const DEFAULT_ZOOM = 2;
export const LOCATION_ATTRIBUTE = "geolocation";
const CHILD_NOTE_ICON = "bx bx-pin";
enum State {
Normal,
@ -162,10 +151,16 @@ export default class GeoView extends ViewMode<MapData> {
this.#restoreViewportAndZoom();
const isEditable = !this.isReadOnly;
const updateFn = () => this.spacedUpdate.scheduleUpdate();
map.on("moveend", updateFn);
map.on("zoomend", updateFn);
map.on("click", (e) => this.#onMapClicked(e));
map.on("click", (e) => this.#onMapClicked(e))
map.on("contextmenu", (e) => openMapContextMenu(this.parentNote.noteId, e, isEditable));
if (isEditable) {
setupDragging(this.$container, map, this.parentNote.noteId);
}
this.#reloadMarkers();
@ -227,6 +222,7 @@ export default class GeoView extends ViewMode<MapData> {
// Add the new markers.
this.currentMarkerData = {};
const notes = await this.parentNote.getChildNotes();
const draggable = !this.isReadOnly;
for (const childNote of notes) {
if (childNote.mime === "application/gpx+xml") {
const track = await processNoteWithGpxTrack(this.map, childNote);
@ -236,7 +232,7 @@ export default class GeoView extends ViewMode<MapData> {
const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE);
if (latLng) {
const marker = processNoteWithMarker(this.map, childNote, latLng);
const marker = processNoteWithMarker(this.map, childNote, latLng, draggable);
this.currentMarkerData[childNote.noteId] = marker;
}
}
@ -298,32 +294,10 @@ export default class GeoView extends ViewMode<MapData> {
}
toast.closePersistent("geo-new-note");
const title = await dialog.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.parentNote.noteId}/children?target=into`, {
title,
content: "",
type: "text"
});
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
moveMarker(note.noteId, e.latlng);
}
await createNewNote(this.parentNote.noteId, e);
this.#changeState(State.Normal);
}
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">) {
moveMarker(noteId, null);
}

View File

@ -5,34 +5,39 @@ import type FNote from "../../../entities/fnote.js";
import openContextMenu from "./context_menu.js";
import server from "../../../services/server.js";
import { moveMarker } from "./editing.js";
import appContext from "../../../components/app_context.js";
import L from "leaflet";
let gpxLoaded = false;
export default function processNoteWithMarker(map: Map, note: FNote, location: string) {
export default function processNoteWithMarker(map: Map, note: FNote, location: string, isEditable: boolean) {
const [lat, lng] = location.split(",", 2).map((el) => parseFloat(el));
const icon = buildIcon(note.getIcon(), note.getColorClass(), note.title, note.noteId);
const newMarker = marker(latLng(lat, lng), {
icon,
draggable: true,
draggable: isEditable,
autoPan: true,
autoPanSpeed: 5
})
.addTo(map)
.on("moveend", (e) => {
}).addTo(map);
if (isEditable) {
newMarker.on("moveend", (e) => {
moveMarker(note.noteId, (e.target as Marker).getLatLng());
});
}
newMarker.on("mousedown", ({ originalEvent }) => {
// Middle click to open in new tab
if (originalEvent.button === 1) {
const hoistedNoteId = this.hoistedNoteId;
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
//@ts-ignore, fix once tab manager is ported.
appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId);
return true;
}
});
newMarker.on("contextmenu", (e) => {
openContextMenu(note.noteId, e.originalEvent);
openContextMenu(note.noteId, e, isEditable);
});
return newMarker;
@ -40,7 +45,7 @@ export default function processNoteWithMarker(map: Map, note: FNote, location: s
export async function processNoteWithGpxTrack(map: Map, note: FNote) {
if (!gpxLoaded) {
await import("leaflet-gpx");
const GPX = await import("leaflet-gpx");
gpxLoaded = true;
}

View File

@ -44,6 +44,10 @@ export default abstract class ViewMode<T extends object> extends Component {
return false;
}
get isReadOnly() {
return this.parentNote.hasLabel("readOnly");
}
get viewStorage() {
if (this._viewStorage) {
return this._viewStorage;

Binary file not shown.

View File

@ -70,6 +70,19 @@ describe("processNoteContent", () => {
expect(content).toContain(`<a class="reference-link" href="#root/${shopNote.noteId}`);
expect(content).toContain(`<img src="api/images/${bananaNote!.noteId}/banana.jpeg`);
});
it("can import old geomap notes", async () => {
const { importedNote } = await testImport("geomap.zip");
expect(importedNote.type).toBe("book");
expect(importedNote.mime).toBe("");
expect(importedNote.getRelationValue("template")).toBe("_template_geo_map");
const attachment = importedNote.getAttachmentsByRole("viewConfig")[0];
expect(attachment.title).toBe("geoMap.json");
expect(attachment.mime).toBe("application/json");
const content = attachment.getContent();
expect(content).toStrictEqual(`{"view":{"center":{"lat":49.19598332223546,"lng":-2.1414576506668808},"zoom":12}}`);
});
});
function getNoteByTitlePath(parentNote: BNote, ...titlePath: string[]) {

View File

@ -502,6 +502,28 @@ async function importZip(taskContext: TaskContext, fileBuffer: Buffer, importRoo
firstNote = firstNote || note;
}
} else {
if (detectedType as string === "geoMap") {
attributes.push({
noteId,
type: "relation",
name: "template",
value: "_template_geo_map"
});
const attachment = new BAttachment({
attachmentId: getNewAttachmentId(newEntityId()),
ownerId: noteId,
title: "geoMap.json",
role: "viewConfig",
mime: "application/json",
position: 0
});
attachment.setContent(content, { forceSave: true });
content = "";
mime = "";
}
({ note } = noteService.createNewNote({
parentNoteId: parentNoteId,
title: noteTitle || "",
@ -656,12 +678,15 @@ export function readZipFile(buffer: Buffer, processEntryCallback: (zipfile: yauz
function resolveNoteType(type: string | undefined): NoteType {
// BC for ZIPs created in Trilium 0.57 and older
if (type === "relation-map") {
return "relationMap";
} else if (type === "note-map") {
return "noteMap";
} else if (type === "web-view") {
return "webView";
switch (type) {
case "relation-map":
return "relationMap";
case "note-map":
return "noteMap";
case "web-view":
return "webView";
case "geoMap":
return "book";
}
if (type && (ALLOWED_NOTE_TYPES as readonly string[]).includes(type)) {