mirror of
https://github.com/TriliumNext/Trilium.git
synced 2025-12-10 21:07:05 -06:00
React context-aware widgets (ribbon, note actions, note title) (#6731)
This commit is contained in:
commit
2adfa55acd
@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{js,ts}]
|
||||
[*.{js,ts,.tsx}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
|
||||
5
.vscode/snippets.code-snippets
vendored
5
.vscode/snippets.code-snippets
vendored
@ -20,5 +20,10 @@
|
||||
"scope": "typescript",
|
||||
"prefix": "jqf",
|
||||
"body": ["private $${1:name}!: JQuery<HTMLElement>;"]
|
||||
},
|
||||
"region": {
|
||||
"scope": "css",
|
||||
"prefix": "region",
|
||||
"body": ["/* #region ${1:name} */\n$0\n/* #endregion */"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,16 +31,13 @@ import { StartupChecks } from "./startup_checks.js";
|
||||
import type { CreateNoteOpts } from "../services/note_create.js";
|
||||
import { ColumnComponent } from "tabulator-tables";
|
||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
getRootWidget: (appContext: AppContext) => RootContainer;
|
||||
}
|
||||
|
||||
interface RootWidget extends Component {
|
||||
render: () => JQuery<HTMLElement>;
|
||||
}
|
||||
|
||||
interface BeforeUploadListener extends Component {
|
||||
export interface BeforeUploadListener extends Component {
|
||||
beforeUnloadEvent(): boolean;
|
||||
}
|
||||
|
||||
@ -85,7 +82,6 @@ export type CommandMappings = {
|
||||
focusTree: CommandData;
|
||||
focusOnTitle: CommandData;
|
||||
focusOnDetail: CommandData;
|
||||
focusOnSearchDefinition: Required<CommandData>;
|
||||
searchNotes: CommandData & {
|
||||
searchString?: string;
|
||||
ancestorNoteId?: string | null;
|
||||
@ -323,6 +319,7 @@ export type CommandMappings = {
|
||||
printActiveNote: CommandData;
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
openNoteCustom: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
@ -526,7 +523,7 @@ export type FilteredCommandNames<T extends CommandData> = keyof Pick<CommandMapp
|
||||
export class AppContext extends Component {
|
||||
isMainWindow: boolean;
|
||||
components: Component[];
|
||||
beforeUnloadListeners: WeakRef<BeforeUploadListener>[];
|
||||
beforeUnloadListeners: (WeakRef<BeforeUploadListener> | (() => boolean))[];
|
||||
tabManager!: TabManager;
|
||||
layout?: Layout;
|
||||
noteTreeWidget?: NoteTreeWidget;
|
||||
@ -619,7 +616,7 @@ export class AppContext extends Component {
|
||||
component.triggerCommand(commandName, { $el: $(this) });
|
||||
});
|
||||
|
||||
this.child(rootWidget);
|
||||
this.child(rootWidget as Component);
|
||||
|
||||
this.triggerEvent("initialRenderComplete", {});
|
||||
}
|
||||
@ -649,13 +646,17 @@ export class AppContext extends Component {
|
||||
return $(el).closest(".component").prop("component");
|
||||
}
|
||||
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener) {
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) {
|
||||
if (typeof WeakRef !== "function") {
|
||||
// older browsers don't support WeakRef
|
||||
return;
|
||||
}
|
||||
|
||||
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
|
||||
if (typeof obj === "object") {
|
||||
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
|
||||
} else {
|
||||
this.beforeUnloadListeners.push(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -665,25 +666,29 @@ const appContext = new AppContext(window.glob.isMainWindow);
|
||||
$(window).on("beforeunload", () => {
|
||||
let allSaved = true;
|
||||
|
||||
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => !!wr.deref());
|
||||
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => typeof wr === "function" || !!wr.deref());
|
||||
|
||||
for (const weakRef of appContext.beforeUnloadListeners) {
|
||||
const component = weakRef.deref();
|
||||
for (const listener of appContext.beforeUnloadListeners) {
|
||||
if (typeof listener === "object") {
|
||||
const component = listener.deref();
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
|
||||
allSaved = false;
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
allSaved = false;
|
||||
}
|
||||
} else {
|
||||
if (!listener()) {
|
||||
allSaved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!allSaved) {
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
return "some string";
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import utils from "../services/utils.js";
|
||||
import type { CommandMappings, CommandNames, EventData, EventNames } from "./app_context.js";
|
||||
|
||||
type EventHandler = ((data: any) => void);
|
||||
|
||||
/**
|
||||
* Abstract class for all components in the Trilium's frontend.
|
||||
*
|
||||
@ -19,6 +21,7 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
initialized: Promise<void> | null;
|
||||
parent?: TypedComponent<any>;
|
||||
_position!: number;
|
||||
private listeners: Record<string, EventHandler[]> | null = {};
|
||||
|
||||
constructor() {
|
||||
this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;
|
||||
@ -76,6 +79,14 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// Handle React children.
|
||||
if (this.listeners?.[name]) {
|
||||
for (const listener of this.listeners[name]) {
|
||||
listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle legacy children.
|
||||
for (const child of this.children) {
|
||||
const ret = child.handleEvent(name, data) as Promise<void>;
|
||||
|
||||
@ -120,6 +131,35 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
registerHandler<T extends EventNames>(name: T, handler: EventHandler) {
|
||||
if (!this.listeners) {
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
if (!this.listeners[name]) {
|
||||
this.listeners[name] = [];
|
||||
}
|
||||
|
||||
if (this.listeners[name].includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[name].push(handler);
|
||||
}
|
||||
|
||||
removeHandler<T extends EventNames>(name: T, handler: EventHandler) {
|
||||
if (!this.listeners?.[name]?.includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[name] = this.listeners[name]
|
||||
.filter(listener => listener !== handler);
|
||||
|
||||
if (!this.listeners[name].length) {
|
||||
delete this.listeners[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class Component extends TypedComponent<Component> {}
|
||||
|
||||
@ -43,8 +43,6 @@ export default class RootCommandExecutor extends Component {
|
||||
const noteContext = await appContext.tabManager.openTabWithNoteWithHoisting(searchNote.noteId, {
|
||||
activate: true
|
||||
});
|
||||
|
||||
appContext.triggerCommand("focusOnSearchDefinition", { ntxId: noteContext.ntxId });
|
||||
}
|
||||
|
||||
async searchInSubtreeCommand({ notePath }: CommandListenerData<"searchInSubtree">) {
|
||||
|
||||
@ -8,7 +8,6 @@ import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
import options from "./services/options.js";
|
||||
import server from "./services/server.js";
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
|
||||
@ -1020,6 +1020,14 @@ class FNote {
|
||||
return this.noteId.startsWith("_options");
|
||||
}
|
||||
|
||||
isTriliumSqlite() {
|
||||
return this.mime === "text/x-sqlite;schema=trilium";
|
||||
}
|
||||
|
||||
isTriliumScript() {
|
||||
return this.mime.startsWith("application/javascript");
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides note's date metadata.
|
||||
*/
|
||||
|
||||
@ -4,21 +4,13 @@ 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 NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import OwnedAttributeListWidget from "../widgets/ribbon_widgets/owned_attribute_list.js";
|
||||
import NoteActionsWidget from "../widgets/buttons/note_actions.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import RibbonContainer from "../widgets/containers/ribbon_container.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import InheritedAttributesWidget from "../widgets/ribbon_widgets/inherited_attribute_list.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import SearchDefinitionWidget from "../widgets/ribbon_widgets/search_definition.js";
|
||||
import SqlResultWidget from "../widgets/sql_result.js";
|
||||
import SqlTableSchemasWidget from "../widgets/sql_table_schemas.js";
|
||||
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
|
||||
import ImagePropertiesWidget from "../widgets/ribbon_widgets/image_properties.js";
|
||||
import NotePropertiesWidget from "../widgets/ribbon_widgets/note_properties.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import SearchResultWidget from "../widgets/search_result.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
@ -29,15 +21,8 @@ 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 ClosePaneButton from "../widgets/buttons/close_pane_button.js";
|
||||
import BasicPropertiesWidget from "../widgets/ribbon_widgets/basic_properties.js";
|
||||
import NoteInfoWidget from "../widgets/ribbon_widgets/note_info_widget.js";
|
||||
import BookPropertiesWidget from "../widgets/ribbon_widgets/book_properties.js";
|
||||
import NoteMapRibbonWidget from "../widgets/ribbon_widgets/note_map.js";
|
||||
import NotePathsWidget from "../widgets/ribbon_widgets/note_paths.js";
|
||||
import SimilarNotesWidget from "../widgets/ribbon_widgets/similar_notes.js";
|
||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||
import EditButton from "../widgets/floating_buttons/edit_button.js";
|
||||
import EditedNotesWidget from "../widgets/ribbon_widgets/edited_notes.js";
|
||||
import ShowTocWidgetButton from "../widgets/buttons/show_toc_widget_button.js";
|
||||
import ShowHighlightsListWidgetButton from "../widgets/buttons/show_highlights_list_widget_button.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
@ -51,16 +36,13 @@ import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
|
||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
|
||||
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RevisionsButton from "../widgets/buttons/revisions_button.js";
|
||||
import CodeButtonsWidget from "../widgets/floating_buttons/code_buttons.js";
|
||||
import ApiLogWidget from "../widgets/api_log.js";
|
||||
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
|
||||
import ScriptExecutorWidget from "../widgets/ribbon_widgets/script_executor.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_reference_button.js";
|
||||
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
import options from "../services/options.js";
|
||||
import utils from "../services/utils.js";
|
||||
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
|
||||
@ -73,6 +55,7 @@ import ToggleReadOnlyButton from "../widgets/floating_buttons/toggle_read_only_b
|
||||
import PngExportButton from "../widgets/floating_buttons/png_export_button.js";
|
||||
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@ -151,37 +134,15 @@ export default class DesktopLayout {
|
||||
.css("min-height", "50px")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(new NoteIconWidget())
|
||||
.child(new NoteTitleWidget())
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(new SpacerWidget(0, 1))
|
||||
.child(new MovePaneButton(true))
|
||||
.child(new MovePaneButton(false))
|
||||
.child(new ClosePaneButton())
|
||||
.child(new CreatePaneButton())
|
||||
)
|
||||
.child(
|
||||
new RibbonContainer()
|
||||
// the order of the widgets matter. Some of these want to "activate" themselves
|
||||
// when visible. When this happens to multiple of them, the first one "wins".
|
||||
// promoted attributes should always win.
|
||||
.ribbon(new ClassicEditorToolbar())
|
||||
.ribbon(new ScriptExecutorWidget())
|
||||
.ribbon(new SearchDefinitionWidget())
|
||||
.ribbon(new EditedNotesWidget())
|
||||
.ribbon(new BookPropertiesWidget())
|
||||
.ribbon(new NotePropertiesWidget())
|
||||
.ribbon(new FilePropertiesWidget())
|
||||
.ribbon(new ImagePropertiesWidget())
|
||||
.ribbon(new BasicPropertiesWidget())
|
||||
.ribbon(new OwnedAttributeListWidget())
|
||||
.ribbon(new InheritedAttributesWidget())
|
||||
.ribbon(new NotePathsWidget())
|
||||
.ribbon(new NoteMapRibbonWidget())
|
||||
.ribbon(new SimilarNotesWidget())
|
||||
.ribbon(new NoteInfoWidget())
|
||||
.button(new RevisionsButton())
|
||||
.button(new NoteActionsWidget())
|
||||
)
|
||||
.child(<Ribbon />)
|
||||
.child(new SharedInfoWidget())
|
||||
.child(new WatchedFileUpdateStatusWidget())
|
||||
.child(
|
||||
@ -235,8 +196,8 @@ export default class DesktopLayout {
|
||||
.child(new CloseZenButton())
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
.child(new PasswordNoteSetDialog())
|
||||
.child(new UploadAttachmentsDialog());
|
||||
.child(<PasswordNoteSetDialog />)
|
||||
.child(<UploadAttachmentsDialog />);
|
||||
|
||||
applyModals(rootContainer);
|
||||
return rootContainer;
|
||||
@ -24,48 +24,48 @@ import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import NoteIconWidget from "../widgets/note_icon";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import { CallToActionDialog } from "../widgets/dialogs/call_to_action.jsx";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
.child(new BulkActionsDialog())
|
||||
.child(new AboutDialog())
|
||||
.child(new HelpDialog())
|
||||
.child(new RecentChangesDialog())
|
||||
.child(new BranchPrefixDialog())
|
||||
.child(new SortChildNotesDialog())
|
||||
.child(new IncludeNoteDialog())
|
||||
.child(new NoteTypeChooserDialog())
|
||||
.child(new JumpToNoteDialog())
|
||||
.child(new AddLinkDialog())
|
||||
.child(new CloneToDialog())
|
||||
.child(new MoveToDialog())
|
||||
.child(new ImportDialog())
|
||||
.child(new ExportDialog())
|
||||
.child(new MarkdownImportDialog())
|
||||
.child(new ProtectedSessionPasswordDialog())
|
||||
.child(new RevisionsDialog())
|
||||
.child(new DeleteNotesDialog())
|
||||
.child(new InfoDialog())
|
||||
.child(new ConfirmDialog())
|
||||
.child(new PromptDialog())
|
||||
.child(new IncorrectCpuArchDialog())
|
||||
.child(<BulkActionsDialog />)
|
||||
.child(<AboutDialog />)
|
||||
.child(<HelpDialog />)
|
||||
.child(<RecentChangesDialog />)
|
||||
.child(<BranchPrefixDialog />)
|
||||
.child(<SortChildNotesDialog />)
|
||||
.child(<IncludeNoteDialog />)
|
||||
.child(<NoteTypeChooserDialog />)
|
||||
.child(<JumpToNoteDialog />)
|
||||
.child(<AddLinkDialog />)
|
||||
.child(<CloneToDialog />)
|
||||
.child(<MoveToDialog />)
|
||||
.child(<ImportDialog />)
|
||||
.child(<ExportDialog />)
|
||||
.child(<MarkdownImportDialog />)
|
||||
.child(<ProtectedSessionPasswordDialog />)
|
||||
.child(<RevisionsDialog />)
|
||||
.child(<DeleteNotesDialog />)
|
||||
.child(<InfoDialog />)
|
||||
.child(<ConfirmDialog />)
|
||||
.child(<PromptDialog />)
|
||||
.child(<IncorrectCpuArchDialog />)
|
||||
.child(new PopupEditorDialog()
|
||||
.child(new FlexContainer("row")
|
||||
.class("title-row")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(new NoteIconWidget())
|
||||
.child(new NoteTitleWidget()))
|
||||
.child(new ClassicEditorToolbar())
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />))
|
||||
.child(<PopupEditorFormattingToolbar />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(true)))
|
||||
.child(new CallToActionDialog());
|
||||
.child(<CallToActionDialog />);
|
||||
}
|
||||
@ -7,7 +7,6 @@ import ToggleSidebarButtonWidget from "../widgets/mobile_widgets/toggle_sidebar_
|
||||
import MobileDetailMenuWidget from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
|
||||
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
|
||||
import EditButton from "../widgets/floating_buttons/edit_button.js";
|
||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
|
||||
@ -19,14 +18,18 @@ import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
|
||||
import MobileEditorToolbar from "../widgets/ribbon_widgets/mobile_editor_toolbar.js";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import CloseZenButton from "../widgets/close_zen_button.js";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { ParentComponent } from "../widgets/react/react_utils.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@ -144,7 +147,7 @@ export default class MobileLayout {
|
||||
.css("font-size", "larger")
|
||||
.css("align-items", "center")
|
||||
.child(new ToggleSidebarButtonWidget().contentSized())
|
||||
.child(new NoteTitleWidget().contentSized().css("position", "relative").css("padding-left", "0.5em"))
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(new MobileDetailMenuWidget(true).contentSized())
|
||||
)
|
||||
.child(new SharedInfoWidget())
|
||||
@ -164,7 +167,7 @@ export default class MobileLayout {
|
||||
.contentSized()
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(false))
|
||||
.child(new FilePropertiesWidget().css("font-size", "smaller"))
|
||||
.child(<FilePropertiesWrapper />)
|
||||
)
|
||||
.child(new MobileEditorToolbar())
|
||||
)
|
||||
@ -181,3 +184,13 @@ export default class MobileLayout {
|
||||
return rootContainer;
|
||||
}
|
||||
}
|
||||
|
||||
function FilePropertiesWrapper() {
|
||||
const { note } = useNoteContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{note?.type === "file" && <FilePropertiesTab note={note} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import server from "./server.js";
|
||||
import froca from "./froca.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { AttributeRow } from "./load_results.js";
|
||||
import { AttributeType } from "@triliumnext/commons";
|
||||
|
||||
async function addLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/attribute`, {
|
||||
@ -25,6 +26,14 @@ async function removeAttributeById(noteId: string, attributeId: string) {
|
||||
await server.remove(`notes/${noteId}/attributes/${attributeId}`);
|
||||
}
|
||||
|
||||
export async function removeOwnedAttributesByNameOrType(note: FNote, type: AttributeType, name: string) {
|
||||
for (const attr of note.getOwnedAttributes()) {
|
||||
if (attr.type === type && attr.name === name) {
|
||||
await server.remove(`notes/${note.noteId}/attributes/${attr.attributeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a label identified by its name from the given note, if it exists. Note that the label must be owned, i.e.
|
||||
* it will not remove inherited attributes.
|
||||
@ -52,7 +61,7 @@ function removeOwnedLabelByName(note: FNote, labelName: string) {
|
||||
* @param value the value of the attribute to set.
|
||||
*/
|
||||
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
|
||||
if (value) {
|
||||
if (value !== null && value !== undefined) {
|
||||
// Create or update the attribute.
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
|
||||
} else {
|
||||
|
||||
@ -18,7 +18,7 @@ import type FNote from "../entities/fnote.js";
|
||||
import toast from "./toast.js";
|
||||
import { BulkAction } from "@triliumnext/commons";
|
||||
|
||||
const ACTION_GROUPS = [
|
||||
export const ACTION_GROUPS = [
|
||||
{
|
||||
title: t("bulk_actions.labels"),
|
||||
actions: [AddLabelBulkAction, UpdateLabelValueBulkAction, RenameLabelBulkAction, DeleteLabelBulkAction]
|
||||
|
||||
@ -35,7 +35,7 @@ function download(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFileNote(noteId: string) {
|
||||
export function downloadFileNote(noteId: string) {
|
||||
const url = `${getFileUrl("notes", noteId)}?${Date.now()}`; // don't use cache
|
||||
|
||||
download(url);
|
||||
@ -163,7 +163,7 @@ async function openExternally(type: string, entityId: string, mime: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime);
|
||||
export const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime);
|
||||
const openAttachmentExternally = async (attachmentId: string, mime: string) => await openExternally("attachments", attachmentId, mime);
|
||||
|
||||
function getHost() {
|
||||
|
||||
@ -148,7 +148,7 @@ export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
export function isMac() {
|
||||
return navigator.platform.indexOf("Mac") > -1;
|
||||
}
|
||||
|
||||
@ -185,7 +185,11 @@ export function escapeQuotes(value: string) {
|
||||
return value.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function formatSize(size: number) {
|
||||
export function formatSize(size: number | null | undefined) {
|
||||
if (size === null || size === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
size = Math.max(Math.round(size / 1024), 1);
|
||||
|
||||
if (size < 1024) {
|
||||
@ -292,7 +296,7 @@ function isHtmlEmpty(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function clearBrowserCache() {
|
||||
export async function clearBrowserCache() {
|
||||
if (isElectron()) {
|
||||
const win = dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
await win.webContents.session.clearCache();
|
||||
@ -740,7 +744,7 @@ function isUpdateAvailable(latestVersion: string | null | undefined, currentVers
|
||||
return compareVersions(latestVersion, currentVersion) > 0;
|
||||
}
|
||||
|
||||
function isLaunchBarConfig(noteId: string) {
|
||||
export function isLaunchBarConfig(noteId: string) {
|
||||
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
|
||||
}
|
||||
|
||||
@ -788,6 +792,38 @@ export function arrayEqual<T>(a: T[], b: T[]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
type Indexed<T extends object> = T & { index: number };
|
||||
|
||||
/**
|
||||
* Given an object array, alters every object in the array to have an index field assigned to it.
|
||||
*
|
||||
* @param items the objects to be numbered.
|
||||
* @returns the same object for convenience, with the type changed to indicate the new index field.
|
||||
*/
|
||||
export function numberObjectsInPlace<T extends object>(items: T[]): Indexed<T>[] {
|
||||
let index = 0;
|
||||
for (const item of items) {
|
||||
(item as Indexed<T>).index = index++;
|
||||
}
|
||||
return items as Indexed<T>[];
|
||||
}
|
||||
|
||||
export function mapToKeyValueArray<K extends string | number | symbol, V>(map: Record<K, V>) {
|
||||
const values: { key: K, value: V }[] = [];
|
||||
for (const [ key, value ] of Object.entries(map)) {
|
||||
values.push({ key: key as K, value: value as V });
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getErrorMessage(e: unknown) {
|
||||
if (e && typeof e === "object" && "message" in e && typeof e.message === "string") {
|
||||
return e.message;
|
||||
} else {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
restartDesktopApp,
|
||||
|
||||
@ -848,7 +848,7 @@
|
||||
"debug": "调试",
|
||||
"debug_description": "调试将打印额外的调试信息到控制台,以帮助调试复杂查询",
|
||||
"action": "操作",
|
||||
"search_button": "搜索 <kbd>回车</kbd>",
|
||||
"search_button": "搜索",
|
||||
"search_execute": "搜索并执行操作",
|
||||
"save_to_note": "保存到笔记",
|
||||
"search_parameters": "搜索参数",
|
||||
|
||||
@ -845,7 +845,7 @@
|
||||
"debug": "debuggen",
|
||||
"debug_description": "Debug gibt zusätzliche Debuginformationen in die Konsole aus, um das Debuggen komplexer Abfragen zu erleichtern",
|
||||
"action": "Aktion",
|
||||
"search_button": "Suchen <kbd>Eingabetaste</kbd>",
|
||||
"search_button": "Suchen",
|
||||
"search_execute": "Aktionen suchen und ausführen",
|
||||
"save_to_note": "Als Notiz speichern",
|
||||
"search_parameters": "Suchparameter",
|
||||
|
||||
@ -732,7 +732,8 @@
|
||||
"note_type": "Note type",
|
||||
"editable": "Editable",
|
||||
"basic_properties": "Basic Properties",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"configure_code_notes": "Configure code notes..."
|
||||
},
|
||||
"book_properties": {
|
||||
"view_type": "View type",
|
||||
@ -848,7 +849,7 @@
|
||||
"debug": "debug",
|
||||
"debug_description": "Debug will print extra debugging information into the console to aid in debugging complex queries",
|
||||
"action": "action",
|
||||
"search_button": "Search <kbd>enter</kbd>",
|
||||
"search_button": "Search",
|
||||
"search_execute": "Search & Execute actions",
|
||||
"save_to_note": "Save to note",
|
||||
"search_parameters": "Search Parameters",
|
||||
|
||||
@ -848,7 +848,7 @@
|
||||
"debug": "depurar",
|
||||
"debug_description": "La depuración imprimirá información de depuración adicional en la consola para ayudar a depurar consultas complejas",
|
||||
"action": "acción",
|
||||
"search_button": "Buscar <kbd>Enter</kbd>",
|
||||
"search_button": "Buscar",
|
||||
"search_execute": "Buscar y ejecutar acciones",
|
||||
"save_to_note": "Guardar en nota",
|
||||
"search_parameters": "Parámetros de búsqueda",
|
||||
|
||||
@ -848,7 +848,7 @@
|
||||
"debug": "debug",
|
||||
"debug_description": "Debug imprimera des informations supplémentaires dans la console pour faciliter le débogage des requêtes complexes",
|
||||
"action": "action",
|
||||
"search_button": "Recherche <kbd>Entrée</kbd>",
|
||||
"search_button": "Recherche",
|
||||
"search_execute": "Rechercher et exécuter des actions",
|
||||
"save_to_note": "Enregistrer dans la note",
|
||||
"search_parameters": "Paramètres de recherche",
|
||||
|
||||
@ -185,7 +185,7 @@
|
||||
"debug": "デバッグ",
|
||||
"debug_description": "デバッグは複雑なクエリのデバッグを支援するために、追加のデバッグ情報をコンソールに出力します",
|
||||
"action": "アクション",
|
||||
"search_button": "検索 <kbd>Enter</kbd>",
|
||||
"search_button": "検索",
|
||||
"search_execute": "検索とアクションの実行",
|
||||
"save_to_note": "ノートに保存",
|
||||
"search_parameters": "検索パラメータ",
|
||||
|
||||
@ -1025,7 +1025,7 @@
|
||||
"limit_description": "Limitar número de resultados",
|
||||
"debug": "depurar",
|
||||
"action": "ação",
|
||||
"search_button": "Pesquisar <kbd>enter</kbd>",
|
||||
"search_button": "Pesquisar",
|
||||
"search_execute": "Pesquisar & Executar ações",
|
||||
"save_to_note": "Salvar para nota",
|
||||
"search_parameters": "Parâmetros de Pesquisa",
|
||||
|
||||
@ -1106,7 +1106,7 @@
|
||||
"limit_description": "Limitează numărul de rezultate",
|
||||
"order_by": "ordonează după",
|
||||
"save_to_note": "Salvează în notiță",
|
||||
"search_button": "Căutare <kbd>Enter</kbd>",
|
||||
"search_button": "Căutare",
|
||||
"search_execute": "Caută și execută acțiunile",
|
||||
"search_note_saved": "Notița de căutare a fost salvată în {{- notePathTitle}}",
|
||||
"search_parameters": "Parametrii de căutare",
|
||||
|
||||
@ -1060,7 +1060,7 @@
|
||||
"fast_search": "быстрый поиск",
|
||||
"include_archived": "включать архивированные",
|
||||
"order_by": "сортировать по",
|
||||
"search_button": "Поиск <kbd>enter</kbd>",
|
||||
"search_button": "Поиск",
|
||||
"search_parameters": "Параметры поиска",
|
||||
"ancestor": "предок",
|
||||
"action": "действие",
|
||||
|
||||
@ -845,7 +845,7 @@
|
||||
"debug": "除錯",
|
||||
"debug_description": "除錯將顯示額外的除錯資訊至控制台,以幫助除錯複雜查詢",
|
||||
"action": "操作",
|
||||
"search_button": "搜尋 <kbd>Enter</kbd>",
|
||||
"search_button": "搜尋",
|
||||
"search_execute": "搜尋並執行操作",
|
||||
"save_to_note": "儲存至筆記",
|
||||
"search_parameters": "搜尋參數",
|
||||
|
||||
@ -960,7 +960,7 @@
|
||||
"debug": "debug",
|
||||
"debug_description": "Debug виведе додаткову інформацію для налагодження в консоль, щоб допомогти у налагодженні складних запитів",
|
||||
"action": "дія",
|
||||
"search_button": "Пошук <kbd>enter</kbd>",
|
||||
"search_button": "Пошук",
|
||||
"search_execute": "Пошук & Виконання дій",
|
||||
"save_to_note": "Зберегти до нотатки",
|
||||
"search_parameters": "Параметри пошуку",
|
||||
|
||||
@ -3,7 +3,11 @@ type DateTimeStyle = "full" | "long" | "medium" | "short" | "none" | undefined;
|
||||
/**
|
||||
* Formats the given date and time to a string based on the current locale.
|
||||
*/
|
||||
export function formatDateTime(date: string | Date | number, dateStyle: DateTimeStyle = "medium", timeStyle: DateTimeStyle = "medium") {
|
||||
export function formatDateTime(date: string | Date | number | null | undefined, dateStyle: DateTimeStyle = "medium", timeStyle: DateTimeStyle = "medium") {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const locale = navigator.language;
|
||||
|
||||
let parsedDate;
|
||||
|
||||
@ -1,504 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import noteAutocompleteService, { type Suggestion } from "../../services/note_autocomplete.js";
|
||||
import server from "../../services/server.js";
|
||||
import contextMenuService from "../../menus/context_menu.js";
|
||||
import attributeParser, { type Attribute } from "../../services/attribute_parser.js";
|
||||
import { AttributeEditor, type EditorConfig, type ModelElement, type MentionFeed, type ModelNode, type ModelPosition } from "@triliumnext/ckeditor5";
|
||||
import froca from "../../services/froca.js";
|
||||
import attributeRenderer from "../../services/attribute_renderer.js";
|
||||
import noteCreateService from "../../services/note_create.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import type AttributeDetailWidget from "./attribute_detail.js";
|
||||
import type { CommandData, EventData, EventListener, FilteredCommandNames } from "../../components/app_context.js";
|
||||
import type { default as FAttribute, AttributeType } from "../../entities/fattribute.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import { escapeQuotes } from "../../services/utils.js";
|
||||
|
||||
const HELP_TEXT = `
|
||||
<p>${t("attribute_editor.help_text_body1")}</p>
|
||||
|
||||
<p>${t("attribute_editor.help_text_body2")}</p>
|
||||
|
||||
<p>${t("attribute_editor.help_text_body3")}</p>`;
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div style="position: relative; padding-top: 10px; padding-bottom: 10px">
|
||||
<style>
|
||||
.attribute-list-editor {
|
||||
border: 0 !important;
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 0 0 5px !important;
|
||||
margin: 0 !important;
|
||||
max-height: 100px;
|
||||
overflow: auto;
|
||||
transition: opacity .1s linear;
|
||||
}
|
||||
|
||||
.attribute-list-editor.ck-content .mention {
|
||||
color: var(--muted-text-color) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.save-attributes-button {
|
||||
color: var(--muted-text-color);
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: 25px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.add-new-attribute-button {
|
||||
color: var(--muted-text-color);
|
||||
position: absolute;
|
||||
bottom: 13px;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.add-new-attribute-button:hover, .save-attributes-button:hover {
|
||||
border: 1px solid var(--button-border-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
background: var(--button-background-color);
|
||||
color: var(--button-text-color);
|
||||
}
|
||||
|
||||
.attribute-errors {
|
||||
color: red;
|
||||
padding: 5px 50px 0px 5px; /* large right padding to avoid buttons */
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="attribute-list-editor" tabindex="200"></div>
|
||||
|
||||
<div class="bx bx-save save-attributes-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.save_attributes"))}"></div>
|
||||
<div class="bx bx-plus add-new-attribute-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.add_a_new_attribute"))}"></div>
|
||||
|
||||
<div class="attribute-errors" style="display: none;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const mentionSetup: MentionFeed[] = [
|
||||
{
|
||||
marker: "@",
|
||||
feed: (queryText) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
|
||||
itemRenderer: (_item) => {
|
||||
const item = _item as Suggestion;
|
||||
const itemElement = document.createElement("button");
|
||||
|
||||
itemElement.innerHTML = `${item.highlightedNotePathTitle} `;
|
||||
|
||||
return itemElement;
|
||||
},
|
||||
minimumCharacters: 0
|
||||
},
|
||||
{
|
||||
marker: "#",
|
||||
feed: async (queryText) => {
|
||||
const names = await server.get<string[]>(`attribute-names/?type=label&query=${encodeURIComponent(queryText)}`);
|
||||
|
||||
return names.map((name) => {
|
||||
return {
|
||||
id: `#${name}`,
|
||||
name: name
|
||||
};
|
||||
});
|
||||
},
|
||||
minimumCharacters: 0
|
||||
},
|
||||
{
|
||||
marker: "~",
|
||||
feed: async (queryText) => {
|
||||
const names = await server.get<string[]>(`attribute-names/?type=relation&query=${encodeURIComponent(queryText)}`);
|
||||
|
||||
return names.map((name) => {
|
||||
return {
|
||||
id: `~${name}`,
|
||||
name: name
|
||||
};
|
||||
});
|
||||
},
|
||||
minimumCharacters: 0
|
||||
}
|
||||
];
|
||||
|
||||
const editorConfig: EditorConfig = {
|
||||
toolbar: {
|
||||
items: []
|
||||
},
|
||||
placeholder: t("attribute_editor.placeholder"),
|
||||
mention: {
|
||||
feeds: mentionSetup
|
||||
},
|
||||
licenseKey: "GPL"
|
||||
};
|
||||
|
||||
type AttributeCommandNames = FilteredCommandNames<CommandData>;
|
||||
|
||||
export default class AttributeEditorWidget extends NoteContextAwareWidget implements EventListener<"entitiesReloaded">, EventListener<"addNewLabel">, EventListener<"addNewRelation"> {
|
||||
private attributeDetailWidget: AttributeDetailWidget;
|
||||
private $editor!: JQuery<HTMLElement>;
|
||||
private $addNewAttributeButton!: JQuery<HTMLElement>;
|
||||
private $saveAttributesButton!: JQuery<HTMLElement>;
|
||||
private $errors!: JQuery<HTMLElement>;
|
||||
|
||||
private textEditor!: AttributeEditor;
|
||||
private lastUpdatedNoteId!: string | undefined;
|
||||
private lastSavedContent!: string;
|
||||
|
||||
constructor(attributeDetailWidget: AttributeDetailWidget) {
|
||||
super();
|
||||
|
||||
this.attributeDetailWidget = attributeDetailWidget;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$editor = this.$widget.find(".attribute-list-editor");
|
||||
|
||||
this.initialized = this.initEditor();
|
||||
|
||||
this.$editor.on("keydown", async (e) => {
|
||||
if (e.which === 13) {
|
||||
// allow autocomplete to fill the result textarea
|
||||
setTimeout(() => this.save(), 100);
|
||||
}
|
||||
|
||||
this.attributeDetailWidget.hide();
|
||||
});
|
||||
|
||||
this.$editor.on("blur", () => setTimeout(() => this.save(), 100)); // Timeout to fix https://github.com/zadam/trilium/issues/4160
|
||||
|
||||
this.$addNewAttributeButton = this.$widget.find(".add-new-attribute-button");
|
||||
this.$addNewAttributeButton.on("click", (e) => this.addNewAttribute(e));
|
||||
|
||||
this.$saveAttributesButton = this.$widget.find(".save-attributes-button");
|
||||
this.$saveAttributesButton.on("click", () => this.save());
|
||||
|
||||
this.$errors = this.$widget.find(".attribute-errors");
|
||||
}
|
||||
|
||||
addNewAttribute(e: JQuery.ClickEvent) {
|
||||
contextMenuService.show<AttributeCommandNames>({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
orientation: "left",
|
||||
items: [
|
||||
{ title: t("attribute_editor.add_new_label"), command: "addNewLabel", uiIcon: "bx bx-hash" },
|
||||
{ title: t("attribute_editor.add_new_relation"), command: "addNewRelation", uiIcon: "bx bx-transfer" },
|
||||
{ title: "----" },
|
||||
{ title: t("attribute_editor.add_new_label_definition"), command: "addNewLabelDefinition", uiIcon: "bx bx-empty" },
|
||||
{ title: t("attribute_editor.add_new_relation_definition"), command: "addNewRelationDefinition", uiIcon: "bx bx-empty" }
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => this.handleAddNewAttributeCommand(command)
|
||||
});
|
||||
// Prevent automatic hiding of the context menu due to the button being clicked.
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// triggered from keyboard shortcut
|
||||
async addNewLabelEvent({ ntxId }: EventData<"addNewLabel">) {
|
||||
if (this.isNoteContext(ntxId)) {
|
||||
await this.refresh();
|
||||
|
||||
this.handleAddNewAttributeCommand("addNewLabel");
|
||||
}
|
||||
}
|
||||
|
||||
// triggered from keyboard shortcut
|
||||
async addNewRelationEvent({ ntxId }: EventData<"addNewRelation">) {
|
||||
if (this.isNoteContext(ntxId)) {
|
||||
await this.refresh();
|
||||
|
||||
this.handleAddNewAttributeCommand("addNewRelation");
|
||||
}
|
||||
}
|
||||
|
||||
async handleAddNewAttributeCommand(command: AttributeCommandNames | undefined) {
|
||||
// TODO: Not sure what the relation between FAttribute[] and Attribute[] is.
|
||||
const attrs = this.parseAttributes() as FAttribute[];
|
||||
|
||||
if (!attrs) {
|
||||
return;
|
||||
}
|
||||
|
||||
let type: AttributeType;
|
||||
let name;
|
||||
let value;
|
||||
|
||||
if (command === "addNewLabel") {
|
||||
type = "label";
|
||||
name = "myLabel";
|
||||
value = "";
|
||||
} else if (command === "addNewRelation") {
|
||||
type = "relation";
|
||||
name = "myRelation";
|
||||
value = "";
|
||||
} else if (command === "addNewLabelDefinition") {
|
||||
type = "label";
|
||||
name = "label:myLabel";
|
||||
value = "promoted,single,text";
|
||||
} else if (command === "addNewRelationDefinition") {
|
||||
type = "label";
|
||||
name = "relation:myRelation";
|
||||
value = "promoted,single";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Incomplete type
|
||||
//@ts-ignore
|
||||
attrs.push({
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
isInheritable: false
|
||||
});
|
||||
|
||||
await this.renderOwnedAttributes(attrs, false);
|
||||
|
||||
this.$editor.scrollTop(this.$editor[0].scrollHeight);
|
||||
|
||||
const rect = this.$editor[0].getBoundingClientRect();
|
||||
|
||||
setTimeout(() => {
|
||||
// showing a little bit later because there's a conflict with outside click closing the attr detail
|
||||
this.attributeDetailWidget.showAttributeDetail({
|
||||
allAttributes: attrs,
|
||||
attribute: attrs[attrs.length - 1],
|
||||
isOwned: true,
|
||||
x: (rect.left + rect.right) / 2,
|
||||
y: rect.bottom,
|
||||
focus: "name"
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (this.lastUpdatedNoteId !== this.noteId) {
|
||||
// https://github.com/zadam/trilium/issues/3090
|
||||
console.warn("Ignoring blur event because a different note is loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
const attributes = this.parseAttributes();
|
||||
|
||||
if (attributes) {
|
||||
await server.put(`notes/${this.noteId}/attributes`, attributes, this.componentId);
|
||||
|
||||
this.$saveAttributesButton.fadeOut();
|
||||
|
||||
// blink the attribute text to give a visual hint that save has been executed
|
||||
this.$editor.css("opacity", 0);
|
||||
|
||||
// revert back
|
||||
setTimeout(() => this.$editor.css("opacity", 1), 100);
|
||||
}
|
||||
}
|
||||
|
||||
parseAttributes() {
|
||||
try {
|
||||
return attributeParser.lexAndParse(this.getPreprocessedData());
|
||||
} catch (e: any) {
|
||||
this.$errors.text(e.message).slideDown();
|
||||
}
|
||||
}
|
||||
|
||||
getPreprocessedData() {
|
||||
const str = this.textEditor
|
||||
.getData()
|
||||
.replace(/<a[^>]+href="(#[A-Za-z0-9_/]*)"[^>]*>[^<]*<\/a>/g, "$1")
|
||||
.replace(/ /g, " "); // otherwise .text() below outputs non-breaking space in unicode
|
||||
|
||||
return $("<div>").html(str).text();
|
||||
}
|
||||
|
||||
async initEditor() {
|
||||
this.$widget.show();
|
||||
|
||||
this.$editor.on("click", (e) => this.handleEditorClick(e));
|
||||
|
||||
this.textEditor = await AttributeEditor.create(this.$editor[0], editorConfig);
|
||||
this.textEditor.model.document.on("change:data", () => this.dataChanged());
|
||||
this.textEditor.editing.view.document.on(
|
||||
"enter",
|
||||
(event, data) => {
|
||||
// disable entering new line - see https://github.com/ckeditor/ckeditor5/issues/9422
|
||||
data.preventDefault();
|
||||
event.stop();
|
||||
},
|
||||
{ priority: "high" }
|
||||
);
|
||||
|
||||
// disable spellcheck for attribute editor
|
||||
const documentRoot = this.textEditor.editing.view.document.getRoot();
|
||||
if (documentRoot) {
|
||||
this.textEditor.editing.view.change((writer) => writer.setAttribute("spellcheck", "false", documentRoot));
|
||||
}
|
||||
}
|
||||
|
||||
dataChanged() {
|
||||
this.lastUpdatedNoteId = this.noteId;
|
||||
|
||||
if (this.lastSavedContent === this.textEditor.getData()) {
|
||||
this.$saveAttributesButton.fadeOut();
|
||||
} else {
|
||||
this.$saveAttributesButton.fadeIn();
|
||||
}
|
||||
|
||||
if (this.$errors.is(":visible")) {
|
||||
// using .hide() instead of .slideUp() since this will also hide the error after confirming
|
||||
// mention for relation name which suits up. When using.slideUp() error will appear and the slideUp which is weird
|
||||
this.$errors.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async handleEditorClick(e: JQuery.ClickEvent) {
|
||||
const pos = this.textEditor.model.document.selection.getFirstPosition();
|
||||
|
||||
if (pos && pos.textNode && pos.textNode.data) {
|
||||
const clickIndex = this.getClickIndex(pos);
|
||||
|
||||
let parsedAttrs;
|
||||
|
||||
try {
|
||||
parsedAttrs = attributeParser.lexAndParse(this.getPreprocessedData(), true);
|
||||
} catch (e) {
|
||||
// the input is incorrect because the user messed up with it and now needs to fix it manually
|
||||
return null;
|
||||
}
|
||||
|
||||
let matchedAttr: Attribute | null = null;
|
||||
|
||||
for (const attr of parsedAttrs) {
|
||||
if (attr.startIndex && clickIndex > attr.startIndex && attr.endIndex && clickIndex <= attr.endIndex) {
|
||||
matchedAttr = attr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (matchedAttr) {
|
||||
this.$editor.tooltip("hide");
|
||||
|
||||
this.attributeDetailWidget.showAttributeDetail({
|
||||
allAttributes: parsedAttrs,
|
||||
attribute: matchedAttr,
|
||||
isOwned: true,
|
||||
x: e.pageX,
|
||||
y: e.pageY
|
||||
});
|
||||
} else {
|
||||
this.showHelpTooltip();
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
this.showHelpTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
showHelpTooltip() {
|
||||
this.attributeDetailWidget.hide();
|
||||
|
||||
this.$editor.tooltip({
|
||||
trigger: "focus",
|
||||
html: true,
|
||||
title: HELP_TEXT,
|
||||
placement: "bottom",
|
||||
offset: "0,30"
|
||||
});
|
||||
|
||||
this.$editor.tooltip("show");
|
||||
}
|
||||
|
||||
getClickIndex(pos: ModelPosition) {
|
||||
let clickIndex = pos.offset - (pos.textNode?.startOffset ?? 0);
|
||||
|
||||
let curNode: ModelNode | Text | ModelElement | null = pos.textNode;
|
||||
|
||||
while (curNode?.previousSibling) {
|
||||
curNode = curNode.previousSibling;
|
||||
|
||||
if ((curNode as ModelElement).name === "reference") {
|
||||
clickIndex += (curNode.getAttribute("href") as string).length + 1;
|
||||
} else if ("data" in curNode) {
|
||||
clickIndex += (curNode.data as string).length;
|
||||
}
|
||||
}
|
||||
|
||||
return clickIndex;
|
||||
}
|
||||
|
||||
async loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string) {
|
||||
const { noteId } = linkService.parseNavigationStateFromUrl(href);
|
||||
const note = noteId ? await froca.getNote(noteId, true) : null;
|
||||
const title = note ? note.title : "[missing]";
|
||||
|
||||
$el.text(title);
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
await this.renderOwnedAttributes(note.getOwnedAttributes(), true);
|
||||
}
|
||||
|
||||
async renderOwnedAttributes(ownedAttributes: FAttribute[], saved: boolean) {
|
||||
// attrs are not resorted if position changes after the initial load
|
||||
ownedAttributes.sort((a, b) => a.position - b.position);
|
||||
|
||||
let htmlAttrs = (await attributeRenderer.renderAttributes(ownedAttributes, true)).html();
|
||||
|
||||
if (htmlAttrs.length > 0) {
|
||||
htmlAttrs += " ";
|
||||
}
|
||||
|
||||
this.textEditor.setData(htmlAttrs);
|
||||
|
||||
if (saved) {
|
||||
this.lastSavedContent = this.textEditor.getData();
|
||||
|
||||
this.$saveAttributesButton.fadeOut(0);
|
||||
}
|
||||
}
|
||||
|
||||
async createNoteForReferenceLink(title: string) {
|
||||
let result;
|
||||
if (this.notePath) {
|
||||
result = await noteCreateService.createNoteWithTypePrompt(this.notePath, {
|
||||
activate: false,
|
||||
title: title
|
||||
});
|
||||
}
|
||||
|
||||
return result?.note?.getBestNotePathString();
|
||||
}
|
||||
|
||||
async updateAttributeList(attributes: FAttribute[]) {
|
||||
await this.renderOwnedAttributes(attributes, false);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$editor.trigger("focus");
|
||||
|
||||
this.textEditor.model.change((writer) => {
|
||||
const documentRoot = this.textEditor.editing.model.document.getRoot();
|
||||
if (!documentRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const positionAt = writer.createPositionAt(documentRoot, "end");
|
||||
writer.setSelection(positionAt);
|
||||
});
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
import { isValidElement, VNode } from "preact";
|
||||
import Component, { TypedComponent } from "../components/component.js";
|
||||
import froca from "../services/froca.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import { renderReactWidget } from "./react/react_utils.jsx";
|
||||
import { EventNames, EventData } from "../components/app_context.js";
|
||||
import { Handler } from "leaflet";
|
||||
|
||||
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
|
||||
protected attrs: Record<string, string>;
|
||||
@ -22,11 +26,14 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
this.childPositionCounter = 10;
|
||||
}
|
||||
|
||||
child(...components: T[]) {
|
||||
if (!components) {
|
||||
child(..._components: (T | VNode)[]) {
|
||||
if (!_components) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Convert any React components to legacy wrapped components.
|
||||
const components = wrapReactWidgets(_components);
|
||||
|
||||
super.child(...components);
|
||||
|
||||
for (const component of components) {
|
||||
@ -258,3 +265,30 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
* For information on using widgets, see the tutorial {@tutorial widget_basics}.
|
||||
*/
|
||||
export default class BasicWidget extends TypedBasicWidget<Component> {}
|
||||
|
||||
export function wrapReactWidgets<T extends TypedComponent<any>>(components: (T | VNode)[]) {
|
||||
const wrappedResult: T[] = [];
|
||||
for (const component of components) {
|
||||
if (isValidElement(component)) {
|
||||
wrappedResult.push(new ReactWrappedWidget(component) as unknown as T);
|
||||
} else {
|
||||
wrappedResult.push(component);
|
||||
}
|
||||
}
|
||||
return wrappedResult;
|
||||
}
|
||||
|
||||
export class ReactWrappedWidget extends BasicWidget {
|
||||
|
||||
private el: VNode;
|
||||
|
||||
constructor(el: VNode) {
|
||||
super();
|
||||
this.el = el;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = renderReactWidget(this, this.el);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
import SwitchWidget from "./switch.js";
|
||||
import server from "../services/server.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
|
||||
// TODO: Deduplicate
|
||||
type Response = {
|
||||
success: true;
|
||||
} | {
|
||||
success: false;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default class BookmarkSwitchWidget extends SwitchWidget {
|
||||
isEnabled() {
|
||||
return (
|
||||
super.isEnabled() &&
|
||||
// it's not possible to bookmark root because that would clone it under bookmarks and thus create a cycle
|
||||
!["root", "_hidden"].includes(this.noteId ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.switchOnName = t("bookmark_switch.bookmark");
|
||||
this.switchOnTooltip = t("bookmark_switch.bookmark_this_note");
|
||||
|
||||
this.switchOffName = t("bookmark_switch.bookmark");
|
||||
this.switchOffTooltip = t("bookmark_switch.remove_bookmark");
|
||||
}
|
||||
|
||||
async toggle(state: boolean | null | undefined) {
|
||||
const resp = await server.put<Response>(`notes/${this.noteId}/toggle-in-parent/_lbBookmarks/${!!state}`);
|
||||
|
||||
if (!resp.success && "message" in resp) {
|
||||
toastService.showError(resp.message);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const isBookmarked = !!note.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks");
|
||||
|
||||
this.isToggled = isBookmarked;
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getBranchRows().find((b) => b.noteId === this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { memo } from "preact/compat";
|
||||
import AbstractBulkAction from "./abstract_bulk_action";
|
||||
import HelpRemoveButtons from "../react/HelpRemoveButtons";
|
||||
|
||||
interface BulkActionProps {
|
||||
label: string | ComponentChildren;
|
||||
@ -24,19 +25,11 @@ const BulkAction = memo(({ label, children, helpText, bulkAction }: BulkActionPr
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
<td className="button-column">
|
||||
{helpText && <div className="dropdown help-dropdown">
|
||||
<span className="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div className="dropdown-menu dropdown-menu-right p-4">
|
||||
{helpText}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<span
|
||||
className="bx bx-x icon-action action-conf-del"
|
||||
onClick={() => bulkAction?.deleteAction()}
|
||||
/>
|
||||
</td>
|
||||
<HelpRemoveButtons
|
||||
help={helpText}
|
||||
removeText="Delete"
|
||||
onRemove={() => bulkAction?.deleteAction()}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function MoveNoteBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
function MoveNoteBulkActionComponent({ bulkAction }: { bulkAction: AbstractBulkAction }) {
|
||||
const [ targetParentNoteId, setTargetParentNoteId ] = useState<string>();
|
||||
const spacedUpdate = useSpacedUpdate(() => {
|
||||
return bulkAction.saveAction({ targetParentNoteId: targetParentNoteId })
|
||||
@ -45,6 +45,6 @@ export default class MoveNoteBulkAction extends AbstractBulkAction {
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <MoveNoteBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
return <MoveNoteBulkActionComponent bulkAction={this} />
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction from "../BulkAction.jsx";
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import noteAutocompleteService from "../../../services/note_autocomplete.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
|
||||
@ -1,252 +0,0 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import dialogService from "../../services/dialog.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import appContext, { type EventData } from "../../components/app_context.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { FAttachmentRow } from "../../entities/fattachment.js";
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
interface ConvertToAttachmentResponse {
|
||||
attachment: FAttachmentRow;
|
||||
}
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="dropdown note-actions">
|
||||
<style>
|
||||
.note-actions {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-menu {
|
||||
min-width: 15em;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-item .bx {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 120%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-item[disabled], .note-actions .dropdown-item[disabled]:hover {
|
||||
color: var(--muted-text-color) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none; /* makes it unclickable */
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
|
||||
class="icon-action bx bx-dots-vertical-rounded"></button>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<li data-trigger-command="convertNoteIntoAttachment" class="dropdown-item">
|
||||
<span class="bx bx-paperclip"></span> ${t("note_actions.convert_into_attachment")}
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="renderActiveNote" class="dropdown-item render-note-button">
|
||||
<span class="bx bx-extension"></span> ${t("note_actions.re_render_note")}<kbd data-command="renderActiveNote"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="findInText" class="dropdown-item find-in-text-button">
|
||||
<span class='bx bx-search'></span> ${t("note_actions.search_in_note")}<kbd data-command="findInText"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="printActiveNote" class="dropdown-item print-active-note-button">
|
||||
<span class="bx bx-printer"></span> ${t("note_actions.print_note")}<kbd data-command="printActiveNote"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="exportAsPdf" class="dropdown-item export-as-pdf-button">
|
||||
<span class="bx bxs-file-pdf"></span> ${t("note_actions.print_pdf")}<kbd data-command="exportAsPdf"></kbd>
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li class="dropdown-item import-files-button"><span class="bx bx-import"></span> ${t("note_actions.import_files")}</li>
|
||||
|
||||
<li class="dropdown-item export-note-button"><span class="bx bx-export"></span> ${t("note_actions.export_note")}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
|
||||
<li data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button" title="${t("note_actions.open_note_externally_title")}">
|
||||
<span class="bx bx-file-find"></span> ${t("note_actions.open_note_externally")}<kbd data-command="openNoteExternally"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="openNoteCustom" class="dropdown-item open-note-custom-button">
|
||||
<span class="bx bx-customize"></span> ${t("note_actions.open_note_custom")}<kbd data-command="openNoteCustom"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="showNoteSource" class="dropdown-item show-source-button">
|
||||
<span class="bx bx-code"></span> ${t("note_actions.note_source")}<kbd data-command="showNoteSource"></kbd>
|
||||
</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="forceSaveRevision" class="dropdown-item save-revision-button">
|
||||
<span class="bx bx-save"></span> ${t("note_actions.save_revision")}<kbd data-command="forceSaveRevision"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item delete-note-button"><span class="bx bx-trash destructive-action-icon"></span> ${t("note_actions.delete_note")}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="showAttachments" class="dropdown-item show-attachments-button">
|
||||
<span class="bx bx-paperclip"></span> ${t("note_actions.note_attachments")}<kbd data-command="showAttachments"></kbd>
|
||||
</li>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $convertNoteIntoAttachmentButton!: JQuery<HTMLElement>;
|
||||
private $findInTextButton!: JQuery<HTMLElement>;
|
||||
private $printActiveNoteButton!: JQuery<HTMLElement>;
|
||||
private $exportAsPdfButton!: JQuery<HTMLElement>;
|
||||
private $showSourceButton!: JQuery<HTMLElement>;
|
||||
private $showAttachmentsButton!: JQuery<HTMLElement>;
|
||||
private $renderNoteButton!: JQuery<HTMLElement>;
|
||||
private $saveRevisionButton!: JQuery<HTMLElement>;
|
||||
private $exportNoteButton!: JQuery<HTMLElement>;
|
||||
private $importNoteButton!: JQuery<HTMLElement>;
|
||||
private $openNoteExternallyButton!: JQuery<HTMLElement>;
|
||||
private $openNoteCustomButton!: JQuery<HTMLElement>;
|
||||
private $deleteNoteButton!: JQuery<HTMLElement>;
|
||||
|
||||
isEnabled() {
|
||||
return this.note?.type !== "launcher";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$widget.on("show.bs.dropdown", () => {
|
||||
if (this.note) {
|
||||
this.refreshVisibility(this.note);
|
||||
}
|
||||
});
|
||||
|
||||
this.$convertNoteIntoAttachmentButton = this.$widget.find("[data-trigger-command='convertNoteIntoAttachment']");
|
||||
this.$findInTextButton = this.$widget.find(".find-in-text-button");
|
||||
this.$printActiveNoteButton = this.$widget.find(".print-active-note-button");
|
||||
this.$exportAsPdfButton = this.$widget.find(".export-as-pdf-button");
|
||||
this.$showSourceButton = this.$widget.find(".show-source-button");
|
||||
this.$showAttachmentsButton = this.$widget.find(".show-attachments-button");
|
||||
this.$renderNoteButton = this.$widget.find(".render-note-button");
|
||||
this.$saveRevisionButton = this.$widget.find(".save-revision-button");
|
||||
|
||||
this.$exportNoteButton = this.$widget.find(".export-note-button");
|
||||
this.$exportNoteButton.on("click", () => {
|
||||
if (this.$exportNoteButton.hasClass("disabled") || !this.noteContext?.notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerCommand("showExportDialog", {
|
||||
notePath: this.noteContext.notePath,
|
||||
defaultType: "single"
|
||||
});
|
||||
});
|
||||
|
||||
this.$importNoteButton = this.$widget.find(".import-files-button");
|
||||
this.$importNoteButton.on("click", () => {
|
||||
if (this.noteId) {
|
||||
this.triggerCommand("showImportDialog", { noteId: this.noteId });
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.on("click", ".dropdown-item", () => this.$widget.find("[data-bs-toggle='dropdown']").dropdown("toggle"));
|
||||
|
||||
this.$openNoteExternallyButton = this.$widget.find(".open-note-externally-button");
|
||||
this.$openNoteCustomButton = this.$widget.find(".open-note-custom-button");
|
||||
|
||||
this.$deleteNoteButton = this.$widget.find(".delete-note-button");
|
||||
this.$deleteNoteButton.on("click", () => {
|
||||
if (!this.note || this.note.noteId === "root") {
|
||||
return;
|
||||
}
|
||||
|
||||
branchService.deleteNotes([this.note.getParentBranches()[0].branchId], true);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshVisibility(note: FNote) {
|
||||
const isInOptions = note.noteId.startsWith("_options");
|
||||
|
||||
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
|
||||
|
||||
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap", "doc"].includes(note.type));
|
||||
|
||||
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
|
||||
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));
|
||||
|
||||
const canPrint = ["text", "code"].includes(note.type);
|
||||
this.toggleDisabled(this.$printActiveNoteButton, canPrint);
|
||||
this.toggleDisabled(this.$exportAsPdfButton, canPrint);
|
||||
this.$exportAsPdfButton.toggleClass("hidden-ext", !utils.isElectron());
|
||||
|
||||
this.$renderNoteButton.toggle(note.type === "render");
|
||||
|
||||
this.toggleDisabled(this.$openNoteExternallyButton, utils.isElectron() && !["search", "book"].includes(note.type));
|
||||
this.toggleDisabled(
|
||||
this.$openNoteCustomButton,
|
||||
utils.isElectron() &&
|
||||
!utils.isMac() && // no implementation for Mac yet
|
||||
!["search", "book"].includes(note.type)
|
||||
);
|
||||
|
||||
// I don't want to handle all special notes like this, but intuitively user might want to export content of backend log
|
||||
this.toggleDisabled(this.$exportNoteButton, !["_backendLog"].includes(note.noteId) && !isInOptions);
|
||||
|
||||
this.toggleDisabled(this.$importNoteButton, !["search"].includes(note.type) && !isInOptions);
|
||||
this.toggleDisabled(this.$deleteNoteButton, !isInOptions);
|
||||
this.toggleDisabled(this.$saveRevisionButton, !isInOptions);
|
||||
}
|
||||
|
||||
async convertNoteIntoAttachmentCommand() {
|
||||
if (!this.note || !(await dialogService.confirm(t("note_actions.convert_into_attachment_prompt", { title: this.note.title })))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { attachment: newAttachment } = await server.post<ConvertToAttachmentResponse>(`notes/${this.noteId}/convert-to-attachment`);
|
||||
|
||||
if (!newAttachment) {
|
||||
toastService.showMessage(t("note_actions.convert_into_attachment_failed", { title: this.note.title }));
|
||||
return;
|
||||
}
|
||||
|
||||
toastService.showMessage(t("note_actions.convert_into_attachment_successful", { title: newAttachment.title }));
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
await appContext.tabManager.getActiveContext()?.setNote(newAttachment.ownerId, {
|
||||
viewScope: {
|
||||
viewMode: "attachments",
|
||||
attachmentId: newAttachment.attachmentId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDisabled($el: JQuery<HTMLElement>, enable: boolean) {
|
||||
if (enable) {
|
||||
$el.removeAttr("disabled");
|
||||
} else {
|
||||
$el.attr("disabled", "disabled");
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isNoteReloaded(this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
|
||||
export default class RevisionsButton extends CommandButtonWidget {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.icon("bx-history").title(t("revisions_button.note_revisions")).command("showRevisions").titlePlacement("bottom").class("icon-action");
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && !["launcher", "doc"].includes(this.note?.type ?? "");
|
||||
}
|
||||
}
|
||||
@ -1,388 +0,0 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import keyboardActionsService from "../../services/keyboard_actions.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import type CommandButtonWidget from "../buttons/command_button.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { NoteType } from "../../entities/fnote.js";
|
||||
import type { EventData, EventNames } from "../../components/app_context.js";
|
||||
import type NoteActionsWidget from "../buttons/note_actions.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="ribbon-container">
|
||||
<style>
|
||||
.ribbon-container {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.ribbon-top-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ribbon-tab-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
margin-left: 10px;
|
||||
flex-grow: 1;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
.ribbon-tab-title {
|
||||
color: var(--muted-text-color);
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
min-width: 24px;
|
||||
flex-basis: 24px;
|
||||
max-width: max-content;
|
||||
flex-grow: 10;
|
||||
}
|
||||
|
||||
.ribbon-tab-title .bx {
|
||||
font-size: 150%;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.ribbon-tab-title.active {
|
||||
color: var(--main-text-color);
|
||||
border-bottom: 3px solid var(--main-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ribbon-tab-title:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ribbon-tab-title:hover {
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.ribbon-tab-title:first-of-type {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.ribbon-tab-spacer {
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
max-width: 35px;
|
||||
flex-grow: 1;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.ribbon-tab-spacer:last-of-type {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
max-width: 10000px;
|
||||
}
|
||||
|
||||
.ribbon-button-container {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.ribbon-button-container > * {
|
||||
position: relative;
|
||||
top: -3px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.ribbon-body {
|
||||
display: none;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
margin-left: 10px;
|
||||
margin-right: 5px; /* needs to have this value so that the bottom border is the same width as the top one */
|
||||
}
|
||||
|
||||
.ribbon-body.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ribbon-tab-title-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ribbon-tab-title.active .ribbon-tab-title-label {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ribbon-top-row">
|
||||
<div class="ribbon-tab-container"></div>
|
||||
<div class="ribbon-button-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="ribbon-body-container"></div>
|
||||
</div>`;
|
||||
|
||||
type ButtonWidget = (CommandButtonWidget | NoteActionsWidget);
|
||||
|
||||
export default class RibbonContainer extends NoteContextAwareWidget {
|
||||
|
||||
private lastActiveComponentId?: string | null;
|
||||
private lastNoteType?: NoteType;
|
||||
|
||||
private ribbonWidgets: NoteContextAwareWidget[];
|
||||
private buttonWidgets: ButtonWidget[];
|
||||
private $tabContainer!: JQuery<HTMLElement>;
|
||||
private $buttonContainer!: JQuery<HTMLElement>;
|
||||
private $bodyContainer!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.contentSized();
|
||||
this.ribbonWidgets = [];
|
||||
this.buttonWidgets = [];
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && this.noteContext?.viewScope?.viewMode === "default";
|
||||
}
|
||||
|
||||
ribbon(widget: NoteContextAwareWidget) {
|
||||
// TODO: Base class
|
||||
super.child(widget);
|
||||
|
||||
this.ribbonWidgets.push(widget);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
button(widget: ButtonWidget) {
|
||||
super.child(widget);
|
||||
|
||||
this.buttonWidgets.push(widget);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.$tabContainer = this.$widget.find(".ribbon-tab-container");
|
||||
this.$buttonContainer = this.$widget.find(".ribbon-button-container");
|
||||
this.$bodyContainer = this.$widget.find(".ribbon-body-container");
|
||||
|
||||
for (const ribbonWidget of this.ribbonWidgets) {
|
||||
this.$bodyContainer.append($('<div class="ribbon-body">').attr("data-ribbon-component-id", ribbonWidget.componentId).append(ribbonWidget.render()));
|
||||
}
|
||||
|
||||
for (const buttonWidget of this.buttonWidgets) {
|
||||
this.$buttonContainer.append(buttonWidget.render());
|
||||
}
|
||||
|
||||
this.$tabContainer.on("click", ".ribbon-tab-title", (e) => {
|
||||
const $ribbonTitle = $(e.target).closest(".ribbon-tab-title");
|
||||
|
||||
this.toggleRibbonTab($ribbonTitle);
|
||||
});
|
||||
}
|
||||
|
||||
toggleRibbonTab($ribbonTitle: JQuery<HTMLElement>, refreshActiveTab = true) {
|
||||
const activate = !$ribbonTitle.hasClass("active");
|
||||
|
||||
this.$tabContainer.find(".ribbon-tab-title").removeClass("active");
|
||||
this.$bodyContainer.find(".ribbon-body").removeClass("active");
|
||||
|
||||
if (activate) {
|
||||
const ribbonComponendId = $ribbonTitle.attr("data-ribbon-component-id");
|
||||
|
||||
const wasAlreadyActive = this.lastActiveComponentId === ribbonComponendId;
|
||||
|
||||
this.lastActiveComponentId = ribbonComponendId;
|
||||
|
||||
this.$tabContainer.find(`.ribbon-tab-title[data-ribbon-component-id="${ribbonComponendId}"]`).addClass("active");
|
||||
this.$bodyContainer.find(`.ribbon-body[data-ribbon-component-id="${ribbonComponendId}"]`).addClass("active");
|
||||
|
||||
const activeChild = this.getActiveRibbonWidget();
|
||||
|
||||
if (activeChild && (refreshActiveTab || !wasAlreadyActive) && this.noteContext && this.notePath) {
|
||||
const handleEventPromise = activeChild.handleEvent("noteSwitched", { noteContext: this.noteContext, notePath: this.notePath });
|
||||
|
||||
if (refreshActiveTab) {
|
||||
if (handleEventPromise) {
|
||||
handleEventPromise.then(() => (activeChild as any).focus?.()); // TODO: Base class
|
||||
} else {
|
||||
// TODO: Base class
|
||||
(activeChild as any).focus?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.lastActiveComponentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async noteSwitched() {
|
||||
this.lastActiveComponentId = null;
|
||||
|
||||
await super.noteSwitched();
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote, noExplicitActivation = false) {
|
||||
this.lastNoteType = note.type;
|
||||
|
||||
let $ribbonTabToActivate, $lastActiveRibbon;
|
||||
|
||||
this.$tabContainer.empty();
|
||||
|
||||
for (const ribbonWidget of this.ribbonWidgets) {
|
||||
// TODO: Base class for ribbon widget
|
||||
const ret = await (ribbonWidget as any).getTitle(note);
|
||||
|
||||
if (!ret.show) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const $ribbonTitle = $('<div class="ribbon-tab-title">')
|
||||
.attr("data-ribbon-component-id", ribbonWidget.componentId)
|
||||
.attr("data-ribbon-component-name", (ribbonWidget as any).name as string) // TODO: base class for ribbon widgets
|
||||
.append(
|
||||
$('<span class="ribbon-tab-title-icon">')
|
||||
.addClass(ret.icon)
|
||||
.attr("title", ret.title)
|
||||
.attr("data-toggle-command", (ribbonWidget as any).toggleCommand)
|
||||
) // TODO: base class
|
||||
.append(" ")
|
||||
.append($('<span class="ribbon-tab-title-label">').text(ret.title));
|
||||
|
||||
this.$tabContainer.append($ribbonTitle);
|
||||
this.$tabContainer.append('<div class="ribbon-tab-spacer">');
|
||||
|
||||
if (ret.activate && !this.lastActiveComponentId && !$ribbonTabToActivate && !noExplicitActivation) {
|
||||
$ribbonTabToActivate = $ribbonTitle;
|
||||
}
|
||||
|
||||
if (this.lastActiveComponentId === ribbonWidget.componentId) {
|
||||
$lastActiveRibbon = $ribbonTitle;
|
||||
}
|
||||
}
|
||||
|
||||
keyboardActionsService.getActions().then((actions) => {
|
||||
this.$tabContainer.find(".ribbon-tab-title-icon").tooltip({
|
||||
title: () => {
|
||||
const toggleCommandName = $(this).attr("data-toggle-command");
|
||||
const action = actions.find((act) => act.actionName === toggleCommandName);
|
||||
const title = $(this).attr("data-title");
|
||||
|
||||
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
|
||||
return `${title} (${action.effectiveShortcuts.join(", ")})`;
|
||||
} else {
|
||||
return title ?? "";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!$ribbonTabToActivate) {
|
||||
$ribbonTabToActivate = $lastActiveRibbon;
|
||||
}
|
||||
|
||||
if ($ribbonTabToActivate) {
|
||||
this.toggleRibbonTab($ribbonTabToActivate, false);
|
||||
} else {
|
||||
this.$bodyContainer.find(".ribbon-body").removeClass("active");
|
||||
}
|
||||
}
|
||||
|
||||
isRibbonTabActive(name: string) {
|
||||
const $ribbonComponent = this.$widget.find(`.ribbon-tab-title[data-ribbon-component-name='${name}']`);
|
||||
|
||||
return $ribbonComponent.hasClass("active");
|
||||
}
|
||||
|
||||
ensureOwnedAttributesAreOpen(ntxId: string | null | undefined) {
|
||||
if (ntxId && this.isNoteContext(ntxId) && !this.isRibbonTabActive("ownedAttributes")) {
|
||||
this.toggleRibbonTabWithName("ownedAttributes", ntxId);
|
||||
}
|
||||
}
|
||||
|
||||
addNewLabelEvent({ ntxId }: EventData<"addNewLabel">) {
|
||||
this.ensureOwnedAttributesAreOpen(ntxId);
|
||||
}
|
||||
|
||||
addNewRelationEvent({ ntxId }: EventData<"addNewRelation">) {
|
||||
this.ensureOwnedAttributesAreOpen(ntxId);
|
||||
}
|
||||
|
||||
toggleRibbonTabWithName(name: string, ntxId?: string) {
|
||||
if (!this.isNoteContext(ntxId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $ribbonComponent = this.$widget.find(`.ribbon-tab-title[data-ribbon-component-name='${name}']`);
|
||||
|
||||
if ($ribbonComponent) {
|
||||
this.toggleRibbonTab($ribbonComponent);
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent<T extends EventNames>(name: T, data: EventData<T>) {
|
||||
const PREFIX = "toggleRibbonTab";
|
||||
|
||||
if (name.startsWith(PREFIX)) {
|
||||
let componentName = name.substr(PREFIX.length);
|
||||
componentName = componentName[0].toLowerCase() + componentName.substr(1);
|
||||
|
||||
this.toggleRibbonTabWithName(componentName, (data as any).ntxId);
|
||||
} else {
|
||||
return super.handleEvent(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>) {
|
||||
if (["activeContextChanged", "setNoteContext"].includes(name)) {
|
||||
// won't trigger .refresh();
|
||||
await super.handleEventInChildren("setNoteContext", data as EventData<"activeContextChanged" | "setNoteContext">);
|
||||
} else if (this.isEnabled() || name === "initialRenderComplete") {
|
||||
const activeRibbonWidget = this.getActiveRibbonWidget();
|
||||
|
||||
// forward events only to active ribbon tab, inactive ones don't need to be updated
|
||||
if (activeRibbonWidget) {
|
||||
await activeRibbonWidget.handleEvent(name, data);
|
||||
}
|
||||
|
||||
for (const buttonWidget of this.buttonWidgets) {
|
||||
await buttonWidget.handleEvent(name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.noteId && loadResults.isNoteReloaded(this.noteId) && this.lastNoteType !== this.note.type) {
|
||||
// note type influences the list of available ribbon tabs the most
|
||||
// check for the type is so that we don't update on each title rename
|
||||
this.lastNoteType = this.note.type;
|
||||
|
||||
this.refresh();
|
||||
} else if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
|
||||
this.refreshWithNote(this.note, true);
|
||||
}
|
||||
}
|
||||
|
||||
async noteTypeMimeChangedEvent() {
|
||||
// We are ignoring the event which triggers a refresh since it is usually already done by a different
|
||||
// event and causing a race condition in which the items appear twice.
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed as soon as the user presses the "Edit" floating button in a read-only text note.
|
||||
*
|
||||
* <p>
|
||||
* We need to refresh the ribbon for cases such as the classic editor which relies on the read-only state.
|
||||
*/
|
||||
readOnlyTemporarilyDisabledEvent() {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
getActiveRibbonWidget() {
|
||||
return this.ribbonWidgets.find((ch) => ch.componentId === this.lastActiveComponentId);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
@ -8,11 +7,11 @@ import openService from "../../services/open.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
|
||||
function AboutDialogComponent() {
|
||||
let [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
let [shown, setShown] = useState(false);
|
||||
export default function AboutDialog() {
|
||||
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
const [shown, setShown] = useState(false);
|
||||
const forceWordBreak: CSSProperties = { wordBreak: "break-all" };
|
||||
|
||||
useTriliumEvent("openAboutDialog", () => setShown(true));
|
||||
@ -82,11 +81,3 @@ function DirectoryLink({ directory, style }: { directory: string, style?: CSSPro
|
||||
return <span style={style}>{directory}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export default class AboutDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <AboutDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Button from "../react/Button";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
@ -11,11 +10,11 @@ import { default as TextTypeWidget } from "../type_widgets/editable_text.js";
|
||||
import { logError } from "../../services/ws";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
||||
|
||||
function AddLinkDialogComponent() {
|
||||
export default function AddLinkDialog() {
|
||||
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>();
|
||||
const initialText = useRef<string>();
|
||||
const [ linkTitle, setLinkTitle ] = useState("");
|
||||
@ -160,11 +159,3 @@ function AddLinkDialogComponent() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class AddLinkDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <AddLinkDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -4,15 +4,14 @@ import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import toast from "../../services/toast.js";
|
||||
import Modal from "../react/Modal.jsx";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import tree from "../../services/tree.js";
|
||||
import Button from "../react/Button.jsx";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
import FBranch from "../../entities/fbranch.js";
|
||||
|
||||
function BranchPrefixDialogComponent() {
|
||||
export default function BranchPrefixDialog() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ branch, setBranch ] = useState<FBranch>();
|
||||
const [ prefix, setPrefix ] = useState(branch?.prefix ?? "");
|
||||
@ -75,14 +74,6 @@ function BranchPrefixDialogComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default class BranchPrefixDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <BranchPrefixDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function savePrefix(branchId: string, prefix: string) {
|
||||
await server.put(`branches/${branchId}/set-prefix`, { prefix: prefix });
|
||||
toast.showMessage(t("branch_prefix.branch_prefix_saved"));
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import "./bulk_actions.css";
|
||||
import { BulkActionAffectedNotes } from "@triliumnext/commons";
|
||||
import server from "../../services/server";
|
||||
@ -12,9 +11,9 @@ import toast from "../../services/toast";
|
||||
import RenameNoteBulkAction from "../bulk_actions/note/rename_note";
|
||||
import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function BulkActionComponent() {
|
||||
export default function BulkActionsDialog() {
|
||||
const [ selectedOrActiveNoteIds, setSelectedOrActiveNoteIds ] = useState<string[]>();
|
||||
const [ bulkActionNote, setBulkActionNote ] = useState<FNote | null>();
|
||||
const [ includeDescendants, setIncludeDescendants ] = useState(false);
|
||||
@ -51,7 +50,7 @@ function BulkActionComponent() {
|
||||
row.type === "label" && row.name === "action" && row.noteId === "_bulkAction")) {
|
||||
refreshExistingActions();
|
||||
}
|
||||
}, shown);
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -117,11 +116,3 @@ function ExistingActionsList({ existingActions }: { existingActions?: RenameNote
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default class BulkActionsDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <BulkActionComponent />
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,15 +1,11 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import { CallToAction, dismissCallToAction, getCallToActions } from "./call_to_action_definitions";
|
||||
import { dismissCallToAction, getCallToActions } from "./call_to_action_definitions";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActions: CallToAction[] }) {
|
||||
if (!activeCallToActions.length) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export default function CallToActionDialog() {
|
||||
const activeCallToActions = useMemo(() => getCallToActions(), []);
|
||||
const [ activeIndex, setActiveIndex ] = useState(0);
|
||||
const [ shown, setShown ] = useState(true);
|
||||
const activeItem = activeCallToActions[activeIndex];
|
||||
@ -22,7 +18,7 @@ function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActi
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return (activeCallToActions.length &&
|
||||
<Modal
|
||||
className="call-to-action"
|
||||
size="md"
|
||||
@ -48,11 +44,3 @@ function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActi
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export class CallToActionDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <CallToActionDialogComponent activeCallToActions={getCallToActions()} />
|
||||
}
|
||||
|
||||
}
|
||||
@ -2,7 +2,6 @@ import { useRef, useState } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import { t } from "../../services/i18n";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import froca from "../../services/froca";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
@ -14,9 +13,9 @@ import tree from "../../services/tree";
|
||||
import branches from "../../services/branches";
|
||||
import toast from "../../services/toast";
|
||||
import NoteList from "../react/NoteList";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function CloneToDialogComponent() {
|
||||
export default function CloneToDialog() {
|
||||
const [ clonedNoteIds, setClonedNoteIds ] = useState<string[]>();
|
||||
const [ prefix, setPrefix ] = useState("");
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
@ -83,14 +82,6 @@ function CloneToDialogComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
export default class CloneToDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <CloneToDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function cloneNotesTo(notePath: string, clonedNoteIds: string[], prefix?: string) {
|
||||
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
|
||||
if (!noteId || !parentNoteId) {
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useState } from "preact/hooks";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title?: string;
|
||||
@ -13,7 +12,7 @@ interface ConfirmDialogProps {
|
||||
isConfirmDeleteNoteBox?: boolean;
|
||||
}
|
||||
|
||||
function ConfirmDialogComponent() {
|
||||
export default function ConfirmDialog() {
|
||||
const [ opts, setOpts ] = useState<ConfirmDialogProps>();
|
||||
const [ isDeleteNoteChecked, setIsDeleteNoteChecked ] = useState(false);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
@ -92,11 +91,3 @@ export interface ConfirmWithTitleOptions {
|
||||
title: string;
|
||||
callback: ConfirmDialogCallback;
|
||||
}
|
||||
|
||||
export default class ConfirmDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ConfirmDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@ -2,7 +2,6 @@ import { useRef, useState, useEffect } from "preact/hooks";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import FormCheckbox from "../react/FormCheckbox.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import type { DeleteNotesPreview } from "@triliumnext/commons";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
@ -10,7 +9,7 @@ import FNote from "../../entities/fnote.js";
|
||||
import link from "../../services/link.js";
|
||||
import Button from "../react/Button.jsx";
|
||||
import Alert from "../react/Alert.jsx";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
|
||||
export interface ResolveOptions {
|
||||
proceed: boolean;
|
||||
@ -30,7 +29,7 @@ interface BrokenRelationData {
|
||||
source: string;
|
||||
}
|
||||
|
||||
function DeleteNotesDialogComponent() {
|
||||
export default function DeleteNotesDialog() {
|
||||
const [ opts, setOpts ] = useState<ShowDeleteNotesDialogOpts>({});
|
||||
const [ deleteAllClones, setDeleteAllClones ] = useState(false);
|
||||
const [ eraseNotes, setEraseNotes ] = useState(!!opts.forceDeleteAllClones);
|
||||
@ -140,7 +139,7 @@ function BrokenRelations({ brokenRelations }: { brokenRelations: DeleteNotesPrev
|
||||
const noteIds = brokenRelations
|
||||
.map(relation => relation.noteId)
|
||||
.filter(noteId => noteId) as string[];
|
||||
froca.getNotes(noteIds).then(async (notes) => {
|
||||
froca.getNotes(noteIds).then(async () => {
|
||||
const notesWithBrokenRelations: BrokenRelationData[] = [];
|
||||
for (const attr of brokenRelations) {
|
||||
notesWithBrokenRelations.push({
|
||||
@ -171,11 +170,3 @@ function BrokenRelations({ brokenRelations }: { brokenRelations: DeleteNotesPrev
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
export default class DeleteNotesDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <DeleteNotesDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@ -4,14 +4,13 @@ import tree from "../../services/tree";
|
||||
import Button from "../react/Button";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import "./export.css";
|
||||
import ws from "../../services/ws";
|
||||
import toastService, { ToastOptions } from "../../services/toast";
|
||||
import utils from "../../services/utils";
|
||||
import open from "../../services/open";
|
||||
import froca from "../../services/froca";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
interface ExportDialogProps {
|
||||
branchId?: string | null;
|
||||
@ -19,7 +18,7 @@ interface ExportDialogProps {
|
||||
defaultType?: "subtree" | "single";
|
||||
}
|
||||
|
||||
function ExportDialogComponent() {
|
||||
export default function ExportDialog() {
|
||||
const [ opts, setOpts ] = useState<ExportDialogProps>();
|
||||
const [ exportType, setExportType ] = useState<string>(opts?.defaultType ?? "subtree");
|
||||
const [ subtreeFormat, setSubtreeFormat ] = useState("html");
|
||||
@ -125,14 +124,6 @@ function ExportDialogComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default class ExportDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ExportDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function exportBranch(branchId: string, type: string, format: string, version: string) {
|
||||
const taskId = utils.randomString(10);
|
||||
const url = open.getUrlForDownload(`api/branches/${branchId}/export/${type}/${format}/${version}/${taskId}`);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import Modal from "../react/Modal.jsx";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { ComponentChildren } from "preact";
|
||||
@ -6,9 +5,9 @@ import { CommandNames } from "../../components/app_context.js";
|
||||
import RawHtml from "../react/RawHtml.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import keyboard_actions from "../../services/keyboard_actions.js";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
|
||||
function HelpDialogComponent() {
|
||||
export default function HelpDialog() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
useTriliumEvent("showCheatsheet", () => setShown(true));
|
||||
|
||||
@ -161,11 +160,3 @@ function Card({ title, children }: { title: string, children: ComponentChildren
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default class HelpDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <HelpDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -7,11 +7,10 @@ import FormFileUpload from "../react/FormFileUpload";
|
||||
import FormGroup, { FormMultiGroup } from "../react/FormGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import importService, { UploadFilesOptions } from "../../services/import";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function ImportDialogComponent() {
|
||||
export default function ImportDialog() {
|
||||
const [ parentNoteId, setParentNoteId ] = useState<string>();
|
||||
const [ noteTitle, setNoteTitle ] = useState<string>();
|
||||
const [ files, setFiles ] = useState<FileList | null>(null);
|
||||
@ -89,14 +88,6 @@ function ImportDialogComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default class ImportDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ImportDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function boolToString(value: boolean) {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
@ -4,15 +4,14 @@ import FormGroup from "../react/FormGroup";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Button from "../react/Button";
|
||||
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
|
||||
import tree from "../../services/tree";
|
||||
import froca from "../../services/froca";
|
||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function IncludeNoteDialogComponent() {
|
||||
export default function IncludeNoteDialog() {
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
|
||||
const [boxSize, setBoxSize] = useState("medium");
|
||||
@ -70,14 +69,6 @@ function IncludeNoteDialogComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
export default class IncludeNoteDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <IncludeNoteDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWidget) {
|
||||
const noteId = tree.getNoteIdFromUrl(notePath);
|
||||
if (!noteId) {
|
||||
|
||||
@ -3,11 +3,10 @@ import { t } from "../../services/i18n.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import Button from "../react/Button.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import useTriliumEvent from "../react/hooks.jsx";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
|
||||
function IncorrectCpuArchDialogComponent() {
|
||||
export default function IncorrectCpuArchDialogComponent() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const downloadButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useTriliumEvent("showCpuArchWarning", () => setShown(true));
|
||||
@ -44,11 +43,3 @@ function IncorrectCpuArchDialogComponent() {
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class IncorrectCpuArchDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <IncorrectCpuArchDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { EventData } from "../../components/app_context";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { RawHtmlBlock } from "../react/RawHtml";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function ShowInfoDialogComponent() {
|
||||
export default function InfoDialog() {
|
||||
const [ opts, setOpts ] = useState<EventData<"showInfoDialog">>();
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const okButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@ -37,11 +36,3 @@ function ShowInfoDialogComponent() {
|
||||
<RawHtmlBlock className="info-dialog-content" html={opts?.message ?? ""} />
|
||||
</Modal>);
|
||||
}
|
||||
|
||||
export default class InfoDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ShowInfoDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
@ -8,14 +7,14 @@ import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"
|
||||
import appContext from "../../components/app_context";
|
||||
import commandRegistry from "../../services/command_registry";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import shortcutService from "../../services/shortcuts";
|
||||
|
||||
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
||||
|
||||
type Mode = "last-search" | "recent-notes" | "commands";
|
||||
|
||||
function JumpToNoteDialogComponent() {
|
||||
export default function JumpToNoteDialogComponent() {
|
||||
const [ mode, setMode ] = useState<Mode>();
|
||||
const [ lastOpenedTs, setLastOpenedTs ] = useState<number>(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@ -27,12 +26,12 @@ function JumpToNoteDialogComponent() {
|
||||
|
||||
async function openDialog(commandMode: boolean) {
|
||||
let newMode: Mode;
|
||||
let initialText: string = "";
|
||||
let initialText = "";
|
||||
|
||||
if (commandMode) {
|
||||
newMode = "commands";
|
||||
initialText = ">";
|
||||
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText) {
|
||||
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
// so we'll keep the content.
|
||||
@ -142,11 +141,3 @@ function JumpToNoteDialogComponent() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class JumpToNoteDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <JumpToNoteDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,18 +5,17 @@ import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import utils from "../../services/utils";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Button from "../react/Button";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
}
|
||||
|
||||
function MarkdownImportDialogComponent() {
|
||||
export default function MarkdownImportDialog() {
|
||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||
let [ text, setText ] = useState("");
|
||||
let [ shown, setShown ] = useState(false);
|
||||
const [ text, setText ] = useState("");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
const triggerImport = useCallback(() => {
|
||||
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
|
||||
@ -64,14 +63,6 @@ function MarkdownImportDialogComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
export default class MarkdownImportDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <MarkdownImportDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function convertMarkdownToHtml(markdownContent: string) {
|
||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import NoteList from "../react/NoteList";
|
||||
@ -11,9 +10,9 @@ import tree from "../../services/tree";
|
||||
import froca from "../../services/froca";
|
||||
import branches from "../../services/branches";
|
||||
import toast from "../../services/toast";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function MoveToDialogComponent() {
|
||||
export default function MoveToDialog() {
|
||||
const [ movedBranchIds, setMovedBranchIds ] = useState<string[]>();
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
@ -67,14 +66,6 @@ function MoveToDialogComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
export default class MoveToDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <MoveToDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function moveNotesTo(movedBranchIds: string[] | undefined, parentBranchId: string) {
|
||||
if (movedBranchIds) {
|
||||
await branches.moveToParentNote(movedBranchIds, parentBranchId);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
@ -10,7 +9,7 @@ import { MenuCommandItem, MenuItem } from "../../menus/context_menu";
|
||||
import { TreeCommandNames } from "../../menus/tree_context_menu";
|
||||
import { Suggestion } from "../../services/note_autocomplete";
|
||||
import Badge from "../react/Badge";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
export interface ChooseNoteTypeResponse {
|
||||
success: boolean;
|
||||
@ -26,7 +25,7 @@ const SEPARATOR_TITLE_REPLACEMENTS = [
|
||||
t("note_type_chooser.templates")
|
||||
];
|
||||
|
||||
function NoteTypeChooserDialogComponent() {
|
||||
export default function NoteTypeChooserDialogComponent() {
|
||||
const [ callback, setCallback ] = useState<ChooseNoteTypeCallback>();
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ parentNote, setParentNote ] = useState<Suggestion | null>();
|
||||
@ -37,25 +36,23 @@ function NoteTypeChooserDialogComponent() {
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
if (!noteTypes.length) {
|
||||
useEffect(() => {
|
||||
note_types.getNoteTypeItems().then(noteTypes => {
|
||||
let index = -1;
|
||||
useEffect(() => {
|
||||
note_types.getNoteTypeItems().then(noteTypes => {
|
||||
let index = -1;
|
||||
|
||||
setNoteTypes((noteTypes ?? []).map((item, _index) => {
|
||||
if (item.title === "----") {
|
||||
index++;
|
||||
return {
|
||||
title: SEPARATOR_TITLE_REPLACEMENTS[index],
|
||||
enabled: false
|
||||
}
|
||||
setNoteTypes((noteTypes ?? []).map((item) => {
|
||||
if (item.title === "----") {
|
||||
index++;
|
||||
return {
|
||||
title: SEPARATOR_TITLE_REPLACEMENTS[index],
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}));
|
||||
});
|
||||
return item;
|
||||
}));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
function onNoteTypeSelected(value: string) {
|
||||
const [ noteType, templateNoteId ] = value.split(",");
|
||||
@ -120,11 +117,3 @@ function NoteTypeChooserDialogComponent() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class NoteTypeChooserDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <NoteTypeChooserDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import Modal from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import appContext from "../../components/app_context";
|
||||
import { useState } from "preact/hooks";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function PasswordNotSetDialogComponent() {
|
||||
export default function PasswordNotSetDialog() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
useTriliumEvent("showPasswordNotSet", () => setShown(true));
|
||||
|
||||
@ -27,10 +26,3 @@ function PasswordNotSetDialogComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default class PasswordNotSetDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <PasswordNotSetDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -2,12 +2,10 @@ import { useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import { Modal as BootstrapModal } from "bootstrap";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
// JQuery here is maintained for compatibility with existing code.
|
||||
interface ShownCallbackData {
|
||||
@ -28,7 +26,7 @@ export interface PromptDialogOptions {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function PromptDialogComponent() {
|
||||
export default function PromptDialog() {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const labelRef = useRef<HTMLLabelElement>(null);
|
||||
@ -84,11 +82,3 @@ function PromptDialogComponent() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class PromptDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <PromptDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -3,11 +3,10 @@ import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import protected_session from "../../services/protected_session";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function ProtectedSessionPasswordDialogComponent() {
|
||||
export default function ProtectedSessionPasswordDialog() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ password, setPassword ] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@ -38,11 +37,3 @@ function ProtectedSessionPasswordDialogComponent() {
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class ProtectedSessionPasswordDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <ProtectedSessionPasswordDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@ -6,7 +6,6 @@ import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import hoisted_note from "../../services/hoisted_note";
|
||||
import type { RecentChangeRow } from "@triliumnext/commons";
|
||||
import froca from "../../services/froca";
|
||||
@ -14,39 +13,32 @@ import { formatDateTime } from "../../utils/formatters";
|
||||
import link from "../../services/link";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import ws from "../../services/ws";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function RecentChangesDialogComponent() {
|
||||
export default function RecentChangesDialog() {
|
||||
const [ ancestorNoteId, setAncestorNoteId ] = useState<string>();
|
||||
const [ groupedByDate, setGroupedByDate ] = useState<Map<String, RecentChangeRow[]>>();
|
||||
const [ needsRefresh, setNeedsRefresh ] = useState(false);
|
||||
const [ groupedByDate, setGroupedByDate ] = useState<Map<string, RecentChangeRow[]>>();
|
||||
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useTriliumEvent("showRecentChanges", ({ ancestorNoteId }) => {
|
||||
setNeedsRefresh(true);
|
||||
useTriliumEvent("showRecentChanges", ({ ancestorNoteId }) => {
|
||||
setAncestorNoteId(ancestorNoteId ?? hoisted_note.getHoistedNoteId());
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
if (!groupedByDate || needsRefresh) {
|
||||
useEffect(() => {
|
||||
if (needsRefresh) {
|
||||
setNeedsRefresh(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
await froca.getNotes(
|
||||
recentChanges.map((r) => r.noteId),
|
||||
true
|
||||
);
|
||||
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
await froca.getNotes(
|
||||
recentChanges.map((r) => r.noteId),
|
||||
true
|
||||
);
|
||||
|
||||
const groupedByDate = groupByDate(recentChanges);
|
||||
setGroupedByDate(groupedByDate);
|
||||
});
|
||||
})
|
||||
}
|
||||
const groupedByDate = groupByDate(recentChanges);
|
||||
setGroupedByDate(groupedByDate);
|
||||
});
|
||||
}, [ shown, refreshCounter ])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -61,7 +53,7 @@ function RecentChangesDialogComponent() {
|
||||
style={{ padding: "0 10px" }}
|
||||
onClick={() => {
|
||||
server.post("notes/erase-deleted-notes-now").then(() => {
|
||||
setNeedsRefresh(true);
|
||||
setRefreshCounter(refreshCounter + 1);
|
||||
toast.showMessage(t("recent_changes.deleted_notes_message"));
|
||||
});
|
||||
}}
|
||||
@ -79,7 +71,7 @@ function RecentChangesDialogComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
function RecentChangesTimeline({ groupedByDate, setShown }: { groupedByDate: Map<String, RecentChangeRow[]>, setShown: Dispatch<StateUpdater<boolean>> }) {
|
||||
function RecentChangesTimeline({ groupedByDate, setShown }: { groupedByDate: Map<string, RecentChangeRow[]>, setShown: Dispatch<StateUpdater<boolean>> }) {
|
||||
return (
|
||||
<>
|
||||
{ Array.from(groupedByDate.entries()).map(([dateDay, dayChanges]) => {
|
||||
@ -114,10 +106,6 @@ function RecentChangesTimeline({ groupedByDate, setShown }: { groupedByDate: Map
|
||||
}
|
||||
|
||||
function NoteLink({ notePath, title }: { notePath: string, title: string }) {
|
||||
if (!notePath || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [ noteLink, setNoteLink ] = useState<JQuery<HTMLElement> | null>(null);
|
||||
useEffect(() => {
|
||||
link.createLink(notePath, {
|
||||
@ -156,25 +144,19 @@ function DeletedNoteLink({ change, setShown }: { change: RecentChangeRow, setSho
|
||||
);
|
||||
}
|
||||
|
||||
export default class RecentChangesDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <RecentChangesDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function groupByDate(rows: RecentChangeRow[]) {
|
||||
const groupedByDate = new Map<String, RecentChangeRow[]>();
|
||||
const groupedByDate = new Map<string, RecentChangeRow[]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const dateDay = row.date.substr(0, 10);
|
||||
|
||||
if (!groupedByDate.has(dateDay)) {
|
||||
groupedByDate.set(dateDay, []);
|
||||
let dateDayArray = groupedByDate.get(dateDay);
|
||||
if (!dateDayArray) {
|
||||
dateDayArray = [];
|
||||
groupedByDate.set(dateDay, dateDayArray);
|
||||
}
|
||||
|
||||
groupedByDate.get(dateDay)!.push(row);
|
||||
dateDayArray.push(row);
|
||||
}
|
||||
|
||||
return groupedByDate;
|
||||
|
||||
@ -8,7 +8,6 @@ import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import FormList, { FormListItem } from "../react/FormList";
|
||||
import utils from "../../services/utils";
|
||||
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
||||
@ -18,9 +17,9 @@ import type { CSSProperties } from "preact/compat";
|
||||
import open from "../../services/open";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import options from "../../services/options";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function RevisionsDialogComponent() {
|
||||
export default function RevisionsDialog() {
|
||||
const [ note, setNote ] = useState<FNote>();
|
||||
const [ revisions, setRevisions ] = useState<RevisionItem[]>();
|
||||
const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>();
|
||||
@ -72,6 +71,8 @@ function RevisionsDialogComponent() {
|
||||
onHidden={() => {
|
||||
setShown(false);
|
||||
setNote(undefined);
|
||||
setCurrentRevision(undefined);
|
||||
setRevisions(undefined);
|
||||
}}
|
||||
show={shown}
|
||||
>
|
||||
@ -202,17 +203,9 @@ function RevisionContent({ revisionItem, fullRevision }: { revisionItem?: Revisi
|
||||
return <></>;
|
||||
}
|
||||
|
||||
|
||||
switch (revisionItem.type) {
|
||||
case "text": {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (contentRef.current?.querySelector("span.math-tex")) {
|
||||
renderMathInElement(contentRef.current, { trust: true });
|
||||
}
|
||||
});
|
||||
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div>
|
||||
}
|
||||
case "text":
|
||||
return <RevisionContentText content={content} />
|
||||
case "code":
|
||||
return <pre style={CODE_STYLE}>{content}</pre>;
|
||||
case "image":
|
||||
@ -264,6 +257,16 @@ function RevisionContent({ revisionItem, fullRevision }: { revisionItem?: Revisi
|
||||
}
|
||||
}
|
||||
|
||||
function RevisionContentText({ content }: { content: string | Buffer<ArrayBufferLike> | undefined }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (contentRef.current?.querySelector("span.math-tex")) {
|
||||
renderMathInElement(contentRef.current, { trust: true });
|
||||
}
|
||||
}, [content]);
|
||||
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div>
|
||||
}
|
||||
|
||||
function RevisionFooter({ note }: { note?: FNote }) {
|
||||
if (!note) {
|
||||
return <></>;
|
||||
@ -291,14 +294,6 @@ function RevisionFooter({ note }: { note?: FNote }) {
|
||||
</>;
|
||||
}
|
||||
|
||||
export default class RevisionsDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <RevisionsDialogComponent />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function getNote(noteId?: string | null) {
|
||||
if (noteId) {
|
||||
return await froca.getNote(noteId);
|
||||
|
||||
@ -5,12 +5,11 @@ import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import server from "../../services/server";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function SortChildNotesDialogComponent() {
|
||||
export default function SortChildNotesDialog() {
|
||||
const [ parentNoteId, setParentNoteId ] = useState<string>();
|
||||
const [ sortBy, setSortBy ] = useState("title");
|
||||
const [ sortDirection, setSortDirection ] = useState("asc");
|
||||
@ -89,11 +88,3 @@ function SortChildNotesDialogComponent() {
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class SortChildNotesDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <SortChildNotesDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,13 +5,12 @@ import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormFileUpload from "../react/FormFileUpload";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import options from "../../services/options";
|
||||
import importService from "../../services/import.js";
|
||||
import tree from "../../services/tree";
|
||||
import useTriliumEvent from "../react/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
function UploadAttachmentsDialogComponent() {
|
||||
export default function UploadAttachmentsDialog() {
|
||||
const [ parentNoteId, setParentNoteId ] = useState<string>();
|
||||
const [ files, setFiles ] = useState<FileList | null>(null);
|
||||
const [ shrinkImages, setShrinkImages ] = useState(options.is("compressImages"));
|
||||
@ -24,12 +23,12 @@ function UploadAttachmentsDialogComponent() {
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
if (parentNoteId) {
|
||||
useEffect(() => {
|
||||
tree.getNoteTitle(parentNoteId).then((noteTitle) =>
|
||||
setDescription(t("upload_attachments.files_will_be_uploaded", { noteTitle })));
|
||||
}, [parentNoteId]);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!parentNoteId) return;
|
||||
|
||||
tree.getNoteTitle(parentNoteId).then((noteTitle) =>
|
||||
setDescription(t("upload_attachments.files_will_be_uploaded", { noteTitle })));
|
||||
}, [parentNoteId]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -64,11 +63,3 @@ function UploadAttachmentsDialogComponent() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default class UploadAttachmentsDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <UploadAttachmentsDialogComponent />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
import attributeService from "../services/attributes.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import { Dropdown } from "bootstrap";
|
||||
|
||||
type Editability = "auto" | "readOnly" | "autoReadOnlyDisabled";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="dropdown editability-select-widget">
|
||||
<style>
|
||||
.editability-dropdown {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.editability-dropdown .dropdown-item {
|
||||
display: flex !importamt;
|
||||
}
|
||||
|
||||
.editability-dropdown .dropdown-item > div {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.editability-dropdown .description {
|
||||
font-size: small;
|
||||
color: var(--muted-text-color);
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm select-button dropdown-toggle editability-button">
|
||||
<span class="editability-active-desc">${t("editability_select.auto")}</span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<div class="editability-dropdown dropdown-menu dropdown-menu-right tn-dropdown-list">
|
||||
<a class="dropdown-item" href="#" data-editability="auto">
|
||||
<span class="check">✓</span>
|
||||
<div>
|
||||
${t("editability_select.auto")}
|
||||
<div class="description">${t("editability_select.note_is_editable")}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" data-editability="readOnly">
|
||||
<span class="check">✓</span>
|
||||
<div>
|
||||
${t("editability_select.read_only")}
|
||||
<div class="description">${t("editability_select.note_is_read_only")}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" data-editability="autoReadOnlyDisabled">
|
||||
<span class="check">✓</span>
|
||||
<div>
|
||||
${t("editability_select.always_editable")}
|
||||
<div class="description">${t("editability_select.note_is_always_editable")}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class EditabilitySelectWidget extends NoteContextAwareWidget {
|
||||
|
||||
private dropdown!: Dropdown;
|
||||
private $editabilityActiveDesc!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
|
||||
|
||||
this.$editabilityActiveDesc = this.$widget.find(".editability-active-desc");
|
||||
|
||||
this.$widget.on("click", ".dropdown-item", async (e) => {
|
||||
this.dropdown.toggle();
|
||||
|
||||
const editability = $(e.target).closest("[data-editability]").attr("data-editability");
|
||||
|
||||
if (!this.note || !this.noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ownedAttr of this.note.getOwnedLabels()) {
|
||||
if (["readOnly", "autoReadOnlyDisabled"].includes(ownedAttr.name)) {
|
||||
await attributeService.removeAttributeById(this.noteId, ownedAttr.attributeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (editability && editability !== "auto") {
|
||||
await attributeService.addLabel(this.noteId, editability);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
let editability: Editability = "auto";
|
||||
|
||||
if (this.note?.isLabelTruthy("readOnly")) {
|
||||
editability = "readOnly";
|
||||
} else if (this.note?.isLabelTruthy("autoReadOnlyDisabled")) {
|
||||
editability = "autoReadOnlyDisabled";
|
||||
}
|
||||
|
||||
const labels = {
|
||||
auto: t("editability_select.auto"),
|
||||
readOnly: t("editability_select.read_only"),
|
||||
autoReadOnlyDisabled: t("editability_select.always_editable")
|
||||
};
|
||||
|
||||
this.$widget.find(".dropdown-item").removeClass("selected");
|
||||
this.$widget.find(`.dropdown-item[data-editability='${editability}']`).addClass("selected");
|
||||
|
||||
this.$editabilityActiveDesc.text(labels[editability]);
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
// taken from the HTML source of https://boxicons.com/
|
||||
|
||||
interface Category {
|
||||
export interface Category {
|
||||
name: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
59
apps/client/src/widgets/note_icon.css
Normal file
59
apps/client/src/widgets/note_icon.css
Normal file
@ -0,0 +1,59 @@
|
||||
.note-icon-widget {
|
||||
padding-top: 3px;
|
||||
padding-left: 7px;
|
||||
margin-right: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.note-icon-widget button.note-icon {
|
||||
font-size: 180%;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.note-icon-widget button.note-icon:hover {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-icon-widget .dropdown-menu {
|
||||
border-radius: 10px;
|
||||
border-width: 2px;
|
||||
box-shadow: 10px 10px 93px -25px black;
|
||||
padding: 10px 15px 10px 15px !important;
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-right: 20px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row span {
|
||||
display: block;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list {
|
||||
height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
font-size: 180%;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span:hover {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
@ -1,229 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import server from "../services/server.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import type { Icon } from "./icon_list.js";
|
||||
import { Dropdown } from "bootstrap";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-icon-widget dropdown">
|
||||
<style>
|
||||
.note-icon-widget {
|
||||
padding-top: 3px;
|
||||
padding-left: 7px;
|
||||
margin-right: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.note-icon-widget button.note-icon {
|
||||
font-size: 180%;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.note-icon-widget button.note-icon:hover {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-icon-widget .dropdown-menu {
|
||||
border-radius: 10px;
|
||||
border-width: 2px;
|
||||
box-shadow: 10px 10px 93px -25px black;
|
||||
padding: 10px 15px 10px 15px !important;
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-right: 20px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row span {
|
||||
display: block;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list {
|
||||
height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
font-size: 180%;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span:hover {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<button class="btn dropdown-toggle note-icon" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="${t("note_icon.change_note_icon")}"></button>
|
||||
<div class="dropdown-menu" aria-labelledby="note-path-list-button" style="width: 610px;">
|
||||
<div class="filter-row">
|
||||
<span>${t("note_icon.category")}</span> <select name="icon-category" class="form-select"></select>
|
||||
|
||||
<span>${t("note_icon.search")}</span> <input type="text" name="icon-search" class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="icon-list"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
interface IconToCountCache {
|
||||
iconClassToCountMap: Record<string, number>;
|
||||
}
|
||||
|
||||
export default class NoteIconWidget extends NoteContextAwareWidget {
|
||||
|
||||
private dropdown!: bootstrap.Dropdown;
|
||||
private $icon!: JQuery<HTMLElement>;
|
||||
private $iconList!: JQuery<HTMLElement>;
|
||||
private $iconCategory!: JQuery<HTMLElement>;
|
||||
private $iconSearch!: JQuery<HTMLElement>;
|
||||
private iconToCountCache!: Promise<IconToCountCache | null> | null;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
|
||||
|
||||
this.$icon = this.$widget.find("button.note-icon");
|
||||
this.$iconList = this.$widget.find(".icon-list");
|
||||
this.$iconList.on("click", "span", async (e) => {
|
||||
const clazz = $(e.target).attr("class");
|
||||
|
||||
if (this.noteId && this.note) {
|
||||
await attributeService.setLabel(this.noteId, this.note.hasOwnedLabel("workspace") ? "workspaceIconClass" : "iconClass", clazz);
|
||||
}
|
||||
});
|
||||
|
||||
this.$iconCategory = this.$widget.find("select[name='icon-category']");
|
||||
this.$iconCategory.on("change", () => this.renderDropdown());
|
||||
this.$iconCategory.on("click", (e) => e.stopPropagation());
|
||||
|
||||
this.$iconSearch = this.$widget.find("input[name='icon-search']");
|
||||
this.$iconSearch.on("input", () => this.renderDropdown());
|
||||
|
||||
this.$widget.on("show.bs.dropdown", async () => {
|
||||
const { categories } = (await import("./icon_list.js")).default;
|
||||
|
||||
this.$iconCategory.empty();
|
||||
|
||||
for (const category of categories) {
|
||||
this.$iconCategory.append($("<option>").text(category.name).attr("value", category.id));
|
||||
}
|
||||
|
||||
this.$iconSearch.val("");
|
||||
|
||||
this.renderDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$icon.removeClass().addClass(`${note.getIcon()} note-icon`);
|
||||
this.$icon.prop("disabled", !!(this.noteContext?.viewScope?.viewMode !== "default"));
|
||||
this.dropdown.hide();
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (this.noteId && loadResults.isNoteReloaded(this.noteId)) {
|
||||
this.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const attr of loadResults.getAttributeRows()) {
|
||||
if (attr.type === "label" && ["iconClass", "workspaceIconClass"].includes(attr.name ?? "") && attributeService.isAffecting(attr, this.note)) {
|
||||
this.refresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async renderDropdown() {
|
||||
const iconToCount = await this.getIconToCountMap();
|
||||
const { icons } = (await import("./icon_list.js")).default;
|
||||
|
||||
this.$iconList.empty();
|
||||
|
||||
if (this.getIconLabels().length > 0) {
|
||||
this.$iconList.append(
|
||||
$(`<div style="text-align: center">`).append(
|
||||
$(`<button class="btn btn-sm">${t("note_icon.reset-default")}</button>`).on("click", () =>
|
||||
this.getIconLabels().forEach((label) => {
|
||||
if (this.noteId) {
|
||||
attributeService.removeAttributeById(this.noteId, label.attributeId);
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const categoryId = parseInt(String(this.$iconCategory.find("option:selected")?.val()));
|
||||
const search = String(this.$iconSearch.val())?.trim()?.toLowerCase();
|
||||
|
||||
const filteredIcons = icons.filter((icon) => {
|
||||
if (categoryId && icon.category_id !== categoryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
if (!icon.name.includes(search) && !icon.term?.find((t) => t.includes(search))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (iconToCount) {
|
||||
filteredIcons.sort((a, b) => {
|
||||
const countA = iconToCount[a.className ?? ""] || 0;
|
||||
const countB = iconToCount[b.className ?? ""] || 0;
|
||||
|
||||
return countB - countA;
|
||||
});
|
||||
}
|
||||
|
||||
for (const icon of filteredIcons) {
|
||||
this.$iconList.append(this.renderIcon(icon));
|
||||
}
|
||||
|
||||
this.$iconSearch.focus();
|
||||
}
|
||||
|
||||
async getIconToCountMap() {
|
||||
if (!this.iconToCountCache) {
|
||||
this.iconToCountCache = server.get<IconToCountCache>("other/icon-usage");
|
||||
setTimeout(() => (this.iconToCountCache = null), 20000); // invalidate cache after 20 seconds
|
||||
}
|
||||
|
||||
return (await this.iconToCountCache)?.iconClassToCountMap;
|
||||
}
|
||||
|
||||
renderIcon(icon: Icon) {
|
||||
return $("<span>")
|
||||
.addClass("bx " + icon.className)
|
||||
.attr("title", icon.name);
|
||||
}
|
||||
|
||||
getIconLabels() {
|
||||
if (!this.note) {
|
||||
return [];
|
||||
}
|
||||
return this.note.getOwnedLabels().filter((label) => ["workspaceIconClass", "iconClass"].includes(label.name));
|
||||
}
|
||||
}
|
||||
184
apps/client/src/widgets/note_icon.tsx
Normal file
184
apps/client/src/widgets/note_icon.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import Dropdown from "./react/Dropdown";
|
||||
import "./note_icon.css";
|
||||
import { t } from "i18next";
|
||||
import { useNoteContext, useNoteLabel } from "./react/hooks";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import server from "../services/server";
|
||||
import type { Category, Icon } from "./icon_list";
|
||||
import FormTextBox from "./react/FormTextBox";
|
||||
import FormSelect from "./react/FormSelect";
|
||||
import FNote from "../entities/fnote";
|
||||
import attributes from "../services/attributes";
|
||||
import Button from "./react/Button";
|
||||
|
||||
interface IconToCountCache {
|
||||
iconClassToCountMap: Record<string, number>;
|
||||
}
|
||||
|
||||
interface IconData {
|
||||
iconToCount: Record<string, number>;
|
||||
categories: Category[];
|
||||
icons: Icon[];
|
||||
}
|
||||
|
||||
let fullIconData: {
|
||||
categories: Category[];
|
||||
icons: Icon[];
|
||||
};
|
||||
let iconToCountCache!: Promise<IconToCountCache> | null;
|
||||
|
||||
export default function NoteIcon() {
|
||||
const { note, viewScope } = useNoteContext();
|
||||
const [ icon, setIcon ] = useState<string | null | undefined>();
|
||||
const [ iconClass ] = useNoteLabel(note, "iconClass");
|
||||
const [ workspaceIconClass ] = useNoteLabel(note, "workspaceIconClass");
|
||||
|
||||
useEffect(() => {
|
||||
setIcon(note?.getIcon());
|
||||
}, [ note, iconClass, workspaceIconClass ]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="note-icon-widget"
|
||||
title={t("note_icon.change_note_icon")}
|
||||
dropdownContainerStyle={{ width: "610px" }}
|
||||
buttonClassName={`note-icon ${icon ?? "bx bx-empty"}`}
|
||||
hideToggleArrow
|
||||
disabled={viewScope?.viewMode !== "default"}
|
||||
>
|
||||
{ note && <NoteIconList note={note} /> }
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteIconList({ note }: { note: FNote }) {
|
||||
const searchBoxRef = useRef<HTMLInputElement>(null);
|
||||
const [ search, setSearch ] = useState<string>();
|
||||
const [ categoryId, setCategoryId ] = useState<string>("0");
|
||||
const [ iconData, setIconData ] = useState<IconData>();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadIcons() {
|
||||
if (!fullIconData) {
|
||||
fullIconData = (await import("./icon_list.js")).default;
|
||||
}
|
||||
|
||||
// Filter by text and/or category.
|
||||
let icons: Icon[] = fullIconData.icons;
|
||||
const processedSearch = search?.trim()?.toLowerCase();
|
||||
if (processedSearch || categoryId) {
|
||||
icons = icons.filter((icon) => {
|
||||
if (categoryId !== "0" && String(icon.category_id) !== categoryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (processedSearch) {
|
||||
if (!icon.name.includes(processedSearch) &&
|
||||
!icon.term?.find((t) => t.includes(processedSearch))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by count.
|
||||
const iconToCount = await getIconToCountMap();
|
||||
if (iconToCount) {
|
||||
icons.sort((a, b) => {
|
||||
const countA = iconToCount[a.className ?? ""] || 0;
|
||||
const countB = iconToCount[b.className ?? ""] || 0;
|
||||
|
||||
return countB - countA;
|
||||
});
|
||||
}
|
||||
|
||||
setIconData({
|
||||
iconToCount,
|
||||
icons,
|
||||
categories: fullIconData.categories
|
||||
})
|
||||
}
|
||||
|
||||
loadIcons();
|
||||
}, [ search, categoryId ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="filter-row">
|
||||
<span>{t("note_icon.category")}</span>
|
||||
<FormSelect
|
||||
name="icon-category"
|
||||
values={fullIconData?.categories ?? []}
|
||||
currentValue={categoryId} onChange={setCategoryId}
|
||||
keyProperty="id" titleProperty="name"
|
||||
/>
|
||||
|
||||
<span>{t("note_icon.search")}</span>
|
||||
<FormTextBox
|
||||
inputRef={searchBoxRef}
|
||||
type="text"
|
||||
name="icon-search"
|
||||
currentValue={search} onChange={setSearch}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="icon-list"
|
||||
onClick={(e) => {
|
||||
const clickedTarget = e.target as HTMLElement;
|
||||
|
||||
if (!clickedTarget.classList.contains("bx")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconClass = Array.from(clickedTarget.classList.values()).join(" ");
|
||||
if (note) {
|
||||
const attributeToSet = note.hasOwnedLabel("workspace") ? "workspaceIconClass" : "iconClass";
|
||||
attributes.setLabel(note.noteId, attributeToSet, iconClass);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getIconLabels(note).length > 0 && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Button
|
||||
text={t("note_icon.reset-default")}
|
||||
onClick={() => {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
for (const label of getIconLabels(note)) {
|
||||
attributes.removeAttributeById(note.noteId, label.attributeId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(iconData?.icons ?? []).map(({className, name}) => (
|
||||
<span class={`bx ${className}`} title={name} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function getIconToCountMap() {
|
||||
if (!iconToCountCache) {
|
||||
iconToCountCache = server.get<IconToCountCache>("other/icon-usage");
|
||||
setTimeout(() => (iconToCountCache = null), 20000); // invalidate cache after 20 seconds
|
||||
}
|
||||
|
||||
return (await iconToCountCache).iconClassToCountMap;
|
||||
}
|
||||
|
||||
function getIconLabels(note: FNote) {
|
||||
if (!note) {
|
||||
return [];
|
||||
}
|
||||
return note.getOwnedLabels()
|
||||
.filter((label) => ["workspaceIconClass", "iconClass"]
|
||||
.includes(label.name));
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
import { Dropdown } from "bootstrap";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import { getAvailableLocales, getLocaleById, t } from "../services/i18n.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import attributes from "../services/attributes.js";
|
||||
import type { Locale } from "@triliumnext/commons";
|
||||
import options from "../services/options.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`\
|
||||
<div class="dropdown note-language-widget">
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle select-button note-language-button">
|
||||
<span class="note-language-desc"></span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<div class="note-language-dropdown dropdown-menu dropdown-menu-left tn-dropdown-list"></div>
|
||||
<button class="language-help-button icon-action bx bx-help-circle" type="button" data-in-app-help="B0lcI9xz1r8K" title="${t("open-help-page")}"></button>
|
||||
|
||||
<style>
|
||||
.note-language-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-help-button {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.note-language-dropdown [dir=rtl] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dropdown-item.rtl > .check {
|
||||
order: 1;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const DEFAULT_LOCALE: Locale = {
|
||||
id: "",
|
||||
name: t("note_language.not_set")
|
||||
};
|
||||
|
||||
export default class NoteLanguageWidget extends NoteContextAwareWidget {
|
||||
|
||||
private dropdown!: Dropdown;
|
||||
private $noteLanguageDropdown!: JQuery<HTMLElement>;
|
||||
private $noteLanguageDesc!: JQuery<HTMLElement>;
|
||||
private locales: (Locale | "---")[];
|
||||
private currentLanguageId?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.locales = NoteLanguageWidget.#buildLocales();
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
|
||||
this.$widget.on("show.bs.dropdown", () => this.renderDropdown());
|
||||
|
||||
this.$noteLanguageDropdown = this.$widget.find(".note-language-dropdown")
|
||||
this.$noteLanguageDesc = this.$widget.find(".note-language-desc");
|
||||
}
|
||||
|
||||
renderDropdown() {
|
||||
this.$noteLanguageDropdown.empty();
|
||||
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const locale of this.locales) {
|
||||
if (typeof locale === "object") {
|
||||
const $title = $("<span>").text(locale.name);
|
||||
|
||||
const $link = $('<a class="dropdown-item">')
|
||||
.attr("data-language", locale.id)
|
||||
.append('<span class="check">✓</span> ')
|
||||
.append($title)
|
||||
.on("click", () => {
|
||||
const languageId = $link.attr("data-language") ?? "";
|
||||
this.save(languageId);
|
||||
});
|
||||
|
||||
if (locale.rtl) {
|
||||
$link.attr("dir", "rtl");
|
||||
}
|
||||
|
||||
if (locale.id === this.currentLanguageId) {
|
||||
$link.addClass("selected");
|
||||
}
|
||||
|
||||
this.$noteLanguageDropdown.append($link);
|
||||
} else {
|
||||
this.$noteLanguageDropdown.append('<div class="dropdown-divider"></div>');
|
||||
}
|
||||
}
|
||||
|
||||
const $configureLink = $('<a class="dropdown-item">')
|
||||
.append(`<span>${t("note_language.configure-languages")}</span>`)
|
||||
.on("click", () => appContext.tabManager.openContextWithNote("_optionsLocalization", { activate: true }));
|
||||
this.$noteLanguageDropdown.append($configureLink);
|
||||
}
|
||||
|
||||
async save(languageId: string) {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
attributes.setAttribute(this.note, "label", "language", languageId);
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const currentLanguageId = note.getLabelValue("language") ?? "";
|
||||
const language = getLocaleById(currentLanguageId) ?? DEFAULT_LOCALE;
|
||||
this.currentLanguageId = currentLanguageId;
|
||||
this.$noteLanguageDesc.text(language.name);
|
||||
this.dropdown.hide();
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("languages")) {
|
||||
this.locales = NoteLanguageWidget.#buildLocales();
|
||||
}
|
||||
|
||||
if (loadResults.getAttributeRows().find((a) => a.noteId === this.noteId && a.name === "language")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
static #buildLocales() {
|
||||
const enabledLanguages = JSON.parse(options.get("languages") ?? "[]") as string[];
|
||||
const filteredLanguages = getAvailableLocales().filter((l) => typeof l !== "object" || enabledLanguages.includes(l.id));
|
||||
const leftToRightLanguages = filteredLanguages.filter((l) => !l.rtl);
|
||||
const rightToLeftLanguages = filteredLanguages.filter((l) => l.rtl);
|
||||
|
||||
let locales: ("---" | Locale)[] = [
|
||||
DEFAULT_LOCALE
|
||||
];
|
||||
|
||||
if (leftToRightLanguages.length > 0) {
|
||||
locales = [
|
||||
...locales,
|
||||
"---",
|
||||
...leftToRightLanguages
|
||||
];
|
||||
}
|
||||
|
||||
if (rightToLeftLanguages.length > 0) {
|
||||
locales = [
|
||||
...locales,
|
||||
"---",
|
||||
...rightToLeftLanguages
|
||||
];
|
||||
}
|
||||
|
||||
// This will separate the list of languages from the "Configure languages" button.
|
||||
// If there is at least one language.
|
||||
locales.push("---");
|
||||
return locales;
|
||||
}
|
||||
|
||||
}
|
||||
25
apps/client/src/widgets/note_title.css
Normal file
25
apps/client/src/widgets/note_title.css
Normal file
@ -0,0 +1,25 @@
|
||||
.note-title-widget {
|
||||
flex-grow: 1000;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-title-widget input.note-title {
|
||||
font-size: 110%;
|
||||
border: 0;
|
||||
margin: 2px 0px;
|
||||
min-width: 5em;
|
||||
width: 100%;
|
||||
padding: 1px 12px;
|
||||
}
|
||||
|
||||
.note-title-widget input.note-title.protected {
|
||||
text-shadow: 4px 4px 4px var(--muted-text-color);
|
||||
}
|
||||
|
||||
body.mobile .note-title-widget input.note-title {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body.desktop .note-title-widget input.note-title {
|
||||
font-size: 180%;
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import server from "../services/server.js";
|
||||
import SpacedUpdate from "../services/spaced_update.js";
|
||||
import appContext, { type EventData } from "../components/app_context.js";
|
||||
import branchService from "../services/branches.js";
|
||||
import shortcutService from "../services/shortcuts.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-title-widget">
|
||||
<style>
|
||||
.note-title-widget {
|
||||
flex-grow: 1000;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-title-widget input.note-title {
|
||||
font-size: 110%;
|
||||
border: 0;
|
||||
margin: 2px 0px;
|
||||
min-width: 5em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.mobile .note-title-widget input.note-title {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body.desktop .note-title-widget input.note-title {
|
||||
font-size: 180%;
|
||||
}
|
||||
|
||||
.note-title-widget input.note-title.protected {
|
||||
text-shadow: 4px 4px 4px var(--muted-text-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<input autocomplete="off" value="" placeholder="${t("note_title.placeholder")}" class="note-title" tabindex="100">
|
||||
</div>`;
|
||||
|
||||
export default class NoteTitleWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $noteTitle!: JQuery<HTMLElement>;
|
||||
private deleteNoteOnEscape: boolean;
|
||||
private spacedUpdate: SpacedUpdate;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.spacedUpdate = new SpacedUpdate(async () => {
|
||||
const title = this.$noteTitle.val();
|
||||
|
||||
if (this.note) {
|
||||
protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);
|
||||
}
|
||||
|
||||
await server.put(`notes/${this.noteId}/title`, { title }, this.componentId);
|
||||
});
|
||||
|
||||
this.deleteNoteOnEscape = false;
|
||||
|
||||
appContext.addBeforeUnloadListener(this);
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$noteTitle = this.$widget.find(".note-title");
|
||||
|
||||
this.$noteTitle.on("input", () => this.spacedUpdate.scheduleUpdate());
|
||||
|
||||
this.$noteTitle.on("blur", () => {
|
||||
this.spacedUpdate.updateNowIfNecessary();
|
||||
|
||||
this.deleteNoteOnEscape = false;
|
||||
});
|
||||
|
||||
shortcutService.bindElShortcut(this.$noteTitle, "esc", () => {
|
||||
if (this.deleteNoteOnEscape && this.noteContext?.isActive() && this.noteContext?.note) {
|
||||
branchService.deleteNotes(Object.values(this.noteContext.note.parentToBranch));
|
||||
}
|
||||
});
|
||||
|
||||
shortcutService.bindElShortcut(this.$noteTitle, "return", () => {
|
||||
this.triggerCommand("focusOnDetail", { ntxId: this.noteContext?.ntxId });
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const isReadOnly =
|
||||
(note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable())
|
||||
|| utils.isLaunchBarConfig(note.noteId)
|
||||
|| this.noteContext?.viewScope?.viewMode !== "default";
|
||||
|
||||
this.$noteTitle.val(isReadOnly ? (await this.noteContext?.getNavigationTitle()) || "" : note.title);
|
||||
this.$noteTitle.prop("readonly", isReadOnly);
|
||||
|
||||
this.setProtectedStatus(note);
|
||||
}
|
||||
|
||||
setProtectedStatus(note: FNote) {
|
||||
this.$noteTitle.toggleClass("protected", !!note.isProtected);
|
||||
}
|
||||
|
||||
async beforeNoteSwitchEvent({ noteContext }: EventData<"beforeNoteSwitch">) {
|
||||
if (this.isNoteContext(noteContext.ntxId)) {
|
||||
await this.spacedUpdate.updateNowIfNecessary();
|
||||
}
|
||||
}
|
||||
|
||||
async beforeNoteContextRemoveEvent({ ntxIds }: EventData<"beforeNoteContextRemove">) {
|
||||
if (this.isNoteContext(ntxIds)) {
|
||||
await this.spacedUpdate.updateNowIfNecessary();
|
||||
}
|
||||
}
|
||||
|
||||
focusOnTitleEvent() {
|
||||
if (this.noteContext && this.noteContext.isActive()) {
|
||||
this.$noteTitle.trigger("focus");
|
||||
}
|
||||
}
|
||||
|
||||
focusAndSelectTitleEvent({ isNewNote } = { isNewNote: false }) {
|
||||
if (this.noteContext && this.noteContext.isActive()) {
|
||||
this.$noteTitle.trigger("focus").trigger("select");
|
||||
|
||||
this.deleteNoteOnEscape = isNewNote;
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isNoteReloaded(this.noteId) && this.note) {
|
||||
// not updating the title specifically since the synced title might be older than what the user is currently typing
|
||||
this.setProtectedStatus(this.note);
|
||||
}
|
||||
|
||||
if (loadResults.isNoteReloaded(this.noteId, this.componentId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
beforeUnloadEvent() {
|
||||
return this.spacedUpdate.isAllSavedAndTriggerUpdate();
|
||||
}
|
||||
}
|
||||
99
apps/client/src/widgets/note_title.tsx
Normal file
99
apps/client/src/widgets/note_title.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { t } from "../services/i18n";
|
||||
import FormTextBox from "./react/FormTextBox";
|
||||
import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks";
|
||||
import protected_session_holder from "../services/protected_session_holder";
|
||||
import server from "../services/server";
|
||||
import "./note_title.css";
|
||||
import { isLaunchBarConfig } from "../services/utils";
|
||||
import appContext from "../components/app_context";
|
||||
import branches from "../services/branches";
|
||||
|
||||
export default function NoteTitleWidget() {
|
||||
const { note, noteId, componentId, viewScope, noteContext, parentComponent } = useNoteContext();
|
||||
const title = useNoteProperty(note, "title", componentId);
|
||||
const isProtected = useNoteProperty(note, "isProtected");
|
||||
const newTitle = useRef("");
|
||||
|
||||
const [ isReadOnly, setReadOnly ] = useState<boolean>(false);
|
||||
const [ navigationTitle, setNavigationTitle ] = useState<string | null>(null);
|
||||
|
||||
// Manage read-only
|
||||
useEffect(() => {
|
||||
const isReadOnly = note === null
|
||||
|| note === undefined
|
||||
|| (note.isProtected && !protected_session_holder.isProtectedSessionAvailable())
|
||||
|| isLaunchBarConfig(note.noteId)
|
||||
|| viewScope?.viewMode !== "default";
|
||||
setReadOnly(isReadOnly);
|
||||
}, [ note, note?.noteId, note?.isProtected, viewScope?.viewMode ]);
|
||||
|
||||
// Manage the title for read-only notes
|
||||
useEffect(() => {
|
||||
if (isReadOnly) {
|
||||
noteContext?.getNavigationTitle().then(setNavigationTitle);
|
||||
}
|
||||
}, [isReadOnly]);
|
||||
|
||||
// Save changes to title.
|
||||
const spacedUpdate = useSpacedUpdate(async () => {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
protected_session_holder.touchProtectedSessionIfNecessary(note);
|
||||
await server.put<void>(`notes/${noteId}/title`, { title: newTitle.current }, componentId);
|
||||
});
|
||||
|
||||
// Prevent user from navigating away if the spaced update is not done.
|
||||
useEffect(() => {
|
||||
appContext.addBeforeUnloadListener(() => spacedUpdate.isAllSavedAndTriggerUpdate());
|
||||
}, []);
|
||||
useTriliumEvents([ "beforeNoteSwitch", "beforeNoteContextRemove" ], () => spacedUpdate.updateNowIfNecessary());
|
||||
|
||||
// Manage focus.
|
||||
const textBoxRef = useRef<HTMLInputElement>(null);
|
||||
const isNewNote = useRef<boolean>();
|
||||
useTriliumEvents([ "focusOnTitle", "focusAndSelectTitle" ], (e, eventName) => {
|
||||
if (noteContext?.isActive() && textBoxRef.current) {
|
||||
textBoxRef.current.focus();
|
||||
if (eventName === "focusAndSelectTitle") {
|
||||
textBoxRef.current.select();
|
||||
}
|
||||
isNewNote.current = ("isNewNote" in e ? e.isNewNote : false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="note-title-widget">
|
||||
{note && <FormTextBox
|
||||
inputRef={textBoxRef}
|
||||
autocomplete="off"
|
||||
currentValue={(!isReadOnly ? title : navigationTitle) ?? ""}
|
||||
placeholder={t("note_title.placeholder")}
|
||||
className={`note-title ${isProtected ? "protected" : ""}`}
|
||||
tabIndex={100}
|
||||
readOnly={isReadOnly}
|
||||
onChange={(newValue) => {
|
||||
newTitle.current = newValue;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Focus on the note content when pressing enter.
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
parentComponent.triggerCommand("focusOnDetail", { ntxId: noteContext?.ntxId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && isNewNote.current && noteContext?.isActive() && note) {
|
||||
branches.deleteNotes(Object.values(note.parentToBranch));
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
spacedUpdate.updateNowIfNecessary();
|
||||
isNewNote.current = false;
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,175 +0,0 @@
|
||||
import { Dropdown } from "bootstrap";
|
||||
import { NOTE_TYPES } from "../services/note_types.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import dialogService from "../services/dialog.js";
|
||||
import mimeTypesService from "../services/mime_types.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import server from "../services/server.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => nt.reserved || nt.static).map((nt) => nt.type);
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="dropdown note-type-widget">
|
||||
<style>
|
||||
.note-type-dropdown {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle select-button note-type-button">
|
||||
<span class="note-type-desc"></span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<div class="note-type-dropdown dropdown-menu dropdown-menu-left tn-dropdown-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class NoteTypeWidget extends NoteContextAwareWidget {
|
||||
|
||||
private dropdown!: Dropdown;
|
||||
private $noteTypeDropdown!: JQuery<HTMLElement>;
|
||||
private $noteTypeButton!: JQuery<HTMLElement>;
|
||||
private $noteTypeDesc!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
|
||||
|
||||
this.$widget.on("show.bs.dropdown", () => this.renderDropdown());
|
||||
|
||||
this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown");
|
||||
this.$noteTypeButton = this.$widget.find(".note-type-button");
|
||||
this.$noteTypeDesc = this.$widget.find(".note-type-desc");
|
||||
|
||||
this.$widget.on("click", ".dropdown-item", () => this.dropdown.toggle());
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$noteTypeButton.prop("disabled", () => NOT_SELECTABLE_NOTE_TYPES.includes(note.type));
|
||||
|
||||
this.$noteTypeDesc.text(await this.findTypeTitle(note.type, note.mime));
|
||||
|
||||
this.dropdown.hide();
|
||||
}
|
||||
|
||||
/** the actual body is rendered lazily on note-type button click */
|
||||
async renderDropdown() {
|
||||
this.$noteTypeDropdown.empty();
|
||||
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const noteType of NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static)) {
|
||||
let $typeLink: JQuery<HTMLElement>;
|
||||
|
||||
const $title = $("<span>").text(noteType.title);
|
||||
|
||||
if (noteType.isNew) {
|
||||
$title.append($(`<span class="badge new-note-type-badge">`).text(t("note_types.new-feature")));
|
||||
}
|
||||
|
||||
if (noteType.isBeta) {
|
||||
$title.append($(`<span class="badge">`).text(t("note_types.beta-feature")));
|
||||
}
|
||||
|
||||
if (noteType.type !== "code") {
|
||||
$typeLink = $('<a class="dropdown-item">')
|
||||
.attr("data-note-type", noteType.type)
|
||||
.append('<span class="check">✓</span> ')
|
||||
.append($title)
|
||||
.on("click", (e) => {
|
||||
const type = $typeLink.attr("data-note-type");
|
||||
const noteType = NOTE_TYPES.find((nt) => nt.type === type);
|
||||
|
||||
if (noteType) {
|
||||
this.save(noteType.type, noteType.mime);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.$noteTypeDropdown.append('<div class="dropdown-divider"></div>');
|
||||
$typeLink = $('<a class="dropdown-item disabled">').attr("data-note-type", noteType.type).append('<span class="check">✓</span> ').append($("<strong>").text(noteType.title));
|
||||
}
|
||||
|
||||
if (this.note.type === noteType.type) {
|
||||
$typeLink.addClass("selected");
|
||||
}
|
||||
|
||||
this.$noteTypeDropdown.append($typeLink);
|
||||
}
|
||||
|
||||
for (const mimeType of mimeTypesService.getMimeTypes()) {
|
||||
if (!mimeType.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const $mimeLink = $('<a class="dropdown-item">')
|
||||
.attr("data-mime-type", mimeType.mime)
|
||||
.append('<span class="check">✓</span> ')
|
||||
.append($("<span>").text(mimeType.title))
|
||||
.on("click", (e) => {
|
||||
const $link = $(e.target).closest(".dropdown-item");
|
||||
|
||||
this.save("code", $link.attr("data-mime-type") ?? "");
|
||||
});
|
||||
|
||||
if (this.note.type === "code" && this.note.mime === mimeType.mime) {
|
||||
$mimeLink.addClass("selected");
|
||||
|
||||
this.$noteTypeDesc.text(mimeType.title);
|
||||
}
|
||||
|
||||
this.$noteTypeDropdown.append($mimeLink);
|
||||
}
|
||||
}
|
||||
|
||||
async findTypeTitle(type: NoteType, mime: string) {
|
||||
if (type === "code") {
|
||||
const mimeTypes = mimeTypesService.getMimeTypes();
|
||||
const found = mimeTypes.find((mt) => mt.mime === mime);
|
||||
|
||||
return found ? found.title : mime;
|
||||
} else {
|
||||
const noteType = NOTE_TYPES.find((nt) => nt.type === type);
|
||||
|
||||
return noteType ? noteType.title : type;
|
||||
}
|
||||
}
|
||||
|
||||
async save(type: NoteType, mime?: string) {
|
||||
if (type === this.note?.type && mime === this.note?.mime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== this.note?.type && !(await this.confirmChangeIfContent())) {
|
||||
return;
|
||||
}
|
||||
|
||||
await server.put(`notes/${this.noteId}/type`, { type, mime });
|
||||
}
|
||||
|
||||
async confirmChangeIfContent() {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await this.note.getBlob();
|
||||
|
||||
if (!blob?.content || !blob.content.trim().length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await dialogService.confirm(t("note_types.confirm-change"));
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isNoteReloaded(this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,16 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import options from "../../services/options.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import type FAttribute from "../../entities/fattribute.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import server from "../services/server.js";
|
||||
import ws from "../services/ws.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import noteAutocompleteService from "../services/note_autocomplete.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import options from "../services/options.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { Attribute } from "../services/attribute_parser.js";
|
||||
import type FAttribute from "../entities/fattribute.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="promoted-attributes-widget">
|
||||
@ -1,39 +0,0 @@
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import SwitchWidget from "./switch.js";
|
||||
|
||||
export default class ProtectedNoteSwitchWidget extends SwitchWidget {
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.switchOnName = t("protect_note.toggle-on");
|
||||
this.switchOnTooltip = t("protect_note.toggle-on-hint");
|
||||
|
||||
this.switchOffName = t("protect_note.toggle-off");
|
||||
this.switchOffTooltip = t("protect_note.toggle-off-hint");
|
||||
}
|
||||
|
||||
switchOn() {
|
||||
if (this.noteId) {
|
||||
protectedSessionService.protectNote(this.noteId, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
switchOff() {
|
||||
if (this.noteId) {
|
||||
protectedSessionService.protectNote(this.noteId, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.isToggled = note.isProtected;
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isNoteReloaded(this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,19 @@
|
||||
import { CommandNames } from "../../components/app_context";
|
||||
|
||||
interface ActionButtonProps {
|
||||
text: string;
|
||||
titlePosition?: "bottom"; // TODO: Use it
|
||||
icon: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
triggerCommand?: CommandNames;
|
||||
}
|
||||
|
||||
export default function ActionButton({ text, icon, onClick }: ActionButtonProps) {
|
||||
export default function ActionButton({ text, icon, className, onClick, triggerCommand }: ActionButtonProps) {
|
||||
return <button
|
||||
class={`icon-action ${icon}`}
|
||||
class={`icon-action ${icon} ${className ?? ""}`}
|
||||
title={text}
|
||||
onClick={onClick}
|
||||
onClick={onClick}
|
||||
data-trigger-command={triggerCommand}
|
||||
/>;
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import type { RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useRef, useMemo } from "preact/hooks";
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { memo } from "preact/compat";
|
||||
import { CommandNames } from "../../components/app_context";
|
||||
|
||||
interface ButtonProps {
|
||||
export interface ButtonProps {
|
||||
name?: string;
|
||||
/** Reference to the button element. Mostly useful for requesting focus. */
|
||||
buttonRef?: RefObject<HTMLButtonElement>;
|
||||
@ -17,9 +18,11 @@ interface ButtonProps {
|
||||
disabled?: boolean;
|
||||
size?: "normal" | "small" | "micro";
|
||||
style?: CSSProperties;
|
||||
triggerCommand?: CommandNames;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const Button = memo(({ name, buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style }: ButtonProps) => {
|
||||
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
|
||||
// Memoize classes array to prevent recreation
|
||||
const classes = useMemo(() => {
|
||||
const classList: string[] = ["btn"];
|
||||
@ -39,8 +42,6 @@ const Button = memo(({ name, buttonRef: _buttonRef, className, text, onClick, ke
|
||||
return classList.join(" ");
|
||||
}, [primary, className, size]);
|
||||
|
||||
const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Memoize keyboard shortcut rendering
|
||||
const shortcutElements = useMemo(() => {
|
||||
if (!keyboardShortcut) return null;
|
||||
@ -57,11 +58,13 @@ const Button = memo(({ name, buttonRef: _buttonRef, className, text, onClick, ke
|
||||
<button
|
||||
name={name}
|
||||
className={classes}
|
||||
type={onClick ? "button" : "submit"}
|
||||
type={onClick || triggerCommand ? "button" : "submit"}
|
||||
onClick={onClick}
|
||||
ref={buttonRef}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
data-trigger-command={triggerCommand}
|
||||
{...restProps}
|
||||
>
|
||||
{icon && <span className={`bx ${icon}`}></span>}
|
||||
{text} {shortcutElements}
|
||||
|
||||
106
apps/client/src/widgets/react/CKEditor.tsx
Normal file
106
apps/client/src/widgets/react/CKEditor.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import type { CKTextEditor, AttributeEditor, EditorConfig, ModelPosition } from "@triliumnext/ckeditor5";
|
||||
import { useEffect, useImperativeHandle, useRef } from "preact/compat";
|
||||
import { MutableRef } from "preact/hooks";
|
||||
|
||||
export interface CKEditorApi {
|
||||
focus(): void;
|
||||
/**
|
||||
* Imperatively sets the text in the editor.
|
||||
*
|
||||
* Prefer setting `currentValue` prop where possible.
|
||||
*
|
||||
* @param text text to set in the editor
|
||||
*/
|
||||
setText(text: string): void;
|
||||
}
|
||||
|
||||
interface CKEditorOpts {
|
||||
apiRef: MutableRef<CKEditorApi | undefined>;
|
||||
currentValue?: string;
|
||||
className: string;
|
||||
tabIndex?: number;
|
||||
config: EditorConfig;
|
||||
editor: typeof AttributeEditor;
|
||||
disableNewlines?: boolean;
|
||||
disableSpellcheck?: boolean;
|
||||
onChange?: (newValue?: string) => void;
|
||||
onClick?: (e: MouseEvent, pos?: ModelPosition | null) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export default function CKEditor({ apiRef, currentValue, editor, config, disableNewlines, disableSpellcheck, onChange, onClick, ...restProps }: CKEditorOpts) {
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const textEditorRef = useRef<CKTextEditor>(null);
|
||||
useImperativeHandle(apiRef, () => {
|
||||
return {
|
||||
focus() {
|
||||
editorContainerRef.current?.focus();
|
||||
textEditorRef.current?.model.change((writer) => {
|
||||
const documentRoot = textEditorRef.current?.editing.model.document.getRoot();
|
||||
if (documentRoot) {
|
||||
writer.setSelection(writer.createPositionAt(documentRoot, "end"));
|
||||
}
|
||||
});
|
||||
},
|
||||
setText(text: string) {
|
||||
textEditorRef.current?.setData(text);
|
||||
}
|
||||
};
|
||||
}, [ editorContainerRef ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorContainerRef.current) return;
|
||||
|
||||
editor.create(editorContainerRef.current, config).then((textEditor) => {
|
||||
textEditorRef.current = textEditor;
|
||||
|
||||
if (disableNewlines) {
|
||||
textEditor.editing.view.document.on(
|
||||
"enter",
|
||||
(event, data) => {
|
||||
// disable entering new line - see https://github.com/ckeditor/ckeditor5/issues/9422
|
||||
data.preventDefault();
|
||||
event.stop();
|
||||
},
|
||||
{ priority: "high" }
|
||||
);
|
||||
}
|
||||
|
||||
if (disableSpellcheck) {
|
||||
const documentRoot = textEditor.editing.view.document.getRoot();
|
||||
if (documentRoot) {
|
||||
textEditor.editing.view.change((writer) => writer.setAttribute("spellcheck", "false", documentRoot));
|
||||
}
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
textEditor.model.document.on("change:data", () => {
|
||||
onChange(textEditor.getData())
|
||||
});
|
||||
}
|
||||
|
||||
if (currentValue) {
|
||||
textEditor.setData(currentValue);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textEditorRef.current) return;
|
||||
textEditorRef.current.setData(currentValue ?? "");
|
||||
}, [ currentValue ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editorContainerRef}
|
||||
onClick={(e) => {
|
||||
if (onClick) {
|
||||
const pos = textEditorRef.current?.model.document.selection.getFirstPosition();
|
||||
onClick(e, pos);
|
||||
}
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,17 +1,31 @@
|
||||
import { Dropdown as BootstrapDropdown } from "bootstrap";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { CSSProperties } from "preact/compat";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useUniqueName } from "./hooks";
|
||||
|
||||
interface DropdownProps {
|
||||
export interface DropdownProps {
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
isStatic?: boolean;
|
||||
children: ComponentChildren;
|
||||
title?: string;
|
||||
dropdownContainerStyle?: CSSProperties;
|
||||
dropdownContainerClassName?: string;
|
||||
hideToggleArrow?: boolean;
|
||||
/** If set to true, then the dropdown button will be considered an icon action (without normal border and sized for icons only). */
|
||||
iconAction?: boolean;
|
||||
noSelectButtonStyle?: boolean;
|
||||
disabled?: boolean;
|
||||
text?: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function Dropdown({ className, isStatic, children }: DropdownProps) {
|
||||
export default function Dropdown({ className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, hideToggleArrow, iconAction, disabled, noSelectButtonStyle }: DropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
@ -19,33 +33,54 @@ export default function Dropdown({ className, isStatic, children }: DropdownProp
|
||||
return () => dropdown.dispose();
|
||||
}, []); // Add dependency array
|
||||
|
||||
const onShown = useCallback(() => {
|
||||
setShown(true);
|
||||
}, [])
|
||||
|
||||
const onHidden = useCallback(() => {
|
||||
setShown(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dropdownRef.current) return;
|
||||
|
||||
const handleHide = () => {
|
||||
// Remove console.log from production code
|
||||
};
|
||||
|
||||
const $dropdown = $(dropdownRef.current);
|
||||
$dropdown.on("hide.bs.dropdown", handleHide);
|
||||
$dropdown.on("show.bs.dropdown", onShown);
|
||||
$dropdown.on("hide.bs.dropdown", onHidden);
|
||||
|
||||
// Add proper cleanup
|
||||
return () => {
|
||||
$dropdown.off("hide.bs.dropdown", handleHide);
|
||||
$dropdown.off("show.bs.dropdown", onShown);
|
||||
$dropdown.off("hide.bs.dropdown", onHidden);
|
||||
};
|
||||
}, []); // Add dependency array
|
||||
|
||||
const ariaId = useUniqueName("button");
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} class="dropdown" style={{ display: "flex" }}>
|
||||
<div ref={dropdownRef} class={`dropdown ${className ?? ""}`} style={{ display: "flex" }}>
|
||||
<button
|
||||
className={`${iconAction ? "icon-action" : "btn"} ${!noSelectButtonStyle ? "select-button" : ""} ${buttonClassName ?? ""} ${!hideToggleArrow ? "dropdown-toggle" : ""}`}
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
style={{ display: "none" }}
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-display={ isStatic ? "static" : undefined } />
|
||||
data-bs-display={ isStatic ? "static" : undefined }
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
title={title}
|
||||
id={ariaId}
|
||||
disabled={disabled}
|
||||
>
|
||||
{text}
|
||||
<span className="caret"></span>
|
||||
</button>
|
||||
|
||||
<div class={`dropdown-menu ${className ?? ""} ${isStatic ? "static" : undefined}`}>
|
||||
{children}
|
||||
<div
|
||||
class={`dropdown-menu ${isStatic ? "static" : ""} ${dropdownContainerClassName ?? ""} tn-dropdown-list`}
|
||||
style={dropdownContainerStyle}
|
||||
aria-labelledby={ariaId}
|
||||
>
|
||||
{shown && children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -6,7 +6,6 @@ import { CSSProperties, memo } from "preact/compat";
|
||||
import { useUniqueName } from "./hooks";
|
||||
|
||||
interface FormCheckboxProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
label: string | ComponentChildren;
|
||||
/**
|
||||
@ -19,9 +18,9 @@ interface FormCheckboxProps {
|
||||
containerStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
const FormCheckbox = memo(({ name, id: _id, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) => {
|
||||
const id = _id ?? useUniqueName(name);
|
||||
const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) => {
|
||||
const labelRef = useRef<HTMLLabelElement>(null);
|
||||
const id = useUniqueName(name);
|
||||
|
||||
// Fix: Move useEffect outside conditional
|
||||
useEffect(() => {
|
||||
|
||||
30
apps/client/src/widgets/react/FormDropdownList.tsx
Normal file
30
apps/client/src/widgets/react/FormDropdownList.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import Dropdown, { DropdownProps } from "./Dropdown";
|
||||
import { FormListItem } from "./FormList";
|
||||
|
||||
interface FormDropdownList<T> extends Omit<DropdownProps, "children"> {
|
||||
values: T[];
|
||||
keyProperty: keyof T;
|
||||
titleProperty: keyof T;
|
||||
descriptionProperty?: keyof T;
|
||||
currentValue: string;
|
||||
onChange(newValue: string): void;
|
||||
}
|
||||
|
||||
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
|
||||
const currentValueData = values.find(value => value[keyProperty] === currentValue);
|
||||
|
||||
return (
|
||||
<Dropdown text={currentValueData?.[titleProperty] ?? ""} {...restProps}>
|
||||
{values.map(item => (
|
||||
<FormListItem
|
||||
onClick={() => onChange(item[keyProperty] as string)}
|
||||
checked={currentValue === item[keyProperty]}
|
||||
description={descriptionProperty && item[descriptionProperty] as string}
|
||||
selected={currentValue === item[keyProperty]}
|
||||
>
|
||||
{item[titleProperty] as string}
|
||||
</FormListItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
@ -1,13 +1,48 @@
|
||||
import { Ref } from "preact";
|
||||
import Button, { ButtonProps } from "./Button";
|
||||
import { useRef } from "preact/hooks";
|
||||
|
||||
interface FormFileUploadProps {
|
||||
name?: string;
|
||||
onChange: (files: FileList | null) => void;
|
||||
multiple?: boolean;
|
||||
hidden?: boolean;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export default function FormFileUpload({ onChange, multiple }: FormFileUploadProps) {
|
||||
export default function FormFileUpload({ inputRef, name, onChange, multiple, hidden }: FormFileUploadProps) {
|
||||
return (
|
||||
<label class="tn-file-input tn-input-field">
|
||||
<input type="file" class="form-control-file" multiple={multiple}
|
||||
<label class="tn-file-input tn-input-field" style={hidden ? { display: "none" } : undefined}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
name={name}
|
||||
type="file"
|
||||
class="form-control-file"
|
||||
multiple={multiple}
|
||||
onChange={e => onChange((e.target as HTMLInputElement).files)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Combination of a button with a hidden file upload field.
|
||||
*
|
||||
* @param param the change listener for the file upload and the properties for the button.
|
||||
*/
|
||||
export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange">) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
<FormFileUpload
|
||||
inputRef={inputRef}
|
||||
hidden
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -8,6 +8,7 @@ interface FormGroupProps {
|
||||
label?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children: VNode<any>;
|
||||
description?: string | ComponentChildren;
|
||||
disabled?: boolean;
|
||||
@ -25,7 +26,7 @@ export default function FormGroup({ name, label, title, className, children, des
|
||||
|
||||
{childWithId}
|
||||
|
||||
{description && <small className="form-text">{description}</small>}
|
||||
{description && <div><small className="form-text">{description}</small></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
9
apps/client/src/widgets/react/FormList.css
Normal file
9
apps/client/src/widgets/react/FormList.css
Normal file
@ -0,0 +1,9 @@
|
||||
.dropdown-item .description {
|
||||
font-size: small;
|
||||
color: var(--muted-text-color);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dropdown-item span.bx {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -2,6 +2,8 @@ import { Dropdown as BootstrapDropdown } from "bootstrap";
|
||||
import { ComponentChildren } from "preact";
|
||||
import Icon from "./Icon";
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from "preact/compat";
|
||||
import "./FormList.css";
|
||||
import { CommandNames } from "../../components/app_context";
|
||||
|
||||
interface FormListOpts {
|
||||
children: ComponentChildren;
|
||||
@ -33,6 +35,7 @@ export default function FormList({ children, onSelect, style, fullHeight }: Form
|
||||
const style: CSSProperties = {};
|
||||
if (fullHeight) {
|
||||
style.height = "100%";
|
||||
style.overflow = "auto";
|
||||
}
|
||||
return style;
|
||||
}, [ fullHeight ]);
|
||||
@ -51,7 +54,8 @@ export default function FormList({ children, onSelect, style, fullHeight }: Form
|
||||
...builtinStyles,
|
||||
position: "relative",
|
||||
}} onClick={(e) => {
|
||||
const value = (e.target as HTMLElement)?.dataset?.value;
|
||||
const dropdownItem = (e.target as HTMLElement).closest(".dropdown-item") as HTMLElement | null;
|
||||
const value = dropdownItem?.dataset?.value;
|
||||
if (value && onSelect) {
|
||||
onSelect(value);
|
||||
}
|
||||
@ -63,23 +67,50 @@ export default function FormList({ children, onSelect, style, fullHeight }: Form
|
||||
);
|
||||
}
|
||||
|
||||
export interface FormListBadge {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface FormListItemOpts {
|
||||
children: ComponentChildren;
|
||||
icon?: string;
|
||||
value?: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
badges?: FormListBadge[];
|
||||
disabled?: boolean;
|
||||
checked?: boolean | null;
|
||||
selected?: boolean;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
triggerCommand?: CommandNames;
|
||||
description?: string;
|
||||
className?: string;
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
export function FormListItem({ children, icon, value, title, active }: FormListItemOpts) {
|
||||
export function FormListItem({ children, icon, value, title, active, badges, disabled, checked, onClick, description, selected, rtl, triggerCommand }: FormListItemOpts) {
|
||||
if (checked) {
|
||||
icon = "bx bx-check";
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
class={`dropdown-item ${active ? "active" : ""}`}
|
||||
class={`dropdown-item ${active ? "active" : ""} ${disabled ? "disabled" : ""} ${selected ? "selected" : ""}`}
|
||||
data-value={value} title={title}
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
data-trigger-command={triggerCommand}
|
||||
dir={rtl ? "rtl" : undefined}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
{children}
|
||||
<div>
|
||||
{children}
|
||||
{badges && badges.map(({ className, text }) => (
|
||||
<span className={`badge ${className ?? ""}`}>{text}</span>
|
||||
))}
|
||||
{description && <div className="description">{description}</div>}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -95,3 +126,7 @@ export function FormListHeader({ text }: FormListHeaderOpts) {
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormDropdownDivider() {
|
||||
return <div className="dropdown-divider" />;
|
||||
}
|
||||
@ -20,16 +20,18 @@ interface ValueConfig<T, Q> {
|
||||
|
||||
interface FormSelectProps<T, Q> extends ValueConfig<T, Q> {
|
||||
id?: string;
|
||||
name?: string;
|
||||
onChange: OnChangeListener;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combobox component that takes in any object array as data. Each item of the array is rendered as an item, and the key and values are obtained by looking into the object by a specified key.
|
||||
*/
|
||||
export default function FormSelect<T>({ id, onChange, style, ...restProps }: FormSelectProps<T, T>) {
|
||||
export default function FormSelect<T>({ name, id, onChange, style, className, ...restProps }: FormSelectProps<T, T>) {
|
||||
return (
|
||||
<FormSelectBody id={id} onChange={onChange} style={style}>
|
||||
<FormSelectBody name={name} id={id} onChange={onChange} style={style} className={className}>
|
||||
<FormSelectGroup {...restProps} />
|
||||
</FormSelectBody>
|
||||
);
|
||||
@ -38,27 +40,35 @@ export default function FormSelect<T>({ id, onChange, style, ...restProps }: For
|
||||
/**
|
||||
* Similar to {@link FormSelect}, but the top-level elements are actually groups.
|
||||
*/
|
||||
export function FormSelectWithGroups<T>({ id, values, keyProperty, titleProperty, currentValue, onChange }: FormSelectProps<T, FormSelectGroup<T>>) {
|
||||
export function FormSelectWithGroups<T>({ name, id, values, keyProperty, titleProperty, currentValue, onChange, ...restProps }: FormSelectProps<T, FormSelectGroup<T> | T>) {
|
||||
return (
|
||||
<FormSelectBody id={id} onChange={onChange}>
|
||||
{values.map(({ title, items }) => {
|
||||
return (
|
||||
<optgroup label={title}>
|
||||
<FormSelectGroup values={items} keyProperty={keyProperty} titleProperty={titleProperty} currentValue={currentValue} />
|
||||
</optgroup>
|
||||
);
|
||||
<FormSelectBody name={name} id={id} onChange={onChange} {...restProps}>
|
||||
{values.map((item) => {
|
||||
if (!item) return <></>;
|
||||
if (typeof item === "object" && "items" in item) {
|
||||
return (
|
||||
<optgroup label={item.title}>
|
||||
<FormSelectGroup values={item.items} keyProperty={keyProperty} titleProperty={titleProperty} currentValue={currentValue} />
|
||||
</optgroup>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<FormSelectGroup values={[ item ]} keyProperty={keyProperty} titleProperty={titleProperty} currentValue={currentValue} />
|
||||
)
|
||||
}
|
||||
})}
|
||||
</FormSelectBody>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSelectBody({ id, children, onChange, style }: { id?: string, children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties }) {
|
||||
function FormSelectBody({ id, name, children, onChange, style, className }: { id?: string, name?: string, children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties, className?: string }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
class="form-select"
|
||||
name={name}
|
||||
onChange={e => onChange((e.target as HTMLInputElement).value)}
|
||||
style={style}
|
||||
className={`form-select ${className ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
@ -69,10 +79,10 @@ function FormSelectGroup<T>({ values, keyProperty, titleProperty, currentValue }
|
||||
return values.map(item => {
|
||||
return (
|
||||
<option
|
||||
value={item[keyProperty] as any}
|
||||
value={item[keyProperty] as string | number}
|
||||
selected={item[keyProperty] === currentValue}
|
||||
>
|
||||
{item[titleProperty ?? keyProperty] ?? item[keyProperty] as any}
|
||||
{item[titleProperty ?? keyProperty] ?? item[keyProperty] as string | number}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
interface FormTextAreaProps {
|
||||
import { RefObject, TextareaHTMLAttributes } from "preact/compat";
|
||||
|
||||
interface FormTextAreaProps extends Omit<TextareaHTMLAttributes, "onBlur" | "onChange"> {
|
||||
id?: string;
|
||||
currentValue: string;
|
||||
onChange?(newValue: string): void;
|
||||
onBlur?(newValue: string): void;
|
||||
rows: number;
|
||||
inputRef?: RefObject<HTMLTextAreaElement>
|
||||
}
|
||||
export default function FormTextArea({ id, onBlur, rows, currentValue }: FormTextAreaProps) {
|
||||
export default function FormTextArea({ inputRef, id, onBlur, onChange, currentValue, className, ...restProps }: FormTextAreaProps) {
|
||||
return (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
rows={rows}
|
||||
className={`form-control ${className ?? ""}`}
|
||||
onChange={(e) => {
|
||||
onChange?.(e.currentTarget.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur?.(e.currentTarget.value);
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
{...restProps}
|
||||
>{currentValue}</textarea>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, RefObject } from "preact/compat";
|
||||
import { useEffect, type InputHTMLAttributes, type RefObject } from "preact/compat";
|
||||
|
||||
interface FormTextBoxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "onBlur" | "value"> {
|
||||
id?: string;
|
||||
@ -8,7 +8,7 @@ interface FormTextBoxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export default function FormTextBox({ inputRef, className, type, currentValue, onChange, onBlur,...rest}: FormTextBoxProps) {
|
||||
export default function FormTextBox({ inputRef, className, type, currentValue, onChange, onBlur, autoFocus, ...rest}: FormTextBoxProps) {
|
||||
if (type === "number" && currentValue) {
|
||||
const { min, max } = rest;
|
||||
const currentValueNum = parseInt(currentValue, 10);
|
||||
@ -19,6 +19,12 @@ export default function FormTextBox({ inputRef, className, type, currentValue, o
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
inputRef?.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
98
apps/client/src/widgets/react/FormToggle.css
Normal file
98
apps/client/src/widgets/react/FormToggle.css
Normal file
@ -0,0 +1,98 @@
|
||||
.switch-widget {
|
||||
--switch-track-width: 50px;
|
||||
--switch-track-height: 24px;
|
||||
--switch-off-track-background: var(--more-accented-background-color);
|
||||
--switch-on-track-background: var(--main-text-color);
|
||||
|
||||
--switch-thumb-width: 16px;
|
||||
--switch-thumb-height: 16px;
|
||||
--switch-off-thumb-background: var(--main-background-color);
|
||||
--switch-on-thumb-background: var(--main-background-color);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* The track of the toggle switch */
|
||||
|
||||
.switch-widget .switch-button {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
width: var(--switch-track-width);
|
||||
height: var(--switch-track-height);
|
||||
border-radius: 24px;
|
||||
background-color: var(--switch-off-track-background);
|
||||
transition: background 200ms ease-in;
|
||||
}
|
||||
|
||||
.switch-widget .switch-button.on {
|
||||
background: var(--switch-on-track-background);
|
||||
transition: background 100ms ease-out;
|
||||
}
|
||||
|
||||
/* The thumb of the toggle switch */
|
||||
|
||||
.switch-widget .switch-button:after {
|
||||
--y: calc((var(--switch-track-height) - var(--switch-thumb-height)) / 2);
|
||||
--x: var(--y);
|
||||
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--switch-thumb-width);
|
||||
height: var(--switch-thumb-height);
|
||||
background-color: var(--switch-off-thumb-background);
|
||||
border-radius: 50%;
|
||||
transform: translate(var(--x), var(--y));
|
||||
transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background 200ms ease-out;
|
||||
}
|
||||
|
||||
.switch-widget .switch-button.on:after {
|
||||
--x: calc(var(--switch-track-width) - var(--switch-thumb-width) - var(--y));
|
||||
|
||||
background: var(--switch-on-thumb-background);
|
||||
transition: transform 200ms cubic-bezier(0.64, 0, 0.78, 0),
|
||||
background 100ms ease-in;
|
||||
}
|
||||
|
||||
|
||||
.switch-widget .switch-button input[type="checkbox"] {
|
||||
/* A hidden check box for accesibility purposes */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.switch-widget .switch-button:not(.disabled) input[type="checkbox"],
|
||||
.switch-widget .switch-button:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-widget .switch-button:has(input[type="checkbox"]:focus-visible) {
|
||||
outline: 2px solid var(--button-border-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.switch-widget .switch-button.disabled {
|
||||
opacity: 70%;
|
||||
}
|
||||
|
||||
.switch-widget .switch-help-button {
|
||||
border: 0;
|
||||
margin-left: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.switch-widget .switch-help-button:hover {
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
41
apps/client/src/widgets/react/FormToggle.tsx
Normal file
41
apps/client/src/widgets/react/FormToggle.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import "./FormToggle.css";
|
||||
import HelpButton from "./HelpButton";
|
||||
|
||||
interface FormToggleProps {
|
||||
currentValue: boolean | null;
|
||||
onChange(newValue: boolean): void;
|
||||
switchOnName: string;
|
||||
switchOnTooltip: string;
|
||||
switchOffName: string;
|
||||
switchOffTooltip: string;
|
||||
helpPage?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function FormToggle({ currentValue, helpPage, switchOnName, switchOnTooltip, switchOffName, switchOffTooltip, onChange, disabled }: FormToggleProps) {
|
||||
return (
|
||||
<div className="switch-widget">
|
||||
<span className="switch-name">{ currentValue ? switchOffName : switchOnName }</span>
|
||||
|
||||
<label>
|
||||
<div
|
||||
className={`switch-button ${currentValue ? "on" : ""} ${disabled ? "disabled" : ""}`}
|
||||
title={currentValue ? switchOffTooltip : switchOnTooltip }
|
||||
>
|
||||
<input
|
||||
className="switch-toggle"
|
||||
type="checkbox"
|
||||
checked={currentValue === true}
|
||||
onInput={(e) => {
|
||||
onChange(!currentValue);
|
||||
e.preventDefault();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{ helpPage && <HelpButton className="switch-help-button" helpPage={helpPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
apps/client/src/widgets/react/HelpButton.tsx
Normal file
21
apps/client/src/widgets/react/HelpButton.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { CSSProperties } from "preact/compat";
|
||||
import { t } from "../../services/i18n";
|
||||
import { openInAppHelpFromUrl } from "../../services/utils";
|
||||
|
||||
interface HelpButtonProps {
|
||||
className?: string;
|
||||
helpPage: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function HelpButton({ className, helpPage, style }: HelpButtonProps) {
|
||||
return (
|
||||
<button
|
||||
class={`${className ?? ""} icon-action bx bx-help-circle`}
|
||||
type="button"
|
||||
onClick={() => openInAppHelpFromUrl(helpPage)}
|
||||
title={t("open-help-page")}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/client/src/widgets/react/HelpRemoveButtons.tsx
Normal file
30
apps/client/src/widgets/react/HelpRemoveButtons.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import type { ComponentChildren } from "preact";
|
||||
import ActionButton from "./ActionButton";
|
||||
import Dropdown from "./Dropdown";
|
||||
|
||||
interface HelpRemoveButtonsProps {
|
||||
help?: ComponentChildren;
|
||||
removeText?: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export default function HelpRemoveButtons({ help, removeText, onRemove }: HelpRemoveButtonsProps) {
|
||||
return (
|
||||
<td className="button-column">
|
||||
{help && <>
|
||||
<Dropdown
|
||||
className="help-dropdown"
|
||||
buttonClassName="bx bx-help-circle icon-action"
|
||||
hideToggleArrow
|
||||
>{help}</Dropdown>
|
||||
{" "}
|
||||
</>}
|
||||
<ActionButton
|
||||
icon="bx bx-x"
|
||||
className="search-option-del"
|
||||
text={removeText ?? ""}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/commons";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import keyboard_actions from "../../services/keyboard_actions";
|
||||
import { joinElements } from "./react_utils";
|
||||
|
||||
interface KeyboardShortcutProps {
|
||||
actionName: KeyboardActionNames;
|
||||
@ -19,15 +20,15 @@ export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps)
|
||||
|
||||
return (
|
||||
<>
|
||||
{action.effectiveShortcuts?.map((shortcut, i) => {
|
||||
{action.effectiveShortcuts?.map((shortcut) => {
|
||||
const keys = shortcut.split("+");
|
||||
return keys
|
||||
return joinElements(keys
|
||||
.map((key, i) => (
|
||||
<>
|
||||
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "}
|
||||
</>
|
||||
))
|
||||
}).reduce<any>((acc, item) => (acc.length ? [...acc, ", ", item] : [item]), [])}
|
||||
)))
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
apps/client/src/widgets/react/LoadingSpinner.tsx
Normal file
3
apps/client/src/widgets/react/LoadingSpinner.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function LoadingSpinner() {
|
||||
return <span className="bx bx-loader bx-spin" />
|
||||
}
|
||||
@ -1,11 +1,11 @@
|
||||
import { useContext, useEffect, useRef, useMemo, useCallback } from "preact/hooks";
|
||||
import { useEffect, useRef, useMemo } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import { ComponentChildren } from "preact";
|
||||
import type { CSSProperties, RefObject } from "preact/compat";
|
||||
import { openDialog } from "../../services/dialog";
|
||||
import { ParentComponent } from "./ReactBasicWidget";
|
||||
import { Modal as BootstrapModal } from "bootstrap";
|
||||
import { memo } from "preact/compat";
|
||||
import { useSyncedRef } from "./hooks";
|
||||
|
||||
interface ModalProps {
|
||||
className: string;
|
||||
@ -64,50 +64,43 @@ interface ModalProps {
|
||||
stackable?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ children, className, size, title, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden: onHidden, modalRef: _modalRef, formRef: _formRef, bodyStyle, show, stackable }: ModalProps) {
|
||||
const modalRef = _modalRef ?? useRef<HTMLDivElement>(null);
|
||||
export default function Modal({ children, className, size, title, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden: onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable }: ModalProps) {
|
||||
const modalRef = useSyncedRef<HTMLDivElement>(externalModalRef);
|
||||
const modalInstanceRef = useRef<BootstrapModal>();
|
||||
const formRef = _formRef ?? useRef<HTMLFormElement>(null);
|
||||
const parentWidget = useContext(ParentComponent);
|
||||
const elementToFocus = useRef<Element | null>();
|
||||
|
||||
if (onShown || onHidden) {
|
||||
useEffect(() => {
|
||||
const modalElement = modalRef.current;
|
||||
if (!modalElement) {
|
||||
return;
|
||||
}
|
||||
if (onShown) {
|
||||
modalElement.addEventListener("shown.bs.modal", onShown);
|
||||
}
|
||||
modalElement.addEventListener("hidden.bs.modal", () => {
|
||||
onHidden();
|
||||
if (elementToFocus.current && "focus" in elementToFocus.current) {
|
||||
(elementToFocus.current as HTMLElement).focus();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (onShown) {
|
||||
modalElement.removeEventListener("shown.bs.modal", onShown);
|
||||
}
|
||||
modalElement.removeEventListener("hidden.bs.modal", onHidden);
|
||||
};
|
||||
}, [ ]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentWidget) {
|
||||
const modalElement = modalRef.current;
|
||||
if (!modalElement) {
|
||||
return;
|
||||
}
|
||||
if (show) {
|
||||
if (onShown) {
|
||||
modalElement.addEventListener("shown.bs.modal", onShown);
|
||||
}
|
||||
modalElement.addEventListener("hidden.bs.modal", () => {
|
||||
onHidden();
|
||||
if (elementToFocus.current && "focus" in elementToFocus.current) {
|
||||
(elementToFocus.current as HTMLElement).focus();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (onShown) {
|
||||
modalElement.removeEventListener("shown.bs.modal", onShown);
|
||||
}
|
||||
modalElement.removeEventListener("hidden.bs.modal", onHidden);
|
||||
};
|
||||
}, [ onShown, onHidden ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show && modalRef.current) {
|
||||
elementToFocus.current = document.activeElement;
|
||||
openDialog(parentWidget.$widget, !stackable).then(($widget) => {
|
||||
openDialog($(modalRef.current), !stackable).then(($widget) => {
|
||||
modalInstanceRef.current = BootstrapModal.getOrCreateInstance($widget[0]);
|
||||
})
|
||||
} else {
|
||||
modalInstanceRef.current?.hide();
|
||||
}
|
||||
}, [ show ]);
|
||||
}, [ show, modalRef.current ]);
|
||||
|
||||
// Memoize styles to prevent recreation on every render
|
||||
const dialogStyle = useMemo<CSSProperties>(() => {
|
||||
@ -147,10 +140,10 @@ export default function Modal({ children, className, size, title, header, footer
|
||||
</div>
|
||||
|
||||
{onSubmit ? (
|
||||
<form ref={formRef} onSubmit={useCallback((e) => {
|
||||
<form ref={formRef} onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}, [onSubmit])}>
|
||||
}}>
|
||||
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner>
|
||||
</form>
|
||||
) : (
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useRef } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import type { RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useSyncedRef } from "./hooks";
|
||||
|
||||
interface NoteAutocompleteProps {
|
||||
id?: string;
|
||||
@ -19,8 +19,8 @@ interface NoteAutocompleteProps {
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export default function NoteAutocomplete({ id, inputRef: _ref, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
|
||||
const ref = _ref ?? useRef<HTMLInputElement>(null);
|
||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
|
||||
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
37
apps/client/src/widgets/react/NoteLink.tsx
Normal file
37
apps/client/src/widgets/react/NoteLink.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import link from "../../services/link";
|
||||
import RawHtml from "./RawHtml";
|
||||
|
||||
interface NoteLinkOpts {
|
||||
notePath: string | string[];
|
||||
showNotePath?: boolean;
|
||||
style?: Record<string, string | number>;
|
||||
noPreview?: boolean;
|
||||
noTnLink?: boolean;
|
||||
}
|
||||
|
||||
export default function NoteLink({ notePath, showNotePath, style, noPreview, noTnLink }: NoteLinkOpts) {
|
||||
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
||||
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
|
||||
|
||||
useEffect(() => {
|
||||
link.createLink(stringifiedNotePath, { showNotePath })
|
||||
.then(setJqueryEl);
|
||||
}, [ stringifiedNotePath, showNotePath ]);
|
||||
|
||||
if (style) {
|
||||
jqueryEl?.css(style);
|
||||
}
|
||||
|
||||
const $linkEl = jqueryEl?.find("a");
|
||||
if (noPreview) {
|
||||
$linkEl?.addClass("no-tooltip-preview");
|
||||
}
|
||||
|
||||
if (!noTnLink) {
|
||||
$linkEl?.addClass("tn-link");
|
||||
}
|
||||
|
||||
return <RawHtml html={jqueryEl} />
|
||||
|
||||
}
|
||||
@ -4,8 +4,9 @@ type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
|
||||
|
||||
interface RawHtmlProps {
|
||||
className?: string;
|
||||
html: HTMLElementLike;
|
||||
html?: HTMLElementLike;
|
||||
style?: CSSProperties;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export default function RawHtml(props: RawHtmlProps) {
|
||||
@ -16,11 +17,12 @@ export function RawHtmlBlock(props: RawHtmlProps) {
|
||||
return <div {...getProps(props)} />
|
||||
}
|
||||
|
||||
function getProps({ className, html, style }: RawHtmlProps) {
|
||||
function getProps({ className, html, style, onClick }: RawHtmlProps) {
|
||||
return {
|
||||
className: className,
|
||||
dangerouslySetInnerHTML: getHtml(html),
|
||||
style
|
||||
dangerouslySetInnerHTML: getHtml(html ?? ""),
|
||||
style,
|
||||
onClick
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,96 +1,61 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { EventData, EventNames } from "../../components/app_context";
|
||||
import { ParentComponent } from "./ReactBasicWidget";
|
||||
import { ParentComponent } from "./react_utils";
|
||||
import SpacedUpdate from "../../services/spaced_update";
|
||||
import { OptionNames } from "@triliumnext/commons";
|
||||
import options, { type OptionValue } from "../../services/options";
|
||||
import utils, { reloadFrontendApp } from "../../services/utils";
|
||||
import Component from "../../components/component";
|
||||
import server from "../../services/server";
|
||||
import NoteContext from "../../components/note_context";
|
||||
import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import FBlob from "../../entities/fblob";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget";
|
||||
import { RefObject, VNode } from "preact";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import { CSSProperties } from "preact/compat";
|
||||
|
||||
type TriliumEventHandler<T extends EventNames> = (data: EventData<T>) => void;
|
||||
const registeredHandlers: Map<Component, Map<EventNames, TriliumEventHandler<any>[]>> = new Map();
|
||||
|
||||
/**
|
||||
* Allows a React component to react to Trilium events (e.g. `entitiesReloaded`). When the desired event is triggered, the handler is invoked with the event parameters.
|
||||
*
|
||||
* Under the hood, it works by altering the parent (Trilium) component of the React element to introduce the corresponding event.
|
||||
*
|
||||
* @param eventName the name of the Trilium event to listen for.
|
||||
* @param handler the handler to be invoked when the event is triggered.
|
||||
* @param enabled determines whether the event should be listened to or not. Useful to conditionally limit the listener based on a state (e.g. a modal being displayed).
|
||||
*/
|
||||
export default function useTriliumEvent<T extends EventNames>(eventName: T, handler: TriliumEventHandler<T>, enabled = true) {
|
||||
const parentWidget = useContext(ParentComponent);
|
||||
if (!parentWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlerName = `${eventName}Event`;
|
||||
const customHandler = useMemo(() => {
|
||||
return async (data: EventData<T>) => {
|
||||
// Inform the attached event listeners.
|
||||
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName) ?? [];
|
||||
for (const eventHandler of eventHandlers) {
|
||||
eventHandler(data);
|
||||
}
|
||||
}
|
||||
}, [ eventName, parentWidget ]);
|
||||
|
||||
useEffect(() => {
|
||||
// Attach to the list of handlers.
|
||||
let handlersByWidget = registeredHandlers.get(parentWidget);
|
||||
if (!handlersByWidget) {
|
||||
handlersByWidget = new Map();
|
||||
registeredHandlers.set(parentWidget, handlersByWidget);
|
||||
}
|
||||
|
||||
let handlersByWidgetAndEventName = handlersByWidget.get(eventName);
|
||||
if (!handlersByWidgetAndEventName) {
|
||||
handlersByWidgetAndEventName = [];
|
||||
handlersByWidget.set(eventName, handlersByWidgetAndEventName);
|
||||
}
|
||||
|
||||
if (!handlersByWidgetAndEventName.includes(handler)) {
|
||||
handlersByWidgetAndEventName.push(handler);
|
||||
}
|
||||
|
||||
// Apply the custom event handler.
|
||||
if (parentWidget[handlerName] && parentWidget[handlerName] !== customHandler) {
|
||||
console.warn(`Widget ${parentWidget.componentId} already had an event listener and it was replaced by the React one.`);
|
||||
}
|
||||
|
||||
parentWidget[handlerName] = customHandler;
|
||||
|
||||
return () => {
|
||||
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName);
|
||||
if (!eventHandlers || !eventHandlers.includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the event handler from the array.
|
||||
const newEventHandlers = eventHandlers.filter(e => e !== handler);
|
||||
if (newEventHandlers.length) {
|
||||
registeredHandlers.get(parentWidget)?.set(eventName, newEventHandlers);
|
||||
} else {
|
||||
registeredHandlers.get(parentWidget)?.delete(eventName);
|
||||
}
|
||||
|
||||
if (!registeredHandlers.get(parentWidget)?.size) {
|
||||
registeredHandlers.delete(parentWidget);
|
||||
}
|
||||
};
|
||||
}, [ eventName, parentWidget, handler ]);
|
||||
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
useLayoutEffect(() => {
|
||||
parentComponent?.registerHandler(eventName, handler);
|
||||
return (() => parentComponent?.removeHandler(eventName, handler));
|
||||
}, [ eventName, handler ]);
|
||||
useDebugValue(eventName);
|
||||
}
|
||||
|
||||
export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000) {
|
||||
export function useTriliumEvents<T extends EventNames>(eventNames: T[], handler: (data: EventData<T>, eventName: T) => void) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handlers: ({ eventName: T, callback: (data: EventData<T>) => void })[] = [];
|
||||
for (const eventName of eventNames) {
|
||||
handlers.push({ eventName, callback: (data) => {
|
||||
handler(data, eventName);
|
||||
}})
|
||||
}
|
||||
|
||||
for (const { eventName, callback } of handlers) {
|
||||
parentComponent?.registerHandler(eventName, callback);
|
||||
}
|
||||
|
||||
return (() => {
|
||||
for (const { eventName, callback } of handlers) {
|
||||
parentComponent?.removeHandler(eventName, callback);
|
||||
}
|
||||
});
|
||||
}, [ eventNames, handler ]);
|
||||
useDebugValue(() => eventNames.join(", "));
|
||||
}
|
||||
|
||||
export function useSpacedUpdate(callback: () => void | Promise<void>, interval = 1000) {
|
||||
const callbackRef = useRef(callback);
|
||||
const spacedUpdateRef = useRef<SpacedUpdate>();
|
||||
|
||||
// Update callback ref when it changes
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
});
|
||||
}, [callback]);
|
||||
|
||||
// Create SpacedUpdate instance only once
|
||||
if (!spacedUpdateRef.current) {
|
||||
@ -137,7 +102,9 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st
|
||||
const newValue = options.get(name);
|
||||
setValue(newValue);
|
||||
}
|
||||
}, [ name ]));
|
||||
}, [ name, setValue ]));
|
||||
|
||||
useDebugValue(name);
|
||||
|
||||
return [
|
||||
value,
|
||||
@ -154,6 +121,7 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st
|
||||
*/
|
||||
export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean): [boolean, (newValue: boolean) => Promise<void>] {
|
||||
const [ value, setValue ] = useTriliumOption(name, needsRefresh);
|
||||
useDebugValue(name);
|
||||
return [
|
||||
(value === "true"),
|
||||
(newValue) => setValue(newValue ? "true" : "false")
|
||||
@ -169,6 +137,7 @@ export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean):
|
||||
*/
|
||||
export function useTriliumOptionInt(name: OptionNames): [number, (newValue: number) => Promise<void>] {
|
||||
const [ value, setValue ] = useTriliumOption(name);
|
||||
useDebugValue(name);
|
||||
return [
|
||||
(parseInt(value, 10)),
|
||||
(newValue) => setValue(newValue)
|
||||
@ -183,6 +152,7 @@ export function useTriliumOptionInt(name: OptionNames): [number, (newValue: numb
|
||||
*/
|
||||
export function useTriliumOptionJson<T>(name: OptionNames): [ T, (newValue: T) => Promise<void> ] {
|
||||
const [ value, setValue ] = useTriliumOption(name);
|
||||
useDebugValue(name);
|
||||
return [
|
||||
(JSON.parse(value) as T),
|
||||
(newValue => setValue(JSON.stringify(newValue)))
|
||||
@ -201,6 +171,8 @@ export function useTriliumOptions<T extends OptionNames>(...names: T[]) {
|
||||
values[name] = options.get(name);
|
||||
}
|
||||
|
||||
useDebugValue(() => names.join(", "));
|
||||
|
||||
return [
|
||||
values as Record<T, string>,
|
||||
options.saveMany
|
||||
@ -218,4 +190,340 @@ export function useTriliumOptions<T extends OptionNames>(...names: T[]) {
|
||||
*/
|
||||
export function useUniqueName(prefix?: string) {
|
||||
return useMemo(() => (prefix ? prefix + "-" : "") + utils.randomString(10), [ prefix ]);
|
||||
}
|
||||
|
||||
export function useNoteContext() {
|
||||
const [ noteContext, setNoteContext ] = useState<NoteContext>();
|
||||
const [ notePath, setNotePath ] = useState<string | null | undefined>();
|
||||
const [ note, setNote ] = useState<FNote | null | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setNote(noteContext?.note);
|
||||
}, [ notePath ]);
|
||||
|
||||
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
|
||||
setNoteContext(noteContext);
|
||||
setNotePath(noteContext.notePath);
|
||||
});
|
||||
useTriliumEvent("frocaReloaded", () => {
|
||||
setNote(noteContext?.note);
|
||||
});
|
||||
|
||||
const parentComponent = useContext(ParentComponent) as ReactWrappedWidget;
|
||||
useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`);
|
||||
|
||||
return {
|
||||
note: note,
|
||||
noteId: noteContext?.note?.noteId,
|
||||
notePath: noteContext?.notePath,
|
||||
hoistedNoteId: noteContext?.hoistedNoteId,
|
||||
ntxId: noteContext?.ntxId,
|
||||
viewScope: noteContext?.viewScope,
|
||||
componentId: parentComponent.componentId,
|
||||
noteContext,
|
||||
parentComponent
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows a React component to listen to obtain a property of a {@link FNote} while also automatically watching for changes, either via the user changing to a different note or the property being changed externally.
|
||||
*
|
||||
* @param note the {@link FNote} whose property to obtain.
|
||||
* @param property a property of a {@link FNote} to obtain the value from (e.g. `title`, `isProtected`).
|
||||
* @param componentId optionally, constricts the refresh of the value if an update occurs externally via the component ID of a legacy widget. This can be used to avoid external data replacing fresher, user-inputted data.
|
||||
* @returns the value of the requested property.
|
||||
*/
|
||||
export function useNoteProperty<T extends keyof FNote>(note: FNote | null | undefined, property: T, componentId?: string) {
|
||||
const [, setValue ] = useState<FNote[T] | undefined>(note?.[property]);
|
||||
const refreshValue = () => setValue(note?.[property]);
|
||||
|
||||
// Watch for note changes.
|
||||
useEffect(() => refreshValue(), [ note, note?.[property] ]);
|
||||
|
||||
// Watch for external changes.
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.isNoteReloaded(note?.noteId, componentId)) {
|
||||
refreshValue();
|
||||
}
|
||||
});
|
||||
|
||||
useDebugValue(property);
|
||||
return note?.[property];
|
||||
}
|
||||
|
||||
export function useNoteRelation(note: FNote | undefined | null, relationName: string): [string | null | undefined, (newValue: string) => void] {
|
||||
const [ relationValue, setRelationValue ] = useState<string | null | undefined>(note?.getRelationValue(relationName));
|
||||
|
||||
useEffect(() => setRelationValue(note?.getRelationValue(relationName) ?? null), [ note ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
for (const attr of loadResults.getAttributeRows()) {
|
||||
if (attr.type === "relation" && attr.name === relationName && attributes.isAffecting(attr, note)) {
|
||||
setRelationValue(attr.value ?? null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setter = useCallback((value: string | undefined) => {
|
||||
if (note) {
|
||||
attributes.setAttribute(note, "relation", relationName, value)
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
useDebugValue(relationName);
|
||||
|
||||
return [
|
||||
relationValue,
|
||||
setter
|
||||
] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows a React component to read or write a note's label while also reacting to changes in value.
|
||||
*
|
||||
* @param note the note whose label to read/write.
|
||||
* @param labelName the name of the label to read/write.
|
||||
* @returns an array where the first element is the getter and the second element is the setter. The setter has a special behaviour for convenience: if the value is undefined, the label is created without a value (e.g. a tag), if the value is null then the label is removed.
|
||||
*/
|
||||
export function useNoteLabel(note: FNote | undefined | null, labelName: string): [string | null | undefined, (newValue: string | null | undefined) => void] {
|
||||
const [ labelValue, setLabelValue ] = useState<string | null | undefined>(note?.getLabelValue(labelName));
|
||||
|
||||
useEffect(() => setLabelValue(note?.getLabelValue(labelName) ?? null), [ note ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
for (const attr of loadResults.getAttributeRows()) {
|
||||
if (attr.type === "label" && attr.name === labelName && attributes.isAffecting(attr, note)) {
|
||||
setLabelValue(attr.value ?? null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setter = useCallback((value: string | null | undefined) => {
|
||||
if (note) {
|
||||
if (value || value === undefined) {
|
||||
attributes.setLabel(note.noteId, labelName, value)
|
||||
} else if (value === null) {
|
||||
attributes.removeOwnedLabelByName(note, labelName);
|
||||
}
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
useDebugValue(labelName);
|
||||
|
||||
return [
|
||||
labelValue,
|
||||
setter
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: string): [ boolean, (newValue: boolean) => void] {
|
||||
const [ labelValue, setLabelValue ] = useState<boolean>(!!note?.hasLabel(labelName));
|
||||
|
||||
useEffect(() => setLabelValue(!!note?.hasLabel(labelName)), [ note ]);
|
||||
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
for (const attr of loadResults.getAttributeRows()) {
|
||||
if (attr.type === "label" && attr.name === labelName && attributes.isAffecting(attr, note)) {
|
||||
setLabelValue(!attr.isDeleted);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setter = useCallback((value: boolean) => {
|
||||
if (note) {
|
||||
if (value) {
|
||||
attributes.setLabel(note.noteId, labelName, "");
|
||||
} else {
|
||||
attributes.removeOwnedLabelByName(note, labelName);
|
||||
}
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
useDebugValue(labelName);
|
||||
|
||||
return [ labelValue, setter ] as const;
|
||||
}
|
||||
|
||||
export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] {
|
||||
const [ blob, setBlob ] = useState<FBlob | null>();
|
||||
|
||||
function refresh() {
|
||||
note?.getBlob().then(setBlob);
|
||||
}
|
||||
|
||||
useEffect(refresh, [ note?.noteId ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (note && loadResults.hasRevisionForNote(note.noteId)) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
useDebugValue(note?.noteId);
|
||||
|
||||
return [ blob ] as const;
|
||||
}
|
||||
|
||||
export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, { noteContext, containerClassName, containerStyle }: {
|
||||
noteContext?: NoteContext;
|
||||
containerClassName?: string;
|
||||
containerStyle?: CSSProperties;
|
||||
} = {}): [VNode, T] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
// Render the widget once.
|
||||
const [ widget, renderedWidget ] = useMemo(() => {
|
||||
const widget = widgetFactory();
|
||||
|
||||
if (parentComponent) {
|
||||
parentComponent.child(widget);
|
||||
}
|
||||
|
||||
if (noteContext && widget instanceof NoteContextAwareWidget) {
|
||||
widget.setNoteContextEvent({ noteContext });
|
||||
}
|
||||
|
||||
const renderedWidget = widget.render();
|
||||
return [ widget, renderedWidget ];
|
||||
}, []);
|
||||
|
||||
// Attach the widget to the parent.
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.innerHTML = "";
|
||||
renderedWidget.appendTo(ref.current);
|
||||
}
|
||||
}, [ renderedWidget ]);
|
||||
|
||||
// Inject the note context.
|
||||
useEffect(() => {
|
||||
if (noteContext && widget instanceof NoteContextAwareWidget) {
|
||||
widget.activeContextChangedEvent({ noteContext });
|
||||
}
|
||||
}, [ noteContext ]);
|
||||
|
||||
useDebugValue(widget);
|
||||
|
||||
return [ <div className={containerClassName} style={containerStyle} ref={ref} />, widget ]
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a {@link ResizeObserver} to the given ref and reads the bounding client rect whenever it changes.
|
||||
*
|
||||
* @param ref a ref to a {@link HTMLElement} to determine the size and observe the changes in size.
|
||||
* @returns the size of the element, reacting to changes.
|
||||
*/
|
||||
export function useElementSize(ref: RefObject<HTMLElement>) {
|
||||
const [ size, setSize ] = useState<DOMRect | undefined>(ref.current?.getBoundingClientRect());
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
setSize(ref.current?.getBoundingClientRect());
|
||||
}
|
||||
|
||||
const element = ref.current;
|
||||
const resizeObserver = new ResizeObserver(onResize);
|
||||
resizeObserver.observe(element);
|
||||
return () => {
|
||||
resizeObserver.unobserve(element);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
}, [ ref ]);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains the inner width and height of the window, as well as reacts to changes in size.
|
||||
*
|
||||
* @returns the width and height of the window.
|
||||
*/
|
||||
export function useWindowSize() {
|
||||
const [ size, setSize ] = useState<{ windowWidth: number, windowHeight: number }>({
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
setSize({
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
export function useTooltip(elRef: RefObject<HTMLElement>, config: Partial<Tooltip.Options>) {
|
||||
useEffect(() => {
|
||||
if (!elRef?.current) return;
|
||||
|
||||
const $el = $(elRef.current);
|
||||
$el.tooltip("dispose");
|
||||
$el.tooltip(config);
|
||||
}, [ elRef, config ]);
|
||||
|
||||
const showTooltip = useCallback(() => {
|
||||
if (!elRef?.current) return;
|
||||
|
||||
const $el = $(elRef.current);
|
||||
$el.tooltip("show");
|
||||
}, [ elRef, config ]);
|
||||
|
||||
const hideTooltip = useCallback(() => {
|
||||
if (!elRef?.current) return;
|
||||
|
||||
const $el = $(elRef.current);
|
||||
$el.tooltip("hide");
|
||||
}, [ elRef ]);
|
||||
|
||||
useDebugValue(config.title);
|
||||
|
||||
return { showTooltip, hideTooltip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link useTooltip}, but doesn't expose methods to imperatively hide or show the tooltip.
|
||||
*
|
||||
* @param elRef the element to bind the tooltip to.
|
||||
* @param config optionally, the tooltip configuration.
|
||||
*/
|
||||
export function useStaticTooltip(elRef: RefObject<HTMLElement>, config?: Partial<Tooltip.Options>) {
|
||||
useEffect(() => {
|
||||
if (!elRef?.current) return;
|
||||
|
||||
const $el = $(elRef.current);
|
||||
$el.tooltip(config);
|
||||
return () => {
|
||||
$el.tooltip("dispose");
|
||||
}
|
||||
}, [ elRef, config ]);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export function useLegacyImperativeHandlers(handlers: Record<string, Function>) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
useEffect(() => {
|
||||
Object.assign(parentComponent as never, handlers);
|
||||
}, [ handlers ]);
|
||||
}
|
||||
|
||||
export function useSyncedRef<T>(externalRef?: RefObject<T>, initialValue: T | null = null): RefObject<T> {
|
||||
const ref = useRef<T>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalRef) {
|
||||
externalRef.current = ref.current;
|
||||
}
|
||||
}, [ ref, externalRef ]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import type { RefObject } from "preact";
|
||||
|
||||
/**
|
||||
* Takes in a React ref and returns a corresponding JQuery selector.
|
||||
*
|
||||
* @param ref the React ref from which to obtain the jQuery selector.
|
||||
* @returns the corresponding jQuery selector.
|
||||
*/
|
||||
export function refToJQuerySelector<T extends HTMLElement>(ref: RefObject<T> | null): JQuery<T> {
|
||||
if (ref?.current) {
|
||||
return $(ref.current);
|
||||
} else {
|
||||
return $();
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,25 @@
|
||||
import { createContext, JSX, render } from "preact";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import Component from "../../components/component.js";
|
||||
import { ComponentChild, createContext, render, type JSX, type RefObject } from "preact";
|
||||
import Component from "../../components/component";
|
||||
|
||||
export const ParentComponent = createContext<Component | null>(null);
|
||||
|
||||
export default abstract class ReactBasicWidget extends BasicWidget {
|
||||
|
||||
abstract get component(): JSX.Element;
|
||||
|
||||
doRender() {
|
||||
this.$widget = renderReactWidget(this, this.component);
|
||||
/**
|
||||
* Takes in a React ref and returns a corresponding JQuery selector.
|
||||
*
|
||||
* @param ref the React ref from which to obtain the jQuery selector.
|
||||
* @returns the corresponding jQuery selector.
|
||||
*/
|
||||
export function refToJQuerySelector<T extends HTMLElement>(ref: RefObject<T> | null): JQuery<T> {
|
||||
if (ref?.current) {
|
||||
return $(ref.current);
|
||||
} else {
|
||||
return $();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a React component and returns the corresponding DOM element wrapped in JQuery.
|
||||
*
|
||||
*
|
||||
* @param parentComponent the parent Trilium component for the component to be able to handle events.
|
||||
* @param el the JSX element to render.
|
||||
* @returns the rendered wrapped DOM element.
|
||||
@ -36,4 +39,16 @@ export function renderReactWidgetAtElement(parentComponent: Component, el: JSX.E
|
||||
|
||||
export function disposeReactWidget(container: Element) {
|
||||
render(null, container);
|
||||
}
|
||||
}
|
||||
|
||||
export function joinElements(components: ComponentChild[], separator = ", ") {
|
||||
const joinedComponents: ComponentChild[] = [];
|
||||
for (let i=0; i<components.length; i++) {
|
||||
joinedComponents.push(components[i]);
|
||||
if (i + 1 < components.length) {
|
||||
joinedComponents.push(separator);
|
||||
}
|
||||
}
|
||||
|
||||
return joinedComponents;
|
||||
}
|
||||
381
apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx
Normal file
381
apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { NOTE_TYPES } from "../../services/note_types";
|
||||
import { FormDropdownDivider, FormListBadge, FormListItem } from "../react/FormList";
|
||||
import { getAvailableLocales, t } from "../../services/i18n";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks";
|
||||
import mime_types from "../../services/mime_types";
|
||||
import { Locale, NoteType, ToggleInParentResponse } from "@triliumnext/commons";
|
||||
import server from "../../services/server";
|
||||
import dialog from "../../services/dialog";
|
||||
import FormToggle from "../react/FormToggle";
|
||||
import FNote from "../../entities/fnote";
|
||||
import protected_session from "../../services/protected_session";
|
||||
import FormDropdownList from "../react/FormDropdownList";
|
||||
import toast from "../../services/toast";
|
||||
import branches from "../../services/branches";
|
||||
import sync from "../../services/sync";
|
||||
import HelpButton from "../react/HelpButton";
|
||||
import { TabContext } from "./ribbon-interface";
|
||||
import Modal from "../react/Modal";
|
||||
import { CodeMimeTypesList } from "../type_widgets/options/code_notes";
|
||||
import { ContentLanguagesList } from "../type_widgets/options/i18n";
|
||||
|
||||
export default function BasicPropertiesTab({ note }: TabContext) {
|
||||
return (
|
||||
<div className="basic-properties-widget">
|
||||
<NoteTypeWidget note={note} />
|
||||
<ProtectedNoteSwitch note={note} />
|
||||
<EditabilitySelect note={note} />
|
||||
<BookmarkSwitch note={note} />
|
||||
<SharedSwitch note={note} />
|
||||
<TemplateSwitch note={note} />
|
||||
<NoteLanguageSwitch note={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteTypeWidget({ note }: { note?: FNote | null }) {
|
||||
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []);
|
||||
const [ codeNotesMimeTypes ] = useTriliumOption("codeNotesMimeTypes");
|
||||
const mimeTypes = useMemo(() => {
|
||||
mime_types.loadMimeTypes();
|
||||
return mime_types.getMimeTypes().filter(mimeType => mimeType.enabled)
|
||||
}, [ codeNotesMimeTypes ]);
|
||||
const notSelectableNoteTypes = useMemo(() => NOTE_TYPES.filter((nt) => nt.reserved || nt.static).map((nt) => nt.type), []);
|
||||
|
||||
const currentNoteType = useNoteProperty(note, "type") ?? undefined;
|
||||
const currentNoteMime = useNoteProperty(note, "mime");
|
||||
const [ modalShown, setModalShown ] = useState(false);
|
||||
|
||||
const changeNoteType = useCallback(async (type: NoteType, mime?: string) => {
|
||||
if (!note || (type === currentNoteType && mime === currentNoteMime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm change if the note already has a content.
|
||||
if (type !== currentNoteType) {
|
||||
const blob = await note.getBlob();
|
||||
|
||||
if (blob?.content && blob.content.trim().length &&
|
||||
!await (dialog.confirm(t("note_types.confirm-change")))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await server.put(`notes/${note.noteId}/type`, { type, mime });
|
||||
}, [ note, currentNoteType, currentNoteMime ]);
|
||||
|
||||
return (
|
||||
<div className="note-type-container">
|
||||
<span>{t("basic_properties.note_type")}:</span>
|
||||
<Dropdown
|
||||
dropdownContainerClassName="note-type-dropdown"
|
||||
text={<span className="note-type-desc">{findTypeTitle(currentNoteType, currentNoteMime)}</span>}
|
||||
disabled={notSelectableNoteTypes.includes(currentNoteType ?? "text")}
|
||||
>
|
||||
{noteTypes.map(({ isNew, isBeta, type, mime, title }) => {
|
||||
const badges: FormListBadge[] = [];
|
||||
if (isNew) {
|
||||
badges.push({
|
||||
className: "new-note-type-badge",
|
||||
text: t("note_types.new-feature")
|
||||
});
|
||||
}
|
||||
if (isBeta) {
|
||||
badges.push({
|
||||
text: t("note_types.beta-feature")
|
||||
});
|
||||
}
|
||||
|
||||
const checked = (type === currentNoteType);
|
||||
if (type !== "code") {
|
||||
return (
|
||||
<FormListItem
|
||||
checked={checked}
|
||||
badges={badges}
|
||||
onClick={() => changeNoteType(type, mime)}
|
||||
>{title}</FormListItem>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<FormDropdownDivider />
|
||||
<FormListItem
|
||||
checked={checked}
|
||||
disabled
|
||||
>
|
||||
<strong>{title}</strong>
|
||||
</FormListItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
||||
{mimeTypes.map(({ title, mime }) => (
|
||||
<FormListItem onClick={() => changeNoteType("code", mime)}>
|
||||
{title}
|
||||
</FormListItem>
|
||||
))}
|
||||
|
||||
<FormDropdownDivider />
|
||||
<FormListItem icon="bx bx-cog" onClick={() => setModalShown(true)}>{t("basic_properties.configure_code_notes")}</FormListItem>
|
||||
</Dropdown>
|
||||
|
||||
<Modal
|
||||
className="code-mime-types-modal"
|
||||
title={t("code_mime_types.title")}
|
||||
show={modalShown} onHidden={() => setModalShown(false)}
|
||||
size="xl" scrollable
|
||||
>
|
||||
<CodeMimeTypesList />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProtectedNoteSwitch({ note }: { note?: FNote | null }) {
|
||||
const isProtected = useNoteProperty(note, "isProtected");
|
||||
|
||||
return (
|
||||
<div className="protected-note-switch-container">
|
||||
<FormToggle
|
||||
switchOnName={t("protect_note.toggle-on")} switchOnTooltip={t("protect_note.toggle-on-hint")}
|
||||
switchOffName={t("protect_note.toggle-off")} switchOffTooltip={t("protect_note.toggle-off-hint")}
|
||||
currentValue={!!isProtected}
|
||||
onChange={(shouldProtect) => note && protected_session.protectNote(note.noteId, shouldProtect, false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditabilitySelect({ note }: { note?: FNote | null }) {
|
||||
const [ readOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const [ autoReadOnlyDisabled, setAutoReadOnlyDisabled ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled");
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{
|
||||
value: "auto",
|
||||
label: t("editability_select.auto"),
|
||||
description: t("editability_select.note_is_editable"),
|
||||
},
|
||||
{
|
||||
value: "readOnly",
|
||||
label: t("editability_select.read_only"),
|
||||
description: t("editability_select.note_is_read_only")
|
||||
},
|
||||
{
|
||||
value: "autoReadOnlyDisabled",
|
||||
label: t("editability_select.always_editable"),
|
||||
description: t("editability_select.note_is_always_editable")
|
||||
}
|
||||
]), []);
|
||||
|
||||
return (
|
||||
<div class="editability-select-container">
|
||||
<span>{t("basic_properties.editable")}:</span>
|
||||
|
||||
<FormDropdownList
|
||||
dropdownContainerClassName="editability-dropdown"
|
||||
values={options}
|
||||
currentValue={ readOnly ? "readOnly" : autoReadOnlyDisabled ? "autoReadOnlyDisabled" : "auto" }
|
||||
keyProperty="value" titleProperty="label" descriptionProperty="description"
|
||||
onChange={(editability: string) => {
|
||||
setReadOnly(editability === "readOnly");
|
||||
setAutoReadOnlyDisabled(editability === "autoReadOnlyDisabled");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkSwitch({ note }: { note?: FNote | null }) {
|
||||
const [ isBookmarked, setIsBookmarked ] = useState<boolean>(false);
|
||||
const refreshState = useCallback(() => {
|
||||
const isBookmarked = note && !!note.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks");
|
||||
setIsBookmarked(!!isBookmarked);
|
||||
}, [ note ]);
|
||||
|
||||
useEffect(() => refreshState(), [ note ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (note && loadResults.getBranchRows().find((b) => b.noteId === note.noteId)) {
|
||||
refreshState();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bookmark-switch-container">
|
||||
<FormToggle
|
||||
switchOnName={t("bookmark_switch.bookmark")} switchOnTooltip={t("bookmark_switch.bookmark_this_note")}
|
||||
switchOffName={t("bookmark_switch.bookmark")} switchOffTooltip={t("bookmark_switch.remove_bookmark")}
|
||||
currentValue={isBookmarked}
|
||||
onChange={async (shouldBookmark) => {
|
||||
if (!note) return;
|
||||
const resp = await server.put<ToggleInParentResponse>(`notes/${note.noteId}/toggle-in-parent/_lbBookmarks/${shouldBookmark}`);
|
||||
|
||||
if (!resp.success && "message" in resp) {
|
||||
toast.showError(resp.message);
|
||||
}
|
||||
}}
|
||||
disabled={["root", "_hidden"].includes(note?.noteId ?? "")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateSwitch({ note }: { note?: FNote | null }) {
|
||||
const [ isTemplate, setIsTemplate ] = useNoteLabelBoolean(note, "template");
|
||||
|
||||
return (
|
||||
<div className="template-switch-container">
|
||||
<FormToggle
|
||||
switchOnName={t("template_switch.template")} switchOnTooltip={t("template_switch.toggle-on-hint")}
|
||||
switchOffName={t("template_switch.template")} switchOffTooltip={t("template_switch.toggle-off-hint")}
|
||||
helpPage="KC1HB96bqqHX"
|
||||
disabled={note?.noteId.startsWith("_options")}
|
||||
currentValue={isTemplate} onChange={setIsTemplate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SharedSwitch({ note }: { note?: FNote | null }) {
|
||||
const [ isShared, setIsShared ] = useState(false);
|
||||
const refreshState = useCallback(() => {
|
||||
setIsShared(!!note?.hasAncestor("_share"));
|
||||
}, [ note ]);
|
||||
|
||||
useEffect(() => refreshState(), [ note ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (note && loadResults.getBranchRows().find((b) => b.noteId === note.noteId)) {
|
||||
refreshState();
|
||||
}
|
||||
});
|
||||
|
||||
const switchShareState = useCallback(async (shouldShare: boolean) => {
|
||||
if (!note) return;
|
||||
|
||||
if (shouldShare) {
|
||||
await branches.cloneNoteToParentNote(note.noteId, "_share");
|
||||
} else {
|
||||
if (note?.getParentBranches().length === 1 && !(await dialog.confirm(t("shared_switch.shared-branch")))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shareBranch = note?.getParentBranches().find((b) => b.parentNoteId === "_share");
|
||||
if (!shareBranch?.branchId) return;
|
||||
await server.remove(`branches/${shareBranch.branchId}?taskId=no-progress-reporting`);
|
||||
}
|
||||
|
||||
sync.syncNow(true);
|
||||
}, [ note ]);
|
||||
|
||||
return (
|
||||
<div className="shared-switch-container">
|
||||
<FormToggle
|
||||
currentValue={isShared}
|
||||
onChange={switchShareState}
|
||||
switchOnName={t("shared_switch.shared")} switchOnTooltip={t("shared_switch.toggle-on-title")}
|
||||
switchOffName={t("shared_switch.shared")} switchOffTooltip={t("shared_switch.toggle-off-title")}
|
||||
helpPage="R9pX4DGra2Vt"
|
||||
disabled={["root", "_share", "_hidden"].includes(note?.noteId ?? "") || note?.noteId.startsWith("_options")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteLanguageSwitch({ note }: { note?: FNote | null }) {
|
||||
const [ languages ] = useTriliumOption("languages");
|
||||
const DEFAULT_LOCALE = {
|
||||
id: "",
|
||||
name: t("note_language.not_set")
|
||||
};
|
||||
|
||||
const [ currentNoteLanguage, setCurrentNoteLanguage ] = useNoteLabel(note, "language");
|
||||
const [ modalShown, setModalShown ] = useState(false);
|
||||
|
||||
const locales = useMemo(() => {
|
||||
const enabledLanguages = JSON.parse(languages ?? "[]") as string[];
|
||||
const filteredLanguages = getAvailableLocales().filter((l) => typeof l !== "object" || enabledLanguages.includes(l.id));
|
||||
const leftToRightLanguages = filteredLanguages.filter((l) => !l.rtl);
|
||||
const rightToLeftLanguages = filteredLanguages.filter((l) => l.rtl);
|
||||
|
||||
let locales: ("---" | Locale)[] = [
|
||||
DEFAULT_LOCALE
|
||||
];
|
||||
|
||||
if (leftToRightLanguages.length > 0) {
|
||||
locales = [
|
||||
...locales,
|
||||
"---",
|
||||
...leftToRightLanguages
|
||||
];
|
||||
}
|
||||
|
||||
if (rightToLeftLanguages.length > 0) {
|
||||
locales = [
|
||||
...locales,
|
||||
"---",
|
||||
...rightToLeftLanguages
|
||||
];
|
||||
}
|
||||
|
||||
// This will separate the list of languages from the "Configure languages" button.
|
||||
// If there is at least one language.
|
||||
locales.push("---");
|
||||
return locales;
|
||||
}, [ languages ]);
|
||||
|
||||
const currentLocale = useMemo(() => {
|
||||
return locales.find(locale => typeof locale === "object" && locale.id === currentNoteLanguage) as Locale | undefined;
|
||||
}, [ currentNoteLanguage ]);
|
||||
|
||||
return (
|
||||
<div className="note-language-container">
|
||||
<span>{t("basic_properties.language")}:</span>
|
||||
|
||||
<Dropdown text={currentLocale?.name ?? DEFAULT_LOCALE.name}>
|
||||
{locales.map(locale => {
|
||||
if (typeof locale === "object") {
|
||||
const checked = locale.id === (currentNoteLanguage ?? "");
|
||||
return <FormListItem
|
||||
rtl={locale.rtl}
|
||||
checked={checked}
|
||||
onClick={() => setCurrentNoteLanguage(locale.id)}
|
||||
>{locale.name}</FormListItem>
|
||||
} else {
|
||||
return <FormDropdownDivider />
|
||||
}
|
||||
})}
|
||||
|
||||
<FormListItem
|
||||
onClick={() => setModalShown(true)}
|
||||
>{t("note_language.configure-languages")}</FormListItem>
|
||||
</Dropdown>
|
||||
|
||||
<HelpButton helpPage="B0lcI9xz1r8K" style={{ marginLeft: "4px" }} />
|
||||
|
||||
<Modal
|
||||
className="content-languages-modal"
|
||||
title={t("content_language.title")}
|
||||
show={modalShown} onHidden={() => setModalShown(false)}
|
||||
size="lg" scrollable
|
||||
>
|
||||
<ContentLanguagesList />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function findTypeTitle(type?: NoteType, mime?: string | null) {
|
||||
if (type === "code") {
|
||||
const mimeTypes = mime_types.getMimeTypes();
|
||||
const found = mimeTypes.find((mt) => mt.mime === mime);
|
||||
|
||||
return found ? found.title : mime;
|
||||
} else {
|
||||
const noteType = NOTE_TYPES.find((nt) => nt.type === type);
|
||||
|
||||
return noteType ? noteType.title : type;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user