Port small widgets to React (#6830)

This commit is contained in:
Elian Doran
2025-08-30 12:51:31 +03:00
committed by GitHub
57 changed files with 1275 additions and 1383 deletions

View File

@@ -1,6 +1,6 @@
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import RootCommandExecutor from "./root_command_executor.js"; import RootCommandExecutor from "./root_command_executor.js";
import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js"; import Entrypoints from "./entrypoints.js";
import options from "../services/options.js"; import options from "../services/options.js";
import utils, { hasTouchBar } from "../services/utils.js"; import utils, { hasTouchBar } from "../services/utils.js";
import zoomComponent from "./zoom.js"; import zoomComponent from "./zoom.js";
@@ -32,6 +32,7 @@ import type { CreateNoteOpts } from "../services/note_create.js";
import { ColumnComponent } from "tabulator-tables"; import { ColumnComponent } from "tabulator-tables";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type RootContainer from "../widgets/containers/root_container.js"; import type RootContainer from "../widgets/containers/root_container.js";
import { SqlExecuteResults } from "@triliumnext/commons";
interface Layout { interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer; getRootWidget: (appContext: AppContext) => RootContainer;
@@ -89,6 +90,11 @@ export type CommandMappings = {
closeTocCommand: CommandData; closeTocCommand: CommandData;
closeHlt: CommandData; closeHlt: CommandData;
showLaunchBarSubtree: CommandData; showLaunchBarSubtree: CommandData;
showHiddenSubtree: CommandData;
showSQLConsoleHistory: CommandData;
logout: CommandData;
switchToMobileVersion: CommandData;
switchToDesktopVersion: CommandData;
showRevisions: CommandData & { showRevisions: CommandData & {
noteId?: string | null; noteId?: string | null;
}; };
@@ -134,6 +140,7 @@ export type CommandMappings = {
showLeftPane: CommandData; showLeftPane: CommandData;
showAttachments: CommandData; showAttachments: CommandData;
showSearchHistory: CommandData; showSearchHistory: CommandData;
showShareSubtree: CommandData;
hoistNote: CommandData & { noteId: string }; hoistNote: CommandData & { noteId: string };
leaveProtectedSession: CommandData; leaveProtectedSession: CommandData;
enterProtectedSession: CommandData; enterProtectedSession: CommandData;

View File

@@ -10,22 +10,7 @@ import bundleService from "../services/bundle.js";
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import linkService from "../services/link.js"; import linkService from "../services/link.js";
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import type FNote from "../entities/fnote.js"; import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
// TODO: Move somewhere else nicer.
export type SqlExecuteResults = string[][][];
// TODO: Deduplicate with server.
interface SqlExecuteResponse {
success: boolean;
error?: string;
results: SqlExecuteResults;
}
// TODO: Deduplicate with server.
interface CreateChildrenResponse {
note: FNote;
}
export default class Entrypoints extends Component { export default class Entrypoints extends Component {
constructor() { constructor() {
@@ -34,7 +19,7 @@ export default class Entrypoints extends Component {
openDevToolsCommand() { openDevToolsCommand() {
if (utils.isElectron()) { if (utils.isElectron()) {
utils.dynamicRequire("@electron/remote").getCurrentWindow().toggleDevTools(); utils.dynamicRequire("@electron/remote").getCurrentWindow().webContents.toggleDevTools();
} }
} }
@@ -124,7 +109,7 @@ export default class Entrypoints extends Component {
if (utils.isElectron()) { if (utils.isElectron()) {
// standard JS version does not work completely correctly in electron // standard JS version does not work completely correctly in electron
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents(); const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex()); const activeIndex = webContents.navigationHistory.getActiveIndex();
webContents.goToIndex(activeIndex - 1); webContents.goToIndex(activeIndex - 1);
} else { } else {
@@ -136,7 +121,7 @@ export default class Entrypoints extends Component {
if (utils.isElectron()) { if (utils.isElectron()) {
// standard JS version does not work completely correctly in electron // standard JS version does not work completely correctly in electron
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents(); const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex()); const activeIndex = webContents.navigationHistory.getActiveIndex();
webContents.goToIndex(activeIndex + 1); webContents.goToIndex(activeIndex + 1);
} else { } else {

View File

@@ -1,24 +1,18 @@
import FlexContainer from "../widgets/containers/flex_container.js"; import FlexContainer from "../widgets/containers/flex_container.js";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import TabRowWidget from "../widgets/tab_row.js"; import TabRowWidget from "../widgets/tab_row.js";
import TitleBarButtonsWidget from "../widgets/title_bar_buttons.js";
import LeftPaneContainer from "../widgets/containers/left_pane_container.js"; import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
import NoteTreeWidget from "../widgets/note_tree.js"; import NoteTreeWidget from "../widgets/note_tree.js";
import NoteTitleWidget from "../widgets/note_title.jsx"; import NoteTitleWidget from "../widgets/note_title.jsx";
import NoteDetailWidget from "../widgets/note_detail.js"; import NoteDetailWidget from "../widgets/note_detail.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import NoteListWidget from "../widgets/note_list.js"; import NoteListWidget from "../widgets/note_list.js";
import SqlResultWidget from "../widgets/sql_result.js";
import SqlTableSchemasWidget from "../widgets/sql_table_schemas.js";
import NoteIconWidget from "../widgets/note_icon.jsx"; import NoteIconWidget from "../widgets/note_icon.jsx";
import SearchResultWidget from "../widgets/search_result.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import RootContainer from "../widgets/containers/root_container.js"; import RootContainer from "../widgets/containers/root_container.js";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
import SpacerWidget from "../widgets/spacer.js"; import SpacerWidget from "../widgets/spacer.js";
import QuickSearchWidget from "../widgets/quick_search.js"; import QuickSearchWidget from "../widgets/quick_search.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js"; import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import LeftPaneToggleWidget from "../widgets/buttons/left_pane_toggle.js";
import CreatePaneButton from "../widgets/buttons/create_pane_button.js"; import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
import ClosePaneButton from "../widgets/buttons/close_pane_button.js"; import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
import RightPaneContainer from "../widgets/containers/right_pane_container.js"; import RightPaneContainer from "../widgets/containers/right_pane_container.js";
@@ -29,19 +23,25 @@ import TocWidget from "../widgets/toc.js";
import HighlightsListWidget from "../widgets/highlights_list.js"; import HighlightsListWidget from "../widgets/highlights_list.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import LauncherContainer from "../widgets/containers/launcher_container.js"; import LauncherContainer from "../widgets/containers/launcher_container.js";
import ApiLogWidget from "../widgets/api_log.js";
import MovePaneButton from "../widgets/buttons/move_pane_button.js"; import MovePaneButton from "../widgets/buttons/move_pane_button.js";
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js"; import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import ScrollPaddingWidget from "../widgets/scroll_padding.js"; import ScrollPadding from "../widgets/scroll_padding.js";
import options from "../services/options.js"; import options from "../services/options.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import CloseZenButton from "../widgets/close_zen_button.js";
import type { AppContext } from "../components/app_context.js"; import type { AppContext } from "../components/app_context.js";
import type { WidgetsByParent } from "../services/bundle.js"; import type { WidgetsByParent } from "../services/bundle.js";
import { applyModals } from "./layout_commons.js"; import { applyModals } from "./layout_commons.js";
import Ribbon from "../widgets/ribbon/Ribbon.jsx"; import Ribbon from "../widgets/ribbon/Ribbon.jsx";
import FloatingButtons from "../widgets/FloatingButtons.jsx"; import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import SearchResult from "../widgets/search_result.jsx";
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
import SqlResults from "../widgets/sql_result.js";
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
import TitleBarButtons from "../widgets/title_bar_buttons.jsx";
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
import ApiLog from "../widgets/api_log.jsx";
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
export default class DesktopLayout { export default class DesktopLayout {
@@ -76,9 +76,9 @@ export default class DesktopLayout {
new FlexContainer("row") new FlexContainer("row")
.class("tab-row-container") .class("tab-row-container")
.child(new FlexContainer("row").id("tab-row-left-spacer")) .child(new FlexContainer("row").id("tab-row-left-spacer"))
.optChild(launcherPaneIsHorizontal, new LeftPaneToggleWidget(true)) .optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
.child(new TabRowWidget().class("full-width")) .child(new TabRowWidget().class("full-width"))
.optChild(customTitleBarButtons, new TitleBarButtonsWidget()) .optChild(customTitleBarButtons, <TitleBarButtons />)
.css("height", "40px") .css("height", "40px")
.css("background-color", "var(--launcher-pane-background-color)") .css("background-color", "var(--launcher-pane-background-color)")
.setParent(appContext) .setParent(appContext)
@@ -99,7 +99,7 @@ export default class DesktopLayout {
new FlexContainer("column") new FlexContainer("column")
.id("rest-pane") .id("rest-pane")
.css("flex-grow", "1") .css("flex-grow", "1")
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, new TitleBarButtonsWidget()).css("height", "40px")) .optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, <TitleBarButtons />).css("height", "40px"))
.child( .child(
new FlexContainer("row") new FlexContainer("row")
.filling() .filling()
@@ -136,14 +136,14 @@ export default class DesktopLayout {
new ScrollingContainer() new ScrollingContainer()
.filling() .filling()
.child(new PromotedAttributesWidget()) .child(new PromotedAttributesWidget())
.child(new SqlTableSchemasWidget()) .child(<SqlTableSchemas />)
.child(new NoteDetailWidget()) .child(new NoteDetailWidget())
.child(new NoteListWidget(false)) .child(new NoteListWidget(false))
.child(new SearchResultWidget()) .child(<SearchResult />)
.child(new SqlResultWidget()) .child(<SqlResults />)
.child(new ScrollPaddingWidget()) .child(<ScrollPadding />)
) )
.child(new ApiLogWidget()) .child(<ApiLog />)
.child(new FindWidget()) .child(new FindWidget())
.child( .child(
...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC ...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC
@@ -162,7 +162,7 @@ export default class DesktopLayout {
) )
) )
) )
.child(new CloseZenButton()) .child(<CloseZenModeButton />)
// Desktop-specific dialogs. // Desktop-specific dialogs.
.child(<PasswordNoteSetDialog />) .child(<PasswordNoteSetDialog />)
@@ -176,14 +176,18 @@ export default class DesktopLayout {
let launcherPane; let launcherPane;
if (isHorizontal) { if (isHorizontal) {
launcherPane = new FlexContainer("row").css("height", "53px").class("horizontal").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true)); launcherPane = new FlexContainer("row")
.css("height", "53px")
.class("horizontal")
.child(new LauncherContainer(true))
.child(<GlobalMenu isHorizontalLayout={true} />);
} else { } else {
launcherPane = new FlexContainer("column") launcherPane = new FlexContainer("column")
.css("width", "53px") .css("width", "53px")
.class("vertical") .class("vertical")
.child(new GlobalMenuWidget(false)) .child(<GlobalMenu isHorizontalLayout={false} />)
.child(new LauncherContainer(false)) .child(new LauncherContainer(false))
.child(new LeftPaneToggleWidget(false)); .child(<LeftPaneToggle isHorizontalLayout={false} />);
} }
launcherPane.id("launcher-pane"); launcherPane.id("launcher-pane");

View File

@@ -3,8 +3,6 @@ import NoteTitleWidget from "../widgets/note_title.js";
import NoteDetailWidget from "../widgets/note_detail.js"; import NoteDetailWidget from "../widgets/note_detail.js";
import QuickSearchWidget from "../widgets/quick_search.js"; import QuickSearchWidget from "../widgets/quick_search.js";
import NoteTreeWidget from "../widgets/note_tree.js"; import NoteTreeWidget from "../widgets/note_tree.js";
import ToggleSidebarButtonWidget from "../widgets/mobile_widgets/toggle_sidebar_button.js";
import MobileDetailMenuWidget from "../widgets/mobile_widgets/mobile_detail_menu.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js"; import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import NoteListWidget from "../widgets/note_list.js"; import NoteListWidget from "../widgets/note_list.js";
@@ -18,11 +16,13 @@ import type AppContext from "../components/app_context.js";
import TabRowWidget from "../widgets/tab_row.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/ckeditor/mobile_editor_toolbar.js";
import { applyModals } from "./layout_commons.js"; import { applyModals } from "./layout_commons.js";
import CloseZenButton from "../widgets/close_zen_button.js";
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx"; import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import { useNoteContext } from "../widgets/react/hooks.jsx"; import { useNoteContext } from "../widgets/react/hooks.jsx";
import FloatingButtons from "../widgets/FloatingButtons.jsx"; import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
const MOBILE_CSS = ` const MOBILE_CSS = `
<style> <style>
@@ -139,9 +139,9 @@ export default class MobileLayout {
.contentSized() .contentSized()
.css("font-size", "larger") .css("font-size", "larger")
.css("align-items", "center") .css("align-items", "center")
.child(new ToggleSidebarButtonWidget().contentSized()) .child(<ToggleSidebarButton />)
.child(<NoteTitleWidget />) .child(<NoteTitleWidget />)
.child(new MobileDetailMenuWidget(true).contentSized()) .child(<MobileDetailMenu />)
) )
.child(new SharedInfoWidget()) .child(new SharedInfoWidget())
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />) .child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
@@ -154,7 +154,7 @@ export default class MobileLayout {
.child(new NoteListWidget(false)) .child(new NoteListWidget(false))
.child(<FilePropertiesWrapper />) .child(<FilePropertiesWrapper />)
) )
.child(new MobileEditorToolbar()) .child(<MobileEditorToolbar />)
) )
) )
.child( .child(
@@ -162,9 +162,14 @@ export default class MobileLayout {
.contentSized() .contentSized()
.id("mobile-bottom-bar") .id("mobile-bottom-bar")
.child(new TabRowWidget().css("height", "40px")) .child(new TabRowWidget().css("height", "40px"))
.child(new FlexContainer("row").class("horizontal").css("height", "53px").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true)).id("launcher-pane")) .child(new FlexContainer("row")
.class("horizontal")
.css("height", "53px")
.child(new LauncherContainer(true))
.child(<GlobalMenuWidget isHorizontalLayout />)
.id("launcher-pane"))
) )
.child(new CloseZenButton()); .child(<CloseZenModeButton />);
applyModals(rootContainer); applyModals(rootContainer);
return rootContainer; return rootContainer;
} }

