Merge remote-tracking branch 'origin/main' into renovate/mind-elixir-5.x

This commit is contained in:
Elian Doran
2025-07-07 20:50:23 +03:00
107 changed files with 1563 additions and 1062 deletions

View File

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

View File

@@ -326,7 +326,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
}
// Some book types must always display a note list, even if no children.
if (["calendar", "table"].includes(note.getLabelValue("viewType") ?? "")) {
if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) {
return true;
}

View File

@@ -27,7 +27,6 @@ const NOTE_TYPE_ICONS = {
doc: "bx bxs-file-doc",
contentWidget: "bx bxs-widget",
mindMap: "bx bx-sitemap",
geoMap: "bx bx-map-alt",
aiChat: "bx bx-bot"
};
@@ -36,7 +35,7 @@ const NOTE_TYPE_ICONS = {
* end user. Those types should be used only for checking against, they are
* not for direct use.
*/
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "geoMap" | "aiChat";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat";
export interface NotePathRecord {
isArchived: boolean;

View File

@@ -17,11 +17,17 @@ interface MenuSeparatorItem {
title: "----";
}
export interface MenuItemBadge {
title: string;
className?: string;
}
export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
uiIcon?: string;
badges?: MenuItemBadge[];
templateNoteId?: string;
enabled?: boolean;
handler?: MenuHandler<T>;
@@ -161,6 +167,18 @@ class ContextMenu {
.append(" &nbsp; ") // some space between icon and text
.append(item.title);
if ("badges" in item && item.badges) {
for (let badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
if (badge.className) {
badgeElement.addClass(badge.className);
}
$link.append(badgeElement);
}
}
if ("shortcut" in item && item.shortcut) {
$link.append($("<kbd>").text(item.shortcut));
}

View File

@@ -277,13 +277,21 @@ 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) {
/**
* Handles navigation to a link, which can be an internal note path (e.g., `#root/1234`) or an external URL (e.g., `https://example.com`).
*
* @param evt the event that triggered the link navigation, or `null` if the link was clicked programmatically. Used to determine if the link should be opened in a new tab/window, based on the button presses.
* @param hrefLink the link to navigate to, which can be a note path (e.g., `#root/1234`) or an external URL with any supported protocol (e.g., `https://example.com`).
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
*/
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 +301,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 +319,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

@@ -1,11 +1,12 @@
import type FNote from "../entities/fnote.js";
import CalendarView from "../widgets/view_widgets/calendar_view.js";
import GeoView from "../widgets/view_widgets/geo_view/index.js";
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
import TableView from "../widgets/view_widgets/table_view/index.js";
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
import type ViewMode from "../widgets/view_widgets/view_mode.js";
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table";
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap";
export default class NoteListRenderer {
@@ -26,6 +27,9 @@ export default class NoteListRenderer {
case "table":
this.viewMode = new TableView(args);
break;
case "geoMap":
this.viewMode = new GeoView(args);
break;
default:
this.viewMode = null;
}
@@ -34,7 +38,7 @@ export default class NoteListRenderer {
#getViewType(parentNote: FNote): ViewTypeOptions {
const viewType = parentNote.getLabelValue("viewType");
if (!["list", "grid", "calendar", "table"].includes(viewType || "")) {
if (!["list", "grid", "calendar", "table", "geoMap"].includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return parentNote.type === "search" ? "list" : "grid";
} else {

View File

@@ -1,42 +1,86 @@
import server from "./server.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import type { MenuItem } from "../menus/context_menu.js";
import froca from "./froca.js";
import server from "./server.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge } from "../menus/context_menu.js";
import type { NoteType } from "../entities/fnote.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
export interface NoteTypeMapping {
type: NoteType;
mime?: string;
title: string;
icon?: string;
/** Indicates whether this type should be marked as a newly introduced feature. */
isNew?: boolean;
/** Indicates that this note type is part of a beta feature. */
isBeta?: boolean;
/** Indicates that this note type cannot be created by the user. */
reserved?: boolean;
/** Indicates that once a note of this type is created, its type can no longer be changed. */
static?: boolean;
}
export const NOTE_TYPES: NoteTypeMapping[] = [
// The suggested note type ordering method: insert the item into the corresponding group,
// then ensure the items within the group are ordered alphabetically.
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
// Graphic notes
{ type: "canvas", mime: "application/json", title: t("note_types.canvas"), icon: "bx-pen" },
{ type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), icon: "bx-selection" },
// Map notes
{ type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), icon: "bx-sitemap" },
{ type: "noteMap", mime: "", title: t("note_types.note-map"), icon: "bxs-network-chart", static: true },
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
// Misc note types
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" },
// Reserved types (cannot be created by the user)
{ type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true },
{ type: "doc", mime: "", title: t("note_types.doc"), reserved: true },
{ type: "file", title: t("note_types.file"), reserved: true },
{ type: "image", title: t("note_types.image"), reserved: true },
{ type: "launcher", mime: "", title: t("note_types.launcher"), reserved: true },
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), reserved: true }
];
/** The maximum age in days for a template to be marked with the "New" badge */
const NEW_TEMPLATE_MAX_AGE = 3;
/** The length of a day in milliseconds. */
const DAY_LENGTH = 1000 * 60 * 60 * 24;
/** The menu item badge used to mark new note types and templates */
const NEW_BADGE: MenuItemBadge = {
title: t("note_types.new-feature"),
className: "new-note-type-badge"
};
/** The menu item badge used to mark note types that are part of a beta feature */
const BETA_BADGE = {
title: t("note_types.beta-feature")
};
const SEPARATOR = { title: "----" };
const creationDateCache = new Map<string, Date>();
let rootCreationDate: Date | undefined;
async function getNoteTypeItems(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
// The suggested note type ordering method: insert the item into the corresponding group,
// then ensure the items within the group are ordered alphabetically.
// Please keep the order synced with the listing found also in aps/client/src/widgets/note_types.ts.
// The default note type (always the first item)
{ title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" },
// Text notes group
{ title: t("note_types.book"), command, type: "book", uiIcon: "bx bx-book" },
// Graphic notes
{ title: t("note_types.canvas"), command, type: "canvas", uiIcon: "bx bx-pen" },
{ title: t("note_types.mermaid-diagram"), command, type: "mermaid", uiIcon: "bx bx-selection" },
// Map notes
{ title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" },
{ title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" },
{ title: t("note_types.note-map"), command, type: "noteMap", uiIcon: "bx bxs-network-chart" },
{ title: t("note_types.relation-map"), command, type: "relationMap", uiIcon: "bx bxs-network-chart" },
// Misc note types
{ title: t("note_types.render-note"), command, type: "render", uiIcon: "bx bx-extension" },
{ title: t("note_types.saved-search"), command, type: "search", uiIcon: "bx bx-file-find" },
{ title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" },
// Code notes
{ title: t("note_types.code"), command, type: "code", uiIcon: "bx bx-code" },
// Templates
...getBlankNoteTypes(command),
...await getBuiltInTemplates(command),
...await getUserTemplates(command)
];
@@ -44,6 +88,28 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
return items;
}
function getBlankNoteTypes(command): MenuItem<TreeCommandNames>[] {
return NOTE_TYPES.filter((nt) => !nt.reserved).map((nt) => {
const menuItem: MenuCommandItem<TreeCommandNames> = {
title: nt.title,
command,
type: nt.type,
uiIcon: "bx " + nt.icon,
badges: []
}
if (nt.isNew) {
menuItem.badges?.push(NEW_BADGE);
}
if (nt.isBeta) {
menuItem.badges?.push(BETA_BADGE);
}
return menuItem;
});
}
async function getUserTemplates(command?: TreeCommandNames) {
const templateNoteIds = await server.get<string[]>("search-templates");
const templateNotes = await froca.getNotes(templateNoteIds);
@@ -54,14 +120,21 @@ async function getUserTemplates(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
SEPARATOR
];
for (const templateNote of templateNotes) {
items.push({
const item: MenuItem<TreeCommandNames> = {
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
};
if (await isNewTemplate(templateNote.noteId)) {
item.badges = [NEW_BADGE];
}
items.push(item);
}
return items;
}
@@ -81,18 +154,71 @@ async function getBuiltInTemplates(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
SEPARATOR
];
for (const templateNote of childNotes) {
items.push({
const item: MenuItem<TreeCommandNames> = {
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
};
if (await isNewTemplate(templateNote.noteId)) {
item.badges = [NEW_BADGE];
}
items.push(item);
}
return items;
}
async function isNewTemplate(templateNoteId) {
if (rootCreationDate === undefined) {
// Retrieve the root note creation date
try {
let rootNoteInfo: any = await server.get("notes/root");
if ("dateCreated" in rootNoteInfo) {
rootCreationDate = new Date(rootNoteInfo.dateCreated);
}
} catch (ex) {
console.error(ex);
}
}
// Try to retrieve the template's creation date from the cache
let creationDate: Date | undefined = creationDateCache.get(templateNoteId);
if (creationDate === undefined) {
// The creation date isn't available in the cache, try to retrieve it from the server
try {
const noteInfo: any = await server.get("notes/" + templateNoteId);
if ("dateCreated" in noteInfo) {
creationDate = new Date(noteInfo.dateCreated);
creationDateCache.set(templateNoteId, creationDate);
}
} catch (ex) {
console.error(ex);
}
}
if (creationDate) {
if (rootCreationDate && creationDate.getTime() - rootCreationDate.getTime() < 30000) {
// Ignore templates created within 30 seconds after the root note is created.
// This is useful to prevent predefined templates from being marked
// as 'New' after setting up a new database.
return false;
}
// Determine the difference in days between now and the template's creation date
const age = (new Date().getTime() - creationDate.getTime()) / DAY_LENGTH;
// Return true if the template is at most NEW_TEMPLATE_MAX_AGE days old
return (age <= NEW_TEMPLATE_MAX_AGE);
} else {
return false;
}
}
export default {
getNoteTypeItems
};

View File

@@ -192,6 +192,13 @@ samp {
font-family: var(--monospace-font-family) !important;
}
.badge {
--bs-badge-color: var(--muted-text-color);
margin-left: 8px;
background: var(--accented-background-color);
}
.input-group-text {
background-color: var(--accented-background-color) !important;
color: var(--muted-text-color) !important;

View File

@@ -178,6 +178,9 @@
--alert-bar-background: #6b6b6b3b;
--badge-background-color: #ffffff1a;
--badge-text-color: var(--muted-text-color);
--promoted-attribute-card-background-color: var(--card-background-color);
--promoted-attribute-card-shadow-color: #000000b3;

View File

@@ -171,6 +171,9 @@
--alert-bar-background: #32637b29;
--badge-background-color: #00000011;
--badge-text-color: var(--muted-text-color);
--promoted-attribute-card-background-color: var(--card-background-color);
--promoted-attribute-card-shadow-color: #00000033;

View File

@@ -171,6 +171,16 @@ html body .dropdown-item[disabled] {
opacity: var(--menu-item-disabled-opacity);
}
/* Badges */
:root .badge {
--bs-badge-color: var(--badge-text-color);
--bs-badge-font-weight: 500;
background: var(--badge-background-color);
text-transform: uppercase;
letter-spacing: .2pt;
}
/* Menu item icon */
.dropdown-item .bx {
transform: translateY(var(--menu-item-icon-vert-offset));

View File

@@ -761,7 +761,8 @@
"book_properties": "Book Properties",
"invalid_view_type": "Invalid view type '{{type}}'",
"calendar": "Calendar",
"table": "Table"
"table": "Table",
"geo-map": "Geo Map"
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@@ -1627,7 +1628,8 @@
"geo-map": "Geo Map",
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"task-list": "Task List"
"task-list": "Task List",
"new-feature": "New"
},
"protect_note": {
"toggle-on": "Protect the note",
@@ -1858,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

@@ -189,7 +189,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type));
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "geoMap"].includes(note.type));
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));
const canPrint = ["text", "code"].includes(note.type);
this.toggleDisabled(this.$printActiveNoteButton, canPrint);

View File

@@ -154,13 +154,21 @@ export default class NoteTypeChooserDialog extends BasicWidget {
this.$noteTypeDropdown.append($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
} else {
const commandItem = noteType as MenuCommandItem<CommandNames>;
this.$noteTypeDropdown.append(
$('<a class="dropdown-item" tabindex="0">')
.attr("data-note-type", commandItem.type || "")
.attr("data-template-note-id", commandItem.templateNoteId || "")
.append($("<span>").addClass(commandItem.uiIcon || ""))
.append(` ${noteType.title}`)
);
const listItem = $('<a class="dropdown-item" tabindex="0">')
.attr("data-note-type", commandItem.type || "")
.attr("data-template-note-id", commandItem.templateNoteId || "")
.append($("<span>").addClass(commandItem.uiIcon || ""))
.append(` ${noteType.title}`);
if (commandItem.badges) {
for (let badge of commandItem.badges) {
listItem.append($(`<span class="badge">`)
.addClass(badge.className || "")
.text(badge.title));
}
}
this.$noteTypeDropdown.append(listItem);
}
}

View File

@@ -23,7 +23,9 @@ const TPL = /*html*/`\
export default class GeoMapButtons extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled() && this.note?.type === "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

@@ -1,58 +0,0 @@
import type { Map } from "leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
const TPL = /*html*/`\
<div class="geo-map-widget">
<style>
.note-detail-geo-map,
.geo-map-widget,
.geo-map-container {
height: 100%;
overflow: hidden;
}
.leaflet-top,
.leaflet-bottom {
z-index: 900;
}
</style>
<div class="geo-map-container"></div>
</div>`;
export type Leaflet = typeof L;
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");
const map = L.map(this.$container[0], {
worldCopyJump: true
});
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',
detectRetina: true
}).addTo(map);
}
}

View File

@@ -28,7 +28,6 @@ import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
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 GeoMapTypeWidget from "./type_widgets/geo_map.js";
import utils from "../services/utils.js";
import type { NoteType } from "../entities/fnote.js";
import type TypeWidget from "./type_widgets/type_widget.js";
@@ -71,7 +70,6 @@ const typeWidgetClasses = {
attachmentDetail: AttachmentDetailTypeWidget,
attachmentList: AttachmentListTypeWidget,
mindMap: MindMapWidget,
geoMap: GeoMapTypeWidget,
aiChat: AiChatTypeWidget,
// Split type editors
@@ -197,7 +195,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
// https://github.com/zadam/trilium/issues/2522
const isBackendNote = this.noteContext?.noteId === "_backendLog";
const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "geoMap", "mermaid"].includes(this.type ?? "");
const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid"].includes(this.type ?? "");
const isFullHeight = (!this.noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|| this.noteContext?.viewScope?.viewMode === "attachments"
|| isBackendNote;

View File

@@ -1,7 +1,7 @@
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import NoteListRenderer from "../services/note_list_renderer.js";
import type FNote from "../entities/fnote.js";
import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData } from "../components/app_context.js";
import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData, EventNames } from "../components/app_context.js";
import type ViewMode from "./view_widgets/view_mode.js";
import AttributeDetailWidget from "./attribute_widgets/attribute_detail.js";
import { Attribute } from "../services/attribute_parser.js";
@@ -176,4 +176,17 @@ export default class NoteListWidget extends NoteContextAwareWidget {
return super.triggerCommand(name, data);
}
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
super.handleEventInChildren(name, data);
if (this.viewMode) {
const ret = this.viewMode.handleEvent(name, data);
if (ret) {
return ret;
}
}
return null;
}
}

View File

@@ -186,6 +186,15 @@ interface RefreshContext {
noteIdsToReload: Set<string>;
}
/**
* The information contained within a drag event.
*/
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

@@ -1,60 +1,15 @@
import server from "../services/server.js";
import { Dropdown } from "bootstrap";
import { NOTE_TYPES } from "../services/note_types.js";
import { t } from "../services/i18n.js";
import dialogService from "../services/dialog.js";
import mimeTypesService from "../services/mime_types.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import dialogService from "../services/dialog.js";
import { t } from "../services/i18n.js";
import type FNote from "../entities/fnote.js";
import type { NoteType } from "../entities/fnote.js";
import server from "../services/server.js";
import type { EventData } from "../components/app_context.js";
import { Dropdown } from "bootstrap";
import type { NoteType } from "../entities/fnote.js";
import type FNote from "../entities/fnote.js";
interface NoteTypeMapping {
type: NoteType;
mime?: string;
title: string;
isBeta?: boolean;
selectable: boolean;
}
const NOTE_TYPES: NoteTypeMapping[] = [
// The suggested note type ordering method: insert the item into the corresponding group,
// then ensure the items within the group are ordered alphabetically.
// Please keep the order synced with the listing found also in apps/client/src/services/note_types.ts.
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), selectable: true },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), selectable: true },
// Graphic notes
{ type: "canvas", mime: "application/json", title: t("note_types.canvas"), selectable: true },
{ type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), selectable: true },
// Map notes
{ type: "geoMap", mime: "application/json", title: t("note_types.geo-map"), isBeta: true, selectable: true },
{ type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), selectable: true },
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), selectable: true },
// Misc note types
{ type: "render", mime: "", title: t("note_types.render-note"), selectable: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), selectable: true },
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), selectable: true },
// Reserved types (cannot be created by the user)
{ type: "contentWidget", mime: "", title: t("note_types.widget"), selectable: false },
{ type: "doc", mime: "", title: t("note_types.doc"), selectable: false },
{ type: "file", title: t("note_types.file"), selectable: false },
{ type: "image", title: t("note_types.image"), selectable: false },
{ type: "launcher", mime: "", title: t("note_types.launcher"), selectable: false },
{ type: "noteMap", mime: "", title: t("note_types.note-map"), selectable: false },
{ type: "search", title: t("note_types.saved-search"), selectable: false },
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), selectable: false }
];
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => !nt.selectable).map((nt) => nt.type);
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => nt.reserved || nt.static).map((nt) => nt.type);
const TPL = /*html*/`
<div class="dropdown note-type-widget">
@@ -64,13 +19,6 @@ const TPL = /*html*/`
overflow-y: auto;
overflow-x: hidden;
}
.note-type-dropdown .badge {
margin-left: 8px;
background: var(--accented-background-color);
font-weight: normal;
color: var(--menu-text-color);
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle select-button note-type-button">
<span class="note-type-desc"></span>
@@ -117,10 +65,15 @@ export default class NoteTypeWidget extends NoteContextAwareWidget {
return;
}
for (const noteType of NOTE_TYPES.filter((nt) => nt.selectable)) {
for (const noteType of NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static)) {
let $typeLink: JQuery<HTMLElement>;
const $title = $("<span>").text(noteType.title);
if (noteType.isNew) {
$title.append($(`<span class="badge new-note-type-badge">`).text(t("note_types.new-feature")));
}
if (noteType.isBeta) {
$title.append($(`<span class="badge">`).text(t("note_types.beta-feature")));
}

View File

@@ -64,7 +64,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
}
#isFullWidthNote(note: FNote) {
if (["image", "mermaid", "book", "render", "canvas", "webView", "mindMap", "geoMap"].includes(note.type)) {
if (["image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type)) {
return true;
}

View File

@@ -25,6 +25,7 @@ const TPL = /*html*/`
<option value="list">${t("book_properties.list")}</option>
<option value="calendar">${t("book_properties.calendar")}</option>
<option value="table">${t("book_properties.table")}</option>
<option value="geoMap">${t("book_properties.geo-map")}</option>
</select>
</div>
@@ -126,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

@@ -47,6 +47,7 @@ export default class BookTypeWidget extends TypeWidget {
switch (this.note?.getAttributeValue("label", "viewType")) {
case "calendar":
case "table":
case "geoMap":
return false;
default:
return true;

View File

@@ -1,447 +0,0 @@
import { GPX, 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 { CommandListenerData, EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import attributes from "../../services/attributes.js";
import openContextMenu from "./geo_map_context_menu.js";
import link from "../../services/link.js";
import note_tooltip from "../../services/note_tooltip.js";
import appContext from "../../components/app_context.js";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
import { hasTouchBar } from "../../services/utils.js";
const TPL = /*html*/`\
<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;
};
}
enum State {
Normal,
NewNote
}
export default class GeoMapTypeWidget extends TypeWidget {
private geoMapWidget: GeoMapWidget;
private _state: State;
private L!: Leaflet;
private currentMarkerData: Record<string, Marker>;
private currentTrackData: Record<string, GPX>;
private gpxLoaded?: boolean;
private ignoreNextZoomEvent?: boolean;
static getType() {
return "geoMap";
}
constructor() {
super();
this.geoMapWidget = new GeoMapWidget("type", (L: Leaflet) => this.#onMapInitialized(L));
this.currentMarkerData = {};
this.currentTrackData = {};
this._state = State.Normal;
this.child(this.geoMapWidget);
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.append(this.geoMapWidget.render());
}
async #onMapInitialized(L: Leaflet) {
this.L = L;
const map = this.geoMapWidget.map;
if (!map) {
throw new Error(t("geo-map.unable-to-load-map"));
}
this.#restoreViewportAndZoom();
// Restore markers.
await this.#reloadMarkers();
// This fixes an issue with the map appearing cut off at the beginning, due to the container not being properly attached
setTimeout(() => {
map.invalidateSize();
}, 100);
const updateFn = () => this.spacedUpdate.scheduleUpdate();
map.on("moveend", updateFn);
map.on("zoomend", updateFn);
map.on("click", (e) => this.#onMapClicked(e));
if (hasTouchBar) {
map.on("zoom", () => {
if (!this.ignoreNextZoomEvent) {
this.triggerCommand("refreshTouchBar");
}
this.ignoreNextZoomEvent = false;
});
}
}
async #restoreViewportAndZoom() {
const map = this.geoMapWidget.map;
if (!map || !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);
}
async #reloadMarkers() {
if (!this.note) {
return;
}
// Delete all existing markers
for (const marker of Object.values(this.currentMarkerData)) {
marker.remove();
}
// Delete all existing tracks
for (const track of Object.values(this.currentTrackData)) {
track.remove();
}
// Add the new markers.
this.currentMarkerData = {};
const childNotes = await this.note.getChildNotes();
for (const childNote of childNotes) {
if (childNote.mime === "application/gpx+xml") {
this.#processNoteWithGpxTrack(childNote);
continue;
}
const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE);
if (latLng) {
this.#processNoteWithMarker(childNote, latLng);
}
}
}
async #processNoteWithGpxTrack(note: FNote) {
if (!this.L || !this.geoMapWidget.map) {
return;
}
if (!this.gpxLoaded) {
await import("leaflet-gpx");
this.gpxLoaded = true;
}
const xmlResponse = await server.get<string | Uint8Array>(`notes/${note.noteId}/open`, undefined, true);
let stringResponse: string;
if (xmlResponse instanceof Uint8Array) {
stringResponse = new TextDecoder().decode(xmlResponse);
} else {
stringResponse = xmlResponse;
}
const track = new this.L.GPX(stringResponse, {
markers: {
startIcon: this.#buildIcon(note.getIcon(), note.getColorClass(), note.title),
endIcon: this.#buildIcon("bxs-flag-checkered"),
wptIcons: {
"": this.#buildIcon("bx bx-pin")
}
},
polyline_options: {
color: note.getLabelValue("color") ?? "blue"
}
});
track.addTo(this.geoMapWidget.map);
this.currentTrackData[note.noteId] = track;
}
#processNoteWithMarker(note: FNote, latLng: string) {
const map = this.geoMapWidget.map;
if (!map) {
return;
}
const [lat, lng] = latLng.split(",", 2).map((el) => parseFloat(el));
const L = this.L;
const icon = this.#buildIcon(note.getIcon(), note.getColorClass(), note.title);
const marker = L.marker(L.latLng(lat, lng), {
icon,
draggable: true,
autoPan: true,
autoPanSpeed: 5
})
.addTo(map)
.on("moveend", (e) => {
this.moveMarker(note.noteId, (e.target as Marker).getLatLng());
});
marker.on("mousedown", ({ originalEvent }) => {
// Middle click to open in new tab
if (originalEvent.button === 1) {
const hoistedNoteId = this.hoistedNoteId;
//@ts-ignore, fix once tab manager is ported.
appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId);
return true;
}
});
marker.on("contextmenu", (e) => {
openContextMenu(note.noteId, e.originalEvent);
});
const el = marker.getElement();
if (el) {
const $el = $(el);
$el.attr("data-href", `#${note.noteId}`);
note_tooltip.setupElementTooltip($($el));
}
this.currentMarkerData[note.noteId] = marker;
}
#buildIcon(bxIconClass: string, colorClass?: string, title?: string) {
return this.L.divIcon({
html: /*html*/`\
<img class="icon" src="${markerIcon}" />
<img class="icon-shadow" src="${markerIconShadow}" />
<span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
<span class="title-label">${title ?? ""}</span>`,
iconSize: [25, 41],
iconAnchor: [12, 41]
});
}
#changeState(newState: State) {
this._state = newState;
this.geoMapWidget.$container.toggleClass("placing-note", newState === State.NewNote);
if (hasTouchBar) {
this.triggerCommand("refreshTouchBar");
}
}
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();
this.#restoreViewportAndZoom();
await this.#reloadMarkers();
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// If any of the children branches are altered.
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.noteId)) {
this.#reloadMarkers();
return;
}
// If any of note has its location attribute changed.
// TODO: Should probably filter by parent here as well.
const attributeRows = loadResults.getAttributeRows();
if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color"].includes(at.name ?? ""))) {
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);
}
buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) {
const map = this.geoMapWidget.map;
const that = this;
if (!map) {
return;
}
return [
new TouchBar.TouchBarSlider({
label: "Zoom",
value: map.getZoom(),
minValue: map.getMinZoom(),
maxValue: map.getMaxZoom(),
change(newValue) {
that.ignoreNextZoomEvent = true;
map.setZoom(newValue);
},
}),
new TouchBar.TouchBarButton({
label: "New geo note",
click: () => this.triggerCommand("geoMapCreateChildNote", { ntxId: this.ntxId }),
enabled: (this._state === State.Normal)
})
];
}
}

