From a3d77421fdd66983e7b1d591469ee8335d8ba54c Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sun, 24 Aug 2025 05:17:28 +0000 Subject: [PATCH] feat(options): allow for user selection of ckeditor plugins --- .../src/services/ckeditor_plugin_config.ts | 223 +++++++ .../src/translations/en/translation.json | 37 ++ .../widgets/type_widgets/ckeditor/config.ts | 18 +- .../widgets/type_widgets/ckeditor/toolbar.ts | 249 ++++--- .../widgets/type_widgets/content_widget.tsx | 4 +- .../type_widgets/options/ckeditor_plugins.tsx | 397 ++++++++++++ .../server/src/routes/api/ckeditor_plugins.ts | 388 +++++++++++ apps/server/src/routes/api/options.ts | 1 + apps/server/src/routes/routes.ts | 10 + apps/server/src/services/options_init.ts | 4 + packages/ckeditor5/src/index.ts | 2 +- packages/ckeditor5/src/plugins.ts | 612 ++++++++++++++++++ packages/ckeditor5/tsconfig.json | 11 +- packages/ckeditor5/tsconfig.lib.json | 11 +- packages/commons/src/index.ts | 1 + .../src/lib/ckeditor_plugin_interface.ts | 163 +++++ packages/commons/src/lib/options_interface.ts | 3 + 17 files changed, 2019 insertions(+), 115 deletions(-) create mode 100644 apps/client/src/services/ckeditor_plugin_config.ts create mode 100644 apps/client/src/widgets/type_widgets/options/ckeditor_plugins.tsx create mode 100644 apps/server/src/routes/api/ckeditor_plugins.ts create mode 100644 packages/commons/src/lib/ckeditor_plugin_interface.ts diff --git a/apps/client/src/services/ckeditor_plugin_config.ts b/apps/client/src/services/ckeditor_plugin_config.ts new file mode 100644 index 0000000000..ea70276a68 --- /dev/null +++ b/apps/client/src/services/ckeditor_plugin_config.ts @@ -0,0 +1,223 @@ +/** + * @module CKEditor Plugin Configuration Service + * + * This service manages the dynamic configuration of CKEditor plugins based on user preferences. + * It handles plugin enablement, dependency resolution, and toolbar configuration. + */ + +import server from "./server.js"; +import type { + PluginConfiguration, + PluginMetadata, + PluginRegistry, + PluginValidationResult +} from "@triliumnext/commons"; + +/** + * Cache for plugin registry and user configuration + */ +let pluginRegistryCache: PluginRegistry | null = null; +let userConfigCache: PluginConfiguration[] | null = null; +let cacheTimestamp = 0; +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + +/** + * Get the plugin registry from server + */ +export async function getPluginRegistry(): Promise { + const now = Date.now(); + if (pluginRegistryCache && (now - cacheTimestamp) < CACHE_DURATION) { + return pluginRegistryCache; + } + + try { + pluginRegistryCache = await server.get('ckeditor-plugins/registry'); + cacheTimestamp = now; + return pluginRegistryCache; + } catch (error) { + console.error('Failed to load CKEditor plugin registry:', error); + throw error; + } +} + +/** + * Get the user's plugin configuration from server + */ +export async function getUserPluginConfig(): Promise { + const now = Date.now(); + if (userConfigCache && (now - cacheTimestamp) < CACHE_DURATION) { + return userConfigCache; + } + + try { + userConfigCache = await server.get('ckeditor-plugins/config'); + cacheTimestamp = now; + return userConfigCache; + } catch (error) { + console.error('Failed to load user plugin configuration:', error); + throw error; + } +} + +/** + * Clear the cache (call when configuration is updated) + */ +export function clearCache(): void { + pluginRegistryCache = null; + userConfigCache = null; + cacheTimestamp = 0; +} + +/** + * Get the enabled plugins for the current user + */ +export async function getEnabledPlugins(): Promise> { + const userConfig = await getUserPluginConfig(); + const enabledPlugins = new Set(); + + // Add all enabled user plugins + userConfig.forEach(config => { + if (config.enabled) { + enabledPlugins.add(config.id); + } + }); + + // Always include core plugins + const registry = await getPluginRegistry(); + Object.values(registry.plugins).forEach(plugin => { + if (plugin.isCore) { + enabledPlugins.add(plugin.id); + } + }); + + return enabledPlugins; +} + +/** + * Get disabled plugin names for CKEditor config + */ +export async function getDisabledPlugins(): Promise { + try { + const registry = await getPluginRegistry(); + const enabledPlugins = await getEnabledPlugins(); + const disabledPlugins: string[] = []; + + // Find plugins that are disabled + Object.values(registry.plugins).forEach(plugin => { + if (!plugin.isCore && !enabledPlugins.has(plugin.id)) { + // Map plugin ID to actual CKEditor plugin names if needed + const pluginNames = getPluginNames(plugin.id); + disabledPlugins.push(...pluginNames); + } + }); + + return disabledPlugins; + } catch (error) { + console.warn("Failed to get disabled plugins, returning empty list:", error); + return []; + } +} + +/** + * Map plugin ID to actual CKEditor plugin names + * Some plugins might have multiple names or different names than their ID + */ +function getPluginNames(pluginId: string): string[] { + const nameMap: Record = { + "emoji": ["EmojiMention", "EmojiPicker"], + "math": ["Math", "AutoformatMath"], + "image": ["Image", "ImageCaption", "ImageInline", "ImageResize", "ImageStyle", "ImageToolbar", "ImageUpload"], + "table": ["Table", "TableToolbar", "TableProperties", "TableCellProperties", "TableSelection", "TableCaption", "TableColumnResize"], + "font": ["Font", "FontColor", "FontBackgroundColor"], + "list": ["List", "ListProperties"], + "specialcharacters": ["SpecialCharacters", "SpecialCharactersEssentials"], + "findandreplace": ["FindAndReplace"], + "horizontalline": ["HorizontalLine"], + "pagebreak": ["PageBreak"], + "removeformat": ["RemoveFormat"], + "alignment": ["Alignment"], + "indent": ["Indent", "IndentBlock"], + "codeblock": ["CodeBlock"], + "blockquote": ["BlockQuote"], + "todolist": ["TodoList"], + "heading": ["Heading", "HeadingButtonsUI"], + "paragraph": ["ParagraphButtonUI"], + // Add more mappings as needed + }; + + return nameMap[pluginId] || [pluginId.charAt(0).toUpperCase() + pluginId.slice(1)]; +} + +/** + * Validate the current plugin configuration + */ +export async function validatePluginConfiguration(): Promise { + try { + const userConfig = await getUserPluginConfig(); + return await server.post('ckeditor-plugins/validate', { + plugins: userConfig + }); + } catch (error) { + console.error('Failed to validate plugin configuration:', error); + return { + valid: false, + errors: [{ + type: "missing_dependency", + pluginId: "unknown", + message: `Validation failed: ${error}` + }], + warnings: [], + resolvedPlugins: [] + }; + } +} + +/** + * Get toolbar items that should be hidden based on disabled plugins + */ +export async function getHiddenToolbarItems(): Promise { + const registry = await getPluginRegistry(); + const enabledPlugins = await getEnabledPlugins(); + const hiddenItems: string[] = []; + + Object.values(registry.plugins).forEach(plugin => { + if (!enabledPlugins.has(plugin.id) && plugin.toolbarItems) { + hiddenItems.push(...plugin.toolbarItems); + } + }); + + return hiddenItems; +} + +/** + * Update user plugin configuration + */ +export async function updatePluginConfiguration(plugins: PluginConfiguration[]): Promise { + try { + const response = await server.put('ckeditor-plugins/config', { + plugins, + validate: true + }); + + if (!response.success) { + throw new Error(response.errors?.join(", ") || "Update failed"); + } + + // Clear cache so next requests fetch fresh data + clearCache(); + } catch (error) { + console.error('Failed to update plugin configuration:', error); + throw error; + } +} + +export default { + getPluginRegistry, + getUserPluginConfig, + getEnabledPlugins, + getDisabledPlugins, + getHiddenToolbarItems, + validatePluginConfiguration, + updatePluginConfiguration, + clearCache +}; \ No newline at end of file diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 6a7a966c96..c149eab007 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1814,6 +1814,43 @@ "multiline-toolbar": "Display the toolbar on multiple lines if it doesn't fit." } }, + "ckeditor_plugins": { + "title": "Editor Plugins", + "description": "Configure which CKEditor plugins are enabled. Changes take effect when the editor is reloaded.", + "loading": "Loading plugin configuration...", + "load_failed": "Failed to load plugin configuration.", + "load_error": "Error loading plugins", + "retry": "Retry", + "category_formatting": "Text Formatting", + "category_structure": "Document Structure", + "category_media": "Media & Files", + "category_tables": "Tables", + "category_advanced": "Advanced Features", + "category_trilium": "Trilium Features", + "category_external": "External Plugins", + "stats_enabled": "Enabled", + "stats_total": "Total", + "stats_core": "Core", + "stats_premium": "Premium", + "no_license": "no license", + "premium": "Premium", + "premium_required": "Requires premium CKEditor license", + "has_dependencies": "Dependencies", + "depends_on": "Depends on", + "toolbar_items": "Toolbar items", + "validate": "Validate", + "validation_error": "Validation failed", + "validation_errors": "Configuration Errors:", + "validation_warnings": "Configuration Warnings:", + "save": "Save Changes", + "save_success": "Plugin configuration saved successfully", + "save_error": "Failed to save configuration", + "reload_editor_notice": "Please reload any open text notes to apply changes", + "reset_defaults": "Reset to Defaults", + "reset_confirm": "Are you sure you want to reset all plugin settings to their default values?", + "reset_success": "Plugin configuration reset to defaults", + "reset_error": "Failed to reset configuration" + }, "electron_context_menu": { "add-term-to-dictionary": "Add \"{{term}}\" to dictionary", "cut": "Cut", diff --git a/apps/client/src/widgets/type_widgets/ckeditor/config.ts b/apps/client/src/widgets/type_widgets/ckeditor/config.ts index 4eb15e913c..f12a6dde5f 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/config.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/config.ts @@ -13,6 +13,7 @@ import noteAutocompleteService, { type Suggestion } from "../../../services/note import mimeTypesService from "../../../services/mime_types.js"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; import { buildToolbarConfig } from "./toolbar.js"; +import ckeditorPluginConfigService from "../../../services/ckeditor_plugin_config.js"; export const OPEN_SOURCE_LICENSE_KEY = "GPL"; @@ -164,7 +165,7 @@ export async function buildConfig(opts: BuildEditorOptions): Promise> { + try { + const hiddenItems = await ckeditorPluginConfigService.getHiddenToolbarItems(); + return new Set(hiddenItems); + } catch (error) { + console.warn("Failed to get hidden toolbar items, using empty set:", error); + return new Set(); + } +} + +/** + * Filter toolbar items based on disabled plugins + */ +function filterToolbarItems(items: (string | object)[], hiddenItems: Set): (string | object)[] { + return items.filter(item => { + if (typeof item === 'string') { + // Don't hide separators + if (item === '|') return true; + // Check if this item should be hidden + return !hiddenItems.has(item); + } else if (typeof item === 'object' && item !== null && 'items' in item) { + // Filter nested items recursively + const nestedItem = item as { items: (string | object)[] }; + const filteredNested = filterToolbarItems(nestedItem.items, hiddenItems); + // Only keep the group if it has at least one non-separator item + const hasNonSeparatorItems = filteredNested.some(subItem => + typeof subItem === 'string' ? subItem !== '|' : true + ); + if (hasNonSeparatorItems) { + return { ...item, items: filteredNested }; + } + return null; + } + return true; + }).filter(item => item !== null) as (string | object)[]; +} + +export function buildMobileToolbar(hiddenItems: Set) { + const classicConfig = buildClassicToolbar(false, hiddenItems); const items: string[] = []; for (const item of classicConfig.toolbar.items) { if (typeof item === "object" && "items" in item) { - for (const subitem of item.items) { + for (const subitem of (item as any).items) { items.push(subitem); } } else { - items.push(item); + items.push(item as string); } } @@ -40,110 +80,115 @@ export function buildMobileToolbar() { }; } -export function buildClassicToolbar(multilineToolbar: boolean) { +export function buildClassicToolbar(multilineToolbar: boolean, hiddenItems: Set) { // For nested toolbars, refer to https://ckeditor.com/docs/ckeditor5/latest/getting-started/setup/toolbar.html#grouping-toolbar-items-in-dropdowns-nested-toolbars. + const items = [ + "heading", + "fontSize", + "|", + "bold", + "italic", + { + ...TEXT_FORMATTING_GROUP, + items: ["underline", "strikethrough", "|", "superscript", "subscript", "|", "kbd"] + }, + "|", + "fontColor", + "fontBackgroundColor", + "removeFormat", + "|", + "bulletedList", + "numberedList", + "todoList", + "|", + "blockQuote", + "admonition", + "insertTable", + "|", + "code", + "codeBlock", + "|", + "footnote", + { + label: "Insert", + icon: "plus", + items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"] + }, + "|", + "alignment", + "outdent", + "indent", + "|", + "insertTemplate", + "markdownImport", + "cuttonote", + "findAndReplace" + ]; + return { toolbar: { - items: [ - "heading", - "fontSize", - "|", - "bold", - "italic", - { - ...TEXT_FORMATTING_GROUP, - items: ["underline", "strikethrough", "|", "superscript", "subscript", "|", "kbd"] - }, - "|", - "fontColor", - "fontBackgroundColor", - "removeFormat", - "|", - "bulletedList", - "numberedList", - "todoList", - "|", - "blockQuote", - "admonition", - "insertTable", - "|", - "code", - "codeBlock", - "|", - "footnote", - { - label: "Insert", - icon: "plus", - items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"] - }, - "|", - "alignment", - "outdent", - "indent", - "|", - "insertTemplate", - "markdownImport", - "cuttonote", - "findAndReplace" - ], + items: filterToolbarItems(items, hiddenItems), shouldNotGroupWhenFull: multilineToolbar } }; } -export function buildFloatingToolbar() { +export function buildFloatingToolbar(hiddenItems: Set) { + const toolbarItems = [ + "fontSize", + "bold", + "italic", + "underline", + { + ...TEXT_FORMATTING_GROUP, + items: [ "strikethrough", "|", "superscript", "subscript", "|", "kbd" ] + }, + "|", + "fontColor", + "fontBackgroundColor", + "|", + "code", + "link", + "bookmark", + "removeFormat", + "internallink", + "cuttonote" + ]; + + const blockToolbarItems = [ + "heading", + "|", + "bulletedList", + "numberedList", + "todoList", + "|", + "blockQuote", + "admonition", + "codeBlock", + "insertTable", + "footnote", + { + label: "Insert", + icon: "plus", + items: ["link", "bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"] + }, + "|", + "alignment", + "outdent", + "indent", + "|", + "insertTemplate", + "imageUpload", + "markdownImport", + "specialCharacters", + "emoji", + "findAndReplace" + ]; + return { toolbar: { - items: [ - "fontSize", - "bold", - "italic", - "underline", - { - ...TEXT_FORMATTING_GROUP, - items: [ "strikethrough", "|", "superscript", "subscript", "|", "kbd" ] - }, - "|", - "fontColor", - "fontBackgroundColor", - "|", - "code", - "link", - "bookmark", - "removeFormat", - "internallink", - "cuttonote" - ] + items: filterToolbarItems(toolbarItems, hiddenItems) }, - - blockToolbar: [ - "heading", - "|", - "bulletedList", - "numberedList", - "todoList", - "|", - "blockQuote", - "admonition", - "codeBlock", - "insertTable", - "footnote", - { - label: "Insert", - icon: "plus", - items: ["link", "bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"] - }, - "|", - "alignment", - "outdent", - "indent", - "|", - "insertTemplate", - "imageUpload", - "markdownImport", - "specialCharacters", - "emoji", - "findAndReplace" - ] + blockToolbar: filterToolbarItems(blockToolbarItems, hiddenItems) }; } diff --git a/apps/client/src/widgets/type_widgets/content_widget.tsx b/apps/client/src/widgets/type_widgets/content_widget.tsx index be803018f9..34ac3a4f6c 100644 --- a/apps/client/src/widgets/type_widgets/content_widget.tsx +++ b/apps/client/src/widgets/type_widgets/content_widget.tsx @@ -17,6 +17,7 @@ import PasswordSettings from "./options/password.jsx"; import ShortcutSettings from "./options/shortcuts.js"; import TextNoteSettings from "./options/text_notes.jsx"; import CodeNoteSettings from "./options/code_notes.jsx"; +import CKEditorPluginSettings from "./options/ckeditor_plugins.jsx"; import OtherSettings from "./options/other.jsx"; import BackendLogWidget from "./content/backend_log.js"; import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js"; @@ -45,13 +46,14 @@ const TPL = /*html*/`
`; -export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced"; +export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsCKEditorPlugins" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced"; const CONTENT_WIDGETS: Record = { _optionsAppearance: , _optionsShortcuts: , _optionsTextNotes: , _optionsCodeNotes: , + _optionsCKEditorPlugins: , _optionsImages: , _optionsSpellcheck: , _optionsPassword: , diff --git a/apps/client/src/widgets/type_widgets/options/ckeditor_plugins.tsx b/apps/client/src/widgets/type_widgets/options/ckeditor_plugins.tsx new file mode 100644 index 0000000000..fb97d2dfac --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/ckeditor_plugins.tsx @@ -0,0 +1,397 @@ +import { useEffect, useState, useCallback, useMemo } from "preact/hooks"; +import { t } from "../../../services/i18n"; +import server from "../../../services/server"; +import FormCheckbox from "../../react/FormCheckbox"; +import FormGroup from "../../react/FormGroup"; +import FormText from "../../react/FormText"; +import OptionsSection from "./components/OptionsSection"; +import Button from "../../react/Button"; +import toast from "../../../services/toast"; +import type { + PluginMetadata, + PluginConfiguration, + PluginRegistry, + PluginValidationResult, + UpdatePluginConfigRequest, + UpdatePluginConfigResponse, + QueryPluginsResult, + PluginCategory +} from "@triliumnext/commons"; + +interface PluginStats { + enabled: number; + total: number; + core: number; + premium: number; + configurable: number; + categories: Record; + hasPremiumLicense: boolean; +} + +const CATEGORY_DISPLAY_NAMES: Record = { + formatting: t("ckeditor_plugins.category_formatting"), + structure: t("ckeditor_plugins.category_structure"), + media: t("ckeditor_plugins.category_media"), + tables: t("ckeditor_plugins.category_tables"), + advanced: t("ckeditor_plugins.category_advanced"), + trilium: t("ckeditor_plugins.category_trilium"), + external: t("ckeditor_plugins.category_external") +}; + +export default function CKEditorPluginSettings() { + const [pluginRegistry, setPluginRegistry] = useState(null); + const [userConfig, setUserConfig] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [showValidation, setShowValidation] = useState(false); + + // Load initial data + useEffect(() => { + loadData(); + }, []); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [registry, config, statsData] = await Promise.all([ + server.get('ckeditor-plugins/registry'), + server.get('ckeditor-plugins/config'), + server.get('ckeditor-plugins/stats') + ]); + + setPluginRegistry(registry); + setUserConfig(config); + setStats(statsData); + } catch (error) { + toast.showError(`${t("ckeditor_plugins.load_error")}: ${error}`); + } finally { + setLoading(false); + } + }, []); + + // Organize plugins by category + const pluginsByCategory = useMemo(() => { + if (!pluginRegistry) return {}; + + const categories: Record = { + formatting: [], + structure: [], + media: [], + tables: [], + advanced: [], + trilium: [], + external: [] + }; + + Object.values(pluginRegistry.plugins).forEach(plugin => { + if (!plugin.isCore) { // Don't show core plugins in settings + categories[plugin.category].push(plugin); + } + }); + + // Sort plugins within each category by name + Object.keys(categories).forEach(category => { + categories[category as PluginCategory].sort((a, b) => a.name.localeCompare(b.name)); + }); + + return categories; + }, [pluginRegistry]); + + // Get enabled status for a plugin + const isPluginEnabled = useCallback((pluginId: string): boolean => { + return userConfig.find(config => config.id === pluginId)?.enabled ?? false; + }, [userConfig]); + + // Toggle plugin enabled state + const togglePlugin = useCallback((pluginId: string) => { + setUserConfig(prev => prev.map(config => + config.id === pluginId + ? { ...config, enabled: !config.enabled } + : config + )); + }, []); + + // Validate current configuration + const validateConfig = useCallback(async () => { + if (!userConfig.length) return; + + try { + const result = await server.post('ckeditor-plugins/validate', { + plugins: userConfig + }); + setValidationResult(result); + setShowValidation(true); + return result; + } catch (error) { + toast.showError(`${t("ckeditor_plugins.validation_error")}: ${error}`); + return null; + } + }, [userConfig]); + + // Save configuration + const saveConfiguration = useCallback(async () => { + setSaving(true); + setShowValidation(false); + + try { + const request: UpdatePluginConfigRequest = { + plugins: userConfig, + validate: true + }; + + const response = await server.put('ckeditor-plugins/config', request); + + if (response.success) { + toast.showMessage(t("ckeditor_plugins.save_success")); + await loadData(); // Reload stats + + // Notify user that editor reload might be needed + toast.showMessage(t("ckeditor_plugins.reload_editor_notice"), { + timeout: 5000 + }); + } else { + setValidationResult(response.validation); + setShowValidation(true); + toast.showError(`${t("ckeditor_plugins.save_error")}: ${response.errors?.join(", ")}`); + } + } catch (error) { + toast.showError(`${t("ckeditor_plugins.save_error")}: ${error}`); + } finally { + setSaving(false); + } + }, [userConfig, loadData]); + + // Reset to defaults + const resetToDefaults = useCallback(async () => { + if (!confirm(t("ckeditor_plugins.reset_confirm"))) return; + + setSaving(true); + try { + const response = await server.post('ckeditor-plugins/reset'); + if (response.success) { + setUserConfig(response.plugins); + toast.showMessage(t("ckeditor_plugins.reset_success")); + await loadData(); + } else { + toast.showError(`${t("ckeditor_plugins.reset_error")}: ${response.errors?.join(", ")}`); + } + } catch (error) { + toast.showError(`${t("ckeditor_plugins.reset_error")}: ${error}`); + } finally { + setSaving(false); + } + }, [loadData]); + + if (loading) { + return ( + + {t("ckeditor_plugins.loading")} + + ); + } + + if (!pluginRegistry || !stats) { + return ( + + {t("ckeditor_plugins.load_failed")} +