mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 15:56:29 +01:00
Compare commits
198 Commits
v0.99.2
...
react/type
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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 { TypeWidget } from "../widgets/note_types.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;
|
||||
};
|
||||
@@ -222,8 +223,8 @@ export type CommandMappings = {
|
||||
showPasswordNotSet: CommandData;
|
||||
showProtectedSessionPasswordDialog: CommandData;
|
||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
||||
showIncludeNoteDialog: CommandData & IncludeNoteOpts;
|
||||
showAddLinkDialog: CommandData & AddLinkOpts;
|
||||
closeProtectedSessionPasswordDialog: CommandData;
|
||||
copyImageReferenceToClipboard: CommandData;
|
||||
copyImageToClipboard: CommandData;
|
||||
@@ -484,13 +485,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[];
|
||||
};
|
||||
@@ -665,6 +661,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);
|
||||
|
||||
@@ -165,6 +165,7 @@ export default class Entrypoints extends Component {
|
||||
return;
|
||||
}
|
||||
const { ntxId, note } = noteContext;
|
||||
console.log("Run active note");
|
||||
|
||||
// ctrl+enter is also used elsewhere, so make sure we're running only when appropriate
|
||||
if (!note || note.type !== "code") {
|
||||
|
||||
@@ -9,10 +9,11 @@ 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 { TypeWidget } from "../widgets/note_types.jsx";
|
||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
|
||||
export interface SetNoteOpts {
|
||||
triggerSwitchEvent?: unknown;
|
||||
@@ -395,7 +396,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
|
||||
|
||||
@@ -3,7 +3,6 @@ import TabRowWidget from "../widgets/tab_row.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
@@ -42,6 +41,7 @@ import ApiLog from "../widgets/api_log.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@@ -137,7 +137,7 @@ export default class DesktopLayout {
|
||||
.filling()
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(<SqlTableSchemas />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList />)
|
||||
.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 { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
@@ -65,7 +65,7 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(<NoteTitleWidget />))
|
||||
.child(<PopupEditorFormattingToolbar />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList displayOnlyCollections />))
|
||||
.child(<CallToActionDialog />);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
@@ -13,7 +12,7 @@ import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
@@ -24,6 +23,7 @@ import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -153,7 +153,7 @@ export default class MobileLayout {
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList />)
|
||||
.child(<FilePropertiesWrapper />)
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -279,7 +279,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;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,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}`;
|
||||
|
||||
@@ -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;
|
||||
@@ -126,10 +126,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) {
|
||||
|
||||
@@ -169,7 +169,7 @@ const entityMap: Record<string, string> = {
|
||||
"=": "="
|
||||
};
|
||||
|
||||
function escapeHtml(str: string) {
|
||||
export function escapeHtml(str: string) {
|
||||
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
|
||||
}
|
||||
|
||||
@@ -869,6 +869,29 @@ export function getErrorMessage(e: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
export interface DeferredPromise<T> extends Promise<T> {
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
export function deferred<T>(): DeferredPromise<T> {
|
||||
return (() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: any) => void;
|
||||
|
||||
let promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
}) as DeferredPromise<T>;
|
||||
|
||||
promise.resolve = resolve;
|
||||
promise.reject = reject;
|
||||
return promise as DeferredPromise<T>;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles left or right placement of e.g. tooltips in case of right-to-left languages. If the current language is a RTL one, then left and right are swapped. Other directions are unaffected.
|
||||
* @param placement a string optionally containing a "left" or "right" value.
|
||||
|
||||
@@ -404,7 +404,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;
|
||||
|
||||
@@ -543,9 +543,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 {
|
||||
|
||||
@@ -987,7 +987,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
||||
"start_session_button": "开始受保护的会话",
|
||||
"started": "受保护的会话已启动。",
|
||||
"wrong_password": "密码错误。",
|
||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||
|
||||
@@ -984,7 +984,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 flasch.",
|
||||
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
||||
|
||||
@@ -989,7 +989,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.",
|
||||
|
||||
@@ -987,7 +987,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.",
|
||||
|
||||
@@ -982,7 +982,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.",
|
||||
|
||||
@@ -968,7 +968,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.",
|
||||
|
||||
@@ -1219,7 +1219,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",
|
||||
|
||||
@@ -979,7 +979,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.",
|
||||
|
||||
@@ -1691,7 +1691,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": "Защищенный сеанс запущен.",
|
||||
|
||||
@@ -984,7 +984,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "顯示受保護的筆記需要輸入您的密碼:",
|
||||
"start_session_button": "開始受保護的作業階段 <kbd>Enter</kbd>",
|
||||
"start_session_button": "開始受保護的作業階段",
|
||||
"started": "已啟動受保護的作業階段。",
|
||||
"wrong_password": "密碼錯誤。",
|
||||
"protecting-finished-successfully": "已成功完成保護操作。",
|
||||
|
||||
@@ -1090,7 +1090,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
@@ -116,11 +116,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ViewTypeOptions } from "./collections/interface";
|
||||
|
||||
export interface FloatingButtonContext {
|
||||
parentComponent: Component;
|
||||
note: FNote;
|
||||
note: FNote;
|
||||
noteContext: NoteContext;
|
||||
isDefaultViewMode: boolean;
|
||||
isReadOnly: boolean;
|
||||
@@ -65,11 +65,11 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
|
||||
EditButton,
|
||||
RelationMapButtons,
|
||||
ExportImageButtons,
|
||||
Backlinks
|
||||
Backlinks
|
||||
]
|
||||
|
||||
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
|
||||
const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode;
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("backend_log.refresh")}
|
||||
icon="bx bx-refresh"
|
||||
@@ -84,14 +84,14 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
|
||||
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
|
||||
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
|
||||
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
|
||||
/>
|
||||
}
|
||||
|
||||
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
@@ -264,7 +264,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 && (
|
||||
@@ -325,7 +325,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
let [ backlinkCount, setBacklinkCount ] = useState(0);
|
||||
let [ popupOpen, setPopupOpen ] = useState(false);
|
||||
const backlinksContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefaultViewMode) return;
|
||||
|
||||
@@ -338,7 +338,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const { windowHeight } = useWindowSize();
|
||||
useLayoutEffect(() => {
|
||||
const el = backlinksContainerRef.current;
|
||||
if (popupOpen && el) {
|
||||
if (popupOpen && el) {
|
||||
const box = el.getBoundingClientRect();
|
||||
const maxHeight = windowHeight - box.top - 10;
|
||||
el.style.maxHeight = `${maxHeight}px`;
|
||||
@@ -374,7 +374,7 @@ function BacklinksList({ noteId }: { noteId: string }) {
|
||||
.filter(bl => "noteId" in bl)
|
||||
.map((bl) => bl.noteId);
|
||||
await froca.getNotes(noteIds);
|
||||
setBacklinks(backlinks);
|
||||
setBacklinks(backlinks);
|
||||
});
|
||||
}, [ noteId ]);
|
||||
|
||||
@@ -395,4 +395,4 @@ function BacklinksList({ noteId }: { noteId: string }) {
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/client/src/widgets/NoteDetail.css
Normal file
13
apps/client/src/widgets/NoteDetail.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.component.note-detail {
|
||||
font-family: var(--detail-font-family);
|
||||
font-size: var(--detail-font-size);
|
||||
contain: none;
|
||||
}
|
||||
|
||||
.note-detail.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail > * {
|
||||
contain: none;
|
||||
}
|
||||
276
apps/client/src/widgets/NoteDetail.tsx
Normal file
276
apps/client/src/widgets/NoteDetail.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useNoteContext, useTriliumEvent, useTriliumEvents } 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, isMobile } from "../services/utils";
|
||||
|
||||
/**
|
||||
* 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 ]);
|
||||
|
||||
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;
|
||||
|
||||
// Trigger in timeout to dismiss the menu while printing.
|
||||
setTimeout(window.print, 0);
|
||||
});
|
||||
|
||||
useTriliumEvent("exportAsPdf", () => {
|
||||
if (!noteContext?.isActive() || !note) return;
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("export-as-pdf", {
|
||||
title: note.title,
|
||||
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(([ type, Element ]) => {
|
||||
return <NoteDetailWrapper
|
||||
Element={Element}
|
||||
key={type}
|
||||
type={type as ExtendedNoteType}
|
||||
isVisible={activeNoteType === type}
|
||||
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.
|
||||
}
|
||||
}, [ isVisible ]);
|
||||
|
||||
const typeMapping = TYPE_MAPPINGS[type];
|
||||
return (
|
||||
<div
|
||||
className={`${typeMapping.className} ${typeMapping.printable ? "note-detail-printable" : ""}`}
|
||||
style={{
|
||||
display: !isVisible ? "none" : "",
|
||||
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> {
|
||||
if (!note) {
|
||||
console.log("Returning empty because no note.");
|
||||
return "empty";
|
||||
}
|
||||
|
||||
const type = note.type;
|
||||
let resultingType: ExtendedNoteType;
|
||||
|
||||
if (noteContext?.viewScope?.viewMode === "source") {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (noteContext?.viewScope && noteContext.viewScope.viewMode === "attachments") {
|
||||
resultingType = noteContext.viewScope.attachmentId ? "attachmentDetail" : "attachmentList";
|
||||
} else if (type === "text" && (await noteContext?.isReadOnly())) {
|
||||
resultingType = "readOnlyText";
|
||||
} else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) {
|
||||
resultingType = "readOnlyCode";
|
||||
} else if (type === "text") {
|
||||
resultingType = "editableText";
|
||||
} else if (type === "code") {
|
||||
resultingType = "editableCode";
|
||||
} else if (type === "launcher") {
|
||||
resultingType = "doc";
|
||||
} else {
|
||||
resultingType = type;
|
||||
}
|
||||
|
||||
if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) {
|
||||
resultingType = "protectedSession";
|
||||
}
|
||||
|
||||
return resultingType;
|
||||
}
|
||||
|
||||
function checkFullHeight(noteContext: NoteContext | undefined, type: ExtendedNoteType | undefined) {
|
||||
if (!noteContext) return false;
|
||||
|
||||
// https://github.com/zadam/trilium/issues/2522
|
||||
const isBackendNote = noteContext?.noteId === "_backendLog";
|
||||
const isSqlNote = noteContext.note?.mime === "text/x-sqlite;schema=trilium";
|
||||
const isFullHeightNoteType = type && TYPE_MAPPINGS[type].isFullHeight;
|
||||
return (!noteContext?.hasNoteList() && isFullHeightNoteType && !isSqlNote)
|
||||
|| noteContext?.viewScope?.viewMode === "attachments"
|
||||
|| isBackendNote;
|
||||
}
|
||||
@@ -1,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";
|
||||
|
||||
@@ -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,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">
|
||||
@@ -130,7 +129,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,419 +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 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";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-detail">
|
||||
<style>
|
||||
.note-detail {
|
||||
font-family: var(--detail-font-family);
|
||||
font-size: var(--detail-font-size);
|
||||
}
|
||||
|
||||
.note-detail.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const typeWidgetClasses = {
|
||||
empty: EmptyTypeWidget,
|
||||
editableText: EditableTextTypeWidget,
|
||||
readOnlyText: ReadOnlyTextTypeWidget,
|
||||
editableCode: EditableCodeTypeWidget,
|
||||
readOnlyCode: ReadOnlyCodeTypeWidget,
|
||||
file: FileTypeWidget,
|
||||
image: ImageTypeWidget,
|
||||
search: NoneTypeWidget,
|
||||
render: RenderTypeWidget,
|
||||
relationMap: RelationMapTypeWidget,
|
||||
canvas: CanvasTypeWidget,
|
||||
protectedSession: ProtectedSessionTypeWidget,
|
||||
book: BookTypeWidget,
|
||||
noteMap: NoteMapTypeWidget,
|
||||
webView: WebViewTypeWidget,
|
||||
doc: DocTypeWidget,
|
||||
contentWidget: ContentWidgetTypeWidget,
|
||||
attachmentDetail: AttachmentDetailTypeWidget,
|
||||
attachmentList: AttachmentListTypeWidget,
|
||||
mindMap: MindMapWidget,
|
||||
aiChat: AiChatTypeWidget,
|
||||
|
||||
// Split type editors
|
||||
mermaid: MermaidTypeWidget
|
||||
};
|
||||
|
||||
/**
|
||||
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
|
||||
* for protected session or attachment information.
|
||||
*/
|
||||
type ExtendedNoteType =
|
||||
| Exclude<NoteType, "launcher" | "text" | "code">
|
||||
| "empty"
|
||||
| "readOnlyCode"
|
||||
| "readOnlyText"
|
||||
| "editableText"
|
||||
| "editableCode"
|
||||
| "attachmentDetail"
|
||||
| "attachmentList"
|
||||
| "protectedSession"
|
||||
| "aiChat";
|
||||
|
||||
export default class NoteDetailWidget extends NoteContextAwareWidget {
|
||||
|
||||
private typeWidgets: Record<string, TypeWidget>;
|
||||
private spacedUpdate: SpacedUpdate;
|
||||
private type?: ExtendedNoteType;
|
||||
private mime?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.typeWidgets = {};
|
||||
|
||||
this.spacedUpdate = new SpacedUpdate(async () => {
|
||||
if (!this.noteContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { note } = this.noteContext;
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { noteId } = note;
|
||||
|
||||
const data = await this.getTypeWidget().getData();
|
||||
|
||||
// for read only notes
|
||||
if (data === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
protectedSessionHolder.touchProtectedSessionIfNecessary(note);
|
||||
|
||||
await server.put(`notes/${noteId}/data`, data, this.componentId);
|
||||
|
||||
this.getTypeWidget().dataSaved();
|
||||
});
|
||||
|
||||
appContext.addBeforeUnloadListener(this);
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Trigger in timeout to dismiss the menu while printing.
|
||||
setTimeout(window.print, 0);
|
||||
}
|
||||
|
||||
async exportAsPdfEvent() {
|
||||
if (!this.noteContext?.isActive() || !this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
||||
ipcRenderer.send("export-as-pdf", {
|
||||
title: this.note.title,
|
||||
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());
|
||||
|
||||
|
||||
142
apps/client/src/widgets/note_types.tsx
Normal file
142
apps/client/src/widgets/note_types.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @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"),
|
||||
className: "note-detail-canvas",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
},
|
||||
relationMap: {
|
||||
view: () => import("./type_widgets/relation_map/RelationMap"),
|
||||
className: "note-detail-relation-map",
|
||||
printable: 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
|
||||
}
|
||||
};
|
||||
@@ -5,16 +5,18 @@ import keyboard_actions from "../../services/keyboard_actions";
|
||||
|
||||
export interface ActionButtonProps {
|
||||
text: string;
|
||||
titlePosition?: "bottom" | "left";
|
||||
titlePosition?: "top" | "right" | "bottom" | "left";
|
||||
icon: string;
|
||||
className?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
triggerCommand?: CommandNames;
|
||||
noIconActionClass?: boolean;
|
||||
frame?: boolean;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame }: ActionButtonProps) {
|
||||
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame, active, disabled }: ActionButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
|
||||
|
||||
@@ -32,8 +34,9 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo
|
||||
|
||||
return <button
|
||||
ref={buttonRef}
|
||||
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon} ${frame ? "btn btn-primary" : ""}`}
|
||||
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon} ${frame ? "btn btn-primary" : ""} ${disabled ? "disabled" : ""} ${active ? "active" : ""}`}
|
||||
onClick={onClick}
|
||||
data-trigger-command={triggerCommand}
|
||||
disabled={disabled}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ComponentChildren } from "preact";
|
||||
interface AdmonitionProps {
|
||||
type: "warning" | "note" | "caution";
|
||||
children: ComponentChildren;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Admonition({ type, children }: AdmonitionProps) {
|
||||
export default function Admonition({ type, children, className }: AdmonitionProps) {
|
||||
return (
|
||||
<div className={`admonition ${type}`} role="alert">
|
||||
<div className={`admonition ${type} ${className}`} role="alert">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,18 @@ export default function FormFileUpload({ inputRef, name, onChange, multiple, hid
|
||||
name={name}
|
||||
type="file"
|
||||
class="form-control-file"
|
||||
multiple={multiple}
|
||||
onChange={e => onChange((e.target as HTMLInputElement).files)} />
|
||||
multiple={multiple}
|
||||
onChange={e => {
|
||||
onChange((e.target as HTMLInputElement).files);
|
||||
e.currentTarget.value = "";
|
||||
}} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Combination of a button with a hidden file upload field.
|
||||
*
|
||||
*
|
||||
* @param param the change listener for the file upload and the properties for the button.
|
||||
*/
|
||||
export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange">) {
|
||||
@@ -39,10 +42,10 @@ export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonPr
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
<FormFileUpload
|
||||
inputRef={inputRef}
|
||||
inputRef={inputRef}
|
||||
hidden
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +5,18 @@ import { openInAppHelpFromUrl } from "../../services/utils";
|
||||
interface HelpButtonProps {
|
||||
className?: string;
|
||||
helpPage: string;
|
||||
title?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function HelpButton({ className, helpPage, style }: HelpButtonProps) {
|
||||
export default function HelpButton({ className, helpPage, title, style }: HelpButtonProps) {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { CommandListenerData, EventData, EventNames } from "../../components/app_context";
|
||||
import { ParentComponent } from "./react_utils";
|
||||
import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import appContext, { EventData, EventNames } from "../../components/app_context";
|
||||
import { ParentComponent, refToJQuerySelector } from "./react_utils";
|
||||
import SpacedUpdate from "../../services/spaced_update";
|
||||
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
|
||||
import options, { type OptionValue } from "../../services/options";
|
||||
@@ -19,7 +19,10 @@ import Mark from "mark.js";
|
||||
import { DragData } from "../note_tree";
|
||||
import Component from "../../components/component";
|
||||
import toast, { ToastOptions } from "../../services/toast";
|
||||
import { ViewMode } from "../../services/link";
|
||||
import protected_session_holder from "../../services/protected_session_holder";
|
||||
import server from "../../services/server";
|
||||
import { removeIndividualBinding } from "../../services/shortcuts";
|
||||
import { ViewScope } from "../../services/link";
|
||||
|
||||
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
@@ -74,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.
|
||||
*
|
||||
@@ -197,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 [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -207,7 +270,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);
|
||||
@@ -373,7 +436,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() {
|
||||
@@ -394,6 +457,10 @@ export function useNoteBlob(note: FNote | null | undefined): FBlob | null | unde
|
||||
if (loadResults.hasRevisionForNote(note.noteId)) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (loadResults.isNoteContentReloaded(note.noteId, componentId)) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
useDebugValue(note?.noteId);
|
||||
@@ -667,26 +734,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(() => {
|
||||
@@ -701,3 +748,17 @@ export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => v
|
||||
return () => observer.disconnect();
|
||||
}, [ 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);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -45,13 +45,13 @@ function RevisionsButton({ note }: { note: FNote }) {
|
||||
function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(note.type);
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc", "render"].includes(note.type);
|
||||
const isInOptions = note.noteId.startsWith("_options");
|
||||
const isPrintable = ["text", "code"].includes(note.type);
|
||||
const isElectron = getIsElectron();
|
||||
const isMac = getIsMac();
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type);
|
||||
const isSearchOrBook = ["search", "book"].includes(note.type);
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "aiChat"].includes(note.type);
|
||||
const isSearchOrBook = ["search", "book"].includes(note.type);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -74,7 +74,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
|
||||
<CommandItem icon="bx bx-export" text={t("note_actions.export_note")}
|
||||
disabled={isInOptions || note.noteId === "_backendLog"}
|
||||
command={() => noteContext?.notePath && parentComponent?.triggerCommand("showExportDialog", {
|
||||
notePath: noteContext.notePath,
|
||||
notePath: noteContext.notePath,
|
||||
defaultType: "single"
|
||||
})} />
|
||||
<FormDropdownDivider />
|
||||
@@ -133,4 +133,4 @@ function ConvertToAttachment({ note }: { note: FNote }) {
|
||||
}}
|
||||
>{t("note_actions.convert_into_attachment")}</FormListItem>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -50,4 +44,4 @@ export default function NoteMapTab({ noteContext }: TabContext) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal file
47
apps/client/src/widgets/type_widgets/AiChat.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef, useState } 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);
|
||||
console.log("Refresh!");
|
||||
}, [ 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 */
|
||||
303
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal file
303
apps/client/src/widgets/type_widgets/Attachment.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
content_renderer.getRenderedContent(attachment, { imageHasZoom: isFullDetail })
|
||||
.then(({ $renderedContent }) => {
|
||||
contentWrapper.current?.replaceChildren(...$renderedContent);
|
||||
})
|
||||
}, [ attachment ]);
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
34
apps/client/src/widgets/type_widgets/Canvas.css
Normal file
34
apps/client/src/widgets/type_widgets/Canvas.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.excalidraw .App-menu_top .buttonList {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Conflict between excalidraw and bootstrap classes keeps the menu hidden */
|
||||
/* https://github.com/zadam/trilium/issues/3780 */
|
||||
/* https://github.com/excalidraw/excalidraw/issues/6567 */
|
||||
.excalidraw .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.excalidraw-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:root[dir="ltr"]
|
||||
.excalidraw
|
||||
.layer-ui__wrapper
|
||||
.zen-mode-transition.App-menu_bottom--transition-left {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* collaboration not possible so hide the button */
|
||||
.CollabButton {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.library-button {
|
||||
display: none !important; /* library won't work without extra support which isn't currently implemented */
|
||||
}
|
||||
|
||||
.note-detail-canvas > .canvas-render {
|
||||
height: 100%;
|
||||
}
|
||||
325
apps/client/src/widgets/type_widgets/Canvas.tsx
Normal file
325
apps/client/src/widgets/type_widgets/Canvas.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { Excalidraw, exportToSvg, getSceneVersion } from "@excalidraw/excalidraw";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import { useEditorSpacedUpdate, useNoteLabelBoolean } from "../react/hooks";
|
||||
import { useCallback, useMemo, useRef } from "preact/hooks";
|
||||
import { type ExcalidrawImperativeAPI, type AppState, type BinaryFileData, LibraryItem, ExcalidrawProps } from "@excalidraw/excalidraw/types";
|
||||
import options from "../../services/options";
|
||||
import "./Canvas.css";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { RefObject } from "preact";
|
||||
import server from "../../services/server";
|
||||
import { ExcalidrawElement, NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import { goToLinkExt } from "../../services/link";
|
||||
import NoteContext from "../../components/note_context";
|
||||
|
||||
// currently required by excalidraw, in order to allows self-hosting fonts locally.
|
||||
// this avoids making excalidraw load the fonts from an external CDN.
|
||||
window.EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
|
||||
|
||||
interface AttachmentMetadata {
|
||||
title: string;
|
||||
attachmentId: string;
|
||||
}
|
||||
|
||||
interface CanvasContent {
|
||||
elements: ExcalidrawElement[];
|
||||
files: BinaryFileData[];
|
||||
appState: Partial<AppState>;
|
||||
}
|
||||
|
||||
export default function Canvas({ note, noteContext }: TypeWidgetProps) {
|
||||
const apiRef = useRef<ExcalidrawImperativeAPI>(null);
|
||||
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const themeStyle = useMemo(() => {
|
||||
const documentStyle = window.getComputedStyle(document.documentElement);
|
||||
return documentStyle.getPropertyValue("--theme-style")?.trim() as AppState["theme"];
|
||||
}, []);
|
||||
const persistence = usePersistence(note, noteContext, apiRef, themeStyle, isReadOnly);
|
||||
|
||||
/** Use excalidraw's native zoom instead of the global zoom. */
|
||||
const onWheel = useCallback((e: MouseEvent) => {
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onLinkOpen = useCallback((element: NonDeletedExcalidrawElement, event: CustomEvent) => {
|
||||
let link = element.link;
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (link.startsWith("root/")) {
|
||||
link = "#" + link;
|
||||
}
|
||||
|
||||
const { nativeEvent } = event.detail;
|
||||
event.preventDefault();
|
||||
return goToLinkExt(nativeEvent, link, null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="canvas-render" onWheel={onWheel}>
|
||||
<div className="excalidraw-wrapper">
|
||||
<Excalidraw
|
||||
excalidrawAPI={api => apiRef.current = api}
|
||||
theme={themeStyle}
|
||||
viewModeEnabled={isReadOnly || options.is("databaseReadonly")}
|
||||
zenModeEnabled={false}
|
||||
isCollaborating={false}
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={false}
|
||||
autoFocus={false}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
saveToActiveFile: false,
|
||||
export: false
|
||||
}
|
||||
}}
|
||||
onLinkOpen={onLinkOpen}
|
||||
{...persistence}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: RefObject<ExcalidrawImperativeAPI>, theme: AppState["theme"], isReadOnly: boolean): Partial<ExcalidrawProps> {
|
||||
const libraryChanged = useRef(false);
|
||||
|
||||
/**
|
||||
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
|
||||
* we compare the scene version as suggested in:
|
||||
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
|
||||
*
|
||||
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
|
||||
*/
|
||||
const currentSceneVersion = useRef(0);
|
||||
|
||||
// these 2 variables are needed to compare the library state (all library items) after loading to the state when the library changed. So we can find attachments to be deleted.
|
||||
//every libraryitem is saved on its own json file in the attachments of the note.
|
||||
const libraryCache = useRef<LibraryItem[]>([]);
|
||||
const attachmentMetadata = useRef<AttachmentMetadata[]>([]);
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
onContentChange(newContent) {
|
||||
const api = apiRef.current;
|
||||
if (!api) return;
|
||||
|
||||
libraryCache.current = [];
|
||||
attachmentMetadata.current = [];
|
||||
currentSceneVersion.current = -1;
|
||||
|
||||
// load saved content into excalidraw canvas
|
||||
let content: CanvasContent = {
|
||||
elements: [],
|
||||
files: [],
|
||||
appState: {}
|
||||
};
|
||||
if (newContent) {
|
||||
try {
|
||||
content = JSON.parse(newContent) as CanvasContent;
|
||||
} catch (err) {
|
||||
console.error("Error parsing content. Probably note.type changed. Starting with empty canvas", note, err);
|
||||
}
|
||||
}
|
||||
|
||||
loadData(api, content, theme);
|
||||
|
||||
// load the library state
|
||||
loadLibrary(note).then(({ libraryItems, metadata }) => {
|
||||
// Update the library and save to independent variables
|
||||
api.updateLibrary({ libraryItems: libraryItems, merge: false });
|
||||
|
||||
// save state of library to compare it to the new state later.
|
||||
libraryCache.current = libraryItems;
|
||||
attachmentMetadata.current = metadata;
|
||||
});
|
||||
},
|
||||
async getData() {
|
||||
const api = apiRef.current;
|
||||
if (!api) return;
|
||||
const { content, svg } = await getData(api);
|
||||
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
|
||||
|
||||
// libraryChanged is unset in dataSaved()
|
||||
if (libraryChanged.current) {
|
||||
// there's no separate method to get library items, so have to abuse this one
|
||||
const libraryItems = await api.updateLibrary({
|
||||
libraryItems() {
|
||||
return [];
|
||||
},
|
||||
merge: true
|
||||
});
|
||||
|
||||
// excalidraw saves the library as a own state. the items are saved to libraryItems. then we compare the library right now with a libraryitemcache. The cache is filled when we first load the Library into the note.
|
||||
//We need the cache to delete old attachments later in the server.
|
||||
|
||||
const libraryItemsMissmatch = libraryCache.current.filter((obj1) => !libraryItems.some((obj2: LibraryItem) => obj1.id === obj2.id));
|
||||
|
||||
// before we saved the metadata of the attachments in a cache. the title of the attachment is a combination of libraryitem ´s ID und it´s name.
|
||||
// we compare the library items in the libraryitemmissmatch variable (this one saves all libraryitems that are different to the state right now. E.g. you delete 1 item, this item is saved as mismatch)
|
||||
// then we combine its id and title and search the according attachmentID.
|
||||
|
||||
const matchingItems = attachmentMetadata.current.filter((meta) => {
|
||||
// Loop through the second array and check for a match
|
||||
return libraryItemsMissmatch.some((item) => {
|
||||
// Combine the `name` and `id` from the second array
|
||||
const combinedTitle = `${item.id}${item.name}`;
|
||||
return meta.title === combinedTitle;
|
||||
});
|
||||
});
|
||||
|
||||
// we save the attachment ID`s in a variable and delete every attachmentID. Now the items that the user deleted will be deleted.
|
||||
const attachmentIds = matchingItems.map((item) => item.attachmentId);
|
||||
|
||||
//delete old attachments that are no longer used
|
||||
for (const item of attachmentIds) {
|
||||
await server.remove(`attachments/${item}`);
|
||||
}
|
||||
|
||||
let position = 10;
|
||||
|
||||
// prepare data to save to server e.g. new library items.
|
||||
for (const libraryItem of libraryItems) {
|
||||
attachments.push({
|
||||
role: "canvasLibraryItem",
|
||||
title: libraryItem.id + libraryItem.name,
|
||||
mime: "application/json",
|
||||
content: JSON.stringify(libraryItem),
|
||||
position: position
|
||||
});
|
||||
|
||||
position += 10;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: JSON.stringify(content),
|
||||
attachments
|
||||
};
|
||||
},
|
||||
dataSaved() {
|
||||
libraryChanged.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
onChange: () => {
|
||||
if (!apiRef.current || isReadOnly) return;
|
||||
const oldSceneVersion = currentSceneVersion.current;
|
||||
const newSceneVersion = getSceneVersion(apiRef.current.getSceneElements());
|
||||
|
||||
if (newSceneVersion !== oldSceneVersion) {
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
spacedUpdate.scheduleUpdate();
|
||||
currentSceneVersion.current = newSceneVersion;
|
||||
}
|
||||
},
|
||||
onLibraryChange: () => {
|
||||
libraryChanged.current = true;
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
spacedUpdate.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getData(api: ExcalidrawImperativeAPI) {
|
||||
const elements = api.getSceneElements();
|
||||
const appState = api.getAppState();
|
||||
|
||||
/**
|
||||
* A file is not deleted, even though removed from canvas. Therefore, we only keep
|
||||
* files that are referenced by an element. Maybe this will change with a new excalidraw version?
|
||||
*/
|
||||
const files = api.getFiles();
|
||||
// parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share
|
||||
const svg = await exportToSvg({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding: 5, // 5 px padding
|
||||
files
|
||||
});
|
||||
const svgString = svg.outerHTML;
|
||||
|
||||
const activeFiles: Record<string, BinaryFileData> = {};
|
||||
elements.forEach((element: NonDeletedExcalidrawElement) => {
|
||||
if ("fileId" in element && element.fileId) {
|
||||
activeFiles[element.fileId] = files[element.fileId];
|
||||
}
|
||||
});
|
||||
|
||||
const content = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements,
|
||||
files: activeFiles,
|
||||
appState: {
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom,
|
||||
gridModeEnabled: appState.gridModeEnabled
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
content,
|
||||
svg: svgString
|
||||
}
|
||||
}
|
||||
|
||||
function loadData(api: ExcalidrawImperativeAPI, content: CanvasContent, theme: AppState["theme"]) {
|
||||
const { elements, files } = content;
|
||||
const appState: Partial<AppState> = content.appState ?? {};
|
||||
appState.theme = theme;
|
||||
|
||||
// files are expected in an array when loading. they are stored as a key-index object
|
||||
// see example for loading here:
|
||||
// https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68
|
||||
const fileArray: BinaryFileData[] = [];
|
||||
for (const fileId in files) {
|
||||
const file = files[fileId];
|
||||
// TODO: dataURL is replaceable with a trilium image url
|
||||
// maybe we can save normal images (pasted) with base64 data url, and trilium images
|
||||
// with their respective url! nice
|
||||
// file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png";
|
||||
fileArray.push(file);
|
||||
}
|
||||
|
||||
// Update the scene
|
||||
// TODO: Fix type of sceneData
|
||||
api.updateScene({
|
||||
elements,
|
||||
appState: appState as AppState
|
||||
});
|
||||
api.addFiles(fileArray);
|
||||
api.history.clear();
|
||||
}
|
||||
|
||||
async function loadLibrary(note: FNote) {
|
||||
return Promise.all(
|
||||
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
|
||||
const blob = await attachment.getBlob();
|
||||
return {
|
||||
blob, // Save the blob for libraryItems
|
||||
metadata: {
|
||||
// metadata to use in the cache variables for comparing old library state and new one. We delete unnecessary items later, calling the server directly
|
||||
attachmentId: attachment.attachmentId,
|
||||
title: attachment.title
|
||||
}
|
||||
};
|
||||
})
|
||||
).then((results) => {
|
||||
// Extract libraryItems from the blobs
|
||||
const libraryItems = results.map((result) => result?.blob?.getJsonContentSafely()).filter((item) => !!item) as LibraryItem[];
|
||||
|
||||
// Extract metadata for each attachment
|
||||
const metadata = results.map((result) => result.metadata);
|
||||
|
||||
return { libraryItems, metadata };
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
36
apps/client/src/widgets/type_widgets/Doc.tsx
Normal file
36
apps/client/src/widgets/type_widgets/Doc.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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}) => {
|
||||
console.log("Got request for content ", 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 { useCallback, 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%);
|
||||
}
|
||||
79
apps/client/src/widgets/type_widgets/File.tsx
Normal file
79
apps/client/src/widgets/type_widgets/File.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { VNode } from "preact";
|
||||
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 { useNoteBlob, 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
|
||||
}
|
||||
161
apps/client/src/widgets/type_widgets/MindMap.tsx
Normal file
161
apps/client/src/widgets/type_widgets/MindMap.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
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, useTriliumOptionBool } 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;
|
||||
content: MindElixirData;
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const content = VanillaMindElixir.new(NEW_TOPIC_NAME);
|
||||
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}
|
||||
content={content}
|
||||
onChange={() => spacedUpdate.scheduleUpdate()}
|
||||
editable={!isReadOnly}
|
||||
containerProps={{
|
||||
className: "mind-map-container",
|
||||
onKeyDown
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MindElixir({ content, containerRef: externalContainerRef, containerProps, apiRef: externalApiRef, onChange, editable }: MindElixirProps) {
|
||||
const containerRef = useSyncedRef<HTMLDivElement>(externalContainerRef, null);
|
||||
const apiRef = useRef<MindElixirInstance>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const mind = new VanillaMindElixir({
|
||||
el: containerRef.current,
|
||||
editable
|
||||
});
|
||||
|
||||
if (editable) {
|
||||
mind.install(nodeMenu);
|
||||
}
|
||||
mind.init(content);
|
||||
|
||||
apiRef.current = mind;
|
||||
if (externalApiRef) {
|
||||
externalApiRef.current = mind;
|
||||
}
|
||||
|
||||
return () => mind.destroy();
|
||||
}, [ 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,283 +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 "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, {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import appContext, { type EventData } from "../../components/app_context.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import linkService, { type ViewScope } from "../../services/link.js";
|
||||
import contentRenderer from "../../services/content_renderer.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import options from "../../services/options.js";
|
||||
import attributes from "../../services/attributes.js";
|
||||
|
||||
export default class AbstractTextTypeWidget extends TypeWidget {
|
||||
doRender() {
|
||||
super.doRender();
|
||||
this.refreshCodeBlockOptions();
|
||||
}
|
||||
|
||||
setupImageOpening(singleClickOpens: boolean) {
|
||||
this.$widget.on("dblclick", "img", (e) => this.openImageInCurrentTab($(e.target)));
|
||||
|
||||
this.$widget.on("click", "img", (e) => {
|
||||
e.stopPropagation();
|
||||
const isLeftClick = e.which === 1;
|
||||
const isMiddleClick = e.which === 2;
|
||||
const ctrlKey = utils.isCtrlKey(e);
|
||||
const activate = (isLeftClick && ctrlKey && e.shiftKey) || (isMiddleClick && e.shiftKey);
|
||||
|
||||
if ((isLeftClick && ctrlKey) || isMiddleClick) {
|
||||
this.openImageInNewTab($(e.target), activate);
|
||||
} else if (isLeftClick && singleClickOpens) {
|
||||
this.openImageInCurrentTab($(e.target));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async openImageInCurrentTab($img: JQuery<HTMLElement>) {
|
||||
const parsedImage = await this.parseFromImage($img);
|
||||
|
||||
if (parsedImage) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
|
||||
} else {
|
||||
window.open($img.prop("src"), "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
async openImageInNewTab($img: JQuery<HTMLElement>, activate: boolean = false) {
|
||||
const parsedImage = await this.parseFromImage($img);
|
||||
|
||||
if (parsedImage?.noteId) {
|
||||
appContext.tabManager.openTabWithNoteWithHoisting(parsedImage.noteId, { activate, viewScope: parsedImage.viewScope });
|
||||
} else {
|
||||
window.open($img.prop("src"), "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
async parseFromImage($img: JQuery<HTMLElement>): Promise<{ noteId?: string, viewScope: ViewScope } | null> {
|
||||
const imgSrc = $img.prop("src");
|
||||
|
||||
const imageNoteMatch = imgSrc.match(/\/api\/images\/([A-Za-z0-9_]+)\//);
|
||||
if (imageNoteMatch) {
|
||||
return {
|
||||
noteId: imageNoteMatch[1],
|
||||
viewScope: {}
|
||||
};
|
||||
}
|
||||
|
||||
const attachmentMatch = imgSrc.match(/\/api\/attachments\/([A-Za-z0-9_]+)\/image\//);
|
||||
if (attachmentMatch) {
|
||||
const attachmentId = attachmentMatch[1];
|
||||
const attachment = await froca.getAttachment(attachmentId);
|
||||
|
||||
return {
|
||||
noteId: attachment?.ownerId,
|
||||
viewScope: {
|
||||
viewMode: "attachments",
|
||||
attachmentId: attachmentId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>) {
|
||||
const note = await froca.getNote(noteId);
|
||||
|
||||
if (note) {
|
||||
const $wrapper = $('<div class="include-note-wrapper">');
|
||||
|
||||
const $link = await linkService.createLink(note.noteId, {
|
||||
showTooltip: false
|
||||
});
|
||||
|
||||
$wrapper.empty().append($('<h4 class="include-note-title">').append($link));
|
||||
|
||||
const { $renderedContent, type } = await contentRenderer.getRenderedContent(note);
|
||||
|
||||
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
|
||||
|
||||
$el.empty().append($wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
async loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null = null) {
|
||||
await linkService.loadReferenceLinkTitle($el, href);
|
||||
}
|
||||
|
||||
refreshIncludedNote($container: JQuery<HTMLElement>, noteId: string) {
|
||||
if ($container) {
|
||||
$container.find(`section[data-note-id="${noteId}"]`).each((_, el) => {
|
||||
this.loadIncludedNote(noteId, $(el));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshCodeBlockOptions() {
|
||||
const wordWrap = options.is("codeBlockWordWrap");
|
||||
this.$widget.toggleClass("word-wrap", wordWrap);
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("codeBlockWordWrap")) {
|
||||
this.refreshCodeBlockOptions();
|
||||
}
|
||||
|
||||
if (loadResults.getAttributeRows().find((attr) =>
|
||||
attr.type === "label" &&
|
||||
attr.name === "language" &&
|
||||
attributes.isAffecting(attr, this.note)))
|
||||
{
|
||||
await this.onLanguageChanged();
|
||||
}
|
||||
}
|
||||
|
||||
async onLanguageChanged() { }
|
||||
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import LlmChatPanel from "../llm_chat_panel.js";
|
||||
import { type EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
|
||||
export default class AiChatTypeWidget extends TypeWidget {
|
||||
private llmChatPanel: LlmChatPanel;
|
||||
private isInitialized: boolean = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.llmChatPanel = new LlmChatPanel();
|
||||
|
||||
// Connect the data callbacks
|
||||
this.llmChatPanel.setDataCallbacks(
|
||||
(data) => this.saveData(data),
|
||||
() => this.getData()
|
||||
);
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "aiChat";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $('<div class="ai-chat-widget-container" style="height: 100%;"></div>');
|
||||
this.$widget.append(this.llmChatPanel.render());
|
||||
|
||||
return this.$widget;
|
||||
}
|
||||
|
||||
// Override the refreshWithNote method to ensure we get note changes
|
||||
async refreshWithNote(note: FNote | null | undefined) {
|
||||
console.log("refreshWithNote called for note:", note?.noteId);
|
||||
|
||||
// Always force a refresh when the note changes
|
||||
if (this.note?.noteId !== note?.noteId) {
|
||||
console.log(`Note ID changed from ${this.note?.noteId} to ${note?.noteId}, forcing reset`);
|
||||
this.isInitialized = false;
|
||||
this.initPromise = null;
|
||||
|
||||
// Force refresh the chat panel with the new note
|
||||
if (note) {
|
||||
this.llmChatPanel.setCurrentNoteId(note.noteId);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with regular doRefresh
|
||||
await this.doRefresh(note);
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote | null | undefined) {
|
||||
try {
|
||||
console.log("doRefresh called for note:", note?.noteId);
|
||||
|
||||
// If we're already initializing, wait for that to complete
|
||||
if (this.initPromise) {
|
||||
await this.initPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize once or when note changes
|
||||
if (!this.isInitialized) {
|
||||
console.log("Initializing AI Chat Panel for note:", note?.noteId);
|
||||
|
||||
// Initialize the note content first
|
||||
if (note) {
|
||||
try {
|
||||
const content = await note.getContent();
|
||||
// Check if content is empty
|
||||
if (!content || content === '{}') {
|
||||
// Initialize with empty chat history
|
||||
await this.saveData({
|
||||
messages: [],
|
||||
title: note.title,
|
||||
noteId: note.noteId // Store the note ID in the data
|
||||
});
|
||||
console.log("Initialized empty chat history for new note");
|
||||
} else {
|
||||
console.log("Note already has content, will load in LlmChatPanel.refresh()");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error initializing AI Chat note content:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a promise to track initialization
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
// Reset the UI before refreshing
|
||||
this.llmChatPanel.clearNoteContextChatMessages();
|
||||
this.llmChatPanel.setMessages([]);
|
||||
|
||||
// Set the note ID for the chat panel
|
||||
if (note) {
|
||||
this.llmChatPanel.setNoteId(note.noteId);
|
||||
}
|
||||
|
||||
// This will load saved data via the getData callback
|
||||
await this.llmChatPanel.refresh();
|
||||
this.isInitialized = true;
|
||||
} catch (e) {
|
||||
console.error("Error initializing LlmChatPanel:", e);
|
||||
toastService.showError("Failed to initialize chat panel. Try reloading.");
|
||||
}
|
||||
})();
|
||||
|
||||
await this.initPromise;
|
||||
this.initPromise = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error in doRefresh:", e);
|
||||
toastService.showError("Error refreshing chat. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent(data: EventData<"entitiesReloaded">) {
|
||||
// We don't need to refresh on entities reloaded for the chat
|
||||
}
|
||||
|
||||
async noteSwitched() {
|
||||
console.log("Note switched to:", this.noteId);
|
||||
|
||||
// Force a full reset when switching notes
|
||||
this.isInitialized = false;
|
||||
this.initPromise = null;
|
||||
|
||||
if (this.note) {
|
||||
// Update the chat panel with the new note ID before refreshing
|
||||
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
|
||||
|
||||
// Reset the chat panel UI
|
||||
this.llmChatPanel.clearNoteContextChatMessages();
|
||||
this.llmChatPanel.setMessages([]);
|
||||
this.llmChatPanel.setNoteId(this.note.noteId);
|
||||
}
|
||||
|
||||
// Call the parent method to refresh
|
||||
await super.noteSwitched();
|
||||
}
|
||||
|
||||
async activeContextChangedEvent(data: EventData<"activeContextChanged">) {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Active context changed, refreshing AI Chat Panel");
|
||||
|
||||
// Always refresh when we become active - this ensures we load the correct note data
|
||||
try {
|
||||
// Reset initialization flag to force a refresh
|
||||
this.isInitialized = false;
|
||||
|
||||
// Make sure the chat panel has the current note ID
|
||||
if (this.note) {
|
||||
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
|
||||
this.llmChatPanel.setNoteId(this.note.noteId);
|
||||
}
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
// Reset the UI before refreshing
|
||||
this.llmChatPanel.clearNoteContextChatMessages();
|
||||
this.llmChatPanel.setMessages([]);
|
||||
|
||||
await this.llmChatPanel.refresh();
|
||||
this.isInitialized = true;
|
||||
} catch (e) {
|
||||
console.error("Error refreshing LlmChatPanel:", e);
|
||||
}
|
||||
})();
|
||||
|
||||
await this.initPromise;
|
||||
this.initPromise = null;
|
||||
} catch (e) {
|
||||
console.error("Error in activeContextChangedEvent:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save chat data to the note
|
||||
async saveData(data: any) {
|
||||
// If we have a noteId in the data, that's the AI Chat note we should save to
|
||||
// This happens when the chat panel is saving its conversation
|
||||
const targetNoteId = data.noteId;
|
||||
|
||||
// If no noteId in data, use the current note (for new chats)
|
||||
const noteIdToUse = targetNoteId || this.note?.noteId;
|
||||
|
||||
if (!noteIdToUse) {
|
||||
console.warn("Cannot save AI Chat data: no note ID available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`AiChatTypeWidget: Saving data for note ${noteIdToUse} (current note: ${this.note?.noteId}, data.noteId: ${data.noteId})`);
|
||||
|
||||
// Safety check: if we have both IDs and they don't match, warn about it
|
||||
if (targetNoteId && this.note?.noteId && targetNoteId !== this.note.noteId) {
|
||||
console.warn(`Note ID mismatch: saving to ${targetNoteId} but current note is ${this.note.noteId}`);
|
||||
}
|
||||
|
||||
// Format the data properly - this is the canonical format of the data
|
||||
const formattedData = {
|
||||
messages: data.messages || [],
|
||||
noteId: noteIdToUse, // Always preserve the correct note ID
|
||||
toolSteps: data.toolSteps || [],
|
||||
sources: data.sources || [],
|
||||
metadata: {
|
||||
...(data.metadata || {}),
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
// Save the data to the correct note
|
||||
await server.put(`notes/${noteIdToUse}/data`, {
|
||||
content: JSON.stringify(formattedData, null, 2)
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error saving AI Chat data:", e);
|
||||
toastService.showError("Failed to save chat data");
|
||||
}
|
||||
}
|
||||
|
||||
// Get data from the note
|
||||
async getData() {
|
||||
if (!this.note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`AiChatTypeWidget: Getting data for note ${this.note.noteId}`);
|
||||
const content = await this.note.getContent();
|
||||
|
||||
if (!content) {
|
||||
console.log("Note content is empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the content as JSON
|
||||
let parsedContent;
|
||||
try {
|
||||
parsedContent = JSON.parse(content as string);
|
||||
console.log("Successfully parsed note content as JSON");
|
||||
} catch (e) {
|
||||
console.error("Error parsing chat content as JSON:", e);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this is a blob response with 'content' property that needs to be parsed again
|
||||
// This happens when the content is returned from the /blob endpoint
|
||||
if (parsedContent.content && typeof parsedContent.content === 'string' &&
|
||||
parsedContent.blobId && parsedContent.contentLength) {
|
||||
try {
|
||||
// The actual chat data is inside the 'content' property as a string
|
||||
console.log("Detected blob response structure, parsing inner content");
|
||||
const innerContent = JSON.parse(parsedContent.content);
|
||||
console.log("Successfully parsed blob inner content");
|
||||
return innerContent;
|
||||
} catch (innerError) {
|
||||
console.error("Error parsing inner blob content:", innerError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
} catch (e) {
|
||||
console.error("Error loading AI Chat data:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import AttachmentDetailWidget from "../attachment_detail.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attachment-detail note-detail-printable">
|
||||
<style>
|
||||
.attachment-detail {
|
||||
padding-inline-start: 15px;
|
||||
padding-inline-end: 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="links-wrapper use-tn-links"></div>
|
||||
|
||||
<div class="attachment-wrapper"></div>
|
||||
</div>`;
|
||||
|
||||
export default class AttachmentDetailTypeWidget extends TypeWidget {
|
||||
$wrapper!: JQuery<HTMLElement>;
|
||||
$linksWrapper!: JQuery<HTMLElement>;
|
||||
|
||||
static getType() {
|
||||
return "attachmentDetail";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$wrapper = this.$widget.find(".attachment-wrapper");
|
||||
this.$linksWrapper = this.$widget.find(".links-wrapper");
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async doRefresh(note: Parameters<TypeWidget["doRefresh"]>[0]) {
|
||||
this.$wrapper.empty();
|
||||
this.children = [];
|
||||
|
||||
const $helpButton = $(`
|
||||
<button class="attachment-help-button icon-action bx bx-help-circle"
|
||||
type="button" data-help-page="attachments.html"
|
||||
title="${t("attachment_detail.open_help_page")}"
|
||||
</button>
|
||||
`);
|
||||
utils.initHelpButtons($helpButton);
|
||||
|
||||
this.$linksWrapper.empty().append(
|
||||
t("attachment_detail.owning_note"),
|
||||
await linkService.createLink(this.noteId),
|
||||
t("attachment_detail.you_can_also_open"),
|
||||
await linkService.createLink(this.noteId, {
|
||||
title: t("attachment_detail.list_of_all_attachments"),
|
||||
viewScope: {
|
||||
viewMode: "attachments"
|
||||
}
|
||||
}),
|
||||
$helpButton
|
||||
);
|
||||
|
||||
const attachment = this.attachmentId ? await froca.getAttachment(this.attachmentId, true) : null;
|
||||
|
||||
if (!attachment) {
|
||||
this.$wrapper.html("<strong>" + t("attachment_detail.attachment_deleted") + "</strong>");
|
||||
return;
|
||||
}
|
||||
|
||||
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, true);
|
||||
this.child(attachmentDetailWidget);
|
||||
|
||||
this.$wrapper.append(attachmentDetailWidget.render());
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
const attachmentRow = loadResults.getAttachmentRows().find((att) => att.attachmentId === this.attachmentId);
|
||||
|
||||
if (attachmentRow?.isDeleted) {
|
||||
this.refresh(); // all other updates are handled within AttachmentDetailWidget
|
||||
}
|
||||
}
|
||||
|
||||
get attachmentId() {
|
||||
return this?.noteContext?.viewScope?.attachmentId;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import AttachmentDetailWidget from "../attachment_detail.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attachment-list note-detail-printable">
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="links-wrapper"></div>
|
||||
|
||||
<div class="attachment-list-wrapper"></div>
|
||||
</div>`;
|
||||
|
||||
export default class AttachmentListTypeWidget extends TypeWidget {
|
||||
$list!: JQuery<HTMLElement>;
|
||||
$linksWrapper!: JQuery<HTMLElement>;
|
||||
renderedAttachmentIds!: Set<string>;
|
||||
|
||||
static getType() {
|
||||
return "attachmentList";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$list = this.$widget.find(".attachment-list-wrapper");
|
||||
this.$linksWrapper = this.$widget.find(".links-wrapper");
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async doRefresh(note: Parameters<TypeWidget["doRefresh"]>[0]) {
|
||||
const $helpButton = $(`
|
||||
<button class="attachment-help-button icon-action bx bx-help-circle"
|
||||
type="button" data-help-page="attachments.html"
|
||||
title="${t("attachment_list.open_help_page")}">
|
||||
</button>
|
||||
`);
|
||||
utils.initHelpButtons($helpButton);
|
||||
|
||||
const noteLink = await linkService.createLink(this.noteId); // do separately to avoid race condition between empty() and .append()
|
||||
noteLink.addClass("use-tn-links");
|
||||
|
||||
const $uploadButton = $(`
|
||||
<button class="btn btn-sm">
|
||||
<span class="bx bx-folder-open"></span>
|
||||
${t("attachment_list.upload_attachments")}
|
||||
</button>
|
||||
`);
|
||||
|
||||
$uploadButton.on("click", () => {
|
||||
if (this.noteId) {
|
||||
this.triggerCommand("showUploadAttachmentsDialog", { noteId: this.noteId });
|
||||
}
|
||||
})
|
||||
|
||||
this.$linksWrapper.empty().append(
|
||||
$("<div>").append(t("attachment_list.owning_note"), noteLink),
|
||||
$(`<div class="attachment-actions-toolbar">`).append($uploadButton, $helpButton)
|
||||
);
|
||||
|
||||
this.$list.empty();
|
||||
this.children = [];
|
||||
this.renderedAttachmentIds = new Set();
|
||||
|
||||
const attachments = await note.getAttachments();
|
||||
|
||||
if (attachments.length === 0) {
|
||||
this.$list.html('<div class="alert alert-info">' + t("attachment_list.no_attachments") + "</div>");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false);
|
||||
|
||||
this.child(attachmentDetailWidget);
|
||||
|
||||
this.renderedAttachmentIds.add(attachment.attachmentId);
|
||||
|
||||
this.$list.append(attachmentDetailWidget.render());
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed
|
||||
const attachmentsAdded = loadResults.getAttachmentRows().some((att) => att.attachmentId && !this.renderedAttachmentIds.has(att.attachmentId));
|
||||
|
||||
if (attachmentsAdded) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-detail-book note-detail-printable">
|
||||
<style>
|
||||
.note-detail-book-auto-help {
|
||||
background-color: var(--accented-background-color);
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
margin: 0 10px 10px 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="note-detail-book-empty-help alert alert-warning" style="margin: 50px; padding: 20px;">
|
||||
${t("book.no_children_help")}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class BookTypeWidget extends TypeWidget {
|
||||
|
||||
private $helpNoChildren!: JQuery<HTMLElement>;
|
||||
|
||||
static getType() {
|
||||
return "book";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$helpNoChildren = this.$widget.find(".note-detail-book-empty-help");
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote) {
|
||||
this.$helpNoChildren.toggle(this.shouldDisplayNoChildrenWarning());
|
||||
}
|
||||
|
||||
shouldDisplayNoChildrenWarning() {
|
||||
if (this.note?.hasChildren()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (this.note?.getAttributeValue("label", "viewType")) {
|
||||
case "list":
|
||||
case "grid":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import server from "../../services/server.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import options from "../../services/options.js";
|
||||
import type { LibraryItem } from "@excalidraw/excalidraw/types";
|
||||
import type { Theme } from "@excalidraw/excalidraw/element/types";
|
||||
import type Canvas from "./canvas_el.js";
|
||||
import { CanvasContent } from "./canvas_el.js";
|
||||
import { renderReactWidget } from "../react/react_utils.jsx";
|
||||
import SpacedUpdate from "../../services/spaced_update.js";
|
||||
import protected_session_holder from "../../services/protected_session_holder.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="canvas-widget note-detail-canvas note-detail-printable note-detail">
|
||||
<style>
|
||||
.excalidraw .App-menu_top .buttonList {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Conflict between excalidraw and bootstrap classes keeps the menu hidden */
|
||||
/* https://github.com/zadam/trilium/issues/3780 */
|
||||
/* https://github.com/excalidraw/excalidraw/issues/6567 */
|
||||
.excalidraw .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.excalidraw-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:root[dir="ltr"]
|
||||
.excalidraw
|
||||
.layer-ui__wrapper
|
||||
.zen-mode-transition.App-menu_bottom--transition-left {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* collaboration not possible so hide the button */
|
||||
.CollabButton {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.library-button {
|
||||
display: none !important; /* library won't work without extra support which isn't currently implemented */
|
||||
}
|
||||
|
||||
</style>
|
||||
<!-- height here necessary. otherwise excalidraw not shown -->
|
||||
<div class="canvas-render" style="height: 100%"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
|
||||
interface AttachmentMetadata {
|
||||
title: string;
|
||||
attachmentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* # Canvas note with excalidraw
|
||||
* @author thfrei 2022-05-11
|
||||
*
|
||||
* Background:
|
||||
* excalidraw gives great support for hand-drawn notes. It also allows including images and support
|
||||
* for sketching. Excalidraw has a vibrant and active community.
|
||||
*
|
||||
* Functionality:
|
||||
* We store the excalidraw assets (elements and files) in the note. In addition to that, we
|
||||
* export the SVG from the canvas on every update and store it in the note's attachment. It is used when
|
||||
* calling api/images and makes referencing very easy.
|
||||
*
|
||||
* Paths not taken.
|
||||
* - excalidraw-to-svg (node.js) could be used to avoid storing the svg in the backend.
|
||||
* We could render the SVG on the fly. However, as of now, it does not render any hand drawn
|
||||
* (freedraw) paths. There is an issue with Path2D object not present in the node-canvas library
|
||||
* used by jsdom. (See Trilium PR for samples and other issues in the respective library.
|
||||
* Link will be added later). Related links:
|
||||
* - https://github.com/Automattic/node-canvas/pull/2013
|
||||
* - https://github.com/google/canvas-5-polyfill
|
||||
* - https://github.com/Automattic/node-canvas/issues/1116
|
||||
* - https://www.npmjs.com/package/path2d-polyfill
|
||||
* - excalidraw-to-svg (node.js) takes quite some time to load an image (1-2s)
|
||||
* - excalidraw-utils (browser) does render freedraw, however NOT freedraw with a background. It is not
|
||||
* used, since it is a big dependency, and has the same functionality as react + excalidraw.
|
||||
* - infinite-drawing-canvas with fabric.js. This library lacked a lot of features, excalidraw already
|
||||
* has.
|
||||
*
|
||||
* Known issues:
|
||||
* - the 3 excalidraw fonts should be included in the share and everywhere, so that it is shown
|
||||
* when requiring svg.
|
||||
*
|
||||
* Discussion of storing svg in the note attachment:
|
||||
* - Pro: we will combat bit-rot. Showing the SVG will be very fast and easy, since it is already there.
|
||||
* - Con: The note will get bigger (~40-50%?), we will generate more bandwidth. However, using trilium
|
||||
* desktop instance mitigates that issue.
|
||||
*
|
||||
* Roadmap:
|
||||
* - Support image-notes as reference in excalidraw
|
||||
* - Support canvas note as reference (svg) in other canvas notes.
|
||||
* - Make it easy to include a canvas note inside a text note
|
||||
*/
|
||||
export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
|
||||
private currentNoteId: string;
|
||||
|
||||
private libraryChanged: boolean;
|
||||
private librarycache: LibraryItem[];
|
||||
private attachmentMetadata: AttachmentMetadata[];
|
||||
private themeStyle!: Theme;
|
||||
|
||||
private $render!: JQuery<HTMLElement>;
|
||||
private reactHandlers!: JQuery<HTMLElement>;
|
||||
private canvasInstance!: Canvas;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// temporary vars
|
||||
this.currentNoteId = "";
|
||||
|
||||
// will be overwritten
|
||||
this.$render;
|
||||
this.$widget;
|
||||
this.reactHandlers; // used to control react state
|
||||
|
||||
this.libraryChanged = false;
|
||||
|
||||
// these 2 variables are needed to compare the library state (all library items) after loading to the state when the library changed. So we can find attachments to be deleted.
|
||||
//every libraryitem is saved on its own json file in the attachments of the note.
|
||||
this.librarycache = [];
|
||||
this.attachmentMetadata = [];
|
||||
|
||||
// TODO: We are duplicating the logic of note_detail.ts because it switches note ID mid-save, causing overwrites.
|
||||
// This problem will get solved by itself once type widgets will be rewritten in React without the use of dangerous singletons.
|
||||
this.spacedUpdate = new SpacedUpdate(async () => {
|
||||
if (!this.noteContext) return;
|
||||
|
||||
const { note } = this.noteContext;
|
||||
if (!note) return;
|
||||
|
||||
const { noteId } = note;
|
||||
const data = await this.getData();
|
||||
|
||||
// for read only notes
|
||||
if (data === undefined) return;
|
||||
|
||||
protected_session_holder.touchProtectedSessionIfNecessary(note);
|
||||
await server.put(`notes/${noteId}/data`, data, this.componentId);
|
||||
this.dataSaved();
|
||||
});
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "canvas";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$widget.bind("mousewheel DOMMouseScroll", (event) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.toggleClass("full-height", true);
|
||||
this.$render = this.$widget.find(".canvas-render");
|
||||
const documentStyle = window.getComputedStyle(document.documentElement);
|
||||
this.themeStyle = documentStyle.getPropertyValue("--theme-style")?.trim() as Theme;
|
||||
|
||||
this.#init();
|
||||
|
||||
return this.$widget;
|
||||
}
|
||||
|
||||
async #init() {
|
||||
const renderElement = this.$render.get(0);
|
||||
if (!renderElement) {
|
||||
throw new Error("Unable to find element to render.");
|
||||
}
|
||||
|
||||
const Canvas = (await import("./canvas_el.js")).default;
|
||||
this.canvasInstance = new Canvas({
|
||||
// this makes sure that 1) manual theme switch button is hidden 2) theme stays as it should after opening menu
|
||||
theme: this.themeStyle,
|
||||
onChange: () => this.onChangeHandler(),
|
||||
viewModeEnabled: options.is("databaseReadonly"),
|
||||
zenModeEnabled: false,
|
||||
isCollaborating: false,
|
||||
detectScroll: false,
|
||||
handleKeyboardGlobally: false,
|
||||
autoFocus: false,
|
||||
UIOptions: {
|
||||
canvasActions: {
|
||||
saveToActiveFile: false,
|
||||
export: false
|
||||
}
|
||||
},
|
||||
onLibraryChange: () => {
|
||||
this.libraryChanged = true;
|
||||
|
||||
this.saveData();
|
||||
},
|
||||
});
|
||||
|
||||
await setupFonts();
|
||||
const canvasEl = renderReactWidget(this, this.canvasInstance.createCanvasElement())[0];
|
||||
renderElement.replaceChildren(canvasEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* called to populate the widget container with the note content
|
||||
*/
|
||||
async doRefresh(note: FNote) {
|
||||
if (!this.canvasInstance) {
|
||||
await this.#init();
|
||||
}
|
||||
|
||||
// see if the note changed, since we do not get a new class for a new note
|
||||
const noteChanged = this.currentNoteId !== note.noteId;
|
||||
if (noteChanged) {
|
||||
// reset the scene to omit unnecessary onchange handler
|
||||
this.canvasInstance.resetSceneVersion();
|
||||
}
|
||||
this.currentNoteId = note.noteId;
|
||||
|
||||
// get note from backend and put into canvas
|
||||
const blob = await note.getBlob();
|
||||
|
||||
// before we load content into excalidraw, make sure excalidraw has loaded
|
||||
await this.canvasInstance.waitForApiToBecomeAvailable();
|
||||
|
||||
/**
|
||||
* new and empty note - make sure that canvas is empty.
|
||||
* If we do not set it manually, we occasionally get some "bleeding" from another
|
||||
* note into this fresh note. Probably due to that this note-instance does not get
|
||||
* newly instantiated?
|
||||
*/
|
||||
if (!blob?.content?.trim()) {
|
||||
this.canvasInstance.resetScene(this.themeStyle);
|
||||
} else if (blob.content) {
|
||||
let content: CanvasContent;
|
||||
|
||||
// load saved content into excalidraw canvas
|
||||
try {
|
||||
content = blob.getJsonContent() as CanvasContent;
|
||||
} catch (err) {
|
||||
console.error("Error parsing content. Probably note.type changed. Starting with empty canvas", note, blob, err);
|
||||
|
||||
content = {
|
||||
elements: [],
|
||||
files: [],
|
||||
appState: {}
|
||||
};
|
||||
}
|
||||
|
||||
this.canvasInstance.loadData(content, this.themeStyle);
|
||||
|
||||
Promise.all(
|
||||
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
|
||||
const blob = await attachment.getBlob();
|
||||
return {
|
||||
blob, // Save the blob for libraryItems
|
||||
metadata: {
|
||||
// metadata to use in the cache variables for comparing old library state and new one. We delete unnecessary items later, calling the server directly
|
||||
attachmentId: attachment.attachmentId,
|
||||
title: attachment.title
|
||||
}
|
||||
};
|
||||
})
|
||||
).then((results) => {
|
||||
if (note.noteId !== this.currentNoteId) {
|
||||
// current note changed in the course of the async operation
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract libraryItems from the blobs
|
||||
const libraryItems = results.map((result) => result?.blob?.getJsonContentSafely()).filter((item) => !!item) as LibraryItem[];
|
||||
|
||||
// Extract metadata for each attachment
|
||||
const metadata = results.map((result) => result.metadata);
|
||||
|
||||
// Update the library and save to independent variables
|
||||
this.canvasInstance.updateLibrary(libraryItems);
|
||||
|
||||
// save state of library to compare it to the new state later.
|
||||
this.librarycache = libraryItems;
|
||||
this.attachmentMetadata = metadata;
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
// set initial scene version
|
||||
if (this.canvasInstance.isInitialScene()) {
|
||||
this.canvasInstance.updateSceneVersion();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* gets data from widget container that will be sent via spacedUpdate.scheduleUpdate();
|
||||
* this is automatically called after this.saveData();
|
||||
*/
|
||||
async getData() {
|
||||
const { content, svg } = await this.canvasInstance.getData();
|
||||
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
|
||||
|
||||
if (this.libraryChanged) {
|
||||
// this.libraryChanged is unset in dataSaved()
|
||||
|
||||
// there's no separate method to get library items, so have to abuse this one
|
||||
const libraryItems = await this.canvasInstance.getLibraryItems();
|
||||
|
||||
// excalidraw saves the library as a own state. the items are saved to libraryItems. then we compare the library right now with a libraryitemcache. The cache is filled when we first load the Library into the note.
|
||||
//We need the cache to delete old attachments later in the server.
|
||||
|
||||
const libraryItemsMissmatch = this.librarycache.filter((obj1) => !libraryItems.some((obj2: LibraryItem) => obj1.id === obj2.id));
|
||||
|
||||
// before we saved the metadata of the attachments in a cache. the title of the attachment is a combination of libraryitem ´s ID und it´s name.
|
||||
// we compare the library items in the libraryitemmissmatch variable (this one saves all libraryitems that are different to the state right now. E.g. you delete 1 item, this item is saved as mismatch)
|
||||
// then we combine its id and title and search the according attachmentID.
|
||||
|
||||
const matchingItems = this.attachmentMetadata.filter((meta) => {
|
||||
// Loop through the second array and check for a match
|
||||
return libraryItemsMissmatch.some((item) => {
|
||||
// Combine the `name` and `id` from the second array
|
||||
const combinedTitle = `${item.id}${item.name}`;
|
||||
return meta.title === combinedTitle;
|
||||
});
|
||||
});
|
||||
|
||||
// we save the attachment ID`s in a variable and delete every attachmentID. Now the items that the user deleted will be deleted.
|
||||
const attachmentIds = matchingItems.map((item) => item.attachmentId);
|
||||
|
||||
//delete old attachments that are no longer used
|
||||
for (const item of attachmentIds) {
|
||||
await server.remove(`attachments/${item}`);
|
||||
}
|
||||
|
||||
let position = 10;
|
||||
|
||||
// prepare data to save to server e.g. new library items.
|
||||
for (const libraryItem of libraryItems) {
|
||||
attachments.push({
|
||||
role: "canvasLibraryItem",
|
||||
title: libraryItem.id + libraryItem.name,
|
||||
mime: "application/json",
|
||||
content: JSON.stringify(libraryItem),
|
||||
position: position
|
||||
});
|
||||
|
||||
position += 10;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: JSON.stringify(content),
|
||||
attachments
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* save content to backend
|
||||
*/
|
||||
saveData() {
|
||||
// Since Excalidraw sends an enormous amount of events, wait for them to stop before actually saving.
|
||||
this.spacedUpdate.resetUpdateTimer();
|
||||
this.spacedUpdate.scheduleUpdate();
|
||||
}
|
||||
|
||||
dataSaved() {
|
||||
this.libraryChanged = false;
|
||||
}
|
||||
|
||||
onChangeHandler() {
|
||||
if (options.is("databaseReadonly")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.canvasInstance.isInitialized()) return;
|
||||
|
||||
// changeHandler is called upon any tiny change in excalidraw. button clicked, hover, etc.
|
||||
// make sure only when a new element is added, we actually save something.
|
||||
const isNewSceneVersion = this.canvasInstance.isNewSceneVersion();
|
||||
/**
|
||||
* FIXME: however, we might want to make an exception, if viewport changed, since viewport
|
||||
* is desired to save? (add) and appState background, and some things
|
||||
*/
|
||||
|
||||
// upon updateScene, onchange is called, even though "nothing really changed" that is worth saving
|
||||
const isNotInitialScene = !this.canvasInstance.isInitialScene();
|
||||
const shouldSave = isNewSceneVersion && isNotInitialScene;
|
||||
|
||||
if (shouldSave) {
|
||||
this.canvasInstance.updateSceneVersion();
|
||||
this.saveData();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function setupFonts() {
|
||||
if (window.EXCALIDRAW_ASSET_PATH) {
|
||||
return;
|
||||
}
|
||||
|
||||
// currently required by excalidraw, in order to allows self-hosting fonts locally.
|
||||
// this avoids making excalidraw load the fonts from an external CDN.
|
||||
let path: string;
|
||||
if (!glob.isDev) {
|
||||
path = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
|
||||
} else {
|
||||
path = (await import("../../../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2?url")).default;
|
||||
let pathComponents = path.split("/");
|
||||
path = pathComponents.slice(0, pathComponents.length - 2).join("/");
|
||||
}
|
||||
|
||||
window.EXCALIDRAW_ASSET_PATH = path;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import { Excalidraw, getSceneVersion, exportToSvg } from "@excalidraw/excalidraw";
|
||||
import { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem } from "@excalidraw/excalidraw/types";
|
||||
import { ExcalidrawElement, NonDeletedExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
|
||||
import { useCallback } from "preact/hooks";
|
||||
import linkService from "../../services/link.js";
|
||||
|
||||
export interface CanvasContent {
|
||||
elements: ExcalidrawElement[];
|
||||
files: BinaryFileData[];
|
||||
appState: Partial<AppState>;
|
||||
}
|
||||
|
||||
/** Indicates that it is fresh. excalidraw scene version is always >0 */
|
||||
const SCENE_VERSION_INITIAL = -1;
|
||||
|
||||
export default class Canvas {
|
||||
|
||||
private currentSceneVersion: number;
|
||||
private opts: ExcalidrawProps;
|
||||
private excalidrawApi!: ExcalidrawImperativeAPI;
|
||||
private initializedPromise: JQuery.Deferred<void>;
|
||||
|
||||
constructor(opts: ExcalidrawProps) {
|
||||
this.opts = opts;
|
||||
this.currentSceneVersion = SCENE_VERSION_INITIAL;
|
||||
this.initializedPromise = $.Deferred();
|
||||
}
|
||||
|
||||
async waitForApiToBecomeAvailable() {
|
||||
while (!this.excalidrawApi) {
|
||||
await this.initializedPromise;
|
||||
}
|
||||
}
|
||||
|
||||
createCanvasElement() {
|
||||
return <CanvasElement
|
||||
{...this.opts}
|
||||
excalidrawAPI={api => {
|
||||
this.excalidrawApi = api;
|
||||
this.initializedPromise.resolve();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
/**
|
||||
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
|
||||
* we compare the scene version as suggested in:
|
||||
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
|
||||
*
|
||||
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
|
||||
*/
|
||||
isNewSceneVersion() {
|
||||
const sceneVersion = this.getSceneVersion();
|
||||
|
||||
return (
|
||||
this.currentSceneVersion === SCENE_VERSION_INITIAL || // initial scene version update
|
||||
this.currentSceneVersion !== sceneVersion
|
||||
); // ensure scene changed
|
||||
}
|
||||
|
||||
getSceneVersion() {
|
||||
const elements = this.excalidrawApi.getSceneElements();
|
||||
return getSceneVersion(elements);
|
||||
}
|
||||
|
||||
updateSceneVersion() {
|
||||
this.currentSceneVersion = this.getSceneVersion();
|
||||
}
|
||||
|
||||
resetSceneVersion() {
|
||||
this.currentSceneVersion = SCENE_VERSION_INITIAL;
|
||||
}
|
||||
|
||||
isInitialScene() {
|
||||
return this.currentSceneVersion === SCENE_VERSION_INITIAL;
|
||||
}
|
||||
|
||||
isInitialized() {
|
||||
return !!this.excalidrawApi;
|
||||
}
|
||||
|
||||
resetScene(theme: Theme) {
|
||||
this.excalidrawApi.updateScene({
|
||||
elements: [],
|
||||
appState: {
|
||||
theme
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadData(content: CanvasContent, theme: Theme) {
|
||||
const { elements, files } = content;
|
||||
const appState: Partial<AppState> = content.appState ?? {};
|
||||
appState.theme = theme;
|
||||
|
||||
// files are expected in an array when loading. they are stored as a key-index object
|
||||
// see example for loading here:
|
||||
// https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68
|
||||
const fileArray: BinaryFileData[] = [];
|
||||
for (const fileId in files) {
|
||||
const file = files[fileId];
|
||||
// TODO: dataURL is replaceable with a trilium image url
|
||||
// maybe we can save normal images (pasted) with base64 data url, and trilium images
|
||||
// with their respective url! nice
|
||||
// file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png";
|
||||
fileArray.push(file);
|
||||
}
|
||||
|
||||
// Update the scene
|
||||
// TODO: Fix type of sceneData
|
||||
this.excalidrawApi.updateScene({
|
||||
elements,
|
||||
appState: appState as AppState
|
||||
});
|
||||
this.excalidrawApi.addFiles(fileArray);
|
||||
this.excalidrawApi.history.clear();
|
||||
}
|
||||
|
||||
async getData() {
|
||||
const elements = this.excalidrawApi.getSceneElements();
|
||||
const appState = this.excalidrawApi.getAppState();
|
||||
|
||||
/**
|
||||
* A file is not deleted, even though removed from canvas. Therefore, we only keep
|
||||
* files that are referenced by an element. Maybe this will change with a new excalidraw version?
|
||||
*/
|
||||
const files = this.excalidrawApi.getFiles();
|
||||
// parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share
|
||||
const svg = await exportToSvg({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding: 5, // 5 px padding
|
||||
files
|
||||
});
|
||||
const svgString = svg.outerHTML;
|
||||
|
||||
const activeFiles: Record<string, BinaryFileData> = {};
|
||||
elements.forEach((element: NonDeletedExcalidrawElement) => {
|
||||
if ("fileId" in element && element.fileId) {
|
||||
activeFiles[element.fileId] = files[element.fileId];
|
||||
}
|
||||
});
|
||||
|
||||
const content = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements,
|
||||
files: activeFiles,
|
||||
appState: {
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom,
|
||||
gridModeEnabled: appState.gridModeEnabled
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
content,
|
||||
svg: svgString
|
||||
}
|
||||
}
|
||||
|
||||
async getLibraryItems() {
|
||||
return this.excalidrawApi.updateLibrary({
|
||||
libraryItems() {
|
||||
return [];
|
||||
},
|
||||
merge: true
|
||||
});
|
||||
}
|
||||
|
||||
async updateLibrary(libraryItems: LibraryItem[]) {
|
||||
this.excalidrawApi.updateLibrary({ libraryItems, merge: false });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function CanvasElement(opts: ExcalidrawProps) {
|
||||
return (
|
||||
<div className="excalidraw-wrapper">
|
||||
<Excalidraw
|
||||
{...opts}
|
||||
onLinkOpen={useCallback((element: NonDeletedExcalidrawElement, event: CustomEvent) => {
|
||||
let link = element.link;
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (link.startsWith("root/")) {
|
||||
link = "#" + link;
|
||||
}
|
||||
|
||||
const { nativeEvent } = event.detail;
|
||||
event.preventDefault();
|
||||
return linkService.goToLinkExt(nativeEvent, link, null);
|
||||
}, [])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
apps/client/src/widgets/type_widgets/code/BackendLog.tsx
Normal file
44
apps/client/src/widgets/type_widgets/code/BackendLog.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import "./code.css";
|
||||
import { CodeEditor } from "./Code";
|
||||
import CodeMirror from "@triliumnext/codemirror";
|
||||
import server from "../../../services/server";
|
||||
import { useTriliumEvent } from "../../react/hooks";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
|
||||
export default function BackendLog({ ntxId, parentComponent }: TypeWidgetProps) {
|
||||
const [ content, setContent ] = useState<string>();
|
||||
const editorRef = useRef<CodeMirror>(null);
|
||||
|
||||
function refresh() {
|
||||
server.get<string>("backend-log").then(content => {
|
||||
setContent(content);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(refresh, []);
|
||||
|
||||
// Scroll to end
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => editorRef.current?.scrollToEnd());
|
||||
}, [ content ]);
|
||||
|
||||
// React to refresh button.
|
||||
useTriliumEvent("refreshData", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="backend-log-editor-container">
|
||||
<CodeEditor
|
||||
editorRef={editorRef}
|
||||
ntxId={ntxId} parentComponent={parentComponent}
|
||||
content={content ?? ""}
|
||||
mime="text/plain"
|
||||
readOnly
|
||||
preferPerformance
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
apps/client/src/widgets/type_widgets/code/Code.tsx
Normal file
210
apps/client/src/widgets/type_widgets/code/Code.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { getThemeById, default as VanillaCodeMirror } from "@triliumnext/codemirror";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
import "./code.css";
|
||||
import CodeMirror, { CodeMirrorProps } from "./CodeMirror";
|
||||
import utils from "../../../services/utils";
|
||||
import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { t } from "../../../services/i18n";
|
||||
import appContext, { CommandListenerData } from "../../../components/app_context";
|
||||
import TouchBar, { TouchBarButton } from "../../react/TouchBar";
|
||||
import { refToJQuerySelector } from "../../react/react_utils";
|
||||
import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants";
|
||||
import FNote from "../../../entities/fnote";
|
||||
|
||||
interface CodeEditorProps {
|
||||
/** By default, the code editor will try to match the color of the scrolling container to match the one from the theme for a full-screen experience. If the editor is embedded, it makes sense not to have this behaviour. */
|
||||
noBackgroundChange?: boolean;
|
||||
}
|
||||
|
||||
export interface EditableCodeProps extends TypeWidgetProps, Omit<CodeEditorProps, "onContentChanged"> {
|
||||
// if true, the update will be debounced to prevent excessive updates. Especially useful if the editor is linked to a live preview.
|
||||
debounceUpdate?: boolean;
|
||||
lineWrapping?: boolean;
|
||||
updateInterval?: number;
|
||||
/** Invoked when the content of the note is changed, such as a different revision or a note switch. */
|
||||
onContentChanged?: (content: string) => void;
|
||||
/** Invoked after the content of the note has been uploaded to the server, using a spaced update. */
|
||||
dataSaved?: () => void;
|
||||
}
|
||||
|
||||
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) {
|
||||
const [ content, setContent ] = useState("");
|
||||
const blob = useNoteBlob(note);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blob) return;
|
||||
|
||||
let newContent = blob.content;
|
||||
if (viewScope?.viewMode === "source") {
|
||||
newContent = formatViewSource(note, newContent);
|
||||
}
|
||||
|
||||
setContent(newContent);
|
||||
}, [ blob ]);
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
ntxId={ntxId} parentComponent={parentComponent}
|
||||
className="note-detail-readonly-code-content"
|
||||
content={content}
|
||||
mime={note.mime}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function formatViewSource(note: FNote, content: string) {
|
||||
if (note.type === "text") {
|
||||
return utils.formatHtml(content);
|
||||
}
|
||||
|
||||
if (note.type !== "code" && note.mime === "application/json" && content.length < 512_000) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, 4);
|
||||
} catch (e) {
|
||||
// Fallback to content.
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, onContentChanged, dataSaved, ...editorProps }: EditableCodeProps) {
|
||||
const editorRef = useRef<VanillaCodeMirror>(null);
|
||||
const containerRef = useRef<HTMLPreElement>(null);
|
||||
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
getData: () => ({ content: editorRef.current?.getText() }),
|
||||
onContentChange: (content) => {
|
||||
const codeEditor = editorRef.current;
|
||||
if (!codeEditor) return;
|
||||
codeEditor.setText(content ?? "");
|
||||
codeEditor.setMimeType(note.mime);
|
||||
codeEditor.clearHistory();
|
||||
},
|
||||
dataSaved,
|
||||
updateInterval
|
||||
});
|
||||
|
||||
// make sure that script is saved before running it #4028
|
||||
useLegacyImperativeHandlers({
|
||||
async runActiveNoteCommand(params: CommandListenerData<"runActiveNote">) {
|
||||
if (params.ntxId === ntxId) {
|
||||
await spacedUpdate.updateNowIfNecessary();
|
||||
}
|
||||
|
||||
return await parentComponent?.parent?.triggerCommand("runActiveNote", params);
|
||||
}
|
||||
});
|
||||
|
||||
useKeyboardShortcuts("code-detail", containerRef, parentComponent);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeEditor
|
||||
ntxId={ntxId} parentComponent={parentComponent}
|
||||
editorRef={editorRef} containerRef={containerRef}
|
||||
mime={note.mime}
|
||||
className="note-detail-code-editor"
|
||||
placeholder={t("editable_code.placeholder")}
|
||||
vimKeybindings={vimKeymapEnabled}
|
||||
tabIndex={300}
|
||||
onContentChanged={() => {
|
||||
if (debounceUpdate) {
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
}
|
||||
spacedUpdate.scheduleUpdate();
|
||||
if (editorRef.current && onContentChanged) {
|
||||
onContentChanged(editorRef.current.getText());
|
||||
}
|
||||
}}
|
||||
{...editorProps}
|
||||
/>
|
||||
|
||||
<TouchBar>
|
||||
{(note?.mime.startsWith("application/javascript") || note?.mime === "text/x-sqlite;schema=trilium") && (
|
||||
<TouchBarButton icon="NSImageNameTouchBarPlayTemplate" click={() => appContext.triggerCommand("runActiveNote")} />
|
||||
)}
|
||||
</TouchBar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeEditor({ parentComponent, ntxId, containerRef: externalContainerRef, editorRef: externalEditorRef, mime, onInitialized, lineWrapping, noBackgroundChange, ...editorProps }: CodeEditorProps & CodeMirrorProps & Pick<TypeWidgetProps, "parentComponent" | "ntxId">) {
|
||||
const codeEditorRef = useRef<VanillaCodeMirror>(null);
|
||||
const containerRef = useSyncedRef(externalContainerRef);
|
||||
const initialized = useRef($.Deferred());
|
||||
const [ codeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
|
||||
const [ codeNoteTheme ] = useTriliumOption("codeNoteTheme");
|
||||
|
||||
// React to background color.
|
||||
const [ backgroundColor, setBackgroundColor ] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!backgroundColor || noBackgroundChange) return;
|
||||
parentComponent?.$widget.closest(".scrolling-container").css("background-color", backgroundColor);
|
||||
return () => {
|
||||
parentComponent?.$widget.closest(".scrolling-container").css("background-color", "unset");
|
||||
};
|
||||
}, [ backgroundColor ]);
|
||||
|
||||
// React to theme changes.
|
||||
useEffect(() => {
|
||||
if (codeEditorRef.current && codeNoteTheme.startsWith(DEFAULT_PREFIX)) {
|
||||
const theme = getThemeById(codeNoteTheme.substring(DEFAULT_PREFIX.length));
|
||||
if (theme) {
|
||||
codeEditorRef.current.setTheme(theme).then(() => {
|
||||
if (mime === "text/x-sqlite;schema=trilium") return;
|
||||
const editor = containerRef.current?.querySelector(".cm-editor");
|
||||
if (!editor) return;
|
||||
const style = window.getComputedStyle(editor);
|
||||
setBackgroundColor(style.backgroundColor);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [ codeEditorRef, codeNoteTheme ]);
|
||||
|
||||
useTriliumEvent("executeWithCodeEditor", async ({ resolve, ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
await initialized.current.promise();
|
||||
resolve(codeEditorRef.current!);
|
||||
});
|
||||
|
||||
useTriliumEvent("executeWithContentElement", async ({ resolve, ntxId: eventNtxId}) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
await initialized.current.promise();
|
||||
resolve(refToJQuerySelector(containerRef));
|
||||
});
|
||||
|
||||
useTriliumEvent("scrollToEnd", () => {
|
||||
const editor = codeEditorRef.current;
|
||||
if (!editor) return;
|
||||
editor.scrollToEnd();
|
||||
editor.focus();
|
||||
});
|
||||
|
||||
useTriliumEvent("focusOnDetail", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
codeEditorRef.current?.focus();
|
||||
});
|
||||
|
||||
return <CodeMirror
|
||||
{...editorProps}
|
||||
mime={mime}
|
||||
editorRef={codeEditorRef}
|
||||
containerRef={containerRef}
|
||||
lineWrapping={lineWrapping ?? codeLineWrapEnabled}
|
||||
onInitialized={() => {
|
||||
if (externalContainerRef && containerRef.current) {
|
||||
externalContainerRef.current = containerRef.current;
|
||||
}
|
||||
if (externalEditorRef && codeEditorRef.current) {
|
||||
externalEditorRef.current = codeEditorRef.current;
|
||||
}
|
||||
initialized.current.resolve();
|
||||
onInitialized?.();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
50
apps/client/src/widgets/type_widgets/code/CodeMirror.tsx
Normal file
50
apps/client/src/widgets/type_widgets/code/CodeMirror.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { EditorConfig, default as VanillaCodeMirror } from "@triliumnext/codemirror";
|
||||
import { useSyncedRef } from "../../react/hooks";
|
||||
import { RefObject } from "preact";
|
||||
|
||||
export interface CodeMirrorProps extends Omit<EditorConfig, "parent"> {
|
||||
content?: string;
|
||||
mime: string;
|
||||
className?: string;
|
||||
editorRef?: RefObject<VanillaCodeMirror>;
|
||||
containerRef?: RefObject<HTMLPreElement>;
|
||||
onInitialized?: () => void;
|
||||
}
|
||||
|
||||
export default function CodeMirror({ className, content, mime, editorRef: externalEditorRef, containerRef: externalContainerRef, onInitialized, lineWrapping, ...extraOpts }: CodeMirrorProps) {
|
||||
const parentRef = useSyncedRef(externalContainerRef);
|
||||
const codeEditorRef = useRef<VanillaCodeMirror>();
|
||||
|
||||
// Create CodeMirror instance.
|
||||
useEffect(() => {
|
||||
if (!parentRef.current) return;
|
||||
|
||||
const codeEditor = new VanillaCodeMirror({
|
||||
parent: parentRef.current,
|
||||
...extraOpts
|
||||
});
|
||||
codeEditorRef.current = codeEditor;
|
||||
if (externalEditorRef) {
|
||||
externalEditorRef.current = codeEditor;
|
||||
}
|
||||
onInitialized?.();
|
||||
|
||||
return () => codeEditor.destroy();
|
||||
}, []);
|
||||
|
||||
// React to text changes.
|
||||
useEffect(() => {
|
||||
const codeEditor = codeEditorRef.current;
|
||||
codeEditor?.setText(content ?? "");
|
||||
codeEditor?.setMimeType(mime);
|
||||
codeEditor?.clearHistory();
|
||||
}, [content]);
|
||||
|
||||
// React to line wrapping.
|
||||
useEffect(() => codeEditorRef.current?.setLineWrapping(!!lineWrapping), [ lineWrapping ]);
|
||||
|
||||
return (
|
||||
<pre ref={parentRef} className={className} />
|
||||
)
|
||||
}
|
||||
34
apps/client/src/widgets/type_widgets/code/code.css
Normal file
34
apps/client/src/widgets/type_widgets/code/code.css
Normal file
@@ -0,0 +1,34 @@
|
||||
/* #region Read-only code */
|
||||
.note-detail-readonly-code {
|
||||
min-height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Editable code */
|
||||
.note-detail-code {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-code-editor {
|
||||
min-height: 50px;
|
||||
height: 100%;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Backend log */
|
||||
.backend-log-editor-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backend-log-editor {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
border: none;
|
||||
resize: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* #endregion */
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user