mirror of
https://github.com/zadam/trilium.git
synced 2025-11-13 08:45:50 +01:00
Compare commits
270 Commits
v0.99.5
...
react/prom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54c8322960 | ||
|
|
3d0d1fa36e | ||
|
|
3f93d191b3 | ||
|
|
3a83e7f632 | ||
|
|
2acede95d7 | ||
|
|
052a60ba58 | ||
|
|
a633c6eecd | ||
|
|
b6561e6375 | ||
|
|
7af9df2ee3 | ||
|
|
3c4b7fd490 | ||
|
|
f5ed4007e3 | ||
|
|
f26469bdc2 | ||
|
|
3326d803a0 | ||
|
|
b60a28c52c | ||
|
|
139c60ac73 | ||
|
|
c7ad6131cb | ||
|
|
86e2a762a4 | ||
|
|
bf9f880a21 | ||
|
|
5491aaab85 | ||
|
|
40568ac547 | ||
|
|
0039e5e60c | ||
|
|
813e2e8c9b | ||
|
|
f186e929b2 | ||
|
|
31d2abf954 | ||
|
|
bd69280735 | ||
|
|
b86c656895 | ||
|
|
bc32fe749d | ||
|
|
5970a242c9 | ||
|
|
4fc9848a3b | ||
|
|
b8de5b3348 | ||
|
|
5a01f75d67 | ||
|
|
d0a994c102 | ||
|
|
e883f32f89 | ||
|
|
a6fc54cb81 | ||
|
|
76bd6a5ab9 | ||
|
|
3b5d749d86 | ||
|
|
6761f741ca | ||
|
|
a1c959aabd | ||
|
|
4d1ebd011c | ||
|
|
2947967b79 | ||
|
|
52691b9e88 | ||
|
|
daba806e12 | ||
|
|
d7f7049b5d | ||
|
|
e811db3651 | ||
|
|
a1d38b6bb8 | ||
|
|
c781eab061 | ||
|
|
fb91d4fbd4 | ||
|
|
cd6fcbd283 | ||
|
|
0ccc350ddf | ||
|
|
efa7fd0b7d | ||
|
|
5f4d0325aa | ||
|
|
763fa0b515 | ||
|
|
7ba91b7a9d | ||
|
|
7fc9f08843 | ||
|
|
e28794d706 | ||
|
|
ae1c8f0a0b | ||
|
|
1257e46852 | ||
|
|
40a7f286a3 | ||
|
|
652cccadd7 | ||
|
|
308fd00508 | ||
|
|
35f413505c | ||
|
|
a82d15e83d | ||
|
|
24b169d667 | ||
|
|
b36ef54507 | ||
|
|
c772430dd0 | ||
|
|
d4194c503c | ||
|
|
33a41d2f86 | ||
|
|
319e28387f | ||
|
|
0b740bb007 | ||
|
|
bb7fa9a2e6 | ||
|
|
5e83e6fa34 | ||
|
|
8828e36624 | ||
|
|
bca0846565 | ||
|
|
8fef28dcc7 | ||
|
|
040ffe945a | ||
|
|
4997543fc7 | ||
|
|
63c91b6741 | ||
|
|
27b6e26fa5 | ||
|
|
7930745a01 | ||
|
|
6ffe8a2eb5 | ||
|
|
0dcaa8719f | ||
|
|
608605af12 | ||
|
|
3f7b8447d0 | ||
|
|
d3594e4a05 | ||
|
|
156b4101a5 | ||
|
|
73213d2a17 | ||
|
|
763bcbd394 | ||
|
|
d90043e586 | ||
|
|
c209a699ea | ||
|
|
22069d0aef | ||
|
|
3248654820 | ||
|
|
269c7c9ce7 | ||
|
|
b0c984decd | ||
|
|
cebb54ddf6 | ||
|
|
22f8929da6 | ||
|
|
7192d40e80 | ||
|
|
df9d481a93 | ||
|
|
cf37549f19 | ||
|
|
d2dda95654 | ||
|
|
0770f97010 | ||
|
|
3caaf2ab79 | ||
|
|
8f819a7786 | ||
|
|
0da66617a8 | ||
|
|
5efe05490d | ||
|
|
656b234740 | ||
|
|
e6e9cd3f35 | ||
|
|
845c76fc42 | ||
|
|
a4d6da72a1 | ||
|
|
35438d2599 | ||
|
|
9a1e7ca3ae | ||
|
|
2d29d1b41f | ||
|
|
ad5ff6e41a | ||
|
|
20dcbff68f | ||
|
|
c127e19cfa | ||
|
|
e32237559e | ||
|
|
09811d23f6 | ||
|
|
b41042fec4 | ||
|
|
08fae19d19 | ||
|
|
9cceff4f02 | ||
|
|
67d9154795 | ||
|
|
1eca9f6541 | ||
|
|
c469fffb6e | ||
|
|
d076d54170 | ||
|
|
3256c14a20 | ||
|
|
460e01a2d6 | ||
|
|
1913355069 | ||
|
|
f687d91201 | ||
|
|
e8e93e985d | ||
|
|
c5c304f85b | ||
|
|
58aea03114 | ||
|
|
4af842d2f2 | ||
|
|
3b2f5bb09d | ||
|
|
2d67aab288 | ||
|
|
838d761b50 | ||
|
|
7a2d91e7de | ||
|
|
082ea7b5c1 | ||
|
|
c58414bbc1 | ||
|
|
1c1243912b | ||
|
|
614fc66890 | ||
|
|
0937ef72e2 | ||
|
|
3571023685 | ||
|
|
2cd3e3f9c8 | ||
|
|
3d08f686cf | ||
|
|
d2bf972305 | ||
|
|
39bd236799 | ||
|
|
d8b9d14712 | ||
|
|
9d4127ba6d | ||
|
|
04b678ef4c | ||
|
|
286d7c8228 | ||
|
|
5547c3fc2b | ||
|
|
4381399978 | ||
|
|
5bfa0d13e3 | ||
|
|
5c21759de9 | ||
|
|
e2ef58ed50 | ||
|
|
7af610a5b4 | ||
|
|
8a442ba492 | ||
|
|
3ed399a888 | ||
|
|
37d33fb975 | ||
|
|
d443d79685 | ||
|
|
a975576214 | ||
|
|
3673162a48 | ||
|
|
0ac428b57a | ||
|
|
45bd9b72b9 | ||
|
|
cc6ac7d1da | ||
|
|
232fe4e63a | ||
|
|
597426f10d | ||
|
|
a0a904766f | ||
|
|
db46ca0a76 | ||
|
|
a26ee0d769 | ||
|
|
46db047fa0 | ||
|
|
efaa1815ec | ||
|
|
2eab8b92d5 | ||
|
|
8a185262fb | ||
|
|
f6631b7b9a | ||
|
|
1e323de01b | ||
|
|
f00f2ee5e4 | ||
|
|
78b83cd17b | ||
|
|
adea3abff4 | ||
|
|
206618fd54 | ||
|
|
58a6d70cbb | ||
|
|
44b92a024c | ||
|
|
68bf5b7e68 | ||
|
|
8c85aa343c | ||
|
|
592a8b2232 | ||
|
|
e1ac319a7b | ||
|
|
763c489cd3 | ||
|
|
b990770e48 | ||
|
|
344607d437 | ||
|
|
70d0a5441a | ||
|
|
61278e1f5a | ||
|
|
b73ea6ac4f | ||
|
|
5d833c1ac4 | ||
|
|
2947682783 | ||
|
|
fb46e09428 | ||
|
|
ff941b2cb1 | ||
|
|
a8007b9063 | ||
|
|
2f3c2bbac8 | ||
|
|
e4eb96a1ae | ||
|
|
ffe4e9b8de | ||
|
|
f2b4f49be2 | ||
|
|
376ef0c679 | ||
|
|
b7574b3ca7 | ||
|
|
ae1954c320 | ||
|
|
3171413a18 | ||
|
|
dc73467d34 | ||
|
|
58b14ae31c | ||
|
|
e117fbd471 | ||
|
|
9a3f675950 | ||
|
|
26400f2590 | ||
|
|
7d99a92bd9 | ||
|
|
3417e37f16 | ||
|
|
143e6a556c | ||
|
|
02259c55f3 | ||
|
|
cc19a217ad | ||
|
|
d95ed4a5d2 | ||
|
|
469683f30f | ||
|
|
42d0cc12b5 | ||
|
|
b376842e2d | ||
|
|
145ff1a2a5 | ||
|
|
8e9f5fb486 | ||
|
|
3dd757a857 | ||
|
|
bde7b753a0 | ||
|
|
02017ebd9d | ||
|
|
8caaa99415 | ||
|
|
c49b90d33f | ||
|
|
6dd939df14 | ||
|
|
b19da81572 | ||
|
|
425ffc02d8 | ||
|
|
695e8489ad | ||
|
|
2f4e13b1bb | ||
|
|
c8a9b994d6 | ||
|
|
3d5b319eb2 | ||
|
|
bed3c2dc67 | ||
|
|
256d1863d2 | ||
|
|
4a4502dfea | ||
|
|
91f21e149b | ||
|
|
6ef468adc4 | ||
|
|
e576fa03da | ||
|
|
6bcce08042 | ||
|
|
f496caa92c | ||
|
|
43dcdf8925 | ||
|
|
2c014fb071 | ||
|
|
2273507ef4 | ||
|
|
70a710be79 | ||
|
|
7a3ee7971c | ||
|
|
c86123e3a9 | ||
|
|
9480227b69 | ||
|
|
79be13e6c7 | ||
|
|
63e3a27b34 | ||
|
|
9eae6620d0 | ||
|
|
6517dd1190 | ||
|
|
f72087acc3 | ||
|
|
77e7c414b6 | ||
|
|
3a68395ca7 | ||
|
|
0a0d9775b2 | ||
|
|
aa6e68ad39 | ||
|
|
034073a5e1 | ||
|
|
d83ff641d7 | ||
|
|
071fcb85c9 | ||
|
|
daa5ee93e9 | ||
|
|
db7cda3fe6 | ||
|
|
fa55c5720e | ||
|
|
d1a9890932 | ||
|
|
c9fe358811 | ||
|
|
bbb927c83f | ||
|
|
07b86c8cf7 | ||
|
|
3dbf20af52 | ||
|
|
1fb329565f | ||
|
|
06bfb0073a | ||
|
|
3d64c320fb |
@@ -9,9 +9,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"packageManager": "pnpm@10.21.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.11.0",
|
||||
"@redocly/cli": "2.11.1",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.2",
|
||||
"react": "19.2.0",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "16.5.0",
|
||||
"i18next": "25.6.1",
|
||||
"i18next": "25.6.2",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
|
||||
@@ -13,7 +13,6 @@ import MainTreeExecutors from "./main_tree_executors.js";
|
||||
import toast from "../services/toast.js";
|
||||
import ShortcutComponent from "./shortcut_component.js";
|
||||
import { t, initLocale } from "../services/i18n.js";
|
||||
import type NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
|
||||
@@ -21,8 +20,6 @@ import type LoadResults from "../services/load_results.js";
|
||||
import type { Attribute } from "../services/attribute_parser.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
|
||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
|
||||
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
|
||||
import type { NativeImage, TouchBar } from "electron";
|
||||
import TouchBarComponent from "./touch_bar.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
@@ -33,6 +30,10 @@ import { ColumnComponent } from "tabulator-tables";
|
||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
import { SqlExecuteResults } from "@triliumnext/commons";
|
||||
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
|
||||
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
|
||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootContainer;
|
||||
@@ -199,7 +200,7 @@ export type CommandMappings = {
|
||||
resetLauncher: ContextMenuCommandData;
|
||||
|
||||
executeInActiveNoteDetailWidget: CommandData & {
|
||||
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
|
||||
callback: (value: ReactWrappedWidget) => void;
|
||||
};
|
||||
executeWithTextEditor: CommandData &
|
||||
ExecuteCommandData<CKTextEditor> & {
|
||||
@@ -211,7 +212,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<TypeWidget | null>;
|
||||
executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>;
|
||||
addTextToActiveEditor: CommandData & {
|
||||
text: string;
|
||||
};
|
||||
@@ -221,9 +222,9 @@ export type CommandMappings = {
|
||||
showPasswordNotSet: CommandData;
|
||||
showProtectedSessionPasswordDialog: CommandData;
|
||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
||||
showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
showIncludeNoteDialog: CommandData & IncludeNoteOpts;
|
||||
showAddLinkDialog: CommandData & AddLinkOpts;
|
||||
showPasteMarkdownDialog: CommandData & MarkdownImportOpts;
|
||||
closeProtectedSessionPasswordDialog: CommandData;
|
||||
copyImageReferenceToClipboard: CommandData;
|
||||
copyImageToClipboard: CommandData;
|
||||
@@ -328,6 +329,7 @@ export type CommandMappings = {
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
openNoteCustom: CommandData;
|
||||
openNoteOnServer: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
@@ -485,13 +487,8 @@ type EventMappings = {
|
||||
relationMapResetZoomIn: { ntxId: string | null | undefined };
|
||||
relationMapResetZoomOut: { ntxId: string | null | undefined };
|
||||
activeNoteChanged: {};
|
||||
showAddLinkDialog: {
|
||||
textTypeWidget: EditableTextTypeWidget;
|
||||
text: string;
|
||||
};
|
||||
showIncludeDialog: {
|
||||
textTypeWidget: EditableTextTypeWidget;
|
||||
};
|
||||
showAddLinkDialog: AddLinkOpts;
|
||||
showIncludeDialog: IncludeNoteOpts;
|
||||
openBulkActionsDialog: {
|
||||
selectedOrActiveNoteIds: string[];
|
||||
};
|
||||
@@ -670,6 +667,10 @@ export class AppContext extends Component {
|
||||
this.beforeUnloadListeners.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
removeBeforeUnloadListener(listener: (() => boolean)) {
|
||||
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener);
|
||||
}
|
||||
}
|
||||
|
||||
const appContext = new AppContext(window.glob.isMainWindow);
|
||||
|
||||
@@ -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<TypeWidget | null>((resolve) =>
|
||||
new Promise<ReactWrappedWidget | null>((resolve) =>
|
||||
appContext.triggerCommand("executeWithTypeWidget", {
|
||||
resolve,
|
||||
ntxId: this.ntxId
|
||||
|
||||
@@ -66,6 +66,13 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
openNoteOnServerCommand() {
|
||||
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||
if (noteId) {
|
||||
openService.openNoteOnServer(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
enterProtectedSessionCommand() {
|
||||
protectedSessionService.enterProtectedSession();
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ 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);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
@@ -22,7 +21,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import options from "../services/options.js";
|
||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||
@@ -44,6 +42,8 @@ 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";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@@ -140,9 +140,9 @@ export default class DesktopLayout {
|
||||
.child(<ReadOnlyNoteInfoBar />)
|
||||
.child(<SharedInfo />)
|
||||
)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(<PromotedAttributes />)
|
||||
.child(<SqlTableSchemas />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.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(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" displayOnlyCollections />))
|
||||
.child(<CallToActionDialog />);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import ContentHeader from "../widgets/containers/content-header.js";
|
||||
@@ -29,6 +27,8 @@ import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibb
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -161,7 +161,7 @@ export default class MobileLayout {
|
||||
.child(<ReadOnlyNoteInfoBar />)
|
||||
.child(<SharedInfoWidget />)
|
||||
)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
|
||||
.child(<SearchResult />)
|
||||
|
||||
@@ -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 from "../widgets/basic_widget.js";
|
||||
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import SpacedUpdate from "./spaced_update.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import dialogService from "./dialog.js";
|
||||
@@ -19,7 +19,6 @@ 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";
|
||||
|
||||
@@ -317,7 +316,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<NoteDetailWidget>;
|
||||
getActiveNoteDetailWidget(): Promise<ReactWrappedWidget>;
|
||||
/**
|
||||
* @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, { type CommandNames } from "../components/app_context.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
@@ -30,12 +30,18 @@ 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 ?? []) {
|
||||
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
if (binding) {
|
||||
bindings.push(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
|
||||
export 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,4 +1,5 @@
|
||||
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;
|
||||
@@ -126,7 +127,7 @@ function downloadRevision(noteId: string, revisionId: string) {
|
||||
/**
|
||||
* @param url - should be without initial slash!!!
|
||||
*/
|
||||
function getUrlForDownload(url: string) {
|
||||
export function getUrlForDownload(url: string) {
|
||||
if (utils.isElectron()) {
|
||||
// electron needs absolute URL, so we extract current host, port, protocol
|
||||
return `${getHost()}/${url}`;
|
||||
@@ -171,6 +172,21 @@ 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()) {
|
||||
@@ -198,5 +214,6 @@ export default {
|
||||
openAttachmentExternally,
|
||||
openNoteCustom,
|
||||
openAttachmentCustom,
|
||||
openNoteOnServer,
|
||||
openDirectory
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import utils from "./utils.js";
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
interface ShortcutBinding {
|
||||
export interface ShortcutBinding {
|
||||
element: HTMLElement | Document;
|
||||
shortcut: string;
|
||||
handler: Handler;
|
||||
@@ -127,10 +127,20 @@ 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) {
|
||||
|
||||
@@ -173,7 +173,7 @@ const entityMap: Record<string, string> = {
|
||||
"=": "="
|
||||
};
|
||||
|
||||
function escapeHtml(str: string) {
|
||||
export function escapeHtml(str: string) {
|
||||
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
|
||||
}
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ body.desktop .tabulator-popup-container,
|
||||
.dropdown-menu .disabled .disabled-tooltip {
|
||||
pointer-events: all;
|
||||
margin-inline-start: 8px;
|
||||
font-size: 0.5em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--disabled-tooltip-icon-color);
|
||||
cursor: help;
|
||||
opacity: 0.75;
|
||||
|
||||
@@ -581,9 +581,14 @@ 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 {
|
||||
|
||||
@@ -42,7 +42,7 @@ div.promoted-attributes-container {
|
||||
*/
|
||||
|
||||
/* The property label */
|
||||
.note-info-widget-table th,
|
||||
.note-info-item > span:first-child,
|
||||
.file-properties-widget .file-table th,
|
||||
.image-properties > div:first-child > span > strong {
|
||||
opacity: 0.65;
|
||||
@@ -50,7 +50,6 @@ div.promoted-attributes-container {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.note-info-widget-table td,
|
||||
.file-properties-widget .file-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -995,7 +995,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
||||
"start_session_button": "开始受保护的会话",
|
||||
"started": "受保护的会话已启动。",
|
||||
"wrong_password": "密码错误。",
|
||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||
|
||||
@@ -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 <kbd>Eingabetaste</kbd>",
|
||||
"start_session_button": "Starte eine geschützte Sitzung",
|
||||
"started": "Geschützte Sitzung gestartet.",
|
||||
"wrong_password": "Passwort falsch.",
|
||||
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
"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",
|
||||
@@ -995,7 +996,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Showing protected note requires entering your password:",
|
||||
"start_session_button": "Start protected session <kbd>enter</kbd>",
|
||||
"start_session_button": "Start protected session",
|
||||
"started": "Protected session has been started.",
|
||||
"wrong_password": "Wrong password.",
|
||||
"protecting-finished-successfully": "Protecting finished successfully.",
|
||||
|
||||
@@ -39,7 +39,10 @@
|
||||
"help_on_tree_prefix": "Ayuda sobre el prefijo del árbol",
|
||||
"prefix": "Prefijo: ",
|
||||
"save": "Guardar",
|
||||
"branch_prefix_saved": "Se ha guardado el prefijo de rama."
|
||||
"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}}):"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Acciones en bloque",
|
||||
@@ -992,7 +995,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Para mostrar una nota protegida es necesario ingresar su contraseña:",
|
||||
"start_session_button": "Iniciar sesión protegida <kbd>Enter</kbd>",
|
||||
"start_session_button": "Iniciar sesión protegida",
|
||||
"started": "La sesión protegida ha iniciado.",
|
||||
"wrong_password": "Contraseña incorrecta.",
|
||||
"protecting-finished-successfully": "La protección finalizó exitosamente.",
|
||||
@@ -1107,7 +1110,8 @@
|
||||
"title": "Ancho del contenido",
|
||||
"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"
|
||||
"max_width_unit": "píxeles",
|
||||
"centerContent": "Mantener el contenido centrado"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Barra de título nativa (requiere reiniciar la aplicación)",
|
||||
@@ -2079,5 +2083,14 @@
|
||||
},
|
||||
"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 <kbd>Entrée</kbd>",
|
||||
"start_session_button": "Démarrer une session protégée",
|
||||
"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.",
|
||||
|
||||
@@ -39,7 +39,10 @@
|
||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||
"prefix": "Prefisso: ",
|
||||
"save": "Salva",
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
||||
"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}}):"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Azioni massive",
|
||||
@@ -1499,7 +1502,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Per visualizzare la nota protetta è necessario inserire la password:",
|
||||
"start_session_button": "Avvia sessione protetta <kbd>invio</kbd>",
|
||||
"start_session_button": "Avvia sessione protetta",
|
||||
"started": "La sessione protetta è stata avviata.",
|
||||
"wrong_password": "Password errata.",
|
||||
"protecting-finished-successfully": "Protezione completata con successo.",
|
||||
@@ -1570,7 +1573,8 @@
|
||||
"title": "Larghezza del contenuto",
|
||||
"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"
|
||||
"max_width_unit": "pixel",
|
||||
"centerContent": "Mantieni il contenuto centrato"
|
||||
},
|
||||
"native_title_bar": {
|
||||
"title": "Barra del titolo nativa (richiede il riavvio dell'app)",
|
||||
@@ -2080,5 +2084,14 @@
|
||||
},
|
||||
"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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +836,8 @@
|
||||
"title": "コンテンツ幅",
|
||||
"default_description": "Triliumは、ワイドスクリーンで最大化された画面での可読性を向上させるために、デフォルトでコンテンツの最大幅を制限しています。",
|
||||
"max_width_label": "最大コンテンツ幅",
|
||||
"max_width_unit": "ピクセル"
|
||||
"max_width_unit": "ピクセル",
|
||||
"centerContent": "コンテンツを中央に配置"
|
||||
},
|
||||
"theme": {
|
||||
"title": "アプリのテーマ",
|
||||
@@ -1783,7 +1784,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "保護されたノートを表示するにはパスワードを入力する必要があります:",
|
||||
"start_session_button": "保護されたセッションを開始 <kbd>enter</kbd>",
|
||||
"start_session_button": "保護されたセッションを開始",
|
||||
"started": "保護されたセッションが開始されました。",
|
||||
"wrong_password": "パスワードが間違っています。",
|
||||
"protecting-finished-successfully": "保護が正常に完了しました。",
|
||||
@@ -2082,5 +2083,11 @@
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "ノートを削除..."
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "現在、読み取り専用のノートを表示しています。",
|
||||
"auto-read-only-note": "このノートは読み込みを高速化するために読み取り専用モードで表示されています。",
|
||||
"auto-read-only-learn-more": "さらに詳しく",
|
||||
"edit-note": "ノートを編集"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <kbd>enter</kbd>",
|
||||
"start_session_button": "Iniciar sessão protegida",
|
||||
"started": "A sessão protegida foi iniciada.",
|
||||
"wrong_password": "Palavra-passe incorreta.",
|
||||
"protecting-finished-successfully": "A proteção foi finalizada com sucesso.",
|
||||
|
||||
@@ -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 <kbd>enter</kbd>"
|
||||
"start_session_button": "Iniciar sessão protegida"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir em nova aba",
|
||||
|
||||
@@ -982,7 +982,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Afișarea notițelor protejate necesită introducerea parolei:",
|
||||
"start_session_button": "Deschide sesiunea protejată <kbd>enter</kbd>",
|
||||
"start_session_button": "Deschide sesiunea protejată",
|
||||
"started": "Sesiunea protejată este activă.",
|
||||
"wrong_password": "Parolă greșită.",
|
||||
"protecting-finished-successfully": "Protejarea a avut succes.",
|
||||
|
||||
@@ -1685,7 +1685,7 @@
|
||||
"unprotecting-title": "Статус снятия защиты",
|
||||
"protecting-finished-successfully": "Защита успешно завершена.",
|
||||
"unprotecting-finished-successfully": "Снятие защиты успешно завершено.",
|
||||
"start_session_button": "Начать защищенный сеанс <kbd>enter</kbd>",
|
||||
"start_session_button": "Начать защищенный сеанс",
|
||||
"protecting-in-progress": "Защита в процессе: {{count}}",
|
||||
"unprotecting-in-progress-count": "Снятие защиты в процессе: {{count}}",
|
||||
"started": "Защищенный сеанс запущен.",
|
||||
|
||||
@@ -992,7 +992,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
|
||||
"start_session_button": "開始受保護的作業階段 <kbd>Enter</kbd>",
|
||||
"start_session_button": "開始受保護的作業階段",
|
||||
"started": "已啟動受保護的作業階段。",
|
||||
"wrong_password": "密碼錯誤。",
|
||||
"protecting-finished-successfully": "已成功完成保護操作。",
|
||||
|
||||
@@ -1089,7 +1089,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Для відображення захищеної нотатки потрібно ввести пароль:",
|
||||
"start_session_button": "Розпочати захищений сеанс <kbd>enter</kbd>",
|
||||
"start_session_button": "Розпочати захищений сеанс",
|
||||
"started": "Захищений сеанс розпочато.",
|
||||
"wrong_password": "Неправильний пароль.",
|
||||
"protecting-finished-successfully": "Захист успішно завершено.",
|
||||
|
||||
11
apps/client/src/types-lib.d.ts
vendored
11
apps/client/src/types-lib.d.ts
vendored
@@ -60,3 +60,14 @@ 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,11 +118,17 @@ 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(): unknown;
|
||||
getTransform(): PanZoomTransform;
|
||||
dispose(): void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
|
||||
]
|
||||
|
||||
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
|
||||
const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("backend_log.refresh")}
|
||||
icon="bx bx-refresh"
|
||||
@@ -89,7 +89,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
|
||||
|
||||
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
@@ -240,7 +240,7 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
|
||||
|
||||
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
|
||||
const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
|
||||
const isEnabled = ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "")
|
||||
&& note?.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && (
|
||||
|
||||
15
apps/client/src/widgets/NoteDetail.css
Normal file
15
apps/client/src/widgets/NoteDetail.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.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;
|
||||
}
|
||||
324
apps/client/src/widgets/NoteDetail.tsx
Normal file
324
apps/client/src/widgets/NoteDetail.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
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;
|
||||
}
|
||||
91
apps/client/src/widgets/PromotedAttributes.css
Normal file
91
apps/client/src/widgets/PromotedAttributes.css
Normal file
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
}
|
||||
120
apps/client/src/widgets/PromotedAttributes.tsx
Normal file
120
apps/client/src/widgets/PromotedAttributes.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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,207 +0,0 @@
|
||||
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,8 +4,6 @@ import froca from "../services/froca.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import { renderReactWidget } from "./react/react_utils.jsx";
|
||||
import { EventNames, EventData } from "../components/app_context.js";
|
||||
import { Handler } from "leaflet";
|
||||
|
||||
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
|
||||
protected attrs: Record<string, string>;
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import server from "../../services/server.js";
|
||||
import dialogService from "../../services/dialog.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import openService from "../../services/open.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { Dropdown } from "bootstrap";
|
||||
import type FAttachment from "../../entities/fattachment.js";
|
||||
import type AttachmentDetailWidget from "../attachment_detail.js";
|
||||
import type { NoteRow } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<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 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Calendar from "./calendar";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import "./index.css";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
||||
import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { Calendar as FullCalendar } from "@fullcalendar/core";
|
||||
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
||||
import dialog from "../../../services/dialog";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Map from "./map";
|
||||
import "./index.css";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks";
|
||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
|
||||
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
|
||||
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.scrolling-container {
|
||||
overflow: auto;
|
||||
scroll-behavior: smooth;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-split.type-code:not(.mime-text-x-sqlite) > .scrolling-container {
|
||||
background-color: var(--code-background-color);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { CommandListenerData, EventData, EventNames } from "../../component
|
||||
import type NoteContext from "../../components/note_context.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import Container from "./container.js";
|
||||
import "./scrolling_container.css";
|
||||
|
||||
export default class ScrollingContainer extends Container<BasicWidget> {
|
||||
|
||||
@@ -11,9 +12,6 @@ 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,7 +6,6 @@ import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { useRef, useState, useEffect } from "preact/hooks";
|
||||
import tree from "../../services/tree";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import { default as TextTypeWidget } from "../type_widgets/editable_text.js";
|
||||
import { logError } from "../../services/ws";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
@@ -14,29 +13,32 @@ import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
||||
|
||||
export interface AddLinkOpts {
|
||||
text: string;
|
||||
hasSelection: boolean;
|
||||
addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export default function AddLinkDialog() {
|
||||
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>();
|
||||
const initialText = useRef<string>();
|
||||
const [ opts, setOpts ] = useState<AddLinkOpts>();
|
||||
const [ linkTitle, setLinkTitle ] = useState("");
|
||||
const hasSelection = textTypeWidget?.hasSelection();
|
||||
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
|
||||
const [ linkType, setLinkType ] = useState<LinkType>();
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const hasSubmittedRef = useRef(false);
|
||||
|
||||
useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
initialText.current = text;
|
||||
useTriliumEvent("showAddLinkDialog", opts => {
|
||||
setOpts(opts);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSelection) {
|
||||
if (opts?.hasSelection) {
|
||||
setLinkType("hyper-link");
|
||||
} else {
|
||||
setLinkType("reference-link");
|
||||
}
|
||||
}, [ hasSelection ])
|
||||
}, [ opts ]);
|
||||
|
||||
async function setDefaultLinkTitle(noteId: string) {
|
||||
const noteTitle = await tree.getNoteTitle(noteId);
|
||||
@@ -71,10 +73,10 @@ export default function AddLinkDialog() {
|
||||
|
||||
function onShown() {
|
||||
const $autocompleteEl = refToJQuerySelector(autocompleteRef);
|
||||
if (!initialText.current) {
|
||||
if (!opts?.text) {
|
||||
note_autocomplete.showRecentNotes($autocompleteEl);
|
||||
} else {
|
||||
note_autocomplete.setText($autocompleteEl, initialText.current);
|
||||
note_autocomplete.setText($autocompleteEl, opts.text);
|
||||
}
|
||||
|
||||
// to be able to quickly remove entered text
|
||||
@@ -108,15 +110,15 @@ export default function AddLinkDialog() {
|
||||
onShown={onShown}
|
||||
onHidden={() => {
|
||||
// Insert the link.
|
||||
if (hasSubmittedRef.current && suggestion && textTypeWidget) {
|
||||
if (hasSubmittedRef.current && suggestion && opts) {
|
||||
hasSubmittedRef.current = false;
|
||||
|
||||
if (suggestion.notePath) {
|
||||
// Handle note link
|
||||
textTypeWidget.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
} else if (suggestion.externalLink) {
|
||||
// Handle external link
|
||||
textTypeWidget.addLink(suggestion.externalLink, linkTitle, true);
|
||||
opts.addLink(suggestion.externalLink, linkTitle, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ export default function AddLinkDialog() {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{!hasSelection && (
|
||||
{!opts?.hasSelection && (
|
||||
<div className="add-link-title-settings">
|
||||
{(linkType !== "external-link") && (
|
||||
<>
|
||||
|
||||
@@ -8,17 +8,21 @@ import Button from "../react/Button";
|
||||
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
|
||||
import tree from "../../services/tree";
|
||||
import froca from "../../services/froca";
|
||||
import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
|
||||
|
||||
export interface IncludeNoteOpts {
|
||||
editorApi: CKEditorApi;
|
||||
}
|
||||
|
||||
export default function IncludeNoteDialog() {
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const editorApiRef = useRef<CKEditorApi>(null);
|
||||
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
|
||||
const [boxSize, setBoxSize] = useState("medium");
|
||||
const [boxSize, setBoxSize] = useState<string>("medium");
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
|
||||
editorApiRef.current = editorApi;
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
@@ -32,12 +36,9 @@ export default function IncludeNoteDialog() {
|
||||
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
|
||||
onHidden={() => setShown(false)}
|
||||
onSubmit={() => {
|
||||
if (!suggestion?.notePath || !textTypeWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!suggestion?.notePath || !editorApiRef.current) return;
|
||||
setShown(false);
|
||||
includeNote(suggestion.notePath, textTypeWidget, boxSize as BoxSize);
|
||||
includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
|
||||
}}
|
||||
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
||||
show={shown}
|
||||
@@ -69,7 +70,7 @@ export default function IncludeNoteDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWidget, boxSize: BoxSize) {
|
||||
async function includeNote(notePath: string, editorApi: CKEditorApi, boxSize: BoxSize) {
|
||||
const noteId = tree.getNoteIdFromUrl(notePath);
|
||||
if (!noteId) {
|
||||
return;
|
||||
@@ -79,8 +80,8 @@ async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWid
|
||||
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
|
||||
textTypeWidget.addImage(noteId);
|
||||
editorApi.addImage(noteId);
|
||||
} else {
|
||||
textTypeWidget.addIncludeNote(noteId, boxSize);
|
||||
editorApi.addIncludeNote(noteId, boxSize);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useRef, useState } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
@@ -7,7 +6,11 @@ import utils from "../../services/utils";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
||||
import { CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
|
||||
|
||||
export interface MarkdownImportOpts {
|
||||
editorApi: CKEditorApi;
|
||||
}
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
@@ -15,18 +18,18 @@ interface RenderMarkdownResponse {
|
||||
|
||||
export default function MarkdownImportDialog() {
|
||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const editorApiRef = useRef<CKEditorApi>(null);
|
||||
const [ text, setText ] = useState("");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
useTriliumEvent("showPasteMarkdownDialog", ({ editorApi }) => {
|
||||
if (utils.isElectron()) {
|
||||
const { clipboard } = utils.dynamicRequire("electron");
|
||||
const text = clipboard.readText();
|
||||
|
||||
convertMarkdownToHtml(text, textTypeWidget);
|
||||
convertMarkdownToHtml(text, editorApi);
|
||||
} else {
|
||||
editorApiRef.current = editorApi;
|
||||
setShown(true);
|
||||
}
|
||||
});
|
||||
@@ -37,8 +40,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 (textTypeWidget) {
|
||||
await convertMarkdownToHtml(text, textTypeWidget);
|
||||
if (editorApiRef.current) {
|
||||
await convertMarkdownToHtml(text, editorApiRef.current);
|
||||
}
|
||||
setShown(false);
|
||||
setText("");
|
||||
@@ -59,10 +62,8 @@ export default function MarkdownImportDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) {
|
||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: CKEditorApi) {
|
||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||
|
||||
await textTypeWidget.addHtmlToEditor(htmlContent);
|
||||
|
||||
textTypeWidget.addHtmlToEditor(htmlContent);
|
||||
toast.showMessage(t("markdown_import.import_success"));
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { EventNames, EventData } from "../../components/app_context.js";
|
||||
import NoteContext from "../../components/note_context.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import BasicWidget, { ReactWrappedWidget } 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">
|
||||
@@ -149,7 +148,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 TypeWidget;
|
||||
const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget;
|
||||
typeWidget.cleanup();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import SpacedUpdate from "../services/spaced_update.js";
|
||||
import server from "../services/server.js";
|
||||
import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js";
|
||||
import keyboardActionsService from "../services/keyboard_actions.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
|
||||
import EmptyTypeWidget from "./type_widgets/empty.js";
|
||||
import EditableTextTypeWidget from "./type_widgets/editable_text.js";
|
||||
import EditableCodeTypeWidget from "./type_widgets/editable_code.js";
|
||||
import FileTypeWidget from "./type_widgets/file.js";
|
||||
import ImageTypeWidget from "./type_widgets/image.js";
|
||||
import RenderTypeWidget from "./type_widgets/render.js";
|
||||
import RelationMapTypeWidget from "./type_widgets/relation_map.js";
|
||||
import CanvasTypeWidget from "./type_widgets/canvas.js";
|
||||
import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js";
|
||||
import BookTypeWidget from "./type_widgets/book.js";
|
||||
import ReadOnlyTextTypeWidget from "./type_widgets/read_only_text.js";
|
||||
import ReadOnlyCodeTypeWidget from "./type_widgets/read_only_code.js";
|
||||
import NoneTypeWidget from "./type_widgets/none.js";
|
||||
import NoteMapTypeWidget from "./type_widgets/note_map.js";
|
||||
import WebViewTypeWidget from "./type_widgets/web_view.js";
|
||||
import DocTypeWidget from "./type_widgets/doc.js";
|
||||
import ContentWidgetTypeWidget from "./type_widgets/content_widget.js";
|
||||
import AttachmentListTypeWidget from "./type_widgets/attachment_list.js";
|
||||
import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
|
||||
import MindMapWidget from "./type_widgets/mind_map.js";
|
||||
import utils, { isElectron } from "../services/utils.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type TypeWidget from "./type_widgets/type_widget.js";
|
||||
import { MermaidTypeWidget } from "./type_widgets/mermaid.js";
|
||||
import AiChatTypeWidget from "./type_widgets/ai_chat.js";
|
||||
import toast from "../services/toast.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-detail">
|
||||
<style>
|
||||
.note-detail {
|
||||
max-width: var(--max-content-width); /* Inherited from .note-split */
|
||||
font-family: var(--detail-font-family);
|
||||
font-size: var(--detail-font-size);
|
||||
}
|
||||
|
||||
body.prefers-centered-content .note-detail {
|
||||
/* Horizontally center the widget in its parent when the "Keep content centered" option is on */
|
||||
margin-inline: auto;
|
||||
}
|
||||
</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());
|
||||
}
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
import server from "../services/server.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import hoistedNoteService from "../services/hoisted_note.js";
|
||||
import appContext, { type EventData } from "../components/app_context.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import linkContextMenuService from "../menus/link_context_menu.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type ForceGraph from "force-graph";
|
||||
import type { GraphData, LinkObject, NodeObject } from "force-graph";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
const esc = utils.escapeHtml;
|
||||
|
||||
const TPL = /*html*/`<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
57
apps/client/src/widgets/note_map/NoteMap.css
Normal file
57
apps/client/src/widgets/note_map/NoteMap.css
Normal file
@@ -0,0 +1,57 @@
|
||||
.note-detail-note-map {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Style Ui Element to Drag Nodes */
|
||||
.fixnodes-type-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 10; /* should be below dropdown (note actions) */
|
||||
border-radius: .2rem;
|
||||
}
|
||||
|
||||
/* Start of styling the slider */
|
||||
.fixnodes-type-switcher input[type="range"] {
|
||||
|
||||
/* removing default appearance */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-inline-start: 15px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
/* Changing slider tracker */
|
||||
.fixnodes-type-switcher input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background-color: var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Changing Slider Thumb */
|
||||
.fixnodes-type-switcher input[type="range"]::-webkit-slider-thumb {
|
||||
/* removing default appearance */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* creating a custom design */
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
margin-top:-5px;
|
||||
background-color: var(--accented-background-color);
|
||||
border: 1px solid var(--main-text-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.fixnodes-type-switcher input[type="range"]::-moz-range-track {
|
||||
background-color: var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fixnodes-type-switcher input[type="range"]::-moz-range-thumb {
|
||||
background-color: var(--accented-background-color);
|
||||
border-color: var(--main-text-color);
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
/* End of styling the slider */
|
||||
174
apps/client/src/widgets/note_map/NoteMap.tsx
Normal file
174
apps/client/src/widgets/note_map/NoteMap.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import "./NoteMap.css";
|
||||
import { getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
|
||||
import { RefObject } from "preact";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { useElementSize, useNoteLabel } from "../react/hooks";
|
||||
import ForceGraph from "force-graph";
|
||||
import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
|
||||
import { CssData, setupRendering } from "./rendering";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { t } from "../../services/i18n";
|
||||
import link_context_menu from "../../menus/link_context_menu";
|
||||
import appContext from "../../components/app_context";
|
||||
import Slider from "../react/Slider";
|
||||
import hoisted_note from "../../services/hoisted_note";
|
||||
|
||||
interface NoteMapProps {
|
||||
note: FNote;
|
||||
widgetMode: NoteMapWidgetMode;
|
||||
parentRef: RefObject<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)
|
||||
}
|
||||
}
|
||||
120
apps/client/src/widgets/note_map/data.ts
Normal file
120
apps/client/src/widgets/note_map/data.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NoteMapLink, NoteMapPostResponse } from "@triliumnext/commons";
|
||||
import server from "../../services/server";
|
||||
import { LinkObject, NodeObject } from "force-graph";
|
||||
|
||||
type MapType = "tree" | "link";
|
||||
|
||||
interface GroupedLink {
|
||||
id: string;
|
||||
sourceNoteId: string;
|
||||
targetNoteId: string;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export interface NoteMapNodeObject extends NodeObject {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface NoteMapLinkObject extends LinkObject<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);
|
||||
}
|
||||
282
apps/client/src/widgets/note_map/rendering.ts
Normal file
282
apps/client/src/widgets/note_map/rendering.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type ForceGraph from "force-graph";
|
||||
import { NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
|
||||
import { LinkObject, NodeObject } from "force-graph";
|
||||
import { generateColorFromString, MapType, NoteMapWidgetMode } from "./utils";
|
||||
import { escapeHtml } from "../../services/utils";
|
||||
import FNote from "../../entities/fnote";
|
||||
|
||||
export interface CssData {
|
||||
fontFamily: string;
|
||||
textColor: string;
|
||||
mutedTextColor: string;
|
||||
}
|
||||
|
||||
interface RenderData {
|
||||
note: FNote;
|
||||
noteIdToSizeMap: Record<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;
|
||||
}
|
||||
33
apps/client/src/widgets/note_map/utils.ts
Normal file
33
apps/client/src/widgets/note_map/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type NoteMapWidgetMode = "ribbon" | "hoisted" | "type";
|
||||
export type MapType = "tree" | "link";
|
||||
|
||||
export function rgb2hex(rgb: string) {
|
||||
return `#${(rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) || [])
|
||||
.slice(1)
|
||||
.map((n) => parseInt(n, 10).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export function generateColorFromString(str: string, themeStyle: "light" | "dark") {
|
||||
if (themeStyle === "dark") {
|
||||
str = `0${str}`; // magic lightning modifier
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
|
||||
color += `00${value.toString(16)}`.substr(-2);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
export function getThemeStyle() {
|
||||
const documentStyle = window.getComputedStyle(document.documentElement);
|
||||
return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark";
|
||||
}
|
||||
@@ -47,7 +47,9 @@ export default function NoteTitleWidget() {
|
||||
|
||||
// Prevent user from navigating away if the spaced update is not done.
|
||||
useEffect(() => {
|
||||
appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate());
|
||||
const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
|
||||
appContext.addBeforeUnloadListener(listener);
|
||||
return () => appContext.removeBeforeUnloadListener(listener);
|
||||
}, []);
|
||||
useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());
|
||||
|
||||
|
||||
143
apps/client/src/widgets/note_types.tsx
Normal file
143
apps/client/src/widgets/note_types.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @module
|
||||
* Contains the definitions for all the note types supported by the application.
|
||||
*/
|
||||
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
import { VNode, type JSX } from "preact";
|
||||
import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||
|
||||
/**
|
||||
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
|
||||
* for protected session or attachment information.
|
||||
*/
|
||||
export type ExtendedNoteType = Exclude<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
|
||||
}
|
||||
};
|
||||
@@ -12,102 +12,6 @@ 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;
|
||||
@@ -117,115 +21,17 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $container!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "promotedAttributes";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabPromotedAttributes";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$widget = $("");
|
||||
this.contentSized();
|
||||
this.$container = this.$widget.find(".promoted-attributes-container");
|
||||
}
|
||||
|
||||
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}`;
|
||||
// .on("change", (event) => this.promotedAttributeChanged(event));
|
||||
|
||||
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") {
|
||||
@@ -359,8 +165,6 @@ 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,11 +3,12 @@ import { ComponentChildren } from "preact";
|
||||
interface AdmonitionProps {
|
||||
type: "warning" | "note" | "caution";
|
||||
children: ComponentChildren;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Admonition({ type, children }: AdmonitionProps) {
|
||||
export default function Admonition({ type, children, className }: AdmonitionProps) {
|
||||
return (
|
||||
<div className={`admonition ${type}`} role="alert">
|
||||
<div className={`admonition ${type} ${className}`} role="alert">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -82,6 +82,8 @@ interface FormListItemOpts {
|
||||
active?: boolean;
|
||||
badges?: FormListBadge[];
|
||||
disabled?: boolean;
|
||||
/** Will indicate the reason why the item is disabled via an icon, when hovered over it. */
|
||||
disabledTooltip?: string;
|
||||
checked?: boolean | null;
|
||||
selected?: boolean;
|
||||
container?: boolean;
|
||||
@@ -119,21 +121,24 @@ export function FormListItem({ className, icon, value, title, active, disabled,
|
||||
<Icon icon={icon} />
|
||||
{description ? (
|
||||
<div>
|
||||
<FormListContent description={description} {...contentProps} />
|
||||
<FormListContent description={description} disabled={disabled} {...contentProps} />
|
||||
</div>
|
||||
) : (
|
||||
<FormListContent description={description} {...contentProps} />
|
||||
<FormListContent description={description} disabled={disabled} {...contentProps} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function FormListContent({ children, badges, description }: Pick<FormListItemOpts, "children" | "badges" | "description">) {
|
||||
function FormListContent({ children, badges, description, disabled, disabledTooltip }: Pick<FormListItemOpts, "children" | "badges" | "description" | "disabled" | "disabledTooltip">) {
|
||||
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,16 +5,17 @@ import { openInAppHelpFromUrl } from "../../services/utils";
|
||||
interface HelpButtonProps {
|
||||
className?: string;
|
||||
helpPage: string;
|
||||
title?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function HelpButton({ className, helpPage, style }: HelpButtonProps) {
|
||||
export default function HelpButton({ className, helpPage, title, style }: HelpButtonProps) {
|
||||
return (
|
||||
<button
|
||||
class={`${className ?? ""} icon-action bx bx-help-circle`}
|
||||
type="button"
|
||||
onClick={() => openInAppHelpFromUrl(helpPage)}
|
||||
title={t("open-help-page")}
|
||||
title={title ?? t("open-help-page")}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
.info-bar-subtle {
|
||||
color: var(--muted-text-color);
|
||||
background: var(--main-background-color);
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
margin-block: 0;
|
||||
margin-inline: 10px;
|
||||
padding-inline: 12px;
|
||||
padding-inline: 22px;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import link from "../../services/link";
|
||||
import link, { ViewScope } from "../../services/link";
|
||||
import { useImperativeSearchHighlighlighting } from "./hooks";
|
||||
|
||||
interface NoteLinkOpts {
|
||||
@@ -11,18 +11,26 @@ interface NoteLinkOpts {
|
||||
noPreview?: boolean;
|
||||
noTnLink?: boolean;
|
||||
highlightedTokens?: string[] | null | undefined;
|
||||
// Override the text of the link, otherwise the note title is used.
|
||||
title?: string;
|
||||
viewScope?: ViewScope;
|
||||
noContextMenu?: boolean;
|
||||
}
|
||||
|
||||
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) {
|
||||
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) {
|
||||
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
|
||||
useEffect(() => {
|
||||
link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon })
|
||||
.then(setJqueryEl);
|
||||
}, [ stringifiedNotePath, showNotePath ]);
|
||||
link.createLink(stringifiedNotePath, {
|
||||
title,
|
||||
showNotePath,
|
||||
showNoteIcon,
|
||||
viewScope
|
||||
}).then(setJqueryEl);
|
||||
}, [ stringifiedNotePath, showNotePath, title, viewScope ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !jqueryEl) return;
|
||||
@@ -43,6 +51,10 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc
|
||||
$linkEl?.addClass("tn-link");
|
||||
}
|
||||
|
||||
if (noContextMenu) {
|
||||
$linkEl?.attr("data-no-context-menu", "true");
|
||||
}
|
||||
|
||||
if (className) {
|
||||
$linkEl?.addClass(className);
|
||||
}
|
||||
|
||||
20
apps/client/src/widgets/react/Slider.tsx
Normal file
20
apps/client/src/widgets/react/Slider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
interface SliderProps {
|
||||
value: number;
|
||||
onChange(newValue: number);
|
||||
min?: number;
|
||||
max?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function Slider({ onChange, ...restProps }: SliderProps) {
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
className="slider"
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.valueAsNumber);
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ interface ButtonProps {
|
||||
icon?: string;
|
||||
click: () => void;
|
||||
enabled?: boolean;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
interface SpacerProps {
|
||||
@@ -129,13 +130,14 @@ export function TouchBarSlider({ label, value, minValue, maxValue, onChange }: S
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) {
|
||||
export function TouchBarButton({ label, icon, click, enabled, backgroundColor }: ButtonProps) {
|
||||
const api = useContext(TouchBarContext);
|
||||
const item = useMemo(() => {
|
||||
if (!api) return null;
|
||||
return new api.TouchBar.TouchBarButton({
|
||||
label, click, enabled,
|
||||
icon: icon ? buildIcon(api.nativeImage, icon) : undefined
|
||||
icon: icon ? buildIcon(api.nativeImage, icon) : undefined,
|
||||
backgroundColor
|
||||
});
|
||||
}, [ label, icon ]);
|
||||
|
||||
@@ -171,6 +173,32 @@ export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChan
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export function TouchBarGroup({ children }: { children: ComponentChildren }) {
|
||||
const remote = dynamicRequire("@electron/remote") as typeof import("@electron/remote");
|
||||
const items: TouchBarItem[] = [];
|
||||
|
||||
const api: TouchBarContextApi = {
|
||||
TouchBar: remote.TouchBar,
|
||||
nativeImage: remote.nativeImage,
|
||||
addItem: (item) => {
|
||||
items.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
if (api) {
|
||||
const item = new api.TouchBar.TouchBarGroup({
|
||||
items: new api.TouchBar({ items })
|
||||
});
|
||||
api.addItem(item);
|
||||
}
|
||||
|
||||
return <>
|
||||
<TouchBarContext.Provider value={api}>
|
||||
{children}
|
||||
</TouchBarContext.Provider>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function TouchBarSpacer({ size }: SpacerProps) {
|
||||
const api = useContext(TouchBarContext);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CSSProperties } from "preact/compat";
|
||||
import { DragData } from "../note_tree";
|
||||
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
|
||||
import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ParentComponent } from "./react_utils";
|
||||
import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ParentComponent, refToJQuerySelector } from "./react_utils";
|
||||
import { RefObject, VNode } from "preact";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import { ViewMode } from "../../services/link";
|
||||
import appContext, { CommandListenerData, EventData, EventNames } from "../../components/app_context";
|
||||
import { ViewMode, ViewScope } from "../../services/link";
|
||||
import appContext, { EventData, EventNames } from "../../components/app_context";
|
||||
import attributes from "../../services/attributes";
|
||||
import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
|
||||
import Component from "../../components/component";
|
||||
@@ -21,6 +21,8 @@ import protected_session_holder from "../../services/protected_session_holder";
|
||||
import SpacedUpdate from "../../services/spaced_update";
|
||||
import toast, { ToastOptions } from "../../services/toast";
|
||||
import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils";
|
||||
import server from "../../services/server";
|
||||
import { removeIndividualBinding } from "../../services/shortcuts";
|
||||
|
||||
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
@@ -75,6 +77,66 @@ export function useSpacedUpdate(callback: () => void | Promise<void>, interval =
|
||||
return spacedUpdateRef.current;
|
||||
}
|
||||
|
||||
export function useEditorSpacedUpdate({ note, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
|
||||
note: FNote,
|
||||
noteContext: NoteContext | null | undefined,
|
||||
getData: () => Promise<object | undefined> | object | undefined,
|
||||
onContentChange: (newContent: string) => void,
|
||||
dataSaved?: () => void,
|
||||
updateInterval?: number;
|
||||
}) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const blob = useNoteBlob(note, parentComponent?.componentId);
|
||||
|
||||
const callback = useMemo(() => {
|
||||
return async () => {
|
||||
const data = await getData();
|
||||
|
||||
// for read only notes
|
||||
if (data === undefined) return;
|
||||
|
||||
protected_session_holder.touchProtectedSessionIfNecessary(note);
|
||||
await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId);
|
||||
|
||||
dataSaved?.();
|
||||
}
|
||||
}, [ note, getData, dataSaved ])
|
||||
const spacedUpdate = useSpacedUpdate(callback);
|
||||
|
||||
// React to note/blob changes.
|
||||
useEffect(() => {
|
||||
if (!blob) return;
|
||||
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
|
||||
}, [ blob ]);
|
||||
|
||||
// React to update interval changes.
|
||||
useEffect(() => {
|
||||
if (!updateInterval) return;
|
||||
spacedUpdate.setUpdateInterval(updateInterval);
|
||||
}, [ updateInterval ]);
|
||||
|
||||
// Save if needed upon switching tabs.
|
||||
useTriliumEvent("beforeNoteSwitch", async ({ noteContext: eventNoteContext }) => {
|
||||
if (eventNoteContext.ntxId !== noteContext?.ntxId) return;
|
||||
await spacedUpdate.updateNowIfNecessary();
|
||||
});
|
||||
|
||||
// Save if needed upon tab closing.
|
||||
useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => {
|
||||
if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return;
|
||||
await spacedUpdate.updateNowIfNecessary();
|
||||
})
|
||||
|
||||
// Save if needed upon window/browser closing.
|
||||
useEffect(() => {
|
||||
const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
|
||||
appContext.addBeforeUnloadListener(listener);
|
||||
return () => appContext.removeBeforeUnloadListener(listener);
|
||||
}, []);
|
||||
|
||||
return spacedUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows a React component to read and write a Trilium option, while also watching for external changes.
|
||||
*
|
||||
@@ -198,7 +260,7 @@ export function useNoteContext() {
|
||||
const [ noteContext, setNoteContext ] = useState<NoteContext>();
|
||||
const [ notePath, setNotePath ] = useState<string | null | undefined>();
|
||||
const [ note, setNote ] = useState<FNote | null | undefined>();
|
||||
const [ , setViewMode ] = useState<ViewMode>();
|
||||
const [ , setViewScope ] = useState<ViewScope>();
|
||||
const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState<boolean | null | undefined>(noteContext?.viewScope?.isReadOnly);
|
||||
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||
|
||||
@@ -209,7 +271,7 @@ export function useNoteContext() {
|
||||
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
|
||||
setNoteContext(noteContext);
|
||||
setNotePath(noteContext.notePath);
|
||||
setViewMode(noteContext.viewScope?.viewMode);
|
||||
setViewScope(noteContext.viewScope);
|
||||
});
|
||||
useTriliumEvent("frocaReloaded", () => {
|
||||
setNote(noteContext?.note);
|
||||
@@ -381,7 +443,7 @@ export function useNoteLabelInt(note: FNote | undefined | null, labelName: Filte
|
||||
]
|
||||
}
|
||||
|
||||
export function useNoteBlob(note: FNote | null | undefined): FBlob | null | undefined {
|
||||
export function useNoteBlob(note: FNote | null | undefined, componentId?: string): FBlob | null | undefined {
|
||||
const [ blob, setBlob ] = useState<FBlob | null>();
|
||||
|
||||
function refresh() {
|
||||
@@ -402,6 +464,10 @@ export function useNoteBlob(note: FNote | null | undefined): FBlob | null | unde
|
||||
if (loadResults.hasRevisionForNote(note.noteId)) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (loadResults.isNoteContentReloaded(note.noteId, componentId)) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
useDebugValue(note?.noteId);
|
||||
@@ -675,26 +741,6 @@ export function useNoteTreeDrag(containerRef: MutableRef<HTMLElement | null | un
|
||||
}, [ containerRef, callback ]);
|
||||
}
|
||||
|
||||
export function useTouchBar(
|
||||
factory: (context: CommandListenerData<"buildTouchBar"> & { parentComponent: Component | null }) => void,
|
||||
inputs: Inputs
|
||||
) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
useLegacyImperativeHandlers({
|
||||
buildTouchBarCommand(context: CommandListenerData<"buildTouchBar">) {
|
||||
return factory({
|
||||
...context,
|
||||
parentComponent
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
parentComponent?.triggerCommand("refreshTouchBar");
|
||||
}, inputs);
|
||||
}
|
||||
|
||||
export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => void) {
|
||||
const resizeObserver = useRef<ResizeObserver>(null);
|
||||
useEffect(() => {
|
||||
@@ -710,6 +756,20 @@ export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => v
|
||||
}, [ callback, ref ]);
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", containerRef: RefObject<HTMLElement>, parentComponent: Component | undefined) {
|
||||
useEffect(() => {
|
||||
if (!parentComponent) return;
|
||||
const $container = refToJQuerySelector(containerRef);
|
||||
const bindingPromise = keyboard_actions.setupActionsForElement(scope, $container, parentComponent);
|
||||
return async () => {
|
||||
const bindings = await bindingPromise;
|
||||
for (const binding of bindings) {
|
||||
removeIndividualBinding(binding);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the current note is in read-only mode, while an editing mode is available,
|
||||
* and provides a way to switch to editing mode.
|
||||
|
||||
@@ -18,58 +18,60 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
|
||||
<div className="file-properties-widget">
|
||||
{note && (
|
||||
<table class="file-table">
|
||||
<tr>
|
||||
<th class="text-nowrap">{t("file_properties.note_id")}:</th>
|
||||
<td class="file-note-id">{note.noteId}</td>
|
||||
<th class="text-nowrap">{t("file_properties.original_file_name")}:</th>
|
||||
<td class="file-filename">{originalFileName ?? "?"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap">{t("file_properties.file_type")}:</th>
|
||||
<td class="file-filetype">{note.mime}</td>
|
||||
<th class="text-nowrap">{t("file_properties.file_size")}:</th>
|
||||
<td class="file-filesize">{formatSize(blob?.contentLength ?? 0)}</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="text-nowrap">{t("file_properties.note_id")}:</th>
|
||||
<td class="file-note-id">{note.noteId}</td>
|
||||
<th class="text-nowrap">{t("file_properties.original_file_name")}:</th>
|
||||
<td class="file-filename">{originalFileName ?? "?"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap">{t("file_properties.file_type")}:</th>
|
||||
<td class="file-filetype">{note.mime}</td>
|
||||
<th class="text-nowrap">{t("file_properties.file_size")}:</th>
|
||||
<td class="file-filesize">{formatSize(blob?.contentLength ?? 0)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<div class="file-buttons">
|
||||
<Button
|
||||
icon="bx bx-download"
|
||||
text={t("file_properties.download")}
|
||||
primary
|
||||
disabled={!canAccessProtectedNote}
|
||||
onClick={() => downloadFileNote(note.noteId)}
|
||||
/>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<div class="file-buttons">
|
||||
<Button
|
||||
icon="bx bx-download"
|
||||
text={t("file_properties.download")}
|
||||
primary
|
||||
disabled={!canAccessProtectedNote}
|
||||
onClick={() => downloadFileNote(note.noteId)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
icon="bx bx-link-external"
|
||||
text={t("file_properties.open")}
|
||||
disabled={note.isProtected}
|
||||
onClick={() => openNoteExternally(note.noteId, note.mime)}
|
||||
/>
|
||||
<Button
|
||||
icon="bx bx-link-external"
|
||||
text={t("file_properties.open")}
|
||||
disabled={note.isProtected}
|
||||
onClick={() => openNoteExternally(note.noteId, note.mime)}
|
||||
/>
|
||||
|
||||
<FormFileUploadButton
|
||||
icon="bx bx-folder-open"
|
||||
text={t("file_properties.upload_new_revision")}
|
||||
disabled={!canAccessProtectedNote}
|
||||
onChange={(fileToUpload) => {
|
||||
if (!fileToUpload) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.upload(`notes/${note.noteId}/file`, fileToUpload[0]).then((result) => {
|
||||
if (result.uploaded) {
|
||||
toast.showMessage(t("file_properties.upload_success"));
|
||||
} else {
|
||||
toast.showError(t("file_properties.upload_failed"));
|
||||
<FormFileUploadButton
|
||||
icon="bx bx-folder-open"
|
||||
text={t("file_properties.upload_new_revision")}
|
||||
disabled={!canAccessProtectedNote}
|
||||
onChange={(fileToUpload) => {
|
||||
if (!fileToUpload) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
server.upload(`notes/${note.noteId}/file`, fileToUpload[0]).then((result) => {
|
||||
if (result.uploaded) {
|
||||
toast.showMessage(t("file_properties.upload_success"));
|
||||
} else {
|
||||
toast.showError(t("file_properties.upload_failed"));
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ParentComponent } from "../react/react_utils";
|
||||
import { t } from "../../services/i18n"
|
||||
import { useContext } from "preact/hooks";
|
||||
import { useIsNoteReadOnly } from "../react/hooks";
|
||||
import { useTriliumOption } from "../react/hooks";
|
||||
import ActionButton from "../react/ActionButton"
|
||||
import appContext, { CommandNames } from "../../components/app_context";
|
||||
import branches from "../../services/branches";
|
||||
@@ -51,8 +52,9 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
||||
const isPrintable = ["text", "code"].includes(note.type) || (note.type === "book" && note.getLabelValue("viewType") === "presentation");
|
||||
const isElectron = getIsElectron();
|
||||
const isMac = getIsMac();
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type);
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "aiChat"].includes(note.type);
|
||||
const isSearchOrBook = ["search", "book"].includes(note.type);
|
||||
const [ syncServerHost ] = useTriliumOption("syncServerHost");
|
||||
const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext);
|
||||
|
||||
return (
|
||||
@@ -90,6 +92,9 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
||||
<CommandItem command="openNoteExternally" icon="bx bx-file-find" disabled={isSearchOrBook || !isElectron} text={t("note_actions.open_note_externally")} title={t("note_actions.open_note_externally_title")} />
|
||||
<CommandItem command="openNoteCustom" icon="bx bx-customize" disabled={isSearchOrBook || isMac || !isElectron} text={t("note_actions.open_note_custom")} />
|
||||
<CommandItem command="showNoteSource" icon="bx bx-code" disabled={!hasSource} text={t("note_actions.note_source")} />
|
||||
{(syncServerHost && isElectron) &&
|
||||
<CommandItem command="openNoteOnServer" icon="bx bx-world" disabled={!syncServerHost} text={t("note_actions.open_note_on_server")} />
|
||||
}
|
||||
<FormDropdownDivider />
|
||||
|
||||
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} />
|
||||
|
||||
@@ -36,57 +36,58 @@ export default function NoteInfoTab({ note }: TabContext) {
|
||||
return (
|
||||
<div className="note-info-widget">
|
||||
{note && (
|
||||
<table className="note-info-widget-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("note_info_widget.note_id")}:</th>
|
||||
<td class="note-info-id">{note.noteId}</td>
|
||||
<th>{t("note_info_widget.created")}:</th>
|
||||
<td>{formatDateTime(metadata?.dateCreated)}</td>
|
||||
<th>{t("note_info_widget.modified")}:</th>
|
||||
<td>{formatDateTime(metadata?.dateModified)}</td>
|
||||
</tr>
|
||||
<>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.note_id")}:</span>
|
||||
<span className="note-info-id">{note.noteId}</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.created")}:</span>
|
||||
<span>{formatDateTime(metadata?.dateCreated)}</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.modified")}:</span>
|
||||
<span>{formatDateTime(metadata?.dateModified)}</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
<span>{t("note_info_widget.type")}:</span>
|
||||
<span>
|
||||
<span className="note-info-type">{note.type}</span>{' '}
|
||||
{note.mime && <span className="note-info-mime">({note.mime})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="note-info-item">
|
||||
<span title={t("note_info_widget.note_size_info")}>{t("note_info_widget.note_size")}:</span>
|
||||
<span className="note-info-size-col-span">
|
||||
{!isLoading && !noteSizeResponse && !subtreeSizeResponse && (
|
||||
<Button
|
||||
className="calculate-button"
|
||||
icon="bx bx-calculator"
|
||||
text={t("note_info_widget.calculate")}
|
||||
onClick={() => {
|
||||
setIsLoading(true);
|
||||
setTimeout(async () => {
|
||||
await Promise.allSettled([
|
||||
server.get<NoteSizeResponse>(`stats/note-size/${note.noteId}`).then(setNoteSizeResponse),
|
||||
server.get<SubtreeSizeResponse>(`stats/subtree-size/${note.noteId}`).then(setSubtreeSizeResponse)
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<tr>
|
||||
<th>{t("note_info_widget.type")}:</th>
|
||||
<td>
|
||||
<span class="note-info-type">{note.type}</span>{' '}
|
||||
{ note.mime && <span class="note-info-mime">({note.mime})</span> }
|
||||
</td>
|
||||
|
||||
<th title={t("note_info_widget.note_size_info")}>{t("note_info_widget.note_size")}:</th>
|
||||
<td colSpan={3}>
|
||||
{!isLoading && !noteSizeResponse && !subtreeSizeResponse && (
|
||||
<Button
|
||||
className="calculate-button"
|
||||
style={{ padding: "0px 10px 0px 10px" }}
|
||||
icon="bx bx-calculator"
|
||||
text={t("note_info_widget.calculate")}
|
||||
onClick={() => {
|
||||
setIsLoading(true);
|
||||
setTimeout(async () => {
|
||||
await Promise.allSettled([
|
||||
server.get<NoteSizeResponse>(`stats/note-size/${note.noteId}`).then(setNoteSizeResponse),
|
||||
server.get<SubtreeSizeResponse>(`stats/subtree-size/${note.noteId}`).then(setSubtreeSizeResponse)
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="note-sizes-wrapper">
|
||||
<span class="note-size">{formatSize(noteSizeResponse?.noteSize)}</span>
|
||||
{" "}
|
||||
{subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 &&
|
||||
<span class="subtree-size">{t("note_info_widget.subtree_size", { size: formatSize(subtreeSizeResponse.subTreeSize), count: subtreeSizeResponse.subTreeNoteCount })}</span>
|
||||
}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<span className="note-sizes-wrapper">
|
||||
<span className="note-size">{formatSize(noteSizeResponse?.noteSize)}</span>
|
||||
{" "}
|
||||
{subtreeSizeResponse && subtreeSizeResponse.subTreeNoteCount > 1 &&
|
||||
<span className="subtree-size">{t("note_info_widget.subtree_size", { size: formatSize(subtreeSizeResponse.subTreeSize), count: subtreeSizeResponse.subTreeNoteCount })}</span>
|
||||
}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { TabContext } from "./ribbon-interface";
|
||||
import NoteMapWidget from "../note_map";
|
||||
import { useElementSize, useLegacyWidget, useWindowSize } from "../react/hooks";
|
||||
import { useElementSize, useWindowSize } from "../react/hooks";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import NoteMap from "../note_map/NoteMap";
|
||||
|
||||
const SMALL_SIZE_HEIGHT = "300px";
|
||||
|
||||
export default function NoteMapTab({ noteContext }: TabContext) {
|
||||
export default function NoteMapTab({ note }: TabContext) {
|
||||
const [ isExpanded, setExpanded ] = useState(false);
|
||||
const [ height, setHeight ] = useState(SMALL_SIZE_HEIGHT);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { windowHeight } = useWindowSize();
|
||||
const containerSize = useElementSize(containerRef);
|
||||
|
||||
const [ noteMapContainer, noteMapWidget ] = useLegacyWidget(() => new NoteMapWidget("ribbon"), {
|
||||
noteContext,
|
||||
containerClassName: "note-map-container"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && containerRef.current && containerSize) {
|
||||
const height = windowHeight - containerSize.top;
|
||||
@@ -27,11 +22,10 @@ export default function NoteMapTab({ noteContext }: TabContext) {
|
||||
setHeight(SMALL_SIZE_HEIGHT);
|
||||
}
|
||||
}, [ isExpanded, containerRef, windowHeight, containerSize?.top ]);
|
||||
useEffect(() => noteMapWidget.setDimensions(), [ containerSize?.width, height ]);
|
||||
|
||||
return (
|
||||
<div className="note-map-ribbon-widget" style={{ height }} ref={containerRef}>
|
||||
{noteMapContainer}
|
||||
{note && <NoteMap note={note} widgetMode="ribbon" parentRef={containerRef} />}
|
||||
|
||||
{!isExpanded ? (
|
||||
<ActionButton
|
||||
|
||||
@@ -160,17 +160,20 @@
|
||||
/* #region Note info */
|
||||
.note-info-widget {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.note-info-widget-table {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
.note-info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding-inline-end: 15px;
|
||||
padding-block: 5px;
|
||||
}
|
||||
|
||||
.note-info-widget-table td, .note-info-widget-table th {
|
||||
padding: 5px;
|
||||
.note-info-item > span:first-child {
|
||||
padding-inline-end: 5px;
|
||||
}
|
||||
|
||||
.note-info-mime {
|
||||
@@ -186,6 +189,10 @@
|
||||
font-size: 0.8em;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.note-info-widget .calculate-button {
|
||||
padding: 0 10px;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Similar Notes */
|
||||
|
||||
46
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal file
46
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { useEditorSpacedUpdate, useLegacyWidget } from "../react/hooks";
|
||||
import { type TypeWidgetProps } from "./type_widget";
|
||||
import LlmChatPanel from "../llm_chat";
|
||||
|
||||
export default function AiChat({ note, noteContext }: TypeWidgetProps) {
|
||||
const dataRef = useRef<object>();
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
getData: async () => ({
|
||||
content: JSON.stringify(dataRef.current)
|
||||
}),
|
||||
onContentChange: (newContent) => {
|
||||
try {
|
||||
dataRef.current = JSON.parse(newContent);
|
||||
llmChatPanel.refresh();
|
||||
} catch (e) {
|
||||
dataRef.current = {};
|
||||
}
|
||||
}
|
||||
});
|
||||
const [ ChatWidget, llmChatPanel ] = useLegacyWidget(() => {
|
||||
const llmChatPanel = new LlmChatPanel();
|
||||
llmChatPanel.setDataCallbacks(
|
||||
async (data) => {
|
||||
dataRef.current = data;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
},
|
||||
async () => dataRef.current
|
||||
);
|
||||
return llmChatPanel;
|
||||
}, {
|
||||
noteContext,
|
||||
containerStyle: {
|
||||
height: "100%"
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
llmChatPanel.setNoteId(note.noteId);
|
||||
llmChatPanel.setCurrentNoteId(note.noteId);
|
||||
}, [ note ]);
|
||||
|
||||
return ChatWidget;
|
||||
}
|
||||
137
apps/client/src/widgets/type_widgets/Attachment.css
Normal file
137
apps/client/src/widgets/type_widgets/Attachment.css
Normal file
@@ -0,0 +1,137 @@
|
||||
/* #region Attachment list */
|
||||
.attachment-list {
|
||||
padding-inline-start: 15px;
|
||||
padding-inline-end: 15px;
|
||||
}
|
||||
|
||||
.attachment-list .links-wrapper {
|
||||
font-size: larger;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Attachment info */
|
||||
.attachment-detail-widget {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.attachment-title-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.attachment-details {
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper .rendered-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper pre {
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.list-view .attachment-content-wrapper {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.full-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.full-detail .attachment-content-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.list-view .attachment-content-wrapper pre {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper img {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
|
||||
max-height: 300px;
|
||||
max-width: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.full-detail .attachment-content-wrapper img {
|
||||
max-width: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
|
||||
filter: contrast(10%);
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper .attachment-deletion-warning {
|
||||
margin-top: 15px;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Attachment detail */
|
||||
.attachment-detail {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.attachment-detail .links-wrapper {
|
||||
font-size: larger;
|
||||
padding: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.attachment-detail .attachment-wrapper {
|
||||
flex-grow: 1;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Attachment actions */
|
||||
.attachment-actions {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.attachment-actions .select-button {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.attachment-actions .dropdown-menu {
|
||||
width: 20em;
|
||||
}
|
||||
|
||||
.attachment-actions .dropdown-item .bx {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 120%;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover {
|
||||
color: var(--muted-text-color) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none; /* makes it unclickable */
|
||||
}
|
||||
/* #endregion */
|
||||
310
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal file
310
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { t } from "i18next";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import "./Attachment.css";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import Button from "../react/Button";
|
||||
import { useContext, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
|
||||
import HelpButton from "../react/HelpButton";
|
||||
import FAttachment from "../../entities/fattachment";
|
||||
import Alert from "../react/Alert";
|
||||
import utils from "../../services/utils";
|
||||
import content_renderer from "../../services/content_renderer";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import froca from "../../services/froca";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import Icon from "../react/Icon";
|
||||
import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import open from "../../services/open";
|
||||
import toast from "../../services/toast";
|
||||
import link from "../../services/link";
|
||||
import image from "../../services/image";
|
||||
import FormFileUpload from "../react/FormFileUpload";
|
||||
import server from "../../services/server";
|
||||
import dialog from "../../services/dialog";
|
||||
import ws from "../../services/ws";
|
||||
import appContext from "../../components/app_context";
|
||||
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
|
||||
import options from "../../services/options";
|
||||
|
||||
/**
|
||||
* Displays the full list of attachments of a note and allows the user to interact with them.
|
||||
*/
|
||||
export function AttachmentList({ note }: TypeWidgetProps) {
|
||||
const [ attachments, setAttachments ] = useState<FAttachment[]>([]);
|
||||
|
||||
function refresh() {
|
||||
note.getAttachments().then(attachments => setAttachments(Array.from(attachments)));
|
||||
}
|
||||
|
||||
useEffect(refresh, [ note ]);
|
||||
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getAttachmentRows().some((att) => att.attachmentId && att.ownerId === note.noteId)) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AttachmentListHeader noteId={note.noteId} />
|
||||
|
||||
<div className="attachment-list-wrapper">
|
||||
{attachments.length ? (
|
||||
attachments.map(attachment => <AttachmentInfo key={attachment.attachmentId} attachment={attachment} />)
|
||||
) : (
|
||||
<Alert type="info">
|
||||
{t("attachment_list.no_attachments")}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentListHeader({ noteId }: { noteId: string }) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
return (
|
||||
<div className="links-wrapper">
|
||||
<div>
|
||||
{t("attachment_list.owning_note")}{" "}<NoteLink notePath={noteId} />
|
||||
</div>
|
||||
<div className="attachment-actions-toolbar">
|
||||
<Button
|
||||
size="small"
|
||||
icon="bx bx-folder-open"
|
||||
text={t("attachment_list.upload_attachments")}
|
||||
onClick={() => parentComponent?.triggerCommand("showUploadAttachmentsDialog", { noteId })}
|
||||
/>
|
||||
|
||||
<HelpButton
|
||||
helpPage="0vhv7lsOLy82"
|
||||
title={t("attachment_list.open_help_page")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays information about a single attachment.
|
||||
*/
|
||||
export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) {
|
||||
const [ attachment, setAttachment ] = useState<FAttachment | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewScope?.attachmentId) return;
|
||||
froca.getAttachment(viewScope.attachmentId).then(setAttachment);
|
||||
}, [ viewScope ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="links-wrapper use-tn-links">
|
||||
{t("attachment_detail.owning_note")}{" "}
|
||||
<NoteLink notePath={note.noteId} />
|
||||
{t("attachment_detail.you_can_also_open")}{" "}
|
||||
<NoteLink
|
||||
notePath={note.noteId}
|
||||
viewScope={{ viewMode: "attachments" }}
|
||||
title={t("attachment_detail.list_of_all_attachments")}
|
||||
/>
|
||||
<HelpButton
|
||||
helpPage="0vhv7lsOLy82"
|
||||
title={t("attachment_list.open_help_page")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="attachment-wrapper">
|
||||
{attachment !== null ? (
|
||||
attachment && <AttachmentInfo attachment={attachment} isFullDetail />
|
||||
) : (
|
||||
<strong>{t("attachment_detail.attachment_deleted")}</strong>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) {
|
||||
const contentWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
function refresh() {
|
||||
content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail })
|
||||
.then(({ $renderedContent }) => {
|
||||
contentWrapper.current?.replaceChildren(...$renderedContent);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(refresh, [ attachment ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getAttachmentRows().find(attachment => attachment.attachmentId)) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
async function copyAttachmentLinkToClipboard() {
|
||||
if (attachment.role === "image") {
|
||||
const $contentWrapper = refToJQuerySelector(contentWrapper);
|
||||
image.copyImageReferenceToClipboard($contentWrapper);
|
||||
} else if (attachment.role === "file") {
|
||||
const $link = await link.createLink(attachment.ownerId, {
|
||||
referenceLink: true,
|
||||
viewScope: {
|
||||
viewMode: "attachments",
|
||||
attachmentId: attachment.attachmentId
|
||||
}
|
||||
});
|
||||
|
||||
utils.copyHtmlToClipboard($link[0].outerHTML);
|
||||
|
||||
toast.showMessage(t("attachment_detail_2.link_copied"));
|
||||
} else {
|
||||
throw new Error(t("attachment_detail_2.unrecognized_role", { role: attachment.role }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="attachment-detail-widget">
|
||||
<div className={`attachment-detail-wrapper ${isFullDetail ? "full-detail" : "list-view"} ${attachment.utcDateScheduledForErasureSince ? "scheduled-for-deletion" : ""}`}>
|
||||
<div className="attachment-title-line">
|
||||
<AttachmentActions attachment={attachment} copyAttachmentLinkToClipboard={copyAttachmentLinkToClipboard} />
|
||||
<h4 className="attachment-title">
|
||||
{!isFullDetail ? (
|
||||
<NoteLink
|
||||
notePath={attachment.ownerId}
|
||||
title={attachment.title}
|
||||
viewScope={{
|
||||
viewMode: "attachments",
|
||||
attachmentId: attachment.attachmentId
|
||||
}}
|
||||
/>
|
||||
) : (attachment.title)}
|
||||
</h4>
|
||||
<div className="attachment-details">
|
||||
{t("attachment_detail_2.role_and_size", { role: attachment.role, size: utils.formatSize(attachment.contentLength) })}
|
||||
</div>
|
||||
<div style="flex: 1 1;"></div>
|
||||
</div>
|
||||
|
||||
{attachment.utcDateScheduledForErasureSince && <DeletionAlert utcDateScheduledForErasureSince={attachment.utcDateScheduledForErasureSince} />}
|
||||
<div ref={contentWrapper} className="attachment-content-wrapper" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeletionAlert({ utcDateScheduledForErasureSince }: { utcDateScheduledForErasureSince: string }) {
|
||||
const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime();
|
||||
// use default value (30 days in seconds) from options_init as fallback, in case getInt returns null
|
||||
const intervalMs = options.getInt("eraseUnusedAttachmentsAfterSeconds") || 2592000 * 1000;
|
||||
const deletionTimestamp = scheduledSinceTimestamp + intervalMs;
|
||||
const willBeDeletedInMs = deletionTimestamp - Date.now();
|
||||
|
||||
return (
|
||||
<Alert className="attachment-deletion-warning" type="info">
|
||||
{ willBeDeletedInMs >= 60000
|
||||
? t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) })
|
||||
: t("attachment_detail_2.will_be_deleted_soon")}
|
||||
{t("attachment_detail_2.deletion_reason")}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentActions({ attachment, copyAttachmentLinkToClipboard }: { attachment: FAttachment, copyAttachmentLinkToClipboard: () => void }) {
|
||||
const isElectron = utils.isElectron();
|
||||
const fileUploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="attachment-actions-container">
|
||||
<Dropdown
|
||||
className="attachment-actions"
|
||||
text={<Icon icon="bx bx-dots-vertical-rounded" />}
|
||||
buttonClassName="icon-action-always-border"
|
||||
iconAction
|
||||
>
|
||||
<FormListItem
|
||||
icon="bx bx-file-find"
|
||||
title={t("attachments_actions.open_externally_title")}
|
||||
onClick={() => open.openAttachmentExternally(attachment.attachmentId, attachment.mime)}
|
||||
>{t("attachments_actions.open_externally")}</FormListItem>
|
||||
<FormListItem
|
||||
icon="bx bx-customize"
|
||||
title={t("attachments_actions.open_custom_title")}
|
||||
onClick={() => open.openAttachmentCustom(attachment.attachmentId, attachment.mime)}
|
||||
disabled={!isElectron}
|
||||
disabledTooltip={!isElectron ? t("attachments_actions.open_custom_client_only") : t("attachments_actions.open_externally_detail_page")}
|
||||
>{t("attachments_actions.open_custom")}</FormListItem>
|
||||
<FormListItem
|
||||
icon="bx bx-download"
|
||||
onClick={() => open.downloadAttachment(attachment.attachmentId)}
|
||||
>{t("attachments_actions.download")}</FormListItem>
|
||||
<FormListItem
|
||||
icon="bx bx-link"
|
||||
onClick={copyAttachmentLinkToClipboard}
|
||||
>{t("attachments_actions.copy_link_to_clipboard")}</FormListItem>
|
||||
<FormDropdownDivider />
|
||||
|
||||
<FormListItem
|
||||
icon="bx bx-upload"
|
||||
onClick={() => fileUploadRef.current?.click()}
|
||||
>{t("attachments_actions.upload_new_revision")}</FormListItem>
|
||||
<FormListItem
|
||||
icon="bx bx-rename"
|
||||
onClick={async () => {
|
||||
const attachmentTitle = await dialog.prompt({
|
||||
title: t("attachments_actions.rename_attachment"),
|
||||
message: t("attachments_actions.enter_new_name"),
|
||||
defaultValue: attachment.title
|
||||
});
|
||||
|
||||
if (!attachmentTitle?.trim()) return;
|
||||
await server.put(`attachments/${attachment.attachmentId}/rename`, { title: attachmentTitle });
|
||||
}}
|
||||
>{t("attachments_actions.rename_attachment")}</FormListItem>
|
||||
<FormListItem
|
||||
icon="bx bx-trash destructive-action-icon"
|
||||
onClick={async () => {
|
||||
if (!(await dialog.confirm(t("attachments_actions.delete_confirm", { title: attachment.title })))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await server.remove(`attachments/${attachment.attachmentId}`);
|
||||
toast.showMessage(t("attachments_actions.delete_success", { title: attachment.title }));
|
||||
}}
|
||||
>{t("attachments_actions.delete_attachment")}</FormListItem>
|
||||
<FormDropdownDivider />
|
||||
|
||||
<FormListItem
|
||||
icon="bx bx-note"
|
||||
onClick={async () => {
|
||||
if (!(await dialog.confirm(t("attachments_actions.convert_confirm", { title: attachment.title })))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { note: newNote } = await server.post<ConvertAttachmentToNoteResponse>(`attachments/${attachment.attachmentId}/convert-to-note`);
|
||||
toast.showMessage(t("attachments_actions.convert_success", { title: attachment.title }));
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId);
|
||||
}}
|
||||
>{t("attachments_actions.convert_attachment_into_note")}</FormListItem>
|
||||
|
||||
<FormFileUpload
|
||||
inputRef={fileUploadRef}
|
||||
hidden
|
||||
onChange={async files => {
|
||||
const fileToUpload = files?.item(0);
|
||||
if (fileToUpload) {
|
||||
const result = await server.upload(`attachments/${attachment.attachmentId}/file`, fileToUpload);
|
||||
if (result.uploaded) {
|
||||
toast.showMessage(t("attachments_actions.upload_success"));
|
||||
} else {
|
||||
toast.showError(t("attachments_actions.upload_failed"));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
apps/client/src/widgets/type_widgets/Book.css
Normal file
4
apps/client/src/widgets/type_widgets/Book.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.note-detail-book-empty-help {
|
||||
margin: 50px;
|
||||
padding: 20px;
|
||||
}
|
||||
35
apps/client/src/widgets/type_widgets/Book.tsx
Normal file
35
apps/client/src/widgets/type_widgets/Book.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import Alert from "../react/Alert";
|
||||
import { useNoteLabel, useTriliumEvent } from "../react/hooks";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import "./Book.css";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
const VIEW_TYPES = [ "list", "grid" ];
|
||||
|
||||
export default function Book({ note }: TypeWidgetProps) {
|
||||
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||
const [ shouldDisplayNoChildrenWarning, setShouldDisplayNoChildrenWarning ] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
setShouldDisplayNoChildrenWarning(!note.hasChildren() && VIEW_TYPES.includes(viewType ?? ""));
|
||||
}
|
||||
|
||||
useEffect(refresh, [ note ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getBranchRows().some(branchRow => branchRow.parentNoteId === note.noteId)) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayNoChildrenWarning && (
|
||||
<Alert type="warning" className="note-detail-book-empty-help">
|
||||
<RawHtml html={t("book.no_children_help")} />
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
16
apps/client/src/widgets/type_widgets/ContentWidget.css
Normal file
16
apps/client/src/widgets/type_widgets/ContentWidget.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.type-contentWidget .note-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget-content {
|
||||
padding: 15px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail.full-height .note-detail-content-widget-content {
|
||||
padding: 0;
|
||||
}
|
||||
58
apps/client/src/widgets/type_widgets/ContentWidget.tsx
Normal file
58
apps/client/src/widgets/type_widgets/ContentWidget.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import { JSX } from "preact/jsx-runtime";
|
||||
import AppearanceSettings from "./options/appearance";
|
||||
import ShortcutSettings from "./options/shortcuts";
|
||||
import TextNoteSettings from "./options/text_notes";
|
||||
import CodeNoteSettings from "./options/code_notes";
|
||||
import ImageSettings from "./options/images";
|
||||
import SpellcheckSettings from "./options/spellcheck";
|
||||
import PasswordSettings from "./options/password";
|
||||
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication";
|
||||
import EtapiSettings from "./options/etapi";
|
||||
import BackupSettings from "./options/backup";
|
||||
import SyncOptions from "./options/sync";
|
||||
import AiSettings from "./options/ai_settings";
|
||||
import OtherSettings from "./options/other";
|
||||
import InternationalizationOptions from "./options/i18n";
|
||||
import AdvancedSettings from "./options/advanced";
|
||||
import "./ContentWidget.css";
|
||||
import { t } from "../../services/i18n";
|
||||
import BackendLog from "./code/BackendLog";
|
||||
|
||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
|
||||
|
||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
|
||||
_optionsAppearance: AppearanceSettings,
|
||||
_optionsShortcuts: ShortcutSettings,
|
||||
_optionsTextNotes: TextNoteSettings,
|
||||
_optionsCodeNotes: CodeNoteSettings,
|
||||
_optionsImages: ImageSettings,
|
||||
_optionsSpellcheck: SpellcheckSettings,
|
||||
_optionsPassword: PasswordSettings,
|
||||
_optionsMFA: MultiFactorAuthenticationSettings,
|
||||
_optionsEtapi: EtapiSettings,
|
||||
_optionsBackup: BackupSettings,
|
||||
_optionsSync: SyncOptions,
|
||||
_optionsAi: AiSettings,
|
||||
_optionsOther: OtherSettings,
|
||||
_optionsLocalization: InternationalizationOptions,
|
||||
_optionsAdvanced: AdvancedSettings,
|
||||
_backendLog: BackendLog
|
||||
}
|
||||
|
||||
/**
|
||||
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
|
||||
*
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export default function ContentWidget({ note, ...restProps }: TypeWidgetProps) {
|
||||
const Content = CONTENT_WIDGETS[note.noteId];
|
||||
return (
|
||||
<div className={`note-detail-content-widget-content ${note.noteId.startsWith("_options") ? "options" : ""}`}>
|
||||
{Content
|
||||
? <Content note={note} {...restProps} />
|
||||
: (t("content_widget.unknown_widget", { id: note.noteId }))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
apps/client/src/widgets/type_widgets/Doc.css
Normal file
50
apps/client/src/widgets/type_widgets/Doc.css
Normal file
@@ -0,0 +1,50 @@
|
||||
.note-detail-doc-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.note-detail-doc-content pre {
|
||||
border: 0;
|
||||
box-shadow: var(--code-block-box-shadow);
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.note-detail-doc-content code {
|
||||
font-variant: none;
|
||||
}
|
||||
|
||||
.note-detail-doc-content pre:not(.hljs) {
|
||||
background-color: var(--accented-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-detail-doc-content.contextual-help {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.note-detail-doc-content.contextual-help h2,
|
||||
.note-detail-doc-content.contextual-help h3,
|
||||
.note-detail-doc-content.contextual-help h4,
|
||||
.note-detail-doc-content.contextual-help h5,
|
||||
.note-detail-doc-content.contextual-help h6 {
|
||||
font-size: 1.25rem;
|
||||
background-color: var(--main-background-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
margin: 0;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
td img {
|
||||
max-width: 40vw;
|
||||
}
|
||||
|
||||
figure.table {
|
||||
overflow: auto !important;
|
||||
}
|
||||
35
apps/client/src/widgets/type_widgets/Doc.tsx
Normal file
35
apps/client/src/widgets/type_widgets/Doc.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { RawHtmlBlock } from "../react/RawHtml";
|
||||
import renderDoc from "../../services/doc_renderer";
|
||||
import "./Doc.css";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
|
||||
export default function Doc({ note, viewScope, ntxId }: TypeWidgetProps) {
|
||||
const [ html, setHtml ] = useState<string>();
|
||||
const initialized = useRef<Promise<void> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!note) return;
|
||||
|
||||
initialized.current = renderDoc(note).then($content => {
|
||||
setHtml($content.html());
|
||||
});
|
||||
}, [ note ]);
|
||||
|
||||
useTriliumEvent("executeWithContentElement", async ({ resolve, ntxId: eventNtxId}) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
await initialized.current;
|
||||
resolve(refToJQuerySelector(containerRef));
|
||||
});
|
||||
|
||||
return (
|
||||
<RawHtmlBlock
|
||||
containerRef={containerRef}
|
||||
className={`note-detail-doc-content ck-content ${viewScope?.viewMode === "contextual-help" ? "contextual-help" : ""}`}
|
||||
html={html}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
apps/client/src/widgets/type_widgets/Empty.css
Normal file
38
apps/client/src/widgets/type_widgets/Empty.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.workspace-notes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.workspace-notes .workspace-note {
|
||||
width: 130px;
|
||||
text-align: center;
|
||||
margin: 10px;
|
||||
border: 1px transparent solid;
|
||||
}
|
||||
|
||||
.workspace-notes .workspace-note:hover {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-detail-empty-results .aa-dropdown-menu {
|
||||
max-height: 50vh;
|
||||
overflow: scroll;
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search .note-autocomplete-input {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search .input-clearer-button {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.workspace-icon {
|
||||
text-align: center;
|
||||
font-size: 500%;
|
||||
}
|
||||
85
apps/client/src/widgets/type_widgets/Empty.tsx
Normal file
85
apps/client/src/widgets/type_widgets/Empty.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useContext, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import "./Empty.css";
|
||||
import { ParentComponent, refToJQuerySelector } from "../react/react_utils";
|
||||
import note_autocomplete from "../../services/note_autocomplete";
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import search from "../../services/search";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
export default function Empty({ }: TypeWidgetProps) {
|
||||
return (
|
||||
<>
|
||||
<WorkspaceSwitcher />
|
||||
<NoteSearch />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteSearch() {
|
||||
const resultsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const autocompleteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Show recent notes.
|
||||
useEffect(() => {
|
||||
const $autoComplete = refToJQuerySelector(autocompleteRef);
|
||||
note_autocomplete.showRecentNotes($autoComplete);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormGroup name="empty-tab-search" label={t("empty.open_note_instruction")} className="empty-tab-search">
|
||||
<NoteAutocomplete
|
||||
placeholder={t("empty.search_placeholder")}
|
||||
container={resultsContainerRef}
|
||||
inputRef={autocompleteRef}
|
||||
opts={{
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowCreatingNotes: true,
|
||||
allowJumpToSearchNotes: true,
|
||||
}}
|
||||
onChange={suggestion => {
|
||||
if (!suggestion?.notePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext) {
|
||||
activeContext.setNote(suggestion.notePath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<div ref={resultsContainerRef} className="note-detail-empty-results" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher() {
|
||||
const [ workspaceNotes, setWorkspaceNotes ] = useState<FNote[]>();
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
function refresh() {
|
||||
search.searchForNotes("#workspace #!template").then(setWorkspaceNotes);
|
||||
}
|
||||
|
||||
useEffect(refresh, []);
|
||||
|
||||
return (
|
||||
<div class="workspace-notes">
|
||||
{workspaceNotes?.map(workspaceNote => (
|
||||
<div
|
||||
className="workspace-note"
|
||||
title={t("empty.enter_workspace", { title: workspaceNote.title })}
|
||||
onClick={() => parentComponent?.triggerCommand("hoistNote", { noteId: workspaceNote.noteId })}
|
||||
>
|
||||
<div className={`${workspaceNote.getIcon()} workspace-icon`} />
|
||||
<div>{workspaceNote.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
apps/client/src/widgets/type_widgets/File.css
Normal file
41
apps/client/src/widgets/type_widgets/File.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.type-file .note-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-file {
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-split.full-content-width .note-detail-file {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-detail.full-height .note-detail-file[data-preview-type="pdf"],
|
||||
.note-detail.full-height .note-detail-file[data-preview-type="video"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-preview-content {
|
||||
background-color: var(--accented-background-color);
|
||||
padding: 15px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.note-detail-file > .pdf-preview,
|
||||
.note-detail-file > .video-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-grow: 100;
|
||||
}
|
||||
|
||||
.note-detail-file > .audio-preview {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
width: calc(100% - 30px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
78
apps/client/src/widgets/type_widgets/File.tsx
Normal file
78
apps/client/src/widgets/type_widgets/File.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useNoteBlob } from "../react/hooks";
|
||||
import "./File.css";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { getUrlForDownload } from "../../services/open";
|
||||
import Alert from "../react/Alert";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
const TEXT_MAX_NUM_CHARS = 5000;
|
||||
|
||||
export default function File({ note }: TypeWidgetProps) {
|
||||
const blob = useNoteBlob(note);
|
||||
|
||||
if (blob?.content) {
|
||||
return <TextPreview content={blob.content} />
|
||||
} else if (note.mime === "application/pdf") {
|
||||
return <PdfPreview note={note} />
|
||||
} else if (note.mime.startsWith("video/")) {
|
||||
return <VideoPreview note={note} />
|
||||
} else if (note.mime.startsWith("audio/")) {
|
||||
return <AudioPreview note={note} />
|
||||
} else {
|
||||
return <NoPreview />
|
||||
}
|
||||
}
|
||||
|
||||
function TextPreview({ content }: { content: string }) {
|
||||
const trimmedContent = content.substring(0, TEXT_MAX_NUM_CHARS);
|
||||
const isTooLarge = trimmedContent.length !== content.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isTooLarge && (
|
||||
<Alert type="info">
|
||||
{t("file.too_big", { maxNumChars: TEXT_MAX_NUM_CHARS })}
|
||||
</Alert>
|
||||
)}
|
||||
<pre class="file-preview-content">{trimmedContent}</pre>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PdfPreview({ note }: { note: FNote }) {
|
||||
return (
|
||||
<iframe
|
||||
class="pdf-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open`)} />
|
||||
);
|
||||
}
|
||||
|
||||
function VideoPreview({ note }: { note: FNote }) {
|
||||
return (
|
||||
<video
|
||||
class="video-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
datatype={note?.mime}
|
||||
controls
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AudioPreview({ note }: { note: FNote }) {
|
||||
return (
|
||||
<audio
|
||||
class="audio-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
controls
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoPreview() {
|
||||
return (
|
||||
<Alert className="file-preview-not-available" type="info">
|
||||
{t("file.file_preview_not_available")}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
24
apps/client/src/widgets/type_widgets/Image.css
Normal file
24
apps/client/src/widgets/type_widgets/Image.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.type-image .note-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-image {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-image-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-image-view {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
52
apps/client/src/widgets/type_widgets/Image.tsx
Normal file
52
apps/client/src/widgets/type_widgets/Image.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { createImageSrcUrl } from "../../services/utils";
|
||||
import { useTriliumEvent, useUniqueName } from "../react/hooks";
|
||||
import "./Image.css";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||
import image_context_menu from "../../menus/image_context_menu";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import { copyImageReferenceToClipboard } from "../../services/image";
|
||||
|
||||
export default function Image({ note, ntxId }: TypeWidgetProps) {
|
||||
const uniqueId = useUniqueName("image");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||
|
||||
// Set up pan & zoom
|
||||
useEffect(() => {
|
||||
const zoomInstance = WheelZoom.create(`#${uniqueId}`, {
|
||||
maxScale: 50,
|
||||
speed: 1.3,
|
||||
zoomOnClick: false
|
||||
});
|
||||
|
||||
return () => zoomInstance.destroy();
|
||||
}, [ note ]);
|
||||
|
||||
// Set up context menu
|
||||
useEffect(() => image_context_menu.setupContextMenu(refToJQuerySelector(containerRef)), []);
|
||||
|
||||
// Copy reference events
|
||||
useTriliumEvent("copyImageReferenceToClipboard", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
copyImageReferenceToClipboard(refToJQuerySelector(containerRef));
|
||||
});
|
||||
|
||||
// React to new revisions.
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.isNoteReloaded(note.noteId)) {
|
||||
setRefreshCounter(refreshCounter + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="note-detail-image-wrapper">
|
||||
<img
|
||||
id={uniqueId}
|
||||
className="note-detail-image-view"
|
||||
src={createImageSrcUrl(note)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { EditorConfig } from "@triliumnext/codemirror";
|
||||
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid.js";
|
||||
import AbstractSvgSplitTypeWidget from "./abstract_svg_split_type_widget.js";
|
||||
import { useCallback } from "preact/hooks";
|
||||
import SvgSplitEditor from "./helpers/SvgSplitEditor";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid";
|
||||
|
||||
let idCounter = 1;
|
||||
let registeredErrorReporter = false;
|
||||
|
||||
export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
|
||||
|
||||
static getType() {
|
||||
return "mermaid";
|
||||
}
|
||||
|
||||
get attachmentName(): string {
|
||||
return "mermaid-export";
|
||||
}
|
||||
|
||||
async renderSvg(content: string) {
|
||||
export default function Mermaid(props: TypeWidgetProps) {
|
||||
const renderSvg = useCallback(async (content: string) => {
|
||||
const mermaid = (await import("mermaid")).default;
|
||||
await loadElkIfNeeded(mermaid, content);
|
||||
if (!registeredErrorReporter) {
|
||||
@@ -31,6 +23,13 @@ export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
|
||||
idCounter++;
|
||||
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
|
||||
return postprocessMermaidSvg(svg);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SvgSplitEditor
|
||||
attachmentName="mermaid-export"
|
||||
renderSvg={renderSvg}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
124
apps/client/src/widgets/type_widgets/MindMap.css
Normal file
124
apps/client/src/widgets/type_widgets/MindMap.css
Normal file
@@ -0,0 +1,124 @@
|
||||
.note-detail-mind-map {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.note-detail-mind-map .mind-map-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-container .node-menu {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 80px;
|
||||
overflow: auto;
|
||||
background: var(--panel-bgcolor);
|
||||
color: var(--main-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 2px #0003;
|
||||
width: 240px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 15px 15px;
|
||||
transition: .3s all
|
||||
}
|
||||
|
||||
.map-container .node-menu.close {
|
||||
height: 29px;
|
||||
width: 46px;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.map-container .node-menu .button-container {
|
||||
padding: 3px 0;
|
||||
direction: rtl
|
||||
}
|
||||
|
||||
.map-container .node-menu #nm-tag {
|
||||
margin-top: 20px
|
||||
}
|
||||
|
||||
.map-container .node-menu .nm-fontsize-container {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 20px
|
||||
}
|
||||
|
||||
.map-container .node-menu .nm-fontsize-container div {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 2px #0003;
|
||||
background-color: #fff;
|
||||
color: tomato;
|
||||
border-radius: 100%
|
||||
}
|
||||
|
||||
.map-container .node-menu .nm-fontcolor-container {
|
||||
margin-bottom: 10px
|
||||
}
|
||||
|
||||
.map-container .node-menu input,
|
||||
.map-container .node-menu textarea {
|
||||
background: var(--input-background-color);
|
||||
border: 1px solid var(--panel-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
color: var(--main-color);
|
||||
padding: 5px;
|
||||
margin: 10px 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.map-container .node-menu textarea {
|
||||
resize: none
|
||||
}
|
||||
|
||||
.map-container .node-menu .split6 {
|
||||
display: inline-block;
|
||||
width: 16.66%;
|
||||
margin-bottom: 5px
|
||||
}
|
||||
|
||||
.map-container .node-menu .palette {
|
||||
border-radius: 100%;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border: 1px solid #edf1f2;
|
||||
margin: auto
|
||||
}
|
||||
|
||||
.map-container .node-menu .nmenu-selected,
|
||||
.map-container .node-menu .palette:hover {
|
||||
box-shadow: tomato 0 0 0 2px;
|
||||
background-color: #c7e9fa
|
||||
}
|
||||
|
||||
.map-container .node-menu .size-selected {
|
||||
background-color: tomato !important;
|
||||
border-color: tomato;
|
||||
fill: #fff;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.map-container .node-menu .size-selected svg {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.map-container .node-menu .bof {
|
||||
text-align: center
|
||||
}
|
||||
|
||||
.map-container .node-menu .bof span {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px
|
||||
}
|
||||
|
||||
.map-container .node-menu .bof .selected {
|
||||
background-color: tomato;
|
||||
color: #fff
|
||||
}
|
||||
171
apps/client/src/widgets/type_widgets/MindMap.tsx
Normal file
171
apps/client/src/widgets/type_widgets/MindMap.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import { MindElixirData, MindElixirInstance, Operation, default as VanillaMindElixir } from "mind-elixir";
|
||||
import { HTMLAttributes, RefObject } from "preact";
|
||||
// allow node-menu plugin css to be bundled by webpack
|
||||
import nodeMenu from "@mind-elixir/node-menu";
|
||||
import "mind-elixir/style";
|
||||
import "@mind-elixir/node-menu/dist/style.css";
|
||||
import "./MindMap.css";
|
||||
import { useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEvent, useTriliumEvents } from "../react/hooks";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import utils from "../../services/utils";
|
||||
|
||||
const NEW_TOPIC_NAME = "";
|
||||
|
||||
interface MindElixirProps {
|
||||
apiRef?: RefObject<MindElixirInstance>;
|
||||
containerProps?: Omit<HTMLAttributes<HTMLDivElement>, "ref">;
|
||||
containerRef?: RefObject<HTMLDivElement>;
|
||||
editable: boolean;
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const apiRef = useRef<MindElixirInstance>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
getData: async () => {
|
||||
if (!apiRef.current) return;
|
||||
return {
|
||||
content: apiRef.current.getDataString(),
|
||||
attachments: [
|
||||
{
|
||||
role: "image",
|
||||
title: "mindmap-export.svg",
|
||||
mime: "image/svg+xml",
|
||||
content: await apiRef.current.exportSvg().text(),
|
||||
position: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
onContentChange: (content) => {
|
||||
let newContent: MindElixirData;
|
||||
if (content) {
|
||||
try {
|
||||
newContent = JSON.parse(content) as MindElixirData;
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
console.debug("Wrong JSON content: ", content);
|
||||
}
|
||||
} else {
|
||||
newContent = VanillaMindElixir.new(NEW_TOPIC_NAME)
|
||||
}
|
||||
apiRef.current?.init(newContent!);
|
||||
}
|
||||
});
|
||||
|
||||
// Allow search.
|
||||
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
resolve(refToJQuerySelector(containerRef).find(".map-canvas"));
|
||||
});
|
||||
|
||||
// Export as PNG or SVG.
|
||||
useTriliumEvents([ "exportSvg", "exportPng" ], async ({ ntxId: eventNtxId }, eventName) => {
|
||||
if (eventNtxId !== ntxId || !apiRef.current) return;
|
||||
const title = note.title;
|
||||
const svg = await apiRef.current.exportSvg().text();
|
||||
if (eventName === "exportSvg") {
|
||||
utils.downloadSvg(title, svg);
|
||||
} else {
|
||||
utils.downloadSvgAsPng(title, svg);
|
||||
}
|
||||
});
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
/*
|
||||
* Some global shortcuts interfere with the default shortcuts of the mind map,
|
||||
* as defined here: https://mind-elixir.com/docs/guides/shortcuts
|
||||
*/
|
||||
if (e.key === "F1") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Zoom controls
|
||||
const isCtrl = e.ctrlKey && !e.altKey && !e.metaKey;
|
||||
if (isCtrl && (e.key == "-" || e.key == "=" || e.key == "0")) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MindElixir
|
||||
containerRef={containerRef}
|
||||
apiRef={apiRef}
|
||||
onChange={() => spacedUpdate.scheduleUpdate()}
|
||||
editable={!isReadOnly}
|
||||
containerProps={{
|
||||
className: "mind-map-container",
|
||||
onKeyDown
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef: externalApiRef, onChange, editable }: MindElixirProps) {
|
||||
const containerRef = useSyncedRef<HTMLDivElement>(externalContainerRef, null);
|
||||
const apiRef = useRef<MindElixirInstance>(null);
|
||||
|
||||
function reinitialize() {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const mind = new VanillaMindElixir({
|
||||
el: containerRef.current,
|
||||
editable
|
||||
});
|
||||
|
||||
if (editable) {
|
||||
mind.install(nodeMenu);
|
||||
}
|
||||
|
||||
apiRef.current = mind;
|
||||
if (externalApiRef) {
|
||||
externalApiRef.current = mind;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reinitialize();
|
||||
return () => {
|
||||
apiRef.current?.destroy();
|
||||
apiRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const data = apiRef.current?.getData();
|
||||
reinitialize();
|
||||
if (data) {
|
||||
apiRef.current?.init(data);
|
||||
}
|
||||
}, [ editable ]);
|
||||
|
||||
// On change listener.
|
||||
useEffect(() => {
|
||||
const bus = apiRef.current?.bus;
|
||||
if (!onChange || !bus) return;
|
||||
|
||||
const operationListener = (operation: Operation) => {
|
||||
if (operation.name !== "beginEdit") {
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
|
||||
bus.addListener("operation", operationListener);
|
||||
bus.addListener("changeDirection", onChange);
|
||||
|
||||
return () => {
|
||||
bus.removeListener("operation", operationListener);
|
||||
bus.removeListener("changeDirection", onChange);
|
||||
};
|
||||
}, [ onChange ]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...containerProps} />
|
||||
)
|
||||
}
|
||||
13
apps/client/src/widgets/type_widgets/NoteMap.tsx
Normal file
13
apps/client/src/widgets/type_widgets/NoteMap.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import NoteMapEl from "../note_map/NoteMap";
|
||||
import { useRef } from "preact/hooks";
|
||||
|
||||
export default function NoteMap({ note }: TypeWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<NoteMapEl parentRef={containerRef} note={note} widgetMode="type" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.protected-session-password-component {
|
||||
width: 300px;
|
||||
margin: 30px auto auto;
|
||||
}
|
||||
|
||||
.protected-session-password-component input,
|
||||
.protected-session-password-component button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
40
apps/client/src/widgets/type_widgets/ProtectedSession.tsx
Normal file
40
apps/client/src/widgets/type_widgets/ProtectedSession.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useRef } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import "./ProtectedSession.css";
|
||||
import protected_session from "../../services/protected_session";
|
||||
import type { TargetedSubmitEvent } from "preact";
|
||||
|
||||
export default function ProtectedSession() {
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submitCallback = useCallback((e: TargetedSubmitEvent<HTMLFormElement>) => {
|
||||
if (!passwordRef.current) return;
|
||||
e.preventDefault();
|
||||
|
||||
const password = String(passwordRef.current.value);
|
||||
passwordRef.current.value = "";
|
||||
protected_session.setupProtectedSession(password);
|
||||
}, [ passwordRef ]);
|
||||
|
||||
return (
|
||||
<form class="protected-session-password-form" onSubmit={submitCallback}>
|
||||
<FormGroup name="protected-session-password-in-detail" label={t("protected_session.enter_password_instruction")}>
|
||||
<FormTextBox
|
||||
type="password"
|
||||
className="protected-session-password"
|
||||
autocomplete="current-password"
|
||||
inputRef={passwordRef}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Button
|
||||
text={t("protected_session.start_session_button")}
|
||||
primary
|
||||
keyboardShortcut="Enter"
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
8
apps/client/src/widgets/type_widgets/Render.css
Normal file
8
apps/client/src/widgets/type_widgets/Render.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.note-detail-render {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-detail-render .note-detail-render-help {
|
||||
margin: 50px;
|
||||
padding: 20px;
|
||||
}
|
||||
52
apps/client/src/widgets/type_widgets/Render.tsx
Normal file
52
apps/client/src/widgets/type_widgets/Render.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import render from "../../services/render";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import Alert from "../react/Alert";
|
||||
import "./Render.css";
|
||||
import { t } from "../../services/i18n";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [ renderNotesFound, setRenderNotesFound ] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
if (!contentRef) return;
|
||||
render.render(note, refToJQuerySelector(contentRef)).then(setRenderNotesFound);
|
||||
}
|
||||
|
||||
useEffect(refresh, [ note ]);
|
||||
|
||||
// Keyboard shortcut.
|
||||
useTriliumEvent("renderActiveNote", () => {
|
||||
if (!noteContext?.isActive()) return;
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Refresh on floating buttons.
|
||||
useTriliumEvent("refreshData", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Integration with search.
|
||||
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
resolve(refToJQuerySelector(contentRef));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!renderNotesFound && (
|
||||
<Alert className="note-detail-render-help" type="warning">
|
||||
<p><strong>{t("render.note_detail_render_help_1")}</strong></p>
|
||||
<p><RawHtml html={t("render.note_detail_render_help_2")} /></p>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div ref={contentRef} className="note-detail-render-content" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
apps/client/src/widgets/type_widgets/WebView.css
Normal file
11
apps/client/src/widgets/type_widgets/WebView.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.note-detail-web-view {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-detail-web-view > * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
35
apps/client/src/widgets/type_widgets/WebView.tsx
Normal file
35
apps/client/src/widgets/type_widgets/WebView.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import utils from "../../services/utils";
|
||||
import Alert from "../react/Alert";
|
||||
import { useNoteLabel } from "../react/hooks";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import "./WebView.css";
|
||||
|
||||
const isElectron = utils.isElectron();
|
||||
|
||||
export default function WebView({ note }: TypeWidgetProps) {
|
||||
const [ webViewSrc ] = useNoteLabel(note, "webViewSrc");
|
||||
|
||||
return (webViewSrc
|
||||
? <WebViewContent src={webViewSrc} />
|
||||
: <WebViewHelp />
|
||||
);
|
||||
}
|
||||
|
||||
function WebViewContent({ src }: { src: string }) {
|
||||
if (!isElectron) {
|
||||
return <iframe src={src} class="note-detail-web-view-content" sandbox="allow-same-origin allow-scripts allow-popups" />
|
||||
} else {
|
||||
return <webview src={src} class="note-detail-web-view-content" />
|
||||
}
|
||||
}
|
||||
|
||||
function WebViewHelp() {
|
||||
return (
|
||||
<Alert className="note-detail-web-view-help" type="warning" style={{ margin: "50px", padding: "20px 20px 0px 20px;" }}>
|
||||
<h4>{t("web_view.web_view")}</h4>
|
||||
<p>{t("web_view.embed_websites")}</p>
|
||||
<p>{t("web_view.create_label")}</p>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { getThemeById } from "@triliumnext/codemirror";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import options from "../../services/options.js";
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import CodeMirror, { type EditorConfig } from "@triliumnext/codemirror";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
export const DEFAULT_PREFIX = "default:";
|
||||
|
||||
/**
|
||||
* An abstract {@link TypeWidget} which implements the CodeMirror editor, meant to be used as a parent for
|
||||
* widgets requiring the editor.
|
||||
*
|
||||
* The widget handles the loading and initialization of the CodeMirror editor, as well as some common
|
||||
* actions.
|
||||
*
|
||||
* The derived class must:
|
||||
*
|
||||
* - Define `$editor` in the constructor.
|
||||
* - Call `super.doRender()` in the extended class.
|
||||
* - Call `this._update(note, content)` in `#doRefresh(note)`.
|
||||
*/
|
||||
export default class AbstractCodeTypeWidget extends TypeWidget {
|
||||
|
||||
protected $editor!: JQuery<HTMLElement>;
|
||||
protected codeEditor!: CodeMirror;
|
||||
|
||||
doRender() {
|
||||
this.initialized = this.#initEditor();
|
||||
}
|
||||
|
||||
async #initEditor() {
|
||||
this.codeEditor = new CodeMirror({
|
||||
parent: this.$editor[0],
|
||||
lineWrapping: options.is("codeLineWrapEnabled"),
|
||||
...this.getExtraOpts()
|
||||
});
|
||||
|
||||
// Load the theme.
|
||||
const themeId = options.get("codeNoteTheme");
|
||||
if (themeId?.startsWith(DEFAULT_PREFIX)) {
|
||||
const theme = getThemeById(themeId.substring(DEFAULT_PREFIX.length));
|
||||
if (theme) {
|
||||
await this.codeEditor.setTheme(theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be extended in derived classes to add extra options to the CodeMirror constructor. The options are appended
|
||||
* at the end, so it is possible to override the default values introduced by the abstract editor as well.
|
||||
*
|
||||
* @returns the extra options to be passed to the CodeMirror constructor.
|
||||
*/
|
||||
getExtraOpts(): Partial<EditorConfig> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called as soon as the CodeMirror library has been loaded and the editor was constructed. Can be extended in
|
||||
* derived classes to add additional functionality or to register event handlers.
|
||||
*
|
||||
* By default, it does nothing.
|
||||
*/
|
||||
onEditorInitialized() {
|
||||
// Do nothing by default.
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called by the derived classes in `#doRefresh(note)` in order to react to changes.
|
||||
*
|
||||
* @param the note that was changed.
|
||||
* @param new content of the note.
|
||||
*/
|
||||
_update(note: { mime: string }, content: string) {
|
||||
this.codeEditor.setText(content);
|
||||
this.codeEditor.setMimeType(note.mime);
|
||||
this.codeEditor.clearHistory();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.$widget.show();
|
||||
this.updateBackgroundColor();
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.codeEditor.focus();
|
||||
}
|
||||
|
||||
scrollToEnd() {
|
||||
this.codeEditor.scrollToEnd();
|
||||
this.codeEditor.focus();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.codeEditor) {
|
||||
this.spacedUpdate.allowUpdateWithoutChange(() => {
|
||||
this.codeEditor.setText("");
|
||||
});
|
||||
}
|
||||
this.updateBackgroundColor("unset");
|
||||
}
|
||||
|
||||
async executeWithCodeEditorEvent({ resolve, ntxId }: EventData<"executeWithCodeEditor">) {
|
||||
if (!this.isNoteContext(ntxId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialized;
|
||||
|
||||
resolve(this.codeEditor);
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("codeNoteTheme")) {
|
||||
const themeId = options.get("codeNoteTheme");
|
||||
if (themeId?.startsWith(DEFAULT_PREFIX)) {
|
||||
const theme = getThemeById(themeId.substring(DEFAULT_PREFIX.length));
|
||||
if (theme) {
|
||||
await this.codeEditor.setTheme(theme);
|
||||
}
|
||||
this.updateBackgroundColor();
|
||||
}
|
||||
}
|
||||
|
||||
if (loadResults.isOptionReloaded("codeLineWrapEnabled")) {
|
||||
this.codeEditor.setLineWrapping(options.is("codeLineWrapEnabled"));
|
||||
}
|
||||
}
|
||||
|
||||
updateBackgroundColor(color?: string) {
|
||||
if (this.note?.mime === "text/x-sqlite;schema=trilium") {
|
||||
// Don't apply a background color for SQL console notes.
|
||||
return;
|
||||
}
|
||||
|
||||
const $editorEl = $(this.codeEditor.dom);
|
||||
this.$widget.closest(".scrolling-container").css("background-color", color ?? $editorEl.css("background-color"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import EditableCodeTypeWidget from "./editable_code.js";
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import Split from "@triliumnext/split.js";
|
||||
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer.js";
|
||||
import options from "../../services/options.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type OnClickButtonWidget from "../buttons/onclick_button.js";
|
||||
import type { EditorConfig } from "@triliumnext/codemirror";
|
||||
|
||||
const TPL = /*html*/`\
|
||||
<div class="note-detail-split note-detail-printable">
|
||||
<div class="note-detail-split-editor-col">
|
||||
<div class="note-detail-split-editor"></div>
|
||||
<div class="admonition caution note-detail-error-container hidden-ext"></div>
|
||||
</div>
|
||||
<div class="note-detail-split-preview-col">
|
||||
<div class="note-detail-split-preview"></div>
|
||||
<div class="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-detail-split {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-split-editor-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-detail-split-preview-col {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-editor {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-editor .note-detail-code {
|
||||
contain: size !important;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-error-container {
|
||||
font-family: var(--monospace-font-family);
|
||||
margin: 5px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-preview {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-preview.on-error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Horizontal layout */
|
||||
|
||||
.note-detail-split.split-horizontal > .note-detail-split-preview-col {
|
||||
border-inline-start: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-detail-split.split-horizontal > .note-detail-split-editor-col,
|
||||
.note-detail-split.split-horizontal > .note-detail-split-preview-col {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.note-detail-split.split-horizontal .note-detail-split-preview {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Vertical layout */
|
||||
|
||||
.note-detail-split.split-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-detail-split.split-vertical > .note-detail-split-editor-col,
|
||||
.note-detail-split.split-vertical > .note-detail-split-preview-col {
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.note-detail-split.split-vertical > .note-detail-split-editor-col {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-detail-split.split-vertical .note-detail-split-preview-col {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
/* Read-only view */
|
||||
|
||||
.note-detail-split.split-read-only .note-detail-split-preview-col {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Abstract `TypeWidget` which contains a preview and editor pane, each displayed on half of the available screen.
|
||||
*
|
||||
* Features:
|
||||
*
|
||||
* - The two panes are resizeable via a split, on desktop. The split can be optionally customized via {@link buildSplitExtraOptions}.
|
||||
* - Can display errors to the user via {@link setError}.
|
||||
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
|
||||
*/
|
||||
export default abstract class AbstractSplitTypeWidget extends TypeWidget {
|
||||
|
||||
private splitInstance?: Split.Instance;
|
||||
|
||||
protected $preview!: JQuery<HTMLElement>;
|
||||
private $editorCol!: JQuery<HTMLElement>;
|
||||
private $previewCol!: JQuery<HTMLElement>;
|
||||
private $editor!: JQuery<HTMLElement>;
|
||||
private $errorContainer!: JQuery<HTMLElement>;
|
||||
private editorTypeWidget: EditableCodeTypeWidget;
|
||||
private layoutOrientation?: "horizontal" | "vertical";
|
||||
private isReadOnly?: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.editorTypeWidget = new EditableCodeTypeWidget(true);
|
||||
this.editorTypeWidget.updateBackgroundColor = () => {};
|
||||
this.editorTypeWidget.isEnabled = () => true;
|
||||
|
||||
const defaultOptions = this.editorTypeWidget.getExtraOpts();
|
||||
this.editorTypeWidget.getExtraOpts = () => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...this.buildEditorExtraOptions()
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
doRender(): void {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.spacedUpdate.setUpdateInterval(750);
|
||||
|
||||
// Preview pane
|
||||
this.$previewCol = this.$widget.find(".note-detail-split-preview-col");
|
||||
this.$preview = this.$widget.find(".note-detail-split-preview");
|
||||
|
||||
// Editor pane
|
||||
this.$editorCol = this.$widget.find(".note-detail-split-editor-col");
|
||||
this.$editor = this.$widget.find(".note-detail-split-editor");
|
||||
this.$editor.append(this.editorTypeWidget.render());
|
||||
this.$errorContainer = this.$widget.find(".note-detail-error-container");
|
||||
this.#adjustLayoutOrientation();
|
||||
|
||||
// Preview pane buttons
|
||||
const $previewButtons = this.$previewCol.find(".preview-buttons");
|
||||
const previewButtons = this.buildPreviewButtons();
|
||||
$previewButtons.toggle(previewButtons.length > 0);
|
||||
for (const previewButton of previewButtons) {
|
||||
const $button = previewButton.render();
|
||||
$button.removeClass("button-widget")
|
||||
.addClass("btn")
|
||||
.addClass("tn-tool-button");
|
||||
$previewButtons.append($button);
|
||||
previewButton.refreshIcon();
|
||||
}
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.#destroyResizer();
|
||||
this.editorTypeWidget.cleanup();
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote) {
|
||||
this.#adjustLayoutOrientation();
|
||||
|
||||
if (!this.isReadOnly) {
|
||||
await this.editorTypeWidget.initialized;
|
||||
this.editorTypeWidget.noteContext = this.noteContext;
|
||||
this.editorTypeWidget.spacedUpdate = this.spacedUpdate;
|
||||
this.editorTypeWidget.doRefresh(note);
|
||||
}
|
||||
}
|
||||
|
||||
#adjustLayoutOrientation() {
|
||||
// Read-only
|
||||
const isReadOnly = this.note?.hasLabel("readOnly");
|
||||
if (this.isReadOnly !== isReadOnly) {
|
||||
this.$editorCol.toggle(!isReadOnly);
|
||||
}
|
||||
|
||||
// Vertical vs horizontal layout
|
||||
const layoutOrientation = (!utils.isMobile() ? options.get("splitEditorOrientation") ?? "horizontal" : "vertical");
|
||||
if (this.layoutOrientation !== layoutOrientation || this.isReadOnly !== isReadOnly) {
|
||||
this.$widget
|
||||
.toggleClass("split-horizontal", !isReadOnly && layoutOrientation === "horizontal")
|
||||
.toggleClass("split-vertical", !isReadOnly && layoutOrientation === "vertical")
|
||||
.toggleClass("split-read-only", isReadOnly);
|
||||
this.layoutOrientation = layoutOrientation as ("horizontal" | "vertical");
|
||||
this.isReadOnly = isReadOnly;
|
||||
this.#destroyResizer();
|
||||
}
|
||||
|
||||
if (!this.splitInstance) {
|
||||
this.#setupResizer();
|
||||
}
|
||||
}
|
||||
|
||||
#setupResizer() {
|
||||
if (!utils.isDesktop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let elements = [ this.$editorCol[0], this.$previewCol[0] ];
|
||||
if (this.layoutOrientation === "vertical") {
|
||||
elements.reverse();
|
||||
}
|
||||
|
||||
this.splitInstance?.destroy();
|
||||
|
||||
if (!this.isReadOnly) {
|
||||
this.splitInstance = Split(elements, {
|
||||
rtl: glob.isRtl,
|
||||
sizes: [ 50, 50 ],
|
||||
direction: this.layoutOrientation,
|
||||
gutterSize: DEFAULT_GUTTER_SIZE,
|
||||
...this.buildSplitExtraOptions()
|
||||
});
|
||||
} else {
|
||||
this.splitInstance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
#destroyResizer() {
|
||||
this.splitInstance?.destroy();
|
||||
this.splitInstance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon when the split between the preview and content pane is initialized. Can be used to add additional listeners if needed.
|
||||
*/
|
||||
buildSplitExtraOptions(): Split.Options {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon when the code editor is being initialized. Can be used to add additional options to the editor.
|
||||
*/
|
||||
buildEditorExtraOptions(): Partial<EditorConfig> {
|
||||
return {
|
||||
lineWrapping: false
|
||||
};
|
||||
}
|
||||
|
||||
buildPreviewButtons(): OnClickButtonWidget[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
setError(message: string | null | undefined) {
|
||||
this.$errorContainer.toggleClass("hidden-ext", !message);
|
||||
this.$preview.toggleClass("on-error", !!message);
|
||||
this.$errorContainer.text(message ?? "");
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this.editorTypeWidget.getData();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("splitEditorOrientation")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import toast from "../../services/toast.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import OnClickButtonWidget from "../buttons/onclick_button.js";
|
||||
import AbstractSplitTypeWidget from "./abstract_split_type_widget.js";
|
||||
|
||||
/**
|
||||
* A specialization of `SplitTypeWidget` meant for note types that have a SVG preview.
|
||||
*
|
||||
* This adds the following functionality:
|
||||
*
|
||||
* - Automatic handling of the preview when content or the note changes via {@link renderSvg}.
|
||||
* - Built-in pan and zoom functionality with automatic re-centering.
|
||||
* - Automatically displays errors to the user if {@link renderSvg} failed.
|
||||
* - Automatically saves the SVG attachment.
|
||||
*
|
||||
*/
|
||||
export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTypeWidget {
|
||||
|
||||
private $renderContainer!: JQuery<HTMLElement>;
|
||||
private zoomHandler: () => void;
|
||||
private zoomInstance?: SvgPanZoom.Instance;
|
||||
private svg?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.zoomHandler = () => {
|
||||
if (this.zoomInstance) {
|
||||
this.zoomInstance.resize();
|
||||
this.zoomInstance.fit();
|
||||
this.zoomInstance.center();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doRender(): void {
|
||||
super.doRender();
|
||||
this.$renderContainer = $(`<div>`)
|
||||
.addClass("render-container")
|
||||
.css("height", "100%");
|
||||
this.$preview.append(this.$renderContainer);
|
||||
$(window).on("resize", this.zoomHandler);
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote) {
|
||||
super.doRefresh(note);
|
||||
|
||||
const blob = await note?.getBlob();
|
||||
const content = blob?.content || "";
|
||||
this.onContentChanged(content, true);
|
||||
|
||||
// Save the SVG when entering a note only when it does not have an attachment.
|
||||
this.note?.getAttachments().then((attachments) => {
|
||||
const attachmentName = `${this.attachmentName}.svg`;
|
||||
if (!attachments.find((a) => a.title === attachmentName)) {
|
||||
this.#saveSvg();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getData(): { content: string; } {
|
||||
const data = super.getData();
|
||||
this.onContentChanged(data.content, false);
|
||||
this.#saveSvg();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an update of the preview pane with the provided content.
|
||||
*
|
||||
* @param content the content that will be passed to `renderSvg` for rendering. It is not the SVG content.
|
||||
* @param recenter `true` to reposition the pan/zoom to fit the image and to center it.
|
||||
*/
|
||||
async onContentChanged(content: string, recenter: boolean) {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
let svg: string = "";
|
||||
try {
|
||||
svg = await this.renderSvg(content);
|
||||
|
||||
// Rendering was succesful.
|
||||
this.setError(null);
|
||||
|
||||
if (svg === this.svg) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.svg = svg;
|
||||
this.$renderContainer.html(svg);
|
||||
} catch (e: unknown) {
|
||||
// Rendering failed.
|
||||
this.setError((e as Error)?.message);
|
||||
}
|
||||
|
||||
await this.#setupPanZoom(!recenter);
|
||||
}
|
||||
|
||||
#saveSvg() {
|
||||
const payload = {
|
||||
role: "image",
|
||||
title: `${this.attachmentName}.svg`,
|
||||
mime: "image/svg+xml",
|
||||
content: this.svg,
|
||||
position: 0
|
||||
};
|
||||
|
||||
server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.#cleanUpZoom();
|
||||
$(window).off("resize", this.zoomHandler);
|
||||
super.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched.
|
||||
*
|
||||
* The method must return a valid SVG string that will be automatically displayed in the preview.
|
||||
*
|
||||
* @param content the content of the note, in plain text.
|
||||
*/
|
||||
abstract renderSvg(content: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Called to obtain the name of the note attachment (without .svg extension) that will be used for storing the preview.
|
||||
*/
|
||||
abstract get attachmentName(): string;
|
||||
|
||||
/**
|
||||
* @param preservePanZoom `true` to keep the pan/zoom settings of the previous image, or `false` to re-center it.
|
||||
*/
|
||||
async #setupPanZoom(preservePanZoom: boolean) {
|
||||
// Clean up
|
||||
let pan: SvgPanZoom.Point | null = null;
|
||||
let zoom: number | null = null;
|
||||
if (preservePanZoom && this.zoomInstance) {
|
||||
// Store pan and zoom for same note, when the user is editing the note.
|
||||
pan = this.zoomInstance.getPan();
|
||||
zoom = this.zoomInstance.getZoom();
|
||||
this.#cleanUpZoom();
|
||||
}
|
||||
|
||||
const $svgEl = this.$renderContainer.find("svg");
|
||||
|
||||
// Fit the image to bounds
|
||||
$svgEl.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.css("max-width", "100%");
|
||||
|
||||
if (!$svgEl.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svgPanZoom = (await import("svg-pan-zoom")).default;
|
||||
const zoomInstance = svgPanZoom($svgEl[0], {
|
||||
zoomEnabled: true,
|
||||
controlIconsEnabled: false
|
||||
});
|
||||
|
||||
if (preservePanZoom && pan && zoom) {
|
||||
// Restore the pan and zoom.
|
||||
zoomInstance.zoom(zoom);
|
||||
zoomInstance.pan(pan);
|
||||
} else {
|
||||
// New instance, reposition properly.
|
||||
zoomInstance.resize();
|
||||
zoomInstance.center();
|
||||
zoomInstance.fit();
|
||||
}
|
||||
|
||||
this.zoomInstance = zoomInstance;
|
||||
}
|
||||
|
||||
buildSplitExtraOptions(): Split.Options {
|
||||
return {
|
||||
onDrag: () => this.zoomHandler?.()
|
||||
}
|
||||
}
|
||||
|
||||
buildPreviewButtons(): OnClickButtonWidget[] {
|
||||
return [
|
||||
new OnClickButtonWidget()
|
||||
.icon("bx-zoom-in")
|
||||
.title(t("relation_map_buttons.zoom_in_title"))
|
||||
.titlePlacement("top")
|
||||
.onClick(() => this.zoomInstance?.zoomIn())
|
||||
, new OnClickButtonWidget()
|
||||
.icon("bx-zoom-out")
|
||||
.title(t("relation_map_buttons.zoom_out_title"))
|
||||
.titlePlacement("top")
|
||||
.onClick(() => this.zoomInstance?.zoomOut())
|
||||
, new OnClickButtonWidget()
|
||||
.icon("bx-crop")
|
||||
.title(t("relation_map_buttons.reset_pan_zoom_title"))
|
||||
.titlePlacement("top")
|
||||
.onClick(() => this.zoomHandler())
|
||||
];
|
||||
}
|
||||
|
||||
#cleanUpZoom() {
|
||||
if (this.zoomInstance) {
|
||||
this.zoomInstance.destroy();
|
||||
this.zoomInstance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async exportSvgEvent({ ntxId }: EventData<"exportSvg">) {
|
||||
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) {
|
||||
return;
|
||||
}
|
||||
|
||||
utils.downloadSvg(this.note.title, this.svg);
|
||||
}
|
||||
|
||||
async exportPngEvent({ ntxId }: EventData<"exportPng">) {
|
||||
console.log("Export to PNG", this.noteContext?.noteId, ntxId, this.svg);
|
||||
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) {
|
||||
console.log("Return");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await utils.downloadSvgAsPng(this.note.title, this.svg);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
toast.showError(t("svg.export_to_png"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user