mirror of
https://github.com/zadam/trilium.git
synced 2025-11-13 16:55:50 +01:00
Compare commits
23 Commits
react/prom
...
feat/rice-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f1773609f | ||
|
|
da0302066d | ||
|
|
942647ab9c | ||
|
|
b8aa7402d8 | ||
|
|
052e28ab1b | ||
|
|
16912e606e | ||
|
|
321752ac18 | ||
|
|
10988095c2 | ||
|
|
253da139de | ||
|
|
d992a5e4a2 | ||
|
|
58c225237c | ||
|
|
d074841885 | ||
|
|
06b2d71b27 | ||
|
|
0afb8a11c8 | ||
|
|
f529ddc601 | ||
|
|
8572f82e0a | ||
|
|
b09a2c386d | ||
|
|
7c5553bd4b | ||
|
|
37d0136c50 | ||
|
|
5b79e0d71e | ||
|
|
053f722cb8 | ||
|
|
21aaec2c38 | ||
|
|
1db4971da6 |
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.4.2
|
||||
uses: softprops/action-gh-release@v2.4.1
|
||||
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.2
|
||||
uses: softprops/action-gh-release@v2.4.1
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
|
||||
54
.github/workflows/playwright.yml
vendored
54
.github/workflows/playwright.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- hotfix
|
||||
paths-ignore:
|
||||
- "apps/website/**"
|
||||
pull_request:
|
||||
@@ -14,24 +13,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
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
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -46,34 +29,9 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps
|
||||
|
||||
- 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
|
||||
- run: pnpm --filter server-e2e e2e
|
||||
|
||||
- name: Upload test report
|
||||
if: failure()
|
||||
@@ -81,7 +39,3 @@ jobs:
|
||||
with:
|
||||
name: e2e report
|
||||
path: apps/server-e2e/test-output
|
||||
|
||||
- name: Kill the server
|
||||
if: always()
|
||||
run: pkill -f trilium || true
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -127,7 +127,7 @@ jobs:
|
||||
path: upload
|
||||
|
||||
- name: Publish stable release
|
||||
uses: softprops/action-gh-release@v2.4.2
|
||||
uses: softprops/action-gh-release@v2.4.1
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
"@types/node": "24.10.0",
|
||||
"@types/yargs": "17.0.34",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.39.1",
|
||||
"eslint": "9.39.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.5",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"rcedit": "5.0.0",
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.1.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.21.0",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.11.1",
|
||||
"@redocly/cli": "2.10.0",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.2",
|
||||
"react": "19.2.0",
|
||||
|
||||
@@ -22,9 +22,8 @@ async function main() {
|
||||
buildSwagger(context);
|
||||
buildScriptApi(context);
|
||||
|
||||
// Copy index and 404 files.
|
||||
// Copy index file.
|
||||
cpSync(join(__dirname, "index.html"), join(context.baseDir, "index.html"));
|
||||
cpSync(join(context.baseDir, "user-guide/404.html"), join(context.baseDir, "404.html"));
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.99.5",
|
||||
"version": "0.99.3",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -15,7 +15,7 @@
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/js": "9.39.0",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.19",
|
||||
"@fullcalendar/daygrid": "6.1.19",
|
||||
@@ -43,7 +43,7 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "16.5.0",
|
||||
"i18next": "25.6.2",
|
||||
"i18next": "25.6.0",
|
||||
"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.2",
|
||||
"marked": "16.4.1",
|
||||
"mermaid": "11.12.1",
|
||||
"mind-elixir": "5.3.5",
|
||||
"mind-elixir": "5.3.4",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.27.2",
|
||||
"react-i18next": "16.2.4",
|
||||
"react-i18next": "16.2.3",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -20,6 +21,8 @@ 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";
|
||||
@@ -30,10 +33,6 @@ 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;
|
||||
@@ -200,7 +199,7 @@ export type CommandMappings = {
|
||||
resetLauncher: ContextMenuCommandData;
|
||||
|
||||
executeInActiveNoteDetailWidget: CommandData & {
|
||||
callback: (value: ReactWrappedWidget) => void;
|
||||
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
|
||||
};
|
||||
executeWithTextEditor: CommandData &
|
||||
ExecuteCommandData<CKTextEditor> & {
|
||||
@@ -212,7 +211,7 @@ export type CommandMappings = {
|
||||
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
|
||||
*/
|
||||
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
|
||||
executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>;
|
||||
executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
|
||||
addTextToActiveEditor: CommandData & {
|
||||
text: string;
|
||||
};
|
||||
@@ -222,9 +221,9 @@ export type CommandMappings = {
|
||||
showPasswordNotSet: CommandData;
|
||||
showProtectedSessionPasswordDialog: CommandData;
|
||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||
showIncludeNoteDialog: CommandData & IncludeNoteOpts;
|
||||
showAddLinkDialog: CommandData & AddLinkOpts;
|
||||
showPasteMarkdownDialog: CommandData & MarkdownImportOpts;
|
||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
||||
showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
closeProtectedSessionPasswordDialog: CommandData;
|
||||
copyImageReferenceToClipboard: CommandData;
|
||||
copyImageToClipboard: CommandData;
|
||||
@@ -329,7 +328,6 @@ export type CommandMappings = {
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
openNoteCustom: CommandData;
|
||||
openNoteOnServer: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
@@ -487,8 +485,13 @@ type EventMappings = {
|
||||
relationMapResetZoomIn: { ntxId: string | null | undefined };
|
||||
relationMapResetZoomOut: { ntxId: string | null | undefined };
|
||||
activeNoteChanged: {};
|
||||
showAddLinkDialog: AddLinkOpts;
|
||||
showIncludeDialog: IncludeNoteOpts;
|
||||
showAddLinkDialog: {
|
||||
textTypeWidget: EditableTextTypeWidget;
|
||||
text: string;
|
||||
};
|
||||
showIncludeDialog: {
|
||||
textTypeWidget: EditableTextTypeWidget;
|
||||
};
|
||||
openBulkActionsDialog: {
|
||||
selectedOrActiveNoteIds: string[];
|
||||
};
|
||||
@@ -496,10 +499,6 @@ type EventMappings = {
|
||||
noteIds: string[];
|
||||
};
|
||||
refreshData: { ntxId: string | null | undefined };
|
||||
contentSafeMarginChanged: {
|
||||
top: number;
|
||||
noteContext: NoteContext;
|
||||
}
|
||||
};
|
||||
|
||||
export type EventListener<T extends EventNames> = {
|
||||
@@ -667,10 +666,6 @@ 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);
|
||||
|
||||
@@ -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<ReactWrappedWidget | null>((resolve) =>
|
||||
new Promise<TypeWidget | null>((resolve) =>
|
||||
appContext.triggerCommand("executeWithTypeWidget", {
|
||||
resolve,
|
||||
ntxId: this.ntxId
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
|
||||
@@ -66,13 +67,6 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
openNoteOnServerCommand() {
|
||||
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||
if (noteId) {
|
||||
openService.openNoteOnServer(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
enterProtectedSessionCommand() {
|
||||
protectedSessionService.enterProtectedSession();
|
||||
}
|
||||
@@ -177,8 +171,7 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
|
||||
toggleTrayCommand() {
|
||||
if (!utils.isElectron() || options.is("disableTray")) return;
|
||||
|
||||
if (!utils.isElectron()) return;
|
||||
const { BrowserWindow } = utils.dynamicRequire("@electron/remote");
|
||||
const windows = BrowserWindow.getAllWindows() as Electron.BaseWindow[];
|
||||
const isVisible = windows.every((w) => w.isVisible());
|
||||
|
||||
@@ -265,7 +265,6 @@ export default class TabManager extends Component {
|
||||
mainNtxId: string | null = null
|
||||
): Promise<NoteContext> {
|
||||
const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId);
|
||||
noteContext.setEmpty();
|
||||
|
||||
const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId);
|
||||
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.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 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 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 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";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
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 GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import SqlResults from "../widgets/sql_result.js";
|
||||
import SqlTableSchemas from "../widgets/sql_table_schemas.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";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@@ -131,18 +129,15 @@ export default class DesktopLayout {
|
||||
.child(<CreatePaneButton />)
|
||||
)
|
||||
.child(<Ribbon />)
|
||||
.child(<SharedInfo />)
|
||||
.child(new WatchedFileUpdateStatusWidget())
|
||||
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.child(new ContentHeader()
|
||||
.child(<ReadOnlyNoteInfoBar />)
|
||||
.child(<SharedInfo />)
|
||||
)
|
||||
.child(<PromotedAttributes />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(<SqlTableSchemas />)
|
||||
.child(<NoteDetail />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<SearchResult />)
|
||||
.child(<SqlResults />)
|
||||
|
||||
@@ -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(<NoteTitleWidget />))
|
||||
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteList media="screen" displayOnlyCollections />))
|
||||
.child(<CallToActionDialog />);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
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 FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import NoteList from "../widgets/collections/NoteList.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 NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||
import RootContainer from "../widgets/containers/root_container.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 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 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 = `
|
||||
<style>
|
||||
@@ -151,17 +149,14 @@ export default class MobileLayout {
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(<MobileDetailMenu />)
|
||||
)
|
||||
.child(<SharedInfoWidget />)
|
||||
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(new ContentHeader()
|
||||
.child(<ReadOnlyNoteInfoBar />)
|
||||
.child(<SharedInfoWidget />)
|
||||
)
|
||||
.child(<NoteDetail />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
|
||||
.child(<SearchResult />)
|
||||
|
||||
@@ -137,7 +137,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
command: "editBranchPrefix",
|
||||
keyboardShortcut: "editBranchPrefix",
|
||||
uiIcon: "bx bx-rename",
|
||||
enabled: isNotRoot && parentNotSearch && notOptionsOrHelp
|
||||
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import "boxicons/css/boxicons.min.css";
|
||||
|
||||
:root {
|
||||
--print-font-size: 11pt;
|
||||
--ck-content-color-image-caption-background: transparent !important;
|
||||
|
||||
@@ -70,9 +70,6 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Check custom CSS.
|
||||
await loadCustomCss(note);
|
||||
}
|
||||
|
||||
load().then(() => requestAnimationFrame(onReady))
|
||||
@@ -92,10 +89,7 @@ function CollectionRenderer({ note, onReady }: RendererProps) {
|
||||
ntxId="print"
|
||||
highlightedTokens={null}
|
||||
media="print"
|
||||
onReady={async () => {
|
||||
await loadCustomCss(note);
|
||||
onReady();
|
||||
}}
|
||||
onReady={onReady}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -108,25 +102,4 @@ function Error404({ noteId }: { noteId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
async function loadCustomCss(note: FNote) {
|
||||
const printCssNotes = await note.getRelationTargets("printCss");
|
||||
let loadPromises: JQueryPromise<void>[] = [];
|
||||
|
||||
for (const printCssNote of printCssNotes) {
|
||||
if (!printCssNote || (printCssNote.type !== "code" && printCssNote.mime !== "text/css")) continue;
|
||||
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.href = `/api/notes/${printCssNote.noteId}/download`;
|
||||
linkEl.rel = "stylesheet";
|
||||
|
||||
const promise = $.Deferred();
|
||||
loadPromises.push(promise.promise());
|
||||
linkEl.onload = () => promise.resolve();
|
||||
|
||||
document.head.appendChild(linkEl);
|
||||
}
|
||||
|
||||
await Promise.allSettled(loadPromises);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -11,7 +11,7 @@ import RightPanelWidget from "../widgets/right_panel_widget.js";
|
||||
import ws from "./ws.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
|
||||
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import BasicWidget from "../widgets/basic_widget.js";
|
||||
import SpacedUpdate from "./spaced_update.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import dialogService from "./dialog.js";
|
||||
@@ -19,6 +19,7 @@ import type FNote from "../entities/fnote.js";
|
||||
import { t } from "./i18n.js";
|
||||
import dayjs from "dayjs";
|
||||
import type NoteContext from "../components/note_context.js";
|
||||
import type NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import type Component from "../components/component.js";
|
||||
import { formatLogMessage } from "@triliumnext/commons";
|
||||
|
||||
@@ -316,7 +317,7 @@ export interface Api {
|
||||
* Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
|
||||
* implementation of actual widget type.
|
||||
*/
|
||||
getActiveNoteDetailWidget(): Promise<ReactWrappedWidget>;
|
||||
getActiveNoteDetailWidget(): Promise<NoteDetailWidget>;
|
||||
/**
|
||||
* @returns returns a note path of active note or null if there isn't active note
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import server from "./server.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
@@ -30,18 +30,12 @@ async function getActionsForScope(scope: string) {
|
||||
|
||||
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
|
||||
const actions = await getActionsForScope(scope);
|
||||
const bindings: ShortcutBinding[] = [];
|
||||
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
if (binding) {
|
||||
bindings.push(binding);
|
||||
}
|
||||
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
getActionsForScope("window").then((actions) => {
|
||||
|
||||
@@ -280,7 +280,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
|
||||
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
|
||||
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
|
||||
*/
|
||||
export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
|
||||
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
|
||||
if (hrefLink?.startsWith("data:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import utils from "./utils.js";
|
||||
import options from "./options.js";
|
||||
import server from "./server.js";
|
||||
|
||||
type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void;
|
||||
@@ -127,7 +126,7 @@ function downloadRevision(noteId: string, revisionId: string) {
|
||||
/**
|
||||
* @param url - should be without initial slash!!!
|
||||
*/
|
||||
export function getUrlForDownload(url: string) {
|
||||
function getUrlForDownload(url: string) {
|
||||
if (utils.isElectron()) {
|
||||
// electron needs absolute URL, so we extract current host, port, protocol
|
||||
return `${getHost()}/${url}`;
|
||||
@@ -172,21 +171,6 @@ function getHost() {
|
||||
return `${url.protocol}//${url.hostname}:${url.port}`;
|
||||
}
|
||||
|
||||
async function openNoteOnServer(noteId: string) {
|
||||
// Get the sync server host from options
|
||||
const syncServerHost = options.get("syncServerHost");
|
||||
|
||||
if (!syncServerHost) {
|
||||
console.error("No sync server host configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(`#root/${noteId}`, syncServerHost).toString();
|
||||
|
||||
// Use window.open to ensure link opens in external browser in Electron
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
async function openDirectory(directory: string) {
|
||||
try {
|
||||
if (utils.isElectron()) {
|
||||
@@ -214,6 +198,5 @@ export default {
|
||||
openAttachmentExternally,
|
||||
openNoteCustom,
|
||||
openAttachmentCustom,
|
||||
openNoteOnServer,
|
||||
openDirectory
|
||||
};
|
||||
|
||||
@@ -159,7 +159,7 @@ describe("shortcuts", () => {
|
||||
expect(matchesShortcut(event, "Shift+F1")).toBeTruthy();
|
||||
|
||||
// Special keys
|
||||
for (const keyCode of [ "Delete", "Enter", "NumpadEnter" ]) {
|
||||
for (const keyCode of [ "Delete", "Enter" ]) {
|
||||
event = createKeyboardEvent({ key: keyCode, code: keyCode });
|
||||
expect(matchesShortcut(event, keyCode), `Key ${keyCode}`).toBeTruthy();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import utils from "./utils.js";
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
export interface ShortcutBinding {
|
||||
interface ShortcutBinding {
|
||||
element: HTMLElement | Document;
|
||||
shortcut: string;
|
||||
handler: Handler;
|
||||
@@ -46,7 +46,6 @@ for (let i = 1; i <= 19; i++) {
|
||||
const KEYCODES_WITH_NO_MODIFIER = new Set([
|
||||
"Delete",
|
||||
"Enter",
|
||||
"NumpadEnter",
|
||||
...functionKeyCodes
|
||||
]);
|
||||
|
||||
@@ -127,20 +126,10 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
activeBindings.set(key, []);
|
||||
}
|
||||
activeBindings.get(key)!.push(binding);
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeIndividualBinding(binding: ShortcutBinding) {
|
||||
const key = binding.namespace ?? "global";
|
||||
const activeBindingsInNamespace = activeBindings.get(key);
|
||||
if (activeBindingsInNamespace) {
|
||||
activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
|
||||
}
|
||||
binding.element.removeEventListener("keydown", binding.listener);
|
||||
}
|
||||
|
||||
function removeNamespaceBindings(namespace: string) {
|
||||
const bindings = activeBindings.get(namespace);
|
||||
if (bindings) {
|
||||
|
||||
@@ -24,9 +24,7 @@ export async function formatCodeBlocks($container: JQuery<HTMLElement>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (glob.device !== "print") {
|
||||
applyCopyToClipboardButton($(codeBlock));
|
||||
}
|
||||
applyCopyToClipboardButton($(codeBlock));
|
||||
|
||||
if (syntaxHighlightingEnabled) {
|
||||
applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
|
||||
|
||||
@@ -173,7 +173,7 @@ const entityMap: Record<string, string> = {
|
||||
"=": "="
|
||||
};
|
||||
|
||||
export function escapeHtml(str: string) {
|
||||
function escapeHtml(str: string) {
|
||||
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
|
||||
}
|
||||
|
||||
@@ -841,7 +841,7 @@ export function arrayEqual<T>(a: T[], b: T[]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export type Indexed<T extends object> = T & { index: number };
|
||||
type Indexed<T extends object> = T & { index: number };
|
||||
|
||||
/**
|
||||
* Given an object array, alters every object in the array to have an index field assigned to it.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.note-detail-relation-map {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ body.desktop .tabulator-popup-container,
|
||||
.dropdown-menu .disabled .disabled-tooltip {
|
||||
pointer-events: all;
|
||||
margin-inline-start: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.5em;
|
||||
color: var(--disabled-tooltip-icon-color);
|
||||
cursor: help;
|
||||
opacity: 0.75;
|
||||
@@ -1104,6 +1104,7 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
|
||||
.card {
|
||||
color: inherit !important;
|
||||
background-color: inherit !important;
|
||||
border-color: var(--main-border-color) !important;
|
||||
}
|
||||
|
||||
@@ -1758,10 +1759,10 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
flex-direction: column;
|
||||
margin-inline-start: 10px;
|
||||
margin-inline-end: 5px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#right-pane .card-header {
|
||||
background: inherit;
|
||||
padding: 6px 0 3px 0;
|
||||
width: 99%; /* to give minimal right margin */
|
||||
background-color: var(--button-background-color);
|
||||
@@ -1808,15 +1809,12 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
}
|
||||
|
||||
.note-split {
|
||||
/* Limits the maximum width of the note */
|
||||
--max-content-width: var(--preferred-max-content-width);
|
||||
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.note-split.full-content-width {
|
||||
--max-content-width: unset;
|
||||
max-width: 999999px;
|
||||
}
|
||||
|
||||
button.close:hover {
|
||||
@@ -2036,16 +2034,13 @@ body.zen #right-pane,
|
||||
body.zen #mobile-sidebar-wrapper,
|
||||
body.zen .tab-row-container,
|
||||
body.zen .tab-row-widget,
|
||||
body.zen .shared-info-widget,
|
||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget)),
|
||||
body.zen .ribbon-container:has(.classic-toolbar-widget) .ribbon-top-row,
|
||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget)),
|
||||
body.zen .note-icon-widget,
|
||||
body.zen .title-row .icon-action,
|
||||
body.zen .promoted-attributes-widget,
|
||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
||||
body.zen .action-button,
|
||||
body.zen .note-split:not(.type-book) .note-list-widget {
|
||||
body.zen .action-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -2089,121 +2084,12 @@ body.zen .note-title-widget,
|
||||
body.zen .note-title-widget input {
|
||||
font-size: 1rem !important;
|
||||
background: transparent !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.zen #detail-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.zen .note-split:not(.full-content-width) .scrolling-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scroll-behavior: unset !important;
|
||||
}
|
||||
|
||||
body.zen .note-split:not(.full-content-width) .note-detail {
|
||||
margin: auto;
|
||||
padding-bottom: 25vh;
|
||||
max-width: var(--max-content-width);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.zen .note-split:not(.full-content-width) .scroll-padding-widget {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.zen .note-split.type-text {
|
||||
position: relative;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
body.zen:not(.backdrop-effects-disabled) .note-split.type-text .title-row {
|
||||
--start-color: var(--main-background-color);
|
||||
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background: linear-gradient(var(--start-color) 30%, transparent 100%);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@supports (background: color-mix(in srgb, white, transparent)) {
|
||||
body.zen .note-split.type-text .title-row {
|
||||
--start-color: color-mix(in srgb, var(--main-background-color), transparent 10%);
|
||||
}
|
||||
}
|
||||
|
||||
body.zen .note-split.type-text .scrolling-container {
|
||||
--padding-bottom: 130px; /* Should be enough to avoid caret being hidden by the formatting toolbar */
|
||||
|
||||
/* (Usually) keeps the caret above the fixed toolbar */
|
||||
scroll-padding-bottom: var(--padding-bottom);
|
||||
}
|
||||
|
||||
body.zen:not(.backdrop-effects-disabled) .note-split.type-text .scrolling-container {
|
||||
--padding-top: 50px; /* Should be enough to cover the title row */
|
||||
|
||||
padding-top: var(--padding-top);
|
||||
scroll-padding-top: var(--padding-top);
|
||||
}
|
||||
|
||||
/* Fixed formatting toolbar */
|
||||
|
||||
body.zen .note-split .ribbon-container {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 20px;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
opacity: 0; /* Hidden unless the current note split is focused */
|
||||
pointer-events: none;
|
||||
transition: opacity 100ms linear;
|
||||
}
|
||||
|
||||
body.zen .note-split:focus-within .ribbon-container {
|
||||
opacity: 1; /* Show when the note split is focused */
|
||||
}
|
||||
|
||||
body.zen .note-split .ribbon-container .ribbon-body {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
body.zen .note-split .ribbon-container .classic-toolbar-widget {
|
||||
margin: auto;
|
||||
width: fit-content;
|
||||
box-shadow: 0px 10px 20px rgba(0, 0, 0, .1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--main-border-color);
|
||||
padding: 4px;
|
||||
background: var(--menu-background-color);
|
||||
}
|
||||
|
||||
body.zen .note-split .ribbon-container .classic-toolbar-widget:not(:has(> .ck-toolbar)) {
|
||||
/* Hide the toolbar wrapper if the toolbar is missing */
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.zen .note-split:focus-within .ribbon-container .classic-toolbar-widget {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
body.zen .note-split .ribbon-container .classic-toolbar-widget {
|
||||
/* Set the toolbar to full with */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.zen .classic-toolbar-widget .ck.ck-dropdown .ck-dropdown__panel.ck-dropdown__panel_se,
|
||||
body.zen .classic-toolbar-widget .ck.ck-dropdown .ck-dropdown__panel.ck-dropdown__panel_sw,
|
||||
body.zen .classic-toolbar-widget .ck.ck-dropdown .ck-dropdown__panel.ck-dropdown__panel_smw,
|
||||
body.zen .classic-toolbar-widget .ck.ck-dropdown .ck-dropdown__panel.ck-dropdown__panel_sme,
|
||||
body.zen .classic-toolbar-widget .ck.ck-dropdown .ck-dropdown__panel.ck-dropdown__panel_s {
|
||||
/* Force toolbar items overflow dropdowns open upwards */
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Content renderer */
|
||||
|
||||
footer.file-footer,
|
||||
@@ -2520,7 +2406,7 @@ footer.webview-footer button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* CK Editor */
|
||||
/* CK Edito */
|
||||
|
||||
/* Insert text snippet: limit the width of the listed items to avoid overly long names */
|
||||
:root body.desktop div.ck-template-form li.ck-list__item .ck-template-form__text-part > span {
|
||||
@@ -2550,18 +2436,4 @@ iframe.print-iframe {
|
||||
|
||||
.excalidraw.theme--dark canvas {
|
||||
--theme-filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
/* Scrolling container */
|
||||
|
||||
.scrolling-container:has(> :is(.note-detail.full-height, .note-list-widget.full-height)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scrolling-container > .note-detail.full-height,
|
||||
.scrolling-container > .note-list-widget.full-height {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
--native-titlebar-background: #00000000;
|
||||
--window-background-color-bgfx: transparent; /* When background effects enabled */
|
||||
|
||||
--main-background-color: #242424;
|
||||
--main-background-color: #272727;
|
||||
--main-text-color: #ccc;
|
||||
--main-border-color: #454545;
|
||||
--subtle-border-color: #313131;
|
||||
@@ -166,9 +166,6 @@
|
||||
--protected-session-active-icon-color: #8edd8e;
|
||||
--sync-status-error-pulse-color: #f47871;
|
||||
|
||||
--center-pane-vert-layout-background-color-bgfx: #0c0c0c69;
|
||||
--center-pane-horiz-layout-background-color-bgfx: #1e1e1ec7;
|
||||
|
||||
--right-pane-heading-color: gray;
|
||||
|
||||
--root-background: var(--left-pane-background-color);
|
||||
@@ -195,9 +192,9 @@
|
||||
--badge-background-color: #ffffff1a;
|
||||
--badge-text-color: var(--muted-text-color);
|
||||
|
||||
--promoted-attribute-card-background-color: #ffffff21;
|
||||
--promoted-attribute-card-shadow: none;
|
||||
|
||||
--promoted-attribute-card-background-color: var(--card-background-color);
|
||||
--promoted-attribute-card-shadow-color: #000000b3;
|
||||
|
||||
--floating-button-shadow-color: #00000080;
|
||||
--floating-button-background-color: #494949d2;
|
||||
--floating-button-color: var(--button-text-color);
|
||||
@@ -211,8 +208,6 @@
|
||||
--floating-button-hide-button-background: #00000029;
|
||||
--floating-button-hide-button-color: #ffffff63;
|
||||
|
||||
--right-pane-background-color: var(--main-background-color);
|
||||
--right-pane-background-color-bgfx: #0c0c0c24; /* Only for the vertical layout */
|
||||
--right-pane-item-hover-background: #ffffff26;
|
||||
--right-pane-item-hover-color: white;
|
||||
|
||||
@@ -230,9 +225,10 @@
|
||||
--code-block-box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--card-background-color: #ffffff12;
|
||||
--card-background-hover-color: #ffffff20;
|
||||
--card-border-color: transparent;
|
||||
--card-box-shadow: none;
|
||||
--card-background-hover-color: #3c3c3c;
|
||||
--card-background-press-color: #464646;
|
||||
--card-border-color: #222222;
|
||||
--card-box-shadow: 0 0 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
--calendar-color: var(--menu-text-color);
|
||||
--calendar-weekday-labels-color: var(--muted-text-color);
|
||||
@@ -298,10 +294,4 @@ body ::-webkit-calendar-picker-indicator {
|
||||
|
||||
body .todo-list input[type="checkbox"]:not(:checked):before {
|
||||
border-color: var(--muted-text-color) !important;
|
||||
}
|
||||
|
||||
.tinted-quick-edit-dialog {
|
||||
--modal-background-color: hsl(var(--custom-color-hue), 8.8%, 11.2%);
|
||||
--modal-border-color: hsl(var(--custom-color-hue), 9.4%, 25.1%);
|
||||
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 13.2%, 20.8%);
|
||||
}
|
||||
@@ -159,9 +159,6 @@
|
||||
--protected-session-active-icon-color: #16b516;
|
||||
--sync-status-error-pulse-color: #ff5528;
|
||||
|
||||
--center-pane-vert-layout-background-color-bgfx: #ffffff75;
|
||||
--center-pane-horiz-layout-background-color-bgfx: #ffffffd6;
|
||||
|
||||
--right-pane-heading-color: gray;
|
||||
|
||||
--root-background: var(--left-pane-background-color);
|
||||
@@ -183,13 +180,13 @@
|
||||
--inactive-tab-hover-background-color: #00000016;
|
||||
--inactive-tab-text-color: #4e4e4e;
|
||||
|
||||
--alert-bar-background: #f9cf2b29;
|
||||
--alert-bar-background: #32637b29;
|
||||
|
||||
--badge-background-color: #00000011;
|
||||
--badge-text-color: var(--muted-text-color);
|
||||
|
||||
--promoted-attribute-card-background-color: #00000014;
|
||||
--promoted-attribute-card-shadow: none;
|
||||
--promoted-attribute-card-background-color: var(--card-background-color);
|
||||
--promoted-attribute-card-shadow-color: #00000033;
|
||||
|
||||
--floating-button-shadow-color: #00000042;
|
||||
--floating-button-background-color: #eaeaeacc;
|
||||
@@ -210,9 +207,7 @@
|
||||
--new-tab-button-hover-background: white;
|
||||
--new-tab-button-hover-color: black;
|
||||
|
||||
--right-pane-background-color: var(--main-background-color);
|
||||
--right-pane-background-color-bgfx: #ffffff9e; /* Only for the vertical layout */
|
||||
--right-pane-item-hover-background: #00000013;
|
||||
--right-pane-item-hover-background: #ececec;
|
||||
--right-pane-item-hover-color: inherit;
|
||||
|
||||
--scrollbar-thumb-color: #0000005c;
|
||||
@@ -228,11 +223,12 @@
|
||||
|
||||
--code-block-box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1), 0px 0px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
--card-background-color: #0000000d;
|
||||
--card-background-hover-color: #0000001c;
|
||||
--card-border-color: transparent;
|
||||
--card-background-color: var(--accented-background-color);
|
||||
--card-background-hover-color: #f9f9f9;
|
||||
--card-background-press-color: #efefef;
|
||||
--card-border-color: #eaeaea;
|
||||
--card-shadow-color: rgba(0, 0, 0, 0.1);
|
||||
--card-box-shadow: none;
|
||||
--card-box-shadow: 0 0 12px var(--card-shadow-color);
|
||||
|
||||
--calendar-color: var(--menu-text-color);
|
||||
--calendar-weekday-labels-color: var(--muted-text-color);
|
||||
@@ -274,10 +270,4 @@
|
||||
* The --custom-color-hue variable contains the hue of the user-selected note color.
|
||||
* This value is unset for gray tones. */
|
||||
--custom-bg-color: hsl(var(--custom-color-hue), 37%, 89%, 1);
|
||||
}
|
||||
|
||||
.tinted-quick-edit-dialog {
|
||||
--modal-background-color: hsl(var(--custom-color-hue), 56%, 96%);
|
||||
--modal-border-color: hsl(var(--custom-color-hue), 33%, 41%);
|
||||
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 40%, 88%);
|
||||
}
|
||||
@@ -82,7 +82,6 @@
|
||||
|
||||
/* Theme capabilities */
|
||||
--tab-note-icons: true;
|
||||
--allow-background-effects: true;
|
||||
|
||||
/* To ensure that a tree item's custom color remains sufficiently contrasted and readable,
|
||||
* the color is adjusted based on the current color scheme (light or dark). The lightness
|
||||
@@ -132,8 +131,7 @@ body.mobile .dropdown-menu .dropdown-menu {
|
||||
|
||||
body.desktop .dropdown-menu::before,
|
||||
:root .ck.ck-dropdown__panel::before,
|
||||
:root .excalidraw .popover::before,
|
||||
body.zen .note-split .ribbon-container .classic-toolbar-widget::before {
|
||||
:root .excalidraw .popover::before {
|
||||
content: "";
|
||||
backdrop-filter: var(--dropdown-backdrop-filter);
|
||||
border-radius: var(--dropdown-border-radius);
|
||||
@@ -487,21 +485,13 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
--note-list-vertical-padding: 15px;
|
||||
background-color: var(--card-background-color);
|
||||
border: 1px solid var(--card-border-color) !important;
|
||||
box-shadow: 2px 3px 4px var(--card-shadow-color);
|
||||
border-radius: 12px;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
margin: 5px 10px 5px 0;
|
||||
}
|
||||
|
||||
:root .note-list .note-book-card:hover {
|
||||
background-color: var(--card-background-hover-color);
|
||||
transition: background-color 200ms ease-out;
|
||||
}
|
||||
|
||||
:root .note-list.grid-view .note-book-card:active {
|
||||
transform: scale(.98);
|
||||
}
|
||||
|
||||
.note-list.list-view .note-book-card {
|
||||
box-shadow: 0 0 3px var(--card-shadow-color);
|
||||
}
|
||||
@@ -510,6 +500,10 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card:active {
|
||||
background-color: var(--card-background-press-color);
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card a {
|
||||
color: inherit !important;
|
||||
}
|
||||
@@ -581,14 +575,9 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-code {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-code pre {
|
||||
height: 100%;
|
||||
padding: 1em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .bx {
|
||||
@@ -596,6 +585,7 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
}
|
||||
|
||||
.note-list.grid-view .note-book-card:hover {
|
||||
background: var(--card-background-color) !important;
|
||||
filter: contrast(105%);
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,11 @@
|
||||
border-inline-start: 1px solid var(--ck-color-toolbar-border);
|
||||
}
|
||||
|
||||
/* The last separator of the toolbar */
|
||||
:root .classic-toolbar-widget .ck.ck-toolbar__separator:last-of-type {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Heading dropdown */
|
||||
|
||||
:root .ck.ck-dropdown.ck-heading-dropdown .ck-dropdown__panel .ck-list__item {
|
||||
@@ -674,17 +679,4 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
|
||||
|
||||
.ck-content a.reference-link > span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/*
|
||||
* Read-only text content
|
||||
*/
|
||||
|
||||
.note-detail-readonly-text:focus-visible {
|
||||
outline: 2px solid var(--input-focus-outline-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.note-list-widget {
|
||||
outline: 0 !important;
|
||||
}
|
||||
@@ -101,7 +101,7 @@
|
||||
.sql-table-schemas-widget .sql-table-schemas button:hover,
|
||||
.sql-table-schemas-widget .sql-table-schemas button:active,
|
||||
.sql-table-schemas-widget .sql-table-schemas button:focus-visible {
|
||||
--background: var(--card-background-hover-color);
|
||||
--background: var(--card-background-press-color);
|
||||
--color: var(--main-text-color);
|
||||
}
|
||||
|
||||
@@ -123,12 +123,8 @@
|
||||
*/
|
||||
|
||||
/* The container */
|
||||
|
||||
.note-split.empty-note {
|
||||
--max-content-width: 70%;
|
||||
}
|
||||
|
||||
.note-split.empty-note div.note-detail {
|
||||
div.note-detail-empty {
|
||||
max-width: 70%;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
@@ -152,7 +148,7 @@
|
||||
--options-card-min-width: 500px;
|
||||
--options-card-max-width: 900px;
|
||||
--options-card-padding: 17px;
|
||||
--options-title-font-size: .75rem;
|
||||
--options-title-font-size: 1rem;
|
||||
--options-title-offset: 13px;
|
||||
}
|
||||
/* Create a gap at the top of the option pages */
|
||||
@@ -177,19 +173,16 @@
|
||||
}
|
||||
|
||||
.options-section:not(.tn-no-card) {
|
||||
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
|
||||
box-shadow: var(--card-box-shadow);
|
||||
margin: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--card-border-color) !important;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--card-box-shadow);
|
||||
background: var(--card-background-color);
|
||||
padding: var(--options-card-padding);
|
||||
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
|
||||
}
|
||||
|
||||
body.prefers-centered-content .options-section:not(.tn-no-card) {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
body.desktop .options-section:not(.tn-no-card) {
|
||||
body.desktop .option-section:not(.tn-no-card) {
|
||||
min-width: var(--options-card-min-width);
|
||||
max-width: var(--options-card-max-width);
|
||||
}
|
||||
@@ -200,16 +193,9 @@ body.desktop .options-section:not(.tn-no-card) {
|
||||
padding-bottom: var(--default-padding);
|
||||
}
|
||||
|
||||
.options-section:not(.tn-no-card) h4,
|
||||
.options-section:not(.tn-no-card) h5 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4pt;
|
||||
}
|
||||
|
||||
|
||||
.options-section:not(.tn-no-card) h4 {
|
||||
font-size: var(--options-title-font-size);
|
||||
font-weight: 600;
|
||||
font-weight: bold;
|
||||
color: var(--launcher-pane-text-color);
|
||||
margin-top: calc(-1 * var(--options-card-padding) - var(--options-title-font-size) - var(--options-title-offset)) !important;
|
||||
margin-bottom: calc(var(--options-title-offset) + var(--options-card-padding)) !important;
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
div.promoted-attributes-container {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin-inline-start: 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -42,7 +41,7 @@ div.promoted-attributes-container {
|
||||
*/
|
||||
|
||||
/* The property label */
|
||||
.note-info-item > span:first-child,
|
||||
.note-info-widget-table th,
|
||||
.file-properties-widget .file-table th,
|
||||
.image-properties > div:first-child > span > strong {
|
||||
opacity: 0.65;
|
||||
@@ -50,6 +49,7 @@ div.promoted-attributes-container {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.note-info-widget-table td,
|
||||
.file-properties-widget .file-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--dropdown-backdrop-filter: blur(20px) saturate(6);
|
||||
--dropdown-backdrop-filter: blur(10px) saturate(6);
|
||||
--dropdown-border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -35,53 +35,30 @@ body.mobile {
|
||||
}
|
||||
|
||||
/* #region Mica */
|
||||
|
||||
body.background-effects.platform-win32 {
|
||||
/* Quirk: --background-material is read before "theme-supports-background-effects" class
|
||||
* is applied. Apply the matterial even if the theme doesn't support it. */
|
||||
--background-material: tabbed;
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32 {
|
||||
--launcher-pane-horiz-border-color: var(--launcher-pane-horiz-border-color-bgfx);
|
||||
--launcher-pane-horiz-background-color: var(--launcher-pane-horiz-background-color-bgfx);
|
||||
--launcher-pane-vert-background-color: var(--launcher-pane-vert-background-color-bgfx);
|
||||
--tab-background-color: var(--window-background-color-bgfx);
|
||||
--new-tab-button-background: var(--window-background-color-bgfx);
|
||||
--active-tab-background-color: var(--launcher-pane-horiz-background-color);
|
||||
--root-background: transparent;
|
||||
}
|
||||
|
||||
body.background-effects.platform-win32.layout-vertical {
|
||||
--left-pane-background-color: var(--window-background-color-bgfx);
|
||||
--background-material: mica;
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-vertical {
|
||||
--left-pane-background-color: var(--window-background-color-bgfx);
|
||||
--center-pane-background-color-bgfx: var(--center-pane-vert-layout-background-color-bgfx);
|
||||
--right-pane-background-color: var(--right-pane-background-color-bgfx);
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-horizontal {
|
||||
--center-pane-background-color-bgfx: var(--center-pane-horiz-layout-background-color-bgfx);
|
||||
--gutter-color: var(--left-pane-background-color);
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32,
|
||||
body.background-effects.theme-supports-background-effects.platform-win32 #root-widget {
|
||||
body.background-effects.platform-win32,
|
||||
body.background-effects.platform-win32 #root-widget {
|
||||
background: var(--window-background-color-bgfx) !important;
|
||||
}
|
||||
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-horizontal #horizontal-main-container,
|
||||
body.background-effects.theme-supports-background-effects.platform-win32.layout-vertical #vertical-main-container {
|
||||
body.background-effects.platform-win32.layout-horizontal #horizontal-main-container,
|
||||
body.background-effects.platform-win32.layout-vertical #vertical-main-container {
|
||||
background-color: var(--root-background);
|
||||
}
|
||||
|
||||
/* Note split with background effects */
|
||||
body.background-effects.theme-supports-background-effects.platform-win32 #center-pane .note-split.bgfx {
|
||||
--note-split-background-color: var(--center-pane-background-color-bgfx);
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* Matches when the left pane is collapsed */
|
||||
@@ -95,21 +72,9 @@ body.layout-vertical #horizontal-main-container.left-pane-hidden #launcher-pane.
|
||||
border-inline-end: 2px solid var(--left-pane-collapsed-border-color);
|
||||
}
|
||||
|
||||
/*
|
||||
* Zen mode
|
||||
*/
|
||||
|
||||
@keyframes zen-formatting-toolbar-entrance {
|
||||
from {
|
||||
transform: translateY(200%);
|
||||
} to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
body.zen .note-split .ribbon-container .classic-toolbar-widget {
|
||||
position: relative;
|
||||
animation: zen-formatting-toolbar-entrance 300ms ease-out;
|
||||
body.background-effects.zen #root-widget {
|
||||
--main-background-color: transparent;
|
||||
--root-background: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1206,18 +1171,23 @@ body.layout-vertical .tab-row-widget-is-sorting .note-tab.note-tab-is-dragging .
|
||||
* CENTER PANE
|
||||
*/
|
||||
|
||||
/* The first visible note split */
|
||||
.vertical-layout #center-pane .note-split:not(.visible ~ .visible) {
|
||||
#center-pane {
|
||||
background: var(--main-background-color);
|
||||
}
|
||||
|
||||
.vertical-layout #center-pane {
|
||||
border-radius: var(--center-pane-border-radius) 0 0 0;
|
||||
}
|
||||
|
||||
#center-pane .note-split {
|
||||
.note-split {
|
||||
padding-top: 2px;
|
||||
background-color: var(--note-split-background-color, var(--main-background-color));
|
||||
animation: note-entrance 100ms linear;
|
||||
/* will-change: opacity; -- causes some weird artifacts to the note menu in split view */
|
||||
}
|
||||
|
||||
body:not(.background-effects) #center-pane .note-split {
|
||||
animation: note-entrance 100ms linear;
|
||||
.split-note-container-widget > .gutter {
|
||||
background: var(--root-background) !important;
|
||||
transition: background 150ms ease-out;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1230,9 +1200,9 @@ body:not(.background-effects) #center-pane .note-split {
|
||||
|
||||
@keyframes note-entrance {
|
||||
from {
|
||||
filter: opacity(0);
|
||||
opacity: 0;
|
||||
} to {
|
||||
filter: opacity(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1358,7 +1328,8 @@ div.promoted-attribute-cell {
|
||||
--pa-card-padding-inline-end: 2px;
|
||||
--input-background-color: transparent;
|
||||
|
||||
box-shadow: var(--promoted-attribute-card-shadow);
|
||||
box-shadow: 1px 1px 2px var(--promoted-attribute-card-shadow-color);
|
||||
|
||||
display: inline-flex;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
@@ -1745,7 +1716,7 @@ div.find-replace-widget div.find-widget-found-wrapper > span {
|
||||
*/
|
||||
|
||||
#right-pane {
|
||||
background: var(--right-pane-background-color);
|
||||
background: var(--main-background-color);
|
||||
}
|
||||
|
||||
#right-pane div.card-header {
|
||||
|
||||
@@ -520,7 +520,9 @@
|
||||
"max_content_width": {
|
||||
"max_width_unit": "بكسل",
|
||||
"title": "عرض المحتوى",
|
||||
"max_width_label": "اقصى عرض للمحتوى"
|
||||
"reload_button": "اعادة تحميل الواجهة",
|
||||
"max_width_label": "اقصى عرض للمحتوى",
|
||||
"reload_description": "تغييرات من خيارات المظهر"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"enabled": "مفعل",
|
||||
|
||||
@@ -39,10 +39,7 @@
|
||||
"help_on_tree_prefix": "有关树前缀的帮助",
|
||||
"prefix": "前缀: ",
|
||||
"save": "保存",
|
||||
"branch_prefix_saved": "分支前缀已保存。",
|
||||
"edit_branch_prefix_multiple": "编辑 {{count}} 个分支的前缀",
|
||||
"branch_prefix_saved_multiple": "已为 {{count}} 个分支保存分支前缀。",
|
||||
"affected_branches": "受影响的分支 {{count}}:"
|
||||
"branch_prefix_saved": "分支前缀已保存。"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "批量操作",
|
||||
@@ -995,7 +992,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||
"start_session_button": "开始受保护的会话",
|
||||
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
||||
"started": "受保护的会话已启动。",
|
||||
"wrong_password": "密码错误。",
|
||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||
@@ -1110,6 +1107,9 @@
|
||||
"title": "内容宽度",
|
||||
"default_description": "Trilium默认会限制内容的最大宽度以提高在宽屏中全屏时的可读性。",
|
||||
"max_width_label": "内容最大宽度(像素)",
|
||||
"apply_changes_description": "要应用内容宽度更改,请点击",
|
||||
"reload_button": "重载前端",
|
||||
"reload_description": "来自外观选项的更改",
|
||||
"max_width_unit": "像素"
|
||||
},
|
||||
"native_title_bar": {
|
||||
@@ -1917,7 +1917,7 @@
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "自定义日期/时间格式",
|
||||
"description": "自定义通过 <shortcut /> 或工具栏插入的日期和时间格式。有关日期/时间格式字符串中各个字符的含义,请参阅<doc>Day.js docs</doc>。",
|
||||
"description": "通过<shortcut />或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<doc>Day.js docs</doc>。",
|
||||
"format_string": "日期/时间格式字符串:",
|
||||
"formatted_time": "格式化后日期/时间:"
|
||||
},
|
||||
@@ -2079,8 +2079,5 @@
|
||||
"edit-slide": "编辑此幻灯片",
|
||||
"start-presentation": "开始演示",
|
||||
"slide-overview": "切换幻灯片概览"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "删除笔记..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,7 +989,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
|
||||
"start_session_button": "Starte eine geschützte Sitzung",
|
||||
"start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>",
|
||||
"started": "Geschützte Sitzung gestartet.",
|
||||
"wrong_password": "Passwort falsch.",
|
||||
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
||||
@@ -1104,6 +1104,9 @@
|
||||
"title": "Inhaltsbreite",
|
||||
"default_description": "Trilium begrenzt standardmäßig die maximale Inhaltsbreite, um die Lesbarkeit für maximierte Bildschirme auf Breitbildschirmen zu verbessern.",
|
||||
"max_width_label": "Maximale Inhaltsbreite in Pixel",
|
||||
"apply_changes_description": "Um Änderungen an der Inhaltsbreite anzuwenden, klicke auf",
|
||||
"reload_button": "Frontend neu laden",
|
||||
"reload_description": "Änderungen an den Darstellungsoptionen",
|
||||
"max_width_unit": "Pixel"
|
||||
},
|
||||
"native_title_bar": {
|
||||
|
||||
@@ -36,13 +36,10 @@
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Edit branch prefix",
|
||||
"edit_branch_prefix_multiple": "Edit branch prefix for {{count}} branches",
|
||||
"help_on_tree_prefix": "Help on Tree prefix",
|
||||
"prefix": "Prefix: ",
|
||||
"save": "Save",
|
||||
"branch_prefix_saved": "Branch prefix has been saved.",
|
||||
"branch_prefix_saved_multiple": "Branch prefix has been saved for {{count}} branches.",
|
||||
"affected_branches": "Affected branches ({{count}}):"
|
||||
"branch_prefix_saved": "Branch prefix has been saved."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Bulk actions",
|
||||
@@ -682,7 +679,6 @@
|
||||
"open_note_externally": "Open note externally",
|
||||
"open_note_externally_title": "File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.",
|
||||
"open_note_custom": "Open note custom",
|
||||
"open_note_on_server": "Open note on server",
|
||||
"import_files": "Import files",
|
||||
"export_note": "Export note",
|
||||
"delete_note": "Delete note",
|
||||
@@ -996,7 +992,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Showing protected note requires entering your password:",
|
||||
"start_session_button": "Start protected session",
|
||||
"start_session_button": "Start protected session <kbd>enter</kbd>",
|
||||
"started": "Protected session has been started.",
|
||||
"wrong_password": "Wrong password.",
|
||||
"protecting-finished-successfully": "Protecting finished successfully.",
|
||||
@@ -1112,7 +1108,9 @@
|
||||
"default_description": "Trilium by default limits max content width to improve readability for maximized screens on wide screens.",
|
||||
"max_width_label": "Max content width",
|
||||
"max_width_unit": "pixels",
|
||||
"centerContent": "Keep content centered"
|
||||
"apply_changes_description": "To apply content width changes, click on",
|
||||
"reload_button": "reload frontend",
|
||||
"reload_description": "changes from appearance options"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Native Title Bar (requires app restart)",
|
||||
@@ -1638,12 +1636,6 @@
|
||||
"shared_locally": "This note is shared locally on {{- link}}.",
|
||||
"help_link": "For help visit <a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">wiki</a>."
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "Currently viewing a read-only note.",
|
||||
"auto-read-only-note": "This note is shown in a read-only mode for faster loading.",
|
||||
"auto-read-only-learn-more": "Learn more",
|
||||
"edit-note": "Edit note"
|
||||
},
|
||||
"note_types": {
|
||||
"text": "Text",
|
||||
"code": "Code",
|
||||
@@ -2042,9 +2034,6 @@
|
||||
"start-presentation": "Start presentation",
|
||||
"slide-overview": "Toggle an overview of the slides"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Delete note..."
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Tree: {{name}}",
|
||||
"export_note_title": "Export Note",
|
||||
|
||||
@@ -39,10 +39,7 @@
|
||||
"help_on_tree_prefix": "Ayuda sobre el prefijo del árbol",
|
||||
"prefix": "Prefijo: ",
|
||||
"save": "Guardar",
|
||||
"branch_prefix_saved": "Se ha guardado el prefijo de rama.",
|
||||
"edit_branch_prefix_multiple": "Editar prefijo de rama para {{count}} ramas",
|
||||
"branch_prefix_saved_multiple": "El prefijo de rama se ha guardado para {{count}} ramas.",
|
||||
"affected_branches": "Ramas afectadas ({{count}}):"
|
||||
"branch_prefix_saved": "Se ha guardado el prefijo de rama."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Acciones en bloque",
|
||||
@@ -995,7 +992,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Para mostrar una nota protegida es necesario ingresar su contraseña:",
|
||||
"start_session_button": "Iniciar sesión protegida",
|
||||
"start_session_button": "Iniciar sesión protegida <kbd>Enter</kbd>",
|
||||
"started": "La sesión protegida ha iniciado.",
|
||||
"wrong_password": "Contraseña incorrecta.",
|
||||
"protecting-finished-successfully": "La protección finalizó exitosamente.",
|
||||
@@ -1111,7 +1108,9 @@
|
||||
"default_description": "Trilium limita de forma predeterminada el ancho máximo del contenido para mejorar la legibilidad de ventanas maximizadas en pantallas anchas.",
|
||||
"max_width_label": "Ancho máximo del contenido en píxeles",
|
||||
"max_width_unit": "píxeles",
|
||||
"centerContent": "Mantener el contenido centrado"
|
||||
"apply_changes_description": "Para aplicar cambios en el ancho del contenido, haga clic en",
|
||||
"reload_button": "recargar la interfaz",
|
||||
"reload_description": "cambios desde las opciones de apariencia"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Barra de título nativa (requiere reiniciar la aplicación)",
|
||||
@@ -1593,7 +1592,7 @@
|
||||
"tree-context-menu": {
|
||||
"open-in-a-new-tab": "Abrir en nueva pestaña",
|
||||
"open-in-a-new-split": "Abrir en nueva división",
|
||||
"insert-note-after": "Insertar nota contigua",
|
||||
"insert-note-after": "Insertar nota después de",
|
||||
"insert-child-note": "Insertar subnota",
|
||||
"delete": "Eliminar",
|
||||
"search-in-subtree": "Buscar en subárbol",
|
||||
@@ -2083,14 +2082,5 @@
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "No se puede mostrar contenido debido a un error."
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "Actualmente, está viendo una nota de solo lectura.",
|
||||
"auto-read-only-note": "Esta nota se muestra en modo de solo lectura para una carga más rápida.",
|
||||
"auto-read-only-learn-more": "Para saber más",
|
||||
"edit-note": "Editar nota"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Eliminar nota..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,7 +991,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "L'affichage de la note protégée nécessite la saisie de votre mot de passe :",
|
||||
"start_session_button": "Démarrer une session protégée",
|
||||
"start_session_button": "Démarrer une session protégée <kbd>Entrée</kbd>",
|
||||
"started": "La session protégée a démarré.",
|
||||
"wrong_password": "Mot de passe incorrect.",
|
||||
"protecting-finished-successfully": "La protection de la note s'est terminée avec succès.",
|
||||
@@ -1106,6 +1106,9 @@
|
||||
"title": "Largeur du contenu",
|
||||
"default_description": "Trilium limite par défaut la largeur maximale du contenu pour améliorer la lisibilité sur des écrans larges.",
|
||||
"max_width_label": "Largeur maximale du contenu en pixels",
|
||||
"apply_changes_description": "Pour appliquer les modifications de largeur du contenu, cliquez sur",
|
||||
"reload_button": "recharger l'interface",
|
||||
"reload_description": "changements par rapport aux options d'apparence",
|
||||
"max_width_unit": "Pixels"
|
||||
},
|
||||
"native_title_bar": {
|
||||
|
||||
@@ -39,10 +39,7 @@
|
||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||
"prefix": "Prefisso: ",
|
||||
"save": "Salva",
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato.",
|
||||
"edit_branch_prefix_multiple": "Modifica prefisso ramo per {{count}} rami",
|
||||
"branch_prefix_saved_multiple": "Il prefisso del ramo è stato salvato per {{count}} rami.",
|
||||
"affected_branches": "Rami interessati ({{count}}):"
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Azioni massive",
|
||||
@@ -112,8 +109,7 @@
|
||||
"export_type_single": "Solo questa nota, senza le sottostanti",
|
||||
"format_opml": "OPML - formato per scambio informazioni outline. Formattazione, immagini e files non sono inclusi.",
|
||||
"opml_version_1": "OPML v.1.0 - solo testo semplice",
|
||||
"opml_version_2": "OPML v2.0 - supporta anche HTML",
|
||||
"share-format": "HTML per la pubblicazione sul web - utilizza lo stesso tema utilizzato per le note condivise, ma può essere pubblicato come sito web statico."
|
||||
"opml_version_2": "OPML v2.0 - supporta anche HTML"
|
||||
},
|
||||
"password_not_set": {
|
||||
"body1": "Le note protette sono crittografate utilizzando una password utente, ma la password non è stata ancora impostata.",
|
||||
@@ -1502,7 +1498,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Per visualizzare la nota protetta è necessario inserire la password:",
|
||||
"start_session_button": "Avvia sessione protetta",
|
||||
"start_session_button": "Avvia sessione protetta <kbd>invio</kbd>",
|
||||
"started": "La sessione protetta è stata avviata.",
|
||||
"wrong_password": "Password errata.",
|
||||
"protecting-finished-successfully": "Protezione completata con successo.",
|
||||
@@ -1574,7 +1570,9 @@
|
||||
"default_description": "Per impostazione predefinita, Trilium limita la larghezza massima del contenuto per migliorare la leggibilità sugli schermi più grandi.",
|
||||
"max_width_label": "Larghezza massima del contenuto",
|
||||
"max_width_unit": "pixel",
|
||||
"centerContent": "Mantieni il contenuto centrato"
|
||||
"apply_changes_description": "Per applicare le modifiche alla larghezza del contenuto, fare clic su",
|
||||
"reload_button": "ricarica frontend",
|
||||
"reload_description": "modifiche dalle opzioni di aspetto"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Barra del titolo nativa (richiede il riavvio dell'app)",
|
||||
@@ -2084,14 +2082,5 @@
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Impossibile mostrare il contenuto a causa di un errore."
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "Stai visualizzando una nota di sola lettura.",
|
||||
"auto-read-only-note": "Questa nota viene visualizzata in modalità di sola lettura per un caricamento più rapido.",
|
||||
"auto-read-only-learn-more": "Per saperne di più",
|
||||
"edit-note": "Modifica nota"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Eliminazione nota..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@
|
||||
"edit_branch_prefix": "ブランチ接頭辞の編集",
|
||||
"help_on_tree_prefix": "ツリー接頭辞に関するヘルプ",
|
||||
"prefix": "接頭辞: ",
|
||||
"branch_prefix_saved": "ブランチの接頭辞が保存されました。",
|
||||
"edit_branch_prefix_multiple": "{{count}} ブランチのブランチ接頭辞を編集",
|
||||
"branch_prefix_saved_multiple": "{{count}} 個のブランチのブランチ接頭辞が保存されました。",
|
||||
"affected_branches": "影響を受けるブランチ {{count}}:"
|
||||
"branch_prefix_saved": "ブランチの接頭辞が保存されました。"
|
||||
},
|
||||
"global_menu": {
|
||||
"menu": "メニュー",
|
||||
@@ -833,11 +830,13 @@
|
||||
"theme_defined": "テーマが定義されました"
|
||||
},
|
||||
"max_content_width": {
|
||||
"reload_button": "フロントエンドをリロード",
|
||||
"title": "コンテンツ幅",
|
||||
"default_description": "Triliumは、ワイドスクリーンで最大化された画面での可読性を向上させるために、デフォルトでコンテンツの最大幅を制限しています。",
|
||||
"max_width_label": "最大コンテンツ幅",
|
||||
"max_width_unit": "ピクセル",
|
||||
"centerContent": "コンテンツを中央に配置"
|
||||
"apply_changes_description": "コンテンツ幅の変更を適用するには、クリックしてください",
|
||||
"reload_description": "外観設定から変更"
|
||||
},
|
||||
"theme": {
|
||||
"title": "アプリのテーマ",
|
||||
@@ -1784,7 +1783,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "保護されたノートを表示するにはパスワードを入力する必要があります:",
|
||||
"start_session_button": "保護されたセッションを開始",
|
||||
"start_session_button": "保護されたセッションを開始 <kbd>enter</kbd>",
|
||||
"started": "保護されたセッションが開始されました。",
|
||||
"wrong_password": "パスワードが間違っています。",
|
||||
"protecting-finished-successfully": "保護が正常に完了しました。",
|
||||
@@ -2080,14 +2079,5 @@
|
||||
"edit-slide": "このスライドを編集",
|
||||
"start-presentation": "プレゼンテーションを開始",
|
||||
"slide-overview": "スライドの概要を切り替え"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "ノートを削除..."
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "現在、読み取り専用のノートを表示しています。",
|
||||
"auto-read-only-note": "このノートは読み込みを高速化するために読み取り専用モードで表示されています。",
|
||||
"auto-read-only-learn-more": "さらに詳しく",
|
||||
"edit-note": "ノートを編集"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1464,7 +1464,10 @@
|
||||
"title": "Szerokość zawartości",
|
||||
"default_description": "Trilium domyślnie ogranicza maksymalną szerokość zawartości, aby poprawić czytelność na zmaksymalizowanych ekranach o dużej szerokości.",
|
||||
"max_width_label": "Maksymalna szerokość zawartości",
|
||||
"max_width_unit": "piksele"
|
||||
"max_width_unit": "piksele",
|
||||
"apply_changes_description": "Aby zastosować zmiany szerokości zawartości, kliknij na",
|
||||
"reload_button": "przeładuj frontend",
|
||||
"reload_description": "zmiany z opcji wyglądu"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Natywny pasek tytułu (wymaga ponownego uruchomienia aplikacji)",
|
||||
|
||||
@@ -967,7 +967,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "É necessário digitar a sua palavra-passe para mostar notas protegidas:",
|
||||
"start_session_button": "Iniciar sessão protegida",
|
||||
"start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>",
|
||||
"started": "A sessão protegida foi iniciada.",
|
||||
"wrong_password": "Palavra-passe incorreta.",
|
||||
"protecting-finished-successfully": "A proteção foi finalizada com sucesso.",
|
||||
@@ -1082,7 +1082,10 @@
|
||||
"title": "Largura do Conteúdo",
|
||||
"default_description": "Por padrão, o Trilium limita a largura máxima do conteúdo para melhorar a legibilidade em janelas maximizadas em ecrãs largos.",
|
||||
"max_width_label": "Largura máxima do conteúdo",
|
||||
"max_width_unit": "pixels"
|
||||
"max_width_unit": "pixels",
|
||||
"apply_changes_description": "Para aplicar as alterações de largura do conteúdo, clique em",
|
||||
"reload_button": "recarregar frontend",
|
||||
"reload_description": "alterações de opções de aparência"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Barra de Título Nativa (requer recarregar a app)",
|
||||
|
||||
@@ -1218,7 +1218,7 @@
|
||||
"unprotecting-in-progress-count": "Remoções de proteção em andamento: {{count}}",
|
||||
"protecting-title": "Estado da proteção",
|
||||
"unprotecting-title": "Estado da remoção de proteção",
|
||||
"start_session_button": "Iniciar sessão protegida"
|
||||
"start_session_button": "Iniciar sessão protegida <kbd>enter</kbd>"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir em nova aba",
|
||||
@@ -1304,6 +1304,9 @@
|
||||
"title": "Largura do Conteúdo",
|
||||
"max_width_label": "Largura máxima do conteúdo",
|
||||
"max_width_unit": "pixels",
|
||||
"apply_changes_description": "Para aplicar as alterações de largura do conteúdo, clique em",
|
||||
"reload_button": "recarregar frontend",
|
||||
"reload_description": "alterações de opções de aparência",
|
||||
"default_description": "Por padrão, o Trilium limita a largura máxima do conteúdo para melhorar a legibilidade em janelas maximizadas em telas wide."
|
||||
},
|
||||
"native_title_bar": {
|
||||
|
||||
@@ -796,9 +796,12 @@
|
||||
"modal_body_text": "Din cauza limitărilor la nivel de navigator, nu este posibilă citirea clipboard-ului din JavaScript. Inserați Markdown-ul pentru a-l importa în caseta de mai jos și dați clic pe butonul Import"
|
||||
},
|
||||
"max_content_width": {
|
||||
"apply_changes_description": "Pentru a aplica schimbările de lățime a conținutului, dați click pe",
|
||||
"default_description": "În mod implicit Trilium limitează lățimea conținutului pentru a îmbunătăți lizibilitatea pentru ferestrele maximizate pe ecrane late.",
|
||||
"max_width_label": "Lungimea maximă a conținutului",
|
||||
"max_width_unit": "pixeli",
|
||||
"reload_button": "reîncarcă interfața",
|
||||
"reload_description": "schimbări din opțiunile de afișare",
|
||||
"title": "Lățime conținut"
|
||||
},
|
||||
"mobile_detail_menu": {
|
||||
@@ -982,7 +985,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Afișarea notițelor protejate necesită introducerea parolei:",
|
||||
"start_session_button": "Deschide sesiunea protejată",
|
||||
"start_session_button": "Deschide sesiunea protejată <kbd>enter</kbd>",
|
||||
"started": "Sesiunea protejată este activă.",
|
||||
"wrong_password": "Parolă greșită.",
|
||||
"protecting-finished-successfully": "Protejarea a avut succes.",
|
||||
|
||||
@@ -1203,8 +1203,11 @@
|
||||
"max_content_width": {
|
||||
"max_width_unit": "пикселей",
|
||||
"title": "Ширина контентной области",
|
||||
"reload_button": "перезагрузить интерфейс",
|
||||
"default_description": "Trilium по умолчанию ограничивает максимальную ширину контента, чтобы улучшить читаемость на широких экранах.",
|
||||
"max_width_label": "Максимальная ширина контентной области"
|
||||
"max_width_label": "Максимальная ширина контентной области",
|
||||
"apply_changes_description": "Чтобы применить изменения, нажмите на",
|
||||
"reload_description": "изменения в параметрах внешнего вида"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"enabled": "включено",
|
||||
@@ -1685,7 +1688,7 @@
|
||||
"unprotecting-title": "Статус снятия защиты",
|
||||
"protecting-finished-successfully": "Защита успешно завершена.",
|
||||
"unprotecting-finished-successfully": "Снятие защиты успешно завершено.",
|
||||
"start_session_button": "Начать защищенный сеанс",
|
||||
"start_session_button": "Начать защищенный сеанс <kbd>enter</kbd>",
|
||||
"protecting-in-progress": "Защита в процессе: {{count}}",
|
||||
"unprotecting-in-progress-count": "Снятие защиты в процессе: {{count}}",
|
||||
"started": "Защищенный сеанс запущен.",
|
||||
|
||||
@@ -39,10 +39,7 @@
|
||||
"help_on_tree_prefix": "有關樹前綴的說明",
|
||||
"prefix": "前綴: ",
|
||||
"save": "儲存",
|
||||
"branch_prefix_saved": "已儲存分支前綴。",
|
||||
"edit_branch_prefix_multiple": "編輯 {{count}} 個分支的前綴",
|
||||
"branch_prefix_saved_multiple": "已為 {{count}} 個分支儲存分支前綴。",
|
||||
"affected_branches": "受影響的分支 ({{count}}):"
|
||||
"branch_prefix_saved": "已儲存分支前綴。"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "批次操作",
|
||||
@@ -992,7 +989,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
|
||||
"start_session_button": "開始受保護的作業階段",
|
||||
"start_session_button": "開始受保護的作業階段 <kbd>Enter</kbd>",
|
||||
"started": "已啟動受保護的作業階段。",
|
||||
"wrong_password": "密碼錯誤。",
|
||||
"protecting-finished-successfully": "已成功完成保護操作。",
|
||||
@@ -1107,6 +1104,9 @@
|
||||
"title": "內容寬度",
|
||||
"default_description": "Trilium 預設會限制內容的最大寬度以提高在寬螢幕中全螢幕時的可讀性。",
|
||||
"max_width_label": "內容最大寬度(像素)",
|
||||
"apply_changes_description": "要套用內容寬度更改,請點擊",
|
||||
"reload_button": "重新載入前端",
|
||||
"reload_description": "來自外觀選項的更改",
|
||||
"max_width_unit": "像素"
|
||||
},
|
||||
"native_title_bar": {
|
||||
@@ -2079,8 +2079,5 @@
|
||||
"edit-slide": "編輯此投影片",
|
||||
"start-presentation": "開始簡報",
|
||||
"slide-overview": "切換投影片概覽"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "刪除筆記…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,7 +1089,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:",
|
||||
"start_session_button": "Розпочати захищений сеанс",
|
||||
"start_session_button": "Розпочати захищений сеанс <kbd>enter</kbd>",
|
||||
"started": "Захищений сеанс розпочато.",
|
||||
"wrong_password": "Неправильний пароль.",
|
||||
"protecting-finished-successfully": "Захист успішно завершено.",
|
||||
@@ -1204,7 +1204,10 @@
|
||||
"title": "Ширина вмісту",
|
||||
"default_description": "Trilium за замовчуванням обмежує максимальну ширину вмісту, щоб поліпшити читабельність на широкоформатних екранах у режимі максимального розширення.",
|
||||
"max_width_label": "Максимальна ширина вмісту",
|
||||
"max_width_unit": "пікселів"
|
||||
"max_width_unit": "пікселів",
|
||||
"apply_changes_description": "Щоб застосувати зміни ширини вмісту, натисніть на",
|
||||
"reload_button": "перезавантажити інтерфейс",
|
||||
"reload_description": "зміни в параметрах зовнішнього вигляду"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Нативний рядок заголовка (потрібен перезапуск)",
|
||||
|
||||
26
apps/client/src/types-fancytree.d.ts
vendored
26
apps/client/src/types-fancytree.d.ts
vendored
@@ -215,30 +215,6 @@ declare namespace Fancytree {
|
||||
enableUpdate(enabled: boolean): void;
|
||||
}
|
||||
|
||||
interface FancytreeNodeData {
|
||||
noteId: string;
|
||||
parentNoteId: string;
|
||||
branchId: string;
|
||||
isProtected: boolean;
|
||||
noteType: NoteType;
|
||||
}
|
||||
|
||||
interface FancytreeNewNode extends FancytreeNodeData {
|
||||
title: string;
|
||||
extraClasses: string;
|
||||
icon: string;
|
||||
refKey: string;
|
||||
/** True if this node is loaded on demand, i.e. on first expansion. */
|
||||
lazy: boolean;
|
||||
/** Folder nodes have different default icons and click behavior. Note: Also non-folders may have children. */
|
||||
folder: boolean;
|
||||
/** Use isExpanded(), setExpanded() to access this property. */
|
||||
expanded: boolean;
|
||||
/** Node id (must be unique inside the tree) */
|
||||
key: string;
|
||||
children?: FancytreeNewNode[];
|
||||
}
|
||||
|
||||
/** A FancytreeNode represents the hierarchical data model and operations. */
|
||||
interface FancytreeNode {
|
||||
// #region Properties
|
||||
@@ -251,7 +227,7 @@ declare namespace Fancytree {
|
||||
/** Display name (may contain HTML) */
|
||||
title: string;
|
||||
/** Contains all extra data that was passed on node creation */
|
||||
data: FancytreeNodeData;
|
||||
data: any;
|
||||
/** Array of child nodes. For lazy nodes, null or undefined means 'not yet loaded'. Use an empty array to define a node that has no children. */
|
||||
children: FancytreeNode[];
|
||||
/** Use isExpanded(), setExpanded() to access this property. */
|
||||
|
||||
11
apps/client/src/types-lib.d.ts
vendored
11
apps/client/src/types-lib.d.ts
vendored
@@ -60,14 +60,3 @@ declare global {
|
||||
windowControlsOverlay?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "preact" {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
webview: {
|
||||
src: string;
|
||||
class: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/client/src/types.d.ts
vendored
8
apps/client/src/types.d.ts
vendored
@@ -118,17 +118,11 @@ declare global {
|
||||
filterKey: (e: { altKey: boolean }, dx: number, dy: number, dz: number) => void;
|
||||
});
|
||||
|
||||
interface PanZoomTransform {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
interface PanZoom {
|
||||
zoomTo(x: number, y: number, scale: number);
|
||||
moveTo(x: number, y: number);
|
||||
on(event: string, callback: () => void);
|
||||
getTransform(): PanZoomTransform;
|
||||
getTransform(): unknown;
|
||||
dispose(): void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,24 +23,6 @@ export class CssVarReader {
|
||||
return (!isNaN(number.valueOf()) ? number.valueOf() : defaultValue)
|
||||
}
|
||||
|
||||
asBoolean(defaultValue?: boolean) {
|
||||
let value = this.value.toLocaleLowerCase().trim();
|
||||
let result: boolean | undefined;
|
||||
|
||||
switch (value) {
|
||||
case "true":
|
||||
case "1":
|
||||
result = true;
|
||||
break;
|
||||
case "false":
|
||||
case "0":
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
|
||||
return (result !== undefined) ? result : defaultValue;
|
||||
}
|
||||
|
||||
asEnum<T>(enumType: T, defaultValue?: T[keyof T]): T[keyof T] | undefined {
|
||||
let result: T[keyof T] | undefined;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.floating-buttons-children,
|
||||
.show-floating-buttons {
|
||||
position: absolute;
|
||||
top: var(--floating-buttons-vert-offset, 14px);
|
||||
top: var(--floating-buttons-vert-offset, 10px);
|
||||
inset-inline-end: var(--floating-buttons-horiz-offset, 10px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "i18next";
|
||||
import "./FloatingButtons.css";
|
||||
import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useTriliumEvent } from "./react/hooks";
|
||||
import { useNoteContext, useNoteLabel, useNoteLabelBoolean } from "./react/hooks";
|
||||
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { ParentComponent } from "./react/react_utils";
|
||||
import { EventData, EventNames } from "../components/app_context";
|
||||
@@ -20,7 +20,6 @@ interface FloatingButtonsProps {
|
||||
* properly handle rounded corners, as defined by the --border-radius CSS variable.
|
||||
*/
|
||||
export default function FloatingButtons({ items }: FloatingButtonsProps) {
|
||||
const [ top, setTop ] = useState(0);
|
||||
const { note, noteContext } = useNoteContext();
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||
@@ -48,14 +47,8 @@ export default function FloatingButtons({ items }: FloatingButtonsProps) {
|
||||
const [ visible, setVisible ] = useState(true);
|
||||
useEffect(() => setVisible(true), [ note ]);
|
||||
|
||||
useTriliumEvent("contentSafeMarginChanged", (e) => {
|
||||
if (e.noteContext === noteContext) {
|
||||
setTop(e.top);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="floating-buttons no-print" style={{top}}>
|
||||
<div className="floating-buttons no-print">
|
||||
<div className={`floating-buttons-children ${!visible ? "temporarily-hidden" : ""}`}>
|
||||
{context && items.map((Component) => (
|
||||
<Component {...context} />
|
||||
|
||||
@@ -4,7 +4,7 @@ import Component from "../components/component";
|
||||
import NoteContext from "../components/note_context";
|
||||
import FNote from "../entities/fnote";
|
||||
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
|
||||
import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
|
||||
import { useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
|
||||
import server from "../services/server";
|
||||
@@ -13,6 +13,8 @@ import toast from "../services/toast";
|
||||
import { t } from "../services/i18n";
|
||||
import { copyImageReferenceToClipboard } from "../services/image";
|
||||
import tree from "../services/tree";
|
||||
import protected_session_holder from "../services/protected_session_holder";
|
||||
import options from "../services/options";
|
||||
import { getHelpUrlForNote } from "../services/in_app_help";
|
||||
import froca from "../services/froca";
|
||||
import NoteLink from "./react/NoteLink";
|
||||
@@ -21,7 +23,7 @@ import { ViewTypeOptions } from "./collections/interface";
|
||||
|
||||
export interface FloatingButtonContext {
|
||||
parentComponent: Component;
|
||||
note: FNote;
|
||||
note: FNote;
|
||||
noteContext: NoteContext;
|
||||
isDefaultViewMode: boolean;
|
||||
isReadOnly: boolean;
|
||||
@@ -63,11 +65,11 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
|
||||
EditButton,
|
||||
RelationMapButtons,
|
||||
ExportImageButtons,
|
||||
Backlinks
|
||||
Backlinks
|
||||
]
|
||||
|
||||
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
|
||||
const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("backend_log.refresh")}
|
||||
icon="bx bx-refresh"
|
||||
@@ -82,14 +84,14 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
|
||||
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
|
||||
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
|
||||
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
|
||||
/>
|
||||
}
|
||||
|
||||
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
@@ -99,26 +101,48 @@ function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingBut
|
||||
/>
|
||||
}
|
||||
|
||||
function EditButton({ note, noteContext }: FloatingButtonContext) {
|
||||
const [animationClass, setAnimationClass] = useState("");
|
||||
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);
|
||||
|
||||
const isReadOnlyInfoBarDismissed = false; // TODO
|
||||
function EditButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ animationClass, setAnimationClass ] = useState("");
|
||||
const [ isEnabled, setIsEnabled ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReadOnly) {
|
||||
noteContext.isReadOnly().then(isReadOnly => {
|
||||
setIsEnabled(
|
||||
isDefaultViewMode
|
||||
&& (!note.isProtected || protected_session_holder.isProtectedSessionAvailable())
|
||||
&& !options.is("databaseReadonly")
|
||||
&& isReadOnly
|
||||
);
|
||||
});
|
||||
}, [ note ]);
|
||||
|
||||
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
|
||||
if (noteContext?.ntxId === eventNoteContext.ntxId) {
|
||||
setIsEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
// make the edit button stand out on the first display, otherwise
|
||||
// it's difficult to notice that the note is readonly
|
||||
useEffect(() => {
|
||||
if (isEnabled) {
|
||||
setAnimationClass("bx-tada bx-lg");
|
||||
setTimeout(() => {
|
||||
setAnimationClass("");
|
||||
}, 1700);
|
||||
}
|
||||
}, [ isReadOnly ]);
|
||||
}, [ isEnabled ]);
|
||||
|
||||
return !!isReadOnly && isReadOnlyInfoBarDismissed && <FloatingButton
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("edit_button.edit_this_note")}
|
||||
icon="bx bx-pencil"
|
||||
className={animationClass}
|
||||
onClick={() => enableEditing()}
|
||||
onClick={() => {
|
||||
if (noteContext.viewScope) {
|
||||
noteContext.viewScope.readOnlyTemporarilyDisabled = true;
|
||||
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -240,7 +264,7 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
|
||||
|
||||
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
|
||||
const isEnabled = ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "")
|
||||
const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
|
||||
&& note?.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && (
|
||||
@@ -301,7 +325,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
let [ backlinkCount, setBacklinkCount ] = useState(0);
|
||||
let [ popupOpen, setPopupOpen ] = useState(false);
|
||||
const backlinksContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefaultViewMode) return;
|
||||
|
||||
@@ -314,7 +338,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const { windowHeight } = useWindowSize();
|
||||
useLayoutEffect(() => {
|
||||
const el = backlinksContainerRef.current;
|
||||
if (popupOpen && el) {
|
||||
if (popupOpen && el) {
|
||||
const box = el.getBoundingClientRect();
|
||||
const maxHeight = windowHeight - box.top - 10;
|
||||
el.style.maxHeight = `${maxHeight}px`;
|
||||
@@ -350,7 +374,7 @@ function BacklinksList({ noteId }: { noteId: string }) {
|
||||
.filter(bl => "noteId" in bl)
|
||||
.map((bl) => bl.noteId);
|
||||
await froca.getNotes(noteIds);
|
||||
setBacklinks(backlinks);
|
||||
setBacklinks(backlinks);
|
||||
});
|
||||
}, [ noteId ]);
|
||||
|
||||
@@ -371,4 +395,4 @@ function BacklinksList({ noteId }: { noteId: string }) {
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.component.note-detail {
|
||||
max-width: var(--max-content-width); /* Inherited from .note-split */
|
||||
font-family: var(--detail-font-family);
|
||||
font-size: var(--detail-font-size);
|
||||
contain: none;
|
||||
}
|
||||
|
||||
body.prefers-centered-content .note-detail {
|
||||
/* Horizontally center the widget in its parent when the "Keep content centered" option is on */
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.note-detail > * {
|
||||
contain: none;
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import { useNoteContext, useTriliumEvent } from "./react/hooks"
|
||||
import FNote from "../entities/fnote";
|
||||
import protected_session_holder from "../services/protected_session_holder";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import NoteContext from "../components/note_context";
|
||||
import { isValidElement, VNode } from "preact";
|
||||
import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||
import "./NoteDetail.css";
|
||||
import attributes from "../services/attributes";
|
||||
import { ExtendedNoteType, TYPE_MAPPINGS, TypeWidget } from "./note_types";
|
||||
import { dynamicRequire, isElectron, isMobile } from "../services/utils";
|
||||
import toast from "../services/toast.js";
|
||||
import { t } from "../services/i18n";
|
||||
|
||||
/**
|
||||
* The note detail is in charge of rendering the content of a note, by determining its type (e.g. text, code) and using the appropriate view widget.
|
||||
*
|
||||
* Apart from that, it:
|
||||
* - Applies a full-height style depending on the content type (e.g. canvas notes).
|
||||
* - Focuses the content when switching tabs.
|
||||
* - Caches the note type elements based on what the user has accessed, in order to quickly load it again.
|
||||
* - Fixes the tree for launch bar configurations on mobile.
|
||||
* - Provides scripting events such as obtaining the active note detail widget, or note type widget.
|
||||
* - Printing and exporting to PDF.
|
||||
*/
|
||||
export default function NoteDetail() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { note, type, mime, noteContext, parentComponent } = useNoteInfo();
|
||||
const { ntxId, viewScope } = noteContext ?? {};
|
||||
const isFullHeight = checkFullHeight(noteContext, type);
|
||||
const noteTypesToRender = useRef<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({});
|
||||
const [ activeNoteType, setActiveNoteType ] = useState<ExtendedNoteType>();
|
||||
|
||||
const props: TypeWidgetProps = {
|
||||
note: note!,
|
||||
viewScope,
|
||||
ntxId,
|
||||
parentComponent,
|
||||
noteContext
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!type) return;
|
||||
|
||||
if (!noteTypesToRender.current[type]) {
|
||||
getCorrespondingWidget(type).then((el) => {
|
||||
if (!el) return;
|
||||
noteTypesToRender.current[type] = el;
|
||||
setActiveNoteType(type);
|
||||
});
|
||||
} else {
|
||||
setActiveNoteType(type);
|
||||
}
|
||||
}, [ note, viewScope, type ]);
|
||||
|
||||
// Detect note type changes.
|
||||
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
|
||||
if (!note) return;
|
||||
|
||||
// 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 (note.noteId && loadResults.isNoteContentReloaded(note.noteId, parentComponent.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
|
||||
parentComponent.handleEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||
} else if (note.noteId
|
||||
&& loadResults.isNoteReloaded(note.noteId, parentComponent.componentId)
|
||||
&& (type !== (await getWidgetType(note, noteContext)) || mime !== note?.mime)) {
|
||||
// this needs to have a triggerEvent so that e.g., note type (not in the component subtree) is updated
|
||||
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||
} else {
|
||||
const attrs = loadResults.getAttributeRows();
|
||||
|
||||
const label = attrs.find(
|
||||
(attr) =>
|
||||
attr.type === "label" &&
|
||||
["readOnly", "autoReadOnlyDisabled", "cssClass", "displayRelations", "hideRelations"].includes(attr.name ?? "") &&
|
||||
attributes.isAffecting(attr, note)
|
||||
);
|
||||
|
||||
const relation = attrs.find((attr) => attr.type === "relation" && ["template", "inherit", "renderNote"]
|
||||
.includes(attr.name ?? "") && attributes.isAffecting(attr, note));
|
||||
|
||||
if (note.noteId && (label || relation)) {
|
||||
// probably incorrect event
|
||||
// calling this.refresh() is not enough since the event needs to be propagated to children as well
|
||||
parentComponent.triggerEvent("noteTypeMimeChanged", { noteId: note.noteId });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically focus the editor.
|
||||
useTriliumEvent("activeNoteChanged", () => {
|
||||
// Restore focus to the editor when switching tabs, but only if the note tree is not already focused.
|
||||
if (!document.activeElement?.classList.contains("fancytree-title")) {
|
||||
parentComponent.triggerCommand("focusOnDetail", { ntxId });
|
||||
}
|
||||
});
|
||||
|
||||
// Fixed tree for launch bar config on mobile.
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
const hasFixedTree = noteContext?.hoistedNoteId === "_lbMobileRoot";
|
||||
document.body.classList.toggle("force-fixed-tree", hasFixedTree);
|
||||
}, [ note ]);
|
||||
|
||||
// Handle toast notifications.
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
const listener = () => {
|
||||
toast.closePersistent("printing");
|
||||
};
|
||||
ipcRenderer.on("print-done", listener);
|
||||
return () => ipcRenderer.off("print-done", listener);
|
||||
}, []);
|
||||
|
||||
useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => {
|
||||
if (!noteContext?.isActive()) return;
|
||||
callback(parentComponent);
|
||||
});
|
||||
|
||||
useTriliumEvent("executeWithTypeWidget", ({ resolve, ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId || !activeNoteType || !containerRef.current) return;
|
||||
|
||||
const classNameToSearch = TYPE_MAPPINGS[activeNoteType].className;
|
||||
const componentEl = containerRef.current.querySelector<HTMLElement>(`.${classNameToSearch}`);
|
||||
if (!componentEl) return;
|
||||
|
||||
const component = glob.getComponentByEl(componentEl);
|
||||
resolve(component);
|
||||
});
|
||||
|
||||
useTriliumEvent("printActiveNote", () => {
|
||||
if (!noteContext?.isActive() || !note) return;
|
||||
|
||||
toast.showPersistent({
|
||||
icon: "bx bx-loader-circle bx-spin",
|
||||
message: t("note_detail.printing"),
|
||||
id: "printing"
|
||||
});
|
||||
|
||||
if (isElectron()) {
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("print-note", {
|
||||
notePath: noteContext.notePath
|
||||
});
|
||||
} else {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `?print#${noteContext.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);
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useTriliumEvent("exportAsPdf", () => {
|
||||
if (!noteContext?.isActive() || !note) return;
|
||||
toast.showPersistent({
|
||||
icon: "bx bx-loader-circle bx-spin",
|
||||
message: t("note_detail.printing_pdf"),
|
||||
id: "printing"
|
||||
});
|
||||
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("export-as-pdf", {
|
||||
title: note.title,
|
||||
notePath: noteContext.notePath,
|
||||
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
||||
landscape: note.hasAttribute("label", "printLandscape")
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`note-detail ${isFullHeight ? "full-height" : ""}`}
|
||||
>
|
||||
{Object.entries(noteTypesToRender.current).map(([ itemType, Element ]) => {
|
||||
return <NoteDetailWrapper
|
||||
Element={Element}
|
||||
key={itemType}
|
||||
type={itemType as ExtendedNoteType}
|
||||
isVisible={type === itemType}
|
||||
isFullHeight={isFullHeight}
|
||||
props={props}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a single note type widget, in order to keep it in the DOM even after the user has switched away to another note type. This allows faster loading of the same note type again. The properties are cached, so that they are updated only
|
||||
* while the widget is visible, to avoid rendering in the background. When not visible, the DOM element is simply hidden.
|
||||
*/
|
||||
function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: { Element: (props: TypeWidgetProps) => VNode, type: ExtendedNoteType, isVisible: boolean, isFullHeight: boolean, props: TypeWidgetProps }) {
|
||||
const [ cachedProps, setCachedProps ] = useState(props);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setCachedProps(props);
|
||||
} else {
|
||||
// Do nothing, keep the old props.
|
||||
}
|
||||
}, [ props, isVisible ]);
|
||||
|
||||
const typeMapping = TYPE_MAPPINGS[type];
|
||||
return (
|
||||
<div
|
||||
className={`${typeMapping.className} ${typeMapping.printable ? "note-detail-printable" : ""} ${isVisible ? "visible" : "hidden-ext"}`}
|
||||
style={{
|
||||
height: isFullHeight ? "100%" : ""
|
||||
}}
|
||||
>
|
||||
{ <Element {...cachedProps} /> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Manages both note changes and changes to the widget type, which are asynchronous. */
|
||||
function useNoteInfo() {
|
||||
const { note: actualNote, noteContext, parentComponent } = useNoteContext();
|
||||
const [ note, setNote ] = useState<FNote | null | undefined>();
|
||||
const [ type, setType ] = useState<ExtendedNoteType>();
|
||||
const [ mime, setMime ] = useState<string>();
|
||||
|
||||
function refresh() {
|
||||
getWidgetType(actualNote, noteContext).then(type => {
|
||||
setNote(actualNote);
|
||||
setType(type);
|
||||
setMime(actualNote?.mime);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(refresh, [ actualNote, noteContext, noteContext?.viewScope ]);
|
||||
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
|
||||
if (eventNoteContext?.ntxId !== noteContext?.ntxId) return;
|
||||
refresh();
|
||||
});
|
||||
useTriliumEvent("noteTypeMimeChanged", refresh);
|
||||
|
||||
return { note, type, mime, noteContext, parentComponent };
|
||||
}
|
||||
|
||||
async function getCorrespondingWidget(type: ExtendedNoteType): Promise<null | TypeWidget> {
|
||||
const correspondingType = TYPE_MAPPINGS[type].view;
|
||||
if (!correspondingType) return null;
|
||||
|
||||
const result = await correspondingType();
|
||||
|
||||
if ("default" in result) {
|
||||
return result.default;
|
||||
} else if (isValidElement(result)) {
|
||||
// Direct VNode provided.
|
||||
return result;
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function getWidgetType(note: FNote | null | undefined, noteContext: NoteContext | undefined): Promise<ExtendedNoteType | undefined> {
|
||||
if (!noteContext) return undefined;
|
||||
if (!note) {
|
||||
// If the note is null, then it's a new tab. If it's undefined, then it's not loaded yet.
|
||||
return note === null ? "empty" : undefined;
|
||||
}
|
||||
|
||||
const type = note.type;
|
||||
let resultingType: ExtendedNoteType;
|
||||
|
||||
if (noteContext?.viewScope?.viewMode === "source") {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (noteContext?.viewScope && noteContext.viewScope.viewMode === "attachments") {
|
||||
resultingType = noteContext.viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||
} else if (type === "text" && (await noteContext?.isReadOnly())) {
|
||||
resultingType = "readOnlyText";
|
||||
} else if ((type === "code" || type === "mermaid") && (await 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 && !protected_session_holder.isProtectedSessionAvailable()) {
|
||||
resultingType = "protectedSession";
|
||||
}
|
||||
|
||||
return resultingType;
|
||||
}
|
||||
|
||||
function checkFullHeight(noteContext: NoteContext | undefined, type: ExtendedNoteType | undefined) {
|
||||
if (!noteContext) return false;
|
||||
|
||||
// https://github.com/zadam/trilium/issues/2522
|
||||
const isBackendNote = noteContext?.noteId === "_backendLog";
|
||||
const isSqlNote = noteContext.note?.mime === "text/x-sqlite;schema=trilium";
|
||||
const isFullHeightNoteType = type && TYPE_MAPPINGS[type].isFullHeight;
|
||||
return (!noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|
||||
|| noteContext?.viewScope?.viewMode === "attachments"
|
||||
|| isBackendNote;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
body.mobile .promoted-attributes-widget {
|
||||
/* https://github.com/zadam/trilium/issues/4468 */
|
||||
flex-shrink: 0.4;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.component.promoted-attributes-widget {
|
||||
contain: none;
|
||||
}
|
||||
|
||||
.promoted-attributes-container {
|
||||
margin: 0 1.5em;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
flex-wrap: wrap;
|
||||
display: table;
|
||||
}
|
||||
.promoted-attribute-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px;
|
||||
display: table-row;
|
||||
}
|
||||
.promoted-attribute-cell > label {
|
||||
user-select: none;
|
||||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.promoted-attribute-cell > * {
|
||||
display: table-cell;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell div.input-group {
|
||||
margin-inline-start: 10px;
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
}
|
||||
.promoted-attribute-cell strong {
|
||||
word-break:keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
width: 22px !important;
|
||||
flex-grow: 0;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
/* Restore default apperance */
|
||||
.promoted-attribute-cell input[type="number"],
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 2px;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
border-radius: 25% !important;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
inset-inline-start: 0px;
|
||||
inset-inline-end: 0;
|
||||
height: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import "./PromotedAttributes.css";
|
||||
import { useNoteContext, useNoteLabel } from "./react/hooks";
|
||||
import { Attribute } from "../services/attribute_parser";
|
||||
import { ComponentChild } from "preact";
|
||||
import FAttribute from "../entities/fattribute";
|
||||
import { t } from "../services/i18n";
|
||||
import ActionButton from "./react/ActionButton";
|
||||
|
||||
export default function PromotedAttributes() {
|
||||
const { note } = useNoteContext();
|
||||
const [ promotedAttributes, setPromotedAttributes ] = useState<ComponentChild[]>();
|
||||
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||
|
||||
useEffect(() => {
|
||||
if (!note) {
|
||||
setPromotedAttributes([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
||||
const ownedAttributes = note.getOwnedAttributes();
|
||||
// attrs are not resorted if position changes after the initial load
|
||||
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
|
||||
// the order of attributes is important as well
|
||||
ownedAttributes.sort((a, b) => a.position - b.position);
|
||||
|
||||
let promotedAttributes: ComponentChild[] = [];
|
||||
for (const definitionAttr of promotedDefAttrs) {
|
||||
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
|
||||
const valueName = definitionAttr.name.substr(valueType.length + 1);
|
||||
|
||||
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
|
||||
|
||||
if (valueAttrs.length === 0) {
|
||||
valueAttrs.push({
|
||||
attributeId: "",
|
||||
type: valueType,
|
||||
name: valueName,
|
||||
value: ""
|
||||
});
|
||||
}
|
||||
|
||||
if (definitionAttr.getDefinition().multiplicity === "single") {
|
||||
valueAttrs = valueAttrs.slice(0, 1);
|
||||
}
|
||||
|
||||
for (const valueAttr of valueAttrs) {
|
||||
promotedAttributes.push(<PromotedAttributeCell
|
||||
noteId={note.noteId}
|
||||
definitionAttr={definitionAttr}
|
||||
valueAttr={valueAttr} valueName={valueName} />)
|
||||
}
|
||||
}
|
||||
setPromotedAttributes(promotedAttributes);
|
||||
console.log("Got ", promotedAttributes);
|
||||
}, [ note ]);
|
||||
|
||||
return (
|
||||
<div className="promoted-attributes-widget">
|
||||
{viewType !== "table" && (
|
||||
<div className="promoted-attributes-container">
|
||||
{promotedAttributes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromotedAttributeCell({ noteId, definitionAttr, valueAttr, valueName }: {
|
||||
noteId: string;
|
||||
definitionAttr: FAttribute;
|
||||
valueAttr: Attribute;
|
||||
valueName: string;
|
||||
}) {
|
||||
const definition = definitionAttr.getDefinition();
|
||||
const id = `value-${valueAttr.attributeId}`;
|
||||
|
||||
return (
|
||||
<div className="promoted-attribute-cell">
|
||||
<label
|
||||
for={id}
|
||||
>{definition.promotedAlias ?? valueName}</label>
|
||||
|
||||
<div className="input-group">
|
||||
<input
|
||||
className="form-control promoted-attribute-input"
|
||||
tabindex={200 + definitionAttr.position}
|
||||
id={id}
|
||||
// if not owned, we'll force creation of a new attribute instead of updating the inherited one
|
||||
data-attribute-id={valueAttr.noteId === noteId ? valueAttr.attributeId ?? "" : ""}
|
||||
data-attribute-type={valueAttr.type}
|
||||
data-attribute-name={valueAttr.name}
|
||||
value={valueAttr.value}
|
||||
placeholder={t("promoted_attributes.unset-field-placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div />
|
||||
|
||||
{definition.multiplicity === "multi" && (
|
||||
<td className="multiplicity">
|
||||
<ActionButton
|
||||
icon="bx bx-plus"
|
||||
className="pointer tn-tool-button"
|
||||
text={t("promoted_attributes.add_new_attribute")}
|
||||
noIconActionClass
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
icon="bx bx-trash"
|
||||
className="pointer tn-tool-button"
|
||||
text={t("promoted_attributes.remove_this_attribute")}
|
||||
noIconActionClass
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
body.zen div.read-only-note-info-bar-widget {
|
||||
width: fit-content;
|
||||
max-width: var(--max-content-width);
|
||||
border-radius: 8px;
|
||||
border: unset;
|
||||
margin: 0 auto 10px auto;
|
||||
}
|
||||
|
||||
.read-only-note-info-bar-widget-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
:root div.read-only-note-info-bar-widget button {
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import "./ReadOnlyNoteInfoBar.css";
|
||||
import { t } from "../services/i18n";
|
||||
import { useIsNoteReadOnly, useNoteContext, useTriliumEvent } from "./react/hooks"
|
||||
import Button from "./react/Button";
|
||||
import InfoBar from "./react/InfoBar";
|
||||
|
||||
export default function ReadOnlyNoteInfoBar(props: {}) {
|
||||
const {note, noteContext} = useNoteContext();
|
||||
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);
|
||||
const isExplicitReadOnly = note?.isLabelTruthy("readOnly");
|
||||
|
||||
return <InfoBar className="read-only-note-info-bar-widget"
|
||||
type={(isExplicitReadOnly ? "subtle" : "prominent")}
|
||||
style={{display: (!isReadOnly) ? "none" : undefined}}>
|
||||
|
||||
<div class="read-only-note-info-bar-widget-content">
|
||||
{(isExplicitReadOnly) ? (
|
||||
<div>{t("read-only-info.read-only-note")}</div>
|
||||
) : (
|
||||
<div>
|
||||
{t("read-only-info.auto-read-only-note")}
|
||||
|
||||
<a class="tn-link"
|
||||
href="https://docs.triliumnotes.org/user-guide/concepts/notes/read-only-notes#automatic-read-only-mode">
|
||||
|
||||
{t("read-only-info.auto-read-only-learn-more")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button text={t("read-only-info.edit-note")}
|
||||
icon="bx-pencil" onClick={() => enableEditing()} />
|
||||
</div>
|
||||
</InfoBar>
|
||||
|
||||
}
|
||||
207
apps/client/src/widgets/attachment_detail.ts
Normal file
207
apps/client/src/widgets/attachment_detail.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import utils from "../services/utils.js";
|
||||
import AttachmentActionsWidget from "./buttons/attachments_actions.js";
|
||||
import BasicWidget from "./basic_widget.js";
|
||||
import options from "../services/options.js";
|
||||
import imageService from "../services/image.js";
|
||||
import linkService from "../services/link.js";
|
||||
import contentRenderer from "../services/content_renderer.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attachment-detail-widget">
|
||||
<style>
|
||||
.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%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="attachment-detail-wrapper">
|
||||
<div class="attachment-title-line">
|
||||
<div class="attachment-actions-container"></div>
|
||||
<h4 class="attachment-title"></h4>
|
||||
<div class="attachment-details"></div>
|
||||
<div style="flex: 1 1;"></div>
|
||||
</div>
|
||||
|
||||
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
|
||||
|
||||
<div class="attachment-content-wrapper"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class AttachmentDetailWidget extends BasicWidget {
|
||||
attachment: FAttachment;
|
||||
attachmentActionsWidget: AttachmentActionsWidget;
|
||||
isFullDetail: boolean;
|
||||
$wrapper!: JQuery<HTMLElement>;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ 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<T extends TypedComponent<any>> extends TypedComponent<T> {
|
||||
protected attrs: Record<string, string>;
|
||||
|
||||
195
apps/client/src/widgets/buttons/attachments_actions.ts
Normal file
195
apps/client/src/widgets/buttons/attachments_actions.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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*/`
|
||||
<div class="dropdown attachment-actions">
|
||||
<style>
|
||||
.attachment-actions {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.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 */
|
||||
}
|
||||
</style>
|
||||
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
|
||||
aria-expanded="false" class="icon-action icon-action-always-border bx bx-dots-vertical-rounded"
|
||||
style="position: relative; top: 3px;"></button>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
|
||||
<li data-trigger-command="openAttachment" class="dropdown-item"
|
||||
title="${t("attachments_actions.open_externally_title")}"><span class="bx bx-file-find"></span> ${t("attachments_actions.open_externally")}</li>
|
||||
|
||||
<li data-trigger-command="openAttachmentCustom" class="dropdown-item"
|
||||
title="${t("attachments_actions.open_custom_title")}"><span class="bx bx-customize"></span> ${t("attachments_actions.open_custom")}</li>
|
||||
|
||||
<li data-trigger-command="downloadAttachment" class="dropdown-item">
|
||||
<span class="bx bx-download"></span> ${t("attachments_actions.download")}</li>
|
||||
|
||||
<li data-trigger-command="copyAttachmentLinkToClipboard" class="dropdown-item"><span class="bx bx-link">
|
||||
</span> ${t("attachments_actions.copy_link_to_clipboard")}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="uploadNewAttachmentRevision" class="dropdown-item"><span class="bx bx-upload">
|
||||
</span> ${t("attachments_actions.upload_new_revision")}</li>
|
||||
|
||||
<li data-trigger-command="renameAttachment" class="dropdown-item">
|
||||
<span class="bx bx-rename"></span> ${t("attachments_actions.rename_attachment")}</li>
|
||||
|
||||
<li data-trigger-command="deleteAttachment" class="dropdown-item">
|
||||
<span class="bx bx-trash destructive-action-icon"></span> ${t("attachments_actions.delete_attachment")}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="convertAttachmentIntoNote" class="dropdown-item"><span class="bx bx-note">
|
||||
</span> ${t("attachments_actions.convert_attachment_into_note")}</li>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="file" class="attachment-upload-new-revision-input" style="display: none">
|
||||
</div>`;
|
||||
|
||||
// TODO: Deduplicate
|
||||
interface AttachmentResponse {
|
||||
note: NoteRow;
|
||||
}
|
||||
|
||||
export default class AttachmentActionsWidget extends BasicWidget {
|
||||
$uploadNewRevisionInput!: JQuery<HTMLInputElement>;
|
||||
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($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
|
||||
if (isElectron) {
|
||||
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
|
||||
$openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
|
||||
}
|
||||
}
|
||||
if (!isElectron) {
|
||||
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
|
||||
$openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').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<AttachmentResponse>(`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 });
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
.note-list-widget {
|
||||
min-height: 0;
|
||||
max-width: var(--max-content-width); /* Inherited from .note-split */
|
||||
|
||||
overflow: auto;
|
||||
contain: none !important;
|
||||
}
|
||||
|
||||
body.prefers-centered-content .note-list-widget:not(.full-height) {
|
||||
/* Horizontally center the widget in its parent when the "Keep content centered" option is on */
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.note-list-widget .note-list {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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, new Set<string>());
|
||||
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn, includeArchived);
|
||||
|
||||
// Get all columns that exist in the notes
|
||||
const columnsFromNotes = [...byColumn.keys()];
|
||||
@@ -61,28 +61,26 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per
|
||||
};
|
||||
}
|
||||
|
||||
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string, includeArchived: boolean, seenNoteIds: Set<string>) {
|
||||
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string, includeArchived: boolean) {
|
||||
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, seenNoteIds);
|
||||
await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn, includeArchived);
|
||||
}
|
||||
|
||||
const group = note.getLabelValue(groupByColumn);
|
||||
if (!group || seenNoteIds.has(note.noteId)) {
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byColumn.has(group)) {
|
||||
byColumn.set(group, []);
|
||||
}
|
||||
|
||||
byColumn.get(group)!.push({
|
||||
branch,
|
||||
note
|
||||
});
|
||||
seenNoteIds.add(note.noteId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ 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";
|
||||
|
||||
setAttribute(note, "label", startAttribute, startTime);
|
||||
setAttribute(note, "label", endAttribute, endTime);
|
||||
if (startTime && endTime) {
|
||||
setAttribute(note, "label", startAttribute, startTime);
|
||||
setAttribute(note, "label", endAttribute, endTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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 { 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 branchId = parentNote.childToBranch[noteId];
|
||||
await branches.deleteNotes([ branchId ], false, false);
|
||||
}
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId),
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
.calendar-view {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
outline: 0;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
padding: 10px;
|
||||
@@ -68,7 +67,6 @@
|
||||
}
|
||||
|
||||
body.desktop:not(.zen) .calendar-view .calendar-header {
|
||||
padding-block-start: 4px;
|
||||
padding-inline-end: 5em;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } from "@triliumnext/commons";
|
||||
import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { Calendar as FullCalendar } from "@fullcalendar/core";
|
||||
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
||||
import dialog from "../../../services/dialog";
|
||||
@@ -20,7 +20,6 @@ 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 {
|
||||
|
||||
@@ -107,7 +106,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
|
||||
const plugins = usePlugins(isEditable, isCalendarRoot);
|
||||
const locale = useLocale();
|
||||
|
||||
const { eventDidMount } = useEventDisplayCustomization(note);
|
||||
const { eventDidMount } = useEventDisplayCustomization();
|
||||
const editingProps = useEditing(note, isEditable, isCalendarRoot);
|
||||
|
||||
// React to changes.
|
||||
@@ -197,11 +196,11 @@ function usePlugins(isEditable: boolean, isCalendarRoot: boolean) {
|
||||
}
|
||||
|
||||
function useLocale() {
|
||||
const [ formattingLocale ] = useTriliumOption("formattingLocale");
|
||||
const [ locale ] = useTriliumOption("locale");
|
||||
const [ calendarLocale, setCalendarLocale ] = useState<LocaleInput>();
|
||||
|
||||
useEffect(() => {
|
||||
const correspondingLocale = LOCALE_MAPPINGS[formattingLocale];
|
||||
const correspondingLocale = LOCALE_MAPPINGS[locale];
|
||||
if (correspondingLocale) {
|
||||
correspondingLocale().then((locale) => setCalendarLocale(locale.default));
|
||||
} else {
|
||||
@@ -254,7 +253,7 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
function useEventDisplayCustomization(parentNote: FNote) {
|
||||
function useEventDisplayCustomization() {
|
||||
const eventDidMount = useCallback((e: EventMountArg) => {
|
||||
const { iconClass, promotedAttributes } = e.event.extendedProps;
|
||||
|
||||
@@ -303,11 +302,6 @@ function useEventDisplayCustomization(parentNote: FNote) {
|
||||
}
|
||||
$(mainContainer ?? e.el).append($(promotedAttributesHtml));
|
||||
}
|
||||
|
||||
e.el.addEventListener("contextmenu", (contextMenuEvent) => {
|
||||
const noteId = e.event.extendedProps.noteId;
|
||||
openCalendarContextMenu(contextMenuEvent, noteId, parentNote);
|
||||
});
|
||||
}, []);
|
||||
return { eventDidMount };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Map from "./map";
|
||||
import "./index.css";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
|
||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, 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";
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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<BasicWidget> {
|
||||
|
||||
noteContext?: NoteContext;
|
||||
thisElement?: HTMLElement;
|
||||
parentElement?: HTMLElement;
|
||||
resizeObserver: ResizeObserver;
|
||||
currentHeight: number = 0;
|
||||
currentSafeMargin: number = NaN;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
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 <ContentHeader>.");
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
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.
|
||||
@@ -31,11 +30,9 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
window.visualViewport?.addEventListener("resize", () => this.#onMobileResize());
|
||||
}
|
||||
|
||||
this.#setMaxContentWidth();
|
||||
this.#setMotion();
|
||||
this.#setShadows();
|
||||
this.#setBackdropEffects();
|
||||
this.#setThemeCapabilities();
|
||||
this.#setMotion(options.is("motionEnabled"));
|
||||
this.#setShadows(options.is("shadowsEnabled"));
|
||||
this.#setBackdropEffects(options.is("backdropEffectsEnabled"));
|
||||
this.#setLocaleAndDirection(options.get("locale"));
|
||||
|
||||
return super.render();
|
||||
@@ -43,21 +40,15 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("motionEnabled")) {
|
||||
this.#setMotion();
|
||||
this.#setMotion(options.is("motionEnabled"));
|
||||
}
|
||||
|
||||
if (loadResults.isOptionReloaded("shadowsEnabled")) {
|
||||
this.#setShadows();
|
||||
this.#setShadows(options.is("shadowsEnabled"));
|
||||
}
|
||||
|
||||
if (loadResults.isOptionReloaded("backdropEffectsEnabled")) {
|
||||
this.#setBackdropEffects();
|
||||
}
|
||||
|
||||
if (loadResults.isOptionReloaded("maxContentWidth")
|
||||
|| loadResults.isOptionReloaded("centerContent")) {
|
||||
|
||||
this.#setMaxContentWidth();
|
||||
this.#setBackdropEffects(options.is("backdropEffectsEnabled"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,38 +58,19 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
this.$widget.toggleClass("virtual-keyboard-opened", isKeyboardOpened);
|
||||
}
|
||||
|
||||
#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");
|
||||
#setMotion(enabled: boolean) {
|
||||
document.body.classList.toggle("motion-disabled", !enabled);
|
||||
jQuery.fx.off = !enabled;
|
||||
}
|
||||
|
||||
#setShadows() {
|
||||
const enabled = options.is("shadowsEnabled");
|
||||
#setShadows(enabled: boolean) {
|
||||
document.body.classList.toggle("shadows-disabled", !enabled);
|
||||
}
|
||||
|
||||
#setBackdropEffects() {
|
||||
const enabled = options.is("backdropEffectsEnabled");
|
||||
#setBackdropEffects(enabled: boolean) {
|
||||
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;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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<BasicWidget> {
|
||||
|
||||
@@ -12,6 +11,9 @@ export default class ScrollingContainer extends Container<BasicWidget> {
|
||||
super();
|
||||
|
||||
this.class("scrolling-container");
|
||||
this.css("overflow", "auto");
|
||||
this.css("scroll-behavior", "smooth");
|
||||
this.css("position", "relative");
|
||||
}
|
||||
|
||||
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -13,32 +14,29 @@ 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<void>;
|
||||
}
|
||||
|
||||
export default function AddLinkDialog() {
|
||||
const [ opts, setOpts ] = useState<AddLinkOpts>();
|
||||
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>();
|
||||
const initialText = useRef<string>();
|
||||
const [ linkTitle, setLinkTitle ] = useState("");
|
||||
const [ linkType, setLinkType ] = useState<LinkType>();
|
||||
const hasSelection = textTypeWidget?.hasSelection();
|
||||
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const hasSubmittedRef = useRef(false);
|
||||
|
||||
useTriliumEvent("showAddLinkDialog", opts => {
|
||||
setOpts(opts);
|
||||
useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
initialText.current = text;
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opts?.hasSelection) {
|
||||
if (hasSelection) {
|
||||
setLinkType("hyper-link");
|
||||
} else {
|
||||
setLinkType("reference-link");
|
||||
}
|
||||
}, [ opts ]);
|
||||
}, [ hasSelection ])
|
||||
|
||||
async function setDefaultLinkTitle(noteId: string) {
|
||||
const noteTitle = await tree.getNoteTitle(noteId);
|
||||
@@ -73,10 +71,10 @@ export default function AddLinkDialog() {
|
||||
|
||||
function onShown() {
|
||||
const $autocompleteEl = refToJQuerySelector(autocompleteRef);
|
||||
if (!opts?.text) {
|
||||
if (!initialText.current) {
|
||||
note_autocomplete.showRecentNotes($autocompleteEl);
|
||||
} else {
|
||||
note_autocomplete.setText($autocompleteEl, opts.text);
|
||||
note_autocomplete.setText($autocompleteEl, initialText.current);
|
||||
}
|
||||
|
||||
// to be able to quickly remove entered text
|
||||
@@ -110,15 +108,15 @@ export default function AddLinkDialog() {
|
||||
onShown={onShown}
|
||||
onHidden={() => {
|
||||
// Insert the link.
|
||||
if (hasSubmittedRef.current && suggestion && opts) {
|
||||
if (hasSubmittedRef.current && suggestion && textTypeWidget) {
|
||||
hasSubmittedRef.current = false;
|
||||
|
||||
if (suggestion.notePath) {
|
||||
// Handle note link
|
||||
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
textTypeWidget.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
} else if (suggestion.externalLink) {
|
||||
// Handle external link
|
||||
opts.addLink(suggestion.externalLink, linkTitle, true);
|
||||
textTypeWidget.addLink(suggestion.externalLink, linkTitle, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +136,7 @@ export default function AddLinkDialog() {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{!opts?.hasSelection && (
|
||||
{!hasSelection && (
|
||||
<div className="add-link-title-settings">
|
||||
{(linkType !== "external-link") && (
|
||||
<>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.branch-prefix-dialog .branch-prefix-notes-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.branch-prefix-dialog .branch-prefix-notes-list ul {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.branch-prefix-dialog .branch-prefix-current {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -10,86 +10,53 @@ import Button from "../react/Button.jsx";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
import FBranch from "../../entities/fbranch.js";
|
||||
import type { ContextMenuCommandData } from "../../components/app_context.js";
|
||||
import "./branch_prefix.css";
|
||||
|
||||
// Virtual branches (e.g., from search results) start with this prefix
|
||||
const VIRTUAL_BRANCH_PREFIX = "virt-";
|
||||
|
||||
export default function BranchPrefixDialog() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ branches, setBranches ] = useState<FBranch[]>([]);
|
||||
const [ branch, setBranch ] = useState<FBranch>();
|
||||
const [ prefix, setPrefix ] = useState("");
|
||||
const branchInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
useTriliumEvent("editBranchPrefix", async (data?: ContextMenuCommandData) => {
|
||||
let branchIds: string[] = [];
|
||||
|
||||
if (data?.selectedOrActiveBranchIds && data.selectedOrActiveBranchIds.length > 0) {
|
||||
// Multi-select mode from tree context menu
|
||||
branchIds = data.selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith(VIRTUAL_BRANCH_PREFIX));
|
||||
} else {
|
||||
// Single branch mode from keyboard shortcut or when no selection
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
|
||||
|
||||
if (!noteId || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const branchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
if (!branchId) {
|
||||
return;
|
||||
}
|
||||
const parentNote = await froca.getNote(parentNoteId);
|
||||
if (!parentNote || parentNote.type === "search") {
|
||||
return;
|
||||
}
|
||||
|
||||
branchIds = [branchId];
|
||||
}
|
||||
|
||||
if (branchIds.length === 0) {
|
||||
useTriliumEvent("editBranchPrefix", async () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newBranches = branchIds
|
||||
.map(id => froca.getBranch(id))
|
||||
.filter((branch): branch is FBranch => branch !== null);
|
||||
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
|
||||
|
||||
if (newBranches.length === 0) {
|
||||
if (!noteId || !parentNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBranches(newBranches);
|
||||
// Use the prefix of the first branch as the initial value
|
||||
setPrefix(newBranches[0]?.prefix ?? "");
|
||||
const newBranchId = await froca.getBranchId(parentNoteId, noteId);
|
||||
if (!newBranchId) {
|
||||
return;
|
||||
}
|
||||
const parentNote = await froca.getNote(parentNoteId);
|
||||
if (!parentNote || parentNote.type === "search") {
|
||||
return;
|
||||
}
|
||||
|
||||
const newBranch = froca.getBranch(newBranchId);
|
||||
setBranch(newBranch);
|
||||
setPrefix(newBranch?.prefix ?? "");
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (branches.length === 0) {
|
||||
if (!branch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (branches.length === 1) {
|
||||
await savePrefix(branches[0].branchId, prefix);
|
||||
} else {
|
||||
await savePrefixBatch(branches.map(b => b.branchId), prefix);
|
||||
}
|
||||
savePrefix(branch.branchId, prefix);
|
||||
setShown(false);
|
||||
}
|
||||
|
||||
const isSingleBranch = branches.length === 1;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="branch-prefix-dialog"
|
||||
title={isSingleBranch ? t("branch_prefix.edit_branch_prefix") : t("branch_prefix.edit_branch_prefix_multiple", { count: branches.length })}
|
||||
title={t("branch_prefix.edit_branch_prefix")}
|
||||
size="lg"
|
||||
onShown={() => branchInput.current?.focus()}
|
||||
onHidden={() => setShown(false)}
|
||||
@@ -102,27 +69,9 @@ export default function BranchPrefixDialog() {
|
||||
<div class="input-group">
|
||||
<input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
|
||||
onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />
|
||||
{isSingleBranch && branches[0] && (
|
||||
<div class="branch-prefix-note-title input-group-text"> - {branches[0].getNoteFromCache().title}</div>
|
||||
)}
|
||||
<div class="branch-prefix-note-title input-group-text"> - {branch && branch.getNoteFromCache().title}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
{!isSingleBranch && (
|
||||
<div className="branch-prefix-notes-list">
|
||||
<strong>{t("branch_prefix.affected_branches", { count: branches.length })}</strong>
|
||||
<ul>
|
||||
{branches.map((branch) => {
|
||||
const note = branch.getNoteFromCache();
|
||||
return (
|
||||
<li key={branch.branchId}>
|
||||
{branch.prefix && <span className="branch-prefix-current">{branch.prefix} - </span>}
|
||||
{note.title}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -131,8 +80,3 @@ async function savePrefix(branchId: string, prefix: string) {
|
||||
await server.put(`branches/${branchId}/set-prefix`, { prefix: prefix });
|
||||
toast.showMessage(t("branch_prefix.branch_prefix_saved"));
|
||||
}
|
||||
|
||||
async function savePrefixBatch(branchIds: string[], prefix: string) {
|
||||
await server.put("branches/set-prefix-batch", { branchIds, prefix });
|
||||
toast.showMessage(t("branch_prefix.branch_prefix_saved_multiple", { count: branchIds.length }));
|
||||
}
|
||||
|
||||
@@ -8,21 +8,17 @@ 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 editorApiRef = useRef<CKEditorApi>(null);
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
|
||||
const [boxSize, setBoxSize] = useState<string>("medium");
|
||||
const [boxSize, setBoxSize] = useState("medium");
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
|
||||
editorApiRef.current = editorApi;
|
||||
useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
@@ -36,9 +32,12 @@ export default function IncludeNoteDialog() {
|
||||
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
|
||||
onHidden={() => setShown(false)}
|
||||
onSubmit={() => {
|
||||
if (!suggestion?.notePath || !editorApiRef.current) return;
|
||||
if (!suggestion?.notePath || !textTypeWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShown(false);
|
||||
includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
|
||||
includeNote(suggestion.notePath, textTypeWidget, boxSize as BoxSize);
|
||||
}}
|
||||
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
||||
show={shown}
|
||||
@@ -70,7 +69,7 @@ export default function IncludeNoteDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) {
|
||||
async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWidget, boxSize: BoxSize) {
|
||||
const noteId = tree.getNoteIdFromUrl(notePath);
|
||||
if (!noteId) {
|
||||
return;
|
||||
@@ -80,8 +79,8 @@ async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: Bo
|
||||
if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
|
||||
// there's no benefit to use insert note functionlity for images,
|
||||
// so we'll just add an IMG tag
|
||||
editorApi.addImage(noteId);
|
||||
textTypeWidget.addImage(noteId);
|
||||
} else {
|
||||
editorApi.addIncludeNote(noteId, boxSize);
|
||||
textTypeWidget.addIncludeNote(noteId, boxSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { useCallback, useRef, useState } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import { t } from "../../services/i18n";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
@@ -6,11 +7,7 @@ import utils from "../../services/utils";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
|
||||
|
||||
export interface MarkdownImportOpts {
|
||||
editorApi: CKEditorApi;
|
||||
}
|
||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
@@ -18,18 +15,18 @@ interface RenderMarkdownResponse {
|
||||
|
||||
export default function MarkdownImportDialog() {
|
||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||
const editorApiRef = useRef<CKEditorApi>(null);
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const [ text, setText ] = useState("");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showPasteMarkdownDialog", ({ editorApi }) => {
|
||||
useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
if (utils.isElectron()) {
|
||||
const { clipboard } = utils.dynamicRequire("electron");
|
||||
const text = clipboard.readText();
|
||||
|
||||
convertMarkdownToHtml(text, editorApi);
|
||||
|
||||
convertMarkdownToHtml(text, textTypeWidget);
|
||||
} else {
|
||||
editorApiRef.current = editorApi;
|
||||
setShown(true);
|
||||
}
|
||||
});
|
||||
@@ -40,8 +37,8 @@ export default function MarkdownImportDialog() {
|
||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
|
||||
onShown={() => markdownImportTextArea.current?.focus()}
|
||||
onHidden={async () => {
|
||||
if (editorApiRef.current) {
|
||||
await convertMarkdownToHtml(text, editorApiRef.current);
|
||||
if (textTypeWidget) {
|
||||
await convertMarkdownToHtml(text, textTypeWidget);
|
||||
}
|
||||
setShown(false);
|
||||
setText("");
|
||||
@@ -62,8 +59,10 @@ export default function MarkdownImportDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: CKEditorApi) {
|
||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) {
|
||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||
textTypeWidget.addHtmlToEditor(htmlContent);
|
||||
|
||||
await textTypeWidget.addHtmlToEditor(htmlContent);
|
||||
|
||||
toast.showMessage(t("markdown_import.import_success"));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import type { EventNames, EventData } from "../../components/app_context.js";
|
||||
import NoteContext from "../../components/note_context.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import BasicWidget, { ReactWrappedWidget } from "../basic_widget.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import Container from "../containers/container.js";
|
||||
import TypeWidget from "../type_widgets/type_widget.js";
|
||||
|
||||
const TPL = /*html*/`\
|
||||
<div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<style>
|
||||
/** Reduce the z-index of modals so that ckeditor popups are properly shown on top of it. */
|
||||
body.popup-editor-open > .modal-backdrop { z-index: 998; }
|
||||
body.popup-editor-open .popup-editor-dialog { z-index: 999; }
|
||||
body.popup-editor-open .ck-clipboard-drop-target-line { z-index: 1000; }
|
||||
|
||||
body.desktop .modal.popup-editor-dialog .modal-dialog {
|
||||
max-width: 75vw;
|
||||
}
|
||||
@@ -61,19 +57,17 @@ const TPL = /*html*/`\
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="quick-edit-dialog-wrapper">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<!-- This is where the first child will be injected -->
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<!-- This is where the first child will be injected -->
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- This is where all but the first child will be injected. -->
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- This is where all but the first child will be injected. -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +79,6 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
private noteContext: NoteContext;
|
||||
private $modalHeader!: JQuery<HTMLElement>;
|
||||
private $modalBody!: JQuery<HTMLElement>;
|
||||
private $wrapper!: JQuery<HTMLDivElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -100,7 +93,6 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
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]);
|
||||
@@ -120,27 +112,17 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -148,7 +130,7 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
$dialog.on("hidden.bs.modal", () => {
|
||||
const $typeWidgetEl = $dialog.find(".note-detail-printable");
|
||||
if ($typeWidgetEl.length) {
|
||||
const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget;
|
||||
const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as TypeWidget;
|
||||
typeWidget.cleanup();
|
||||
}
|
||||
|
||||
@@ -161,12 +143,9 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
462
apps/client/src/widgets/note_detail.ts
Normal file
462
apps/client/src/widgets/note_detail.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
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*/`
|
||||
<div class="note-detail">
|
||||
<style>
|
||||
.note-detail {
|
||||
font-family: var(--detail-font-family);
|
||||
font-size: var(--detail-font-size);
|
||||
}
|
||||
|
||||
.note-detail.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<NoteType, "launcher" | "text" | "code">
|
||||
| "empty"
|
||||
| "readOnlyCode"
|
||||
| "readOnlyText"
|
||||
| "editableText"
|
||||
| "editableCode"
|
||||
| "attachmentDetail"
|
||||
| "attachmentList"
|
||||
| "protectedSession"
|
||||
| "aiChat";
|
||||
|
||||
export default class NoteDetailWidget extends NoteContextAwareWidget {
|
||||
|
||||
private typeWidgets: Record<string, TypeWidget>;
|
||||
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<ExtendedNoteType> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
671
apps/client/src/widgets/note_map.ts
Normal file
671
apps/client/src/widgets/note_map.ts
Normal file
@@ -0,0 +1,671 @@
|
||||
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*/`<div class="note-map-widget">
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
|
||||
.fixnodes-type-switcher button.toggled {
|
||||
background: var(--active-item-background-color);
|
||||
color: var(--active-item-text-color);
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
|
||||
</style>
|
||||
|
||||
<div class="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">
|
||||
<button type="button" class="btn bx bx-network-chart tn-tool-button" title="${t("note-map.button-link-map")}" data-type="link"></button>
|
||||
<button type="button" class="btn bx bx-sitemap tn-tool-button" title="${t("note-map.button-tree-map")}" data-type="tree"></button>
|
||||
</div>
|
||||
|
||||
<! UI for dragging Notes and link force >
|
||||
|
||||
<div class="btn-group-sm fixnodes-type-switcher content-floating-buttons bottom-left" role="group">
|
||||
<button type="button" data-toggle="button" class="btn bx bx-lock-alt tn-tool-button" title="${t("note_map.fix-nodes")}" data-type="moveable"></button>
|
||||
<input type="range" class="slider" min="1" title="${t("note_map.link-distance")}" max="100" value="40" >
|
||||
</div>
|
||||
|
||||
<div class="style-resolver"></div>
|
||||
|
||||
<div class="note-map-container"></div>
|
||||
</div>`;
|
||||
|
||||
type WidgetMode = "type" | "ribbon";
|
||||
type MapType = "tree" | "link";
|
||||
type Data = GraphData<NodeObject, LinkObject<NodeObject>>;
|
||||
|
||||
interface Node extends NodeObject {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Link extends LinkObject<NodeObject> {
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
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<HTMLElement>;
|
||||
private $styleResolver!: JQuery<HTMLElement>;
|
||||
private $fixNodesButton!: JQuery<HTMLElement>;
|
||||
graph!: ForceGraph;
|
||||
private noteIdToSizeMap!: Record<string, number>;
|
||||
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)} - <strong>${esc((l as Link).name)}</strong> - ${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<NotesAndRelationsData> {
|
||||
const resp = await server.post<PostNotesMapResponse>(`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<string, GroupedLink> = {};
|
||||
|
||||
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<string, number> = {};
|
||||
|
||||
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<string | number>();
|
||||
|
||||
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<NodeObject>[], type: "source" | "target") {
|
||||
const map: Record<string | number, LinkObject<NodeObject>[]> = {};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
.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 */
|
||||
@@ -1,174 +0,0 @@
|
||||
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<HTMLElement>;
|
||||
}
|
||||
|
||||
export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const styleResolverRef = useRef<HTMLDivElement>(null);
|
||||
const [ mapTypeRaw, setMapType ] = useNoteLabel(note, "mapType");
|
||||
const [ mapRootIdLabel ] = useNoteLabel(note, "mapRootNoteId");
|
||||
const mapType: MapType = mapTypeRaw === "tree" ? "tree" : "link";
|
||||
|
||||
const graphRef = useRef<ForceGraph<NoteMapNodeObject, NoteMapLinkObject>>();
|
||||
const containerSize = useElementSize(parentRef);
|
||||
const [ fixNodes, setFixNodes ] = useState(false);
|
||||
const [ linkDistance, setLinkDistance ] = useState(40);
|
||||
const notesAndRelationsRef = useRef<NotesAndRelationsData>();
|
||||
|
||||
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<NoteMapNodeObject, NoteMapLinkObject>(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 (
|
||||
<div className="note-map-widget">
|
||||
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">
|
||||
<MapTypeSwitcher type="link" icon="bx bx-network-chart" text={t("note-map.button-link-map")} currentMapType={mapType} setMapType={setMapType} />
|
||||
<MapTypeSwitcher type="tree" icon="bx bx-sitemap" text={t("note-map.button-tree-map")} currentMapType={mapType} setMapType={setMapType} />
|
||||
</div>
|
||||
|
||||
<div class="btn-group-sm fixnodes-type-switcher content-floating-buttons bottom-left" role="group">
|
||||
<ActionButton
|
||||
icon="bx bx-lock-alt"
|
||||
text={t("note_map.fix-nodes")}
|
||||
className={fixNodes ? "active" : ""}
|
||||
onClick={() => setFixNodes(!fixNodes)}
|
||||
frame
|
||||
/>
|
||||
|
||||
<Slider
|
||||
min={1} max={100}
|
||||
value={linkDistance} onChange={setLinkDistance}
|
||||
title={t("note_map.link-distance")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={styleResolverRef} class="style-resolver" />
|
||||
<div ref={containerRef} className="note-map-container" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: {
|
||||
icon: string;
|
||||
text: string;
|
||||
type: MapType;
|
||||
currentMapType: MapType;
|
||||
setMapType: (type: MapType) => void;
|
||||
}) {
|
||||
return (
|
||||
<ActionButton
|
||||
icon={icon} text={text}
|
||||
active={currentMapType === type}
|
||||
onClick={() => 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)
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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<NoteMapNodeObject> {
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
export async function loadNotesAndRelations(mapRootNoteId: string, excludeRelations: string[], includeRelations: string[], mapType: MapType): Promise<NotesAndRelationsData> {
|
||||
const resp = await server.post<NoteMapPostResponse>(`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<string, number> = {};
|
||||
|
||||
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<string, number> = {};
|
||||
|
||||
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<string, GroupedLink> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
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<string, number>;
|
||||
cssData: CssData;
|
||||
noteId: string;
|
||||
themeStyle: "light" | "dark";
|
||||
widgetMode: NoteMapWidgetMode;
|
||||
notesAndRelations: NotesAndRelationsData;
|
||||
mapType: MapType;
|
||||
}
|
||||
|
||||
export function setupRendering(graph: ForceGraph<NoteMapNodeObject, NoteMapLinkObject>, { 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)} - <strong>${escapeHtml(link.name)}</strong> - ${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<string | number>();
|
||||
|
||||
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<NodeObject>[], type: "source" | "target") {
|
||||
const map: Record<string | number, LinkObject<NodeObject>[]> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -47,9 +47,7 @@ export default function NoteTitleWidget() {
|
||||
|
||||
// Prevent user from navigating away if the spaced update is not done.
|
||||
useEffect(() => {
|
||||
const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
|
||||
appContext.addBeforeUnloadListener(listener);
|
||||
return () => appContext.removeBeforeUnloadListener(listener);
|
||||
appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate());
|
||||
}, []);
|
||||
useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());
|
||||
|
||||
|
||||
@@ -173,6 +173,14 @@ interface ExpandedSubtreeResponse {
|
||||
branchIds: string[];
|
||||
}
|
||||
|
||||
interface Node extends Fancytree.NodeData {
|
||||
noteId: string;
|
||||
parentNoteId: string;
|
||||
branchId: string;
|
||||
isProtected: boolean;
|
||||
noteType: NoteType;
|
||||
}
|
||||
|
||||
interface RefreshContext {
|
||||
noteIdsToUpdate: Set<string>;
|
||||
noteIdsToReload: Set<string>;
|
||||
@@ -761,7 +769,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
prepareChildren(parentNote: FNote) {
|
||||
utils.assertArguments(parentNote);
|
||||
|
||||
const noteList: Fancytree.FancytreeNewNode[] = [];
|
||||
const noteList: Node[] = [];
|
||||
|
||||
const hideArchivedNotes = this.hideArchivedNotes;
|
||||
|
||||
@@ -829,7 +837,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
|
||||
const isFolder = note.isFolder();
|
||||
|
||||
const node: Fancytree.FancytreeNewNode = {
|
||||
const node: Node = {
|
||||
noteId: note.noteId,
|
||||
parentNoteId: branch.parentNoteId,
|
||||
branchId: branch.branchId,
|
||||
@@ -841,7 +849,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
|
||||
};
|
||||
|
||||
@@ -903,6 +911,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
return extraClasses.join(" ");
|
||||
}
|
||||
|
||||
/** @returns {FancytreeNode[]} */
|
||||
getSelectedNodes(stopOnParents = false) {
|
||||
return this.tree.getSelectedNodes(stopOnParents);
|
||||
}
|
||||
@@ -1523,7 +1532,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
|
||||
// Automatically expand the hoisted note by default
|
||||
const node = this.getActiveNode();
|
||||
if (node && node.data.noteId === this.noteContext.hoistedNoteId){
|
||||
if (node?.data.noteId === this.noteContext.hoistedNoteId){
|
||||
this.setExpanded(node.data.branchId, true);
|
||||
}
|
||||
}
|
||||
@@ -1582,20 +1591,6 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
this.clearSelectedNodes();
|
||||
}
|
||||
|
||||
async editBranchPrefixCommand({ node }: CommandListenerData<"editBranchPrefix">) {
|
||||
const branchIds = this.getSelectedOrActiveBranchIds(node).filter((branchId) => !branchId.startsWith("virt-"));
|
||||
|
||||
if (!branchIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger the event with the selected branch IDs
|
||||
appContext.triggerEvent("editBranchPrefix", {
|
||||
selectedOrActiveBranchIds: branchIds,
|
||||
node: node
|
||||
});
|
||||
}
|
||||
|
||||
canBeMovedUpOrDown(node: Fancytree.FancytreeNode) {
|
||||
if (node.data.noteId === "root") {
|
||||
return false;
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* @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<NoteType, "launcher" | "text" | "code"> | "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<ExtendedNoteType, NoteTypeMapping> = {
|
||||
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
|
||||
}
|
||||
};
|
||||
@@ -52,7 +52,6 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
|
||||
const note = this.noteContext?.note;
|
||||
if (!note) {
|
||||
this.$widget.addClass("bgfx empty-note");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,7 +61,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
|
||||
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");
|
||||
@@ -71,7 +70,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
}
|
||||
|
||||
#isFullWidthNote(note: FNote) {
|
||||
if (["code", "image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type)) {
|
||||
if (["image", "mermaid", "book", "render", "canvas", "webView", "mindMap"].includes(note.type)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,102 @@ import type { Attribute } from "../services/attribute_parser.js";
|
||||
import type FAttribute from "../entities/fattribute.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="promoted-attributes-widget">
|
||||
<style>
|
||||
body.mobile .promoted-attributes-widget {
|
||||
/* https://github.com/zadam/trilium/issues/4468 */
|
||||
flex-shrink: 0.4;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.promoted-attributes-container {
|
||||
margin: 0 1.5em;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
flex-wrap: wrap;
|
||||
display: table;
|
||||
}
|
||||
.promoted-attribute-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px;
|
||||
display: table-row;
|
||||
}
|
||||
.promoted-attribute-cell > label {
|
||||
user-select: none;
|
||||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.promoted-attribute-cell > * {
|
||||
display: table-cell;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell div.input-group {
|
||||
margin-inline-start: 10px;
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
}
|
||||
.promoted-attribute-cell strong {
|
||||
word-break:keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
width: 22px !important;
|
||||
flex-grow: 0;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
/* Restore default apperance */
|
||||
.promoted-attribute-cell input[type="number"],
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 2px;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
border-radius: 25% !important;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
inset-inline-start: 0px;
|
||||
inset-inline-end: 0;
|
||||
height: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="promoted-attributes-container"></div>
|
||||
</div>`;
|
||||
|
||||
// TODO: Deduplicate
|
||||
interface AttributeResult {
|
||||
attributeId: string;
|
||||
@@ -21,17 +117,115 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $container!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "promotedAttributes";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabPromotedAttributes";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $("");
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$container = this.$widget.find(".promoted-attributes-container");
|
||||
}
|
||||
|
||||
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
|
||||
// .on("change", (event) => this.promotedAttributeChanged(event));
|
||||
getTitle(note: FNote) {
|
||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
||||
|
||||
if (promotedDefAttrs.length === 0) {
|
||||
return { show: false };
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
activate: options.is("promotedAttributesOpenInRibbon"),
|
||||
title: t("promoted_attributes.promoted_attributes"),
|
||||
icon: "bx bx-table"
|
||||
};
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$container.empty();
|
||||
|
||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
||||
const ownedAttributes = note.getOwnedAttributes();
|
||||
// attrs are not resorted if position changes after the initial load
|
||||
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
|
||||
// the order of attributes is important as well
|
||||
ownedAttributes.sort((a, b) => a.position - b.position);
|
||||
|
||||
if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") {
|
||||
this.toggleInt(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const $cells: JQuery<HTMLElement>[] = [];
|
||||
|
||||
for (const definitionAttr of promotedDefAttrs) {
|
||||
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
|
||||
const valueName = definitionAttr.name.substr(valueType.length + 1);
|
||||
|
||||
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
|
||||
|
||||
if (valueAttrs.length === 0) {
|
||||
valueAttrs.push({
|
||||
attributeId: "",
|
||||
type: valueType,
|
||||
name: valueName,
|
||||
value: ""
|
||||
});
|
||||
}
|
||||
|
||||
if (definitionAttr.getDefinition().multiplicity === "single") {
|
||||
valueAttrs = valueAttrs.slice(0, 1);
|
||||
}
|
||||
|
||||
for (const valueAttr of valueAttrs) {
|
||||
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
|
||||
|
||||
if ($cell) {
|
||||
$cells.push($cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we replace the whole content in one step, so there can't be any race conditions
|
||||
// (previously we saw promoted attributes doubling)
|
||||
this.$container.empty().append(...$cells);
|
||||
this.toggleInt(true);
|
||||
}
|
||||
|
||||
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
|
||||
const definition = definitionAttr.getDefinition();
|
||||
const id = `value-${valueAttr.attributeId}`;
|
||||
|
||||
const $input = $("<input>")
|
||||
.prop("tabindex", 200 + definitionAttr.position)
|
||||
.prop("id", id)
|
||||
.attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one
|
||||
.attr("data-attribute-type", valueAttr.type)
|
||||
.attr("data-attribute-name", valueAttr.name)
|
||||
.prop("value", valueAttr.value)
|
||||
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
|
||||
.addClass("form-control")
|
||||
.addClass("promoted-attribute-input")
|
||||
.on("change", (event) => this.promotedAttributeChanged(event));
|
||||
|
||||
const $actionCell = $("<div>");
|
||||
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
|
||||
|
||||
const $wrapper = $('<div class="promoted-attribute-cell">')
|
||||
.append(
|
||||
$("<label>")
|
||||
.prop("for", id)
|
||||
.text(definition.promotedAlias ?? valueName)
|
||||
)
|
||||
.append($("<div>").addClass("input-group").append($input))
|
||||
.append($actionCell)
|
||||
.append($multiplicityCell);
|
||||
|
||||
if (valueAttr.type === "label") {
|
||||
$wrapper.addClass(`promoted-attribute-label-${definition.labelType}`);
|
||||
if (definition.labelType === "text") {
|
||||
@@ -165,6 +359,8 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
||||
|
||||
if (definition.multiplicity === "multi") {
|
||||
const $addButton = $("<span>")
|
||||
.addClass("bx bx-plus pointer tn-tool-button")
|
||||
.prop("title", t("promoted_attributes.add_new_attribute"))
|
||||
.on("click", async () => {
|
||||
const $new = await this.createPromotedAttributeCell(
|
||||
definitionAttr,
|
||||
|
||||
@@ -3,13 +3,12 @@ import { ComponentChildren } from "preact";
|
||||
interface AdmonitionProps {
|
||||
type: "warning" | "note" | "caution";
|
||||
children: ComponentChildren;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Admonition({ type, children, className }: AdmonitionProps) {
|
||||
export default function Admonition({ type, children }: AdmonitionProps) {
|
||||
return (
|
||||
<div className={`admonition ${type} ${className}`} role="alert">
|
||||
<div className={`admonition ${type}`} role="alert">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,16 +27,15 @@ 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, onInitialized, ...restProps }: CKEditorOpts) {
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
export default function CKEditor({ apiRef, currentValue, editor, config, disableNewlines, disableSpellcheck, onChange, onClick, ...restProps }: CKEditorOpts) {
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const textEditorRef = useRef<CKTextEditor>(null);
|
||||
useImperativeHandle(apiRef, () => {
|
||||
return {
|
||||
focus() {
|
||||
textEditorRef.current?.editing.view.focus();
|
||||
editorContainerRef.current?.focus();
|
||||
textEditorRef.current?.model.change((writer) => {
|
||||
const documentRoot = textEditorRef.current?.editing.model.document.getRoot();
|
||||
if (documentRoot) {
|
||||
@@ -84,8 +83,6 @@ export default function CKEditor({ apiRef, currentValue, editor, config, disable
|
||||
if (currentValue) {
|
||||
textEditor.setData(currentValue);
|
||||
}
|
||||
|
||||
onInitialized?.(textEditor);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -106,4 +103,4 @@ export default function CKEditor({ apiRef, currentValue, editor, config, disable
|
||||
{...restProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,6 @@ 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;
|
||||
@@ -121,24 +119,21 @@ export function FormListItem({ className, icon, value, title, active, disabled,
|
||||
<Icon icon={icon} />
|
||||
{description ? (
|
||||
<div>
|
||||
<FormListContent description={description} disabled={disabled} {...contentProps} />
|
||||
<FormListContent description={description} {...contentProps} />
|
||||
</div>
|
||||
) : (
|
||||
<FormListContent description={description} disabled={disabled} {...contentProps} />
|
||||
<FormListContent description={description} {...contentProps} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function FormListContent({ children, badges, description, disabled, disabledTooltip }: Pick<FormListItemOpts, "children" | "badges" | "description" | "disabled" | "disabledTooltip">) {
|
||||
function FormListContent({ children, badges, description }: Pick<FormListItemOpts, "children" | "badges" | "description">) {
|
||||
return <>
|
||||
{children}
|
||||
{badges && badges.map(({ className, text }) => (
|
||||
<span className={`badge ${className ?? ""}`}>{text}</span>
|
||||
))}
|
||||
{disabled && disabledTooltip && (
|
||||
<span class="bx bx-info-circle disabled-tooltip" title={disabledTooltip} />
|
||||
)}
|
||||
{description && <div className="description">{description}</div>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,17 @@ import { openInAppHelpFromUrl } from "../../services/utils";
|
||||
interface HelpButtonProps {
|
||||
className?: string;
|
||||
helpPage: string;
|
||||
title?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function HelpButton({ className, helpPage, title, style }: HelpButtonProps) {
|
||||
export default function HelpButton({ className, helpPage, style }: HelpButtonProps) {
|
||||
return (
|
||||
<button
|
||||
class={`${className ?? ""} icon-action bx bx-help-circle`}
|
||||
type="button"
|
||||
onClick={() => openInAppHelpFromUrl(helpPage)}
|
||||
title={title ?? t("open-help-page")}
|
||||
title={t("open-help-page")}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.info-bar {
|
||||
--link-color: currentColor;
|
||||
|
||||
margin-top: 4px;
|
||||
contain: unset !important;
|
||||
padding: 8px 20px;
|
||||
color: var(--read-only-note-info-bar-color);
|
||||
font-size: .9em;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.info-bar-prominent {
|
||||
background: var(--alert-bar-background, var(--accented-background-color));
|
||||
}
|
||||
|
||||
.info-bar-subtle {
|
||||
color: var(--muted-text-color);
|
||||
background: var(--main-background-color);
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
margin-block: 0;
|
||||
padding-inline: 22px;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user