diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 443014572..161846dde 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -261,7 +261,6 @@ export type CommandMappings = { // Geomap deleteFromMap: { noteId: string }; - openGeoLocation: { noteId: string; event: JQuery.MouseDownEvent }; toggleZenMode: CommandData; diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index 41533647c..107a9c9e3 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -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, hrefLink: string | undefined, $link?: JQuery | null) { +function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent | null, hrefLink: string | undefined, $link?: JQuery | 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"); diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 4470852a9..00cb9f227 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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" diff --git a/apps/client/src/widgets/floating_buttons/geo_map_button.ts b/apps/client/src/widgets/floating_buttons/geo_map_button.ts index 945be41d1..7e59eeaf2 100644 --- a/apps/client/src/widgets/floating_buttons/geo_map_button.ts +++ b/apps/client/src/widgets/floating_buttons/geo_map_button.ts @@ -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() { diff --git a/apps/client/src/widgets/floating_buttons/help_button.ts b/apps/client/src/widgets/floating_buttons/help_button.ts index f0403bfd7..31b031c9d 100644 --- a/apps/client/src/widgets/floating_buttons/help_button.ts +++ b/apps/client/src/widgets/floating_buttons/help_button.ts @@ -17,7 +17,6 @@ export const byNoteType: Record, string | null> = { contentWidget: null, doc: null, file: null, - geoMap: "81SGnPGMk7Xc", image: null, launcher: null, mermaid: null, @@ -35,7 +34,8 @@ export const byBookType: Record = { list: null, grid: null, calendar: "xWbu3jpNWapp", - table: "2FvYrpmOXm29" + table: "2FvYrpmOXm29", + geoMap: "81SGnPGMk7Xc" }; export default class ContextualHelpButton extends NoteContextAwareWidget { diff --git a/apps/client/src/widgets/floating_buttons/toggle_read_only_button.ts b/apps/client/src/widgets/floating_buttons/toggle_read_only_button.ts index f436c820c..571e99017 100644 --- a/apps/client/src/widgets/floating_buttons/toggle_read_only_button.ts +++ b/apps/client/src/widgets/floating_buttons/toggle_read_only_button.ts @@ -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"); } } diff --git a/apps/client/src/widgets/note_tree.ts b/apps/client/src/widgets/note_tree.ts index 46abe10a0..ea6e50325 100644 --- a/apps/client/src/widgets/note_tree.ts +++ b/apps/client/src/widgets/note_tree.ts @@ -186,6 +186,12 @@ interface RefreshContext { noteIdsToReload: Set; } +export interface DragData { + noteId: string; + branchId: string; + title: string; +} + export default class NoteTreeWidget extends NoteContextAwareWidget { private $tree!: JQuery; private $treeActions!: JQuery; diff --git a/apps/client/src/widgets/ribbon_widgets/book_properties.ts b/apps/client/src/widgets/ribbon_widgets/book_properties.ts index f552bc280..655512f9e 100644 --- a/apps/client/src/widgets/ribbon_widgets/book_properties.ts +++ b/apps/client/src/widgets/ribbon_widgets/book_properties.ts @@ -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 })); } diff --git a/apps/client/src/widgets/view_widgets/geo_view/context_menu.ts b/apps/client/src/widgets/view_widgets/geo_view/context_menu.ts index 1e1a4b3d7..26d91df27 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/context_menu.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/context_menu.ts @@ -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[] = [ + ...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[] = [ + ...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}`) + } + ]; +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/editing.ts b/apps/client/src/widgets/view_widgets/geo_view/editing.ts index 863305ebc..c9dd7368c 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/editing.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/editing.ts @@ -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(`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, 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); + } + }); +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index cd98e38c0..41608dbc2 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -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*/`
@@ -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 { 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 { // 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 { 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 { } 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(`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); } diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts index 1d335cfcb..448da11d9 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/markers.ts @@ -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; } diff --git a/apps/client/src/widgets/view_widgets/view_mode.ts b/apps/client/src/widgets/view_widgets/view_mode.ts index d8c4314eb..f3706da4a 100644 --- a/apps/client/src/widgets/view_widgets/view_mode.ts +++ b/apps/client/src/widgets/view_widgets/view_mode.ts @@ -44,6 +44,10 @@ export default abstract class ViewMode extends Component { return false; } + get isReadOnly() { + return this.parentNote.hasLabel("readOnly"); + } + get viewStorage() { if (this._viewStorage) { return this._viewStorage; diff --git a/apps/server/src/services/import/samples/geomap.zip b/apps/server/src/services/import/samples/geomap.zip new file mode 100644 index 000000000..6443c1997 Binary files /dev/null and b/apps/server/src/services/import/samples/geomap.zip differ diff --git a/apps/server/src/services/import/zip.spec.ts b/apps/server/src/services/import/zip.spec.ts index 4860f7b94..db2c7ba76 100644 --- a/apps/server/src/services/import/zip.spec.ts +++ b/apps/server/src/services/import/zip.spec.ts @@ -70,6 +70,19 @@ describe("processNoteContent", () => { expect(content).toContain(` { + 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[]) { diff --git a/apps/server/src/services/import/zip.ts b/apps/server/src/services/import/zip.ts index e7da680bb..b2d83bdc6 100644 --- a/apps/server/src/services/import/zip.ts +++ b/apps/server/src/services/import/zip.ts @@ -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)) {