View File

@@ -1,32 +0,0 @@
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

@@ -0,0 +1,85 @@
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: 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;
}
// 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

@@ -0,0 +1,80 @@
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

@@ -0,0 +1,331 @@
import ViewMode, { ViewModeArgs } from "../view_mode.js";
import L from "leaflet";
import type { GPX, LatLng, LeafletMouseEvent, Map, Marker } from "leaflet";
import "leaflet/dist/leaflet.css";
import SpacedUpdate from "../../../services/spaced_update.js";
import { t } from "../../../services/i18n.js";
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 { createNewNote, moveMarker, setupDragging } from "./editing.js";
import { openMapContextMenu } from "./context_menu.js";
const TPL = /*html*/`
<div class="geo-view">
<style>
.geo-view {
overflow: hidden;
position: relative;
height: 100%;
}
.geo-map-container {
height: 100%;
overflow: hidden;
}
.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 class="geo-map-container"></div>
</div>`;
interface MapData {
view?: {
center?: LatLng | [number, number];
zoom?: number;
};
}
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
const DEFAULT_ZOOM = 2;
export const LOCATION_ATTRIBUTE = "geolocation";
enum State {
Normal,
NewNote
}
export default class GeoView extends ViewMode<MapData> {
private $root: JQuery<HTMLElement>;
private $container!: JQuery<HTMLElement>;
private map?: Map;
private spacedUpdate: SpacedUpdate;
private _state: State;
private ignoreNextZoomEvent?: boolean;
private currentMarkerData: Record<string, Marker>;
private currentTrackData: Record<string, GPX>;
constructor(args: ViewModeArgs) {
super(args, "geoMap");
this.$root = $(TPL);
this.$container = this.$root.find(".geo-map-container");
this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000);
this.currentMarkerData = {};
this.currentTrackData = {};
this._state = State.Normal;
args.$parent.append(this.$root);
}
async renderList() {
this.renderMap();
return this.$root;
}
async renderMap() {
const map = L.map(this.$container[0], {
worldCopyJump: true
});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
detectRetina: true
}).addTo(map);
this.map = map;
this.#onMapInitialized();
}
async #onMapInitialized() {
const map = this.map;
if (!map) {
throw new Error(t("geo-map.unable-to-load-map"));
}
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("contextmenu", (e) => openMapContextMenu(this.parentNote.noteId, e, isEditable));
if (isEditable) {
setupDragging(this.$container, map, this.parentNote.noteId);
}
this.#reloadMarkers();
if (hasTouchBar) {
map.on("zoom", () => {
if (!this.ignoreNextZoomEvent) {
this.triggerCommand("refreshTouchBar");
}
this.ignoreNextZoomEvent = false;
});
}
}
async #restoreViewportAndZoom() {
const map = this.map;
if (!map) {
return;
}
const parsedContent = await this.viewStorage.restore();
// Restore viewport position & zoom
const center = parsedContent?.view?.center ?? DEFAULT_COORDINATES;
const zoom = parsedContent?.view?.zoom ?? DEFAULT_ZOOM;
map.setView(center, zoom);
}
private onSave() {
const map = this.map;
let data: MapData = {};
if (map) {
data = {
view: {
center: map.getBounds().getCenter(),
zoom: map.getZoom()
}
};
}
this.viewStorage.store(data);
}
async #reloadMarkers() {
if (!this.map) {
return;
}
// Delete all existing markers
for (const marker of Object.values(this.currentMarkerData)) {
marker.remove();
}
// Delete all existing tracks
for (const track of Object.values(this.currentTrackData)) {
track.remove();
}
// 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);
this.currentTrackData[childNote.noteId] = track;
continue;
}
const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE);
if (latLng) {
const marker = processNoteWithMarker(this.map, childNote, latLng, draggable);
this.currentMarkerData[childNote.noteId] = marker;
}
}
}
get isFullHeight(): boolean {
return true;
}
#changeState(newState: State) {
this._state = newState;
this.$container.toggleClass("placing-note", newState === State.NewNote);
if (hasTouchBar) {
this.triggerCommand("refreshTouchBar");
}
}
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">): boolean | void {
// If any of the children branches are altered.
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.parentNote.noteId)) {
this.#reloadMarkers();
return;
}
// If any of note has its location attribute changed.
// TODO: Should probably filter by parent here as well.
const attributeRows = loadResults.getAttributeRows();
if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color"].includes(at.name ?? ""))) {
this.#reloadMarkers();
}
}
async geoMapCreateChildNoteEvent({ ntxId }: EventData<"geoMapCreateChildNote">) {
toast.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);
toast.closePersistent("geo-new-note");
};
window.addEventListener("keydown", globalKeyListener);
}
async #onMapClicked(e: LeafletMouseEvent) {
if (this._state !== State.NewNote) {
return;
}
toast.closePersistent("geo-new-note");
await createNewNote(this.parentNote.noteId, e);
this.#changeState(State.Normal);
}
deleteFromMapEvent({ noteId }: EventData<"deleteFromMap">) {
moveMarker(noteId, null);
}
buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) {
const map = this.map;
const that = this;
if (!map) {
return;
}
return [
new TouchBar.TouchBarSlider({
label: "Zoom",
value: map.getZoom(),
minValue: map.getMinZoom(),
maxValue: map.getMaxZoom(),
change(newValue) {
that.ignoreNextZoomEvent = true;
map.setZoom(newValue);
},
}),
new TouchBar.TouchBarButton({
label: "New geo note",
click: () => this.triggerCommand("geoMapCreateChildNote"),
enabled: (this._state === State.Normal)
})
];
}
}

