diff --git a/.github/workflows/main-docker.yml b/.github/workflows/main-docker.yml index fab20d242..e66e79eaf 100644 --- a/.github/workflows/main-docker.yml +++ b/.github/workflows/main-docker.yml @@ -155,6 +155,10 @@ jobs: - name: Update build info run: pnpm run chore:update-build-info + - name: Update nightly version + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: pnpm run chore:ci-update-nightly-version + - name: Run the TypeScript build run: pnpm run server:build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ddce68d42..cd30b44d0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -57,7 +57,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - name: Update nightly version - run: npm run chore:ci-update-nightly-version + run: pnpm run chore:ci-update-nightly-version - name: Run the build uses: ./.github/actions/build-electron with: @@ -77,7 +77,7 @@ jobs: GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }} - name: Publish release - uses: softprops/action-gh-release@v2.4.1 + uses: softprops/action-gh-release@v2.4.2 if: ${{ github.event_name != 'pull_request' }} with: make_latest: false @@ -118,7 +118,7 @@ jobs: arch: ${{ matrix.arch }} - name: Publish release - uses: softprops/action-gh-release@v2.4.1 + uses: softprops/action-gh-release@v2.4.2 if: ${{ github.event_name != 'pull_request' }} with: make_latest: false diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index a33d24283..68e102a65 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - hotfix paths-ignore: - "apps/website/**" pull_request: @@ -13,8 +14,24 @@ permissions: contents: read jobs: - main: - runs-on: ubuntu-latest + e2e: + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + os: ubuntu-22.04 + arch: x64 + - name: linux-arm64 + os: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.os }} + name: E2E tests on ${{ matrix.name }} + env: + TRILIUM_DOCKER: 1 + TRILIUM_PORT: 8082 + TRILIUM_DATA_DIR: "${{ github.workspace }}/apps/server/spec/db" + TRILIUM_INTEGRATION_TEST: memory steps: - uses: actions/checkout@v5 with: @@ -29,9 +46,34 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - run: pnpm exec playwright install --with-deps - - run: pnpm --filter server-e2e e2e + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps + + - name: Build the server + uses: ./.github/actions/build-server + with: + os: linux + arch: ${{ matrix.arch }} + + - name: Unpack and start the server + run: | + version=$(node --eval "console.log(require('./package.json').version)") + file=$(find ./upload -name '*.tar.xz' -print -quit) + name=$(basename "$file" .tar.xz) + mkdir -p ./server-dist + tar -xvf "$file" -C ./server-dist + server_dir="./server-dist/TriliumNotes-Server-$version-linux-${{ matrix.arch }}" + if [ ! -d "$server_dir" ]; then + echo Missing dir. + exit 1 + fi + cd "$server_dir" + "./trilium.sh" & + sleep 10 + + - name: Server end-to-end tests + run: pnpm --filter server-e2e e2e - name: Upload test report if: failure() @@ -39,3 +81,7 @@ jobs: with: name: e2e report path: apps/server-e2e/test-output + + - name: Kill the server + if: always() + run: pkill -f trilium || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d48cb80d..37fbe8c5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,7 +127,7 @@ jobs: path: upload - name: Publish stable release - uses: softprops/action-gh-release@v2.4.1 + uses: softprops/action-gh-release@v2.4.2 with: draft: false body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md diff --git a/.nvmrc b/.nvmrc index 40115e966..f67737705 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.11.0 \ No newline at end of file +24.11.1 \ No newline at end of file diff --git a/_regroup/package.json b/_regroup/package.json index 9f15f392a..698f9a018 100644 --- a/_regroup/package.json +++ b/_regroup/package.json @@ -38,15 +38,15 @@ "@playwright/test": "1.56.1", "@stylistic/eslint-plugin": "5.5.0", "@types/express": "5.0.5", - "@types/node": "24.10.0", - "@types/yargs": "17.0.34", - "@vitest/coverage-v8": "4.0.6", + "@types/node": "24.10.1", + "@types/yargs": "17.0.35", + "@vitest/coverage-v8": "3.2.4", "eslint": "9.39.1", "eslint-plugin-simple-import-sort": "12.1.1", "esm": "3.2.25", "jsdoc": "4.0.5", "lorem-ipsum": "2.0.8", - "rcedit": "4.0.1", + "rcedit": "5.0.1", "rimraf": "6.1.0", "tslib": "2.8.1" }, diff --git a/apps/build-docs/package.json b/apps/build-docs/package.json index 00196de82..3df797a59 100644 --- a/apps/build-docs/package.json +++ b/apps/build-docs/package.json @@ -9,9 +9,9 @@ "keywords": [], "author": "Elian Doran ", "license": "AGPL-3.0-only", - "packageManager": "pnpm@10.20.0", + "packageManager": "pnpm@10.22.0", "devDependencies": { - "@redocly/cli": "2.11.0", + "@redocly/cli": "2.11.1", "archiver": "7.0.1", "fs-extra": "11.3.2", "react": "19.2.0", diff --git a/apps/client/package.json b/apps/client/package.json index 5ecf18cac..cd1317e2b 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "@triliumnext/client", - "version": "0.99.3", + "version": "0.99.5", "description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)", "private": true, "license": "AGPL-3.0-only", @@ -25,7 +25,7 @@ "@fullcalendar/timegrid": "6.1.19", "@maplibre/maplibre-gl-leaflet": "0.1.3", "@mermaid-js/layout-elk": "0.2.0", - "@mind-elixir/node-menu": "5.0.0", + "@mind-elixir/node-menu": "5.0.1", "@popperjs/core": "2.11.8", "@triliumnext/ckeditor5": "workspace:*", "@triliumnext/codemirror": "workspace:*", @@ -36,14 +36,14 @@ "autocomplete.js": "0.38.1", "bootstrap": "5.3.8", "boxicons": "2.1.4", - "color": "5.0.2", + "color": "5.0.3", "dayjs": "1.11.19", "dayjs-plugin-utc": "0.1.2", "debounce": "3.0.0", "draggabilly": "3.0.0", "force-graph": "1.51.0", "globals": "16.5.0", - "i18next": "25.6.0", + "i18next": "25.6.2", "i18next-http-backend": "3.0.2", "jquery": "3.7.1", "jquery.fancytree": "2.38.5", @@ -53,13 +53,13 @@ "leaflet": "1.9.4", "leaflet-gpx": "2.2.0", "mark.js": "8.11.1", - "marked": "16.4.1", + "marked": "16.4.2", "mermaid": "11.12.1", - "mind-elixir": "5.3.4", + "mind-elixir": "5.3.6", "normalize.css": "8.0.1", "panzoom": "9.4.3", "preact": "10.27.2", - "react-i18next": "16.2.4", + "react-i18next": "16.3.3", "reveal.js": "5.2.1", "svg-pan-zoom": "3.6.2", "tabulator-tables": "6.3.1", diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 7bc544e7e..c73fe5a42 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; @@ -328,6 +329,7 @@ export type CommandMappings = { exportAsPdf: CommandData; openNoteExternally: CommandData; openNoteCustom: CommandData; + openNoteOnServer: CommandData; renderActiveNote: CommandData; unhoist: CommandData; reloadFrontendApp: CommandData; @@ -485,13 +487,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[]; }; @@ -499,6 +496,10 @@ type EventMappings = { noteIds: string[]; }; refreshData: { ntxId: string | null | undefined }; + contentSafeMarginChanged: { + top: number; + noteContext: NoteContext; + } }; export type EventListener = { @@ -666,6 +667,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/root_command_executor.ts b/apps/client/src/components/root_command_executor.ts index 632eb0a88..4a1c987f7 100644 --- a/apps/client/src/components/root_command_executor.ts +++ b/apps/client/src/components/root_command_executor.ts @@ -7,7 +7,6 @@ import protectedSessionService from "../services/protected_session.js"; import options from "../services/options.js"; import froca from "../services/froca.js"; import utils from "../services/utils.js"; -import LlmChatPanel from "../widgets/llm_chat_panel.js"; import toastService from "../services/toast.js"; import noteCreateService from "../services/note_create.js"; @@ -67,6 +66,13 @@ export default class RootCommandExecutor extends Component { } } + openNoteOnServerCommand() { + const noteId = appContext.tabManager.getActiveContextNoteId(); + if (noteId) { + openService.openNoteOnServer(noteId); + } + } + enterProtectedSessionCommand() { protectedSessionService.enterProtectedSession(); } @@ -171,7 +177,8 @@ export default class RootCommandExecutor extends Component { } toggleTrayCommand() { - if (!utils.isElectron()) return; + if (!utils.isElectron() || options.is("disableTray")) return; + const { BrowserWindow } = utils.dynamicRequire("@electron/remote"); const windows = BrowserWindow.getAllWindows() as Electron.BaseWindow[]; const isVisible = windows.every((w) => w.isVisible()); diff --git a/apps/client/src/components/tab_manager.ts b/apps/client/src/components/tab_manager.ts index 03b9d98d6..127ec30b7 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); @@ -646,7 +647,32 @@ export default class TabManager extends Component { ...this.noteContexts.slice(-noteContexts.length), ...this.noteContexts.slice(lastClosedTab.position, -noteContexts.length) ]; - this.noteContextReorderEvent({ ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null) }); + + // Update mainNtxId if the restored pane is the main pane in the split pane + const { oldMainNtxId, newMainNtxId } = (() => { + if (noteContexts.length !== 1) { + return { oldMainNtxId: undefined, newMainNtxId: undefined }; + } + + const mainNtxId = noteContexts[0]?.mainNtxId; + const index = this.noteContexts.findIndex(c => c.ntxId === mainNtxId); + + // No need to update if the restored position is after mainNtxId + if (index === -1 || lastClosedTab.position > index) { + return { oldMainNtxId: undefined, newMainNtxId: undefined }; + } + + return { + oldMainNtxId: this.noteContexts[index].ntxId ?? undefined, + newMainNtxId: noteContexts[0]?.ntxId ?? undefined + }; + })(); + + this.triggerCommand("noteContextReorder", { + ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null), + oldMainNtxId, + newMainNtxId + }); let mainNtx = noteContexts.find((nc) => nc.isMainContext()); if (mainNtx) { diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 6d0a15506..5fe9bc67d 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -6,6 +6,7 @@ import type { Froca } from "../services/froca-interface.js"; import type FAttachment from "./fattachment.js"; import type { default as FAttribute, AttributeType } from "./fattribute.js"; import utils from "../services/utils.js"; +import search from "../services/search.js"; const LABEL = "label"; const RELATION = "relation"; @@ -255,6 +256,23 @@ export default class FNote { return this.children; } + async getChildNoteIdsWithArchiveFiltering(includeArchived = false) { + const isHiddenNote = this.noteId.startsWith("_"); + const isSearchNote = this.type === "search"; + if (!includeArchived && !isHiddenNote && !isSearchNote) { + const unorderedIds = new Set(await search.searchForNoteIds(`note.parents.noteId="${this.noteId}" #!archived`)); + const results: string[] = []; + for (const id of this.children) { + if (unorderedIds.has(id)) { + results.push(id); + } + } + return results; + } else { + return this.children; + } + } + async getSubtreeNoteIds(includeArchived = false) { let noteIds: (string | string[])[] = []; for (const child of await this.getChildNotes()) { @@ -788,6 +806,16 @@ export default class FNote { return this.getAttributeValue(LABEL, name); } + getLabelOrRelation(nameWithPrefix: string) { + if (nameWithPrefix.startsWith("#")) { + return this.getLabelValue(nameWithPrefix.substring(1)); + } else if (nameWithPrefix.startsWith("~")) { + return this.getRelationValue(nameWithPrefix.substring(1)); + } else { + return this.getLabelValue(nameWithPrefix); + } + } + /** * @param name - relation name * @returns relation value if relation exists, null otherwise @@ -839,8 +867,7 @@ export default class FNote { return []; } - const promotedAttrs = this.getAttributes() - .filter((attr) => attr.isDefinition()) + const promotedAttrs = this.getAttributeDefinitions() .filter((attr) => { const def = attr.getDefinition(); @@ -860,6 +887,11 @@ export default class FNote { return promotedAttrs; } + getAttributeDefinitions() { + return this.getAttributes() + .filter((attr) => attr.isDefinition()); + } + hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set | null = null) { if (this.noteId === ancestorNoteId) { return true; diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 3f9416584..17c77f8d8 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -1,47 +1,49 @@ -import FlexContainer from "../widgets/containers/flex_container.js"; -import TabRowWidget from "../widgets/tab_row.js"; -import LeftPaneContainer from "../widgets/containers/left_pane_container.js"; -import NoteTreeWidget from "../widgets/note_tree.js"; -import NoteTitleWidget from "../widgets/note_title.jsx"; -import NoteDetailWidget from "../widgets/note_detail.js"; -import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; -import NoteIconWidget from "../widgets/note_icon.jsx"; -import ScrollingContainer from "../widgets/containers/scrolling_container.js"; -import RootContainer from "../widgets/containers/root_container.js"; -import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; -import SpacerWidget from "../widgets/spacer.js"; -import QuickSearchWidget from "../widgets/quick_search.js"; -import SplitNoteContainer from "../widgets/containers/split_note_container.js"; -import CreatePaneButton from "../widgets/buttons/create_pane_button.js"; -import ClosePaneButton from "../widgets/buttons/close_pane_button.js"; -import RightPaneContainer from "../widgets/containers/right_pane_container.js"; -import NoteWrapperWidget from "../widgets/note_wrapper.js"; -import FindWidget from "../widgets/find.js"; -import TocWidget from "../widgets/toc.js"; -import HighlightsListWidget from "../widgets/highlights_list.js"; -import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; -import LauncherContainer from "../widgets/containers/launcher_container.js"; -import MovePaneButton from "../widgets/buttons/move_pane_button.js"; -import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js"; -import ScrollPadding from "../widgets/scroll_padding.js"; -import options from "../services/options.js"; -import utils from "../services/utils.js"; -import type { AppContext } from "../components/app_context.js"; -import type { WidgetsByParent } from "../services/bundle.js"; import { applyModals } from "./layout_commons.js"; -import Ribbon from "../widgets/ribbon/Ribbon.jsx"; -import FloatingButtons from "../widgets/FloatingButtons.jsx"; import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; -import SearchResult from "../widgets/search_result.jsx"; +import ApiLog from "../widgets/api_log.jsx"; +import ClosePaneButton from "../widgets/buttons/close_pane_button.js"; +import CloseZenModeButton from "../widgets/close_zen_button.jsx"; +import ContentHeader from "../widgets/containers/content_header.js"; +import CreatePaneButton from "../widgets/buttons/create_pane_button.js"; +import FindWidget from "../widgets/find.js"; +import FlexContainer from "../widgets/containers/flex_container.js"; +import FloatingButtons from "../widgets/FloatingButtons.jsx"; import GlobalMenu from "../widgets/buttons/global_menu.jsx"; +import HighlightsListWidget from "../widgets/highlights_list.js"; +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 NoteIconWidget from "../widgets/note_icon.jsx"; +import NoteList from "../widgets/collections/NoteList.jsx"; +import NoteTitleWidget from "../widgets/note_title.jsx"; +import NoteTreeWidget from "../widgets/note_tree.js"; +import NoteWrapperWidget from "../widgets/note_wrapper.js"; +import options from "../services/options.js"; +import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; +import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; +import QuickSearchWidget from "../widgets/quick_search.js"; +import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx"; +import Ribbon from "../widgets/ribbon/Ribbon.jsx"; +import RightPaneContainer from "../widgets/containers/right_pane_container.js"; +import RootContainer from "../widgets/containers/root_container.js"; +import ScrollingContainer from "../widgets/containers/scrolling_container.js"; +import ScrollPadding from "../widgets/scroll_padding.js"; +import SearchResult from "../widgets/search_result.jsx"; +import SharedInfo from "../widgets/shared_info.jsx"; +import SpacerWidget from "../widgets/spacer.js"; +import SplitNoteContainer from "../widgets/containers/split_note_container.js"; import SqlResults from "../widgets/sql_result.js"; import SqlTableSchemas from "../widgets/sql_table_schemas.js"; +import TabRowWidget from "../widgets/tab_row.js"; import TitleBarButtons from "../widgets/title_bar_buttons.jsx"; -import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js"; -import ApiLog from "../widgets/api_log.jsx"; -import CloseZenModeButton from "../widgets/close_zen_button.jsx"; -import SharedInfo from "../widgets/shared_info.jsx"; -import NoteList from "../widgets/collections/NoteList.jsx"; +import TocWidget from "../widgets/toc.js"; +import type { AppContext } from "../components/app_context.js"; +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 { @@ -129,15 +131,18 @@ export default class DesktopLayout { .child() ) .child() - .child() .child(new WatchedFileUpdateStatusWidget()) .child() .child( new ScrollingContainer() .filling() + .child(new ContentHeader() + .child() + .child() + ) .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 2bae994b6..c51ef9e92 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -1,32 +1,34 @@ +import { applyModals } from "./layout_commons.js"; +import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; +import { useNoteContext } from "../widgets/react/hooks.jsx"; +import CloseZenModeButton from "../widgets/close_zen_button.js"; +import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx"; import FlexContainer from "../widgets/containers/flex_container.js"; -import NoteTitleWidget from "../widgets/note_title.js"; -import NoteDetailWidget from "../widgets/note_detail.js"; -import QuickSearchWidget from "../widgets/quick_search.js"; -import NoteTreeWidget from "../widgets/note_tree.js"; -import ScreenContainer from "../widgets/mobile_widgets/screen_container.js"; -import ScrollingContainer from "../widgets/containers/scrolling_container.js"; +import FloatingButtons from "../widgets/FloatingButtons.jsx"; import GlobalMenuWidget from "../widgets/buttons/global_menu.js"; import LauncherContainer from "../widgets/containers/launcher_container.js"; -import RootContainer from "../widgets/containers/root_container.js"; -import SharedInfoWidget from "../widgets/shared_info.js"; -import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; -import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js"; -import type AppContext from "../components/app_context.js"; -import TabRowWidget from "../widgets/tab_row.js"; -import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js"; -import { applyModals } from "./layout_commons.js"; -import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx"; -import { useNoteContext } from "../widgets/react/hooks.jsx"; -import FloatingButtons from "../widgets/FloatingButtons.jsx"; -import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; -import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx"; -import CloseZenModeButton from "../widgets/close_zen_button.js"; -import NoteWrapperWidget from "../widgets/note_wrapper.js"; import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js"; import NoteList from "../widgets/collections/NoteList.jsx"; -import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx"; +import NoteTitleWidget from "../widgets/note_title.js"; +import ContentHeader from "../widgets/containers/content_header.js"; +import NoteTreeWidget from "../widgets/note_tree.js"; +import NoteWrapperWidget from "../widgets/note_wrapper.js"; +import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; +import QuickSearchWidget from "../widgets/quick_search.js"; +import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx"; +import RootContainer from "../widgets/containers/root_container.js"; +import ScreenContainer from "../widgets/mobile_widgets/screen_container.js"; +import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx"; import SearchResult from "../widgets/search_result.jsx"; +import SharedInfoWidget from "../widgets/shared_info.js"; +import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js"; +import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx"; +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/attribute_widgets/UserAttributesList.css b/apps/client/src/widgets/attribute_widgets/UserAttributesList.css new file mode 100644 index 000000000..ef8c1763d --- /dev/null +++ b/apps/client/src/widgets/attribute_widgets/UserAttributesList.css @@ -0,0 +1,31 @@ +.user-attributes { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +.user-attributes .user-attribute { + padding: 2px 10px; + border-radius: 9999px; + white-space: nowrap; + background-color: var(--chip-bg, rgba(0, 0, 0, 0.08)); + color: var(--chip-fg, inherit); + border: 1px solid var(--chip-border, rgba(0, 0, 0, 0.15)); + font-size: 12px; + line-height: 1.2; +} + +.user-attributes .user-attribute:hover { + background-color: var(--chip-bg-hover, rgba(0, 0, 0, 0.12)); + border-color: var(--chip-border-hover, rgba(0, 0, 0, 0.22)); +} + +.user-attributes .user-attribute .name { + font-weight: 600; +} + +.user-attributes .user-attribute .value { + opacity: 0.9; +} \ No newline at end of file diff --git a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx new file mode 100644 index 000000000..f01e70b49 --- /dev/null +++ b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx @@ -0,0 +1,134 @@ +import { useState } from "preact/hooks"; +import FNote from "../../entities/fnote"; +import "./UserAttributesList.css"; +import { useTriliumEvent } from "../react/hooks"; +import attributes from "../../services/attributes"; +import { DefinitionObject } from "../../services/promoted_attribute_definition_parser"; +import { formatDateTime } from "../../utils/formatters"; +import { ComponentChildren, CSSProperties } from "preact"; +import Icon from "../react/Icon"; +import NoteLink from "../react/NoteLink"; +import { getReadableTextColor } from "../../services/css_class_manager"; + +interface UserAttributesListProps { + note: FNote; + ignoredAttributes?: string[]; +} + +interface AttributeWithDefinitions { + friendlyName: string; + name: string; + type: string; + value: string; + def: DefinitionObject; +} + +export default function UserAttributesDisplay({ note, ignoredAttributes }: UserAttributesListProps) { + const userAttributes = useNoteAttributesWithDefinitions(note, ignoredAttributes); + return userAttributes?.length > 0 && ( +
+ {userAttributes?.map(attr => buildUserAttribute(attr))} +
+ ) + +} + +function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] { + const [ userAttributes, setUserAttributes ] = useState(getAttributesWithDefinitions(note, attributesToIgnore)); + + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (loadResults.getAttributeRows().some(attr => attributes.isAffecting(attr, note))) { + setUserAttributes(getAttributesWithDefinitions(note, attributesToIgnore)); + } + }); + + return userAttributes; +} + +function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) { + const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`; + + return ( + + {children} + + ) +} + +function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren { + const defaultLabel = <>{attr.friendlyName}:{" "}; + let content: ComponentChildren; + let style: CSSProperties | undefined; + + if (attr.type === "label") { + let value = attr.value; + switch (attr.def.labelType) { + case "number": + let formattedValue = value; + const numberValue = Number(value); + if (!Number.isNaN(numberValue) && attr.def.numberPrecision) formattedValue = numberValue.toFixed(attr.def.numberPrecision); + content = <>{defaultLabel}{formattedValue}; + break; + case "date": + case "datetime": { + const date = new Date(value); + const timeFormat = attr.def.labelType !== "date" ? "short" : "none"; + const formattedValue = formatDateTime(date, "short", timeFormat); + content = <>{defaultLabel}{formattedValue}; + break; + } + case "time": { + const date = new Date(`1970-01-01T${value}Z`); + const formattedValue = formatDateTime(date, "none", "short"); + content = <>{defaultLabel}{formattedValue}; + break; + } + case "boolean": + content = <>{" "}{attr.friendlyName}; + break; + case "url": + content = {attr.friendlyName}; + break; + case "color": + style = { backgroundColor: value, color: getReadableTextColor(value) }; + content = <>{attr.friendlyName}; + break; + case "text": + default: + content = <>{defaultLabel}{value}; + break; + } + } else if (attr.type === "relation") { + content = <>{defaultLabel}; + } + + return {content} +} + +function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] { + const attributeDefintions = note.getAttributeDefinitions(); + const result: AttributeWithDefinitions[] = []; + for (const attr of attributeDefintions) { + const def = attr.getDefinition(); + const [ type, name ] = attr.name.split(":", 2); + const friendlyName = def?.promotedAlias || name; + const props: Omit = { def, name, type, friendlyName }; + + if (attributesToIgnore.includes(name)) continue; + + if (type === "label") { + const labels = note.getLabels(name); + for (const label of labels) { + if (!label.value) continue; + result.push({ ...props, value: label.value } ); + } + } else if (type === "relation") { + const relations = note.getRelations(name); + for (const relation of relations) { + if (!relation.value) continue; + result.push({ ...props, value: relation.value } ); + } + } + } + return result; +} 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/buttons/close_pane_button.tsx b/apps/client/src/widgets/buttons/close_pane_button.tsx index c171d0d8e..7ffae309d 100644 --- a/apps/client/src/widgets/buttons/close_pane_button.tsx +++ b/apps/client/src/widgets/buttons/close_pane_button.tsx @@ -1,18 +1,20 @@ import { useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; import ActionButton from "../react/ActionButton"; -import { useNoteContext, useTriliumEvent } from "../react/hooks"; +import { useNoteContext, useTriliumEvents } from "../react/hooks"; +import appContext from "../../components/app_context"; export default function ClosePaneButton() { const { noteContext, ntxId, parentComponent } = useNoteContext(); - const [ isEnabled, setIsEnabled ] = useState(false); + const [isEnabled, setIsEnabled] = useState(false); function refresh() { - setIsEnabled(!!(noteContext && !!noteContext.mainNtxId)); + const isMainOfSomeContext = appContext.tabManager.noteContexts.some(c => c.mainNtxId === ntxId); + setIsEnabled(!!(noteContext && (!!noteContext.mainNtxId || isMainOfSomeContext))); } - useTriliumEvent("noteContextReorder", refresh); - useEffect(refresh, [ ntxId ]); + useTriliumEvents(["noteContextRemoved", "noteContextReorder", "newNoteContextCreated"], refresh); + useEffect(refresh, [ntxId]); return ( void; } -export default function NoteList(props: Pick) { +export default function NoteList(props: Pick) { const { note, noteContext, notePath, ntxId } = useNoteContext(); - const isEnabled = noteContext?.hasNoteList(); - return -} - -export function SearchNoteList(props: Omit) { - return -} - -export function CustomNoteList({ note, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId, onReady, ...restProps }: NoteListProps) { - const widgetRef = useRef(null); const viewType = useNoteViewType(note); + const [ enabled, setEnabled ] = useState(noteContext?.hasNoteList()); + useEffect(() => { + setEnabled(noteContext?.hasNoteList()); + }, [ noteContext, viewType ]) + return +} + +export function SearchNoteList(props: Omit) { + const viewType = useNoteViewType(props.note); + return +} + +export function CustomNoteList({ note, viewType, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId, onReady, ...restProps }: NoteListProps) { + const widgetRef = useRef(null); const noteIds = useNoteIds(shouldEnable ? note : null, viewType, ntxId); const isFullHeight = (viewType && viewType !== "list" && viewType !== "grid"); const [ isIntersecting, setIsIntersecting ] = useState(false); @@ -77,8 +82,8 @@ export function CustomNoteList({ note, isEnabled: shouldEnable props = { note, noteIds, notePath, highlightedTokens, - viewConfig: viewModeConfig[0], - saveConfig: viewModeConfig[1], + viewConfig: viewModeConfig.config, + saveConfig: viewModeConfig.storeFn, onReady: onReady ?? (() => {}), ...restProps } @@ -114,7 +119,7 @@ function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps< } } -function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined { +export function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined { const [ viewType ] = useNoteLabel(note, "viewType"); if (!note) { @@ -141,7 +146,7 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt async function getNoteIds(note: FNote) { if (viewType === "list" || viewType === "grid" || viewType === "table" || note.type === "search") { - return note.getChildNoteIds(); + return await note.getChildNoteIdsWithArchiveFiltering(includeArchived); } else { return await note.getSubtreeNoteIds(includeArchived); } @@ -192,7 +197,11 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt } export function useViewModeConfig(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) { - const [ viewConfig, setViewConfig ] = useState<[T | undefined, (data: T) => void]>(); + const [ viewConfig, setViewConfig ] = useState<{ + config: T | undefined; + storeFn: (data: T) => void; + note: FNote; + }>(); useEffect(() => { if (!note || !viewType) return; @@ -200,12 +209,14 @@ export function useViewModeConfig(note: FNote | null | undefin const viewStorage = new ViewModeStorage(note, viewType); viewStorage.restore().then(config => { const storeFn = (config: T) => { - setViewConfig([ config, storeFn ]); + setViewConfig({ note, config, storeFn }); viewStorage.store(config); }; - setViewConfig([ config, storeFn ]); + setViewConfig({ note, config, storeFn }); }); }, [ note, viewType ]); + // Only expose config for the current note, avoid leaking notes when switching between them. + if (viewConfig?.note !== note) return undefined; return viewConfig; } diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 90d41d7c4..af88f935e 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -1,3 +1,4 @@ +import { BulkAction } from "@triliumnext/commons"; import { BoardViewData } from "."; import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; @@ -12,15 +13,25 @@ import { ColumnMap } from "./data"; export default class BoardApi { + private isRelationMode: boolean; + statusAttribute: string; + constructor( private byColumn: ColumnMap | undefined, public columns: string[], private parentNote: FNote, - private statusAttribute: string, + statusAttribute: string, private viewConfig: BoardViewData, private saveConfig: (newConfig: BoardViewData) => void, private setBranchIdToEdit: (branchId: string | undefined) => void - ) {}; + ) { + this.isRelationMode = statusAttribute.startsWith("~"); + + if (statusAttribute.startsWith("~") || statusAttribute.startsWith("#")) { + statusAttribute = statusAttribute.substring(1); + } + this.statusAttribute = statusAttribute; + }; async createNewItem(column: string, title: string) { try { @@ -42,7 +53,11 @@ export default class BoardApi { } async changeColumn(noteId: string, newColumn: string) { - await attributes.setLabel(noteId, this.statusAttribute, newColumn); + if (this.isRelationMode) { + await attributes.setRelation(noteId, this.statusAttribute, newColumn); + } else { + await attributes.setLabel(noteId, this.statusAttribute, newColumn); + } } async addNewColumn(columnName: string) { @@ -60,22 +75,20 @@ export default class BoardApi { // Add the new column to persisted data if it doesn't exist const existingColumn = this.viewConfig.columns.find(col => col.value === columnName); - if (!existingColumn) { - this.viewConfig.columns.push({ value: columnName }); - this.saveConfig(this.viewConfig); - } + if (existingColumn) return false; + this.viewConfig.columns.push({ value: columnName }); + this.saveConfig(this.viewConfig); + return true; } async removeColumn(column: string) { // Remove the value from the notes. const noteIds = this.byColumn?.get(column)?.map(item => item.note.noteId) || []; - await executeBulkActions(noteIds, [ - { - name: "deleteLabel", - labelName: this.statusAttribute - } - ]); + const action: BulkAction = this.isRelationMode + ? { name: "deleteRelation", relationName: this.statusAttribute } + : { name: "deleteLabel", labelName: this.statusAttribute } + await executeBulkActions(noteIds, [ action ]); this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column); this.saveConfig(this.viewConfig); } @@ -84,13 +97,10 @@ export default class BoardApi { const noteIds = this.byColumn?.get(oldValue)?.map(item => item.note.noteId) || []; // Change the value in the notes. - await executeBulkActions(noteIds, [ - { - name: "updateLabelValue", - labelName: this.statusAttribute, - labelValue: newValue - } - ]); + const action: BulkAction = this.isRelationMode + ? { name: "updateRelationTarget", relationName: this.statusAttribute, targetNoteId: newValue } + : { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue } + await executeBulkActions(noteIds, [ action ]); // Rename the column in the persisted data. for (const column of this.viewConfig.columns || []) { @@ -167,7 +177,11 @@ export default class BoardApi { removeFromBoard(noteId: string) { const note = froca.getNoteFromCache(noteId); if (!note) return; - return attributes.removeOwnedLabelByName(note, this.statusAttribute); + if (this.isRelationMode) { + return attributes.removeOwnedRelationByName(note, this.statusAttribute); + } else { + return attributes.removeOwnedLabelByName(note, this.statusAttribute); + } } async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) { diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 2879f3a43..b67a00408 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -6,6 +6,8 @@ import { BoardViewContext, TitleEditor } from "."; import { ContextMenuEvent } from "../../../menus/context_menu"; import { openNoteContextMenu } from "./context_menu"; import { t } from "../../../services/i18n"; +import UserAttributesDisplay from "../../attribute_widgets/UserAttributesList"; +import { useTriliumEvent } from "../../react/hooks"; export const CARD_CLIPBOARD_TYPE = "trilium/board-card"; @@ -39,6 +41,13 @@ export default function Card({ const [ isVisible, setVisible ] = useState(true); const [ title, setTitle ] = useState(note.title); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + const row = loadResults.getEntityRow("notes", note.noteId); + if (row) { + setTitle(row.title); + } + }); + const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; const data: CardDragData = { noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }; @@ -108,6 +117,7 @@ export default function Card({ title={t("board_view.edit-note-title")} onClick={handleEdit} /> + ) : ( api.dismissEditingTitle()} - multiline + mode="multiline" /> )} diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index a6779f30d..f014b67bf 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -12,6 +12,7 @@ import Card, { CARD_CLIPBOARD_TYPE, CardDragData } from "./card"; import { JSX } from "preact/jsx-runtime"; import froca from "../../../services/froca"; import { DragData, TREE_CLIPBOARD_TYPE } from "../../note_tree"; +import NoteLink from "../../react/NoteLink"; interface DragContext { column: string; @@ -27,12 +28,14 @@ export default function Column({ api, onColumnHover, isAnyColumnDragging, + isInRelationMode }: { columnItems?: { note: FNote, branch: FBranch }[]; isDraggingColumn: boolean, api: BoardApi, onColumnHover?: (index: number, mouseX: number, rect: DOMRect) => void, - isAnyColumnDragging?: boolean + isAnyColumnDragging?: boolean, + isInRelationMode: boolean } & DragContext) { const [ isVisible, setVisible ] = useState(true); const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition } = useContext(BoardViewContext)!; @@ -103,7 +106,13 @@ export default function Column({ > {!isEditing ? ( <> - {column} + + {isInRelationMode + ? + : column} + + {columnItems?.length ?? 0} +
api.renameColumn(column, newTitle)} dismiss={() => setColumnNameToEdit?.(undefined)} + mode={isInRelationMode ? "relation" : "normal"} /> )} @@ -178,7 +188,7 @@ function AddNewItem({ column, api }: { column: string, api: BoardApi }) { placeholder={t("board_view.new-item-placeholder")} save={(title) => api.createNewItem(column, title)} dismiss={() => setIsCreatingNewItem(false)} - multiline isNewItem + mode="multiline" isNewItem /> )}
diff --git a/apps/client/src/widgets/collections/board/data.spec.ts b/apps/client/src/widgets/collections/board/data.spec.ts new file mode 100644 index 000000000..357aea616 --- /dev/null +++ b/apps/client/src/widgets/collections/board/data.spec.ts @@ -0,0 +1,32 @@ +import { it, describe, expect } from "vitest"; +import { buildNote } from "../../../test/easy-froca"; +import { getBoardData } from "./data"; +import FBranch from "../../../entities/fbranch"; +import froca from "../../../services/froca"; + +describe("Board data", () => { + it("deduplicates cloned notes", async () => { + const parentNote = buildNote({ + title: "Board", + "#collection": "", + "#viewType": "board", + children: [ + { id: "note1", title: "First note", "#status": "To Do" }, + { id: "note2", title: "Second note", "#status": "In progress" }, + { id: "note3", title: "Third note", "#status": "Done" } + ] + }); + const branch = new FBranch(froca, { + branchId: "note1_note2", + notePosition: 10, + fromSearchNote: false, + noteId: "note2", + parentNoteId: "note1" + }); + froca.branches["note1_note2"] = branch; + froca.getNoteFromCache("note1").addChild("note2", "note1_note2", false); + const data = await getBoardData(parentNote, "status", {}, false); + const noteIds = Array.from(data.byColumn.values()).flat().map(item => item.note.noteId); + expect(noteIds.length).toBe(3); + }); +}); diff --git a/apps/client/src/widgets/collections/board/data.ts b/apps/client/src/widgets/collections/board/data.ts index db315564a..a37487ec9 100644 --- a/apps/client/src/widgets/collections/board/data.ts +++ b/apps/client/src/widgets/collections/board/data.ts @@ -11,7 +11,7 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per const byColumn: ColumnMap = new Map(); // First, scan all notes to find what columns actually exist - await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn, includeArchived); + await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn, includeArchived, new Set()); // Get all columns that exist in the notes const columnsFromNotes = [...byColumn.keys()]; @@ -57,30 +57,33 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per return { byColumn, - newPersistedData + newPersistedData, + isInRelationMode: groupByColumn.startsWith("~") }; } -async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string, includeArchived: boolean) { +async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string, includeArchived: boolean, seenNoteIds: Set) { for (const branch of branches) { const note = await branch.getNote(); if (!note || (!includeArchived && note.isArchived)) continue; if (note.type !== "search" && note.hasChildren()) { - await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn, includeArchived); + await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn, includeArchived, seenNoteIds); } - const group = note.getLabelValue(groupByColumn); - if (!group) { + const group = note.getLabelOrRelation(groupByColumn); + if (!group || seenNoteIds.has(note.noteId)) { continue; } if (!byColumn.has(group)) { byColumn.set(group, []); } + byColumn.get(group)!.push({ branch, note }); + seenNoteIds.add(note.noteId); } } diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 581408cf6..8b6ab9180 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -1,5 +1,4 @@ .board-view { - overflow-x: auto; position: relative; height: 100%; user-select: none; @@ -9,12 +8,17 @@ --card-padding: 0.6em; } +body.mobile .board-view { + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + .board-view-container { height: 100%; display: flex; gap: 1em; padding: 1em; - padding-bottom: 0; align-items: flex-start; } @@ -31,6 +35,12 @@ flex-direction: column; } +body.mobile .board-view-container .board-column { + width: 75vw; + max-width: 300px; + scroll-snap-align: center; +} + .board-view-container .board-column.drag-over { border-color: var(--main-text-color); background-color: var(--hover-item-background-color); @@ -53,7 +63,21 @@ align-items: center; } -.board-view-container .board-column h3 > .title { +.board-view-container .board-column h3 a { + text-decoration: none; + color: inherit; +} + +.board-view-container .board-column h3 .counter-badge { + background-color: var(--muted-text-color); + color: var(--main-background-color); + border-radius: 12px; + padding: 0.1em 0.6em; + font-size: 0.75em; + margin-inline-start: 0.5em; +} + +.board-view-container .board-column h3 > .spacer { flex-grow: 1; } @@ -101,7 +125,8 @@ .board-view-container .board-column > .board-column-content { flex-grow: 1; - overflow: scroll; + overflow-x: hidden; + overflow-y: auto; padding: 0.5em; } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 26757229d..7b939224d 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -13,6 +13,8 @@ import Column from "./column"; import BoardApi from "./api"; import FormTextArea from "../../react/FormTextArea"; import FNote from "../../../entities/fnote"; +import NoteAutocomplete from "../../react/NoteAutocomplete"; +import toast from "../../../services/toast"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -42,10 +44,11 @@ interface BoardViewContextData { export const BoardViewContext = createContext(undefined); export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { - const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); + const [ statusAttributeWithPrefix ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); const [ includeArchived ] = useNoteLabelBoolean(parentNote, "includeArchived"); const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); + const [ isInRelationMode, setIsRelationMode ] = useState(false); const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null); const [ dropTarget, setDropTarget ] = useState(null); const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null); @@ -55,8 +58,8 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ branchIdToEdit, setBranchIdToEdit ] = useState(); const [ columnNameToEdit, setColumnNameToEdit ] = useState(); const api = useMemo(() => { - return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); - }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]); + return new Api(byColumn, columns ?? [], parentNote, statusAttributeWithPrefix, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); + }, [ byColumn, columns, parentNote, statusAttributeWithPrefix, viewConfig, saveConfig, setBranchIdToEdit ]); const boardViewContext = useMemo(() => ({ api, parentNote, @@ -78,8 +81,9 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC ]); function refresh() { - getBoardData(parentNote, statusAttribute, viewConfig ?? {}, includeArchived).then(({ byColumn, newPersistedData }) => { + getBoardData(parentNote, statusAttributeWithPrefix, viewConfig ?? {}, includeArchived).then(({ byColumn, newPersistedData, isInRelationMode }) => { setByColumn(byColumn); + setIsRelationMode(isInRelationMode); if (newPersistedData) { viewConfig = { ...newPersistedData }; @@ -94,7 +98,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC }); } - useEffect(refresh, [ parentNote, noteIds, viewConfig ]); + useEffect(refresh, [ parentNote, noteIds, viewConfig, statusAttributeWithPrefix ]); const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => { const newColumns = api.reorderColumn(fromIndex, toIndex); @@ -110,7 +114,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC // Check if any changes affect our board const hasRelevantChanges = // React to changes in status attribute for notes in this board - loadResults.getAttributeRows().some(attr => attr.name === statusAttribute && noteIds.includes(attr.noteId!)) || + loadResults.getAttributeRows().some(attr => attr.name === api.statusAttribute && noteIds.includes(attr.noteId!)) || // React to changes in note title loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) || // React to changes in branches for subchildren (e.g., moved, added, or removed notes) @@ -171,6 +175,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
)} )} - +
) } -function AddNewColumn({ api }: { api: BoardApi }) { +function AddNewColumn({ api, isInRelationMode }: { api: BoardApi, isInRelationMode: boolean }) { const [ isCreatingNewColumn, setIsCreatingNewColumn ] = useState(false); const addColumnCallback = useCallback(() => { @@ -209,22 +214,28 @@ function AddNewColumn({ api }: { api: BoardApi }) { : ( api.addNewColumn(columnName)} + save={async (columnName) => { + const created = await api.addNewColumn(columnName); + if (!created) { + toast.showMessage(t("board_view.column-already-exists"), undefined, "bx bx-duplicate"); + } + }} dismiss={() => setIsCreatingNewColumn(false)} isNewItem + mode={isInRelationMode ? "relation" : "normal"} /> )} ) } -export function TitleEditor({ currentValue, placeholder, save, dismiss, multiline, isNewItem }: { +export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, isNewItem }: { currentValue?: string; placeholder?: string; save: (newValue: string) => void; dismiss: () => void; - multiline?: boolean; isNewItem?: boolean; + mode?: "normal" | "multiline" | "relation"; }) { const inputRef = useRef(null); const focusElRef = useRef(null); @@ -232,13 +243,11 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin const shouldDismiss = useRef(false); useEffect(() => { - focusElRef.current = document.activeElement; + focusElRef.current = document.activeElement !== document.body ? document.activeElement : null; inputRef.current?.focus(); inputRef.current?.select(); }, [ inputRef ]); - const Element = multiline ? FormTextArea : FormTextBox; - useEffect(() => { if (dismissOnNextRefreshRef.current) { dismiss(); @@ -246,31 +255,62 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin } }); - return ( - ) => { - if (e.key === "Enter" || e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - shouldDismiss.current = (e.key === "Escape"); - if (focusElRef.current instanceof HTMLElement) { - focusElRef.current.focus(); + const onKeyDown = (e: TargetedKeyboardEvent | KeyboardEvent) => { + if (e.key === "Enter" || e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (focusElRef.current instanceof HTMLElement) { + shouldDismiss.current = (e.key === "Escape"); + focusElRef.current.focus(); + } else { + dismiss(); + } + } + }; + + const onBlur = (newValue: string) => { + if (!shouldDismiss.current && newValue.trim() && (newValue !== currentValue || isNewItem)) { + save(newValue); + dismissOnNextRefreshRef.current = true; + } else { + dismiss(); + } + }; + + if (mode !== "relation") { + const Element = mode === "multiline" ? FormTextArea : FormTextBox; + + return ( + + ); + } else { + return ( + { + if (e.key === "Escape") { + dismiss(); } - } - }} - onBlur={(newValue) => { - if (!shouldDismiss.current && newValue.trim() && (newValue !== currentValue || isNewItem)) { + }} + onBlur={() => dismiss()} + noteIdChanged={(newValue) => { save(newValue); - dismissOnNextRefreshRef.current = true; - } else { dismiss(); - } - }} - /> - ); + }} + /> + ); + } } diff --git a/apps/client/src/widgets/collections/calendar/api.ts b/apps/client/src/widgets/collections/calendar/api.ts index 934edcb2e..eef391108 100644 --- a/apps/client/src/widgets/collections/calendar/api.ts +++ b/apps/client/src/widgets/collections/calendar/api.ts @@ -58,8 +58,6 @@ export async function changeEvent(note: FNote, { startDate, endDate, startTime, startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime"; endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime"; - if (startTime && endTime) { - setAttribute(note, "label", startAttribute, startTime); - setAttribute(note, "label", endAttribute, endTime); - } + setAttribute(note, "label", startAttribute, startTime); + setAttribute(note, "label", endAttribute, endTime); } diff --git a/apps/client/src/widgets/collections/calendar/context_menu.ts b/apps/client/src/widgets/collections/calendar/context_menu.ts new file mode 100644 index 000000000..b15ba376d --- /dev/null +++ b/apps/client/src/widgets/collections/calendar/context_menu.ts @@ -0,0 +1,41 @@ +import FNote from "../../../entities/fnote"; +import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu"; +import link_context_menu from "../../../menus/link_context_menu"; +import branches from "../../../services/branches"; +import froca from "../../../services/froca"; +import { t } from "../../../services/i18n"; + +export function openCalendarContextMenu(e: ContextMenuEvent, noteId: string, parentNote: FNote) { + e.preventDefault(); + e.stopPropagation(); + + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items: [ + ...link_context_menu.getItems(), + { kind: "separator" }, + { + title: t("calendar_view.delete_note"), + uiIcon: "bx bx-trash", + handler: async () => { + const noteToDelete = await froca.getNote(noteId); + if (!noteToDelete) return; + + let branchIdToDelete: string | null = null; + for (const parentBranch of noteToDelete.getParentBranches()) { + const parentNote = await parentBranch.getNote(); + if (parentNote?.hasAncestor(parentNote.noteId)) { + branchIdToDelete = parentBranch.branchId; + } + } + + if (branchIdToDelete) { + await branches.deleteNotes([ branchIdToDelete ], false, false); + } + } + } + ], + selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), + }) +} diff --git a/apps/client/src/widgets/collections/calendar/index.css b/apps/client/src/widgets/collections/calendar/index.css index e6649e53f..5dd836fe6 100644 --- a/apps/client/src/widgets/collections/calendar/index.css +++ b/apps/client/src/widgets/collections/calendar/index.css @@ -1,6 +1,7 @@ .calendar-view { overflow: hidden; position: relative; + outline: 0; height: 100%; user-select: none; padding: 10px; @@ -67,6 +68,7 @@ } body.desktop:not(.zen) .calendar-view .calendar-header { + padding-block-start: 4px; padding-inline-end: 5em; } diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 3c3925bae..bac6862b2 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"; @@ -20,6 +20,7 @@ import Button, { ButtonGroup } from "../../react/Button"; import ActionButton from "../../react/ActionButton"; import { RefObject } from "preact"; import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar"; +import { openCalendarContextMenu } from "./context_menu"; interface CalendarViewData { @@ -90,6 +91,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps setCalendarView(initialView.current)); useResizeObserver(containerRef, () => calendarRef.current?.updateSize()); @@ -106,7 +108,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps(); useEffect(() => { - const correspondingLocale = LOCALE_MAPPINGS[locale]; + const correspondingLocale = LOCALE_MAPPINGS[formattingLocale]; if (correspondingLocale) { correspondingLocale().then((locale) => setCalendarLocale(locale.default)); } else { @@ -253,7 +256,7 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) { }; } -function useEventDisplayCustomization() { +function useEventDisplayCustomization(parentNote: FNote) { const eventDidMount = useCallback((e: EventMountArg) => { const { iconClass, promotedAttributes } = e.event.extendedProps; @@ -302,6 +305,11 @@ function useEventDisplayCustomization() { } $(mainContainer ?? e.el).append($(promotedAttributesHtml)); } + + e.el.addEventListener("contextmenu", (contextMenuEvent) => { + const noteId = e.event.extendedProps.noteId; + openCalendarContextMenu(contextMenuEvent, noteId, parentNote); + }); }, []); return { eventDidMount }; } 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/collections/legacy/ListOrGridView.css b/apps/client/src/widgets/collections/legacy/ListOrGridView.css index 60afe8954..2a6b25366 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.css +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.css @@ -16,6 +16,10 @@ flex-grow: 1; } +.note-book-card.archived { + opacity: 0.5; +} + .note-book-card:not(.expanded) .note-book-content { padding: 10px } diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx index 2b5d1bdd0..ef37b6685 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx @@ -64,7 +64,7 @@ function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: F return (
@@ -100,7 +100,7 @@ function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, pa return (
link.goToLink(e)} diff --git a/apps/client/src/widgets/collections/presentation/index.tsx b/apps/client/src/widgets/collections/presentation/index.tsx index dfa4574e0..ca236a46c 100644 --- a/apps/client/src/widgets/collections/presentation/index.tsx +++ b/apps/client/src/widgets/collections/presentation/index.tsx @@ -41,7 +41,7 @@ export default function PresentationView({ note, noteIds, media, onReady }: View } }, [ api, presentation ]); - if (!presentation || !stylesheets) return; + if (!presentation || !stylesheets || !note.hasChildren()) return; const content = ( <> {stylesheets.map(stylesheet => )} diff --git a/apps/client/src/widgets/containers/content_header.ts b/apps/client/src/widgets/containers/content_header.ts new file mode 100644 index 000000000..ac001d40a --- /dev/null +++ b/apps/client/src/widgets/containers/content_header.ts @@ -0,0 +1,63 @@ +import { EventData } from "../../components/app_context"; +import BasicWidget from "../basic_widget"; +import Container from "./container"; +import NoteContext from "../../components/note_context"; + +export default class ContentHeader extends Container { + + noteContext?: NoteContext; + thisElement?: HTMLElement; + parentElement?: HTMLElement; + resizeObserver: ResizeObserver; + currentHeight: number = 0; + currentSafeMargin: number = NaN; + + constructor() { + super(); + + this.class("content-header-widget"); + this.css("contain", "unset"); + this.resizeObserver = new ResizeObserver(this.onResize.bind(this)); + } + + setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) { + this.noteContext = noteContext; + this.init(); + } + + init() { + this.parentElement = this.parent?.$widget.get(0); + if (!this.parentElement) { + console.warn("No parent set for ."); + return; + } + + this.thisElement = this.$widget.get(0)!; + + this.resizeObserver.observe(this.thisElement); + this.parentElement.addEventListener("scroll", this.updateSafeMargin.bind(this)); + } + + updateSafeMargin() { + const newSafeMargin = Math.max(this.currentHeight - this.parentElement!.scrollTop, 0); + + if (newSafeMargin !== this.currentSafeMargin) { + this.currentSafeMargin = newSafeMargin; + + this.triggerEvent("contentSafeMarginChanged", { + top: newSafeMargin, + noteContext: this.noteContext! + }); + } + } + + onResize(entries: ResizeObserverEntry[]) { + for (const entry of entries) { + if (entry.target === this.thisElement) { + this.currentHeight = entry.contentRect.height; + this.updateSafeMargin(); + } + } + } + +} \ No newline at end of file diff --git a/apps/client/src/widgets/containers/root_container.ts b/apps/client/src/widgets/containers/root_container.ts index bd1ae9719..d2622e6e2 100644 --- a/apps/client/src/widgets/containers/root_container.ts +++ b/apps/client/src/widgets/containers/root_container.ts @@ -1,9 +1,10 @@ import { EventData } from "../../components/app_context.js"; +import { LOCALES } from "@triliumnext/commons"; +import { readCssVar } from "../../utils/css-var.js"; import FlexContainer from "./flex_container.js"; import options from "../../services/options.js"; import type BasicWidget from "../basic_widget.js"; import utils from "../../services/utils.js"; -import { LOCALES } from "@triliumnext/commons"; /** * The root container is the top-most widget/container, from which the entire layout derives. @@ -30,9 +31,11 @@ export default class RootContainer extends FlexContainer { window.visualViewport?.addEventListener("resize", () => this.#onMobileResize()); } - this.#setMotion(options.is("motionEnabled")); - this.#setShadows(options.is("shadowsEnabled")); - this.#setBackdropEffects(options.is("backdropEffectsEnabled")); + this.#setMaxContentWidth(); + this.#setMotion(); + this.#setShadows(); + this.#setBackdropEffects(); + this.#setThemeCapabilities(); this.#setLocaleAndDirection(options.get("locale")); return super.render(); @@ -40,15 +43,21 @@ export default class RootContainer extends FlexContainer { entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { if (loadResults.isOptionReloaded("motionEnabled")) { - this.#setMotion(options.is("motionEnabled")); + this.#setMotion(); } if (loadResults.isOptionReloaded("shadowsEnabled")) { - this.#setShadows(options.is("shadowsEnabled")); + this.#setShadows(); } if (loadResults.isOptionReloaded("backdropEffectsEnabled")) { - this.#setBackdropEffects(options.is("backdropEffectsEnabled")); + this.#setBackdropEffects(); + } + + if (loadResults.isOptionReloaded("maxContentWidth") + || loadResults.isOptionReloaded("centerContent")) { + + this.#setMaxContentWidth(); } } @@ -58,19 +67,38 @@ export default class RootContainer extends FlexContainer { this.$widget.toggleClass("virtual-keyboard-opened", isKeyboardOpened); } - #setMotion(enabled: boolean) { + #setMaxContentWidth() { + const width = Math.max(options.getInt("maxContentWidth") || 0, 640); + document.body.style.setProperty("--preferred-max-content-width", `${width}px`); + + document.body.classList.toggle("prefers-centered-content", options.is("centerContent")); + } + + #setMotion() { + const enabled = options.is("motionEnabled"); document.body.classList.toggle("motion-disabled", !enabled); jQuery.fx.off = !enabled; } - #setShadows(enabled: boolean) { + #setShadows() { + const enabled = options.is("shadowsEnabled"); document.body.classList.toggle("shadows-disabled", !enabled); } - #setBackdropEffects(enabled: boolean) { + #setBackdropEffects() { + const enabled = options.is("backdropEffectsEnabled"); document.body.classList.toggle("backdrop-effects-disabled", !enabled); } + #setThemeCapabilities() { + // Supports background effects + + const useBgfx = readCssVar(document.documentElement, "allow-background-effects") + .asBoolean(false); + + document.body.classList.toggle("theme-supports-background-effects", useBgfx); + } + #setLocaleAndDirection(locale: string) { const correspondingLocale = LOCALES.find(l => l.id === locale); document.body.lang = locale; 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/containers/split_note_container.ts b/apps/client/src/widgets/containers/split_note_container.ts index 8298d5989..02ed8cf04 100644 --- a/apps/client/src/widgets/containers/split_note_container.ts +++ b/apps/client/src/widgets/containers/split_note_container.ts @@ -100,9 +100,22 @@ export default class SplitNoteContainer extends FlexContainer { } async closeThisNoteSplitCommand({ ntxId }: CommandListenerData<"closeThisNoteSplit">) { - if (ntxId) { - await appContext.tabManager.removeNoteContext(ntxId); + if (!ntxId) return; + const contexts = appContext.tabManager.noteContexts; + const currentIndex = contexts.findIndex((c) => c.ntxId === ntxId); + if (currentIndex === -1) return; + + const isRemoveMainContext = contexts[currentIndex].isMainContext(); + if (isRemoveMainContext && currentIndex + 1 < contexts.length) { + const ntxIds = contexts.map((c) => c.ntxId).filter((c) => !!c) as string[]; + this.triggerCommand("noteContextReorder", { + ntxIdsInOrder: ntxIds, + oldMainNtxId: ntxId, + newMainNtxId: ntxIds[currentIndex + 1] + }); } + + await appContext.tabManager.removeNoteContext(ntxId); } async moveThisNoteSplitCommand({ ntxId, isMovingLeft }: CommandListenerData<"moveThisNoteSplit">) { @@ -167,12 +180,16 @@ export default class SplitNoteContainer extends FlexContainer { splitService.delNoteSplitResizer(ntxIds); } - contextsReopenedEvent({ ntxId, afterNtxId }: EventData<"contextsReopened">) { - if (ntxId === undefined || afterNtxId === undefined) { - // no single split reopened - return; + contextsReopenedEvent({ ntxId, mainNtxId, afterNtxId }: EventData<"contextsReopened">) { + if (ntxId !== undefined && afterNtxId !== undefined) { + this.$widget.find(`[data-ntx-id="${ntxId}"]`).insertAfter(this.$widget.find(`[data-ntx-id="${afterNtxId}"]`)); + } else if (mainNtxId) { + const contexts = appContext.tabManager.noteContexts; + const nextIndex = contexts.findIndex(c => c.ntxId === mainNtxId); + const beforeNtxId = (nextIndex !== -1 && nextIndex + 1 < contexts.length) ? contexts[nextIndex + 1].ntxId : null; + + this.$widget.find(`[data-ntx-id="${mainNtxId}"]`).insertBefore(this.$widget.find(`[data-ntx-id="${beforeNtxId}"]`)); } - this.$widget.find(`[data-ntx-id="${ntxId}"]`).insertAfter(this.$widget.find(`[data-ntx-id="${afterNtxId}"]`)); } async refresh() { 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={
- -
-
@@ -79,6 +85,7 @@ export default class PopupEditorDialog extends Container { private noteContext: NoteContext; private $modalHeader!: JQuery; private $modalBody!: JQuery; + private $wrapper!: JQuery; constructor() { super(); @@ -93,6 +100,7 @@ export default class PopupEditorDialog extends Container { const $newWidget = $(TPL); this.$modalHeader = $newWidget.find(".modal-title"); this.$modalBody = $newWidget.find(".modal-body"); + this.$wrapper = $newWidget.find(".quick-edit-dialog-wrapper"); const children = this.$widget.children(); this.$modalHeader.append(children[0]); @@ -112,17 +120,27 @@ export default class PopupEditorDialog extends Container { } }); + const colorClass = this.noteContext.note?.getColorClass(); + const wrapperElement = this.$wrapper.get(0)!; + + if (colorClass) { + wrapperElement.className = "quick-edit-dialog-wrapper " + colorClass; + } else { + wrapperElement.className = "quick-edit-dialog-wrapper"; + } + + const customHue = getComputedStyle(wrapperElement).getPropertyValue("--custom-color-hue"); + if (customHue) { + /* Apply the tinted-dialog class only if the custom color CSS class specifies a hue */ + wrapperElement.classList.add("tinted-quick-edit-dialog"); + } + const activeEl = document.activeElement; if (activeEl && "blur" in activeEl) { (activeEl as HTMLElement).blur(); } $dialog.on("shown.bs.modal", async () => { - // Reduce the z-index of modals so that ckeditor popups are properly shown on top of it. - // The backdrop instance is not shared so it's OK to make a one-off modification. - $("body > .modal-backdrop").css("z-index", "998"); - $dialog.css("z-index", "999"); - await this.handleEventInChildren("activeContextChanged", { noteContext: this.noteContext }); this.setVisibility(true); await this.handleEventInChildren("focusOnDetail", { ntxId: this.noteContext.ntxId }); @@ -130,7 +148,7 @@ export default class PopupEditorDialog extends Container { $dialog.on("hidden.bs.modal", () => { const $typeWidgetEl = $dialog.find(".note-detail-printable"); if ($typeWidgetEl.length) { - const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as TypeWidget; + const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget; typeWidget.cleanup(); } @@ -143,9 +161,12 @@ export default class PopupEditorDialog extends Container { if (visible) { $bodyItems.fadeIn(); this.$modalHeader.children().show(); + document.body.classList.add("popup-editor-open"); + } else { $bodyItems.hide(); this.$modalHeader.children().hide(); + document.body.classList.remove("popup-editor-open"); } } diff --git a/apps/client/src/widgets/note_detail.ts b/apps/client/src/widgets/note_detail.ts deleted file mode 100644 index a976b97ce..000000000 --- a/apps/client/src/widgets/note_detail.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { t } from "../services/i18n.js"; -import NoteContextAwareWidget from "./note_context_aware_widget.js"; -import protectedSessionHolder from "../services/protected_session_holder.js"; -import SpacedUpdate from "../services/spaced_update.js"; -import server from "../services/server.js"; -import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js"; -import keyboardActionsService from "../services/keyboard_actions.js"; -import noteCreateService from "../services/note_create.js"; -import attributeService from "../services/attributes.js"; - -import EmptyTypeWidget from "./type_widgets/empty.js"; -import EditableTextTypeWidget from "./type_widgets/editable_text.js"; -import EditableCodeTypeWidget from "./type_widgets/editable_code.js"; -import FileTypeWidget from "./type_widgets/file.js"; -import ImageTypeWidget from "./type_widgets/image.js"; -import RenderTypeWidget from "./type_widgets/render.js"; -import RelationMapTypeWidget from "./type_widgets/relation_map.js"; -import CanvasTypeWidget from "./type_widgets/canvas.js"; -import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js"; -import BookTypeWidget from "./type_widgets/book.js"; -import ReadOnlyTextTypeWidget from "./type_widgets/read_only_text.js"; -import ReadOnlyCodeTypeWidget from "./type_widgets/read_only_code.js"; -import NoneTypeWidget from "./type_widgets/none.js"; -import NoteMapTypeWidget from "./type_widgets/note_map.js"; -import WebViewTypeWidget from "./type_widgets/web_view.js"; -import DocTypeWidget from "./type_widgets/doc.js"; -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 utils, { isElectron } from "../services/utils.js"; -import type { NoteType } from "../entities/fnote.js"; -import type TypeWidget from "./type_widgets/type_widget.js"; -import { MermaidTypeWidget } from "./type_widgets/mermaid.js"; -import AiChatTypeWidget from "./type_widgets/ai_chat.js"; -import toast from "../services/toast.js"; - -const TPL = /*html*/` -
- -
-`; - -const typeWidgetClasses = { - empty: EmptyTypeWidget, - editableText: EditableTextTypeWidget, - readOnlyText: ReadOnlyTextTypeWidget, - editableCode: EditableCodeTypeWidget, - readOnlyCode: ReadOnlyCodeTypeWidget, - file: FileTypeWidget, - image: ImageTypeWidget, - search: NoneTypeWidget, - render: RenderTypeWidget, - relationMap: RelationMapTypeWidget, - canvas: CanvasTypeWidget, - protectedSession: ProtectedSessionTypeWidget, - book: BookTypeWidget, - noteMap: NoteMapTypeWidget, - webView: WebViewTypeWidget, - doc: DocTypeWidget, - contentWidget: ContentWidgetTypeWidget, - attachmentDetail: AttachmentDetailTypeWidget, - attachmentList: AttachmentListTypeWidget, - mindMap: MindMapWidget, - aiChat: AiChatTypeWidget, - - // Split type editors - mermaid: MermaidTypeWidget -}; - -/** - * 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. - */ -type ExtendedNoteType = - | Exclude - | "empty" - | "readOnlyCode" - | "readOnlyText" - | "editableText" - | "editableCode" - | "attachmentDetail" - | "attachmentList" - | "protectedSession" - | "aiChat"; - -export default class NoteDetailWidget extends NoteContextAwareWidget { - - private typeWidgets: Record; - private spacedUpdate: SpacedUpdate; - private type?: ExtendedNoteType; - private mime?: string; - - constructor() { - super(); - - this.typeWidgets = {}; - - this.spacedUpdate = new SpacedUpdate(async () => { - if (!this.noteContext) { - return; - } - - const { note } = this.noteContext; - if (!note) { - return; - } - - const { noteId } = note; - - const data = await this.getTypeWidget().getData(); - - // for read only notes - if (data === undefined) { - return; - } - - protectedSessionHolder.touchProtectedSessionIfNecessary(note); - - await server.put(`notes/${noteId}/data`, data, this.componentId); - - this.getTypeWidget().dataSaved(); - }); - - appContext.addBeforeUnloadListener(this); - } - - isEnabled() { - return true; - } - - doRender() { - this.$widget = $(TPL); - this.contentSized(); - - if (utils.isElectron()) { - const { ipcRenderer } = utils.dynamicRequire("electron"); - ipcRenderer.on("print-done", () => { - toast.closePersistent("printing"); - }); - } - } - - async refresh() { - this.type = await this.getWidgetType(); - this.mime = this.note?.mime; - - if (!(this.type in this.typeWidgets)) { - const clazz = typeWidgetClasses[this.type]; - - if (!clazz) { - throw new Error(`Cannot find type widget for type '${this.type}'`); - } - - const typeWidget = (this.typeWidgets[this.type] = new clazz()); - typeWidget.spacedUpdate = this.spacedUpdate; - typeWidget.setParent(this); - - if (this.noteContext) { - typeWidget.setNoteContextEvent({ noteContext: this.noteContext }); - } - const $renderedWidget = typeWidget.render(); - keyboardActionsService.updateDisplayedShortcuts($renderedWidget); - - this.$widget.append($renderedWidget); - - if (this.noteContext) { - await typeWidget.handleEvent("setNoteContext", { noteContext: this.noteContext }); - } - - // this is happening in update(), so note has been already set, and we need to reflect this - if (this.noteContext) { - await typeWidget.handleEvent("noteSwitched", { - noteContext: this.noteContext, - notePath: this.noteContext.notePath - }); - } - - this.child(typeWidget); - } - - this.checkFullHeight(); - - if (utils.isMobile()) { - const hasFixedTree = this.noteContext?.hoistedNoteId === "_lbMobileRoot"; - $("body").toggleClass("force-fixed-tree", hasFixedTree); - } - } - - /** - * sets full height of container that contains note content for a subset of note-types - */ - checkFullHeight() { - // 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", "mermaid", "file", "aiChat"].includes(this.type ?? ""); - const isFullHeight = (!this.noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote) - || this.noteContext?.viewScope?.viewMode === "attachments" - || isBackendNote; - - this.$widget.toggleClass("full-height", isFullHeight); - } - - getTypeWidget() { - if (!this.type || !this.typeWidgets[this.type]) { - throw new Error(t(`note_detail.could_not_find_typewidget`, { type: this.type })); - } - - return this.typeWidgets[this.type]; - } - - async getWidgetType(): Promise { - const note = this.note; - if (!note) { - return "empty"; - } - - const type = note.type; - let resultingType: ExtendedNoteType; - const viewScope = this.noteContext?.viewScope; - - if (viewScope?.viewMode === "source") { - resultingType = "readOnlyCode"; - } else if (viewScope && viewScope.viewMode === "attachments") { - resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList"; - } else if (type === "text" && (await this.noteContext?.isReadOnly())) { - resultingType = "readOnlyText"; - } else if ((type === "code" || type === "mermaid") && (await this.noteContext?.isReadOnly())) { - resultingType = "readOnlyCode"; - } else if (type === "text") { - resultingType = "editableText"; - } else if (type === "code") { - resultingType = "editableCode"; - } else if (type === "launcher") { - resultingType = "doc"; - } else { - resultingType = type; - } - - if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) { - resultingType = "protectedSession"; - } - - return resultingType; - } - - async focusOnDetailEvent({ ntxId }: EventData<"focusOnDetail">) { - if (this.noteContext?.ntxId !== ntxId) { - return; - } - - await this.refresh(); - const widget = this.getTypeWidget(); - await widget.initialized; - widget.focus(); - } - - async scrollToEndEvent({ ntxId }: EventData<"scrollToEnd">) { - if (this.noteContext?.ntxId !== ntxId) { - return; - } - - await this.refresh(); - const widget = this.getTypeWidget(); - await widget.initialized; - - if (widget.scrollToEnd) { - widget.scrollToEnd(); - } - } - - async beforeNoteSwitchEvent({ noteContext }: EventData<"beforeNoteSwitch">) { - if (this.isNoteContext(noteContext.ntxId)) { - await this.spacedUpdate.updateNowIfNecessary(); - } - } - - async beforeNoteContextRemoveEvent({ ntxIds }: EventData<"beforeNoteContextRemove">) { - if (this.isNoteContext(ntxIds)) { - await this.spacedUpdate.updateNowIfNecessary(); - } - } - - async runActiveNoteCommand(params: CommandListenerData<"runActiveNote">) { - if (this.isNoteContext(params.ntxId)) { - // make sure that script is saved before running it #4028 - await this.spacedUpdate.updateNowIfNecessary(); - } - - return await this.parent?.triggerCommand("runActiveNote", params); - } - - async printActiveNoteEvent() { - if (!this.noteContext?.isActive()) { - return; - } - - toast.showPersistent({ - icon: "bx bx-loader-circle bx-spin", - message: t("note_detail.printing"), - id: "printing" - }); - - if (isElectron()) { - const { ipcRenderer } = utils.dynamicRequire("electron"); - ipcRenderer.send("print-note", { - notePath: this.notePath - }); - } else { - const iframe = document.createElement('iframe'); - iframe.src = `?print#${this.notePath}`; - iframe.className = "print-iframe"; - document.body.appendChild(iframe); - iframe.onload = () => { - if (!iframe.contentWindow) { - toast.closePersistent("printing"); - document.body.removeChild(iframe); - return; - } - - iframe.contentWindow.addEventListener("note-ready", () => { - toast.closePersistent("printing"); - iframe.contentWindow?.print(); - document.body.removeChild(iframe); - }); - }; - } - } - - async exportAsPdfEvent() { - if (!this.noteContext?.isActive() || !this.note || !this.notePath) { - return; - } - - toast.showPersistent({ - icon: "bx bx-loader-circle bx-spin", - message: t("note_detail.printing_pdf"), - id: "printing" - }); - - const { ipcRenderer } = utils.dynamicRequire("electron"); - ipcRenderer.send("export-as-pdf", { - title: this.note.title, - notePath: this.notePath, - pageSize: this.note.getAttributeValue("label", "printPageSize") ?? "Letter", - landscape: this.note.hasAttribute("label", "printLandscape") - }); - } - - hoistedNoteChangedEvent({ ntxId }: EventData<"hoistedNoteChanged">) { - if (this.isNoteContext(ntxId)) { - this.refresh(); - } - } - - async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - // we're detecting note type change on the note_detail level, but triggering the noteTypeMimeChanged - // globally, so it gets also to e.g. ribbon components. But this means that the event can be generated multiple - // times if the same note is open in several tabs. - - if (this.noteId && loadResults.isNoteContentReloaded(this.noteId, this.componentId)) { - // probably incorrect event - // calling this.refresh() is not enough since the event needs to be propagated to children as well - // FIXME: create a separate event to force hierarchical refresh - - // this uses handleEvent to make sure that the ordinary content updates are propagated only in the subtree - // to avoid the problem in #3365 - this.handleEvent("noteTypeMimeChanged", { noteId: this.noteId }); - } else if (this.noteId && loadResults.isNoteReloaded(this.noteId, this.componentId) && (this.type !== (await this.getWidgetType()) || this.mime !== this.note?.mime)) { - // this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated - this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId }); - } else { - const attrs = loadResults.getAttributeRows(); - - const label = attrs.find( - (attr) => - attr.type === "label" && - ["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") && - attributeService.isAffecting(attr, this.note) - ); - - const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"].includes(attr.name ?? "") && attributeService.isAffecting(attr, this.note)); - - if (this.noteId && (label || relation)) { - // probably incorrect event - // calling this.refresh() is not enough since the event needs to be propagated to children as well - this.triggerEvent("noteTypeMimeChanged", { noteId: this.noteId }); - } - } - } - - beforeUnloadEvent() { - return this.spacedUpdate.isAllSavedAndTriggerUpdate(); - } - - readOnlyTemporarilyDisabledEvent({ noteContext }: EventData<"readOnlyTemporarilyDisabled">) { - if (this.isNoteContext(noteContext.ntxId)) { - this.refresh(); - } - } - - async executeInActiveNoteDetailWidgetEvent({ callback }: EventData<"executeInActiveNoteDetailWidget">) { - if (!this.isActiveNoteContext()) { - return; - } - - await this.initialized; - - callback(this); - } - - async cutIntoNoteCommand() { - const note = appContext.tabManager.getActiveContextNote(); - - if (!note) { - return; - } - - // without await as this otherwise causes deadlock through component mutex - const parentNotePath = appContext.tabManager.getActiveContextNotePath(); - if (this.noteContext && parentNotePath) { - noteCreateService.createNote(parentNotePath, { - isProtected: note.isProtected, - saveSelection: true, - textEditor: await this.noteContext.getTextEditor() - }); - } - } - - // used by cutToNote in CKEditor build - async saveNoteDetailNowCommand() { - await this.spacedUpdate.updateNowIfNecessary(); - } - - renderActiveNoteEvent() { - if (this.noteContext?.isActive()) { - this.refresh(); - } - } - - async executeWithTypeWidgetEvent({ resolve, ntxId }: EventData<"executeWithTypeWidget">) { - if (!this.isNoteContext(ntxId)) { - return; - } - - await this.initialized; - - await this.getWidgetType(); - - resolve(this.getTypeWidget()); - } -} diff --git a/apps/client/src/widgets/note_map.ts b/apps/client/src/widgets/note_map.ts deleted file mode 100644 index ef36d7b7e..000000000 --- a/apps/client/src/widgets/note_map.ts +++ /dev/null @@ -1,671 +0,0 @@ -import server from "../services/server.js"; -import attributeService from "../services/attributes.js"; -import hoistedNoteService from "../services/hoisted_note.js"; -import appContext, { type EventData } from "../components/app_context.js"; -import NoteContextAwareWidget from "./note_context_aware_widget.js"; -import linkContextMenuService from "../menus/link_context_menu.js"; -import utils from "../services/utils.js"; -import { t } from "../services/i18n.js"; -import type ForceGraph from "force-graph"; -import type { GraphData, LinkObject, NodeObject } from "force-graph"; -import type FNote from "../entities/fnote.js"; - -const esc = utils.escapeHtml; - -const TPL = /*html*/`
- - -
- - -
- - - -
- - -
- -
- -
-
`; - -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_tree.ts b/apps/client/src/widgets/note_tree.ts index cb2120687..1fd373559 100644 --- a/apps/client/src/widgets/note_tree.ts +++ b/apps/client/src/widgets/note_tree.ts @@ -173,14 +173,6 @@ interface ExpandedSubtreeResponse { branchIds: string[]; } -interface Node extends Fancytree.NodeData { - noteId: string; - parentNoteId: string; - branchId: string; - isProtected: boolean; - noteType: NoteType; -} - interface RefreshContext { noteIdsToUpdate: Set; noteIdsToReload: Set; @@ -769,7 +761,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { prepareChildren(parentNote: FNote) { utils.assertArguments(parentNote); - const noteList: Node[] = []; + const noteList: Fancytree.FancytreeNewNode[] = []; const hideArchivedNotes = this.hideArchivedNotes; @@ -837,7 +829,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const isFolder = note.isFolder(); - const node: Node = { + const node: Fancytree.FancytreeNewNode = { noteId: note.noteId, parentNoteId: branch.parentNoteId, branchId: branch.branchId, @@ -849,7 +841,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { refKey: note.noteId, lazy: true, folder: isFolder, - expanded: branch.isExpanded && note.type !== "search", + expanded: !!branch.isExpanded && note.type !== "search", key: utils.randomString(12) // this should prevent some "duplicate key" errors }; @@ -911,7 +903,6 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return extraClasses.join(" "); } - /** @returns {FancytreeNode[]} */ getSelectedNodes(stopOnParents = false) { return this.tree.getSelectedNodes(stopOnParents); } @@ -1532,7 +1523,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // Automatically expand the hoisted note by default const node = this.getActiveNode(); - if (node?.data.noteId === this.noteContext.hoistedNoteId){ + if (node && node.data.noteId === this.noteContext.hoistedNoteId){ this.setExpanded(node.data.branchId, true); } } 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/note_wrapper.ts b/apps/client/src/widgets/note_wrapper.ts index 01a1f7c87..f3c61859d 100644 --- a/apps/client/src/widgets/note_wrapper.ts +++ b/apps/client/src/widgets/note_wrapper.ts @@ -52,6 +52,7 @@ export default class NoteWrapperWidget extends FlexContainer { const note = this.noteContext?.note; if (!note) { + this.$widget.addClass("bgfx empty-note"); return; } @@ -61,7 +62,7 @@ export default class NoteWrapperWidget extends FlexContainer { this.$widget.addClass(utils.getNoteTypeClass(note.type)); this.$widget.addClass(utils.getMimeTypeClass(note.mime)); - + this.$widget.toggleClass(["bgfx", "options"], note.isOptions()); this.$widget.toggleClass("protected", note.isProtected); const noteLanguage = note?.getLabelValue("language"); @@ -70,7 +71,7 @@ export default class NoteWrapperWidget extends FlexContainer { } #isFullWidthNote(note: FNote) { - if (["image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type)) { + if (["code", "image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type)) { return 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/CKEditor.tsx b/apps/client/src/widgets/react/CKEditor.tsx index 8b8839a38..84df2d059 100644 --- a/apps/client/src/widgets/react/CKEditor.tsx +++ b/apps/client/src/widgets/react/CKEditor.tsx @@ -6,9 +6,9 @@ export interface CKEditorApi { focus(): void; /** * Imperatively sets the text in the editor. - * + * * Prefer setting `currentValue` prop where possible. - * + * * @param text text to set in the editor */ setText(text: string): void; @@ -27,15 +27,16 @@ interface CKEditorOpts { onClick?: (e: MouseEvent, pos?: ModelPosition | null) => void; onKeyDown?: (e: KeyboardEvent) => void; onBlur?: () => void; + onInitialized?: (editorInstance: CKTextEditor) => void; } -export default function CKEditor({ apiRef, currentValue, editor, config, disableNewlines, disableSpellcheck, onChange, onClick, ...restProps }: CKEditorOpts) { - const editorContainerRef = useRef(null); +export default function CKEditor({ apiRef, currentValue, editor, config, disableNewlines, disableSpellcheck, onChange, onClick, onInitialized, ...restProps }: CKEditorOpts) { + const editorContainerRef = useRef(null); const textEditorRef = useRef(null); useImperativeHandle(apiRef, () => { return { focus() { - editorContainerRef.current?.focus(); + textEditorRef.current?.editing.view.focus(); textEditorRef.current?.model.change((writer) => { const documentRoot = textEditorRef.current?.editing.model.document.getRoot(); if (documentRoot) { @@ -83,6 +84,8 @@ export default function CKEditor({ apiRef, currentValue, editor, config, disable if (currentValue) { textEditor.setData(currentValue); } + + onInitialized?.(textEditor); }); }, []); @@ -103,4 +106,4 @@ export default function CKEditor({ apiRef, currentValue, editor, config, disable {...restProps} /> ) -} \ 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 (
); -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 06841b534..758122ed0 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "preact/hooks"; -import link from "../../services/link"; -import { useImperativeSearchHighlighlighting } from "./hooks"; +import link, { ViewScope } from "../../services/link"; +import { useImperativeSearchHighlighlighting, useTriliumEvent } from "./hooks"; interface NoteLinkOpts { className?: string; @@ -11,18 +11,28 @@ interface NoteLinkOpts { noPreview?: boolean; noTnLink?: boolean; highlightedTokens?: string[] | null | undefined; + // Override the text of the link, otherwise the note title is used. + title?: string; + viewScope?: ViewScope; + noContextMenu?: boolean; } -export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) { +export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) { const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath; + const noteId = stringifiedNotePath.split("/").at(-1); const ref = useRef(null); const [ jqueryEl, setJqueryEl ] = useState>(); const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens); + const [ noteTitle, setNoteTitle ] = useState(); useEffect(() => { - link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon }) - .then(setJqueryEl); - }, [ stringifiedNotePath, showNotePath ]); + link.createLink(stringifiedNotePath, { + title, + showNotePath, + showNoteIcon, + viewScope + }).then(setJqueryEl); + }, [ stringifiedNotePath, showNotePath, title, viewScope, noteTitle ]); useEffect(() => { if (!ref.current || !jqueryEl) return; @@ -30,6 +40,16 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc highlightSearch(ref.current); }, [ jqueryEl, highlightedTokens ]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + // React to note title changes, but only if the title is not overwritten. + if (!title && noteId) { + const entityRow = loadResults.getEntityRow("notes", noteId); + if (entityRow) { + setNoteTitle(entityRow.title); + } + } + }); + if (style) { jqueryEl?.css(style); } @@ -43,6 +63,10 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc $linkEl?.addClass("tn-link"); } + if (noContextMenu) { + $linkEl?.attr("data-no-context-menu", "true"); + } + if (className) { $linkEl?.addClass(className); } diff --git a/apps/client/src/widgets/react/Slider.tsx b/apps/client/src/widgets/react/Slider.tsx new file mode 100644 index 000000000..515362ea4 --- /dev/null +++ b/apps/client/src/widgets/react/Slider.tsx @@ -0,0 +1,20 @@ +interface SliderProps { + value: number; + onChange(newValue: number); + min?: number; + max?: number; + title?: string; +} + +export default function Slider({ onChange, ...restProps }: SliderProps) { + return ( + { + onChange(e.currentTarget.valueAsNumber); + }} + {...restProps} + /> + ); +} diff --git a/apps/client/src/widgets/react/TouchBar.tsx b/apps/client/src/widgets/react/TouchBar.tsx index e0f40fd87..02918d0a3 100644 --- a/apps/client/src/widgets/react/TouchBar.tsx +++ b/apps/client/src/widgets/react/TouchBar.tsx @@ -25,6 +25,7 @@ interface ButtonProps { icon?: string; click: () => void; enabled?: boolean; + backgroundColor?: string; } interface SpacerProps { @@ -129,13 +130,14 @@ export function TouchBarSlider({ label, value, minValue, maxValue, onChange }: S return <>; } -export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) { +export function TouchBarButton({ label, icon, click, enabled, backgroundColor }: ButtonProps) { const api = useContext(TouchBarContext); const item = useMemo(() => { if (!api) return null; return new api.TouchBar.TouchBarButton({ label, click, enabled, - icon: icon ? buildIcon(api.nativeImage, icon) : undefined + icon: icon ? buildIcon(api.nativeImage, icon) : undefined, + backgroundColor }); }, [ label, icon ]); @@ -171,6 +173,32 @@ export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChan return <>; } +export function TouchBarGroup({ children }: { children: ComponentChildren }) { + const remote = dynamicRequire("@electron/remote") as typeof import("@electron/remote"); + const items: TouchBarItem[] = []; + + const api: TouchBarContextApi = { + TouchBar: remote.TouchBar, + nativeImage: remote.nativeImage, + addItem: (item) => { + items.push(item); + } + }; + + if (api) { + const item = new api.TouchBar.TouchBarGroup({ + items: new api.TouchBar({ items }) + }); + api.addItem(item); + } + + return <> + + {children} + + ; +} + export function TouchBarSpacer({ size }: SpacerProps) { const api = useContext(TouchBarContext); diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index a8a549edb..0ee92f8af 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1,25 +1,28 @@ -import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; -import { CommandListenerData, EventData, EventNames } from "../../components/app_context"; -import { ParentComponent } from "./react_utils"; -import SpacedUpdate from "../../services/spaced_update"; +import { CSSProperties } from "preact/compat"; +import { DragData } from "../note_tree"; import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons"; -import options, { type OptionValue } from "../../services/options"; -import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils"; -import NoteContext from "../../components/note_context"; -import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; -import FNote from "../../entities/fnote"; -import attributes from "../../services/attributes"; -import FBlob from "../../entities/fblob"; -import NoteContextAwareWidget from "../note_context_aware_widget"; +import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; +import { ParentComponent, refToJQuerySelector } from "./react_utils"; import { RefObject, VNode } from "preact"; import { Tooltip } from "bootstrap"; -import { CSSProperties } from "preact/compat"; +import { ViewMode, ViewScope } from "../../services/link"; +import appContext, { EventData, EventNames } from "../../components/app_context"; +import attributes from "../../services/attributes"; +import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; +import Component from "../../components/component"; +import FBlob from "../../entities/fblob"; +import FNote from "../../entities/fnote"; import keyboard_actions from "../../services/keyboard_actions"; import Mark from "mark.js"; -import { DragData } from "../note_tree"; -import Component from "../../components/component"; +import NoteContext from "../../components/note_context"; +import NoteContextAwareWidget from "../note_context_aware_widget"; +import options, { type OptionValue } from "../../services/options"; +import protected_session_holder from "../../services/protected_session_holder"; +import SpacedUpdate from "../../services/spaced_update"; import toast, { ToastOptions } from "../../services/toast"; -import { ViewMode } from "../../services/link"; +import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils"; +import server from "../../services/server"; +import { removeIndividualBinding } from "../../services/shortcuts"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -74,6 +77,66 @@ export function useSpacedUpdate(callback: () => void | Promise, interval = return spacedUpdateRef.current; } +export function useEditorSpacedUpdate({ note, noteContext, getData, onContentChange, dataSaved, updateInterval }: { + note: FNote, + noteContext: NoteContext | null | undefined, + getData: () => Promise | object | undefined, + onContentChange: (newContent: string) => void, + dataSaved?: () => void, + updateInterval?: number; +}) { + const parentComponent = useContext(ParentComponent); + const blob = useNoteBlob(note, parentComponent?.componentId); + + const callback = useMemo(() => { + return async () => { + const data = await getData(); + + // for read only notes + if (data === undefined) return; + + protected_session_holder.touchProtectedSessionIfNecessary(note); + await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId); + + dataSaved?.(); + } + }, [ note, getData, dataSaved ]) + const spacedUpdate = useSpacedUpdate(callback); + + // React to note/blob changes. + useEffect(() => { + if (!blob) return; + spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content)); + }, [ blob ]); + + // React to update interval changes. + useEffect(() => { + if (!updateInterval) return; + spacedUpdate.setUpdateInterval(updateInterval); + }, [ updateInterval ]); + + // Save if needed upon switching tabs. + useTriliumEvent("beforeNoteSwitch", async ({ noteContext: eventNoteContext }) => { + if (eventNoteContext.ntxId !== noteContext?.ntxId) return; + await spacedUpdate.updateNowIfNecessary(); + }); + + // Save if needed upon tab closing. + useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => { + if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return; + await spacedUpdate.updateNowIfNecessary(); + }) + + // Save if needed upon window/browser closing. + useEffect(() => { + const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate(); + appContext.addBeforeUnloadListener(listener); + return () => appContext.removeBeforeUnloadListener(listener); + }, []); + + return spacedUpdate; +} + /** * Allows a React component to read and write a Trilium option, while also watching for external changes. * @@ -197,7 +260,8 @@ export function useNoteContext() { const [ noteContext, setNoteContext ] = useState(); const [ notePath, setNotePath ] = useState(); const [ note, setNote ] = useState(); - const [ , setViewMode ] = useState(); + const [ , setViewScope ] = useState(); + const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState(noteContext?.viewScope?.isReadOnly); const [ refreshCounter, setRefreshCounter ] = useState(0); useEffect(() => { @@ -207,7 +271,7 @@ export function useNoteContext() { useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => { setNoteContext(noteContext); setNotePath(noteContext.notePath); - setViewMode(noteContext.viewScope?.viewMode); + setViewScope(noteContext.viewScope); }); useTriliumEvent("frocaReloaded", () => { setNote(noteContext?.note); @@ -217,6 +281,11 @@ export function useNoteContext() { setRefreshCounter(refreshCounter + 1); } }); + useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => { + if (eventNoteContext.ntxId === noteContext?.ntxId) { + setIsReadOnlyTemporarilyDisabled(eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled); + } + }); const parentComponent = useContext(ParentComponent) as ReactWrappedWidget; useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`); @@ -230,7 +299,8 @@ export function useNoteContext() { viewScope: noteContext?.viewScope, componentId: parentComponent.componentId, noteContext, - parentComponent + parentComponent, + isReadOnlyTemporarilyDisabled }; } @@ -336,14 +406,17 @@ export function useNoteLabelWithDefault(note: FNote | undefined | null, labelNam } export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: FilterLabelsByType): [ boolean, (newValue: boolean) => void] { - const [ labelValue, setLabelValue ] = useState(!!note?.hasLabel(labelName)); + const [, forceRender] = useState({}); - useEffect(() => setLabelValue(!!note?.hasLabel(labelName)), [ note ]); + useEffect(() => { + forceRender({}); + }, [ note ]); useTriliumEvent("entitiesReloaded", ({ loadResults }) => { for (const attr of loadResults.getAttributeRows()) { if (attr.type === "label" && attr.name === labelName && attributes.isAffecting(attr, note)) { - setLabelValue(!attr.isDeleted); + forceRender({}); + break; } } }); @@ -360,6 +433,7 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: F useDebugValue(labelName); + const labelValue = !!note?.hasLabel(labelName); return [ labelValue, setter ] as const; } @@ -373,7 +447,7 @@ export function useNoteLabelInt(note: FNote | undefined | null, labelName: Filte ] } -export function useNoteBlob(note: FNote | null | undefined): FBlob | null | undefined { +export function useNoteBlob(note: FNote | null | undefined, componentId?: string): FBlob | null | undefined { const [ blob, setBlob ] = useState(); function refresh() { @@ -394,6 +468,10 @@ export function useNoteBlob(note: FNote | null | undefined): FBlob | null | unde if (loadResults.hasRevisionForNote(note.noteId)) { refresh(); } + + if (loadResults.isNoteContentReloaded(note.noteId, componentId)) { + refresh(); + } }); useDebugValue(note?.noteId); @@ -667,26 +745,6 @@ export function useNoteTreeDrag(containerRef: MutableRef & { parentComponent: Component | null }) => void, - inputs: Inputs -) { - const parentComponent = useContext(ParentComponent); - - useLegacyImperativeHandlers({ - buildTouchBarCommand(context: CommandListenerData<"buildTouchBar">) { - return factory({ - ...context, - parentComponent - }); - } - }); - - useEffect(() => { - parentComponent?.triggerCommand("refreshTouchBar"); - }, inputs); -} - export function useResizeObserver(ref: RefObject, callback: () => void) { const resizeObserver = useRef(null); useEffect(() => { @@ -701,3 +759,65 @@ export function useResizeObserver(ref: RefObject, callback: () => v return () => observer.disconnect(); }, [ callback, ref ]); } + +export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", containerRef: RefObject, parentComponent: Component | undefined) { + useEffect(() => { + if (!parentComponent) return; + const $container = refToJQuerySelector(containerRef); + const bindingPromise = keyboard_actions.setupActionsForElement(scope, $container, parentComponent); + return async () => { + const bindings = await bindingPromise; + for (const binding of bindings) { + removeIndividualBinding(binding); + } + } + }, []); +} + +/** + * Indicates that the current note is in read-only mode, while an editing mode is available, + * and provides a way to switch to editing mode. + */ +export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: NoteContext | undefined) { + const [isReadOnly, setIsReadOnly] = useState(undefined); + + const enableEditing = useCallback(() => { + if (noteContext?.viewScope) { + noteContext.viewScope.readOnlyTemporarilyDisabled = true; + appContext.triggerEvent("readOnlyTemporarilyDisabled", {noteContext}); + } + }, [noteContext]); + + useEffect(() => { + if (note && noteContext) { + isNoteReadOnly(note, noteContext).then((readOnly) => { + setIsReadOnly(readOnly); + }); + } + }, [note, noteContext]); + + useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => { + if (noteContext?.ntxId === eventNoteContext.ntxId) { + setIsReadOnly(false); + } + }); + + return {isReadOnly, enableEditing}; +} + +async function isNoteReadOnly(note: FNote, noteContext: NoteContext) { + + if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) { + return false; + } + + if (options.is("databaseReadonly")) { + return false; + } + + if (noteContext.viewScope?.viewMode !== "default" || !await noteContext.isReadOnly()) { + return false; + } + + return true; +} diff --git a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx index 9d8113b3c..e961ae1f0 100644 --- a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx @@ -59,13 +59,12 @@ function CollectionTypeSwitcher({ viewType, setViewType }: { viewType: string, s function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOptions, note: FNote, properties: BookProperty[] }) { return ( <> - {properties.map(property => ( + {properties.map(property => (
- {mapPropertyView({ note, property })} + {mapPropertyView({ note, property })}
- ))} + ))} - {viewType !== "list" && viewType !== "grid" && ( - )} ) } diff --git a/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx b/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx index 4b42699d3..4777f4349 100644 --- a/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx @@ -18,58 +18,60 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
{note && ( - - - - - - - - - - - - + + + + + + + + + + + + + - - + - + + server.upload(`notes/${note.noteId}/file`, fileToUpload[0]).then((result) => { + if (result.uploaded) { + toast.showMessage(t("file_properties.upload_success")); + } else { + toast.showError(t("file_properties.upload_failed")); + } + }); + }} + /> + + + +
{t("file_properties.note_id")}:{note.noteId}{t("file_properties.original_file_name")}:{originalFileName ?? "?"}
{t("file_properties.file_type")}:{note.mime}{t("file_properties.file_size")}:{formatSize(blob?.contentLength ?? 0)}
{t("file_properties.note_id")}:{note.noteId}{t("file_properties.original_file_name")}:{originalFileName ?? "?"}
{t("file_properties.file_type")}:{note.mime}{t("file_properties.file_size")}:{formatSize(blob?.contentLength ?? 0)}
-
-
+
+
-
)}
diff --git a/apps/client/src/widgets/ribbon/NoteActions.tsx b/apps/client/src/widgets/ribbon/NoteActions.tsx index 1c1c17502..cbd3bf406 100644 --- a/apps/client/src/widgets/ribbon/NoteActions.tsx +++ b/apps/client/src/widgets/ribbon/NoteActions.tsx @@ -1,19 +1,21 @@ import { ConvertToAttachmentResponse } from "@triliumnext/commons"; -import appContext, { CommandNames } from "../../components/app_context"; -import FNote from "../../entities/fnote" -import dialog from "../../services/dialog"; -import { t } from "../../services/i18n" -import server from "../../services/server"; -import toast from "../../services/toast"; -import ws from "../../services/ws"; -import ActionButton from "../react/ActionButton" -import Dropdown from "../react/Dropdown"; import { FormDropdownDivider, FormListItem } from "../react/FormList"; import { isElectron as getIsElectron, isMac as getIsMac } from "../../services/utils"; import { ParentComponent } from "../react/react_utils"; +import { t } from "../../services/i18n" import { useContext } from "preact/hooks"; -import NoteContext from "../../components/note_context"; +import { useIsNoteReadOnly } from "../react/hooks"; +import { useTriliumOption } from "../react/hooks"; +import ActionButton from "../react/ActionButton" +import appContext, { CommandNames } from "../../components/app_context"; import branches from "../../services/branches"; +import dialog from "../../services/dialog"; +import Dropdown from "../react/Dropdown"; +import FNote from "../../entities/fnote" +import NoteContext from "../../components/note_context"; +import server from "../../services/server"; +import toast from "../../services/toast"; +import ws from "../../services/ws"; interface NoteActionsProps { note?: FNote; @@ -50,8 +52,10 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not const isPrintable = ["text", "code"].includes(note.type) || (note.type === "book" && note.getLabelValue("viewType") === "presentation"); const isElectron = getIsElectron(); const isMac = getIsMac(); - const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type); + const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "aiChat"].includes(note.type); const isSearchOrBook = ["search", "book"].includes(note.type); + const [ syncServerHost ] = useTriliumOption("syncServerHost"); + const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext); return ( + iconAction> + + {isReadOnly && <> + enableEditing()} /> + + } + {canBeConvertedToAttachment && } {note.type === "render" && } @@ -82,6 +92,9 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not + {(syncServerHost && isElectron) && + + } diff --git a/apps/client/src/widgets/ribbon/NoteInfoTab.tsx b/apps/client/src/widgets/ribbon/NoteInfoTab.tsx index 2e670d51d..2fb9daa80 100644 --- a/apps/client/src/widgets/ribbon/NoteInfoTab.tsx +++ b/apps/client/src/widgets/ribbon/NoteInfoTab.tsx @@ -36,57 +36,58 @@ export default function NoteInfoTab({ note }: TabContext) { return (
{note && ( - - - - - - - - - - + <> +
+ {t("note_info_widget.note_id")}: + {note.noteId} +
+
+ {t("note_info_widget.created")}: + {formatDateTime(metadata?.dateCreated)} +
+
+ {t("note_info_widget.modified")}: + {formatDateTime(metadata?.dateModified)} +
+
+ {t("note_info_widget.type")}: + + {note.type}{' '} + {note.mime && ({note.mime})} + +
+
+ {t("note_info_widget.note_size")}: + + {!isLoading && !noteSizeResponse && !subtreeSizeResponse && ( +
- - - - - - - -
{t("note_info_widget.note_id")}:{note.noteId}{t("note_info_widget.created")}:{formatDateTime(metadata?.dateCreated)}{t("note_info_widget.modified")}:{formatDateTime(metadata?.dateModified)}
{t("note_info_widget.type")}: - {note.type}{' '} - { note.mime && ({note.mime}) } - {t("note_info_widget.note_size")}: - {!isLoading && !noteSizeResponse && !subtreeSizeResponse && ( -
+ + {formatSize(noteSizeResponse?.noteSize)} + {" "} + {subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 && + {t("note_info_widget.subtree_size", { size: formatSize(subtreeSizeResponse.subTreeSize), count: subtreeSizeResponse.subTreeNoteCount })} + } + {isLoading && } + + +
+ )} ) diff --git a/apps/client/src/widgets/ribbon/NoteMapTab.tsx b/apps/client/src/widgets/ribbon/NoteMapTab.tsx index de7470141..ec7f4a749 100644 --- a/apps/client/src/widgets/ribbon/NoteMapTab.tsx +++ b/apps/client/src/widgets/ribbon/NoteMapTab.tsx @@ -1,24 +1,19 @@ import { TabContext } from "./ribbon-interface"; -import NoteMapWidget from "../note_map"; -import { useElementSize, useLegacyWidget, useWindowSize } from "../react/hooks"; +import { useElementSize, useWindowSize } from "../react/hooks"; import ActionButton from "../react/ActionButton"; import { t } from "../../services/i18n"; import { useEffect, useRef, useState } from "preact/hooks"; +import NoteMap from "../note_map/NoteMap"; const SMALL_SIZE_HEIGHT = "300px"; -export default function NoteMapTab({ noteContext }: TabContext) { +export default function NoteMapTab({ note }: TabContext) { const [ isExpanded, setExpanded ] = useState(false); const [ height, setHeight ] = useState(SMALL_SIZE_HEIGHT); const containerRef = useRef(null); const { windowHeight } = useWindowSize(); const containerSize = useElementSize(containerRef); - const [ noteMapContainer, noteMapWidget ] = useLegacyWidget(() => new NoteMapWidget("ribbon"), { - noteContext, - containerClassName: "note-map-container" - }); - useEffect(() => { if (isExpanded && containerRef.current && containerSize) { const height = windowHeight - containerSize.top; @@ -27,11 +22,10 @@ export default function NoteMapTab({ noteContext }: TabContext) { setHeight(SMALL_SIZE_HEIGHT); } }, [ isExpanded, containerRef, windowHeight, containerSize?.top ]); - useEffect(() => noteMapWidget.setDimensions(), [ containerSize?.width, height ]); return (
- {noteMapContainer} + {note && } {!isExpanded ? ( ); -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/ribbon/OwnedAttributesTab.tsx b/apps/client/src/widgets/ribbon/OwnedAttributesTab.tsx index 1bdb6c1c0..ae3d90c07 100644 --- a/apps/client/src/widgets/ribbon/OwnedAttributesTab.tsx +++ b/apps/client/src/widgets/ribbon/OwnedAttributesTab.tsx @@ -26,4 +26,4 @@ export default function OwnedAttributesTab({ note, hidden, activate, ntxId, ...r )}
) -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/ribbon/Ribbon.tsx b/apps/client/src/widgets/ribbon/Ribbon.tsx index 1c5b8a7bb..78cec9aca 100644 --- a/apps/client/src/widgets/ribbon/Ribbon.tsx +++ b/apps/client/src/widgets/ribbon/Ribbon.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks" import { useNoteContext, useNoteProperty, useStaticTooltipWithKeyboardShortcut, useTriliumEvents } from "../react/hooks"; import "./style.css"; -import { numberObjectsInPlace } from "../../services/utils"; +import { Indexed, numberObjectsInPlace } from "../../services/utils"; import { EventNames } from "../../components/app_context"; import NoteActions from "./NoteActions"; import { KeyboardActionNames } from "@triliumnext/commons"; @@ -11,30 +11,47 @@ import { TabConfiguration, TitleContext } from "./ribbon-interface"; const TAB_CONFIGURATION = numberObjectsInPlace(RIBBON_TAB_DEFINITIONS); +interface ComputedTab extends Indexed { + shouldShow: boolean; +} + export default function Ribbon() { - const { note, ntxId, hoistedNoteId, notePath, noteContext, componentId } = useNoteContext(); + const { note, ntxId, hoistedNoteId, notePath, noteContext, componentId, isReadOnlyTemporarilyDisabled } = useNoteContext(); const noteType = useNoteProperty(note, "type"); - const titleContext: TitleContext = { note }; const [ activeTabIndex, setActiveTabIndex ] = useState(); - const computedTabs = useMemo( - () => TAB_CONFIGURATION.map(tab => { - const shouldShow = typeof tab.show === "boolean" ? tab.show : tab.show?.(titleContext); - return { + const [ computedTabs, setComputedTabs ] = useState(); + const titleContext: TitleContext = useMemo(() => ({ + note, + noteContext + }), [ note, noteContext ]); + + async function refresh() { + const computedTabs: ComputedTab[] = []; + for (const tab of TAB_CONFIGURATION) { + const shouldShow = await shouldShowTab(tab.show, titleContext); + computedTabs.push({ ...tab, - shouldShow - } - }), - [ titleContext, note, noteType ]); + shouldShow: !!shouldShow + }); + } + setComputedTabs(computedTabs); + } + + useEffect(() => { + refresh(); + }, [ note, noteType, isReadOnlyTemporarilyDisabled ]); // Automatically activate the first ribbon tab that needs to be activated whenever a note changes. useEffect(() => { + if (!computedTabs) return; const tabToActivate = computedTabs.find(tab => tab.shouldShow && (typeof tab.activate === "boolean" ? tab.activate : tab.activate?.(titleContext))); setActiveTabIndex(tabToActivate?.index); - }, [ note?.noteId ]); + }, [ computedTabs, note?.noteId ]); // Register keyboard shortcuts. const eventsToListenTo = useMemo(() => TAB_CONFIGURATION.filter(config => config.toggleCommand).map(config => config.toggleCommand) as EventNames[], []); useTriliumEvents(eventsToListenTo, useCallback((e, toggleCommand) => { + if (!computedTabs) return; const correspondingTab = computedTabs.find(tab => tab.toggleCommand === toggleCommand); if (correspondingTab) { if (activeTabIndex !== correspondingTab.index) { @@ -51,7 +68,7 @@ export default function Ribbon() { <>
- {computedTabs.map(({ title, icon, index, toggleCommand, shouldShow }) => ( + {computedTabs && computedTabs.map(({ title, icon, index, toggleCommand, shouldShow }) => ( shouldShow &&
- {computedTabs.map(tab => { + {computedTabs && computedTabs.map(tab => { const isActive = tab.index === activeTabIndex; if (!isActive && !tab.stayInDom) { return; @@ -129,3 +146,9 @@ function RibbonTab({ icon, title, active, onClick, toggleCommand }: { icon: stri ) } +export async function shouldShowTab(showConfig: boolean | ((context: TitleContext) => Promise | boolean | null | undefined), context: TitleContext) { + if (showConfig === null || showConfig === undefined) return true; + if (typeof showConfig === "boolean") return showConfig; + if ("then" in showConfig) return await showConfig(context); + return showConfig(context); +} diff --git a/apps/client/src/widgets/ribbon/RibbonDefinition.ts b/apps/client/src/widgets/ribbon/RibbonDefinition.ts index 8f37053dc..9f19707cb 100644 --- a/apps/client/src/widgets/ribbon/RibbonDefinition.ts +++ b/apps/client/src/widgets/ribbon/RibbonDefinition.ts @@ -21,7 +21,9 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [ { title: t("classic_editor_toolbar.title"), icon: "bx bx-text", - show: ({ note }) => note?.type === "text" && options.get("textNoteEditorType") === "ckeditor-classic", + show: async ({ note, noteContext }) => note?.type === "text" + && options.get("textNoteEditorType") === "ckeditor-classic" + && !(await noteContext?.isReadOnly()), toggleCommand: "toggleRibbonTabClassicEditor", content: FormattingToolbar, activate: true, diff --git a/apps/client/src/widgets/ribbon/collection-properties-config.ts b/apps/client/src/widgets/ribbon/collection-properties-config.ts index f59415b79..76a2193c5 100644 --- a/apps/client/src/widgets/ribbon/collection-properties-config.ts +++ b/apps/client/src/widgets/ribbon/collection-properties-config.ts @@ -81,7 +81,7 @@ export const bookPropertiesConfig: Record = { await attributes.removeAttributeById(noteId, expandedAttr.attributeId); } - triggerCommand("refreshNoteList", { noteId: noteId }); + triggerCommand("refreshNoteList", { noteId }); }, }, { diff --git a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx index 380241df8..bf0aa9428 100644 --- a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx +++ b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx @@ -238,11 +238,6 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI } }); - // Focus on show. - useEffect(() => { - setTimeout(() => editorRef.current?.focus(), 0); - }, []); - // Interaction with CKEditor. useLegacyImperativeHandlers(useMemo(() => ({ loadReferenceLinkTitle: async ($el: JQuery, href: string) => { @@ -363,6 +358,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI }} onKeyDown={() => attributeDetailWidget.hide()} onBlur={() => save()} + onInitialized={() => editorRef.current?.focus()} disableNewlines disableSpellcheck /> diff --git a/apps/client/src/widgets/ribbon/components/StandaloneRibbonAdapter.tsx b/apps/client/src/widgets/ribbon/components/StandaloneRibbonAdapter.tsx index 54a0f1af0..1fbdd5d63 100644 --- a/apps/client/src/widgets/ribbon/components/StandaloneRibbonAdapter.tsx +++ b/apps/client/src/widgets/ribbon/components/StandaloneRibbonAdapter.tsx @@ -1,8 +1,9 @@ import { ComponentChildren } from "preact"; import { useNoteContext } from "../../react/hooks"; -import { TabContext, TitleContext } from "../ribbon-interface"; +import { TabContext } from "../ribbon-interface"; import { useEffect, useMemo, useState } from "preact/hooks"; import { RIBBON_TAB_DEFINITIONS } from "../RibbonDefinition"; +import { shouldShowTab } from "../Ribbon"; interface StandaloneRibbonAdapterProps { component: (props: TabContext) => ComponentChildren; @@ -16,10 +17,11 @@ export default function StandaloneRibbonAdapter({ component }: StandaloneRibbonA const Component = component; const { note, ntxId, hoistedNoteId, notePath, noteContext, componentId } = useNoteContext(); const definition = useMemo(() => RIBBON_TAB_DEFINITIONS.find(def => def.content === component), [ component ]); - const [ shown, setShown ] = useState(unwrapShown(definition?.show, { note })); + const [ shown, setShown ] = useState(false); useEffect(() => { - setShown(unwrapShown(definition?.show, { note })); + if (!definition) return; + shouldShowTab(definition.show, { note, noteContext }).then(setShown); }, [ note ]); return ( @@ -35,9 +37,3 @@ export default function StandaloneRibbonAdapter({ component }: StandaloneRibbonA /> ); } - -function unwrapShown(value: boolean | ((context: TitleContext) => boolean | null | undefined) | undefined, context: TitleContext) { - if (!value) return true; - if (typeof value === "boolean") return value; - return !!value(context); -} diff --git a/apps/client/src/widgets/ribbon/ribbon-interface.ts b/apps/client/src/widgets/ribbon/ribbon-interface.ts index 2fbc40612..7ab982dd2 100644 --- a/apps/client/src/widgets/ribbon/ribbon-interface.ts +++ b/apps/client/src/widgets/ribbon/ribbon-interface.ts @@ -16,13 +16,14 @@ export interface TabContext { export interface TitleContext { note: FNote | null | undefined; + noteContext: NoteContext | undefined; } export interface TabConfiguration { title: string | ((context: TitleContext) => string); icon: string; content: (context: TabContext) => VNode | false; - show: boolean | ((context: TitleContext) => boolean | null | undefined); + show: boolean | ((context: TitleContext) => Promise | boolean | null | undefined); toggleCommand?: KeyboardActionNames; activate?: boolean | ((context: TitleContext) => boolean); /** diff --git a/apps/client/src/widgets/ribbon/style.css b/apps/client/src/widgets/ribbon/style.css index 16437d618..f47c6d662 100644 --- a/apps/client/src/widgets/ribbon/style.css +++ b/apps/client/src/widgets/ribbon/style.css @@ -160,17 +160,20 @@ /* #region Note info */ .note-info-widget { padding: 12px; + display: flex; + flex-wrap: wrap; + align-items: baseline; } -.note-info-widget-table { - max-width: 100%; - display: block; - overflow-x: auto; - white-space: nowrap; +.note-info-item { + display: flex; + align-items: baseline; + padding-inline-end: 15px; + padding-block: 5px; } -.note-info-widget-table td, .note-info-widget-table th { - padding: 5px; +.note-info-item > span:first-child { + padding-inline-end: 5px; } .note-info-mime { @@ -186,6 +189,10 @@ font-size: 0.8em; vertical-align: middle !important; } + +.note-info-widget .calculate-button { + padding: 0 10px; +} /* #endregion */ /* #region Similar Notes */ diff --git a/apps/client/src/widgets/shared_info.css b/apps/client/src/widgets/shared_info.css new file mode 100644 index 000000000..bc44b4984 --- /dev/null +++ b/apps/client/src/widgets/shared_info.css @@ -0,0 +1,7 @@ +.shared-info-widget { + display: flex; +} + +.shared-info-widget button { + margin-inline-start: 8px; +} \ No newline at end of file diff --git a/apps/client/src/widgets/shared_info.tsx b/apps/client/src/widgets/shared_info.tsx index 15bf53b1a..bd0b72bc2 100644 --- a/apps/client/src/widgets/shared_info.tsx +++ b/apps/client/src/widgets/shared_info.tsx @@ -1,11 +1,12 @@ -import { useEffect, useState } from "preact/hooks"; +import "./shared_info.css"; import { t } from "../services/i18n"; -import Alert from "./react/Alert"; +import { useEffect, useState } from "preact/hooks"; import { useNoteContext, useTriliumEvent, useTriliumOption } from "./react/hooks"; -import FNote from "../entities/fnote"; import attributes from "../services/attributes"; -import RawHtml from "./react/RawHtml"; +import FNote from "../entities/fnote"; import HelpButton from "./react/HelpButton"; +import InfoBar from "./react/InfoBar"; +import RawHtml from "./react/RawHtml"; export default function SharedInfo() { const { note } = useNoteContext(); @@ -23,7 +24,7 @@ export default function SharedInfo() { const shareId = getShareId(note); if (syncServerHost) { - link = `${syncServerHost}/share/${shareId}`; + link = new URL(`/share/${shareId}`, syncServerHost).href; } else { let host = location.host; if (host.endsWith("/")) { @@ -35,7 +36,7 @@ export default function SharedInfo() { link = `${location.protocol}//${host}${location.pathname}share/${shareId}`; } - setLink(`${link}`); + setLink(`${link}`); } useEffect(refresh, [ note ]); @@ -48,20 +49,14 @@ export default function SharedInfo() { }); return ( - + {link && ( )} - + ) } diff --git a/apps/client/src/widgets/tab_row.ts b/apps/client/src/widgets/tab_row.ts index c2405aaed..b78952faf 100644 --- a/apps/client/src/widgets/tab_row.ts +++ b/apps/client/src/widgets/tab_row.ts @@ -820,12 +820,15 @@ export default class TabRowWidget extends BasicWidget { } contextsReopenedEvent({ mainNtxId, tabPosition }: EventData<"contextsReopened">) { - if (!mainNtxId || !tabPosition) { + if (!mainNtxId || tabPosition < 0) { // no tab reopened return; } const tabEl = this.getTabById(mainNtxId)[0]; - tabEl.parentNode?.insertBefore(tabEl, this.tabEls[tabPosition]); + + if ( tabEl && tabEl.parentNode ){ + tabEl.parentNode.insertBefore(tabEl, this.tabEls[tabPosition]); + } } updateTabById(ntxId: string | null) { diff --git a/apps/client/src/widgets/type_widgets/AiChat.tsx b/apps/client/src/widgets/type_widgets/AiChat.tsx new file mode 100644 index 000000000..677d593ab --- /dev/null +++ b/apps/client/src/widgets/type_widgets/AiChat.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef } from "preact/hooks"; +import { useEditorSpacedUpdate, useLegacyWidget } from "../react/hooks"; +import { type TypeWidgetProps } from "./type_widget"; +import LlmChatPanel from "../llm_chat"; + +export default function AiChat({ note, noteContext }: TypeWidgetProps) { + const dataRef = useRef(); + const spacedUpdate = useEditorSpacedUpdate({ + note, + noteContext, + getData: async () => ({ + content: JSON.stringify(dataRef.current) + }), + onContentChange: (newContent) => { + try { + dataRef.current = JSON.parse(newContent); + llmChatPanel.refresh(); + } catch (e) { + dataRef.current = {}; + } + } + }); + const [ ChatWidget, llmChatPanel ] = useLegacyWidget(() => { + const llmChatPanel = new LlmChatPanel(); + llmChatPanel.setDataCallbacks( + async (data) => { + dataRef.current = data; + spacedUpdate.scheduleUpdate(); + }, + async () => dataRef.current + ); + return llmChatPanel; + }, { + noteContext, + containerStyle: { + height: "100%" + } + }); + + useEffect(() => { + llmChatPanel.setNoteId(note.noteId); + llmChatPanel.setCurrentNoteId(note.noteId); + }, [ note ]); + + return ChatWidget; +} diff --git a/apps/client/src/widgets/type_widgets/Attachment.css b/apps/client/src/widgets/type_widgets/Attachment.css new file mode 100644 index 000000000..8a6221fc7 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Attachment.css @@ -0,0 +1,137 @@ +/* #region Attachment list */ +.attachment-list { + padding-inline-start: 15px; + padding-inline-end: 15px; +} + +.attachment-list .links-wrapper { + font-size: larger; + margin-bottom: 15px; + display: flex; + justify-content: space-between; + align-items: baseline; +} +/* #endregion */ + +/* #region Attachment info */ +.attachment-detail-widget { + height: 100%; +} + +.attachment-detail-wrapper { + margin-bottom: 20px; + display: flex; + flex-direction: column; +} + +.attachment-title-line { + display: flex; + align-items: baseline; + gap: 1em; +} + +.attachment-details { + margin-inline-start: 10px; +} + +.attachment-content-wrapper { + flex-grow: 1; +} + +.attachment-content-wrapper .rendered-content { + height: 100%; +} + +.attachment-content-wrapper pre { + padding: 10px; + margin-top: 10px; + margin-bottom: 10px; +} + +.attachment-detail-wrapper.list-view .attachment-content-wrapper { + max-height: 300px; +} + +.attachment-detail-wrapper.full-detail { + height: 100%; +} + +.attachment-detail-wrapper.full-detail .attachment-content-wrapper { + height: 100%; +} + +.attachment-detail-wrapper.list-view .attachment-content-wrapper pre { + max-height: 400px; +} + +.attachment-content-wrapper img { + margin: 10px; +} + +.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video { + max-height: 300px; + max-width: 90%; + object-fit: contain; +} + +.attachment-detail-wrapper.full-detail .attachment-content-wrapper img { + max-width: 90%; + object-fit: contain; +} + +.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img { + filter: contrast(10%); +} + +.attachment-detail-wrapper .attachment-deletion-warning { + margin-top: 15px; +} +/* #endregion */ + +/* #region Attachment detail */ +.attachment-detail { + padding-left: 15px; + padding-right: 15px; + height: 100%; + display: flex; + flex-direction: column; +} + +.attachment-detail .links-wrapper { + font-size: larger; + padding: 0 0 16px 0; +} + +.attachment-detail .attachment-wrapper { + flex-grow: 1; +} +/* #endregion */ + +/* #region Attachment actions */ +.attachment-actions { + width: 35px; + height: 35px; +} + +.attachment-actions .select-button { + position: relative; + top: 3px; +} + +.attachment-actions .dropdown-menu { + width: 20em; +} + +.attachment-actions .dropdown-item .bx { + position: relative; + top: 3px; + font-size: 120%; + margin-inline-end: 5px; +} + +.attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover { + color: var(--muted-text-color) !important; + background-color: transparent !important; + pointer-events: none; /* makes it unclickable */ +} +/* #endregion */ \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/Attachment.tsx b/apps/client/src/widgets/type_widgets/Attachment.tsx new file mode 100644 index 000000000..3fdc60e93 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Attachment.tsx @@ -0,0 +1,310 @@ +import { t } from "i18next"; +import { TypeWidgetProps } from "./type_widget"; +import "./Attachment.css"; +import NoteLink from "../react/NoteLink"; +import Button from "../react/Button"; +import { useContext, useEffect, useRef, useState } from "preact/hooks"; +import { ParentComponent, refToJQuerySelector } from "../react/react_utils"; +import HelpButton from "../react/HelpButton"; +import FAttachment from "../../entities/fattachment"; +import Alert from "../react/Alert"; +import utils from "../../services/utils"; +import content_renderer from "../../services/content_renderer"; +import { useTriliumEvent } from "../react/hooks"; +import froca from "../../services/froca"; +import Dropdown from "../react/Dropdown"; +import Icon from "../react/Icon"; +import { FormDropdownDivider, FormListItem } from "../react/FormList"; +import open from "../../services/open"; +import toast from "../../services/toast"; +import link from "../../services/link"; +import image from "../../services/image"; +import FormFileUpload from "../react/FormFileUpload"; +import server from "../../services/server"; +import dialog from "../../services/dialog"; +import ws from "../../services/ws"; +import appContext from "../../components/app_context"; +import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons"; +import options from "../../services/options"; + +/** + * Displays the full list of attachments of a note and allows the user to interact with them. + */ +export function AttachmentList({ note }: TypeWidgetProps) { + const [ attachments, setAttachments ] = useState([]); + + function refresh() { + note.getAttachments().then(attachments => setAttachments(Array.from(attachments))); + } + + useEffect(refresh, [ note ]); + + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (loadResults.getAttachmentRows().some((att) => att.attachmentId && att.ownerId === note.noteId)) { + refresh(); + } + }); + + return ( + <> + + +
+ {attachments.length ? ( + attachments.map(attachment => ) + ) : ( + + {t("attachment_list.no_attachments")} + + )} +
+ + ) +} + +function AttachmentListHeader({ noteId }: { noteId: string }) { + const parentComponent = useContext(ParentComponent); + + return ( +
+
+ {t("attachment_list.owning_note")}{" "} +
+
+
+
+ ) +} + +/** + * Displays information about a single attachment. + */ +export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) { + const [ attachment, setAttachment ] = useState(undefined); + + useEffect(() => { + if (!viewScope?.attachmentId) return; + froca.getAttachment(viewScope.attachmentId).then(setAttachment); + }, [ viewScope ]); + + return ( + <> +
+ {t("attachment_detail.owning_note")}{" "} + + {t("attachment_detail.you_can_also_open")}{" "} + + +
+ +
+ {attachment !== null ? ( + attachment && + ) : ( + {t("attachment_detail.attachment_deleted")} + )} +
+ + ) +} + +function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) { + const contentWrapper = useRef(null); + + function refresh() { + content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail }) + .then(({ $renderedContent }) => { + contentWrapper.current?.replaceChildren(...$renderedContent); + }); + } + + useEffect(refresh, [ attachment ]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (loadResults.getAttachmentRows().find(attachment => attachment.attachmentId)) { + refresh(); + } + }); + + async function copyAttachmentLinkToClipboard() { + if (attachment.role === "image") { + const $contentWrapper = refToJQuerySelector(contentWrapper); + image.copyImageReferenceToClipboard($contentWrapper); + } else if (attachment.role === "file") { + const $link = await link.createLink(attachment.ownerId, { + referenceLink: true, + viewScope: { + viewMode: "attachments", + attachmentId: attachment.attachmentId + } + }); + + utils.copyHtmlToClipboard($link[0].outerHTML); + + toast.showMessage(t("attachment_detail_2.link_copied")); + } else { + throw new Error(t("attachment_detail_2.unrecognized_role", { role: attachment.role })); + } + } + + return ( +
+
+
+ +

+ {!isFullDetail ? ( + + ) : (attachment.title)} +

+
+ {t("attachment_detail_2.role_and_size", { role: attachment.role, size: utils.formatSize(attachment.contentLength) })} +
+
+
+ + {attachment.utcDateScheduledForErasureSince && } +
+
+
+ ) +} + +function DeletionAlert({ utcDateScheduledForErasureSince }: { utcDateScheduledForErasureSince: string }) { + 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(); + + return ( + + { willBeDeletedInMs >= 60000 + ? t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) }) + : t("attachment_detail_2.will_be_deleted_soon")} + {t("attachment_detail_2.deletion_reason")} + + ) +} + +function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void }) { + const isElectron = utils.isElectron(); + const fileUploadRef = useRef(null); + + return ( +
+ } + buttonClassName="icon-action-always-border" + iconAction + > + open.openAttachmentExternally(attachment.attachmentId, attachment.mime)} + >{t("attachments_actions.open_externally")} + open.openAttachmentCustom(attachment.attachmentId, attachment.mime)} + disabled={!isElectron} + disabledTooltip={!isElectron ? t("attachments_actions.open_custom_client_only") : t("attachments_actions.open_externally_detail_page")} + >{t("attachments_actions.open_custom")} + open.downloadAttachment(attachment.attachmentId)} + >{t("attachments_actions.download")} + {t("attachments_actions.copy_link_to_clipboard")} + + + fileUploadRef.current?.click()} + >{t("attachments_actions.upload_new_revision")} + { + const attachmentTitle = await dialog.prompt({ + title: t("attachments_actions.rename_attachment"), + message: t("attachments_actions.enter_new_name"), + defaultValue: attachment.title + }); + + if (!attachmentTitle?.trim()) return; + await server.put(`attachments/${attachment.attachmentId}/rename`, { title: attachmentTitle }); + }} + >{t("attachments_actions.rename_attachment")} + { + if (!(await dialog.confirm(t("attachments_actions.delete_confirm", { title: attachment.title })))) { + return; + } + + await server.remove(`attachments/${attachment.attachmentId}`); + toast.showMessage(t("attachments_actions.delete_success", { title: attachment.title })); + }} + >{t("attachments_actions.delete_attachment")} + + + { + if (!(await dialog.confirm(t("attachments_actions.convert_confirm", { title: attachment.title })))) { + return; + } + + const { note: newNote } = await server.post(`attachments/${attachment.attachmentId}/convert-to-note`); + toast.showMessage(t("attachments_actions.convert_success", { title: attachment.title })); + await ws.waitForMaxKnownEntityChangeId(); + await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId); + }} + >{t("attachments_actions.convert_attachment_into_note")} + + +
+ ) +} diff --git a/apps/client/src/widgets/type_widgets/Book.css b/apps/client/src/widgets/type_widgets/Book.css new file mode 100644 index 000000000..63398390e --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Book.css @@ -0,0 +1,4 @@ +.note-detail-book-empty-help { + margin: 50px; + padding: 20px; +} \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/Book.tsx b/apps/client/src/widgets/type_widgets/Book.tsx new file mode 100644 index 000000000..8dd1030c5 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Book.tsx @@ -0,0 +1,36 @@ +import { t } from "../../services/i18n"; +import Alert from "../react/Alert"; +import { useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks"; +import RawHtml from "../react/RawHtml"; +import { TypeWidgetProps } from "./type_widget"; +import "./Book.css"; +import { useEffect, useState } from "preact/hooks"; +import { ViewTypeOptions } from "../collections/interface"; + +const VIEW_TYPES: ViewTypeOptions[] = [ "list", "grid", "presentation" ]; + +export default function Book({ note }: TypeWidgetProps) { + const [ viewType ] = useNoteLabelWithDefault(note, "viewType", "grid"); + const [ shouldDisplayNoChildrenWarning, setShouldDisplayNoChildrenWarning ] = useState(false); + + function refresh() { + setShouldDisplayNoChildrenWarning(!note.hasChildren() && VIEW_TYPES.includes(viewType as ViewTypeOptions)); + } + + useEffect(refresh, [ note, viewType ]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (loadResults.getBranchRows().some(branchRow => branchRow.parentNoteId === note.noteId)) { + refresh(); + } + }); + + return ( + <> + {shouldDisplayNoChildrenWarning && ( + + + + )} + + ) +} diff --git a/apps/client/src/widgets/type_widgets/ContentWidget.css b/apps/client/src/widgets/type_widgets/ContentWidget.css new file mode 100644 index 000000000..fb1fb4574 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/ContentWidget.css @@ -0,0 +1,16 @@ +.type-contentWidget .note-detail { + height: 100%; +} + +.note-detail-content-widget { + height: 100%; +} + +.note-detail-content-widget-content { + padding: 15px; + height: 100%; +} + +.note-detail.full-height .note-detail-content-widget-content { + padding: 0; +} \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/ContentWidget.tsx b/apps/client/src/widgets/type_widgets/ContentWidget.tsx new file mode 100644 index 000000000..d7e2aad39 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/ContentWidget.tsx @@ -0,0 +1,58 @@ +import { TypeWidgetProps } from "./type_widget"; +import { JSX } from "preact/jsx-runtime"; +import AppearanceSettings from "./options/appearance"; +import ShortcutSettings from "./options/shortcuts"; +import TextNoteSettings from "./options/text_notes"; +import CodeNoteSettings from "./options/code_notes"; +import ImageSettings from "./options/images"; +import SpellcheckSettings from "./options/spellcheck"; +import PasswordSettings from "./options/password"; +import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication"; +import EtapiSettings from "./options/etapi"; +import BackupSettings from "./options/backup"; +import SyncOptions from "./options/sync"; +import AiSettings from "./options/ai_settings"; +import OtherSettings from "./options/other"; +import InternationalizationOptions from "./options/i18n"; +import AdvancedSettings from "./options/advanced"; +import "./ContentWidget.css"; +import { t } from "../../services/i18n"; +import BackendLog from "./code/BackendLog"; + +export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced"; + +const CONTENT_WIDGETS: Record JSX.Element> = { + _optionsAppearance: AppearanceSettings, + _optionsShortcuts: ShortcutSettings, + _optionsTextNotes: TextNoteSettings, + _optionsCodeNotes: CodeNoteSettings, + _optionsImages: ImageSettings, + _optionsSpellcheck: SpellcheckSettings, + _optionsPassword: PasswordSettings, + _optionsMFA: MultiFactorAuthenticationSettings, + _optionsEtapi: EtapiSettings, + _optionsBackup: BackupSettings, + _optionsSync: SyncOptions, + _optionsAi: AiSettings, + _optionsOther: OtherSettings, + _optionsLocalization: InternationalizationOptions, + _optionsAdvanced: AdvancedSettings, + _backendLog: BackendLog +} + +/** + * Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log. + * + * @param param0 + * @returns + */ +export default function ContentWidget({ note, ...restProps }: TypeWidgetProps) { + const Content = CONTENT_WIDGETS[note.noteId]; + return ( +
+ {Content + ? + : (t("content_widget.unknown_widget", { id: note.noteId }))} +
+ ) +} diff --git a/apps/client/src/widgets/type_widgets/Doc.css b/apps/client/src/widgets/type_widgets/Doc.css new file mode 100644 index 000000000..0081da3c7 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Doc.css @@ -0,0 +1,50 @@ +.note-detail-doc-content { + padding: 15px; +} + +.note-detail-doc-content pre { + border: 0; + box-shadow: var(--code-block-box-shadow); + padding: 15px; + border-radius: 5px; +} + +.note-detail-doc-content code { + font-variant: none; +} + +.note-detail-doc-content pre:not(.hljs) { + background-color: var(--accented-background-color); + border: 1px solid var(--main-border-color); +} + +.note-detail-doc-content.contextual-help { + padding-bottom: 0; +} + +.note-detail-doc-content.contextual-help h2, +.note-detail-doc-content.contextual-help h3, +.note-detail-doc-content.contextual-help h4, +.note-detail-doc-content.contextual-help h5, +.note-detail-doc-content.contextual-help h6 { + font-size: 1.25rem; + background-color: var(--main-background-color); + position: sticky; + top: 0; + z-index: 50; + margin: 0; + padding-bottom: 0.25em; +} + +img { + max-width: 100%; + height: auto; +} + +td img { + max-width: 40vw; +} + +figure.table { + overflow: auto !important; +} \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/Doc.tsx b/apps/client/src/widgets/type_widgets/Doc.tsx new file mode 100644 index 000000000..5c7a31890 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Doc.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { RawHtmlBlock } from "../react/RawHtml"; +import renderDoc from "../../services/doc_renderer"; +import "./Doc.css"; +import { TypeWidgetProps } from "./type_widget"; +import { useTriliumEvent } from "../react/hooks"; +import { refToJQuerySelector } from "../react/react_utils"; + +export default function Doc({ note, viewScope, ntxId }: TypeWidgetProps) { + const initialized = useRef | null>(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!note) return; + + initialized.current = renderDoc(note).then($content => { + containerRef.current?.replaceChildren(...$content); + }); + }, [ note ]); + + useTriliumEvent("executeWithContentElement", async ({ resolve, ntxId: eventNtxId}) => { + if (eventNtxId !== ntxId) return; + await initialized.current; + resolve(refToJQuerySelector(containerRef)); + }); + + return ( +
+ ); +} diff --git a/apps/client/src/widgets/type_widgets/Empty.css b/apps/client/src/widgets/type_widgets/Empty.css new file mode 100644 index 000000000..c207f6524 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Empty.css @@ -0,0 +1,38 @@ +.workspace-notes { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-evenly; +} + +.workspace-notes .workspace-note { + width: 130px; + text-align: center; + margin: 10px; + border: 1px transparent solid; +} + +.workspace-notes .workspace-note:hover { + cursor: pointer; + border: 1px solid var(--main-border-color); +} + +.note-detail-empty-results .aa-dropdown-menu { + max-height: 50vh; + overflow: scroll; + border: var(--bs-border-width) solid var(--bs-border-color); + border-top: 0; +} + +.empty-tab-search .note-autocomplete-input { + border-bottom-left-radius: 0; +} + +.empty-tab-search .input-clearer-button { + border-bottom-right-radius: 0; +} + +.workspace-icon { + text-align: center; + font-size: 500%; +} \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/Empty.tsx b/apps/client/src/widgets/type_widgets/Empty.tsx new file mode 100644 index 000000000..eb0f0c5ee --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Empty.tsx @@ -0,0 +1,85 @@ +import { useContext, useEffect, useRef, useState } from "preact/hooks"; +import { t } from "../../services/i18n"; +import FormGroup from "../react/FormGroup"; +import NoteAutocomplete from "../react/NoteAutocomplete"; +import "./Empty.css"; +import { ParentComponent, refToJQuerySelector } from "../react/react_utils"; +import note_autocomplete from "../../services/note_autocomplete"; +import appContext from "../../components/app_context"; +import FNote from "../../entities/fnote"; +import search from "../../services/search"; +import { TypeWidgetProps } from "./type_widget"; + +export default function Empty({ }: TypeWidgetProps) { + return ( + <> + + + + ) +} + +function NoteSearch() { + const resultsContainerRef = useRef(null); + const autocompleteRef = useRef(null); + + // Show recent notes. + useEffect(() => { + const $autoComplete = refToJQuerySelector(autocompleteRef); + note_autocomplete.showRecentNotes($autoComplete); + }, []); + + return ( + <> + + { + if (!suggestion?.notePath) { + return false; + } + + const activeContext = appContext.tabManager.getActiveContext(); + if (activeContext) { + activeContext.setNote(suggestion.notePath); + } + }} + /> + +
+ + ); +} + +function WorkspaceSwitcher() { + const [ workspaceNotes, setWorkspaceNotes ] = useState(); + const parentComponent = useContext(ParentComponent); + + function refresh() { + search.searchForNotes("#workspace #!template").then(setWorkspaceNotes); + } + + useEffect(refresh, []); + + return ( +
+ {workspaceNotes?.map(workspaceNote => ( +
parentComponent?.triggerCommand("hoistNote", { noteId: workspaceNote.noteId })} + > +
+
{workspaceNote.title}
+
+ ))} +
+ ); +} diff --git a/apps/client/src/widgets/type_widgets/File.css b/apps/client/src/widgets/type_widgets/File.css new file mode 100644 index 000000000..d7f7035c0 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/File.css @@ -0,0 +1,41 @@ +.type-file .note-detail { + height: 100%; +} + +.note-detail-file { + padding: 10px; + height: 100%; +} + +.note-split.full-content-width .note-detail-file { + padding: 0; +} + +.note-detail.full-height .note-detail-file[data-preview-type="pdf"], +.note-detail.full-height .note-detail-file[data-preview-type="video"] { + overflow: hidden; +} + +.file-preview-content { + background-color: var(--accented-background-color); + padding: 15px; + height: 100%; + overflow: auto; + margin: 10px; +} + +.note-detail-file > .pdf-preview, +.note-detail-file > .video-preview { + width: 100%; + height: 100%; + flex-grow: 100; +} + +.note-detail-file > .audio-preview { + position: absolute; + top: 50%; + left: 15px; + right: 15px; + width: calc(100% - 30px); + transform: translateY(-50%); +} \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/File.tsx b/apps/client/src/widgets/type_widgets/File.tsx new file mode 100644 index 000000000..266526754 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/File.tsx @@ -0,0 +1,78 @@ +import { useNoteBlob } from "../react/hooks"; +import "./File.css"; +import { TypeWidgetProps } from "./type_widget"; +import FNote from "../../entities/fnote"; +import { getUrlForDownload } from "../../services/open"; +import Alert from "../react/Alert"; +import { t } from "../../services/i18n"; + +const TEXT_MAX_NUM_CHARS = 5000; + +export default function File({ note }: TypeWidgetProps) { + const blob = useNoteBlob(note); + + if (blob?.content) { + return + } else if (note.mime === "application/pdf") { + return + } else if (note.mime.startsWith("video/")) { + return + } else if (note.mime.startsWith("audio/")) { + return + } else { + return + } +} + +function TextPreview({ content }: { content: string }) { + const trimmedContent = content.substring(0, TEXT_MAX_NUM_CHARS); + const isTooLarge = trimmedContent.length !== content.length; + + return ( + <> + {isTooLarge && ( + + {t("file.too_big", { maxNumChars: TEXT_MAX_NUM_CHARS })} + + )} +
{trimmedContent}
+ + ) +} + +function PdfPreview({ note }: { note: FNote }) { + return ( + - - - - -
`; - -export default class FileTypeWidget extends TypeWidget { - - private $previewContent!: JQuery; - private $previewNotAvailable!: JQuery; - private $previewTooBig!: JQuery; - private $pdfPreview!: JQuery; - private $videoPreview!: JQuery; - private $audioPreview!: JQuery; - - static getType() { - return "file"; - } - - doRender() { - this.$widget = $(TPL); - this.$previewContent = this.$widget.find(".file-preview-content"); - this.$previewNotAvailable = this.$widget.find(".file-preview-not-available"); - this.$previewTooBig = this.$widget.find(".file-preview-too-big"); - this.$pdfPreview = this.$widget.find(".pdf-preview"); - this.$videoPreview = this.$widget.find(".video-preview"); - this.$audioPreview = this.$widget.find(".audio-preview"); - - super.doRender(); - } - - async doRefresh(note: FNote) { - this.$widget.show(); - - const blob = await this.note?.getBlob(); - - this.$previewContent.empty().hide(); - this.$pdfPreview.attr("src", "").empty().hide(); - this.$previewNotAvailable.hide(); - this.$previewTooBig.addClass("hidden-ext"); - this.$videoPreview.hide(); - this.$audioPreview.hide(); - - let previewType: string; - - if (blob?.content) { - this.$previewContent.show().scrollTop(0); - const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS); - if (trimmedContent.length !== blob.content.length) { - this.$previewTooBig.removeClass("hidden-ext"); - } - this.$previewContent.text(trimmedContent); - previewType = "text"; - } else if (note.mime === "application/pdf") { - this.$pdfPreview.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`)); - previewType = "pdf"; - } else if (note.mime.startsWith("video/")) { - this.$videoPreview - .show() - .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) - .attr("type", this.note?.mime ?? "") - .css("width", this.$widget.width() ?? 0); - previewType = "video"; - } else if (note.mime.startsWith("audio/")) { - this.$audioPreview - .show() - .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) - .attr("type", this.note?.mime ?? "") - .css("width", this.$widget.width() ?? 0); - previewType = "audio"; - } else { - this.$previewNotAvailable.show(); - previewType = "not-available"; - } - - this.$widget.attr("data-preview-type", previewType ?? ""); - } - - async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - if (loadResults.isNoteReloaded(this.noteId)) { - this.refresh(); - } - } -} diff --git a/apps/client/src/widgets/type_widgets/helpers/SplitEditor.css b/apps/client/src/widgets/type_widgets/helpers/SplitEditor.css new file mode 100644 index 000000000..7ea0a003c --- /dev/null +++ b/apps/client/src/widgets/type_widgets/helpers/SplitEditor.css @@ -0,0 +1,96 @@ +.note-detail-split { + display: flex; + height: 100%; +} + +.note-detail-split-editor-col { + display: flex; + flex-direction: column; +} + +.note-detail-split-preview-col { + position: relative; +} + +.note-detail-split .note-detail-split-editor { + width: 100%; + flex-grow: 1; +} + +.note-detail-split .note-detail-split-editor .note-detail-code { + contain: size !important; +} + +.note-detail-split .note-detail-code-editor .cm-editor { + margin: 0 !important; +} + +.note-detail-split .note-detail-error-container { + font-family: var(--monospace-font-family); + margin: 5px; + white-space: pre-wrap; + font-size: 0.85em; +} + +.note-detail-split .note-detail-split-preview { + transition: opacity 250ms ease-in-out; + height: 100%; +} + +.note-detail-split .note-detail-split-preview.on-error { + opacity: 0.5; +} + +/* Horizontal layout */ + +.note-detail-split.split-horizontal > .note-detail-split-preview-col { + border-inline-start: 1px solid var(--main-border-color); +} + +.note-detail-split.split-horizontal > .note-detail-split-editor-col, +.note-detail-split.split-horizontal > .note-detail-split-preview-col { + height: 100%; + width: 50%; +} + +.note-detail-split.split-horizontal .note-detail-split-preview { + height: 100%; +} + +/* Vertical layout */ + +.note-detail-split.split-vertical { + flex-direction: column; +} + +.note-detail-split.split-vertical > .note-detail-split-editor-col, +.note-detail-split.split-vertical > .note-detail-split-preview-col { + width: 100%; + height: 50%; +} + +.note-detail-split.split-vertical > .note-detail-split-editor-col { + border-top: 1px solid var(--main-border-color); +} + +.note-detail-split.split-vertical .note-detail-split-preview-col { + order: -1; +} + +/* Read-only view */ + +.note-detail-split.split-read-only .note-detail-split-preview-col { + width: 100%; +} + +/* #region SVG */ +.note-detail-split.svg-editor .render-container { + height: 100%; +} + +.note-detail-split.svg-editor .render-container svg { + width: 100%; + height: 100%; + max-width: 100%; +} +/* #endregion */ \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/helpers/SplitEditor.tsx b/apps/client/src/widgets/type_widgets/helpers/SplitEditor.tsx new file mode 100644 index 000000000..e6d85cf7c --- /dev/null +++ b/apps/client/src/widgets/type_widgets/helpers/SplitEditor.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef } from "preact/hooks"; +import utils, { isMobile } from "../../../services/utils"; +import Admonition from "../../react/Admonition"; +import { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks"; +import "./SplitEditor.css"; +import Split from "@triliumnext/split.js"; +import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer"; +import { EditableCode, EditableCodeProps } from "../code/Code"; +import { ComponentChildren } from "preact"; +import ActionButton, { ActionButtonProps } from "../../react/ActionButton"; + +export interface SplitEditorProps extends EditableCodeProps { + className?: string; + error?: string | null; + splitOptions?: Split.Options; + previewContent: ComponentChildren; + previewButtons?: ComponentChildren; +} + +/** + * Abstract `TypeWidget` which contains a preview and editor pane, each displayed on half of the available screen. + * + * Features: + * + * - The two panes are resizeable via a split, on desktop. The split can be optionally customized via {@link buildSplitExtraOptions}. + * - Can display errors to the user via {@link setError}. + * - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button. + */ +export default function SplitEditor({ note, error, splitOptions, previewContent, previewButtons, className, ...editorProps }: SplitEditorProps) { + const splitEditorOrientation = useSplitOrientation(); + const [ readOnly ] = useNoteLabelBoolean(note, "readOnly"); + const containerRef = useRef(null); + + const editor = (!readOnly && +
+
+ +
+ {error && + {error} + } +
+ ); + + const preview = ( +
+
+ {previewContent} +
+
+ {previewButtons} +
+
+ ); + + useEffect(() => { + if (!utils.isDesktop() || !containerRef.current || readOnly) return; + const elements = Array.from(containerRef.current?.children) as HTMLElement[]; + const splitInstance = Split(elements, { + rtl: glob.isRtl, + sizes: [ 50, 50 ], + direction: splitEditorOrientation, + gutterSize: DEFAULT_GUTTER_SIZE, + ...splitOptions + }); + + return () => splitInstance.destroy(); + }, [ readOnly, splitEditorOrientation ]); + + return ( +
+ {splitEditorOrientation === "horizontal" + ? <>{editor}{preview} + : <>{preview}{editor}} +
+ ) +} + +export function PreviewButton(props: Omit) { + return +} + +function useSplitOrientation() { + const [ splitEditorOrientation ] = useTriliumOption("splitEditorOrientation"); + if (isMobile()) return "vertical"; + if (!splitEditorOrientation) return "horizontal"; + return splitEditorOrientation as "horizontal" | "vertical"; +} diff --git a/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx b/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx new file mode 100644 index 000000000..8a9e64a24 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/helpers/SvgSplitEditor.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { t } from "../../../services/i18n"; +import SplitEditor, { PreviewButton, SplitEditorProps } from "./SplitEditor"; +import { RawHtmlBlock } from "../../react/RawHtml"; +import server from "../../../services/server"; +import svgPanZoom from "svg-pan-zoom"; +import { RefObject } from "preact"; +import { useElementSize, useTriliumEvent } from "../../react/hooks"; +import utils from "../../../services/utils"; +import toast from "../../../services/toast"; + +interface SvgSplitEditorProps extends Omit { + /** + * The name of the note attachment (without .svg extension) that will be used for storing the preview. + */ + attachmentName: string; + /** + * Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched. + * + * The method must return a valid SVG string that will be automatically displayed in the preview. + * + * @param content the content of the note, in plain text. + */ + renderSvg(content: string): string | Promise; +} + +/** + * A specialization of `SplitTypeWidget` meant for note types that have a SVG preview. + * + * This adds the following functionality: + * + * - Automatic handling of the preview when content or the note changes via {@link renderSvg}. + * - Built-in pan and zoom functionality with automatic re-centering. + * - Automatically displays errors to the user if {@link renderSvg} failed. + * - Automatically saves the SVG attachment. + * + */ +export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg, ...props }: SvgSplitEditorProps) { + const [ svg, setSvg ] = useState(); + const [ error, setError ] = useState(); + const containerRef = useRef(null); + + // Render the SVG. + async function onContentChanged(content: string) { + try { + const svg = await renderSvg(content); + + // Rendering was successful. + setError(null); + setSvg(svg); + } catch (e) { + // Rendering failed. + setError((e as Error)?.message); + } + } + + // Save as attachment. + function onSave() { + const payload = { + role: "image", + title: `${attachmentName}.svg`, + mime: "image/svg+xml", + content: svg, + position: 0 + }; + + server.post(`notes/${note.noteId}/attachments?matchBy=title`, payload); + } + + // Save the SVG when entering a note only when it does not have an attachment. + useEffect(() => { + note?.getAttachments().then((attachments) => { + if (!attachments.find((a) => a.title === `${attachmentName}.svg`)) { + onSave(); + } + }); + }, [ note ]); + + // Import/export + useTriliumEvent("exportSvg", ({ ntxId: eventNtxId }) => { + if (eventNtxId !== ntxId || !svg) return; + utils.downloadSvg(note.title, svg); + }); + useTriliumEvent("exportPng", async ({ ntxId: eventNtxId }) => { + if (eventNtxId !== ntxId || !svg) return; + try { + await utils.downloadSvgAsPng(note.title, svg); + } catch (e) { + console.warn(e); + toast.showError(t("svg.export_to_png")); + } + }); + + // Pan & zoom. + const zoomRef = useResizer(containerRef, note.noteId, svg); + + return ( + + )} + previewButtons={ + <> + zoomRef.current?.zoomIn()} + /> + zoomRef.current?.zoomOut()} + /> + zoomRef.current?.fit().center()} + /> + + } + {...props} + /> + ) +} + +function useResizer(containerRef: RefObject, noteId: string, svg: string | undefined) { + const lastPanZoom = useRef<{ pan: SvgPanZoom.Point, zoom: number }>(); + const lastNoteId = useRef(); + const zoomRef = useRef(); + + // Set up pan & zoom. + useEffect(() => { + const shouldPreservePanZoom = (lastNoteId.current === noteId); + const svgEl = containerRef.current?.querySelector("svg"); + if (!svgEl) return; + const zoomInstance = svgPanZoom(svgEl, { + zoomEnabled: true, + controlIconsEnabled: false + }); + + // Restore the previous pan/zoom if the user updates same note. + if (shouldPreservePanZoom && lastPanZoom.current) { + zoomInstance.zoom(lastPanZoom.current.zoom); + zoomInstance.pan(lastPanZoom.current.pan); + } else { + zoomInstance.resize().center().fit(); + } + + lastNoteId.current = noteId; + zoomRef.current = zoomInstance; + + return () => { + lastPanZoom.current = { + pan: zoomInstance.getPan(), + zoom: zoomInstance.getZoom() + } + zoomInstance.destroy(); + }; + }, [ svg ]); + + // React to container changes. + const width = useElementSize(containerRef); + useEffect(() => { + if (!zoomRef.current) return; + zoomRef.current.resize().fit().center(); + }, [ width ]); + + return zoomRef; +} diff --git a/apps/client/src/widgets/type_widgets/image.ts b/apps/client/src/widgets/type_widgets/image.ts deleted file mode 100644 index e4afcbe1e..000000000 --- a/apps/client/src/widgets/type_widgets/image.ts +++ /dev/null @@ -1,95 +0,0 @@ -import utils from "../../services/utils.js"; -import TypeWidget from "./type_widget.js"; -import imageContextMenuService from "../../menus/image_context_menu.js"; -import imageService from "../../services/image.js"; -import type FNote from "../../entities/fnote.js"; -import type { EventData } from "../../components/app_context.js"; -import WheelZoom from 'vanilla-js-wheel-zoom'; - -const TPL = /*html*/` -
- - -
- -
-
`; - -class ImageTypeWidget extends TypeWidget { - - private $imageWrapper!: JQuery; - private $imageView!: JQuery; - - static getType() { - return "image"; - } - - doRender() { - this.$widget = $(TPL); - this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper"); - this.$imageView = this.$widget.find(".note-detail-image-view").attr("id", `image-view-${utils.randomString(10)}`); - - const initZoom = async () => { - const element = document.querySelector(`#${this.$imageView.attr("id")}`); - if (element) { - WheelZoom.create(`#${this.$imageView.attr("id")}`, { - maxScale: 50, - speed: 1.3, - zoomOnClick: false - }); - } else { - requestAnimationFrame(initZoom); - } - }; - initZoom(); - - imageContextMenuService.setupContextMenu(this.$imageView); - - super.doRender(); - } - - async doRefresh(note: FNote) { - this.$imageView.prop("src", utils.createImageSrcUrl(note)); - } - - copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) { - if (!this.isNoteContext(ntxId)) { - return; - } - - imageService.copyImageReferenceToClipboard(this.$imageWrapper); - } - - async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - if (loadResults.isNoteReloaded(this.noteId)) { - this.refresh(); - } - } -} - -export default ImageTypeWidget; diff --git a/apps/client/src/widgets/type_widgets/mind_map.ts b/apps/client/src/widgets/type_widgets/mind_map.ts deleted file mode 100644 index a6f299455..000000000 --- a/apps/client/src/widgets/type_widgets/mind_map.ts +++ /dev/null @@ -1,299 +0,0 @@ -import TypeWidget from "./type_widget.js"; -import utils from "../../services/utils.js"; -import type { MindElixirInstance } from "mind-elixir"; -import nodeMenu from "@mind-elixir/node-menu"; -import type FNote from "../../entities/fnote.js"; -import type { EventData } from "../../components/app_context.js"; - -// allow node-menu plugin css to be bundled by webpack -import "mind-elixir/style"; -import "@mind-elixir/node-menu/dist/style.css"; - -const NEW_TOPIC_NAME = ""; - -const TPL = /*html*/` -
-
-
- - -
-`; - -interface MindmapModel { - direction: number; -} - -export default class MindMapWidget extends TypeWidget { - - private $content!: JQuery; - private triggeredByUserOperation?: boolean; - private mind?: MindElixirInstance; - private MindElixir: any; // TODO: Fix type - - static getType() { - return "mindMap"; - } - - doRender() { - this.$widget = $(TPL); - this.$content = this.$widget.find(".mind-map-container"); - this.$content.on("keydown", (e) => { - /* - * Some global shortcuts interfere with the default shortcuts of the mind map, - * as defined here: https://mind-elixir.com/docs/guides/shortcuts - */ - if (e.key === "F1") { - e.stopPropagation(); - } - - // Zoom controls - const isCtrl = e.ctrlKey && !e.altKey && !e.metaKey; - if (isCtrl && (e.key == "-" || e.key == "=" || e.key == "0")) { - e.stopPropagation(); - } - }); - - // Save the mind map if the user changes the layout direction. - this.$content.on("click", ".mind-elixir-toolbar.lt", () => { - this.spacedUpdate.scheduleUpdate(); - }); - - super.doRender(); - } - - async doRefresh(note: FNote) { - if (this.triggeredByUserOperation) { - this.triggeredByUserOperation = false; - return; - } - - await this.#loadData(note); - } - - cleanup() { - this.triggeredByUserOperation = false; - } - - async #loadData(note: FNote) { - const blob = await note.getBlob(); - const content = blob?.getJsonContent(); - - if (!this.mind) { - await this.#initLibrary(content?.direction); - } - - this.mind!.refresh(content ?? this.MindElixir.new(NEW_TOPIC_NAME)); - this.mind!.toCenter(); - } - - async #initLibrary(direction?: number) { - this.MindElixir = (await import("mind-elixir")).default; - - const mind = new this.MindElixir({ - el: this.$content[0], - direction: direction ?? this.MindElixir.LEFT - }); - mind.install(nodeMenu); - - this.mind = mind; - mind.init(this.MindElixir.new(NEW_TOPIC_NAME)); - // TODO: See why the typeof mindmap is not correct. - mind.bus.addListener("operation", (operation: { name: string }) => { - this.triggeredByUserOperation = true; - if (operation.name !== "beginEdit") { - this.spacedUpdate.scheduleUpdate(); - } - }); - - // If the note is displayed directly after a refresh, the scroll ends up at (0,0), making it difficult for the user to see. - // Adding an arbitrary wait until the element is attached to the DOM seems to do the trick for now. - setTimeout(() => { - mind.toCenter(); - }, 200); - } - - async getData() { - const mind = this.mind; - if (!mind) { - return; - } - - const svgContent = await this.renderSvg(); - return { - content: mind.getDataString(), - attachments: [ - { - role: "image", - title: "mindmap-export.svg", - mime: "image/svg+xml", - content: svgContent, - position: 0 - } - ] - }; - } - - async renderSvg() { - return await this.mind!.exportSvg().text(); - } - - async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - if (this.noteId && loadResults.isNoteReloaded(this.noteId)) { - this.refresh(); - } - } - - async exportSvgEvent({ ntxId }: EventData<"exportSvg">) { - if (!this.isNoteContext(ntxId) || this.note?.type !== "mindMap") { - return; - } - - const svg = await this.renderSvg(); - utils.downloadSvg(this.note.title, svg); - } - - async exportPngEvent({ ntxId }: EventData<"exportPng">) { - if (!this.isNoteContext(ntxId) || this.note?.type !== "mindMap") { - return; - } - - const svg = await this.renderSvg(); - utils.downloadSvgAsPng(this.note.title, svg); - } - - async executeWithContentElementEvent({ resolve, ntxId }: EventData<"executeWithContentElement">) { - if (!this.isNoteContext(ntxId)) { - return; - } - - await this.initialized; - - resolve(this.$content.find('.main-node-container')); - } -} diff --git a/apps/client/src/widgets/type_widgets/none.ts b/apps/client/src/widgets/type_widgets/none.ts deleted file mode 100644 index 41d6631bf..000000000 --- a/apps/client/src/widgets/type_widgets/none.ts +++ /dev/null @@ -1,15 +0,0 @@ -import TypeWidget from "./type_widget.js"; - -const TPL = /*html*/`
`; - -export default class NoneTypeWidget extends TypeWidget { - static getType() { - return "none"; - } - - doRender() { - this.$widget = $(TPL); - - super.doRender(); - } -} diff --git a/apps/client/src/widgets/type_widgets/note_map.ts b/apps/client/src/widgets/type_widgets/note_map.ts deleted file mode 100644 index 33608aef3..000000000 --- a/apps/client/src/widgets/type_widgets/note_map.ts +++ /dev/null @@ -1,32 +0,0 @@ -import TypeWidget from "./type_widget.js"; -import NoteMapWidget from "../note_map.js"; -import type FNote from "../../entities/fnote.js"; - -const TPL = /*html*/`
`; - -export default class NoteMapTypeWidget extends TypeWidget { - - private noteMapWidget: NoteMapWidget; - - static getType() { - return "noteMap"; - } - - constructor() { - super(); - - this.noteMapWidget = new NoteMapWidget("type"); - this.child(this.noteMapWidget); - } - - doRender() { - this.$widget = $(TPL); - this.$widget.append(this.noteMapWidget.render()); - - super.doRender(); - } - - async doRefresh(note: FNote) { - await this.noteMapWidget.refresh(); - } -} diff --git a/apps/client/src/widgets/type_widgets/options/appearance.tsx b/apps/client/src/widgets/type_widgets/options/appearance.tsx index 20ace18f7..809d05e9e 100644 --- a/apps/client/src/widgets/type_widgets/options/appearance.tsx +++ b/apps/client/src/widgets/type_widgets/options/appearance.tsx @@ -203,7 +203,7 @@ function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, font @@ -284,7 +284,8 @@ function SmoothScrollEnabledOption() { } function MaxContentWidth() { - const [ maxContentWidth, setMaxContentWidth ] = useTriliumOption("maxContentWidth"); + const [maxContentWidth, setMaxContentWidth] = useTriliumOption("maxContentWidth"); + const [centerContent, setCenterContent] = useTriliumOptionBool("centerContent"); return ( @@ -300,9 +301,9 @@ function MaxContentWidth() { -

- {t("max_content_width.apply_changes_description")}