diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 36d364049..23924edcb 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -13,7 +13,6 @@ import MainTreeExecutors from "./main_tree_executors.js"; import toast from "../services/toast.js"; import ShortcutComponent from "./shortcut_component.js"; import { t, initLocale } from "../services/i18n.js"; -import type NoteDetailWidget from "../widgets/note_detail.js"; import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; @@ -21,8 +20,6 @@ import type LoadResults from "../services/load_results.js"; import type { Attribute } from "../services/attribute_parser.js"; import type NoteTreeWidget from "../widgets/note_tree.js"; import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js"; -import type TypeWidget from "../widgets/type_widgets/type_widget.js"; -import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js"; import type { NativeImage, TouchBar } from "electron"; import TouchBarComponent from "./touch_bar.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; @@ -33,6 +30,10 @@ import { ColumnComponent } from "tabulator-tables"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import type RootContainer from "../widgets/containers/root_container.js"; import { SqlExecuteResults } from "@triliumnext/commons"; +import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx"; +import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx"; +import { ReactWrappedWidget } from "../widgets/basic_widget.js"; +import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx"; interface Layout { getRootWidget: (appContext: AppContext) => RootContainer; @@ -199,7 +200,7 @@ export type CommandMappings = { resetLauncher: ContextMenuCommandData; executeInActiveNoteDetailWidget: CommandData & { - callback: (value: NoteDetailWidget | PromiseLike) => void; + callback: (value: ReactWrappedWidget) => void; }; executeWithTextEditor: CommandData & ExecuteCommandData & { @@ -211,7 +212,7 @@ export type CommandMappings = { * Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}. */ executeWithContentElement: CommandData & ExecuteCommandData>; - executeWithTypeWidget: CommandData & ExecuteCommandData; + executeWithTypeWidget: CommandData & ExecuteCommandData; addTextToActiveEditor: CommandData & { text: string; }; @@ -221,9 +222,9 @@ export type CommandMappings = { showPasswordNotSet: CommandData; showProtectedSessionPasswordDialog: CommandData; showUploadAttachmentsDialog: CommandData & { noteId: string }; - showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; - showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string }; - showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; + showIncludeNoteDialog: CommandData & IncludeNoteOpts; + showAddLinkDialog: CommandData & AddLinkOpts; + showPasteMarkdownDialog: CommandData & MarkdownImportOpts; closeProtectedSessionPasswordDialog: CommandData; copyImageReferenceToClipboard: CommandData; copyImageToClipboard: CommandData; @@ -485,13 +486,8 @@ type EventMappings = { relationMapResetZoomIn: { ntxId: string | null | undefined }; relationMapResetZoomOut: { ntxId: string | null | undefined }; activeNoteChanged: {}; - showAddLinkDialog: { - textTypeWidget: EditableTextTypeWidget; - text: string; - }; - showIncludeDialog: { - textTypeWidget: EditableTextTypeWidget; - }; + showAddLinkDialog: AddLinkOpts; + showIncludeDialog: IncludeNoteOpts; openBulkActionsDialog: { selectedOrActiveNoteIds: string[]; }; @@ -670,6 +666,10 @@ export class AppContext extends Component { this.beforeUnloadListeners.push(obj); } } + + removeBeforeUnloadListener(listener: (() => boolean)) { + this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener); + } } const appContext = new AppContext(window.glob.isMainWindow); diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index d4bcb1fa6..735978974 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -9,10 +9,10 @@ import hoistedNoteService from "../services/hoisted_note.js"; import options from "../services/options.js"; import type { ViewScope } from "../services/link.js"; import type FNote from "../entities/fnote.js"; -import type TypeWidget from "../widgets/type_widgets/type_widget.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type CodeMirror from "@triliumnext/codemirror"; import { closeActiveDialog } from "../services/dialog.js"; +import { ReactWrappedWidget } from "../widgets/basic_widget.js"; export interface SetNoteOpts { triggerSwitchEvent?: unknown; @@ -397,7 +397,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> async getTypeWidget() { return this.timeout( - new Promise((resolve) => + new Promise((resolve) => appContext.triggerCommand("executeWithTypeWidget", { resolve, ntxId: this.ntxId diff --git a/apps/client/src/components/tab_manager.ts b/apps/client/src/components/tab_manager.ts index 03b9d98d6..517ff2501 100644 --- a/apps/client/src/components/tab_manager.ts +++ b/apps/client/src/components/tab_manager.ts @@ -265,6 +265,7 @@ export default class TabManager extends Component { mainNtxId: string | null = null ): Promise { const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId); + noteContext.setEmpty(); const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId); diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index b7d170bcf..d33799669 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -14,7 +14,6 @@ import LauncherContainer from "../widgets/containers/launcher_container.js"; import LeftPaneContainer from "../widgets/containers/left_pane_container.js"; import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js"; import MovePaneButton from "../widgets/buttons/move_pane_button.js"; -import NoteDetailWidget from "../widgets/note_detail.js"; import NoteIconWidget from "../widgets/note_icon.jsx"; import NoteList from "../widgets/collections/NoteList.jsx"; import NoteTitleWidget from "../widgets/note_title.jsx"; @@ -44,6 +43,7 @@ import type { WidgetsByParent } from "../services/bundle.js"; import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js"; import utils from "../services/utils.js"; import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; +import NoteDetail from "../widgets/NoteDetail.jsx"; export default class DesktopLayout { @@ -142,7 +142,7 @@ export default class DesktopLayout { ) .child(new PromotedAttributesWidget()) .child() - .child(new NoteDetailWidget()) + .child() .child() .child() .child() diff --git a/apps/client/src/layouts/layout_commons.tsx b/apps/client/src/layouts/layout_commons.tsx index 26f8ea232..031ef03de 100644 --- a/apps/client/src/layouts/layout_commons.tsx +++ b/apps/client/src/layouts/layout_commons.tsx @@ -26,11 +26,11 @@ import PopupEditorDialog from "../widgets/dialogs/popup_editor.js"; import FlexContainer from "../widgets/containers/flex_container.js"; import NoteIconWidget from "../widgets/note_icon"; import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; -import NoteDetailWidget from "../widgets/note_detail.js"; import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx"; import NoteTitleWidget from "../widgets/note_title.jsx"; import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js"; import NoteList from "../widgets/collections/NoteList.jsx"; +import NoteDetail from "../widgets/NoteDetail.jsx"; import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx"; export function applyModals(rootContainer: RootContainer) { @@ -66,7 +66,7 @@ export function applyModals(rootContainer: RootContainer) { .child()) .child() .child(new PromotedAttributesWidget()) - .child(new NoteDetailWidget()) + .child() .child()) .child(); } diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index 1ac3bd55c..2024d4580 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -8,8 +8,6 @@ import FloatingButtons from "../widgets/FloatingButtons.jsx"; import GlobalMenuWidget from "../widgets/buttons/global_menu.js"; import LauncherContainer from "../widgets/containers/launcher_container.js"; import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js"; -import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js"; -import NoteDetailWidget from "../widgets/note_detail.js"; import NoteList from "../widgets/collections/NoteList.jsx"; import NoteTitleWidget from "../widgets/note_title.js"; import ContentHeader from "../widgets/containers/content-header.js"; @@ -29,6 +27,8 @@ import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibb import TabRowWidget from "../widgets/tab_row.js"; import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx"; import type AppContext from "../components/app_context.js"; +import NoteDetail from "../widgets/NoteDetail.jsx"; +import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx"; const MOBILE_CSS = ` - -
-
-
-

-
-
-
- -
- -
-
-`; - -export default class AttachmentDetailWidget extends BasicWidget { - attachment: FAttachment; - attachmentActionsWidget: AttachmentActionsWidget; - isFullDetail: boolean; - $wrapper!: JQuery; - - constructor(attachment: FAttachment, isFullDetail: boolean) { - super(); - - this.contentSized(); - this.attachment = attachment; - this.attachmentActionsWidget = new AttachmentActionsWidget(attachment, isFullDetail); - this.isFullDetail = isFullDetail; - this.child(this.attachmentActionsWidget); - } - - doRender() { - this.$widget = $(TPL); - this.refresh(); - - super.doRender(); - } - - async refresh() { - this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html()); - this.$wrapper = this.$widget.find(".attachment-detail-wrapper"); - this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view"); - - if (!this.isFullDetail) { - const $link = await linkService.createLink(this.attachment.ownerId, { - title: this.attachment.title, - viewScope: { - viewMode: "attachments", - attachmentId: this.attachment.attachmentId - } - }); - $link.addClass("use-tn-links"); - - this.$wrapper.find(".attachment-title").append($link); - } else { - this.$wrapper.find(".attachment-title").text(this.attachment.title); - } - - const $deletionWarning = this.$wrapper.find(".attachment-deletion-warning"); - const { utcDateScheduledForErasureSince } = this.attachment; - - if (utcDateScheduledForErasureSince) { - this.$wrapper.addClass("scheduled-for-deletion"); - - const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime(); - // use default value (30 days in seconds) from options_init as fallback, in case getInt returns null - const intervalMs = options.getInt("eraseUnusedAttachmentsAfterSeconds") || 2592000 * 1000; - const deletionTimestamp = scheduledSinceTimestamp + intervalMs; - const willBeDeletedInMs = deletionTimestamp - Date.now(); - - $deletionWarning.show(); - - if (willBeDeletedInMs >= 60000) { - $deletionWarning.text(t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) })); - } else { - $deletionWarning.text(t("attachment_detail_2.will_be_deleted_soon")); - } - - $deletionWarning.append(t("attachment_detail_2.deletion_reason")); - } else { - this.$wrapper.removeClass("scheduled-for-deletion"); - $deletionWarning.hide(); - } - - this.$wrapper.find(".attachment-details").text(t("attachment_detail_2.role_and_size", { role: this.attachment.role, size: utils.formatSize(this.attachment.contentLength) })); - this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render()); - - const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail }); - this.$wrapper.find(".attachment-content-wrapper").append($renderedContent); - } - - async copyAttachmentLinkToClipboard() { - if (this.attachment.role === "image") { - imageService.copyImageReferenceToClipboard(this.$wrapper.find(".attachment-content-wrapper")); - } else if (this.attachment.role === "file") { - const $link = await linkService.createLink(this.attachment.ownerId, { - referenceLink: true, - viewScope: { - viewMode: "attachments", - attachmentId: this.attachment.attachmentId - } - }); - - utils.copyHtmlToClipboard($link[0].outerHTML); - - toastService.showMessage(t("attachment_detail_2.link_copied")); - } else { - throw new Error(t("attachment_detail_2.unrecognized_role", { role: this.attachment.role })); - } - } - - async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - const attachmentRow = loadResults.getAttachmentRows().find((att) => att.attachmentId === this.attachment.attachmentId); - - if (attachmentRow) { - if (attachmentRow.isDeleted) { - this.toggleInt(false); - } else { - this.refresh(); - } - } - } -} diff --git a/apps/client/src/widgets/basic_widget.ts b/apps/client/src/widgets/basic_widget.ts index 61499dbda..db1a31f70 100644 --- a/apps/client/src/widgets/basic_widget.ts +++ b/apps/client/src/widgets/basic_widget.ts @@ -4,8 +4,6 @@ import froca from "../services/froca.js"; import { t } from "../services/i18n.js"; import toastService from "../services/toast.js"; import { renderReactWidget } from "./react/react_utils.jsx"; -import { EventNames, EventData } from "../components/app_context.js"; -import { Handler } from "leaflet"; export class TypedBasicWidget> extends TypedComponent { protected attrs: Record; diff --git a/apps/client/src/widgets/buttons/attachments_actions.ts b/apps/client/src/widgets/buttons/attachments_actions.ts deleted file mode 100644 index 681973a28..000000000 --- a/apps/client/src/widgets/buttons/attachments_actions.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { t } from "../../services/i18n.js"; -import BasicWidget from "../basic_widget.js"; -import server from "../../services/server.js"; -import dialogService from "../../services/dialog.js"; -import toastService from "../../services/toast.js"; -import ws from "../../services/ws.js"; -import appContext from "../../components/app_context.js"; -import openService from "../../services/open.js"; -import utils from "../../services/utils.js"; -import { Dropdown } from "bootstrap"; -import type FAttachment from "../../entities/fattachment.js"; -import type AttachmentDetailWidget from "../attachment_detail.js"; -import type { NoteRow } from "@triliumnext/commons"; - -const TPL = /*html*/` -`; - -// TODO: Deduplicate -interface AttachmentResponse { - note: NoteRow; -} - -export default class AttachmentActionsWidget extends BasicWidget { - $uploadNewRevisionInput!: JQuery; - attachment: FAttachment; - isFullDetail: boolean; - dropdown!: Dropdown; - - constructor(attachment: FAttachment, isFullDetail: boolean) { - super(); - - this.attachment = attachment; - this.isFullDetail = isFullDetail; - } - - get attachmentId() { - return this.attachment.attachmentId; - } - - doRender() { - this.$widget = $(TPL); - this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]); - this.$widget.on("click", ".dropdown-item", () => this.dropdown.toggle()); - - this.$uploadNewRevisionInput = this.$widget.find(".attachment-upload-new-revision-input"); - this.$uploadNewRevisionInput.on("change", async () => { - const fileToUpload = this.$uploadNewRevisionInput[0].files?.item(0); // copy to allow reset below - this.$uploadNewRevisionInput.val(""); - if (fileToUpload) { - const result = await server.upload(`attachments/${this.attachmentId}/file`, fileToUpload); - if (result.uploaded) { - toastService.showMessage(t("attachments_actions.upload_success")); - } else { - toastService.showError(t("attachments_actions.upload_failed")); - } - } - }); - - const isElectron = utils.isElectron(); - if (!this.isFullDetail) { - const $openAttachmentButton = this.$widget.find("[data-trigger-command='openAttachment']"); - $openAttachmentButton.addClass("disabled").append($('').attr("title", t("attachments_actions.open_externally_detail_page"))); - if (isElectron) { - const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']"); - $openAttachmentCustomButton.addClass("disabled").append($('').attr("title", t("attachments_actions.open_externally_detail_page"))); - } - } - if (!isElectron) { - const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']"); - $openAttachmentCustomButton.addClass("disabled").append($('').attr("title", t("attachments_actions.open_custom_client_only"))); - } - } - - async openAttachmentCommand() { - await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime); - } - - async openAttachmentCustomCommand() { - await openService.openAttachmentCustom(this.attachmentId, this.attachment.mime); - } - - async downloadAttachmentCommand() { - await openService.downloadAttachment(this.attachmentId); - } - - async uploadNewAttachmentRevisionCommand() { - this.$uploadNewRevisionInput.trigger("click"); - } - - async copyAttachmentLinkToClipboardCommand() { - if (this.parent && "copyAttachmentLinkToClipboard" in this.parent) { - (this.parent as AttachmentDetailWidget).copyAttachmentLinkToClipboard(); - } - } - - async deleteAttachmentCommand() { - if (!(await dialogService.confirm(t("attachments_actions.delete_confirm", { title: this.attachment.title })))) { - return; - } - - await server.remove(`attachments/${this.attachmentId}`); - toastService.showMessage(t("attachments_actions.delete_success", { title: this.attachment.title })); - } - - async convertAttachmentIntoNoteCommand() { - if (!(await dialogService.confirm(t("attachments_actions.convert_confirm", { title: this.attachment.title })))) { - return; - } - - const { note: newNote } = await server.post(`attachments/${this.attachmentId}/convert-to-note`); - toastService.showMessage(t("attachments_actions.convert_success", { title: this.attachment.title })); - await ws.waitForMaxKnownEntityChangeId(); - await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId); - } - - async renameAttachmentCommand() { - const attachmentTitle = await dialogService.prompt({ - title: t("attachments_actions.rename_attachment"), - message: t("attachments_actions.enter_new_name"), - defaultValue: this.attachment.title - }); - - if (!attachmentTitle?.trim()) { - return; - } - - await server.put(`attachments/${this.attachmentId}/rename`, { title: attachmentTitle }); - } -} diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index ec1e9e23b..6cae60f26 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -4,7 +4,7 @@ import Calendar from "./calendar"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import "./index.css"; import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; -import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "@triliumnext/commons"; +import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; import { Calendar as FullCalendar } from "@fullcalendar/core"; import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; import dialog from "../../../services/dialog"; diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index ccc1330d1..07e942d19 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,7 +1,7 @@ import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks"; +import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; diff --git a/apps/client/src/widgets/containers/scrolling_container.css b/apps/client/src/widgets/containers/scrolling_container.css new file mode 100644 index 000000000..5bea62418 --- /dev/null +++ b/apps/client/src/widgets/containers/scrolling_container.css @@ -0,0 +1,9 @@ +.scrolling-container { + overflow: auto; + scroll-behavior: smooth; + position: relative; +} + +.note-split.type-code:not(.mime-text-x-sqlite) > .scrolling-container { + background-color: var(--code-background-color); +} \ No newline at end of file diff --git a/apps/client/src/widgets/containers/scrolling_container.ts b/apps/client/src/widgets/containers/scrolling_container.ts index fab51254c..c6b67724f 100644 --- a/apps/client/src/widgets/containers/scrolling_container.ts +++ b/apps/client/src/widgets/containers/scrolling_container.ts @@ -2,6 +2,7 @@ import type { CommandListenerData, EventData, EventNames } from "../../component import type NoteContext from "../../components/note_context.js"; import type BasicWidget from "../basic_widget.js"; import Container from "./container.js"; +import "./scrolling_container.css"; export default class ScrollingContainer extends Container { @@ -11,9 +12,6 @@ export default class ScrollingContainer extends Container { super(); this.class("scrolling-container"); - this.css("overflow", "auto"); - this.css("scroll-behavior", "smooth"); - this.css("position", "relative"); } setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) { diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index 43cfa4e4e..4bb1d1711 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -6,7 +6,6 @@ import NoteAutocomplete from "../react/NoteAutocomplete"; import { useRef, useState, useEffect } from "preact/hooks"; import tree from "../../services/tree"; import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; -import { default as TextTypeWidget } from "../type_widgets/editable_text.js"; import { logError } from "../../services/ws"; import FormGroup from "../react/FormGroup.js"; import { refToJQuerySelector } from "../react/react_utils"; @@ -14,29 +13,32 @@ import { useTriliumEvent } from "../react/hooks"; type LinkType = "reference-link" | "external-link" | "hyper-link"; +export interface AddLinkOpts { + text: string; + hasSelection: boolean; + addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): Promise; +} + export default function AddLinkDialog() { - const [ textTypeWidget, setTextTypeWidget ] = useState(); - const initialText = useRef(); + const [ opts, setOpts ] = useState(); const [ linkTitle, setLinkTitle ] = useState(""); - const hasSelection = textTypeWidget?.hasSelection(); - const [ linkType, setLinkType ] = useState(hasSelection ? "hyper-link" : "reference-link"); + const [ linkType, setLinkType ] = useState(); const [ suggestion, setSuggestion ] = useState(null); const [ shown, setShown ] = useState(false); const hasSubmittedRef = useRef(false); - useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => { - setTextTypeWidget(textTypeWidget); - initialText.current = text; + useTriliumEvent("showAddLinkDialog", opts => { + setOpts(opts); setShown(true); }); useEffect(() => { - if (hasSelection) { + if (opts?.hasSelection) { setLinkType("hyper-link"); } else { setLinkType("reference-link"); } - }, [ hasSelection ]) + }, [ opts ]); async function setDefaultLinkTitle(noteId: string) { const noteTitle = await tree.getNoteTitle(noteId); @@ -71,10 +73,10 @@ export default function AddLinkDialog() { function onShown() { const $autocompleteEl = refToJQuerySelector(autocompleteRef); - if (!initialText.current) { + if (!opts?.text) { note_autocomplete.showRecentNotes($autocompleteEl); } else { - note_autocomplete.setText($autocompleteEl, initialText.current); + note_autocomplete.setText($autocompleteEl, opts.text); } // to be able to quickly remove entered text @@ -108,15 +110,15 @@ export default function AddLinkDialog() { onShown={onShown} onHidden={() => { // Insert the link. - if (hasSubmittedRef.current && suggestion && textTypeWidget) { + if (hasSubmittedRef.current && suggestion && opts) { hasSubmittedRef.current = false; if (suggestion.notePath) { // Handle note link - textTypeWidget.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); + opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); } else if (suggestion.externalLink) { // Handle external link - textTypeWidget.addLink(suggestion.externalLink, linkTitle, true); + opts.addLink(suggestion.externalLink, linkTitle, true); } } @@ -136,7 +138,7 @@ export default function AddLinkDialog() { /> - {!hasSelection && ( + {!opts?.hasSelection && (
{(linkType !== "external-link") && ( <> diff --git a/apps/client/src/widgets/dialogs/include_note.tsx b/apps/client/src/widgets/dialogs/include_note.tsx index 911ed0dc0..aabd64bab 100644 --- a/apps/client/src/widgets/dialogs/include_note.tsx +++ b/apps/client/src/widgets/dialogs/include_note.tsx @@ -8,17 +8,21 @@ import Button from "../react/Button"; import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; import tree from "../../services/tree"; import froca from "../../services/froca"; -import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text"; import { useTriliumEvent } from "../react/hooks"; +import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog"; + +export interface IncludeNoteOpts { + editorApi: CKEditorApi; +} export default function IncludeNoteDialog() { - const [textTypeWidget, setTextTypeWidget] = useState(); + const editorApiRef = useRef(null); const [suggestion, setSuggestion] = useState(null); - const [boxSize, setBoxSize] = useState("medium"); + const [boxSize, setBoxSize] = useState("medium"); const [shown, setShown] = useState(false); - useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => { - setTextTypeWidget(textTypeWidget); + useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => { + editorApiRef.current = editorApi; setShown(true); }); @@ -32,12 +36,9 @@ export default function IncludeNoteDialog() { onShown={() => triggerRecentNotes(autoCompleteRef.current)} onHidden={() => setShown(false)} onSubmit={() => { - if (!suggestion?.notePath || !textTypeWidget) { - return; - } - + if (!suggestion?.notePath || !editorApiRef.current) return; setShown(false); - includeNote(suggestion.notePath, textTypeWidget, boxSize as BoxSize); + includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize); }} footer={ - -
- - - -
- - -
- -
- -
-`; - -type WidgetMode = "type" | "ribbon"; -type MapType = "tree" | "link"; -type Data = GraphData>; - -interface Node extends NodeObject { - id: string; - name: string; - type: string; - color: string; -} - -interface Link extends LinkObject { - id: string; - name: string; - - x: number; - y: number; - source: Node; - target: Node; -} - -interface NotesAndRelationsData { - nodes: Node[]; - links: { - id: string; - source: string; - target: string; - name: string; - }[]; -} - -// Replace -interface ResponseLink { - key: string; - sourceNoteId: string; - targetNoteId: string; - name: string; -} - -interface PostNotesMapResponse { - notes: string[]; - links: ResponseLink[]; - noteIdToDescendantCountMap: Record; -} - -interface GroupedLink { - id: string; - sourceNoteId: string; - targetNoteId: string; - names: string[]; -} - -interface CssData { - fontFamily: string; - textColor: string; - mutedTextColor: string; -} - -export default class NoteMapWidget extends NoteContextAwareWidget { - - private fixNodes: boolean; - private widgetMode: WidgetMode; - private mapType?: MapType; - private cssData!: CssData; - - private themeStyle!: string; - private $container!: JQuery; - private $styleResolver!: JQuery; - private $fixNodesButton!: JQuery; - graph!: ForceGraph; - private noteIdToSizeMap!: Record; - private zoomLevel!: number; - private nodes!: Node[]; - - constructor(widgetMode: WidgetMode) { - super(); - this.fixNodes = false; // needed to save the status of the UI element. Is set later in the code - this.widgetMode = widgetMode; // 'type' or 'ribbon' - } - - doRender() { - this.$widget = $(TPL); - - const documentStyle = window.getComputedStyle(document.documentElement); - this.themeStyle = documentStyle.getPropertyValue("--theme-style")?.trim(); - - this.$container = this.$widget.find(".note-map-container"); - this.$styleResolver = this.$widget.find(".style-resolver"); - this.$fixNodesButton = this.$widget.find(".fixnodes-type-switcher > button"); - - new ResizeObserver(() => this.setDimensions()).observe(this.$container[0]); - - this.$widget.find(".map-type-switcher button").on("click", async (e) => { - const type = $(e.target).closest("button").attr("data-type"); - - await attributeService.setLabel(this.noteId ?? "", "mapType", type); - }); - - // Reading the status of the Drag nodes Ui element. Changing it´s color when activated. - // Reading Force value of the link distance. - this.$fixNodesButton.on("click", async (event) => { - this.fixNodes = !this.fixNodes; - this.$fixNodesButton.toggleClass("toggled", this.fixNodes); - }); - - super.doRender(); - } - - setDimensions() { - if (!this.graph) { - // no graph has been even rendered - return; - } - - const $parent = this.$widget.parent(); - - this.graph - .height($parent.height() || 0) - .width($parent.width() || 0); - } - - async refreshWithNote(note: FNote) { - this.$widget.show(); - - this.cssData = { - fontFamily: this.$container.css("font-family"), - textColor: this.rgb2hex(this.$container.css("color")), - mutedTextColor: this.rgb2hex(this.$styleResolver.css("color")) - }; - - this.mapType = note.getLabelValue("mapType") === "tree" ? "tree" : "link"; - - //variables for the hover effekt. We have to save the neighbours of a hovered node in a set. Also we need to save the links as well as the hovered node itself - - let hoverNode: NodeObject | null = null; - const highlightLinks = new Set(); - const neighbours = new Set(); - - const ForceGraph = (await import("force-graph")).default; - this.graph = new ForceGraph(this.$container[0]) - .width(this.$container.width() || 0) - .height(this.$container.height() || 0) - .onZoom((zoom) => this.setZoomLevel(zoom.k)) - .d3AlphaDecay(0.01) - .d3VelocityDecay(0.08) - - //Code to fixate nodes when dragged - .onNodeDragEnd((node) => { - if (this.fixNodes) { - node.fx = node.x; - node.fy = node.y; - } else { - node.fx = undefined; - node.fy = undefined; - } - }) - //check if hovered and set the hovernode variable, saving the hovered node object into it. Clear links variable everytime you hover. Without clearing links will stay highlighted - .onNodeHover((node) => { - hoverNode = node || null; - highlightLinks.clear(); - }) - - // set link width to immitate a highlight effekt. Checking the condition if any links are saved in the previous defined set highlightlinks - .linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4)) - .linkColor((link) => (highlightLinks.has(link) ? "white" : this.cssData.mutedTextColor)) - .linkDirectionalArrowLength(4) - .linkDirectionalArrowRelPos(0.95) - - // main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second. - .nodeCanvasObject((_node, ctx) => { - const node = _node as Node; - if (hoverNode == node) { - //paint only hovered node - this.paintNode(node, "#661822", ctx); - neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over - for (const _link of data.links) { - const link = _link as unknown as Link; - //check if node is part of a link in the canvas, if so add it´s neighbours and related links to the previous defined variables to paint the nodes - if (link.source.id == node.id || link.target.id == node.id) { - neighbours.add(link.source); - neighbours.add(link.target); - highlightLinks.add(link); - neighbours.delete(node); - } - } - } else if (neighbours.has(node) && hoverNode != null) { - //paint neighbours - this.paintNode(node, "#9d6363", ctx); - } else { - this.paintNode(node, this.getColorForNode(node), ctx); //paint rest of nodes in canvas - } - }) - - .nodePointerAreaPaint((node, _, ctx) => this.paintNode(node as Node, this.getColorForNode(node as Node), ctx)) - .nodePointerAreaPaint((node, color, ctx) => { - if (!node.id) { - return; - } - - ctx.fillStyle = color; - ctx.beginPath(); - if (node.x && node.y) { - ctx.arc(node.x, node.y, this.noteIdToSizeMap[node.id], 0, 2 * Math.PI, false); - } - ctx.fill(); - }) - .nodeLabel((node) => esc((node as Node).name)) - .maxZoom(7) - .warmupTicks(30) - .onNodeClick((node) => { - if (node.id) { - appContext.tabManager.getActiveContext()?.setNote((node as Node).id); - } - }) - .onNodeRightClick((node, e) => { - if (node.id) { - linkContextMenuService.openContextMenu((node as Node).id, e); - } - }); - - if (this.mapType === "link") { - this.graph - .linkLabel((l) => `${esc((l as Link).source.name)} - ${esc((l as Link).name)} - ${esc((l as Link).target.name)}`) - .linkCanvasObject((link, ctx) => this.paintLink(link as Link, ctx)) - .linkCanvasObjectMode(() => "after"); - } - - const mapRootNoteId = this.getMapRootNoteId(); - - const labelValues = (name: string) => this.note?.getLabels(name).map(l => l.value) ?? []; - - const excludeRelations = labelValues("mapExcludeRelation"); - const includeRelations = labelValues("mapIncludeRelation"); - - const data = await this.loadNotesAndRelations(mapRootNoteId, excludeRelations, includeRelations); - - const nodeLinkRatio = data.nodes.length / data.links.length; - const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5); - const charge = -20 / magnifiedRatio; - const boundedCharge = Math.min(-3, charge); - let distancevalue = 40; // default value for the link force of the nodes - - this.$widget.find(".fixnodes-type-switcher input").on("change", async (e) => { - distancevalue = parseInt(e.target.closest("input")?.value ?? "0"); - this.graph.d3Force("link")?.distance(distancevalue); - - this.renderData(data); - }); - - this.graph.d3Force("center")?.strength(0.2); - this.graph.d3Force("charge")?.strength(boundedCharge); - this.graph.d3Force("charge")?.distanceMax(1000); - - this.renderData(data); - } - - getMapRootNoteId(): string { - if (this.noteId && this.widgetMode === "ribbon") { - return this.noteId; - } - - let mapRootNoteId = this.note?.getLabelValue("mapRootNoteId"); - - if (mapRootNoteId === "hoisted") { - mapRootNoteId = hoistedNoteService.getHoistedNoteId(); - } else if (!mapRootNoteId) { - mapRootNoteId = appContext.tabManager.getActiveContext()?.parentNoteId; - } - - return mapRootNoteId ?? ""; - } - - getColorForNode(node: Node) { - if (node.color) { - return node.color; - } else if (this.widgetMode === "ribbon" && node.id === this.noteId) { - return "red"; // subtree root mark as red - } else { - return this.generateColorFromString(node.type); - } - } - - generateColorFromString(str: string) { - if (this.themeStyle === "dark") { - str = `0${str}`; // magic lightning modifier - } - - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - - let color = "#"; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xff; - - color += `00${value.toString(16)}`.substr(-2); - } - return color; - } - - rgb2hex(rgb: string) { - return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || []) - .slice(1) - .map((n) => parseInt(n, 10).toString(16).padStart(2, "0")) - .join("")}`; - } - - setZoomLevel(level: number) { - this.zoomLevel = level; - } - - paintNode(node: Node, color: string, ctx: CanvasRenderingContext2D) { - const { x, y } = node; - if (!x || !y) { - return; - } - const size = this.noteIdToSizeMap[node.id]; - - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(x, y, size * 0.8, 0, 2 * Math.PI, false); - ctx.fill(); - - const toRender = this.zoomLevel > 2 || (this.zoomLevel > 1 && size > 6) || (this.zoomLevel > 0.3 && size > 10); - - if (!toRender) { - return; - } - - ctx.fillStyle = this.cssData.textColor; - ctx.font = `${size}px ${this.cssData.fontFamily}`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - let title = node.name; - - if (title.length > 15) { - title = `${title.substr(0, 15)}...`; - } - - ctx.fillText(title, x, y + Math.round(size * 1.5)); - } - - paintLink(link: Link, ctx: CanvasRenderingContext2D) { - if (this.zoomLevel < 5) { - return; - } - - ctx.font = `3px ${this.cssData.fontFamily}`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = this.cssData.mutedTextColor; - - const { source, target } = link; - if (typeof source !== "object" || typeof target !== "object") { - return; - } - - if (source.x && source.y && target.x && target.y) { - const x = (source.x + target.x) / 2; - const y = (source.y + target.y) / 2; - ctx.save(); - ctx.translate(x, y); - - const deltaY = source.y - target.y; - const deltaX = source.x - target.x; - - let angle = Math.atan2(deltaY, deltaX); - let moveY = 2; - - if (angle < -Math.PI / 2 || angle > Math.PI / 2) { - angle += Math.PI; - moveY = -2; - } - - ctx.rotate(angle); - ctx.fillText(link.name, 0, moveY); - } - - ctx.restore(); - } - - async loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[]): Promise { - const resp = await server.post(`note-map/${mapRootNoteId}/${this.mapType}`, { - excludeRelations, includeRelations - }); - - this.calculateNodeSizes(resp); - - const links = this.getGroupedLinks(resp.links); - - this.nodes = resp.notes.map(([noteId, title, type, color]) => ({ - id: noteId, - name: title, - type: type, - color: color - })); - - return { - nodes: this.nodes, - links: links.map((link) => ({ - id: `${link.sourceNoteId}-${link.targetNoteId}`, - source: link.sourceNoteId, - target: link.targetNoteId, - name: link.names.join(", ") - })) - }; - } - - getGroupedLinks(links: ResponseLink[]): GroupedLink[] { - const linksGroupedBySourceTarget: Record = {}; - - for (const link of links) { - const key = `${link.sourceNoteId}-${link.targetNoteId}`; - - if (key in linksGroupedBySourceTarget) { - if (!linksGroupedBySourceTarget[key].names.includes(link.name)) { - linksGroupedBySourceTarget[key].names.push(link.name); - } - } else { - linksGroupedBySourceTarget[key] = { - id: key, - sourceNoteId: link.sourceNoteId, - targetNoteId: link.targetNoteId, - names: [link.name] - }; - } - } - - return Object.values(linksGroupedBySourceTarget); - } - - calculateNodeSizes(resp: PostNotesMapResponse) { - this.noteIdToSizeMap = {}; - - if (this.mapType === "tree") { - const { noteIdToDescendantCountMap } = resp; - - for (const noteId in noteIdToDescendantCountMap) { - this.noteIdToSizeMap[noteId] = 4; - - const count = noteIdToDescendantCountMap[noteId]; - - if (count > 0) { - this.noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5)); - } - } - } else if (this.mapType === "link") { - const noteIdToLinkCount: Record = {}; - - for (const link of resp.links) { - noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0); - } - - for (const [noteId] of resp.notes) { - this.noteIdToSizeMap[noteId] = 4; - - if (noteId in noteIdToLinkCount) { - this.noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15); - } - } - } - } - - renderData(data: Data) { - this.graph.graphData(data); - - if (this.widgetMode === "ribbon" && this.note?.type !== "search") { - setTimeout(() => { - this.setDimensions(); - - const subGraphNoteIds = this.getSubGraphConnectedToCurrentNote(data); - - this.graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id)); - - if (subGraphNoteIds.size < 30) { - this.graph.d3VelocityDecay(0.4); - } - }, 1000); - } else { - if (data.nodes.length > 1) { - setTimeout(() => { - this.setDimensions(); - - const noteIdsWithLinks = this.getNoteIdsWithLinks(data); - - if (noteIdsWithLinks.size > 0) { - this.graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? "")); - } - - if (noteIdsWithLinks.size < 30) { - this.graph.d3VelocityDecay(0.4); - } - }, 1000); - } - } - } - - getNoteIdsWithLinks(data: Data) { - const noteIds = new Set(); - - for (const link of data.links) { - if (typeof link.source === "object" && link.source.id) { - noteIds.add(link.source.id); - } - if (typeof link.target === "object" && link.target.id) { - noteIds.add(link.target.id); - } - } - - return noteIds; - } - - getSubGraphConnectedToCurrentNote(data: Data) { - function getGroupedLinks(links: LinkObject[], type: "source" | "target") { - const map: Record[]> = {}; - - for (const link of links) { - if (typeof link[type] !== "object") { - continue; - } - - const key = link[type].id; - if (key) { - map[key] = map[key] || []; - map[key].push(link); - } - } - - return map; - } - - const linksBySource = getGroupedLinks(data.links, "source"); - const linksByTarget = getGroupedLinks(data.links, "target"); - - const subGraphNoteIds = new Set(); - - function traverseGraph(noteId?: string | number) { - if (!noteId || subGraphNoteIds.has(noteId)) { - return; - } - - subGraphNoteIds.add(noteId); - - for (const link of linksBySource[noteId] || []) { - if (typeof link.target === "object") { - traverseGraph(link.target?.id); - } - } - - for (const link of linksByTarget[noteId] || []) { - if (typeof link.source === "object") { - traverseGraph(link.source?.id); - } - } - } - - traverseGraph(this.noteId); - return subGraphNoteIds; - } - - cleanup() { - this.$container.html(""); - } - - entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - if (loadResults.getAttributeRows(this.componentId) - .find((attr) => attr.type === "label" && ["mapType", "mapRootNoteId"].includes(attr.name || "") && attributeService.isAffecting(attr, this.note))) { - this.refresh(); - } - } -} diff --git a/apps/client/src/widgets/note_map/NoteMap.css b/apps/client/src/widgets/note_map/NoteMap.css new file mode 100644 index 000000000..fa49bb39c --- /dev/null +++ b/apps/client/src/widgets/note_map/NoteMap.css @@ -0,0 +1,57 @@ +.note-detail-note-map { + height: 100%; + overflow: hidden; +} + +/* Style Ui Element to Drag Nodes */ +.fixnodes-type-switcher { + display: flex; + align-items: center; + z-index: 10; /* should be below dropdown (note actions) */ + border-radius: .2rem; +} + +/* Start of styling the slider */ +.fixnodes-type-switcher input[type="range"] { + + /* removing default appearance */ + -webkit-appearance: none; + appearance: none; + margin-inline-start: 15px; + width: 150px; +} + +/* Changing slider tracker */ +.fixnodes-type-switcher input[type="range"]::-webkit-slider-runnable-track { + height: 4px; + background-color: var(--main-border-color); + border-radius: 4px; +} + +/* Changing Slider Thumb */ +.fixnodes-type-switcher input[type="range"]::-webkit-slider-thumb { + /* removing default appearance */ + -webkit-appearance: none; + appearance: none; + /* creating a custom design */ + height: 15px; + width: 15px; + margin-top:-5px; + background-color: var(--accented-background-color); + border: 1px solid var(--main-text-color); + border-radius: 50%; +} + +.fixnodes-type-switcher input[type="range"]::-moz-range-track { + background-color: var(--main-border-color); + border-radius: 4px; +} + +.fixnodes-type-switcher input[type="range"]::-moz-range-thumb { + background-color: var(--accented-background-color); + border-color: var(--main-text-color); + height: 10px; + width: 10px; +} + +/* End of styling the slider */ \ No newline at end of file diff --git a/apps/client/src/widgets/note_map/NoteMap.tsx b/apps/client/src/widgets/note_map/NoteMap.tsx new file mode 100644 index 000000000..1c503c363 --- /dev/null +++ b/apps/client/src/widgets/note_map/NoteMap.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import "./NoteMap.css"; +import { getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils"; +import { RefObject } from "preact"; +import FNote from "../../entities/fnote"; +import { useElementSize, useNoteLabel } from "../react/hooks"; +import ForceGraph from "force-graph"; +import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data"; +import { CssData, setupRendering } from "./rendering"; +import ActionButton from "../react/ActionButton"; +import { t } from "../../services/i18n"; +import link_context_menu from "../../menus/link_context_menu"; +import appContext from "../../components/app_context"; +import Slider from "../react/Slider"; +import hoisted_note from "../../services/hoisted_note"; + +interface NoteMapProps { + note: FNote; + widgetMode: NoteMapWidgetMode; + parentRef: RefObject; +} + +export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { + const containerRef = useRef(null); + const styleResolverRef = useRef(null); + const [ mapTypeRaw, setMapType ] = useNoteLabel(note, "mapType"); + const [ mapRootIdLabel ] = useNoteLabel(note, "mapRootNoteId"); + const mapType: MapType = mapTypeRaw === "tree" ? "tree" : "link"; + + const graphRef = useRef>(); + const containerSize = useElementSize(parentRef); + const [ fixNodes, setFixNodes ] = useState(false); + const [ linkDistance, setLinkDistance ] = useState(40); + const notesAndRelationsRef = useRef(); + + const mapRootId = useMemo(() => { + if (note.noteId && widgetMode === "ribbon") { + return note.noteId; + } else if (mapRootIdLabel === "hoisted") { + return hoisted_note.getHoistedNoteId(); + } else if (mapRootIdLabel) { + return mapRootIdLabel; + } else { + return appContext.tabManager.getActiveContext()?.parentNoteId ?? null; + } + }, [ note ]); + + // Build the note graph instance. + useEffect(() => { + const container = containerRef.current; + if (!container || !mapRootId) return; + const graph = new ForceGraph(container); + + graphRef.current = graph; + + const labelValues = (name: string) => note.getLabels(name).map(l => l.value) ?? []; + const excludeRelations = labelValues("mapExcludeRelation"); + const includeRelations = labelValues("mapIncludeRelation"); + loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => { + if (!containerRef.current || !styleResolverRef.current) return; + const cssData = getCssData(containerRef.current, styleResolverRef.current); + + // Configure rendering properties. + setupRendering(graph, { + note, + noteId: note.noteId, + noteIdToSizeMap: notesAndRelations.noteIdToSizeMap, + cssData, + notesAndRelations, + themeStyle: getThemeStyle(), + widgetMode, + mapType + }); + + // Interaction + graph + .onNodeClick((node) => { + if (!node.id) return; + appContext.tabManager.getActiveContext()?.setNote(node.id); + }) + .onNodeRightClick((node, e) => { + if (!node.id) return; + link_context_menu.openContextMenu(node.id, e); + }); + + // Set data + graph.graphData(notesAndRelations); + notesAndRelationsRef.current = notesAndRelations; + }); + + return () => container.replaceChildren(); + }, [ note, mapType ]); + + useEffect(() => { + if (!graphRef.current || !notesAndRelationsRef.current) return; + graphRef.current.d3Force("link")?.distance(linkDistance); + graphRef.current.graphData(notesAndRelationsRef.current); + }, [ linkDistance ]); + + // React to container size + useEffect(() => { + if (!containerSize || !graphRef.current) return; + graphRef.current.width(containerSize.width).height(containerSize.height); + }, [ containerSize?.width, containerSize?.height ]); + + // Fixing nodes when dragged. + useEffect(() => { + graphRef.current?.onNodeDragEnd((node) => { + if (fixNodes) { + node.fx = node.x; + node.fy = node.y; + } else { + node.fx = undefined; + node.fy = undefined; + } + }) + }, [ fixNodes ]); + + return ( +
+
+ + +
+ +
+ setFixNodes(!fixNodes)} + frame + /> + + +
+ +
+
+
+ ); +} + +function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: { + icon: string; + text: string; + type: MapType; + currentMapType: MapType; + setMapType: (type: MapType) => void; +}) { + return ( + setMapType(type)} + frame + /> + ) +} + +function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData { + const containerStyle = window.getComputedStyle(container); + const styleResolverStyle = window.getComputedStyle(styleResolver); + + return { + fontFamily: containerStyle.fontFamily, + textColor: rgb2hex(containerStyle.color), + mutedTextColor: rgb2hex(styleResolverStyle.color) + } +} diff --git a/apps/client/src/widgets/note_map/data.ts b/apps/client/src/widgets/note_map/data.ts new file mode 100644 index 000000000..1f54f66b3 --- /dev/null +++ b/apps/client/src/widgets/note_map/data.ts @@ -0,0 +1,120 @@ +import { NoteMapLink, NoteMapPostResponse } from "@triliumnext/commons"; +import server from "../../services/server"; +import { LinkObject, NodeObject } from "force-graph"; + +type MapType = "tree" | "link"; + +interface GroupedLink { + id: string; + sourceNoteId: string; + targetNoteId: string; + names: string[]; +} + +export interface NoteMapNodeObject extends NodeObject { + id: string; + name: string; + type: string; + color: string; +} + +export interface NoteMapLinkObject extends LinkObject { + id: string; + name: string; + x?: number; + y?: number; +} + +export interface NotesAndRelationsData { + nodes: NoteMapNodeObject[]; + links: { + id: string; + source: string | NoteMapNodeObject; + target: string | NoteMapNodeObject; + name: string; + }[]; + noteIdToSizeMap: Record; +} + +export async function loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[], mapType: MapType): Promise { + const resp = await server.post(`note-map/${mapRootNoteId}/${mapType}`, { + excludeRelations, includeRelations + }); + + const noteIdToSizeMap = calculateNodeSizes(resp, mapType); + const links = getGroupedLinks(resp.links); + const nodes = resp.notes.map(([noteId, title, type, color]) => ({ + id: noteId, + name: title, + type: type, + color: color + })); + + return { + noteIdToSizeMap, + nodes, + links: links.map((link) => ({ + id: `${link.sourceNoteId}-${link.targetNoteId}`, + source: link.sourceNoteId, + target: link.targetNoteId, + name: link.names.join(", ") + })) + }; +} + +function calculateNodeSizes(resp: NoteMapPostResponse, mapType: MapType) { + const noteIdToSizeMap: Record = {}; + + if (mapType === "tree") { + const { noteIdToDescendantCountMap } = resp; + + for (const noteId in noteIdToDescendantCountMap) { + noteIdToSizeMap[noteId] = 4; + + const count = noteIdToDescendantCountMap[noteId]; + + if (count > 0) { + noteIdToSizeMap[noteId] += 1 + Math.round(Math.log(count) / Math.log(1.5)); + } + } + } else if (mapType === "link") { + const noteIdToLinkCount: Record = {}; + + for (const link of resp.links) { + noteIdToLinkCount[link.targetNoteId] = 1 + (noteIdToLinkCount[link.targetNoteId] || 0); + } + + for (const [noteId] of resp.notes) { + noteIdToSizeMap[noteId] = 4; + + if (noteId in noteIdToLinkCount) { + noteIdToSizeMap[noteId] += Math.min(Math.pow(noteIdToLinkCount[noteId], 0.5), 15); + } + } + } + + return noteIdToSizeMap; +} + +function getGroupedLinks(links: NoteMapLink[]): GroupedLink[] { + const linksGroupedBySourceTarget: Record = {}; + + for (const link of links) { + const key = `${link.sourceNoteId}-${link.targetNoteId}`; + + if (key in linksGroupedBySourceTarget) { + if (!linksGroupedBySourceTarget[key].names.includes(link.name)) { + linksGroupedBySourceTarget[key].names.push(link.name); + } + } else { + linksGroupedBySourceTarget[key] = { + id: key, + sourceNoteId: link.sourceNoteId, + targetNoteId: link.targetNoteId, + names: [link.name] + }; + } + } + + return Object.values(linksGroupedBySourceTarget); +} diff --git a/apps/client/src/widgets/note_map/rendering.ts b/apps/client/src/widgets/note_map/rendering.ts new file mode 100644 index 000000000..129577521 --- /dev/null +++ b/apps/client/src/widgets/note_map/rendering.ts @@ -0,0 +1,282 @@ +import type ForceGraph from "force-graph"; +import { NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data"; +import { LinkObject, NodeObject } from "force-graph"; +import { generateColorFromString, MapType, NoteMapWidgetMode } from "./utils"; +import { escapeHtml } from "../../services/utils"; +import FNote from "../../entities/fnote"; + +export interface CssData { + fontFamily: string; + textColor: string; + mutedTextColor: string; +} + +interface RenderData { + note: FNote; + noteIdToSizeMap: Record; + cssData: CssData; + noteId: string; + themeStyle: "light" | "dark"; + widgetMode: NoteMapWidgetMode; + notesAndRelations: NotesAndRelationsData; + mapType: MapType; +} + +export function setupRendering(graph: ForceGraph, { note, noteId, themeStyle, widgetMode, noteIdToSizeMap, notesAndRelations, cssData, mapType }: RenderData) { + // variables for the hover effect. We have to save the neighbours of a hovered node in a set. Also we need to save the links as well as the hovered node itself + const neighbours = new Set(); + const highlightLinks = new Set(); + let hoverNode: NodeObject | null = null; + let zoomLevel: number; + + function getColorForNode(node: NoteMapNodeObject) { + if (node.color) { + return node.color; + } else if (widgetMode === "ribbon" && node.id === noteId) { + return "red"; // subtree root mark as red + } else { + return generateColorFromString(node.type, themeStyle); + } + } + + function paintNode(node: NoteMapNodeObject, color: string, ctx: CanvasRenderingContext2D) { + const { x, y } = node; + if (!x || !y) { + return; + } + const size = noteIdToSizeMap[node.id]; + + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, size * 0.8, 0, 2 * Math.PI, false); + ctx.fill(); + + const toRender = zoomLevel > 2 || (zoomLevel > 1 && size > 6) || (zoomLevel > 0.3 && size > 10); + + if (!toRender) { + return; + } + + ctx.fillStyle = cssData.textColor; + ctx.font = `${size}px ${cssData.fontFamily}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + let title = node.name; + + if (title.length > 15) { + title = `${title.substr(0, 15)}...`; + } + + ctx.fillText(title, x, y + Math.round(size * 1.5)); + } + + + function paintLink(link: NoteMapLinkObject, ctx: CanvasRenderingContext2D) { + if (zoomLevel < 5) { + return; + } + + ctx.font = `3px ${cssData.fontFamily}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = cssData.mutedTextColor; + + const { source, target } = link; + if (typeof source !== "object" || typeof target !== "object") { + return; + } + + if (source.x && source.y && target.x && target.y) { + const x = (source.x + target.x) / 2; + const y = (source.y + target.y) / 2; + ctx.save(); + ctx.translate(x, y); + + const deltaY = source.y - target.y; + const deltaX = source.x - target.x; + + let angle = Math.atan2(deltaY, deltaX); + let moveY = 2; + + if (angle < -Math.PI / 2 || angle > Math.PI / 2) { + angle += Math.PI; + moveY = -2; + } + + ctx.rotate(angle); + ctx.fillText(link.name, 0, moveY); + } + + ctx.restore(); + } + + // main code for highlighting hovered nodes and neighbours. here we "style" the nodes. the nodes are rendered several hundred times per second. + graph + .d3AlphaDecay(0.01) + .d3VelocityDecay(0.08) + .maxZoom(7) + .warmupTicks(30) + .nodeCanvasObject((node, ctx) => { + if (hoverNode == node) { + //paint only hovered node + paintNode(node, "#661822", ctx); + neighbours.clear(); //clearing neighbours or the effect would be maintained after hovering is over + for (const link of notesAndRelations.links) { + const { source, target } = link; + if (typeof source !== "object" || typeof target !== "object") continue; + + //check if node is part of a link in the canvas, if so add it´s neighbours and related links to the previous defined variables to paint the nodes + if (source.id == node.id || target.id == node.id) { + neighbours.add(link.source); + neighbours.add(link.target); + highlightLinks.add(link); + neighbours.delete(node); + } + } + } else if (neighbours.has(node) && hoverNode != null) { + //paint neighbours + paintNode(node, "#9d6363", ctx); + } else { + paintNode(node, getColorForNode(node), ctx); //paint rest of nodes in canvas + } + }) + //check if hovered and set the hovernode variable, saving the hovered node object into it. Clear links variable everytime you hover. Without clearing links will stay highlighted + .onNodeHover((node) => { + hoverNode = node || null; + highlightLinks.clear(); + }) + .nodePointerAreaPaint((node, _, ctx) => paintNode(node, getColorForNode(node), ctx)) + .nodePointerAreaPaint((node, color, ctx) => { + if (!node.id) { + return; + } + + ctx.fillStyle = color; + ctx.beginPath(); + if (node.x && node.y) { + ctx.arc(node.x, node.y, noteIdToSizeMap[node.id], 0, 2 * Math.PI, false); + } + ctx.fill(); + }) + .nodeLabel((node) => escapeHtml(node.name)) + .onZoom((zoom) => zoomLevel = zoom.k); + + // set link width to immitate a highlight effect. Checking the condition if any links are saved in the previous defined set highlightlinks + graph + .linkWidth((link) => (highlightLinks.has(link) ? 3 : 0.4)) + .linkColor((link) => (highlightLinks.has(link) ? cssData.textColor : cssData.mutedTextColor)) + .linkDirectionalArrowLength(4) + .linkDirectionalArrowRelPos(0.95); + + // Link-specific config + if (mapType) { + graph + .linkLabel((link) => { + const { source, target } = link; + if (typeof source !== "object" || typeof target !== "object") return escapeHtml(link.name); + return `${escapeHtml(source.name)} - ${escapeHtml(link.name)} - ${escapeHtml(target.name)}`; + }) + .linkCanvasObject((link, ctx) => paintLink(link, ctx)) + .linkCanvasObjectMode(() => "after"); + } + + // Forces + const nodeLinkRatio = notesAndRelations.nodes.length / notesAndRelations.links.length; + const magnifiedRatio = Math.pow(nodeLinkRatio, 1.5); + const charge = -20 / magnifiedRatio; + const boundedCharge = Math.min(-3, charge); + graph.d3Force("center")?.strength(0.2); + graph.d3Force("charge")?.strength(boundedCharge); + graph.d3Force("charge")?.distanceMax(1000); + + // Zoom to notes + if (widgetMode === "ribbon" && note?.type !== "search") { + setTimeout(() => { + const subGraphNoteIds = getSubGraphConnectedToCurrentNote(noteId, notesAndRelations); + + graph.zoomToFit(400, 50, (node) => subGraphNoteIds.has(node.id)); + + if (subGraphNoteIds.size < 30) { + graph.d3VelocityDecay(0.4); + } + }, 1000); + } else { + if (notesAndRelations.nodes.length > 1) { + setTimeout(() => { + const noteIdsWithLinks = getNoteIdsWithLinks(notesAndRelations); + + if (noteIdsWithLinks.size > 0) { + graph.zoomToFit(400, 30, (node) => noteIdsWithLinks.has(node.id ?? "")); + } + + if (noteIdsWithLinks.size < 30) { + graph.d3VelocityDecay(0.4); + } + }, 1000); + } + } +} + +function getNoteIdsWithLinks(data: NotesAndRelationsData) { + const noteIds = new Set(); + + for (const link of data.links) { + if (typeof link.source === "object" && link.source.id) { + noteIds.add(link.source.id); + } + if (typeof link.target === "object" && link.target.id) { + noteIds.add(link.target.id); + } + } + + return noteIds; +} + +function getSubGraphConnectedToCurrentNote(noteId: string, data: NotesAndRelationsData) { + function getGroupedLinks(links: LinkObject[], type: "source" | "target") { + const map: Record[]> = {}; + + for (const link of links) { + if (typeof link[type] !== "object") { + continue; + } + + const key = link[type].id; + if (key) { + map[key] = map[key] || []; + map[key].push(link); + } + } + + return map; + } + + const linksBySource = getGroupedLinks(data.links, "source"); + const linksByTarget = getGroupedLinks(data.links, "target"); + + const subGraphNoteIds = new Set(); + + function traverseGraph(noteId?: string | number) { + if (!noteId || subGraphNoteIds.has(noteId)) { + return; + } + + subGraphNoteIds.add(noteId); + + for (const link of linksBySource[noteId] || []) { + if (typeof link.target === "object") { + traverseGraph(link.target?.id); + } + } + + for (const link of linksByTarget[noteId] || []) { + if (typeof link.source === "object") { + traverseGraph(link.source?.id); + } + } + } + + traverseGraph(noteId); + return subGraphNoteIds; +} diff --git a/apps/client/src/widgets/note_map/utils.ts b/apps/client/src/widgets/note_map/utils.ts new file mode 100644 index 000000000..d551ea235 --- /dev/null +++ b/apps/client/src/widgets/note_map/utils.ts @@ -0,0 +1,33 @@ +export type NoteMapWidgetMode = "ribbon" | "hoisted" | "type"; +export type MapType = "tree" | "link"; + +export function rgb2hex(rgb: string) { + return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || []) + .slice(1) + .map((n) => parseInt(n, 10).toString(16).padStart(2, "0")) + .join("")}`; +} + +export function generateColorFromString(str: string, themeStyle: "light" | "dark") { + if (themeStyle === "dark") { + str = `0${str}`; // magic lightning modifier + } + + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = "#"; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + + color += `00${value.toString(16)}`.substr(-2); + } + return color; +} + +export function getThemeStyle() { + const documentStyle = window.getComputedStyle(document.documentElement); + return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark"; +} diff --git a/apps/client/src/widgets/note_title.tsx b/apps/client/src/widgets/note_title.tsx index 8207198d6..7f7f0cd22 100644 --- a/apps/client/src/widgets/note_title.tsx +++ b/apps/client/src/widgets/note_title.tsx @@ -47,7 +47,9 @@ export default function NoteTitleWidget() { // Prevent user from navigating away if the spaced update is not done. useEffect(() => { - appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate()); + const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate(); + appContext.addBeforeUnloadListener(listener); + return () => appContext.removeBeforeUnloadListener(listener); }, []); useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary()); diff --git a/apps/client/src/widgets/note_types.tsx b/apps/client/src/widgets/note_types.tsx new file mode 100644 index 000000000..a6b3feb27 --- /dev/null +++ b/apps/client/src/widgets/note_types.tsx @@ -0,0 +1,143 @@ +/** + * @module + * Contains the definitions for all the note types supported by the application. + */ + +import { NoteType } from "@triliumnext/commons"; +import { VNode, type JSX } from "preact"; +import { TypeWidgetProps } from "./type_widgets/type_widget"; + +/** + * A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one, + * for protected session or attachment information. + */ +export type ExtendedNoteType = Exclude | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "aiChat"; + +export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element); +type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget); + +interface NoteTypeMapping { + view: NoteTypeView; + printable?: boolean; + /** The class name to assign to the note type wrapper */ + className: string; + isFullHeight?: boolean; +} + +export const TYPE_MAPPINGS: Record = { + empty: { + view: () => import("./type_widgets/Empty"), + className: "note-detail-empty", + printable: true + }, + doc: { + view: () => import("./type_widgets/Doc"), + className: "note-detail-doc", + printable: true + }, + search: { + view: () => (props: TypeWidgetProps) => <>, + className: "note-detail-none", + printable: true + }, + protectedSession: { + view: () => import("./type_widgets/ProtectedSession"), + className: "protected-session-password-component" + }, + book: { + view: () => import("./type_widgets/Book"), + className: "note-detail-book", + printable: true, + }, + contentWidget: { + view: () => import("./type_widgets/ContentWidget"), + className: "note-detail-content-widget", + printable: true + }, + webView: { + view: () => import("./type_widgets/WebView"), + className: "note-detail-web-view", + printable: true, + isFullHeight: true + }, + file: { + view: () => import("./type_widgets/File"), + className: "note-detail-file", + printable: true, + isFullHeight: true + }, + image: { + view: () => import("./type_widgets/Image"), + className: "note-detail-image", + printable: true + }, + readOnlyCode: { + view: async () => (await import("./type_widgets/code/Code")).ReadOnlyCode, + className: "note-detail-readonly-code", + printable: true + }, + editableCode: { + view: async () => (await import("./type_widgets/code/Code")).EditableCode, + className: "note-detail-code", + printable: true + }, + mermaid: { + view: () => import("./type_widgets/Mermaid"), + className: "note-detail-mermaid", + printable: true, + isFullHeight: true + }, + mindMap: { + view: () => import("./type_widgets/MindMap"), + className: "note-detail-mind-map", + printable: true, + isFullHeight: true + }, + attachmentList: { + view: async () => (await import("./type_widgets/Attachment")).AttachmentList, + className: "attachment-list", + printable: true + }, + attachmentDetail: { + view: async () => (await import("./type_widgets/Attachment")).AttachmentDetail, + className: "attachment-detail", + printable: true + }, + readOnlyText: { + view: () => import("./type_widgets/text/ReadOnlyText"), + className: "note-detail-readonly-text" + }, + editableText: { + view: () => import("./type_widgets/text/EditableText"), + className: "note-detail-editable-text", + printable: true + }, + render: { + view: () => import("./type_widgets/Render"), + className: "note-detail-render", + printable: true + }, + canvas: { + view: () => import("./type_widgets/canvas/Canvas"), + className: "note-detail-canvas", + printable: true, + isFullHeight: true + }, + relationMap: { + view: () => import("./type_widgets/relation_map/RelationMap"), + className: "note-detail-relation-map", + printable: true, + isFullHeight: true + }, + noteMap: { + view: () => import("./type_widgets/NoteMap"), + className: "note-detail-note-map", + printable: true, + isFullHeight: true + }, + aiChat: { + view: () => import("./type_widgets/AiChat"), + className: "ai-chat-widget-container", + isFullHeight: true + } +}; diff --git a/apps/client/src/widgets/react/Admonition.tsx b/apps/client/src/widgets/react/Admonition.tsx index d5de3a5c8..5fb86daf2 100644 --- a/apps/client/src/widgets/react/Admonition.tsx +++ b/apps/client/src/widgets/react/Admonition.tsx @@ -3,12 +3,13 @@ import { ComponentChildren } from "preact"; interface AdmonitionProps { type: "warning" | "note" | "caution"; children: ComponentChildren; + className?: string; } -export default function Admonition({ type, children }: AdmonitionProps) { +export default function Admonition({ type, children, className }: AdmonitionProps) { return ( -
+
{children}
) -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/FormList.tsx b/apps/client/src/widgets/react/FormList.tsx index c79d63619..ca5fcf66e 100644 --- a/apps/client/src/widgets/react/FormList.tsx +++ b/apps/client/src/widgets/react/FormList.tsx @@ -82,6 +82,8 @@ interface FormListItemOpts { active?: boolean; badges?: FormListBadge[]; disabled?: boolean; + /** Will indicate the reason why the item is disabled via an icon, when hovered over it. */ + disabledTooltip?: string; checked?: boolean | null; selected?: boolean; container?: boolean; @@ -119,21 +121,24 @@ export function FormListItem({ className, icon, value, title, active, disabled,   {description ? (
- +
) : ( - + )} ); } -function FormListContent({ children, badges, description }: Pick) { +function FormListContent({ children, badges, description, disabled, disabledTooltip }: Pick) { return <> {children} {badges && badges.map(({ className, text }) => ( {text} ))} + {disabled && disabledTooltip && ( + + )} {description &&
{description}
} ; } diff --git a/apps/client/src/widgets/react/HelpButton.tsx b/apps/client/src/widgets/react/HelpButton.tsx index 065252264..eb55b1c43 100644 --- a/apps/client/src/widgets/react/HelpButton.tsx +++ b/apps/client/src/widgets/react/HelpButton.tsx @@ -5,17 +5,18 @@ import { openInAppHelpFromUrl } from "../../services/utils"; interface HelpButtonProps { className?: string; helpPage: string; + title?: string; style?: CSSProperties; } -export default function HelpButton({ className, helpPage, style }: HelpButtonProps) { +export default function HelpButton({ className, helpPage, title, style }: HelpButtonProps) { return (