mirror of
https://github.com/zadam/trilium.git
synced 2025-11-16 02:05:53 +01:00
Merge remote-tracking branch 'origin/main' into renovate/mind-elixir-5.x
This commit is contained in:
@@ -261,7 +261,6 @@ export type CommandMappings = {
|
||||
|
||||
// Geomap
|
||||
deleteFromMap: { noteId: string };
|
||||
openGeoLocation: { noteId: string; event: JQuery.MouseDownEvent };
|
||||
|
||||
toggleZenMode: CommandData;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(" ") // 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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
detectRetina: true
|
||||
}).addTo(map);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
];
|
||||
}
|
||||
80
apps/client/src/widgets/view_widgets/geo_view/editing.ts
Normal file
80
apps/client/src/widgets/view_widgets/geo_view/editing.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
331
apps/client/src/widgets/view_widgets/geo_view/index.ts
Normal file
331
apps/client/src/widgets/view_widgets/geo_view/index.ts
Normal 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: '© <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)
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
92
apps/client/src/widgets/view_widgets/geo_view/markers.ts
Normal file
92
apps/client/src/widgets/view_widgets/geo_view/markers.ts
Normal 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]
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user