From 8ff108db9e82b9db3f2887c6da972d1737287360 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 14 Aug 2025 17:19:38 +0300 Subject: [PATCH 01/90] feat(react/settings): basic rendering of React content widgets --- .../widgets/type_widgets/content_widget.ts | 33 ++++++++++--------- .../type_widgets/options/appearance.tsx | 3 ++ 2 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 apps/client/src/widgets/type_widgets/options/appearance.tsx diff --git a/apps/client/src/widgets/type_widgets/content_widget.ts b/apps/client/src/widgets/type_widgets/content_widget.ts index beb9de272..7f1b675bf 100644 --- a/apps/client/src/widgets/type_widgets/content_widget.ts +++ b/apps/client/src/widgets/type_widgets/content_widget.ts @@ -47,6 +47,9 @@ import type BasicWidget from "../basic_widget.js"; import CodeTheme from "./options/code_notes/code_theme.js"; import RelatedSettings from "./options/appearance/related_settings.js"; import EditorFeaturesOptions from "./options/text_notes/features.js"; +import type { JSX } from "preact/jsx-runtime"; +import AppearanceSettings from "./options/appearance.jsx"; +import { renderReactWidget } from "../react/ReactBasicWidget.jsx"; const TPL = /*html*/`
-
-`; - -interface RelatedSettingsConfig { - items: { - title: string; - targetPage: OptionPages; - }[]; -} - -const RELATED_SETTINGS: Record = { - "_optionsAppearance": { - items: [ - { - title: "Color scheme for code blocks in text notes", - targetPage: "_optionsTextNotes" - }, - { - title: "Color scheme for code notes", - targetPage: "_optionsCodeNotes" - } - ] - } -}; - -export default class RelatedSettings extends OptionsWidget { - - doRender() { - this.$widget = $(TPL); - - const config = this.noteId && RELATED_SETTINGS[this.noteId]; - if (!config) { - return; - } - - const $relatedSettings = this.$widget.find(".related-settings"); - $relatedSettings.empty(); - for (const item of config.items) { - const $item = $("
  • "); - const $link = $("").text(item.title); - - $item.append($link); - $link.attr("href", `#root/_hidden/_options/${item.targetPage}`); - $relatedSettings.append($item); - } - } - - isEnabled() { - return (!!this.noteId && this.noteId in RELATED_SETTINGS); - } - -} diff --git a/apps/client/src/widgets/type_widgets/options/components/RelatedSettings.tsx b/apps/client/src/widgets/type_widgets/options/components/RelatedSettings.tsx new file mode 100644 index 000000000..9ba1b1064 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/components/RelatedSettings.tsx @@ -0,0 +1,24 @@ +import OptionsSection from "./OptionsSection"; +import type { OptionPages } from "../../content_widget"; +import { t } from "../../../../services/i18n"; + +interface RelatedSettingsProps { + items: { + title: string; + targetPage: OptionPages; + }[]; +} + +export default function RelatedSettings({ items }: RelatedSettingsProps) { + return ( + + + + ); +} \ No newline at end of file From c67c3a68615e5ef5a3325cfbbc1aa74abb41ea54 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 14 Aug 2025 22:27:07 +0300 Subject: [PATCH 18/90] feat(react/settings): port images --- apps/client/src/stylesheets/style.css | 5 + apps/client/src/widgets/react/FormGroup.tsx | 5 +- .../widgets/type_widgets/content_widget.tsx | 6 +- .../widgets/type_widgets/options/images.tsx | 51 ++++++++++ .../type_widgets/options/images/images.ts | 98 ------------------- 5 files changed, 61 insertions(+), 104 deletions(-) create mode 100644 apps/client/src/widgets/type_widgets/options/images.tsx delete mode 100644 apps/client/src/widgets/type_widgets/options/images/images.ts diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index fa55b04a6..8e1a787e0 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -139,6 +139,11 @@ textarea, color: var(--muted-text-color); } +.form-group.disabled { + opacity: 0.5; + pointer-events: none; +} + /* Add a gap between consecutive radios / check boxes */ label.tn-radio + label.tn-radio, label.tn-checkbox + label.tn-checkbox { diff --git a/apps/client/src/widgets/react/FormGroup.tsx b/apps/client/src/widgets/react/FormGroup.tsx index ff972d49c..513594dfa 100644 --- a/apps/client/src/widgets/react/FormGroup.tsx +++ b/apps/client/src/widgets/react/FormGroup.tsx @@ -7,11 +7,12 @@ interface FormGroupProps { className?: string; children: ComponentChildren; description?: string | ComponentChildren; + disabled?: boolean; } -export default function FormGroup({ label, title, className, children, description, labelRef }: FormGroupProps) { +export default function FormGroup({ label, title, className, children, description, labelRef, disabled }: FormGroupProps) { return ( -
    ) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 04ae86ef2..6ea511be9 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -142,6 +142,14 @@ export function useTriliumOptionInt(name: OptionNames): [number, (newValue: numb ] } +export function useTriliumOptionJson(name: OptionNames): [ T, (newValue: T) => Promise ] { + const [ value, setValue ] = useTriliumOption(name); + return [ + (JSON.parse(value) as T), + (newValue => setValue(JSON.stringify(newValue))) + ]; +} + /** * Generates a unique name via a random alphanumeric string of a fixed length. * diff --git a/apps/client/src/widgets/type_widgets/content_widget.tsx b/apps/client/src/widgets/type_widgets/content_widget.tsx index cf50cb3f3..b847dd04d 100644 --- a/apps/client/src/widgets/type_widgets/content_widget.tsx +++ b/apps/client/src/widgets/type_widgets/content_widget.tsx @@ -24,7 +24,6 @@ import HtmlImportTagsOptions from "./options/other/html_import_tags.js"; import BackendLogWidget from "./content/backend_log.js"; import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js"; import MultiFactorAuthenticationOptions from './options/multi_factor_authentication.js'; -import LocalizationOptions from "./options/i18n/i18n.js"; import CodeBlockOptions from "./options/text_notes/code_block.js"; import EditorOptions from "./options/text_notes/editor.js"; import ShareSettingsOptions from "./options/other/share_settings.js"; @@ -32,7 +31,6 @@ import AiSettingsOptions from "./options/ai_settings.js"; import type FNote from "../../entities/fnote.js"; import type NoteContextAwareWidget from "../note_context_aware_widget.js"; import { t } from "../../services/i18n.js"; -import LanguageOptions from "./options/i18n/language.js"; import type BasicWidget from "../basic_widget.js"; import CodeTheme from "./options/code_notes/code_theme.js"; import EditorFeaturesOptions from "./options/text_notes/features.js"; diff --git a/apps/client/src/widgets/type_widgets/options/components/CheckboxList.tsx b/apps/client/src/widgets/type_widgets/options/components/CheckboxList.tsx new file mode 100644 index 000000000..fedfe1d86 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/components/CheckboxList.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from "preact/hooks"; + +interface CheckboxListProps { + values: T[]; + keyProperty: keyof T; + titleProperty?: keyof T; + currentValue: string[]; + onChange: (newValues: string[]) => void; +} + +export default function CheckboxList({ values, keyProperty, titleProperty, currentValue, onChange }: CheckboxListProps) { + function toggleValue(value: string) { + if (currentValue.includes(value)) { + // Already there, needs removing. + onChange(currentValue.filter(v => v !== value)); + } else { + // Not there, needs adding. + onChange([ ...currentValue, value ]); + } + } + + return ( +
      + {values.map(value => ( +
    • + +
    • + ))} +
    + ) +} \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/options/i18n.tsx b/apps/client/src/widgets/type_widgets/options/i18n.tsx index 796e3b0f4..e424ef791 100644 --- a/apps/client/src/widgets/type_widgets/options/i18n.tsx +++ b/apps/client/src/widgets/type_widgets/options/i18n.tsx @@ -3,7 +3,7 @@ import { getAvailableLocales, t } from "../../../services/i18n"; import FormSelect from "../../react/FormSelect"; import OptionsRow from "./components/OptionsRow"; import OptionsSection from "./components/OptionsSection"; -import { useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; +import { useTriliumOption, useTriliumOptionInt, useTriliumOptionJson } from "../../react/hooks"; import type { Locale } from "@triliumnext/commons"; import { isElectron, restartDesktopApp } from "../../../services/utils"; import FormRadioGroup, { FormInlineRadioGroup } from "../../react/FormRadioGroup"; @@ -11,11 +11,13 @@ import FormText from "../../react/FormText"; import RawHtml from "../../react/RawHtml"; import Admonition from "../../react/Admonition"; import Button from "../../react/Button"; +import CheckboxList from "./components/CheckboxList"; export default function InternationalizationOptions() { return ( <> + ) } @@ -115,4 +117,21 @@ function DateSettings() { ) +} + +function ContentLanguages() { + const locales = useMemo(() => getAvailableLocales(), []); + const [ languages, setLanguages ] = useTriliumOptionJson("languages"); + + return ( + + {t("content_language.description")} + + + + ); } \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/options/i18n/language.ts b/apps/client/src/widgets/type_widgets/options/i18n/language.ts deleted file mode 100644 index 7b38067a9..000000000 --- a/apps/client/src/widgets/type_widgets/options/i18n/language.ts +++ /dev/null @@ -1,63 +0,0 @@ -import OptionsWidget from "../options_widget.js"; -import type { OptionMap } from "@triliumnext/commons"; -import { getAvailableLocales } from "../../../../services/i18n.js"; -import { t } from "../../../../services/i18n.js"; - -const TPL = /*html*/` -
    -

    ${t("content_language.title")}

    -

    ${t("content_language.description")}

    - -
      -
    - - -
    -`; - -export default class LanguageOptions extends OptionsWidget { - - private $languagesContainer!: JQuery; - - doRender() { - this.$widget = $(TPL); - this.$languagesContainer = this.$widget.find(".options-languages"); - } - - async save() { - const enabledLanguages: string[] = []; - - this.$languagesContainer.find("input:checked").each((i, el) => { - const languageId = $(el).attr("data-language-id"); - if (languageId) { - enabledLanguages.push(languageId); - } - }); - - await this.updateOption("languages", JSON.stringify(enabledLanguages)); - } - - async optionsLoaded(options: OptionMap) { - const availableLocales = getAvailableLocales(); - const enabledLanguages = (JSON.parse(options.languages) as string[]); - - this.$languagesContainer.empty(); - for (const locale of availableLocales) { - const checkbox = $('') - .attr("data-language-id", locale.id) - .prop("checked", enabledLanguages.includes(locale.id)); - const wrapper = $(`
  • ").append(wrapper)); - } - } - -} From f62078d02b1ac748a3bd51cc6d8e39bc59f6e636 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 15 Aug 2025 11:21:19 +0300 Subject: [PATCH 33/90] feat(react/settings): port sync options --- apps/client/src/services/utils.ts | 53 +++++----- apps/client/src/widgets/react/FormTextBox.tsx | 7 +- apps/client/src/widgets/react/hooks.tsx | 14 +++ .../widgets/type_widgets/content_widget.tsx | 6 +- .../src/widgets/type_widgets/options/sync.ts | 100 ------------------ .../src/widgets/type_widgets/options/sync.tsx | 62 +++++++++++ 6 files changed, 110 insertions(+), 132 deletions(-) delete mode 100644 apps/client/src/widgets/type_widgets/options/sync.ts create mode 100644 apps/client/src/widgets/type_widgets/options/sync.tsx diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index fb2d1941d..4b714bf9c 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -374,33 +374,36 @@ async function openInAppHelp($button: JQuery) { const inAppHelpPage = $button.attr("data-in-app-help"); if (inAppHelpPage) { - // Dynamic import to avoid import issues in tests. - const appContext = (await import("../components/app_context.js")).default; - const activeContext = appContext.tabManager.getActiveContext(); - if (!activeContext) { - return; - } - const subContexts = activeContext.getSubContexts(); - const targetNote = `_help_${inAppHelpPage}`; - const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help"); - const viewScope: ViewScope = { - viewMode: "contextual-help", - }; - if (!helpSubcontext) { - // The help is not already open, open a new split with it. - const { ntxId } = subContexts[subContexts.length - 1]; - appContext.triggerCommand("openNewNoteSplit", { - ntxId, - notePath: targetNote, - hoistedNoteId: "_help", - viewScope - }) - } else { - // There is already a help window open, make sure it opens on the right note. - helpSubcontext.setNote(targetNote, { viewScope }); - } + openInAppHelpFromUrl(inAppHelpPage); + } +} + +export async function openInAppHelpFromUrl(inAppHelpPage: string) { + // Dynamic import to avoid import issues in tests. + const appContext = (await import("../components/app_context.js")).default; + const activeContext = appContext.tabManager.getActiveContext(); + if (!activeContext) { return; } + const subContexts = activeContext.getSubContexts(); + const targetNote = `_help_${inAppHelpPage}`; + const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help"); + const viewScope: ViewScope = { + viewMode: "contextual-help", + }; + if (!helpSubcontext) { + // The help is not already open, open a new split with it. + const { ntxId } = subContexts[subContexts.length - 1]; + appContext.triggerCommand("openNewNoteSplit", { + ntxId, + notePath: targetNote, + hoistedNoteId: "_help", + viewScope + }) + } else { + // There is already a help window open, make sure it opens on the right note. + helpSubcontext.setNote(targetNote, { viewScope }); + } } function initHelpButtons($el: JQuery | JQuery) { diff --git a/apps/client/src/widgets/react/FormTextBox.tsx b/apps/client/src/widgets/react/FormTextBox.tsx index abf0e9c5a..01b7b56c2 100644 --- a/apps/client/src/widgets/react/FormTextBox.tsx +++ b/apps/client/src/widgets/react/FormTextBox.tsx @@ -1,5 +1,4 @@ import type { InputHTMLAttributes, RefObject } from "preact/compat"; -import FormText from "./FormText"; interface FormTextBoxProps extends Omit, "onChange" | "value"> { id?: string; @@ -11,9 +10,11 @@ interface FormTextBoxProps extends Omit, " export default function FormTextBox({ inputRef, className, type, currentValue, onChange, ...rest}: FormTextBoxProps) { if (type === "number" && currentValue) { const { min, max } = rest; - if (min && currentValue < min) { + console.log(currentValue , min, max); + const currentValueNum = parseInt(currentValue, 10); + if (min && currentValueNum < parseInt(String(min), 10)) { currentValue = String(min); - } else if (max && currentValue > max) { + } else if (max && currentValueNum > parseInt(String(max), 10)) { currentValue = String(max); } } diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 6ea511be9..846ea62fb 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -6,6 +6,7 @@ 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"; type TriliumEventHandler = (data: EventData) => void; const registeredHandlers: Map[]>> = new Map(); @@ -150,6 +151,19 @@ export function useTriliumOptionJson(name: OptionNames): [ T, (newValue: T) = ]; } +export function useTriliumOptions(...names: T[]) { + const values: Record = {}; + for (const name of names) { + values[name] = options.get(name); + } + + const setValue = (newValues: Record) => server.put("options", newValues); + return [ + values as Record, + setValue + ] as const; +} + /** * Generates a unique name via a random alphanumeric string of a fixed length. * diff --git a/apps/client/src/widgets/type_widgets/content_widget.tsx b/apps/client/src/widgets/type_widgets/content_widget.tsx index b847dd04d..9ec12e032 100644 --- a/apps/client/src/widgets/type_widgets/content_widget.tsx +++ b/apps/client/src/widgets/type_widgets/content_widget.tsx @@ -13,7 +13,6 @@ import PasswordOptions from "./options/password/password.js"; import ProtectedSessionTimeoutOptions from "./options/password/protected_session_timeout.js"; import EtapiOptions from "./options/etapi.js"; import BackupOptions from "./options/backup.js"; -import SyncOptions from "./options/sync.js"; import SearchEngineOptions from "./options/other/search_engine.js"; import TrayOptions from "./options/other/tray.js"; import NoteErasureTimeoutOptions from "./options/other/note_erasure_timeout.js"; @@ -40,6 +39,7 @@ import { renderReactWidget } from "../react/ReactBasicWidget.jsx"; import ImageSettings from "./options/images.jsx"; import AdvancedSettings from "./options/advanced.jsx"; import InternationalizationOptions from "./options/i18n.jsx"; +import SyncOptions from "./options/sync.jsx"; const TPL = /*html*/`
    `; - -// TODO: Deduplicate -interface PostTokensResponse { - authToken: string; -} - -// TODO: Deduplicate -interface Token { - name: string; - utcDateCreated: number; - etapiTokenId: string; -} - -export default class EtapiOptions extends OptionsWidget { - - doRender() { - this.$widget = $(TPL); - - this.$widget.find(".create-etapi-token").on("click", async () => { - const tokenName = await dialogService.prompt({ - title: t("etapi.new_token_title"), - message: t("etapi.new_token_message"), - defaultValue: t("etapi.default_token_name") - }); - - if (!tokenName?.trim()) { - toastService.showError(t("etapi.error_empty_name")); - return; - } - - const { authToken } = await server.post("etapi-tokens", { tokenName }); - - await dialogService.prompt({ - title: t("etapi.token_created_title"), - message: t("etapi.token_created_message"), - defaultValue: authToken - }); - - this.refreshTokens(); - }); - - this.refreshTokens(); - } - - async refreshTokens() { - const $noTokensYet = this.$widget.find(".no-tokens-yet"); - const $tokensTable = this.$widget.find(".tokens-table"); - - const tokens = await server.get("etapi-tokens"); - - $noTokensYet.toggle(tokens.length === 0); - $tokensTable.toggle(tokens.length > 0); - - const $tokensTableBody = $tokensTable.find("tbody"); - $tokensTableBody.empty(); - - for (const token of tokens) { - $tokensTableBody.append( - $("") - .append($("").text(token.name)) - .append($("").text(formatDateTime(token.utcDateCreated))) - .append( - $("").append( - $(``).on("click", () => this.renameToken(token.etapiTokenId, token.name)), - $(``).on("click", () => this.deleteToken(token.etapiTokenId, token.name)) - ) - ) - ); - } - } - - async renameToken(etapiTokenId: string, oldName: string) { - const tokenName = await dialogService.prompt({ - title: t("etapi.rename_token_title"), - message: t("etapi.rename_token_message"), - defaultValue: oldName - }); - - if (!tokenName?.trim()) { - return; - } - - await server.patch(`etapi-tokens/${etapiTokenId}`, { name: tokenName }); - - this.refreshTokens(); - } - - async deleteToken(etapiTokenId: string, name: string) { - if (!(await dialogService.confirm(t("etapi.delete_token_confirmation", { name })))) { - return; - } - - await server.remove(`etapi-tokens/${etapiTokenId}`); - - this.refreshTokens(); - } -} diff --git a/apps/client/src/widgets/type_widgets/options/etapi.tsx b/apps/client/src/widgets/type_widgets/options/etapi.tsx new file mode 100644 index 000000000..ebd497ccc --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/etapi.tsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useState } from "preact/hooks"; +import { t } from "../../../services/i18n"; +import Button from "../../react/Button"; +import FormText from "../../react/FormText"; +import RawHtml from "../../react/RawHtml"; +import OptionsSection from "./components/OptionsSection"; +import { EtapiToken, PostTokensResponse } from "@triliumnext/commons"; +import server from "../../../services/server"; +import toast from "../../../services/toast"; +import dialog from "../../../services/dialog"; +import { formatDateTime } from "../../../utils/formatters"; +import ActionButton from "../../react/ActionButton"; + +type RenameTokenCallback = (tokenId: string, oldName: string) => Promise; +type DeleteTokenCallback = (tokenId: string, name: string ) => Promise; + +export default function EtapiSettings() { + const [ tokens, setTokens ] = useState([]); + + function refreshTokens() { + server.get("etapi-tokens").then(setTokens); + } + + useEffect(refreshTokens, []); + + const createTokenCallback = useCallback(async () => { + const tokenName = await dialog.prompt({ + title: t("etapi.new_token_title"), + message: t("etapi.new_token_message"), + defaultValue: t("etapi.default_token_name") + }); + + if (!tokenName?.trim()) { + toast.showError(t("etapi.error_empty_name")); + return; + } + + const { authToken } = await server.post("etapi-tokens", { tokenName }); + + await dialog.prompt({ + title: t("etapi.token_created_title"), + message: t("etapi.token_created_message"), + defaultValue: authToken + }); + + refreshTokens(); + }, []); + + const renameTokenCallback = useCallback(async (tokenId: string, oldName: string) => { + const tokenName = await dialog.prompt({ + title: t("etapi.rename_token_title"), + message: t("etapi.rename_token_message"), + defaultValue: oldName + }); + + if (!tokenName?.trim()) { + return; + } + + await server.patch(`etapi-tokens/${tokenId}`, { name: tokenName }); + + refreshTokens(); + }, []); + + const deleteTokenCallback = useCallback(async (tokenId: string, name: string) => { + if (!(await dialog.confirm(t("etapi.delete_token_confirmation", { name })))) { + return; + } + + await server.remove(`etapi-tokens/${tokenId}`); + refreshTokens(); + }, []); + + return ( + + + {t("etapi.description")}
    + ${t("etapi.wiki")}`, + // TODO: We use window.open src/public/app/services/link.ts -> prevents regular click behavior on "a" element here because it's a relative path + link_to_openapi_spec: `${t("etapi.openapi_spec")}`, + link_to_swagger_ui: `${t("etapi.swagger_ui")}` + })} /> +
    + + - - -
    -`; - -let globActions: KeyboardShortcut[]; - -export default class KeyboardShortcutsOptions extends OptionsWidget { - doRender() { - this.$widget = $(TPL); - - this.$widget.find(".options-keyboard-shortcuts-reload-app").on("click", () => utils.reloadFrontendApp()); - - const $table = this.$widget.find(".keyboard-shortcut-table tbody"); - - server.get("keyboard-actions").then((actions) => { - globActions = actions; - - for (const action of actions) { - const $tr = $(""); - - if ("separator" in action) { - $tr.append($('').attr("style", "background-color: var(--accented-background-color); font-weight: bold;").text(action.separator)); - } else if (action.defaultShortcuts && action.actionName) { - $tr.append($("").text(action.friendlyName)) - .append( - $("").append( - $(``) - .val((action.effectiveShortcuts ?? []).join(", ")) - .attr("data-keyboard-action-name", action.actionName) - .attr("data-default-keyboard-shortcuts", action.defaultShortcuts.join(", ")) - ) - ) - .append($("").text(action.defaultShortcuts.join(", "))) - .append($("").text(action.description ?? "")); - } - - $table.append($tr); - } - }); - - $table.on("change", "input.form-control", (e) => { - const $input = this.$widget.find(e.target); - const actionName = $input.attr("data-keyboard-action-name"); - if (!actionName) { - return; - } - - const shortcuts = ($input.val() as String) - .replace("+,", "+Comma") - .split(",") - .map((shortcut) => shortcut.replace("+Comma", "+,")) - .filter((shortcut) => !!shortcut); - - const optionName = `keyboardShortcuts${actionName.substr(0, 1).toUpperCase()}${actionName.substr(1)}`; - - this.updateOption(optionName as OptionNames, JSON.stringify(shortcuts)); - }); - - this.$widget.find(".options-keyboard-shortcuts-set-all-to-default").on("click", async () => { - if (!(await dialogService.confirm(t("shortcuts.confirm_reset")))) { - return; - } - - $table.find("input.form-control").each((_index, el) => { - const defaultShortcuts = this.$widget.find(el).attr("data-default-keyboard-shortcuts"); - - if (defaultShortcuts && this.$widget.find(el).val() !== defaultShortcuts) { - this.$widget.find(el).val(defaultShortcuts).trigger("change"); - } - }); - }); - - const $filter = this.$widget.find(".keyboard-shortcut-filter"); - - $filter.on("keyup", () => { - const filter = String($filter.val()).trim().toLowerCase(); - - $table.find("tr").each((i, el) => { - if (!filter) { - this.$widget.find(el).show(); - return; - } - - const actionName = this.$widget.find(el).find("input").attr("data-keyboard-action-name"); - - if (!actionName) { - this.$widget.find(el).hide(); - return; - } - - const action = globActions.find((act) => "actionName" in act && act.actionName === actionName) as KeyboardShortcutWithRequiredActionName; - - if (!action) { - this.$widget.find(el).hide(); - return; - } - - this.$widget - .find(el) - .toggle( - !!( - action.actionName.toLowerCase().includes(filter) || - (action.friendlyName && action.friendlyName.toLowerCase().includes(filter)) || - (action.defaultShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) || - (action.effectiveShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) || - (action.description && action.description.toLowerCase().includes(filter)) - ) - ); - }); - }); - } -} diff --git a/apps/client/src/widgets/type_widgets/options/shortcuts.tsx b/apps/client/src/widgets/type_widgets/options/shortcuts.tsx new file mode 100644 index 000000000..770ebce87 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/shortcuts.tsx @@ -0,0 +1,140 @@ +import { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons"; +import { t } from "../../../services/i18n"; +import { reloadFrontendApp } from "../../../services/utils"; +import Button from "../../react/Button"; +import FormGroup from "../../react/FormGroup"; +import FormText from "../../react/FormText"; +import FormTextBox from "../../react/FormTextBox"; +import RawHtml from "../../react/RawHtml"; +import OptionsSection from "./components/OptionsSection"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import server from "../../../services/server"; +import options from "../../../services/options"; +import dialog from "../../../services/dialog"; + +export default function ShortcutSettings() { + const [ keyboardShortcuts, setKeyboardShortcuts ] = useState([]); + const [ filter, setFilter ] = useState(); + + useEffect(() => { + server.get("keyboard-actions").then(setKeyboardShortcuts); + }, []) + + const resetShortcuts = useCallback(async () => { + if (!(await dialog.confirm(t("shortcuts.confirm_reset")))) { + return; + } + + const newKeyboardShortcuts = []; + for (const keyboardShortcut of keyboardShortcuts) { + if (!("effectiveShortcuts" in keyboardShortcut)) { + continue; + } + + } + }, [ keyboardShortcuts ]); + + return ( + + + {t("shortcuts.multiple_shortcuts")} + + + + + setFilter(value.toLowerCase())} + /> + + +
    + +
    + +
    +
    +
    + ) +} + +function filterKeyboardAction(action: ActionKeyboardShortcut, filter: string) { + return action.actionName.toLowerCase().includes(filter) || + (action.friendlyName && action.friendlyName.toLowerCase().includes(filter)) || + (action.defaultShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) || + (action.effectiveShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) || + (action.description && action.description.toLowerCase().includes(filter)); +} + +function KeyboardShortcutTable({ filter, keyboardShortcuts }: { filter?: string, keyboardShortcuts: KeyboardShortcut[] }) { + return ( + + + + + + + + + + + {keyboardShortcuts.map(action => ( + + {"separator" in action ? ( !filter && + + ) : ( (!filter || filterKeyboardAction(action, filter)) && + <> + + + + + + )} + + ))} + +
    {t("shortcuts.action_name")}{t("shortcuts.shortcuts")}{t("shortcuts.default_shortcuts")}{t("shortcuts.description")}
    + {action.separator} + {action.friendlyName} + + {action.defaultShortcuts?.join(", ")}{action.description}
    + ); +} + +function ShortcutEditor({ keyboardShortcut: action }: { keyboardShortcut: ActionKeyboardShortcut }) { + const [ shortcuts, setShortcuts ] = useState((action.effectiveShortcuts ?? []).join(", ")); + + useEffect(() => { + const { actionName } = action; + const optionName = `keyboardShortcuts${actionName.substr(0, 1).toUpperCase()}${actionName.substr(1)}`; + const newShortcuts = shortcuts + .replace("+,", "+Comma") + .split(",") + .map((shortcut) => shortcut.replace("+Comma", "+,")) + .filter((shortcut) => !!shortcut); + options.save(optionName, JSON.stringify(newShortcuts)); + }, [ shortcuts ]) + + return ( + + ) +} \ No newline at end of file From b9b4961f3ca2d899eda212e51826121941bc1830 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 16 Aug 2025 00:13:18 +0300 Subject: [PATCH 47/90] fix(react/settings): shortcuts saved upon render --- .../type_widgets/options/shortcuts.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/options/shortcuts.tsx b/apps/client/src/widgets/type_widgets/options/shortcuts.tsx index 770ebce87..e6171f679 100644 --- a/apps/client/src/widgets/type_widgets/options/shortcuts.tsx +++ b/apps/client/src/widgets/type_widgets/options/shortcuts.tsx @@ -119,22 +119,20 @@ function KeyboardShortcutTable({ filter, keyboardShortcuts }: { filter?: string, } function ShortcutEditor({ keyboardShortcut: action }: { keyboardShortcut: ActionKeyboardShortcut }) { - const [ shortcuts, setShortcuts ] = useState((action.effectiveShortcuts ?? []).join(", ")); - - useEffect(() => { - const { actionName } = action; - const optionName = `keyboardShortcuts${actionName.substr(0, 1).toUpperCase()}${actionName.substr(1)}`; - const newShortcuts = shortcuts - .replace("+,", "+Comma") - .split(",") - .map((shortcut) => shortcut.replace("+Comma", "+,")) - .filter((shortcut) => !!shortcut); - options.save(optionName, JSON.stringify(newShortcuts)); - }, [ shortcuts ]) + const originalShortcut = (action.effectiveShortcuts ?? []).join(", "); return ( { + const { actionName } = action; + const optionName = `keyboardShortcuts${actionName.substr(0, 1).toUpperCase()}${actionName.substr(1)}`; + const newShortcuts = newShortcut + .replace("+,", "+Comma") + .split(",") + .map((shortcut) => shortcut.replace("+Comma", "+,")) + .filter((shortcut) => !!shortcut); + options.save(optionName, JSON.stringify(newShortcuts)); + }} /> ) } \ No newline at end of file From 5614891d925b12a0fc9131bb2550f486fbba7078 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 16 Aug 2025 00:22:18 +0300 Subject: [PATCH 48/90] fix(react/settings): unnecessary top margin --- .../type_widgets/options/components/OptionsSection.tsx | 10 +++++----- .../src/widgets/type_widgets/options/shortcuts.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/options/components/OptionsSection.tsx b/apps/client/src/widgets/type_widgets/options/components/OptionsSection.tsx index bd3a930e0..7fb3440fa 100644 --- a/apps/client/src/widgets/type_widgets/options/components/OptionsSection.tsx +++ b/apps/client/src/widgets/type_widgets/options/components/OptionsSection.tsx @@ -2,17 +2,17 @@ import type { ComponentChildren } from "preact"; import { CSSProperties } from "preact/compat"; interface OptionsSectionProps { - title: string; + title?: string; children: ComponentChildren; noCard?: boolean; style?: CSSProperties; + className?: string; } -export default function OptionsSection({ title, children, noCard, style }: OptionsSectionProps) { +export default function OptionsSection({ title, children, noCard, ...rest }: OptionsSectionProps) { return ( -
    -

    {title}

    - +
    + {title &&

    {title}

    } {children}
    ); diff --git a/apps/client/src/widgets/type_widgets/options/shortcuts.tsx b/apps/client/src/widgets/type_widgets/options/shortcuts.tsx index e6171f679..b948902c3 100644 --- a/apps/client/src/widgets/type_widgets/options/shortcuts.tsx +++ b/apps/client/src/widgets/type_widgets/options/shortcuts.tsx @@ -36,7 +36,7 @@ export default function ShortcutSettings() { return ( From 71b627fbc7f0a4e07619e5e328cb7c358789d6f1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 18 Aug 2025 09:34:16 +0300 Subject: [PATCH 49/90] feat(react/settings): port text formatting toolbar --- .../client/src/widgets/react/FormCheckbox.tsx | 7 +- .../src/widgets/react/FormRadioGroup.tsx | 16 +++-- apps/client/src/widgets/react/hooks.tsx | 14 +++- .../widgets/type_widgets/content_widget.tsx | 12 +--- .../type_widgets/options/appearance.tsx | 6 +- .../type_widgets/options/text_notes.tsx | 46 +++++++++++++ .../type_widgets/options/text_notes/editor.ts | 64 ------------------- 7 files changed, 80 insertions(+), 85 deletions(-) create mode 100644 apps/client/src/widgets/type_widgets/options/text_notes.tsx delete mode 100644 apps/client/src/widgets/type_widgets/options/text_notes/editor.ts diff --git a/apps/client/src/widgets/react/FormCheckbox.tsx b/apps/client/src/widgets/react/FormCheckbox.tsx index 4d6c2a384..f064e2e79 100644 --- a/apps/client/src/widgets/react/FormCheckbox.tsx +++ b/apps/client/src/widgets/react/FormCheckbox.tsx @@ -2,7 +2,7 @@ import { Tooltip } from "bootstrap"; import { useEffect, useRef, useMemo, useCallback } from "preact/hooks"; import { escapeQuotes } from "../../services/utils"; import { ComponentChildren } from "preact"; -import { memo } from "preact/compat"; +import { CSSProperties, memo } from "preact/compat"; interface FormCheckboxProps { name: string; @@ -14,9 +14,10 @@ interface FormCheckboxProps { currentValue: boolean; disabled?: boolean; onChange(newValue: boolean): void; + containerStyle?: CSSProperties; } -const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint }: FormCheckboxProps) => { +const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) => { const labelRef = useRef(null); // Fix: Move useEffect outside conditional @@ -46,7 +47,7 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint const titleText = useMemo(() => hint ? escapeQuotes(hint) : undefined, [hint]); return ( -
    +