View File

@@ -62,6 +62,10 @@ async function getAction(actionName: string, silent = false) {
return action; return action;
} }
export function getActionSync(actionName: string) {
return keyboardActionRepo[actionName];
}
function updateDisplayedShortcuts($container: JQuery<HTMLElement>) { function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
//@ts-ignore //@ts-ignore
//TODO: each() does not support async callbacks. //TODO: each() does not support async callbacks.

View File

@@ -218,7 +218,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile
if (utils.isElectron()) { if (utils.isElectron()) {
const ipc = utils.dynamicRequire("electron").ipcRenderer; const ipc = utils.dynamicRequire("electron").ipcRenderer;
ipc.on("server-response", async (event: string, arg: Arg) => { ipc.on("server-response", async (_, arg: Arg) => {
if (arg.statusCode >= 200 && arg.statusCode < 300) { if (arg.statusCode >= 200 && arg.statusCode < 300) {
handleSuccessfulResponse(arg); handleSuccessfulResponse(arg);
} else { } else {

View File

@@ -311,7 +311,13 @@ function copySelectionToClipboard() {
} }
} }
export function dynamicRequire(moduleName: string) { type dynamicRequireMappings = {
"@electron/remote": typeof import("@electron/remote"),
"electron": typeof import("electron"),
"child_process": typeof import("child_process")
};
export function dynamicRequire<T extends keyof dynamicRequireMappings>(moduleName: T): Awaited<dynamicRequireMappings[T]>{
if (typeof __non_webpack_require__ !== "undefined") { if (typeof __non_webpack_require__ !== "undefined") {
return __non_webpack_require__(moduleName); return __non_webpack_require__(moduleName);
} else { } else {

View File

@@ -442,14 +442,20 @@ body #context-menu-container .dropdown-item > span {
align-items: center; align-items: center;
} }
.dropdown-menu kbd { .dropdown-item span.keyboard-shortcut {
flex-grow: 1; flex-grow: 1;
text-align: right; text-align: right;
}
.dropdown-menu kbd {
color: var(--muted-text-color); color: var(--muted-text-color);
border: none; border: none;
background-color: transparent; background-color: transparent;
box-shadow: none; box-shadow: none;
padding-bottom: 0; padding-bottom: 0;
padding: 0;
flex-grow: 1;
text-align: right;
} }
.dropdown-item, .dropdown-item,

View File

@@ -197,13 +197,17 @@ html body .dropdown-item[disabled] {
/* Menu item keyboard shortcut */ /* Menu item keyboard shortcut */
.dropdown-item kbd { .dropdown-item kbd {
margin-left: 16px;
font-family: unset !important; font-family: unset !important;
font-size: unset !important; font-size: unset !important;
color: var(--menu-item-keyboard-shortcut-color) !important; color: var(--menu-item-keyboard-shortcut-color) !important;
padding-top: 0; padding-top: 0;
} }
.dropdown-item span.keyboard-shortcut {
color: var(--menu-item-keyboard-shortcut-color) !important;
margin-left: 16px;
}
.dropdown-divider { .dropdown-divider {
position: relative; position: relative;
border-color: transparent !important; border-color: transparent !important;

View File

@@ -96,7 +96,6 @@
background: var(--background) !important; background: var(--background) !important;
color: var(--color) !important; color: var(--color) !important;
line-height: unset; line-height: unset;
cursor: help;
} }
.sql-table-schemas-widget .sql-table-schemas button:hover, .sql-table-schemas-widget .sql-table-schemas button:hover,
@@ -106,18 +105,6 @@
--color: var(--main-text-color); --color: var(--main-text-color);
} }
/* Tooltip */
.tooltip .table-schema {
font-family: var(--monospace-font-family);
font-size: .85em;
}
/* Data type */
.tooltip .table-schema td:nth-child(2) {
color: var(--muted-text-color);
}
/* /*
* NOTE MAP * NOTE MAP
*/ */

View File

@@ -1682,7 +1682,8 @@
"hoist-this-note-workspace": "Hoist this note (workspace)", "hoist-this-note-workspace": "Hoist this note (workspace)",
"refresh-saved-search-results": "Refresh saved search results", "refresh-saved-search-results": "Refresh saved search results",
"create-child-note": "Create child note", "create-child-note": "Create child note",
"unhoist": "Unhoist" "unhoist": "Unhoist",
"toggle-sidebar": "Toggle sidebar"
}, },
"title_bar_buttons": { "title_bar_buttons": {
"window-on-top": "Keep Window on Top" "window-on-top": "Keep Window on Top"

View File

@@ -0,0 +1,28 @@
.api-log-widget {
flex-grow: 1;
max-height: 40%;
position: relative;
border-top: 1px solid var(--main-border-color);
background-color: var(--accented-background-color);
}
.api-log-container {
overflow: auto;
height: 100%;
font-family: var(--monospace-font-family);
font-size: 0.8em;
white-space: pre;
padding: 15px;
}
.close-api-log-button {
padding: 5px;
border: 1px solid var(--button-border-color);
background-color: var(--button-background-color);
border-radius: var(--button-border-radius);
color: var(--button-text-color);
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
}

View File

@@ -1,80 +0,0 @@
import type { EventData } from "../components/app_context.js";
import type FNote from "../entities/fnote.js";
import { t } from "../services/i18n.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
const TPL = /*html*/`
<div class="api-log-widget">
<style>
.api-log-widget {
padding: 15px;
flex-grow: 1;
max-height: 40%;
position: relative;
}
.hidden-api-log {
display: none;
}
.api-log-container {
overflow: auto;
height: 100%;
}
.close-api-log-button {
padding: 5px;
border: 1px solid var(--button-border-color);
background-color: var(--button-background-color);
border-radius: var(--button-border-radius);
color: var(--button-text-color);
position: absolute;
top: 10px;
right: 40px;
cursor: pointer;
}
</style>
<div class="bx bx-x close-api-log-button" title="${t("api_log.close")}"></div>
<div class="api-log-container"></div>
</div>`;
export default class ApiLogWidget extends NoteContextAwareWidget {
private $logContainer!: JQuery<HTMLElement>;
private $closeButton!: JQuery<HTMLElement>;
isEnabled() {
return !!this.note && this.note.mime.startsWith("application/javascript;env=") && super.isEnabled();
}
doRender() {
this.$widget = $(TPL);
this.toggle(false);
this.$logContainer = this.$widget.find(".api-log-container");
this.$closeButton = this.$widget.find(".close-api-log-button");
this.$closeButton.on("click", () => this.toggle(false));
}
async refreshWithNote(note: FNote) {
this.$logContainer.empty();
}
apiLogMessagesEvent({ messages, noteId }: EventData<"apiLogMessages">) {
if (!this.isNote(noteId)) {
return;
}
this.toggle(true);
for (const message of messages) {
this.$logContainer.append(message).append($("<br>"));
}
}
toggle(show: boolean) {
this.$widget.toggleClass("hidden-api-log", !show);
}
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from "preact/hooks";
import "./api_log.css";
import { useNoteContext, useTriliumEvent } from "./react/hooks";
import ActionButton from "./react/ActionButton";
import { t } from "../services/i18n";
/**
* Displays the messages that are logged by the current note via `api.log`, for frontend and backend scripts.
*/
export default function ApiLog() {
const { note, noteId } = useNoteContext();
const [ messages, setMessages ] = useState<string[]>();
useTriliumEvent("apiLogMessages", ({ messages, noteId: eventNoteId }) => {
if (eventNoteId !== noteId) return;
setMessages(messages);
});
// Clear when navigating away.
useEffect(() => setMessages(undefined), [ note ]);
const isEnabled = note?.mime.startsWith("application/javascript;env=") && messages?.length;
return (
<div className={`api-log-widget ${!isEnabled ? "hidden-ext" : ""}`}>
{isEnabled && (
<>
<ActionButton
icon="bx bx-x"
className="close-api-log-button"
text={t("api_log.close")}
onClick={() => setMessages(undefined)}
/>
<div className="api-log-container">
{messages.join("\n")}
</div>
</>
)}
</div>
)
}

View File

@@ -55,7 +55,7 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
* @param components the components to be added as children to this component provided the condition is truthy. * @param components the components to be added as children to this component provided the condition is truthy.
* @returns self for chaining. * @returns self for chaining.
*/ */
optChild(condition: boolean, ...components: T[]) { optChild(condition: boolean, ...components: (T | VNode)[]) {
if (condition) { if (condition) {
return this.child(...components); return this.child(...components);
} else { } else {

View File

@@ -0,0 +1,102 @@
.global-menu {
width: 53px;
height: 53px;
flex-shrink: 0;
}
.global-menu .dropdown-menu {
min-width: 20em;
}
.global-menu-button {
width: 100% !important;
height: 100% !important;
position: relative;
padding: 6px;
border: 0;
}
.global-menu-button svg path {
fill: var(--launcher-pane-text-color);
}
.global-menu-button:hover { border: 0; }
.global-menu-button:hover svg path {
transition: 200ms ease-in-out fill;
}
.global-menu-button:hover svg path.st0 { fill:#95C980; }
.global-menu-button:hover svg path.st1 { fill:#72B755; }
.global-menu-button:hover svg path.st2 { fill:#4FA52B; }
.global-menu-button:hover svg path.st3 { fill:#EE8C89; }
.global-menu-button:hover svg path.st4 { fill:#E96562; }
.global-menu-button:hover svg path.st5 { fill:#E33F3B; }
.global-menu-button:hover svg path.st6 { fill:#EFB075; }
.global-menu-button:hover svg path.st7 { fill:#E99547; }
.global-menu-button:hover svg path.st8 { fill:#E47B19; }
.global-menu-button-update-available {
position: absolute;
right: -30px;
bottom: -30px;
width: 100%;
height: 100%;
pointer-events: none;
}
.global-menu .zoom-container {
display: flex;
flex-direction: row;
align-items: baseline;
}
.global-menu .zoom-buttons {
margin-left: 2em;
}
.global-menu .zoom-buttons a {
display: inline-block;
border: 1px solid var(--button-border-color);
border-radius: var(--button-border-radius);
color: var(--button-text-color);
background-color: var(--button-background-color);
padding: 3px;
margin-left: 3px;
text-decoration: none;
}
.global-menu .zoom-buttons a:hover {
text-decoration: none;
}
.global-menu .zoom-state {
margin-left: 5px;
margin-right: 5px;
}
.global-menu .dropdown-item .bx {
position: relative;
top: 3px;
font-size: 120%;
margin-right: 6px;
}
/* #region Update available */
.global-menu-button-update-available-button {
width: 21px !important;
height: 21px !important;
padding: 0 !important;
border-radius: var(--button-border-radius);
transform: scale(0.9);
border: none;
opacity: 0.8;
display: flex;
align-items: center;
justify-content: center;
}
.global-menu-button-wrapper:hover .global-menu-button-update-available-button {
opacity: 1;
}
/* #endregion */

View File

@@ -1,436 +0,0 @@
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import utils from "../../services/utils.js";
import UpdateAvailableWidget from "./update_available.js";
import options from "../../services/options.js";
import { Tooltip, Dropdown } from "bootstrap";
const TPL = /*html*/`
<div class="dropdown global-menu">
<style>
.global-menu {
width: 53px;
height: 53px;
flex-shrink: 0;
}
.global-menu .dropdown-menu {
min-width: 20em;
}
.global-menu-button {
width: 100%;
height: 100%;
position: relative;
padding: 6px;
border: 0;
}
.global-menu-button > svg path {
fill: var(--launcher-pane-text-color);
}
.global-menu-button:hover { border: 0; }
.global-menu-button:hover > svg path {
transition: 200ms ease-in-out fill;
}
.global-menu-button:hover > svg path.st0 { fill:#95C980; }
.global-menu-button:hover > svg path.st1 { fill:#72B755; }
.global-menu-button:hover > svg path.st2 { fill:#4FA52B; }
.global-menu-button:hover > svg path.st3 { fill:#EE8C89; }
.global-menu-button:hover > svg path.st4 { fill:#E96562; }
.global-menu-button:hover > svg path.st5 { fill:#E33F3B; }
.global-menu-button:hover > svg path.st6 { fill:#EFB075; }
.global-menu-button:hover > svg path.st7 { fill:#E99547; }
.global-menu-button:hover > svg path.st8 { fill:#E47B19; }
.global-menu-button-update-available {
position: absolute;
right: -30px;
bottom: -30px;
width: 100%;
height: 100%;
pointer-events: none;
}
.global-menu .zoom-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
}
.global-menu .zoom-buttons a {
display: inline-block;
border: 1px solid var(--button-border-color);
border-radius: var(--button-border-radius);
color: var(--button-text-color);
background-color: var(--button-background-color);
padding: 3px;
margin-left: 3px;
text-decoration: none;
}
.global-menu .zoom-buttons a:hover {
text-decoration: none;
}
.global-menu .zoom-state {
margin-left: 5px;
margin-right: 5px;
}
.global-menu .dropdown-item .bx {
position: relative;
top: 3px;
font-size: 120%;
margin-right: 6px;
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
aria-expanded="false" class="icon-action global-menu-button">
<div class="global-menu-button-update-available"></div>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li class="dropdown-item" data-trigger-command="openNewWindow">
<span class="bx bx-window-open"></span>
${t("global_menu.open_new_window")}
<kbd data-command="openNewWindow"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="showShareSubtree">
<span class="bx bx-share-alt"></span>
${t("global_menu.show_shared_notes_subtree")}
</li>
<div class="dropdown-divider"></div>
<span class="zoom-container dropdown-item dropdown-item-container">
<div>
<span class="bx bx-empty"></span>
${t("global_menu.zoom")}
</div>
<div class="zoom-buttons">
<a data-trigger-command="toggleFullscreen" title="${t("global_menu.toggle_fullscreen")}" class="bx bx-expand-alt"></a>
&nbsp;
<a data-trigger-command="zoomOut" title="${t("global_menu.zoom_out")}" class="bx bx-minus"></a>
<span data-trigger-command="zoomReset" title="${t("global_menu.reset_zoom_level")}" class="zoom-state"></span>
<a data-trigger-command="zoomIn" title="${t("global_menu.zoom_in")}" class="bx bx-plus"></a>
</div>
</span>
<li class="dropdown-item toggle-pin">
<span class="bx bx-pin"></span>
${t("title_bar_buttons.window-on-top")}
</li>
<li class="dropdown-item" data-trigger-command="toggleZenMode">
<span class="bx bxs-yin-yang"></span>
${t("global_menu.toggle-zen-mode")}
<kbd data-command="toggleZenMode"></kbd>
</li>
<div class="dropdown-divider desktop-only"></div>
<li class="dropdown-item switch-to-mobile-version-button" data-trigger-command="switchToMobileVersion">
<span class="bx bx-mobile"></span>
${t("global_menu.switch_to_mobile_version")}
</li>
<li class="dropdown-item switch-to-desktop-version-button" data-trigger-command="switchToDesktopVersion">
<span class="bx bx-desktop"></span>
${t("global_menu.switch_to_desktop_version")}
</li>
<li class="dropdown-item" data-trigger-command="showLaunchBarSubtree">
<span class="bx ${utils.isMobile() ? "bx-mobile" : "bx-sidebar"}"></span>
${t("global_menu.configure_launchbar")}
</li>
<li class="dropdown-item dropdown-submenu">
<span class="dropdown-toggle">
<span class="bx bx-chip"></span>${t("global_menu.advanced")}
</span>
<ul class="dropdown-menu">
<li class="dropdown-item" data-trigger-command="showHiddenSubtree">
<span class="bx bx-hide"></span>
${t("global_menu.show_hidden_subtree")}
</li>
<li class="dropdown-item" data-trigger-command="showSearchHistory">
<span class="bx bx-search-alt"></span>
${t("global_menu.open_search_history")}
</li>
<div class="dropdown-divider"></div>
<li class="dropdown-item" data-trigger-command="showBackendLog">
<span class="bx bx-detail"></span>
${t("global_menu.show_backend_log")}
<kbd data-command="showBackendLog"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="showSQLConsole">
<span class="bx bx-data"></span>
${t("global_menu.open_sql_console")}
<kbd data-command="showSQLConsole"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="showSQLConsoleHistory">
<span class="bx bx-data"></span>
${t("global_menu.open_sql_console_history")}
</li>
<div class="dropdown-divider"></div>
<li class="dropdown-item open-dev-tools-button" data-trigger-command="openDevTools">
<span class="bx bx-bug-alt"></span>
${t("global_menu.open_dev_tools")}
<kbd data-command="openDevTools"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="reloadFrontendApp"
title="${t("global_menu.reload_hint")}">
<span class="bx bx-refresh"></span>
${t("global_menu.reload_frontend")}
<kbd data-command="reloadFrontendApp"></kbd>
</li>
</ul>
</li>
<li class="dropdown-item" data-trigger-command="showOptions">
<span class="bx bx-cog"></span>
${t("global_menu.options")}
</li>
<div class="dropdown-divider desktop-only"></div>
<li class="dropdown-item show-help-button" data-trigger-command="showHelp">
<span class="bx bx-help-circle"></span>
${t("global_menu.show_help")}
<kbd data-command="showHelp"></kbd>
</li>
<li class="dropdown-item show-help-button" data-trigger-command="showCheatsheet">
<span class="bx bxs-keyboard"></span>
${t("global_menu.show-cheatsheet")}
<kbd data-command="showCheatsheet"></kbd>
</li>
<li class="dropdown-item show-about-dialog-button">
<span class="bx bx-info-circle"></span>
${t("global_menu.about")}
</li>
<li class="dropdown-item update-to-latest-version-button" style="display: none;" data-trigger-command="downloadLatestVersion">
<span class="bx bx-sync"></span>
<span class="version-text"></span>
</li>
<div class="dropdown-divider logout-button-separator"></div>
<li class="dropdown-item logout-button" data-trigger-command="logout">
<span class="bx bx-log-out"></span>
${t("global_menu.logout")}
</li>
</ul>
</div>
`;
export default class GlobalMenuWidget extends BasicWidget {
private updateAvailableWidget: UpdateAvailableWidget;
private isHorizontalLayout: boolean;
private tooltip!: Tooltip;
private dropdown!: Dropdown;
private $updateToLatestVersionButton!: JQuery<HTMLElement>;
private $zoomState!: JQuery<HTMLElement>;
private $toggleZenMode!: JQuery<HTMLElement>;
constructor(isHorizontalLayout: boolean) {
super();
this.updateAvailableWidget = new UpdateAvailableWidget();
this.isHorizontalLayout = isHorizontalLayout;
}
doRender() {
this.$widget = $(TPL);
if (!this.isHorizontalLayout) {
this.$widget.addClass("dropend");
}
const $globalMenuButton = this.$widget.find(".global-menu-button");
if (!this.isHorizontalLayout) {
$globalMenuButton.prepend(
$(`\
<svg viewBox="0 0 256 256" data-bs-toggle="tooltip" title="${t("global_menu.menu")}">
<g>
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
</g>
</svg>`)
);
this.tooltip = new Tooltip(this.$widget.find("[data-bs-toggle='tooltip']")[0], { trigger: "hover" });
} else {
$globalMenuButton.toggleClass("bx bx-menu");
}
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], {
popperConfig: {
placement: "bottom"
}
});
this.$widget.find(".show-about-dialog-button").on("click", () => this.triggerCommand("openAboutDialog"));
const isElectron = utils.isElectron();
this.$widget.find(".toggle-pin").toggle(isElectron);
if (isElectron) {
this.$widget.on("click", ".toggle-pin", (e) => {
const $el = $(e.target);
const remote = utils.dynamicRequire("@electron/remote");
const focusedWindow = remote.BrowserWindow.getFocusedWindow();
const isAlwaysOnTop = focusedWindow.isAlwaysOnTop();
if (isAlwaysOnTop) {
focusedWindow.setAlwaysOnTop(false);
$el.removeClass("active");
} else {
focusedWindow.setAlwaysOnTop(true);
$el.addClass("active");
}
});
}
this.$widget.find(".logout-button").toggle(!isElectron);
this.$widget.find(".logout-button-separator").toggle(!isElectron);
this.$widget.find(".open-dev-tools-button").toggle(isElectron);
this.$widget.find(".switch-to-mobile-version-button").toggle(!isElectron && utils.isDesktop());
this.$widget.find(".switch-to-desktop-version-button").toggle(!isElectron && utils.isMobile());
this.$widget.on("click", ".dropdown-item", (e) => {
if ($(e.target).parent(".zoom-buttons")) {
return;
}
this.dropdown.toggle();
});
if (utils.isMobile()) {
this.$widget.on("click", ".dropdown-submenu .dropdown-toggle", (e) => {
const $submenu = $(e.target).closest(".dropdown-item");
$submenu.toggleClass("submenu-open");
$submenu.find("ul.dropdown-menu").toggleClass("show");
e.stopPropagation();
return;
});
}
this.$widget.on("click", ".dropdown-submenu", (e) => {
if ($(e.target).children(".dropdown-menu").length === 1 || $(e.target).hasClass("dropdown-toggle")) {
e.stopPropagation();
}
});
this.$widget.find(".global-menu-button-update-available").append(this.updateAvailableWidget.render());
this.$updateToLatestVersionButton = this.$widget.find(".update-to-latest-version-button");
if (!utils.isElectron()) {
this.$widget.find(".zoom-container").hide();
}
this.$zoomState = this.$widget.find(".zoom-state");
this.$toggleZenMode = this.$widget.find('[data-trigger-command="toggleZenMode"');
this.$widget.on("show.bs.dropdown", () => this.#onShown());
if (this.tooltip) {
this.$widget.on("hide.bs.dropdown", () => this.tooltip.enable());
}
this.$widget.find(".zoom-buttons").on(
"click",
// delay to wait for the actual zoom change
() => setTimeout(() => this.updateZoomState(), 300)
);
this.updateVersionStatus();
setInterval(() => this.updateVersionStatus(), 8 * 60 * 60 * 1000);
}
#onShown() {
this.$toggleZenMode.toggleClass("active", $("body").hasClass("zen"));
this.updateZoomState();
if (this.tooltip) {
this.tooltip.hide();
this.tooltip.disable();
}
}
updateZoomState() {
if (!utils.isElectron()) {
return;
}
const zoomFactor = utils.dynamicRequire("electron").webFrame.getZoomFactor();
const zoomPercent = Math.round(zoomFactor * 100);
this.$zoomState.text(`${zoomPercent}%`);
}
async updateVersionStatus() {
await options.initializedPromise;
if (options.get("checkForUpdates") !== "true") {
return;
}
const latestVersion = await this.fetchLatestVersion();
this.updateAvailableWidget.updateVersionStatus(latestVersion);
// Show "click to download" button in options menu if there's a new version available
this.$updateToLatestVersionButton.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion));
this.$updateToLatestVersionButton.find(".version-text").text(`Version ${latestVersion} is available, click to download.`);
}
async fetchLatestVersion() {
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
const resp = await fetch(RELEASES_API_URL);
const data = await resp.json();
return data?.tag_name?.substring(1);
}
downloadLatestVersionCommand() {
window.open("https://github.com/TriliumNext/Trilium/releases/latest");
}
activeContextChangedEvent() {
this.dropdown.hide();
}
noteSwitchedEvent() {
this.dropdown.hide();
}
}

View File

@@ -0,0 +1,239 @@
import Dropdown from "../react/Dropdown";
import "./global_menu.css";
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool } from "../react/hooks";
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem } from "../react/FormList";
import { CommandNames } from "../../components/app_context";
import KeyboardShortcut from "../react/KeyboardShortcut";
import { KeyboardActionNames } from "@triliumnext/commons";
import { ComponentChildren } from "preact";
import Component from "../../components/component";
import { ParentComponent } from "../react/react_utils";
import utils, { dynamicRequire, isElectron, isMobile } from "../../services/utils";
interface MenuItemProps<T> {
icon: string,
text: ComponentChildren,
title?: string,
command: T,
disabled?: boolean
active?: boolean;
outsideChildren?: ComponentChildren;
}
export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout: boolean }) {
const isVerticalLayout = !isHorizontalLayout;
const parentComponent = useContext(ParentComponent);
const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus();
return (
<Dropdown
className="global-menu"
buttonClassName={`global-menu-button ${isHorizontalLayout ? "bx bx-menu" : ""}`} noSelectButtonStyle iconAction hideToggleArrow
text={<>
{isVerticalLayout && <VerticalLayoutIcon />}
{isUpdateAvailable && <div class="global-menu-button-update-available">
<span className="bx bx-sync global-menu-button-update-available-button" title={t("update_available.update_available")}></span>
</div>}
</>}
>
<MenuItem command="openNewWindow" icon="bx bx-window-open" text={t("global_menu.open_new_window")} />
<MenuItem command="showShareSubtree" icon="bx bx-share-alt" text={t("global_menu.show_shared_notes_subtree")} />
<FormDropdownDivider />
<ZoomControls parentComponent={parentComponent} />
<ToggleWindowOnTop />
<KeyboardActionMenuItem command="toggleZenMode" icon="bx bxs-yin-yang" text={t("global_menu.toggle-zen-mode")} />
<FormDropdownDivider />
<SwitchToOptions />
<MenuItem command="showLaunchBarSubtree" icon={`bx ${isMobile() ? "bx-mobile" : "bx-sidebar"}`} text={t("global_menu.configure_launchbar")} />
<AdvancedMenu />
<MenuItem command="showOptions" icon="bx bx-cog" text={t("global_menu.options")} />
<FormDropdownDivider />
<KeyboardActionMenuItem command="showHelp" icon="bx bx-help-circle" text={t("global_menu.show_help")} />
<KeyboardActionMenuItem command="showCheatsheet" icon="bx bxs-keyboard" text={t("global_menu.show-cheatsheet")} />
<MenuItem command="openAboutDialog" icon="bx bx-info-circle" text={t("global_menu.about")} />
{isUpdateAvailable && <MenuItem command={() => window.open("https://github.com/TriliumNext/Trilium/releases/latest")} icon="bx bx-sync" text={`Version ${latestVersion} is available, click to download.`} /> }
{!isElectron() && <BrowserOnlyOptions />}
</Dropdown>
)
}
function AdvancedMenu() {
return (
<FormDropdownSubmenu icon="bx bx-chip" title={t("global_menu.advanced")}>
<MenuItem command="showHiddenSubtree" icon="bx bx-hide" text={t("global_menu.show_hidden_subtree")} />
<MenuItem command="showSearchHistory" icon="bx bx-search-alt" text={t("global_menu.open_search_history")} />
<FormDropdownDivider />
<KeyboardActionMenuItem command="showBackendLog" icon="bx bx-detail" text={t("global_menu.show_backend_log")} />
<KeyboardActionMenuItem command="showSQLConsole" icon="bx bx-data" text={t("global_menu.open_sql_console")} />
<MenuItem command="showSQLConsoleHistory" icon="bx bx-data" text={t("global_menu.open_sql_console_history")} />
<FormDropdownDivider />
{isElectron() && <MenuItem command="openDevTools" icon="bx bx-bug-alt" text={t("global_menu.open_dev_tools")} />}
<KeyboardActionMenuItem command="reloadFrontendApp" icon="bx bx-refresh" text={t("global_menu.reload_frontend")} title={t("global_menu.reload_hint")} />
</FormDropdownSubmenu>
)
}
function BrowserOnlyOptions() {
return <>
<FormDropdownDivider />
<MenuItem command="logout" icon="bx bx-log-out" text={t("global_menu.logout")} />
</>;
}
function SwitchToOptions() {
if (isElectron()) {
return;
} else if (!isMobile()) {
return <MenuItem command="switchToMobileVersion" icon="bx bx-mobile" text={t("global_menu.switch_to_mobile_version")} />
} else {
return <MenuItem command="switchToDesktopVersion" icon="bx bx-desktop" text={t("global_menu.switch_to_desktop_version")} />
}
}
function MenuItem({ icon, text, title, command, disabled, active }: MenuItemProps<KeyboardActionNames | CommandNames | (() => void)>) {
return <FormListItem
icon={icon}
title={title}
triggerCommand={typeof command === "string" ? command : undefined}
onClick={typeof command === "function" ? command : undefined}
disabled={disabled}
active={active}
>{text}</FormListItem>
}
function KeyboardActionMenuItem({ text, command, ...props }: MenuItemProps<KeyboardActionNames>) {
return <MenuItem
{...props}
command={command}
text={<>{text} <KeyboardShortcut actionName={command as KeyboardActionNames} /></>}
/>
}
function VerticalLayoutIcon() {
const logoRef = useRef<SVGSVGElement>(null);
useStaticTooltip(logoRef);
return (
<svg ref={logoRef} viewBox="0 0 256 256" title={t("global_menu.menu")}>
<g>
<path className="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
<path className="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
<path className="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
<path className="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
<path className="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
<path className="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
<path className="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
<path className="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
<path className="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
</g>
</svg>
)
}
function ZoomControls({ parentComponent }: { parentComponent?: Component | null }) {
const [ zoomLevel, setZoomLevel ] = useState(100);
function updateZoomState() {
if (!isElectron()) {
return;
}
const zoomFactor = dynamicRequire("electron").webFrame.getZoomFactor();
setZoomLevel(Math.round(zoomFactor * 100));
}
useEffect(updateZoomState, []);
function ZoomControlButton({ command, title, icon, children }: { command: KeyboardActionNames, title: string, icon?: string, children?: ComponentChildren }) {
const linkRef = useRef<HTMLAnchorElement>(null);
useStaticTooltipWithKeyboardShortcut(linkRef, title, command);
return (
<a
ref={linkRef}
onClick={(e) => {
parentComponent?.triggerCommand(command);
setTimeout(() => updateZoomState(), 300)
e.stopPropagation();
}}
className={icon}
>{children}</a>
)
}
return isElectron() ? (
<FormListItem
icon="bx bx-empty"
className="zoom-container"
>
{t("global_menu.zoom")}
<>
<div className="zoom-buttons">
<ZoomControlButton command="toggleFullscreen" title={t("global_menu.toggle_fullscreen")} icon="bx bx-expand-alt" />
&nbsp;
<ZoomControlButton command="zoomOut" title={t("global_menu.zoom_out")} icon="bx bx-minus" />
<ZoomControlButton command="zoomReset" title={t("global_menu.reset_zoom_level")}>{zoomLevel}{t("units.percentage")}</ZoomControlButton>
<ZoomControlButton command="zoomIn" title={t("global_menu.zoom_in")} icon="bx bx-plus" />
</div>
</>
</FormListItem>
) : (
<MenuItem icon="bx bx-expand-alt" command="toggleFullscreen" text={t("global_menu.toggle_fullscreen")} />
);
}
function ToggleWindowOnTop() {
const focusedWindow = isElectron() ? dynamicRequire("@electron/remote").BrowserWindow.getFocusedWindow() : null;
const [ isAlwaysOnTop, setIsAlwaysOnTop ] = useState(focusedWindow?.isAlwaysOnTop());
return (isElectron() &&
<MenuItem
icon="bx bx-pin"
text={t("title_bar_buttons.window-on-top")}
active={isAlwaysOnTop}
command={() => {
const newState = !isAlwaysOnTop;
focusedWindow?.setAlwaysOnTop(newState);
setIsAlwaysOnTop(newState);
}}
/>
)
}
function useTriliumUpdateStatus() {
const [ latestVersion, setLatestVersion ] = useState<string>();
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
async function updateVersionStatus() {
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
const resp = await fetch(RELEASES_API_URL);
const data = await resp.json();
const latestVersion = data?.tag_name?.substring(1);
setLatestVersion(latestVersion);
}
useEffect(() => {
if (!checkForUpdates) {
setLatestVersion(undefined);
return;
}
updateVersionStatus();
const interval = setInterval(() => updateVersionStatus(), 8 * 60 * 60 * 1000);
return () => clearInterval(interval);
}, [ checkForUpdates ]);
return { isUpdateAvailable, latestVersion };
}

View File

@@ -4,15 +4,7 @@ import treeService from "../../services/tree.js";
import ButtonFromNoteWidget from "./button_from_note.js"; import ButtonFromNoteWidget from "./button_from_note.js";
import type FNote from "../../entities/fnote.js"; import type FNote from "../../entities/fnote.js";
import type { CommandNames } from "../../components/app_context.js"; import type { CommandNames } from "../../components/app_context.js";
import type { WebContents } from "electron";
interface WebContents {
history: string[];
getActiveIndex(): number;
clearHistory(): void;
canGoBack(): boolean;
canGoForward(): boolean;
goToIndex(index: string): void;
}
interface ContextMenuItem { interface ContextMenuItem {
title: string; title: string;
@@ -51,14 +43,14 @@ export default class HistoryNavigationButton extends ButtonFromNoteWidget {
async showContextMenu(e: JQuery.ContextMenuEvent) { async showContextMenu(e: JQuery.ContextMenuEvent) {
e.preventDefault(); e.preventDefault();
if (!this.webContents || this.webContents.history.length < 2) { if (!this.webContents || this.webContents.navigationHistory.length() < 2) {
return; return;
} }
let items: ContextMenuItem[] = []; let items: ContextMenuItem[] = [];
const activeIndex = this.webContents.getActiveIndex(); const history = this.webContents.navigationHistory;
const history = this.webContents.history; const activeIndex = history.getActiveIndex();
for (const idx in history) { for (const idx in history) {
const url = history[idx]; const url = history[idx];

View File

@@ -1,43 +0,0 @@
import options from "../../services/options.js";
import splitService from "../../services/resizer.js";
import CommandButtonWidget from "./command_button.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
export default class LeftPaneToggleWidget extends CommandButtonWidget {
private currentLeftPaneVisible: boolean;
constructor(isHorizontalLayout: boolean) {
super();
this.currentLeftPaneVisible = options.is("leftPaneVisible");
this.class(isHorizontalLayout ? "toggle-button" : "launcher-button");
this.settings.icon = () => {
if (options.get("layoutOrientation") === "horizontal") {
return "bx-sidebar";
}
return this.currentLeftPaneVisible ? "bx-chevrons-left" : "bx-chevrons-right";
};
this.settings.title = () => (this.currentLeftPaneVisible ? t("left_pane_toggle.hide_panel") : t("left_pane_toggle.show_panel"));
this.settings.command = () => (this.currentLeftPaneVisible ? "hideLeftPane" : "showLeftPane");
if (isHorizontalLayout) {
this.settings.titlePlacement = "bottom";
}
}
refreshIcon() {
super.refreshIcon();
splitService.setupLeftPaneResizer(this.currentLeftPaneVisible);
}
setLeftPaneVisibilityEvent({ leftPaneVisible }: EventData<"setLeftPaneVisibility">) {
this.currentLeftPaneVisible = leftPaneVisible ?? !this.currentLeftPaneVisible;
this.refreshIcon();
}
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from "preact/hooks";
import ActionButton from "../react/ActionButton";
import options from "../../services/options";
import { t } from "../../services/i18n";
import { useTriliumEvent } from "../react/hooks";
import resizer from "../../services/resizer";
export default function LeftPaneToggle({ isHorizontalLayout }: { isHorizontalLayout: boolean }) {
const [ currentLeftPaneVisible, setCurrentLeftPaneVisible ] = useState(options.is("leftPaneVisible"));
useTriliumEvent("setLeftPaneVisibility", ({ leftPaneVisible }) => {
setCurrentLeftPaneVisible(leftPaneVisible ?? !currentLeftPaneVisible);
});
useEffect(() => {
resizer.setupLeftPaneResizer(currentLeftPaneVisible);
}, [ currentLeftPaneVisible ]);
return (
<ActionButton
className={`${isHorizontalLayout ? "toggle-button" : "launcher-button"}`}
text={currentLeftPaneVisible ? t("left_pane_toggle.hide_panel") : t("left_pane_toggle.show_panel")}
triggerCommand={currentLeftPaneVisible ? "hideLeftPane" : "showLeftPane"}
icon={isHorizontalLayout
? "bx bx-sidebar"
: (currentLeftPaneVisible ? "bx bx-chevrons-left" : "bx bx-chevrons-right" )}
/>
)
}

View File

@@ -1,40 +0,0 @@
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import utils from "../../services/utils.js";
const TPL = /*html*/`
<div style="display: none;">
<style>
.global-menu-button-update-available-button {
width: 21px !important;
height: 21px !important;
padding: 0 !important;
border-radius: var(--button-border-radius);
transform: scale(0.9);
border: none;
opacity: 0.8;
display: flex;
align-items: center;
justify-content: center;
}
.global-menu-button-wrapper:hover .global-menu-button-update-available-button {
opacity: 1;
}
</style>
<span class="bx bx-sync global-menu-button-update-available-button" title="${t("update_available.update_available")}"></span>
</div>
`;
export default class UpdateAvailableWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
}
updateVersionStatus(latestVersion: string) {
this.$widget.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion));
}
}

View File

@@ -0,0 +1,26 @@
:root {
--zen-button-size: 32px;
}
.close-zen-container {
width: var(--zen-button-size);
height: var(--zen-button-size);
}
body.zen .close-zen-container {
display: block;
position: fixed;
top: 2px;
right: 2px;
z-index: 9999;
-webkit-app-region: no-drag;
}
body.zen.mobile .close-zen-container {
top: -2px;
}
body.zen.electron:not(.platform-darwin):not(.native-titlebar) .close-zen-container {
left: calc(env(titlebar-area-width) - var(--zen-button-size) - 2px);
right: unset;
}

View File

@@ -1,54 +0,0 @@
import BasicWidget from "./basic_widget.js";
import { t } from "../services/i18n.js";
import utils from "../services/utils.js";
const TPL = /*html*/`\
<div class="close-zen-container">
<button class="button-widget bx icon-action bxs-yin-yang"
data-trigger-command="toggleZenMode"
title="${t("zen_mode.button_exit")}"
/>
<style>
:root {
--zen-button-size: 32px;
}
.close-zen-container {
display: none;
width: var(--zen-button-size);
height: var(--zen-button-size);
}
body.zen .close-zen-container {
display: block;
position: fixed;
top: 2px;
right: 2px;
z-index: 9999;
-webkit-app-region: no-drag;
}
body.zen.mobile .close-zen-container {
top: -2px;
}
body.zen.electron:not(.platform-darwin):not(.native-titlebar) .close-zen-container {
left: calc(env(titlebar-area-width) - var(--zen-button-size) - 2px);
right: unset;
}
</style>
</div>
`;
export default class CloseZenButton extends BasicWidget {
doRender(): void {
this.$widget = $(TPL);
}
zenChangedEvent() {
this.toggleInt(true);
}
}

View File

@@ -0,0 +1,25 @@
import { useState } from "preact/hooks";
import { t } from "../services/i18n";
import ActionButton from "./react/ActionButton";
import { useTriliumEvent } from "./react/hooks";
import "./close_zen_button.css";
export default function CloseZenModeButton() {
const [ zenModeEnabled, setZenModeEnabled ] = useState(false);
useTriliumEvent("zenModeChanged", ({ isEnabled }) => {
setZenModeEnabled(isEnabled);
});
return (
<div class={`close-zen-container ${!zenModeEnabled ? "hidden-ext" : ""}`}>
{zenModeEnabled && (
<ActionButton
icon="bx bxs-yin-yang"
triggerCommand="toggleZenMode"
text={t("zen_mode.button_exit")}
/>
)}
</div>
)
}

View File

@@ -1,64 +0,0 @@
import BasicWidget from "../basic_widget.js";
import appContext from "../../components/app_context.js";
import contextMenu from "../../menus/context_menu.js";
import noteCreateService from "../../services/note_create.js";
import branchService from "../../services/branches.js";
import treeService from "../../services/tree.js";
import { t } from "../../services/i18n.js";
const TPL = /*html*/`<button type="button" class="action-button bx"></button>`;
class MobileDetailMenuWidget extends BasicWidget {
private isHorizontalLayout: boolean;
constructor(isHorizontalLayout: boolean) {
super();
this.isHorizontalLayout = isHorizontalLayout;
}
doRender() {
this.$widget = $(TPL);
this.$widget.addClass(this.isHorizontalLayout ? "bx-dots-vertical-rounded" : "bx-menu");
this.$widget.on("click", async (e) => {
const note = appContext.tabManager.getActiveContextNote();
contextMenu.show<"insertChildNote" | "delete" | "showRevisions">({
x: e.pageX,
y: e.pageY,
items: [
{ title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" },
{ title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" },
{ title: "----" },
{ title: "Note revisions", command: "showRevisions", uiIcon: "bx bx-history" }
],
selectMenuItemHandler: async ({ command }) => {
if (command === "insertChildNote") {
noteCreateService.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined);
} else if (command === "delete") {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (!notePath) {
throw new Error("Cannot get note path to delete.");
}
const branchId = await treeService.getBranchIdFromUrl(notePath);
if (!branchId) {
throw new Error(t("mobile_detail_menu.error_cannot_get_branch_id", { notePath }));
}
if (await branchService.deleteNotes([branchId])) {
this.triggerCommand("setActiveScreen", { screen: "tree" });
}
} else if (command) {
this.triggerCommand(command);
}
},
forcePositionOnMobile: true
});
});
}
}
export default MobileDetailMenuWidget;

View File

@@ -0,0 +1,57 @@
import { useContext } from "preact/hooks";
import appContext from "../../components/app_context";
import contextMenu from "../../menus/context_menu";
import branches from "../../services/branches";
import { t } from "../../services/i18n";
import note_create from "../../services/note_create";
import tree from "../../services/tree";
import ActionButton from "../react/ActionButton";
import { ParentComponent } from "../react/react_utils";
export default function MobileDetailMenu() {
const parentComponent = useContext(ParentComponent);
return (
<ActionButton
icon="bx bx-dots-vertical-rounded"
text=""
onClick={(e) => {
const note = appContext.tabManager.getActiveContextNote();
contextMenu.show<"insertChildNote" | "delete" | "showRevisions">({
x: e.pageX,
y: e.pageY,
items: [
{ title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" },
{ title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" },
{ title: "----" },
{ title: "Note revisions", command: "showRevisions", uiIcon: "bx bx-history" }
],
selectMenuItemHandler: async ({ command }) => {
if (command === "insertChildNote") {
note_create.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined);
} else if (command === "delete") {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (!notePath) {
throw new Error("Cannot get note path to delete.");
}
const branchId = await tree.getBranchIdFromUrl(notePath);
if (!branchId) {
throw new Error(t("mobile_detail_menu.error_cannot_get_branch_id", { notePath }));
}
if (await branches.deleteNotes([branchId]) && parentComponent) {
parentComponent.triggerCommand("setActiveScreen", { screen: "tree" });
}
} else if (command && parentComponent) {
parentComponent.triggerCommand(command);
}
},
forcePositionOnMobile: true
});
}}
/>
)
}

View File

@@ -1,18 +0,0 @@
import BasicWidget from "../basic_widget.js";
const TPL = /*html*/`
<button type="button" class="action-button bx bx-sidebar"></button>`;
class ToggleSidebarButtonWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
this.$widget.on("click", () =>
this.triggerCommand("setActiveScreen", {
screen: "tree"
})
);
}
}
export default ToggleSidebarButtonWidget;

View File

@@ -0,0 +1,18 @@
import { useContext } from "preact/hooks";
import ActionButton from "../react/ActionButton";
import { ParentComponent } from "../react/react_utils";
import { t } from "../../services/i18n";
export default function ToggleSidebarButton() {
const parentComponent = useContext(ParentComponent);
return (
<ActionButton
icon="bx bx-sidebar"
text={t("note_tree.toggle-sidebar")}
onClick={() => parentComponent?.triggerCommand("setActiveScreen", {
screen: "tree"
})}
/>
)
}

View File

@@ -5,7 +5,7 @@ import keyboard_actions from "../../services/keyboard_actions";
export interface ActionButtonProps { export interface ActionButtonProps {
text: string; text: string;
titlePosition?: "bottom" | "left"; // TODO: Use it titlePosition?: "bottom" | "left";
icon: string; icon: string;
className?: string; className?: string;
onClick?: (e: MouseEvent) => void; onClick?: (e: MouseEvent) => void;
@@ -25,7 +25,7 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo
useEffect(() => { useEffect(() => {
if (triggerCommand) { if (triggerCommand) {
keyboard_actions.getAction(triggerCommand).then(action => setKeyboardShortcut(action?.effectiveShortcuts)); keyboard_actions.getAction(triggerCommand, true).then(action => setKeyboardShortcut(action?.effectiveShortcuts));
} }
}, [triggerCommand]); }, [triggerCommand]);

View File

@@ -4,11 +4,12 @@ interface AlertProps {
type: "info" | "danger" | "warning"; type: "info" | "danger" | "warning";
title?: string; title?: string;
children: ComponentChildren; children: ComponentChildren;
className?: string;
} }
export default function Alert({ title, type, children }: AlertProps) { export default function Alert({ title, type, children, className }: AlertProps) {
return ( return (
<div className={`alert alert-${type}`}> <div className={`alert alert-${type} ${className ?? ""}`}>
{title && <h4>{title}</h4>} {title && <h4>{title}</h4>}
{children} {children}

View File

@@ -18,9 +18,10 @@ export interface DropdownProps {
noSelectButtonStyle?: boolean; noSelectButtonStyle?: boolean;
disabled?: boolean; disabled?: boolean;
text?: ComponentChildren; text?: ComponentChildren;
forceShown?: boolean;
} }
export default function Dropdown({ className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, hideToggleArrow, iconAction, disabled, noSelectButtonStyle }: DropdownProps) { export default function Dropdown({ className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, hideToggleArrow, iconAction, disabled, noSelectButtonStyle, forceShown }: DropdownProps) {
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null); const triggerRef = useRef<HTMLButtonElement | null>(null);
@@ -30,8 +31,12 @@ export default function Dropdown({ className, buttonClassName, isStatic, childre
if (!triggerRef.current) return; if (!triggerRef.current) return;
const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current); const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current);
if (forceShown) {
dropdown.show();
setShown(true);
}
return () => dropdown.dispose(); return () => dropdown.dispose();
}, []); // Add dependency array }, []);
const onShown = useCallback(() => { const onShown = useCallback(() => {
setShown(true); setShown(true);
@@ -75,13 +80,13 @@ export default function Dropdown({ className, buttonClassName, isStatic, childre
<span className="caret"></span> <span className="caret"></span>
</button> </button>
<div <ul
class={`dropdown-menu ${isStatic ? "static" : ""} ${dropdownContainerClassName ?? ""} tn-dropdown-list`} class={`dropdown-menu ${isStatic ? "static" : ""} ${dropdownContainerClassName ?? ""} tn-dropdown-list`}
style={dropdownContainerStyle} style={dropdownContainerStyle}
aria-labelledby={ariaId} aria-labelledby={ariaId}
> >
{shown && children} {shown && children}
</div> </ul>
</div> </div>
) )
} }

View File

@@ -1,9 +1,11 @@
import { Dropdown as BootstrapDropdown } from "bootstrap"; import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap";
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import Icon from "./Icon"; import Icon from "./Icon";
import { useEffect, useMemo, useRef, type CSSProperties } from "preact/compat"; import { useEffect, useMemo, useRef, useState, type CSSProperties } from "preact/compat";
import "./FormList.css"; import "./FormList.css";
import { CommandNames } from "../../components/app_context"; import { CommandNames } from "../../components/app_context";
import { useStaticTooltip } from "./hooks";
import { isMobile } from "../../services/utils";
interface FormListOpts { interface FormListOpts {
children: ComponentChildren; children: ComponentChildren;
@@ -89,14 +91,24 @@ interface FormListItemOpts {
rtl?: boolean; rtl?: boolean;
} }
export function FormListItem({ children, icon, value, title, active, badges, disabled, checked, onClick, description, selected, rtl, triggerCommand }: FormListItemOpts) { const TOOLTIP_CONFIG: Partial<Tooltip.Options> = {
placement: "right",
fallbackPlacements: [ "right" ]
}
export function FormListItem({ className, icon, value, title, active, disabled, checked, onClick, selected, rtl, triggerCommand, description, ...contentProps }: FormListItemOpts) {
const itemRef = useRef<HTMLLIElement>(null);
if (checked) { if (checked) {
icon = "bx bx-check"; icon = "bx bx-check";
} }
useStaticTooltip(itemRef, TOOLTIP_CONFIG);
return ( return (
<a <li
class={`dropdown-item ${active ? "active" : ""} ${disabled ? "disabled" : ""} ${selected ? "selected" : ""}`} ref={itemRef}
class={`dropdown-item ${active ? "active" : ""} ${disabled ? "disabled" : ""} ${selected ? "selected" : ""} ${className ?? ""}`}
data-value={value} title={title} data-value={value} title={title}
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
@@ -104,17 +116,27 @@ export function FormListItem({ children, icon, value, title, active, badges, dis
dir={rtl ? "rtl" : undefined} dir={rtl ? "rtl" : undefined}
> >
<Icon icon={icon} />&nbsp; <Icon icon={icon} />&nbsp;
<div> {description ? (
{children} <div>
{badges && badges.map(({ className, text }) => ( <FormListContent description={description} {...contentProps} />
<span className={`badge ${className ?? ""}`}>{text}</span> </div>
))} ) : (
{description && <div className="description">{description}</div>} <FormListContent description={description} {...contentProps} />
</div> )}
</a> </li>
); );
} }
function FormListContent({ children, badges, description }: Pick<FormListItemOpts, "children" | "badges" | "description">) {
return <>
{children}
{badges && badges.map(({ className, text }) => (
<span className={`badge ${className ?? ""}`}>{text}</span>
))}
{description && <div className="description">{description}</div>}
</>;
}
interface FormListHeaderOpts { interface FormListHeaderOpts {
text: string; text: string;
} }
@@ -129,4 +151,30 @@ export function FormListHeader({ text }: FormListHeaderOpts) {
export function FormDropdownDivider() { export function FormDropdownDivider() {
return <div className="dropdown-divider" />; return <div className="dropdown-divider" />;
}
export function FormDropdownSubmenu({ icon, title, children }: { icon: string, title: ComponentChildren, children: ComponentChildren }) {
const [ openOnMobile, setOpenOnMobile ] = useState(false);
return (
<li className={`dropdown-item dropdown-submenu ${openOnMobile ? "submenu-open" : ""}`}>
<span
className="dropdown-toggle"
onClick={(e) => {
e.stopPropagation();
if (isMobile()) {
setOpenOnMobile(!openOnMobile);
}
}}
>
<Icon icon={icon} />{" "}
{title}
</span>
<ul className={`dropdown-menu ${openOnMobile ? "show" : ""}`}>
{children}
</ul>
</li>
)
} }

View File

@@ -2,11 +2,14 @@ import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/common
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import keyboard_actions from "../../services/keyboard_actions"; import keyboard_actions from "../../services/keyboard_actions";
import { joinElements } from "./react_utils"; import { joinElements } from "./react_utils";
import utils from "../../services/utils";
interface KeyboardShortcutProps { interface KeyboardShortcutProps {
actionName: KeyboardActionNames; actionName: KeyboardActionNames;
} }
const isMobile = utils.isMobile();
export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) { export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) {
const [ action, setAction ] = useState<ActionKeyboardShortcut>(); const [ action, setAction ] = useState<ActionKeyboardShortcut>();
@@ -18,17 +21,14 @@ export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps)
return <></>; return <></>;
} }
return ( return (!isMobile &&
<> <span className="keyboard-shortcut">
{action.effectiveShortcuts?.map((shortcut) => { {joinElements(action.effectiveShortcuts?.map((shortcut) => {
const keys = shortcut.split("+"); const keys = shortcut.split("+");
return joinElements(keys return joinElements(
.map((key, i) => ( keys.map((key, i) => <kbd>{key}</kbd>)
<> , "+");
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "} }))}
</> </span>
)))
})}
</>
); );
} }

View File

@@ -2,7 +2,7 @@ import { useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, use
import { EventData, EventNames } from "../../components/app_context"; import { EventData, EventNames } from "../../components/app_context";
import { ParentComponent } from "./react_utils"; import { ParentComponent } from "./react_utils";
import SpacedUpdate from "../../services/spaced_update"; import SpacedUpdate from "../../services/spaced_update";
import { OptionNames } from "@triliumnext/commons"; import { KeyboardActionNames, OptionNames } from "@triliumnext/commons";
import options, { type OptionValue } from "../../services/options"; import options, { type OptionValue } from "../../services/options";
import utils, { reloadFrontendApp } from "../../services/utils"; import utils, { reloadFrontendApp } from "../../services/utils";
import NoteContext from "../../components/note_context"; import NoteContext from "../../components/note_context";
@@ -14,6 +14,7 @@ import NoteContextAwareWidget from "../note_context_aware_widget";
import { RefObject, VNode } from "preact"; import { RefObject, VNode } from "preact";
import { Tooltip } from "bootstrap"; import { Tooltip } from "bootstrap";
import { CSSProperties } from "preact/compat"; import { CSSProperties } from "preact/compat";
import keyboard_actions from "../../services/keyboard_actions";
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) { export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
@@ -502,9 +503,10 @@ export function useTooltip(elRef: RefObject<HTMLElement>, config: Partial<Toolti
* @param elRef the element to bind the tooltip to. * @param elRef the element to bind the tooltip to.
* @param config optionally, the tooltip configuration. * @param config optionally, the tooltip configuration.
*/ */
export function useStaticTooltip(elRef: RefObject<HTMLElement>, config?: Partial<Tooltip.Options>) { export function useStaticTooltip(elRef: RefObject<Element>, config?: Partial<Tooltip.Options>) {
useEffect(() => { useEffect(() => {
if (!elRef?.current) return; const hasTooltip = config?.title || elRef.current?.getAttribute("title");
if (!elRef?.current || !hasTooltip) return;
const $el = $(elRef.current); const $el = $(elRef.current);
$el.tooltip(config); $el.tooltip(config);
@@ -514,6 +516,19 @@ export function useStaticTooltip(elRef: RefObject<HTMLElement>, config?: Partial
}, [ elRef, config ]); }, [ elRef, config ]);
} }
export function useStaticTooltipWithKeyboardShortcut(elRef: RefObject<Element>, title: string, actionName: KeyboardActionNames | undefined) {
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
useStaticTooltip(elRef, {
title: keyboardShortcut?.length ? `${title} (${keyboardShortcut?.join(",")})` : title
});
useEffect(() => {
if (actionName) {
keyboard_actions.getAction(actionName).then(action => setKeyboardShortcut(action?.effectiveShortcuts));
}
}, [actionName]);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export function useLegacyImperativeHandlers(handlers: Record<string, Function>) { export function useLegacyImperativeHandlers(handlers: Record<string, Function>) {
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);

View File

@@ -41,7 +41,9 @@ export function disposeReactWidget(container: Element) {
render(null, container); render(null, container);
} }
export function joinElements(components: ComponentChild[], separator = ", ") { export function joinElements(components: ComponentChild[] | undefined, separator = ", ") {
if (!components) return <></>;
const joinedComponents: ComponentChild[] = []; const joinedComponents: ComponentChild[] = [];
for (let i=0; i<components.length; i++) { for (let i=0; i<components.length; i++) {
joinedComponents.push(components[i]); joinedComponents.push(components[i]);
@@ -50,5 +52,5 @@ export function joinElements(components: ComponentChild[], separator = ", ") {
} }
} }
return joinedComponents; return <>{joinedComponents}</>;
} }

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import { useNoteContext, useNoteProperty, useStaticTooltip, useTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks"; import { useNoteContext, useNoteProperty, useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks";
import "./style.css"; import "./style.css";
import { VNode } from "preact"; import { VNode } from "preact";
import BasicPropertiesTab from "./BasicPropertiesTab"; import BasicPropertiesTab from "./BasicPropertiesTab";
@@ -252,16 +252,7 @@ export default function Ribbon() {
function RibbonTab({ icon, title, active, onClick, toggleCommand }: { icon: string; title: string; active: boolean, onClick: () => void, toggleCommand?: KeyboardActionNames }) { function RibbonTab({ icon, title, active, onClick, toggleCommand }: { icon: string; title: string; active: boolean, onClick: () => void, toggleCommand?: KeyboardActionNames }) {
const iconRef = useRef<HTMLDivElement>(null); const iconRef = useRef<HTMLDivElement>(null);
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>(); useStaticTooltipWithKeyboardShortcut(iconRef, title, toggleCommand);
useStaticTooltip(iconRef, {
title: keyboardShortcut?.length ? `${title} (${keyboardShortcut?.join(",")})` : title
});
useEffect(() => {
if (toggleCommand) {
keyboard_actions.getAction(toggleCommand).then(action => setKeyboardShortcut(action?.effectiveShortcuts));
}
}, [toggleCommand]);
return ( return (
<> <>

View File

@@ -1,33 +0,0 @@
import NoteContextAwareWidget from "./note_context_aware_widget.js";
const TPL = /*html*/`<div class="scroll-padding-widget"></div>`;
export default class ScrollPaddingWidget extends NoteContextAwareWidget {
private $scrollingContainer!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && ["text", "code"].includes(this.note?.type ?? "");
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$widget.on("click", () => this.triggerCommand("scrollToEnd", { ntxId: this.ntxId }));
}
initialRenderCompleteEvent() {
this.$scrollingContainer = this.$widget.closest(".scrolling-container");
new ResizeObserver(() => this.refreshHeight()).observe(this.$scrollingContainer[0]);
this.refreshHeight();
}
refreshHeight() {
const containerHeight = this.$scrollingContainer.height();
this.$widget.css("height", Math.round((containerHeight ?? 0) / 2));
}
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useNoteContext } from "./react/hooks";
export default function ScrollPadding() {
const { note, parentComponent, ntxId } = useNoteContext();
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number>(10);
const isEnabled = ["text", "code"].includes(note?.type ?? "");
const refreshHeight = () => {
if (!ref.current) return;
const container = ref.current.closest(".scrolling-container") as HTMLElement | null;
if (!container) return;
setHeight(Math.round(container.offsetHeight / 2));
};
useEffect(() => {
if (!isEnabled) return;
const container = ref.current?.closest(".scrolling-container") as HTMLElement | null;
if (!container) return;
// Observe container resize
const observer = new ResizeObserver(() => refreshHeight());
observer.observe(container);
// Initial resize
refreshHeight();
return () => observer.disconnect();
}, [note]); // re-run when note changes
return (isEnabled ?
<div
ref={ref}
className="scroll-padding-widget"
style={{ height }}
onClick={() => parentComponent.triggerCommand("scrollToEnd", { ntxId })}
/>
: <div></div>
)
}

View File

@@ -0,0 +1,16 @@
.search-result-widget {
flex-grow: 100000;
flex-shrink: 100000;
min-height: 0;
overflow: auto;
contain: none !important;
}
.search-result-widget .note-list {
padding: 10px;
}
.search-no-results, .search-not-executed-yet {
margin: 20px;
padding: 20px !important;
}

View File

@@ -1,89 +0,0 @@
import { t } from "../services/i18n.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import NoteListRenderer from "../services/note_list_renderer.js";
import type FNote from "../entities/fnote.js";
import type { EventData } from "../components/app_context.js";
const TPL = /*html*/`
<div class="search-result-widget">
<style>
.search-result-widget {
flex-grow: 100000;
flex-shrink: 100000;
min-height: 0;
overflow: auto;
}
.search-result-widget .note-list {
padding: 10px;
}
.search-no-results, .search-not-executed-yet {
margin: 20px;
padding: 20px;
}
</style>
<div class="search-no-results alert alert-info">
${t("search_result.no_notes_found")}
</div>
<div class="search-not-executed-yet alert alert-info">
${t("search_result.search_not_executed")}
</div>
<div class="search-result-widget-content">
</div>
</div>`;
export default class SearchResultWidget extends NoteContextAwareWidget {
private $content!: JQuery<HTMLElement>;
private $noResults!: JQuery<HTMLElement>;
private $notExecutedYet!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && this.note?.type === "search";
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$content = this.$widget.find(".search-result-widget-content");
this.$noResults = this.$widget.find(".search-no-results");
this.$notExecutedYet = this.$widget.find(".search-not-executed-yet");
}
async refreshWithNote(note: FNote) {
const noResults = note.getChildNoteIds().length === 0 && !!note.searchResultsLoaded;
this.$content.empty();
this.$noResults.toggle(noResults);
this.$notExecutedYet.toggle(!note.searchResultsLoaded);
if (noResults || !note.searchResultsLoaded) {
return;
}
const noteListRenderer = new NoteListRenderer({
$parent: this.$content,
parentNote: note,
showNotePath: true
});
await noteListRenderer.renderList();
}
searchRefreshedEvent({ ntxId }: EventData<"searchRefreshed">) {
if (!this.isNoteContext(ntxId)) {
return;
}
this.refresh();
}
notesReloadedEvent({ noteIds }: EventData<"notesReloaded">) {
if (this.noteId && noteIds.includes(this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { t } from "../services/i18n";
import Alert from "./react/Alert";
import { useNoteContext, useNoteProperty, useTriliumEvent } from "./react/hooks";
import "./search_result.css";
import NoteListRenderer from "../services/note_list_renderer";
enum SearchResultState {
NO_RESULTS,
NOT_EXECUTED,
GOT_RESULTS
}
export default function SearchResult() {
const { note, ntxId } = useNoteContext();
const [ state, setState ] = useState<SearchResultState>();
const searchContainerRef = useRef<HTMLDivElement>(null);
function refresh() {
searchContainerRef.current?.replaceChildren();
if (note?.type !== "search") {
setState(undefined);
} else if (!note?.searchResultsLoaded) {
setState(SearchResultState.NOT_EXECUTED);
} else if (note.getChildNoteIds().length === 0) {
setState(SearchResultState.NO_RESULTS);
} else if (searchContainerRef.current) {
setState(SearchResultState.GOT_RESULTS);
const noteListRenderer = new NoteListRenderer({
$parent: $(searchContainerRef.current),
parentNote: note,
showNotePath: true
});
noteListRenderer.renderList();
}
}
useEffect(() => refresh(), [ note ]);
useTriliumEvent("searchRefreshed", ({ ntxId: eventNtxId }) => {
if (eventNtxId === ntxId) {
refresh();
}
});
useTriliumEvent("notesReloaded", ({ noteIds }) => {
if (note?.noteId && noteIds.includes(note.noteId)) {
refresh();
}
});
return (
<div className="search-result-widget">
{state === SearchResultState.NOT_EXECUTED && (
<Alert type="info" className="search-not-executed-yet">{t("search_result.search_not_executed")}</Alert>
)}
{state === SearchResultState.NO_RESULTS && (
<Alert type="info" className="search-no-results">{t("search_result.no_notes_found")}</Alert>
)}
<div ref={searchContainerRef} className="search-result-widget-content" />
</div>
);
}

View File

@@ -0,0 +1,7 @@
.sql-result-widget {
padding: 15px;
}
.sql-console-result-container td {
white-space: preserve;
}

View File

@@ -1,88 +0,0 @@
import type { EventData } from "../components/app_context.js";
import { t } from "../services/i18n.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
const TPL = /*html*/`
<div class="sql-result-widget">
<style>
.sql-result-widget {
padding: 15px;
}
.sql-console-result-container td {
white-space: preserve;
}
</style>
<div class="sql-query-no-rows alert alert-info" style="display: none;">
${t("sql_result.no_rows")}
</div>
<div class="sql-console-result-container"></div>
</div>`;
export default class SqlResultWidget extends NoteContextAwareWidget {
private $resultContainer!: JQuery<HTMLElement>;
private $noRowsAlert!: JQuery<HTMLElement>;
isEnabled() {
return this.note && this.note.mime === "text/x-sqlite;schema=trilium" && super.isEnabled();
}
doRender() {
this.$widget = $(TPL);
this.$resultContainer = this.$widget.find(".sql-console-result-container");
this.$noRowsAlert = this.$widget.find(".sql-query-no-rows");
}
async sqlQueryResultsEvent({ ntxId, results }: EventData<"sqlQueryResults">) {
if (!this.isNoteContext(ntxId)) {
return;
}
this.$noRowsAlert.toggle(results.length === 1 && results[0].length === 0);
this.$resultContainer.toggle(results.length > 1 || results[0].length > 0);
this.$resultContainer.empty();
for (const rows of results) {
if (typeof rows === "object" && !Array.isArray(rows)) {
// inserts, updates
this.$resultContainer
.empty()
.show()
.append($("<pre>").text(JSON.stringify(rows, null, "\t")));
continue;
}
if (!rows.length) {
continue;
}
const $table = $('<table class="table table-striped">');
this.$resultContainer.append($table);
const result = rows[0];
const $row = $("<tr>");
for (const key in result) {
$row.append($("<th>").text(key));
}
$table.append($row);
for (const result of rows) {
const $row = $("<tr>");
for (const key in result) {
$row.append($("<td>").text(result[key]));
}
$table.append($row);
}
}
}
}

View File

@@ -0,0 +1,62 @@
import { SqlExecuteResults } from "@triliumnext/commons";
import { useNoteContext, useTriliumEvent } from "./react/hooks";
import "./sql_result.css";
import { useState } from "preact/hooks";
import Alert from "./react/Alert";
import { t } from "../services/i18n";
export default function SqlResults() {
const { note, ntxId } = useNoteContext();
const [ results, setResults ] = useState<SqlExecuteResults>();
useTriliumEvent("sqlQueryResults", ({ ntxId: eventNtxId, results }) => {
if (eventNtxId !== ntxId) return;
setResults(results);
})
return (
<div className="sql-result-widget">
{note?.mime === "text/x-sqlite;schema=trilium" && (
results?.length === 1 && Array.isArray(results[0]) && results[0].length === 0 ? (
<Alert type="info">
{t("sql_result.no_rows")}
</Alert>
) : (
<div class="sql-console-result-container">
{results?.map(rows => {
// inserts, updates
if (typeof rows === "object" && !Array.isArray(rows)) {
return <pre>{JSON.stringify(rows, null, "\t")}</pre>
}
// selects
return <SqlResultTable rows={rows} />
})}
</div>
)
)}
</div>
)
}
function SqlResultTable({ rows }: { rows: object[] }) {
if (!rows.length) return;
return (
<table className="table table-striped">
<thead>
<tr>
{Object.keys(rows[0]).map(key => <th>{key}</th>)}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr>
{Object.values(row).map(cell => <td>{cell}</td>)}
</tr>
))}
</tbody>
</table>
)
}

View File

@@ -0,0 +1,43 @@
.sql-table-schemas-widget {
padding: 12px;
padding-right: 10%;
contain: none !important;
}
.sql-table-schemas > .dropdown {
display: inline-block !important;
}
.sql-table-schemas button.btn {
padding: 0.25rem 0.4rem;
font-size: 0.875rem;
line-height: 0.5;
border: 1px solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-text-color);
cursor: pointer;
}
.sql-console-result-container {
width: 100%;
font-size: smaller;
margin-top: 10px;
flex-grow: 1;
overflow: auto;
min-height: 0;
}
.table-schema td {
padding: 5px;
}
.dropdown .table-schema {
font-family: var(--monospace-font-family);
font-size: .85em;
}
/* Data type */
.dropdown .table-schema td:nth-child(2) {
color: var(--muted-text-color);
}

View File

@@ -1,94 +0,0 @@
import { t } from "../services/i18n.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import server from "../services/server.js";
import type FNote from "../entities/fnote.js";
const TPL = /*html*/`
<div class="sql-table-schemas-widget">
<style>
.sql-table-schemas-widget {
padding: 12px;
padding-right: 10%;
}
.sql-table-schemas button {
padding: 0.25rem 0.4rem;
font-size: 0.875rem;
line-height: 0.5;
border: 1px solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-text-color);
}
.sql-console-result-container {
width: 100%;
font-size: smaller;
margin-top: 10px;
flex-grow: 1;
overflow: auto;
min-height: 0;
}
.table-schema td {
padding: 5px;
}
</style>
${t("sql_table_schemas.tables")}:
<span class="sql-table-schemas"></span>
</div>`;
interface SchemaResponse {
name: string;
columns: {
name: string;
type: string;
}[];
}
export default class SqlTableSchemasWidget extends NoteContextAwareWidget {
private tableSchemasShown?: boolean;
private $sqlConsoleTableSchemas!: JQuery<HTMLElement>;
isEnabled() {
return this.note && this.note.mime === "text/x-sqlite;schema=trilium" && super.isEnabled();
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$sqlConsoleTableSchemas = this.$widget.find(".sql-table-schemas");
}
async refreshWithNote(note: FNote) {
if (this.tableSchemasShown) {
return;
}
this.tableSchemasShown = true;
const tableSchema = await server.get<SchemaResponse[]>("sql/schema");
for (const table of tableSchema) {
const $tableLink = $('<button class="btn">').text(table.name);
const $table = $('<table class="table-schema">');
for (const column of table.columns) {
$table.append($("<tr>").append($("<td>").text(column.name)).append($("<td>").text(column.type)));
}
this.$sqlConsoleTableSchemas.append($tableLink).append(" ");
$tableLink.tooltip({
html: true,
placement: "bottom",
title: $table[0].outerHTML,
sanitize: false
});
}
}
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from "preact/hooks";
import { t } from "../services/i18n";
import { useNoteContext } from "./react/hooks";
import "./sql_table_schemas.css";
import { SchemaResponse } from "@triliumnext/commons";
import server from "../services/server";
import Dropdown from "./react/Dropdown";
export default function SqlTableSchemas() {
const { note } = useNoteContext();
const [ schemas, setSchemas ] = useState<SchemaResponse[]>();
useEffect(() => {
server.get<SchemaResponse[]>("sql/schema").then(setSchemas);
}, []);
const isEnabled = note?.mime === "text/x-sqlite;schema=trilium" && schemas;
return (
<div className={`sql-table-schemas-widget ${!isEnabled ? "hidden-ext" : ""}`}>
{isEnabled && (
<>
{t("sql_table_schemas.tables")}{": "}
<span class="sql-table-schemas">
{schemas.map(({ name, columns }) => (
<>
<Dropdown text={name} noSelectButtonStyle hideToggleArrow
>
<table className="table-schema">
{columns.map(column => (
<tr>
<td>{column.name}</td>
<td>{column.type}</td>
</tr>
))}
</table>
</Dropdown>
{" "}
</>
))}
</span>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,30 @@
.component.title-bar-buttons {
flex-shrink: 0;
contain: none;
}
.title-bar-buttons div button {
border: none !important;
border-radius: 0;
background: none !important;
font-size: 150%;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.title-bar-buttons div:hover button {
background-color: var(--accented-background-color) !important;
}
.title-bar-buttons div {
display: inline-block;
height: 40px;
width: 40px;
}
.title-bar-buttons .btn.focus, .title-bar-buttons .btn:focus {
box-shadow: none;
}

View File

@@ -1,82 +0,0 @@
import BasicWidget from "./basic_widget.js";
import options from "../services/options.js";
import utils from "../services/utils.js";
const TPL = /*html*/`
<div class="title-bar-buttons">
<style>
.title-bar-buttons {
flex-shrink: 0;
}
.title-bar-buttons div button {
border: none !important;
border-radius: 0;
background: none !important;
font-size: 150%;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.title-bar-buttons div:hover button {
background-color: var(--accented-background-color) !important;
}
.title-bar-buttons div {
display: inline-block;
height: 40px;
width: 40px;
}
.title-bar-buttons .btn.focus, .title-bar-buttons .btn:focus {
box-shadow: none;
}
</style>
<!-- divs act as a hitbox for the buttons, making them clickable on corners -->
<div class="minimize-btn"><button class="btn bx bx-minus"></button></div>
<div class="maximize-btn"><button class="btn bx bx-checkbox"></button></div>
<div class="close-btn"><button class="btn bx bx-x"></button></div>
</div>`;
export default class TitleBarButtonsWidget extends BasicWidget {
doRender() {
if (!utils.isElectron() || options.is("nativeTitleBarVisible")) {
return (this.$widget = $("<div>"));
}
this.$widget = $(TPL);
this.contentSized();
const $minimizeBtn = this.$widget.find(".minimize-btn");
const $maximizeBtn = this.$widget.find(".maximize-btn");
const $closeBtn = this.$widget.find(".close-btn");
$minimizeBtn.on("click", () => {
$minimizeBtn.trigger("blur");
const remote = utils.dynamicRequire("@electron/remote");
remote.BrowserWindow.getFocusedWindow().minimize();
});
$maximizeBtn.on("click", () => {
$maximizeBtn.trigger("blur");
const remote = utils.dynamicRequire("@electron/remote");
const focusedWindow = remote.BrowserWindow.getFocusedWindow();
if (focusedWindow.isMaximized()) {
focusedWindow.unmaximize();
} else {
focusedWindow.maximize();
}
});
$closeBtn.on("click", () => {
$closeBtn.trigger("blur");
const remote = utils.dynamicRequire("@electron/remote");
remote.BrowserWindow.getFocusedWindow().close();
});
}
}

View File

@@ -0,0 +1,63 @@
import { dynamicRequire, isElectron } from "../services/utils";
import { useTriliumOption } from "./react/hooks";
import "./title_bar_buttons.css";
import type { BrowserWindow } from "electron";
interface TitleBarButtonProps {
className: string;
icon: string;
onClick: (context: {
window: BrowserWindow
}) => void;
}
export default function TitleBarButtons() {
const [ nativeTitleBarVisible ] = useTriliumOption("nativeTitleBarVisible");
const isEnabled = (isElectron() && nativeTitleBarVisible);
return (
<div className="title-bar-buttons">
{isEnabled && (
<>
<TitleBarButton
className="minimize-btn"
icon="bx bx-minus"
onClick={({ window }) => window.minimize()}
/>
<TitleBarButton
className="maximize-btn"
icon="bx bx-checkbox"
onClick={({ window }) => {
if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
}}
/>
<TitleBarButton
className="close-btn"
icon="bx bx-x"
onClick={({ window }) => window.close()}
/>
</>
)}
</div>
)
}
function TitleBarButton({ className, icon, onClick }: TitleBarButtonProps) {
// divs act as a hitbox for the buttons, making them clickable on corners
return (
<div className={className}>
<button className={`btn ${icon}`} onClick={() => {
const remote = dynamicRequire("@electron/remote");
const focusedWindow = remote.BrowserWindow.getFocusedWindow();
if (!focusedWindow) return;
onClick({ window: focusedWindow });
}} />
</div>
);
}

View File

@@ -0,0 +1,52 @@
.classic-toolbar-outer-container {
contain: none !important;
}
.classic-toolbar-outer-container.visible {
height: 38px;
background-color: var(--main-background-color);
position: relative;
overflow: visible;
flex-shrink: 0;
}
#root-widget.virtual-keyboard-opened .classic-toolbar-outer-container.ios {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.classic-toolbar-widget {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 38px;
overflow: scroll;
display: flex;
align-items: flex-end;
user-select: none;
}
.classic-toolbar-widget::-webkit-scrollbar {
height: 0 !important;
width: 0 !important;
}
.classic-toolbar-widget.dropdown-active {
height: 50vh;
}
.classic-toolbar-widget .ck.ck-toolbar {
--ck-color-toolbar-background: transparent;
--ck-color-button-default-background: transparent;
--ck-color-button-default-disabled-background: transparent;
position: absolute;
background-color: transparent;
border: none;
}
.classic-toolbar-widget .ck.ck-button.ck-disabled {
opacity: 0.3;
}

View File

@@ -1,130 +0,0 @@
import { isIOS } from "../../../services/utils.js";
import NoteContextAwareWidget from "../../note_context_aware_widget.js";
const TPL = /*html*/`\
<div class="classic-toolbar-outer-container">
<div class="classic-toolbar-widget"></div>
</div>
<style>
.classic-toolbar-outer-container.visible {
height: 38px;
background-color: var(--main-background-color);
position: relative;
overflow: visible;
flex-shrink: 0;
}
#root-widget.virtual-keyboard-opened .classic-toolbar-outer-container.ios {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.classic-toolbar-widget {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 38px;
overflow: scroll;
display: flex;
align-items: flex-end;
user-select: none;
}
.classic-toolbar-widget::-webkit-scrollbar {
height: 0 !important;
width: 0 !important;
}
.classic-toolbar-widget.dropdown-active {
height: 50vh;
}
.classic-toolbar-widget .ck.ck-toolbar {
--ck-color-toolbar-background: transparent;
--ck-color-button-default-background: transparent;
--ck-color-button-default-disabled-background: transparent;
position: absolute;
background-color: transparent;
border: none;
}
.classic-toolbar-widget .ck.ck-button.ck-disabled {
opacity: 0.3;
}
</style>
`;
/**
* Handles the editing toolbar for CKEditor in mobile mode. The toolbar acts as a floating bar, with two different mechanism:
*
* - On iOS, because it does not respect the viewport meta value `interactive-widget=resizes-content`, we need to listen to window resizes and scroll and reposition the toolbar using absolute positioning.
* - On Android, the viewport change makes the keyboard resize the content area, all we have to do is to hide the tab bar and global menu (handled in the global style).
*/
export default class MobileEditorToolbar extends NoteContextAwareWidget {
private observer: MutationObserver;
private $innerWrapper!: JQuery<HTMLElement>;
constructor() {
super();
this.observer = new MutationObserver((e) => this.#onDropdownStateChanged(e));
}
get name() {
return "classicEditor";
}
doRender() {
this.$widget = $(TPL);
this.$innerWrapper = this.$widget.find(".classic-toolbar-widget");
this.contentSized();
// Observe when a dropdown is expanded to apply a style that allows the dropdown to be visible, since we can't have the element both visible and the toolbar scrollable.
this.observer.disconnect();
this.observer.observe(this.$widget[0], {
attributeFilter: ["aria-expanded"],
subtree: true
});
if (isIOS()) {
this.#handlePositioningOniOS();
}
}
#handlePositioningOniOS() {
const adjustPosition = () => {
let bottom = window.innerHeight - (window.visualViewport?.height || 0);
this.$widget.css("bottom", `${bottom}px`);
}
this.$widget.addClass("ios");
window.visualViewport?.addEventListener("resize", adjustPosition);
window.addEventListener("scroll", adjustPosition);
}
#onDropdownStateChanged(e: MutationRecord[]) {
const dropdownActive = e.map((e) => (e.target as any).ariaExpanded === "true").reduce((acc, e) => acc && e);
this.$innerWrapper.toggleClass("dropdown-active", dropdownActive);
}
async #shouldDisplay() {
if (!this.note || this.note.type !== "text") {
return false;
}
if (await this.noteContext?.isReadOnly()) {
return false;
}
return true;
}
async refreshWithNote() {
this.toggleExt(await this.#shouldDisplay());
}
}

View File

@@ -0,0 +1,67 @@
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import { useNoteContext } from "../../react/hooks";
import "./mobile_editor_toolbar.css";
import { isIOS } from "../../../services/utils";
/**
* Handles the editing toolbar for CKEditor in mobile mode. The toolbar acts as a floating bar, with two different mechanism:
*
* - On iOS, because it does not respect the viewport meta value `interactive-widget=resizes-content`, we need to listen to window resizes and scroll and reposition the toolbar using absolute positioning.
* - On Android, the viewport change makes the keyboard resize the content area, all we have to do is to hide the tab bar and global menu (handled in the global style).
*/
export default function MobileEditorToolbar() {
const wrapperRef = useRef<HTMLDivElement>(null);
const { note, noteContext } = useNoteContext();
const [ shouldDisplay, setShouldDisplay ] = useState(false);
const [ dropdownActive, setDropdownActive ] = useState(false);
usePositioningOniOS(wrapperRef);
useEffect(() => {
noteContext?.isReadOnly().then(isReadOnly => {
setShouldDisplay(note?.type === "text" && !isReadOnly);
});
}, [ note ]);
// Observe when a dropdown is expanded to apply a style that allows the dropdown to be visible, since we can't have the element both visible and the toolbar scrollable.
useEffect(() => {
if (!wrapperRef.current) return;
const observer = new MutationObserver(e => {
setDropdownActive(e.map((e) => (e.target as any).ariaExpanded === "true").reduce((acc, e) => acc && e));
});
observer.observe(wrapperRef.current, {
attributeFilter: ["aria-expanded"],
subtree: true
});
return () => observer.disconnect();
}, []);
return (
<div className={`classic-toolbar-outer-container ${!shouldDisplay ? "hidden-ext" : "visible"} ${isIOS() ? "ios" : ""}`}>
<div ref={wrapperRef} className={`classic-toolbar-widget ${dropdownActive ? "dropdown-active" : ""}`}></div>
</div>
)
}
function usePositioningOniOS(wrapperRef: MutableRef<HTMLDivElement | null>) {
const adjustPosition = useCallback(() => {
if (!wrapperRef.current) return;
let bottom = window.innerHeight - (window.visualViewport?.height || 0);
wrapperRef.current.style.bottom = `${bottom}px`;
}, []);
useEffect(() => {
if (!isIOS()) return;
window.visualViewport?.addEventListener("resize", adjustPosition);
window.addEventListener("scroll", adjustPosition);
return () => {
window.visualViewport?.removeEventListener("resize", adjustPosition);
window.removeEventListener("scroll", adjustPosition);
};
}, []);
}

View File

@@ -12,7 +12,7 @@ import ValidationError from "../../errors/validation_error.js";
import blobService from "../../services/blob.js"; import blobService from "../../services/blob.js";
import type { Request } from "express"; import type { Request } from "express";
import type BBranch from "../../becca/entities/bbranch.js"; import type BBranch from "../../becca/entities/bbranch.js";
import type { AttributeRow, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons"; import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons";
/** /**
* @swagger * @swagger
@@ -123,7 +123,7 @@ function createNote(req: Request) {
return { return {
note, note,
branch branch
}; } satisfies CreateChildrenResponse;
} }
function updateNoteData(req: Request) { function updateNoteData(req: Request) {

View File

@@ -18,7 +18,7 @@ function getSchema() {
for (const tableName of tableNames) { for (const tableName of tableNames) {
tables.push({ tables.push({
name: tableName, name: tableName,
columns: sql.getRows(`PRAGMA table_info(${tableName})`) columns: sql.getRows<{ name: string; type: string; }>(`PRAGMA table_info(${tableName})`)
}); });
} }

View File

@@ -1,4 +1,4 @@
import { AttachmentRow, AttributeRow, NoteType } from "./rows.js"; import { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType } from "./rows.js";
type Response = { type Response = {
success: true, success: true,
@@ -220,3 +220,25 @@ export type BacklinksResponse = ({
noteId: string; noteId: string;
excerpts: string[] excerpts: string[]
})[]; })[];
export type SqlExecuteResults = (object[] | object)[];
export interface SqlExecuteResponse {
success: boolean;
error?: string;
results: SqlExecuteResults;
}
export interface CreateChildrenResponse {
note: NoteRow;
branch: BranchRow;
}
export interface SchemaResponse {
name: string;
columns: {
name: string;
type: string;
}[];
}