View File

@@ -0,0 +1,92 @@
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
import { marker, latLng, divIcon, Map, type Marker } from "leaflet";
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, 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: isEditable,
autoPan: true,
autoPanSpeed: 5
}).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 = 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, isEditable);
});
return newMarker;
}
export async function processNoteWithGpxTrack(map: Map, note: FNote) {
if (!gpxLoaded) {
const GPX = await import("leaflet-gpx");
gpxLoaded = true;
}
const xmlResponse = await server.get<string | Uint8Array>(`notes/${note.noteId}/open`, undefined, true);
let stringResponse: string;
if (xmlResponse instanceof Uint8Array) {
stringResponse = new TextDecoder().decode(xmlResponse);
} else {
stringResponse = xmlResponse;
}
const track = new L.GPX(stringResponse, {
markers: {
startIcon: buildIcon(note.getIcon(), note.getColorClass(), note.title),
endIcon: buildIcon("bxs-flag-checkered"),
wptIcons: {
"": buildIcon("bx bx-pin")
}
},
polyline_options: {
color: note.getLabelValue("color") ?? "blue"
}
});
track.addTo(map);
return track;
}
function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string) {
let html = /*html*/`\
<img class="icon" src="${markerIcon}" />
<img class="icon-shadow" src="${markerIconShadow}" />
<span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
<span class="title-label">${title ?? ""}</span>`;
if (noteIdLink) {
html = `<div data-href="#root/${noteIdLink}">${html}</div>`;
}
return divIcon({
html,
iconSize: [25, 41],
iconAnchor: [12, 41]
});
}

View File

@@ -44,12 +44,16 @@ 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;
}
this._viewStorage = new ViewModeStorage(this.parentNote, this.viewType);
this._viewStorage = new ViewModeStorage<T>(this.parentNote, this.viewType);
return this._viewStorage;
}

View File

@@ -26,18 +26,19 @@ export default class ViewModeStorage<T extends object> {
}
async restore() {
const existingAttachments = await this.note.getAttachmentsByRole(ATTACHMENT_ROLE);
const existingAttachments = (await this.note.getAttachmentsByRole(ATTACHMENT_ROLE))
.filter(a => a.title === this.attachmentName);
if (existingAttachments.length === 0) {
return undefined;
}
const attachment = existingAttachments
.find(a => a.title === this.attachmentName);
if (!attachment) {
return undefined;
if (existingAttachments.length > 1) {
// Clean up duplicates.
await Promise.all(existingAttachments.slice(1).map(async a => await server.remove(`attachments/${a.attachmentId}`)));
}
const attachment = existingAttachments[0];
const attachmentData = await server.get<{ content: string } | null>(`attachments/${attachment.attachmentId}/blob`);
return JSON.parse(attachmentData?.content ?? "{}");
return JSON.parse(attachmentData?.content ?? "{}") as T;
}
}