mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			main
			...
			feat/add-c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 5332f015ef | ||
|  | a3d77421fd | 
							
								
								
									
										223
									
								
								apps/client/src/services/ckeditor_plugin_config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								apps/client/src/services/ckeditor_plugin_config.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<PluginRegistry> { | ||||||
|  |     const now = Date.now(); | ||||||
|  |     if (pluginRegistryCache && (now - cacheTimestamp) < CACHE_DURATION) { | ||||||
|  |         return pluginRegistryCache; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     try { | ||||||
|  |         pluginRegistryCache = await server.get<PluginRegistry>('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<PluginConfiguration[]> { | ||||||
|  |     const now = Date.now(); | ||||||
|  |     if (userConfigCache && (now - cacheTimestamp) < CACHE_DURATION) { | ||||||
|  |         return userConfigCache; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     try { | ||||||
|  |         userConfigCache = await server.get<PluginConfiguration[]>('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<Set<string>> { | ||||||
|  |     const userConfig = await getUserPluginConfig(); | ||||||
|  |     const enabledPlugins = new Set<string>(); | ||||||
|  |      | ||||||
|  |     // 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<string[]> { | ||||||
|  |     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<string, string[]> = { | ||||||
|  |         "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<PluginValidationResult> { | ||||||
|  |     try { | ||||||
|  |         const userConfig = await getUserPluginConfig(); | ||||||
|  |         return await server.post<PluginValidationResult>('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<string[]> { | ||||||
|  |     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<void> { | ||||||
|  |     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 | ||||||
|  | }; | ||||||
| @@ -1814,6 +1814,43 @@ | |||||||
|       "multiline-toolbar": "Display the toolbar on multiple lines if it doesn't fit." |       "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": { |   "electron_context_menu": { | ||||||
|     "add-term-to-dictionary": "Add \"{{term}}\" to dictionary", |     "add-term-to-dictionary": "Add \"{{term}}\" to dictionary", | ||||||
|     "cut": "Cut", |     "cut": "Cut", | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ import noteAutocompleteService, { type Suggestion } from "../../../services/note | |||||||
| import mimeTypesService from "../../../services/mime_types.js"; | import mimeTypesService from "../../../services/mime_types.js"; | ||||||
| import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; | import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; | ||||||
| import { buildToolbarConfig } from "./toolbar.js"; | import { buildToolbarConfig } from "./toolbar.js"; | ||||||
|  | import ckeditorPluginConfigService from "../../../services/ckeditor_plugin_config.js"; | ||||||
|  |  | ||||||
| export const OPEN_SOURCE_LICENSE_KEY = "GPL"; | export const OPEN_SOURCE_LICENSE_KEY = "GPL"; | ||||||
|  |  | ||||||
| @@ -164,7 +165,7 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi | |||||||
|         }, |         }, | ||||||
|         // This value must be kept in sync with the language defined in webpack.config.js. |         // This value must be kept in sync with the language defined in webpack.config.js. | ||||||
|         language: "en", |         language: "en", | ||||||
|         removePlugins: getDisabledPlugins() |         removePlugins: await getDisabledPlugins() | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Set up content language. |     // Set up content language. | ||||||
| @@ -203,9 +204,11 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi | |||||||
|         ]; |         ]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     const toolbarConfig = await buildToolbarConfig(opts.isClassicEditor); | ||||||
|  |      | ||||||
|     return { |     return { | ||||||
|         ...config, |         ...config, | ||||||
|         ...buildToolbarConfig(opts.isClassicEditor) |         ...toolbarConfig | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -237,9 +240,18 @@ function getLicenseKey() { | |||||||
|     return premiumLicenseKey; |     return premiumLicenseKey; | ||||||
| } | } | ||||||
|  |  | ||||||
| function getDisabledPlugins() { | async function getDisabledPlugins() { | ||||||
|     let disabledPlugins: string[] = []; |     let disabledPlugins: string[] = []; | ||||||
|  |  | ||||||
|  |     // Check user's plugin configuration | ||||||
|  |     try { | ||||||
|  |         const userDisabledPlugins = await ckeditorPluginConfigService.getDisabledPlugins(); | ||||||
|  |         disabledPlugins.push(...userDisabledPlugins); | ||||||
|  |     } catch (error) { | ||||||
|  |         console.warn("Failed to load user plugin configuration, using defaults:", error); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Legacy emoji setting override | ||||||
|     if (options.get("textNoteEmojiCompletionEnabled") !== "true") { |     if (options.get("textNoteEmojiCompletionEnabled") !== "true") { | ||||||
|         disabledPlugins.push("EmojiMention"); |         disabledPlugins.push("EmojiMention"); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,33 +1,73 @@ | |||||||
| import utils from "../../../services/utils.js"; | import utils from "../../../services/utils.js"; | ||||||
| import options from "../../../services/options.js"; | import options from "../../../services/options.js"; | ||||||
|  | import ckeditorPluginConfigService from "../../../services/ckeditor_plugin_config.js"; | ||||||
|  |  | ||||||
| const TEXT_FORMATTING_GROUP = { | const TEXT_FORMATTING_GROUP = { | ||||||
|     label: "Text formatting", |     label: "Text formatting", | ||||||
|     icon: "text" |     icon: "text" | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export function buildToolbarConfig(isClassicToolbar: boolean) { | export async function buildToolbarConfig(isClassicToolbar: boolean) { | ||||||
|  |     const hiddenItems = await getHiddenToolbarItems(); | ||||||
|  |      | ||||||
|     if (utils.isMobile()) { |     if (utils.isMobile()) { | ||||||
|         return buildMobileToolbar(); |         return buildMobileToolbar(hiddenItems); | ||||||
|     } else if (isClassicToolbar) { |     } else if (isClassicToolbar) { | ||||||
|         const multilineToolbar = utils.isDesktop() && options.get("textNoteEditorMultilineToolbar") === "true"; |         const multilineToolbar = utils.isDesktop() && options.get("textNoteEditorMultilineToolbar") === "true"; | ||||||
|         return buildClassicToolbar(multilineToolbar); |         return buildClassicToolbar(multilineToolbar, hiddenItems); | ||||||
|     } else { |     } else { | ||||||
|         return buildFloatingToolbar(); |         return buildFloatingToolbar(hiddenItems); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| export function buildMobileToolbar() { | async function getHiddenToolbarItems(): Promise<Set<string>> { | ||||||
|     const classicConfig = buildClassicToolbar(false); |     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>): (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<string>) { | ||||||
|  |     const classicConfig = buildClassicToolbar(false, hiddenItems); | ||||||
|     const items: string[] = []; |     const items: string[] = []; | ||||||
|  |  | ||||||
|     for (const item of classicConfig.toolbar.items) { |     for (const item of classicConfig.toolbar.items) { | ||||||
|         if (typeof item === "object" && "items" in item) { |         if (typeof item === "object" && "items" in item) { | ||||||
|             for (const subitem of item.items) { |             for (const subitem of (item as any).items) { | ||||||
|                 items.push(subitem); |                 items.push(subitem); | ||||||
|             } |             } | ||||||
|         } else { |         } 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<string>) { | ||||||
|     // For nested toolbars, refer to https://ckeditor.com/docs/ckeditor5/latest/getting-started/setup/toolbar.html#grouping-toolbar-items-in-dropdowns-nested-toolbars. |     // 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 { |     return { | ||||||
|         toolbar: { |         toolbar: { | ||||||
|             items: [ |             items: filterToolbarItems(items, hiddenItems), | ||||||
|                 "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" |  | ||||||
|             ], |  | ||||||
|             shouldNotGroupWhenFull: multilineToolbar |             shouldNotGroupWhenFull: multilineToolbar | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
| export function buildFloatingToolbar() { | export function buildFloatingToolbar(hiddenItems: Set<string>) { | ||||||
|  |     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 { |     return { | ||||||
|         toolbar: { |         toolbar: { | ||||||
|             items: [ |             items: filterToolbarItems(toolbarItems, hiddenItems) | ||||||
|                 "fontSize", |  | ||||||
|                 "bold", |  | ||||||
|                 "italic", |  | ||||||
|                 "underline", |  | ||||||
|                 { |  | ||||||
|                     ...TEXT_FORMATTING_GROUP, |  | ||||||
|                     items: [ "strikethrough", "|", "superscript", "subscript", "|", "kbd" ] |  | ||||||
|                 }, |  | ||||||
|                 "|", |  | ||||||
|                 "fontColor", |  | ||||||
|                 "fontBackgroundColor", |  | ||||||
|                 "|", |  | ||||||
|                 "code", |  | ||||||
|                 "link", |  | ||||||
|                 "bookmark", |  | ||||||
|                 "removeFormat", |  | ||||||
|                 "internallink", |  | ||||||
|                 "cuttonote" |  | ||||||
|             ] |  | ||||||
|         }, |         }, | ||||||
|  |         blockToolbar: filterToolbarItems(blockToolbarItems, 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" |  | ||||||
|         ] |  | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ import PasswordSettings from "./options/password.jsx"; | |||||||
| import ShortcutSettings from "./options/shortcuts.js"; | import ShortcutSettings from "./options/shortcuts.js"; | ||||||
| import TextNoteSettings from "./options/text_notes.jsx"; | import TextNoteSettings from "./options/text_notes.jsx"; | ||||||
| import CodeNoteSettings from "./options/code_notes.jsx"; | import CodeNoteSettings from "./options/code_notes.jsx"; | ||||||
|  | import CKEditorPluginSettings from "./options/ckeditor_plugins.jsx"; | ||||||
| import OtherSettings from "./options/other.jsx"; | import OtherSettings from "./options/other.jsx"; | ||||||
| import BackendLogWidget from "./content/backend_log.js"; | import BackendLogWidget from "./content/backend_log.js"; | ||||||
| import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js"; | import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js"; | ||||||
| @@ -45,13 +46,14 @@ const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printabl | |||||||
|     <div class="note-detail-content-widget-content"></div> |     <div class="note-detail-content-widget-content"></div> | ||||||
| </div>`; | </div>`; | ||||||
|  |  | ||||||
| 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<OptionPages | "_backendLog", ((typeof NoteContextAwareWidget)[] | JSX.Element)> = { | const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextAwareWidget)[] | JSX.Element)> = { | ||||||
|     _optionsAppearance: <AppearanceSettings />, |     _optionsAppearance: <AppearanceSettings />, | ||||||
|     _optionsShortcuts: <ShortcutSettings />, |     _optionsShortcuts: <ShortcutSettings />, | ||||||
|     _optionsTextNotes: <TextNoteSettings />, |     _optionsTextNotes: <TextNoteSettings />, | ||||||
|     _optionsCodeNotes: <CodeNoteSettings />, |     _optionsCodeNotes: <CodeNoteSettings />, | ||||||
|  |     _optionsCKEditorPlugins: <CKEditorPluginSettings />, | ||||||
|     _optionsImages: <ImageSettings />, |     _optionsImages: <ImageSettings />, | ||||||
|     _optionsSpellcheck: <SpellcheckSettings />, |     _optionsSpellcheck: <SpellcheckSettings />, | ||||||
|     _optionsPassword: <PasswordSettings />, |     _optionsPassword: <PasswordSettings />, | ||||||
|   | |||||||
| @@ -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<string, number>; | ||||||
|  |     hasPremiumLicense: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const CATEGORY_DISPLAY_NAMES: Record<PluginCategory, string> = { | ||||||
|  |     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<PluginRegistry | null>(null); | ||||||
|  |     const [userConfig, setUserConfig] = useState<PluginConfiguration[]>([]); | ||||||
|  |     const [stats, setStats] = useState<PluginStats | null>(null); | ||||||
|  |     const [loading, setLoading] = useState(true); | ||||||
|  |     const [saving, setSaving] = useState(false); | ||||||
|  |     const [validationResult, setValidationResult] = useState<PluginValidationResult | null>(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<PluginRegistry>('ckeditor-plugins/registry'), | ||||||
|  |                 server.get<PluginConfiguration[]>('ckeditor-plugins/config'), | ||||||
|  |                 server.get<PluginStats>('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<PluginCategory, PluginMetadata[]> = { | ||||||
|  |             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<PluginValidationResult>('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<UpdatePluginConfigResponse>('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<UpdatePluginConfigResponse>('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 ( | ||||||
|  |             <OptionsSection title={t("ckeditor_plugins.title")}> | ||||||
|  |                 <FormText>{t("ckeditor_plugins.loading")}</FormText> | ||||||
|  |             </OptionsSection> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!pluginRegistry || !stats) { | ||||||
|  |         return ( | ||||||
|  |             <OptionsSection title={t("ckeditor_plugins.title")}> | ||||||
|  |                 <FormText>{t("ckeditor_plugins.load_failed")}</FormText> | ||||||
|  |                 <Button text={t("ckeditor_plugins.retry")} onClick={loadData} /> | ||||||
|  |             </OptionsSection> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <div> | ||||||
|  |             <OptionsSection title={t("ckeditor_plugins.title")}> | ||||||
|  |                 <FormText>{t("ckeditor_plugins.description")}</FormText> | ||||||
|  |                  | ||||||
|  |                 {/* Stats overview */} | ||||||
|  |                 <div className="plugin-stats" style={{  | ||||||
|  |                     backgroundColor: 'var(--accented-background-color)',  | ||||||
|  |                     padding: '12px',  | ||||||
|  |                     borderRadius: '4px', | ||||||
|  |                     marginBottom: '20px' | ||||||
|  |                 }}> | ||||||
|  |                     <div className="row"> | ||||||
|  |                         <div className="col-md-3"> | ||||||
|  |                             <strong>{t("ckeditor_plugins.stats_enabled")}</strong><br /> | ||||||
|  |                             <span style={{ fontSize: '1.2em', color: 'var(--main-text-color)' }}> | ||||||
|  |                                 {stats.enabled}/{stats.configurable} | ||||||
|  |                             </span> | ||||||
|  |                         </div> | ||||||
|  |                         <div className="col-md-3"> | ||||||
|  |                             <strong>{t("ckeditor_plugins.stats_total")}</strong><br /> | ||||||
|  |                             <span style={{ fontSize: '1.2em', color: 'var(--main-text-color)' }}> | ||||||
|  |                                 {stats.total} | ||||||
|  |                             </span> | ||||||
|  |                         </div> | ||||||
|  |                         <div className="col-md-3"> | ||||||
|  |                             <strong>{t("ckeditor_plugins.stats_core")}</strong><br /> | ||||||
|  |                             <span style={{ fontSize: '1.2em', color: 'var(--main-text-color)' }}> | ||||||
|  |                                 {stats.core} | ||||||
|  |                             </span> | ||||||
|  |                         </div> | ||||||
|  |                         <div className="col-md-3"> | ||||||
|  |                             <strong>{t("ckeditor_plugins.stats_premium")}</strong><br /> | ||||||
|  |                             <span style={{ fontSize: '1.2em', color: stats.hasPremiumLicense ? 'var(--success-color)' : 'var(--muted-text-color)' }}> | ||||||
|  |                                 {stats.premium} {!stats.hasPremiumLicense && `(${t("ckeditor_plugins.no_license")})`} | ||||||
|  |                             </span> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 {/* Validation results */} | ||||||
|  |                 {showValidation && validationResult && ( | ||||||
|  |                     <div className="validation-results" style={{ marginBottom: '20px' }}> | ||||||
|  |                         {!validationResult.valid && ( | ||||||
|  |                             <div className="alert alert-danger"> | ||||||
|  |                                 <strong>{t("ckeditor_plugins.validation_errors")}</strong> | ||||||
|  |                                 <ul> | ||||||
|  |                                     {validationResult.errors.map((error, index) => ( | ||||||
|  |                                         <li key={index}>{error.message}</li> | ||||||
|  |                                     ))} | ||||||
|  |                                 </ul> | ||||||
|  |                             </div> | ||||||
|  |                         )} | ||||||
|  |                         {validationResult.warnings.length > 0 && ( | ||||||
|  |                             <div className="alert alert-warning"> | ||||||
|  |                                 <strong>{t("ckeditor_plugins.validation_warnings")}</strong> | ||||||
|  |                                 <ul> | ||||||
|  |                                     {validationResult.warnings.map((warning, index) => ( | ||||||
|  |                                         <li key={index}>{warning.message}</li> | ||||||
|  |                                     ))} | ||||||
|  |                                 </ul> | ||||||
|  |                             </div> | ||||||
|  |                         )} | ||||||
|  |                     </div> | ||||||
|  |                 )} | ||||||
|  |  | ||||||
|  |                 {/* Action buttons */} | ||||||
|  |                 <div className="plugin-actions" style={{ marginBottom: '20px' }}> | ||||||
|  |                     <Button  | ||||||
|  |                         text={t("ckeditor_plugins.validate")}  | ||||||
|  |                         onClick={validateConfig} | ||||||
|  |                         disabled={saving} | ||||||
|  |                         className="btn-secondary" | ||||||
|  |                         size="small" | ||||||
|  |                     /> | ||||||
|  |                     <Button  | ||||||
|  |                         text={t("ckeditor_plugins.save")}  | ||||||
|  |                         onClick={saveConfiguration} | ||||||
|  |                         disabled={saving} | ||||||
|  |                         className="btn-primary" | ||||||
|  |                         size="small" | ||||||
|  |                         style={{ marginLeft: '10px' }} | ||||||
|  |                     /> | ||||||
|  |                     <Button  | ||||||
|  |                         text={t("ckeditor_plugins.reset_defaults")}  | ||||||
|  |                         onClick={resetToDefaults} | ||||||
|  |                         disabled={saving} | ||||||
|  |                         className="btn-secondary" | ||||||
|  |                         size="small" | ||||||
|  |                         style={{ marginLeft: '10px' }} | ||||||
|  |                     /> | ||||||
|  |                 </div> | ||||||
|  |             </OptionsSection> | ||||||
|  |  | ||||||
|  |             {/* Plugin categories */} | ||||||
|  |             {Object.entries(pluginsByCategory).map(([categoryKey, plugins]) => { | ||||||
|  |                 if (plugins.length === 0) return null; | ||||||
|  |                  | ||||||
|  |                 const category = categoryKey as PluginCategory; | ||||||
|  |                 return ( | ||||||
|  |                     <OptionsSection key={category} title={CATEGORY_DISPLAY_NAMES[category]} level={2}> | ||||||
|  |                         <div className="plugin-category"> | ||||||
|  |                             {plugins.map(plugin => ( | ||||||
|  |                                 <PluginConfigItem  | ||||||
|  |                                     key={plugin.id} | ||||||
|  |                                     plugin={plugin} | ||||||
|  |                                     enabled={isPluginEnabled(plugin.id)} | ||||||
|  |                                     onToggle={() => togglePlugin(plugin.id)} | ||||||
|  |                                     hasPremiumLicense={stats.hasPremiumLicense} | ||||||
|  |                                 /> | ||||||
|  |                             ))} | ||||||
|  |                         </div> | ||||||
|  |                     </OptionsSection> | ||||||
|  |                 ); | ||||||
|  |             })} | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface PluginConfigItemProps { | ||||||
|  |     plugin: PluginMetadata; | ||||||
|  |     enabled: boolean; | ||||||
|  |     onToggle: () => void; | ||||||
|  |     hasPremiumLicense: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function PluginConfigItem({ plugin, enabled, onToggle, hasPremiumLicense }: PluginConfigItemProps) { | ||||||
|  |     const canEnable = !plugin.requiresPremium || hasPremiumLicense; | ||||||
|  |      | ||||||
|  |     return ( | ||||||
|  |         <FormGroup name={`plugin-${plugin.id}`} style={{ marginBottom: '15px' }}> | ||||||
|  |             <div className="plugin-item" style={{ | ||||||
|  |                 display: 'flex', | ||||||
|  |                 alignItems: 'flex-start', | ||||||
|  |                 opacity: canEnable ? 1 : 0.6 | ||||||
|  |             }}> | ||||||
|  |                 <FormCheckbox | ||||||
|  |                     label="" | ||||||
|  |                     currentValue={enabled && canEnable} | ||||||
|  |                     onChange={canEnable ? onToggle : undefined} | ||||||
|  |                     disabled={!canEnable} | ||||||
|  |                     containerStyle={{ marginRight: '10px', marginTop: '2px' }} | ||||||
|  |                 /> | ||||||
|  |                 <div style={{ flex: 1 }}> | ||||||
|  |                     <div style={{  | ||||||
|  |                         fontWeight: 'bold', | ||||||
|  |                         marginBottom: '4px', | ||||||
|  |                         display: 'flex', | ||||||
|  |                         alignItems: 'center', | ||||||
|  |                         gap: '8px' | ||||||
|  |                     }}> | ||||||
|  |                         <span>{plugin.name}</span> | ||||||
|  |                         {plugin.requiresPremium && ( | ||||||
|  |                             <span className="badge badge-warning" style={{ fontSize: '0.75em' }}> | ||||||
|  |                                 {t("ckeditor_plugins.premium")} | ||||||
|  |                             </span> | ||||||
|  |                         )} | ||||||
|  |                         {plugin.dependencies.length > 0 && ( | ||||||
|  |                             <span className="badge badge-info" style={{ fontSize: '0.75em' }}> | ||||||
|  |                                 {t("ckeditor_plugins.has_dependencies")} | ||||||
|  |                             </span> | ||||||
|  |                         )} | ||||||
|  |                     </div> | ||||||
|  |                     <div style={{  | ||||||
|  |                         fontSize: '0.9em',  | ||||||
|  |                         color: 'var(--muted-text-color)', | ||||||
|  |                         marginBottom: '4px' | ||||||
|  |                     }}> | ||||||
|  |                         {plugin.description} | ||||||
|  |                     </div> | ||||||
|  |                     {plugin.dependencies.length > 0 && ( | ||||||
|  |                         <div style={{ fontSize: '0.8em', color: 'var(--muted-text-color)' }}> | ||||||
|  |                             {t("ckeditor_plugins.depends_on")}: {plugin.dependencies.join(', ')} | ||||||
|  |                         </div> | ||||||
|  |                     )} | ||||||
|  |                     {plugin.toolbarItems && plugin.toolbarItems.length > 0 && ( | ||||||
|  |                         <div style={{ fontSize: '0.8em', color: 'var(--muted-text-color)' }}> | ||||||
|  |                             {t("ckeditor_plugins.toolbar_items")}: {plugin.toolbarItems.join(', ')} | ||||||
|  |                         </div> | ||||||
|  |                     )} | ||||||
|  |                     {!canEnable && ( | ||||||
|  |                         <div style={{  | ||||||
|  |                             fontSize: '0.8em',  | ||||||
|  |                             color: 'var(--error-color)', | ||||||
|  |                             fontStyle: 'italic' | ||||||
|  |                         }}> | ||||||
|  |                             {t("ckeditor_plugins.premium_required")} | ||||||
|  |                         </div> | ||||||
|  |                     )} | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </FormGroup> | ||||||
|  |     ); | ||||||
|  | } | ||||||
							
								
								
									
										388
									
								
								apps/server/src/routes/api/ckeditor_plugins.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										388
									
								
								apps/server/src/routes/api/ckeditor_plugins.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,388 @@ | |||||||
|  | /** | ||||||
|  |  * @module CKEditor Plugins API | ||||||
|  |  *  | ||||||
|  |  * This module provides REST endpoints for managing CKEditor plugin configuration | ||||||
|  |  * in Trilium Notes. It handles plugin enablement, validation, and user preferences. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | import options from "../../services/options.js"; | ||||||
|  | import type { Request, Response } from "express"; | ||||||
|  | import type {  | ||||||
|  |     PluginConfiguration,  | ||||||
|  |     PluginRegistry,  | ||||||
|  |     PluginValidationResult,  | ||||||
|  |     UpdatePluginConfigRequest, | ||||||
|  |     UpdatePluginConfigResponse, | ||||||
|  |     QueryPluginsOptions, | ||||||
|  |     QueryPluginsResult, | ||||||
|  |     PluginMetadata | ||||||
|  | } from "@triliumnext/commons"; | ||||||
|  | import { PLUGIN_REGISTRY, getPluginMetadata, getPluginsByCategory, getConfigurablePlugins } from "@triliumnext/ckeditor5"; | ||||||
|  | import log from "../../services/log.js"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get the complete plugin registry with metadata | ||||||
|  |  */ | ||||||
|  | export function getPluginRegistry(): PluginRegistry { | ||||||
|  |     return PLUGIN_REGISTRY; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get current user's plugin configuration | ||||||
|  |  */ | ||||||
|  | export function getUserPluginConfig(): PluginConfiguration[] { | ||||||
|  |     const enabledPluginsJson = options.getOptionOrNull("ckeditorEnabledPlugins"); | ||||||
|  |      | ||||||
|  |     if (!enabledPluginsJson) { | ||||||
|  |         // Return default configuration if none exists | ||||||
|  |         return getDefaultPluginConfiguration(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |         const enabledPlugins = JSON.parse(enabledPluginsJson) as string[]; | ||||||
|  |          | ||||||
|  |         // Convert to PluginConfiguration array | ||||||
|  |         return Object.keys(PLUGIN_REGISTRY.plugins).map(pluginId => ({ | ||||||
|  |             id: pluginId, | ||||||
|  |             enabled: enabledPlugins.includes(pluginId) | ||||||
|  |         })); | ||||||
|  |     } catch (error) { | ||||||
|  |         log.error(`Failed to parse CKEditor plugin configuration: ${error}`); | ||||||
|  |         return getDefaultPluginConfiguration(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get default plugin configuration (all non-premium plugins enabled) | ||||||
|  |  */ | ||||||
|  | function getDefaultPluginConfiguration(): PluginConfiguration[] { | ||||||
|  |     return Object.values(PLUGIN_REGISTRY.plugins).map(plugin => ({ | ||||||
|  |         id: plugin.id, | ||||||
|  |         enabled: plugin.defaultEnabled && !plugin.requiresPremium | ||||||
|  |     })); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Update user's plugin configuration | ||||||
|  |  */ | ||||||
|  | export function updateUserPluginConfig(request: UpdatePluginConfigRequest): UpdatePluginConfigResponse { | ||||||
|  |     const { plugins, validate = true } = request; | ||||||
|  |      | ||||||
|  |     try { | ||||||
|  |         // Validate if requested | ||||||
|  |         let validation: PluginValidationResult | undefined; | ||||||
|  |         if (validate) { | ||||||
|  |             validation = validatePluginConfiguration(plugins); | ||||||
|  |             if (!validation.valid) { | ||||||
|  |                 return { | ||||||
|  |                     success: false, | ||||||
|  |                     validation, | ||||||
|  |                     plugins: [], | ||||||
|  |                     errors: validation.errors.map(err => err.message) | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Save configuration | ||||||
|  |         const enabledPluginIds = plugins | ||||||
|  |             .filter(plugin => plugin.enabled) | ||||||
|  |             .map(plugin => plugin.id); | ||||||
|  |  | ||||||
|  |         options.setOption("ckeditorEnabledPlugins", JSON.stringify(enabledPluginIds)); | ||||||
|  |  | ||||||
|  |         log.info(`Updated CKEditor plugin configuration: ${enabledPluginIds.length} plugins enabled`); | ||||||
|  |  | ||||||
|  |         return { | ||||||
|  |             success: true, | ||||||
|  |             validation, | ||||||
|  |             plugins: plugins, | ||||||
|  |             errors: [] | ||||||
|  |         }; | ||||||
|  |     } catch (error) { | ||||||
|  |         log.error(`Failed to update CKEditor plugin configuration: ${error}`); | ||||||
|  |         return { | ||||||
|  |             success: false, | ||||||
|  |             plugins: [], | ||||||
|  |             errors: [`Failed to update configuration: ${error}`] | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Query plugins with filtering options | ||||||
|  |  */ | ||||||
|  | export function queryPlugins(options: QueryPluginsOptions = {}): QueryPluginsResult { | ||||||
|  |     const { category, enabled, coreOnly, includeConfig } = options; | ||||||
|  |      | ||||||
|  |     let plugins = Object.values(PLUGIN_REGISTRY.plugins); | ||||||
|  |  | ||||||
|  |     // Apply filters | ||||||
|  |     if (category) { | ||||||
|  |         plugins = plugins.filter(plugin => plugin.category === category); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (coreOnly === true) { | ||||||
|  |         plugins = plugins.filter(plugin => plugin.isCore); | ||||||
|  |     } else if (coreOnly === false) { | ||||||
|  |         plugins = plugins.filter(plugin => !plugin.isCore); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Get user configuration if requested or filtering by enabled status | ||||||
|  |     let userConfig: PluginConfiguration[] = []; | ||||||
|  |     if (includeConfig || enabled !== undefined) { | ||||||
|  |         userConfig = getUserPluginConfig(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Filter by enabled status | ||||||
|  |     if (enabled !== undefined) { | ||||||
|  |         const enabledPluginIds = new Set( | ||||||
|  |             userConfig.filter(config => config.enabled).map(config => config.id) | ||||||
|  |         ); | ||||||
|  |         plugins = plugins.filter(plugin =>  | ||||||
|  |             enabled ? enabledPluginIds.has(plugin.id) : !enabledPluginIds.has(plugin.id) | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Add user configuration if requested | ||||||
|  |     const result = plugins.map(plugin => { | ||||||
|  |         if (includeConfig) { | ||||||
|  |             const config = userConfig.find(config => config.id === plugin.id); | ||||||
|  |             return { | ||||||
|  |                 ...plugin, | ||||||
|  |                 enabled: config?.enabled ?? false, | ||||||
|  |                 config: config?.config | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |         return plugin; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Get available categories | ||||||
|  |     const categories = [...new Set(Object.values(PLUGIN_REGISTRY.plugins).map(plugin => plugin.category))]; | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |         plugins: result, | ||||||
|  |         totalCount: Object.keys(PLUGIN_REGISTRY.plugins).length, | ||||||
|  |         categories: categories as any[] | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validate plugin configuration for dependencies and conflicts | ||||||
|  |  */ | ||||||
|  | export function validatePluginConfiguration(plugins: PluginConfiguration[]): PluginValidationResult { | ||||||
|  |     const errors: any[] = []; | ||||||
|  |     const warnings: any[] = []; | ||||||
|  |     const enabledPlugins = new Set(plugins.filter(p => p.enabled).map(p => p.id)); | ||||||
|  |     const resolvedPlugins = new Set<string>(); | ||||||
|  |  | ||||||
|  |     // Check each enabled plugin | ||||||
|  |     for (const plugin of plugins.filter(p => p.enabled)) { | ||||||
|  |         const metadata = getPluginMetadata(plugin.id); | ||||||
|  |          | ||||||
|  |         if (!metadata) { | ||||||
|  |             errors.push({ | ||||||
|  |                 type: "missing_dependency", | ||||||
|  |                 pluginId: plugin.id, | ||||||
|  |                 message: `Plugin '${plugin.id}' not found in registry` | ||||||
|  |             }); | ||||||
|  |             continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check premium requirements | ||||||
|  |         if (metadata.requiresPremium && !hasPremiumLicense()) { | ||||||
|  |             errors.push({ | ||||||
|  |                 type: "premium_required", | ||||||
|  |                 pluginId: plugin.id, | ||||||
|  |                 message: `Plugin '${metadata.name}' requires a premium CKEditor license` | ||||||
|  |             }); | ||||||
|  |             continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check dependencies | ||||||
|  |         for (const depId of metadata.dependencies) { | ||||||
|  |             if (!enabledPlugins.has(depId)) { | ||||||
|  |                 const depMetadata = getPluginMetadata(depId); | ||||||
|  |                 errors.push({ | ||||||
|  |                     type: "missing_dependency", | ||||||
|  |                     pluginId: plugin.id, | ||||||
|  |                     message: `Plugin '${metadata.name}' requires '${depMetadata?.name || depId}' to be enabled`, | ||||||
|  |                     details: { dependency: depId } | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check conflicts | ||||||
|  |         for (const conflictId of metadata.conflicts) { | ||||||
|  |             if (enabledPlugins.has(conflictId)) { | ||||||
|  |                 const conflictMetadata = getPluginMetadata(conflictId); | ||||||
|  |                 errors.push({ | ||||||
|  |                     type: "plugin_conflict", | ||||||
|  |                     pluginId: plugin.id, | ||||||
|  |                     message: `Plugin '${metadata.name}' conflicts with '${conflictMetadata?.name || conflictId}'`, | ||||||
|  |                     details: { conflict: conflictId } | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         resolvedPlugins.add(plugin.id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Add core plugins to resolved list (they're always enabled) | ||||||
|  |     for (const plugin of Object.values(PLUGIN_REGISTRY.plugins)) { | ||||||
|  |         if (plugin.isCore) { | ||||||
|  |             resolvedPlugins.add(plugin.id); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check for circular dependencies (simplified check) | ||||||
|  |     const visited = new Set<string>(); | ||||||
|  |     const visiting = new Set<string>(); | ||||||
|  |  | ||||||
|  |     function hasCircularDependency(pluginId: string): boolean { | ||||||
|  |         if (visiting.has(pluginId)) { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         if (visited.has(pluginId)) { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         visiting.add(pluginId); | ||||||
|  |         const metadata = getPluginMetadata(pluginId); | ||||||
|  |          | ||||||
|  |         if (metadata) { | ||||||
|  |             for (const depId of metadata.dependencies) { | ||||||
|  |                 if (enabledPlugins.has(depId) && hasCircularDependency(depId)) { | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         visiting.delete(pluginId); | ||||||
|  |         visited.add(pluginId); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     for (const pluginId of enabledPlugins) { | ||||||
|  |         if (hasCircularDependency(pluginId)) { | ||||||
|  |             errors.push({ | ||||||
|  |                 type: "circular_dependency", | ||||||
|  |                 pluginId: pluginId, | ||||||
|  |                 message: `Circular dependency detected for plugin '${getPluginMetadata(pluginId)?.name || pluginId}'` | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |         valid: errors.length === 0, | ||||||
|  |         errors, | ||||||
|  |         warnings, | ||||||
|  |         resolvedPlugins: Array.from(resolvedPlugins) | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Check if user has premium CKEditor license | ||||||
|  |  */ | ||||||
|  | function hasPremiumLicense(): boolean { | ||||||
|  |     // This would check the actual license key | ||||||
|  |     // For now, assume no premium license | ||||||
|  |     return process.env.VITE_CKEDITOR_KEY !== undefined && process.env.VITE_CKEDITOR_KEY !== ""; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Reset plugin configuration to defaults | ||||||
|  |  */ | ||||||
|  | export function resetPluginConfigToDefaults(): UpdatePluginConfigResponse { | ||||||
|  |     const defaultConfig = getDefaultPluginConfiguration(); | ||||||
|  |      | ||||||
|  |     return updateUserPluginConfig({ | ||||||
|  |         plugins: defaultConfig, | ||||||
|  |         validate: false | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get plugin statistics | ||||||
|  |  */ | ||||||
|  | export function getPluginStats() { | ||||||
|  |     const userConfig = getUserPluginConfig(); | ||||||
|  |     const enabledCount = userConfig.filter(p => p.enabled).length; | ||||||
|  |     const totalCount = Object.keys(PLUGIN_REGISTRY.plugins).length; | ||||||
|  |     const coreCount = Object.values(PLUGIN_REGISTRY.plugins).filter(p => p.isCore).length; | ||||||
|  |     const premiumCount = Object.values(PLUGIN_REGISTRY.plugins).filter(p => p.requiresPremium).length; | ||||||
|  |  | ||||||
|  |     const categoryCounts = Object.values(PLUGIN_REGISTRY.plugins).reduce((acc, plugin) => { | ||||||
|  |         acc[plugin.category] = (acc[plugin.category] || 0) + 1; | ||||||
|  |         return acc; | ||||||
|  |     }, {} as Record<string, number>); | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |         enabled: enabledCount, | ||||||
|  |         total: totalCount, | ||||||
|  |         core: coreCount, | ||||||
|  |         premium: premiumCount, | ||||||
|  |         configurable: totalCount - coreCount, | ||||||
|  |         categories: categoryCounts, | ||||||
|  |         hasPremiumLicense: hasPremiumLicense() | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Express route handlers | ||||||
|  | function getPluginRegistryHandler(req: Request, res: Response) { | ||||||
|  |     res.json(getPluginRegistry()); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getUserPluginConfigHandler(req: Request, res: Response) { | ||||||
|  |     res.json(getUserPluginConfig()); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function updateUserPluginConfigHandler(req: Request, res: Response) { | ||||||
|  |     const updateRequest: UpdatePluginConfigRequest = req.body; | ||||||
|  |     const result = updateUserPluginConfig(updateRequest); | ||||||
|  |      | ||||||
|  |     if (!result.success) { | ||||||
|  |         res.status(400).json(result); | ||||||
|  |     } else { | ||||||
|  |         res.json(result); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function queryPluginsHandler(req: Request, res: Response) { | ||||||
|  |     const queryOptions: QueryPluginsOptions = { | ||||||
|  |         category: req.query.category as string, | ||||||
|  |         enabled: req.query.enabled === 'true' ? true : req.query.enabled === 'false' ? false : undefined, | ||||||
|  |         coreOnly: req.query.coreOnly === 'true' ? true : req.query.coreOnly === 'false' ? false : undefined, | ||||||
|  |         includeConfig: req.query.includeConfig === 'true' | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     res.json(queryPlugins(queryOptions)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function validatePluginConfigurationHandler(req: Request, res: Response) { | ||||||
|  |     const plugins: PluginConfiguration[] = req.body.plugins || []; | ||||||
|  |     res.json(validatePluginConfiguration(plugins)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function resetPluginConfigToDefaultsHandler(req: Request, res: Response) { | ||||||
|  |     const result = resetPluginConfigToDefaults(); | ||||||
|  |      | ||||||
|  |     if (!result.success) { | ||||||
|  |         res.status(400).json(result); | ||||||
|  |     } else { | ||||||
|  |         res.json(result); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getPluginStatsHandler(req: Request, res: Response) { | ||||||
|  |     res.json(getPluginStats()); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |     getPluginRegistry: getPluginRegistryHandler, | ||||||
|  |     getUserPluginConfig: getUserPluginConfigHandler, | ||||||
|  |     updateUserPluginConfig: updateUserPluginConfigHandler, | ||||||
|  |     queryPlugins: queryPluginsHandler, | ||||||
|  |     validatePluginConfiguration: validatePluginConfigurationHandler, | ||||||
|  |     resetPluginConfigToDefaults: resetPluginConfigToDefaultsHandler, | ||||||
|  |     getPluginStats: getPluginStatsHandler | ||||||
|  | }; | ||||||
| @@ -97,6 +97,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([ | |||||||
|     "showLoginInShareTheme", |     "showLoginInShareTheme", | ||||||
|     "splitEditorOrientation", |     "splitEditorOrientation", | ||||||
|     "seenCallToActions", |     "seenCallToActions", | ||||||
|  |     "ckeditorEnabledPlugins", | ||||||
|  |  | ||||||
|     // AI/LLM integration options |     // AI/LLM integration options | ||||||
|     "aiEnabled", |     "aiEnabled", | ||||||
|   | |||||||
| @@ -59,6 +59,7 @@ import openaiRoute from "./api/openai.js"; | |||||||
| import anthropicRoute from "./api/anthropic.js"; | import anthropicRoute from "./api/anthropic.js"; | ||||||
| import llmRoute from "./api/llm.js"; | import llmRoute from "./api/llm.js"; | ||||||
| import systemInfoRoute from "./api/system_info.js"; | import systemInfoRoute from "./api/system_info.js"; | ||||||
|  | import ckeditorPluginsRoute from "./api/ckeditor_plugins.js"; | ||||||
|  |  | ||||||
| import etapiAuthRoutes from "../etapi/auth.js"; | import etapiAuthRoutes from "../etapi/auth.js"; | ||||||
| import etapiAppInfoRoutes from "../etapi/app_info.js"; | import etapiAppInfoRoutes from "../etapi/app_info.js"; | ||||||
| @@ -221,6 +222,15 @@ function register(app: express.Application) { | |||||||
|     apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes); |     apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes); | ||||||
|     apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales); |     apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales); | ||||||
|      |      | ||||||
|  |     // CKEditor plugins management | ||||||
|  |     apiRoute(GET, "/api/ckeditor-plugins/registry", ckeditorPluginsRoute.getPluginRegistry); | ||||||
|  |     apiRoute(GET, "/api/ckeditor-plugins/config", ckeditorPluginsRoute.getUserPluginConfig); | ||||||
|  |     apiRoute(PUT, "/api/ckeditor-plugins/config", ckeditorPluginsRoute.updateUserPluginConfig); | ||||||
|  |     apiRoute(GET, "/api/ckeditor-plugins/query", ckeditorPluginsRoute.queryPlugins); | ||||||
|  |     apiRoute(PST, "/api/ckeditor-plugins/validate", ckeditorPluginsRoute.validatePluginConfiguration); | ||||||
|  |     apiRoute(PST, "/api/ckeditor-plugins/reset", ckeditorPluginsRoute.resetPluginConfigToDefaults); | ||||||
|  |     apiRoute(GET, "/api/ckeditor-plugins/stats", ckeditorPluginsRoute.getPluginStats); | ||||||
|  |  | ||||||
|     apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword); |     apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword); | ||||||
|     apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword); |     apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import log from "./log.js"; | |||||||
| import dateUtils from "./date_utils.js"; | import dateUtils from "./date_utils.js"; | ||||||
| import keyboardActions from "./keyboard_actions.js"; | import keyboardActions from "./keyboard_actions.js"; | ||||||
| import { SANITIZER_DEFAULT_ALLOWED_TAGS, type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames } from "@triliumnext/commons"; | import { SANITIZER_DEFAULT_ALLOWED_TAGS, type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames } from "@triliumnext/commons"; | ||||||
|  | import { getDefaultPluginConfiguration } from "@triliumnext/ckeditor5"; | ||||||
|  |  | ||||||
| function initDocumentOptions() { | function initDocumentOptions() { | ||||||
|     optionService.createOption("documentId", randomSecureToken(16), false); |     optionService.createOption("documentId", randomSecureToken(16), false); | ||||||
| @@ -185,6 +186,9 @@ const defaultOptions: DefaultOption[] = [ | |||||||
|     { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true }, |     { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true }, | ||||||
|     { name: "textNoteCompletionEnabled", value: "true", isSynced: true }, |     { name: "textNoteCompletionEnabled", value: "true", isSynced: true }, | ||||||
|      |      | ||||||
|  |     // CKEditor plugin configuration | ||||||
|  |     { name: "ckeditorEnabledPlugins", value: getDefaultPluginConfiguration, isSynced: true }, | ||||||
|  |  | ||||||
|     // HTML import configuration |     // HTML import configuration | ||||||
|     { name: "layoutOrientation", value: "vertical", isSynced: false }, |     { name: "layoutOrientation", value: "vertical", isSynced: false }, | ||||||
|     { name: "backgroundEffects", value: "true", isSynced: false }, |     { name: "backgroundEffects", value: "true", isSynced: false }, | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ import { COMMON_PLUGINS, CORE_PLUGINS, POPUP_EDITOR_PLUGINS } from "./plugins.js | |||||||
| import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5"; | import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5"; | ||||||
| import "./translation_overrides.js"; | import "./translation_overrides.js"; | ||||||
| export { EditorWatchdog } from "ckeditor5"; | export { EditorWatchdog } from "ckeditor5"; | ||||||
| export { PREMIUM_PLUGINS } from "./plugins.js"; | export { PREMIUM_PLUGINS, PLUGIN_REGISTRY, getPluginMetadata, getPluginsByCategory, getConfigurablePlugins, getDefaultPluginConfiguration } from "./plugins.js"; | ||||||
| export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, WatchdogConfig } from "ckeditor5"; | export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, WatchdogConfig } from "ckeditor5"; | ||||||
| export type { TemplateDefinition } from "ckeditor5-premium-features"; | export type { TemplateDefinition } from "ckeditor5-premium-features"; | ||||||
| export { default as buildExtraCommands } from "./extra_slash_commands.js"; | export { default as buildExtraCommands } from "./extra_slash_commands.js"; | ||||||
|   | |||||||
| @@ -30,6 +30,8 @@ import CodeBlockLanguageDropdown from "./plugins/code_block_language_dropdown.js | |||||||
| import MoveBlockUpDownPlugin from "./plugins/move_block_updown.js"; | import MoveBlockUpDownPlugin from "./plugins/move_block_updown.js"; | ||||||
| import ScrollOnUndoRedoPlugin from "./plugins/scroll_on_undo_redo.js" | import ScrollOnUndoRedoPlugin from "./plugins/scroll_on_undo_redo.js" | ||||||
|  |  | ||||||
|  | import type { PluginMetadata, PluginRegistry } from "@triliumnext/commons"; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Plugins that are specific to Trilium and not part of the CKEditor 5 core, included in both text editors but not in the attribute editor. |  * Plugins that are specific to Trilium and not part of the CKEditor 5 core, included in both text editors but not in the attribute editor. | ||||||
|  */ |  */ | ||||||
| @@ -159,3 +161,613 @@ export const POPUP_EDITOR_PLUGINS: typeof Plugin[] = [ | |||||||
|     ...COMMON_PLUGINS, |     ...COMMON_PLUGINS, | ||||||
|     BlockToolbar, |     BlockToolbar, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Plugin metadata registry for CKEditor plugins in Trilium. | ||||||
|  |  * This defines the configurable plugins with their metadata, dependencies, and categorization. | ||||||
|  |  */ | ||||||
|  | export const PLUGIN_REGISTRY: PluginRegistry = { | ||||||
|  |     plugins: { | ||||||
|  |         // Core plugins (cannot be disabled) | ||||||
|  |         "clipboard": { | ||||||
|  |             id: "clipboard", | ||||||
|  |             name: "Clipboard", | ||||||
|  |             description: "Basic clipboard operations (copy, paste, cut)", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: true, | ||||||
|  |             commands: ["copy", "paste", "cut"] | ||||||
|  |         }, | ||||||
|  |         "enter": { | ||||||
|  |             id: "enter", | ||||||
|  |             name: "Enter Key", | ||||||
|  |             description: "Enter key handling for line breaks and paragraphs", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: true, | ||||||
|  |         }, | ||||||
|  |         "typing": { | ||||||
|  |             id: "typing", | ||||||
|  |             name: "Typing", | ||||||
|  |             description: "Basic text input and keyboard handling", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: true, | ||||||
|  |         }, | ||||||
|  |         "undo": { | ||||||
|  |             id: "undo", | ||||||
|  |             name: "Undo/Redo", | ||||||
|  |             description: "Undo and redo functionality", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: true, | ||||||
|  |             commands: ["undo", "redo"], | ||||||
|  |             toolbarItems: ["undo", "redo"] | ||||||
|  |         }, | ||||||
|  |         "paragraph": { | ||||||
|  |             id: "paragraph", | ||||||
|  |             name: "Paragraph", | ||||||
|  |             description: "Basic paragraph formatting", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: true, | ||||||
|  |             commands: ["paragraph"] | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         // Formatting plugins | ||||||
|  |         "bold": { | ||||||
|  |             id: "bold", | ||||||
|  |             name: "Bold", | ||||||
|  |             description: "Bold text formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["bold"], | ||||||
|  |             toolbarItems: ["bold"] | ||||||
|  |         }, | ||||||
|  |         "italic": { | ||||||
|  |             id: "italic", | ||||||
|  |             name: "Italic", | ||||||
|  |             description: "Italic text formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["italic"], | ||||||
|  |             toolbarItems: ["italic"] | ||||||
|  |         }, | ||||||
|  |         "underline": { | ||||||
|  |             id: "underline", | ||||||
|  |             name: "Underline", | ||||||
|  |             description: "Underline text formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["underline"], | ||||||
|  |             toolbarItems: ["underline"] | ||||||
|  |         }, | ||||||
|  |         "strikethrough": { | ||||||
|  |             id: "strikethrough", | ||||||
|  |             name: "Strikethrough", | ||||||
|  |             description: "Strikethrough text formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["strikethrough"], | ||||||
|  |             toolbarItems: ["strikethrough"] | ||||||
|  |         }, | ||||||
|  |         "code": { | ||||||
|  |             id: "code", | ||||||
|  |             name: "Inline Code", | ||||||
|  |             description: "Inline code formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["code"], | ||||||
|  |             toolbarItems: ["code"] | ||||||
|  |         }, | ||||||
|  |         "subscript": { | ||||||
|  |             id: "subscript", | ||||||
|  |             name: "Subscript", | ||||||
|  |             description: "Subscript text formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["subscript"], | ||||||
|  |             toolbarItems: ["subscript"] | ||||||
|  |         }, | ||||||
|  |         "superscript": { | ||||||
|  |             id: "superscript", | ||||||
|  |             name: "Superscript", | ||||||
|  |             description: "Superscript text formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["superscript"], | ||||||
|  |             toolbarItems: ["superscript"] | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         // Structure plugins | ||||||
|  |         "heading": { | ||||||
|  |             id: "heading", | ||||||
|  |             name: "Headings", | ||||||
|  |             description: "Heading levels (H2-H6)", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["heading"], | ||||||
|  |             toolbarItems: ["heading"] | ||||||
|  |         }, | ||||||
|  |         "blockquote": { | ||||||
|  |             id: "blockquote", | ||||||
|  |             name: "Block Quote", | ||||||
|  |             description: "Block quote formatting", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["blockQuote"], | ||||||
|  |             toolbarItems: ["blockQuote"] | ||||||
|  |         }, | ||||||
|  |         "list": { | ||||||
|  |             id: "list", | ||||||
|  |             name: "Lists", | ||||||
|  |             description: "Bulleted and numbered lists", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["bulletedList", "numberedList"], | ||||||
|  |             toolbarItems: ["bulletedList", "numberedList"] | ||||||
|  |         }, | ||||||
|  |         "todolist": { | ||||||
|  |             id: "todolist", | ||||||
|  |             name: "Todo List", | ||||||
|  |             description: "Checkable todo list items", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: ["list"], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["todoList"], | ||||||
|  |             toolbarItems: ["todoList"] | ||||||
|  |         }, | ||||||
|  |         "alignment": { | ||||||
|  |             id: "alignment", | ||||||
|  |             name: "Text Alignment", | ||||||
|  |             description: "Text alignment (left, center, right, justify)", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["alignment"], | ||||||
|  |             toolbarItems: ["alignment"] | ||||||
|  |         }, | ||||||
|  |         "indent": { | ||||||
|  |             id: "indent", | ||||||
|  |             name: "Indentation", | ||||||
|  |             description: "Text and block indentation", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["indent", "outdent"], | ||||||
|  |             toolbarItems: ["indent", "outdent"] | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         // Media plugins | ||||||
|  |         "image": { | ||||||
|  |             id: "image", | ||||||
|  |             name: "Images", | ||||||
|  |             description: "Image insertion, resizing and styling", | ||||||
|  |             category: "media", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["insertImage"], | ||||||
|  |             toolbarItems: ["insertImage"] | ||||||
|  |         }, | ||||||
|  |         "link": { | ||||||
|  |             id: "link", | ||||||
|  |             name: "Links", | ||||||
|  |             description: "Hyperlinks and internal note links", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["link", "unlink"], | ||||||
|  |             toolbarItems: ["link", "unlink"] | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         // Table plugins | ||||||
|  |         "table": { | ||||||
|  |             id: "table", | ||||||
|  |             name: "Tables", | ||||||
|  |             description: "Table creation and editing", | ||||||
|  |             category: "tables", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["insertTable"], | ||||||
|  |             toolbarItems: ["insertTable"] | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         // Advanced plugins | ||||||
|  |         "codeblock": { | ||||||
|  |             id: "codeblock", | ||||||
|  |             name: "Code Blocks", | ||||||
|  |             description: "Syntax-highlighted code blocks", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["codeBlock"], | ||||||
|  |             toolbarItems: ["codeBlock"] | ||||||
|  |         }, | ||||||
|  |         "math": { | ||||||
|  |             id: "math", | ||||||
|  |             name: "Math Formulas", | ||||||
|  |             description: "Mathematical formulas using KaTeX", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["math"], | ||||||
|  |             toolbarItems: ["math"] | ||||||
|  |         }, | ||||||
|  |         "mermaid": { | ||||||
|  |             id: "mermaid", | ||||||
|  |             name: "Mermaid Diagrams", | ||||||
|  |             description: "Diagram creation using Mermaid syntax", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["mermaid"], | ||||||
|  |             toolbarItems: ["mermaid"] | ||||||
|  |         }, | ||||||
|  |         "admonition": { | ||||||
|  |             id: "admonition", | ||||||
|  |             name: "Admonitions", | ||||||
|  |             description: "Callout boxes and admonition blocks", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["admonition"], | ||||||
|  |             toolbarItems: ["admonition"] | ||||||
|  |         }, | ||||||
|  |         "footnotes": { | ||||||
|  |             id: "footnotes", | ||||||
|  |             name: "Footnotes", | ||||||
|  |             description: "Footnote references and definitions", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["footnote"], | ||||||
|  |             toolbarItems: ["footnote"] | ||||||
|  |         }, | ||||||
|  |         "keyboard": { | ||||||
|  |             id: "keyboard", | ||||||
|  |             name: "Keyboard Shortcuts", | ||||||
|  |             description: "Visual keyboard shortcut markers", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["kbd"], | ||||||
|  |             toolbarItems: ["kbd"] | ||||||
|  |         }, | ||||||
|  |         "horizontalline": { | ||||||
|  |             id: "horizontalline", | ||||||
|  |             name: "Horizontal Line", | ||||||
|  |             description: "Horizontal rule/divider line", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["horizontalLine"], | ||||||
|  |             toolbarItems: ["horizontalLine"] | ||||||
|  |         }, | ||||||
|  |         "pagebreak": { | ||||||
|  |             id: "pagebreak", | ||||||
|  |             name: "Page Break", | ||||||
|  |             description: "Page break for printing", | ||||||
|  |             category: "structure", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["pageBreak"], | ||||||
|  |             toolbarItems: ["pageBreak"] | ||||||
|  |         }, | ||||||
|  |         "removeformat": { | ||||||
|  |             id: "removeformat", | ||||||
|  |             name: "Remove Formatting", | ||||||
|  |             description: "Remove text formatting", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["removeFormat"], | ||||||
|  |             toolbarItems: ["removeFormat"] | ||||||
|  |         }, | ||||||
|  |         "findandreplace": { | ||||||
|  |             id: "findandreplace", | ||||||
|  |             name: "Find and Replace", | ||||||
|  |             description: "Text search and replace functionality", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["findAndReplace"], | ||||||
|  |             toolbarItems: ["findAndReplace"] | ||||||
|  |         }, | ||||||
|  |         "font": { | ||||||
|  |             id: "font", | ||||||
|  |             name: "Font Styling", | ||||||
|  |             description: "Font family, size, color, and background color", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["fontFamily", "fontSize", "fontColor", "fontBackgroundColor"], | ||||||
|  |             toolbarItems: ["fontFamily", "fontSize", "fontColor", "fontBackgroundColor"] | ||||||
|  |         }, | ||||||
|  |         "specialcharacters": { | ||||||
|  |             id: "specialcharacters", | ||||||
|  |             name: "Special Characters", | ||||||
|  |             description: "Insert special characters and symbols", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["specialCharacters"], | ||||||
|  |             toolbarItems: ["specialCharacters"] | ||||||
|  |         }, | ||||||
|  |         "emoji": { | ||||||
|  |             id: "emoji", | ||||||
|  |             name: "Emoji Support", | ||||||
|  |             description: "Emoji insertion and autocomplete", | ||||||
|  |             category: "formatting", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["emoji"], | ||||||
|  |             toolbarItems: ["emoji"] | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         // Premium plugins | ||||||
|  |         "slashcommand": { | ||||||
|  |             id: "slashcommand", | ||||||
|  |             name: "Slash Commands", | ||||||
|  |             description: "Quick command insertion with / key", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: true, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["slashCommand"] | ||||||
|  |         }, | ||||||
|  |         "template": { | ||||||
|  |             id: "template", | ||||||
|  |             name: "Templates", | ||||||
|  |             description: "Text templates and snippets", | ||||||
|  |             category: "advanced", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: true, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["template"], | ||||||
|  |             toolbarItems: ["template"] | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         // Trilium-specific plugins | ||||||
|  |         "uploadimage": { | ||||||
|  |             id: "uploadimage", | ||||||
|  |             name: "Image Upload", | ||||||
|  |             description: "Trilium-specific image upload handling", | ||||||
|  |             category: "trilium", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: ["image"], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |         }, | ||||||
|  |         "cuttonote": { | ||||||
|  |             id: "cuttonote", | ||||||
|  |             name: "Cut to Note", | ||||||
|  |             description: "Cut selected text to create a new note", | ||||||
|  |             category: "trilium", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["cutToNote"] | ||||||
|  |         }, | ||||||
|  |         "internallink": { | ||||||
|  |             id: "internallink", | ||||||
|  |             name: "Internal Links", | ||||||
|  |             description: "Trilium-specific internal note linking", | ||||||
|  |             category: "trilium", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: ["link"], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |         }, | ||||||
|  |         "insertdatetime": { | ||||||
|  |             id: "insertdatetime", | ||||||
|  |             name: "Insert Date/Time", | ||||||
|  |             description: "Insert current date and time", | ||||||
|  |             category: "trilium", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["insertDateTime"] | ||||||
|  |         }, | ||||||
|  |         "includenote": { | ||||||
|  |             id: "includenote", | ||||||
|  |             name: "Include Note", | ||||||
|  |             description: "Include content from other notes", | ||||||
|  |             category: "trilium", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["includeNote"] | ||||||
|  |         }, | ||||||
|  |         "uploadfile": { | ||||||
|  |             id: "uploadfile", | ||||||
|  |             name: "File Upload", | ||||||
|  |             description: "Upload and attach files", | ||||||
|  |             category: "trilium", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["uploadFile"] | ||||||
|  |         }, | ||||||
|  |         "markdownimport": { | ||||||
|  |             id: "markdownimport", | ||||||
|  |             name: "Markdown Import", | ||||||
|  |             description: "Import markdown content", | ||||||
|  |             category: "trilium", | ||||||
|  |             defaultEnabled: true, | ||||||
|  |             dependencies: [], | ||||||
|  |             conflicts: [], | ||||||
|  |             requiresPremium: false, | ||||||
|  |             isCore: false, | ||||||
|  |             commands: ["markdownImport"] | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     version: "1.0.0", | ||||||
|  |     lastModified: new Date().toISOString() | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get plugin metadata by ID | ||||||
|  |  */ | ||||||
|  | export function getPluginMetadata(pluginId: string): PluginMetadata | undefined { | ||||||
|  |     return PLUGIN_REGISTRY.plugins[pluginId]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get all plugins in a category | ||||||
|  |  */ | ||||||
|  | export function getPluginsByCategory(category: string): PluginMetadata[] { | ||||||
|  |     return Object.values(PLUGIN_REGISTRY.plugins).filter(plugin => plugin.category === category); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get core plugins (cannot be disabled) | ||||||
|  |  */ | ||||||
|  | export function getCorePlugins(): PluginMetadata[] { | ||||||
|  |     return Object.values(PLUGIN_REGISTRY.plugins).filter(plugin => plugin.isCore); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get configurable plugins (can be enabled/disabled) | ||||||
|  |  */ | ||||||
|  | export function getConfigurablePlugins(): PluginMetadata[] { | ||||||
|  |     return Object.values(PLUGIN_REGISTRY.plugins).filter(plugin => !plugin.isCore); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get default enabled plugins configuration as JSON string | ||||||
|  |  */ | ||||||
|  | export function getDefaultPluginConfiguration(): string { | ||||||
|  |     const defaultConfig: Record<string, boolean> = {}; | ||||||
|  |     Object.values(PLUGIN_REGISTRY.plugins).forEach(plugin => { | ||||||
|  |         if (!plugin.isCore) { | ||||||
|  |             defaultConfig[plugin.id] = plugin.defaultEnabled; | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |     return JSON.stringify(defaultConfig); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -4,20 +4,23 @@ | |||||||
|   "include": [], |   "include": [], | ||||||
|   "references": [ |   "references": [ | ||||||
|     { |     { | ||||||
|       "path": "../ckeditor5-footnotes" |       "path": "../commons" | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       "path": "../ckeditor5-math" |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       "path": "../ckeditor5-admonition" |  | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       "path": "../ckeditor5-mermaid" |       "path": "../ckeditor5-mermaid" | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |       "path": "../ckeditor5-math" | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|       "path": "../ckeditor5-keyboard-marker" |       "path": "../ckeditor5-keyboard-marker" | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |       "path": "../ckeditor5-footnotes" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "path": "../ckeditor5-admonition" | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|       "path": "./tsconfig.lib.json" |       "path": "./tsconfig.lib.json" | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -20,19 +20,22 @@ | |||||||
|   ], |   ], | ||||||
|   "references": [ |   "references": [ | ||||||
|     { |     { | ||||||
|       "path": "../ckeditor5-footnotes" |       "path": "../commons/tsconfig.lib.json" | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       "path": "../ckeditor5-math" |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       "path": "../ckeditor5-admonition" |  | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       "path": "../ckeditor5-mermaid" |       "path": "../ckeditor5-mermaid" | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |       "path": "../ckeditor5-math" | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|       "path": "../ckeditor5-keyboard-marker" |       "path": "../ckeditor5-keyboard-marker" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "path": "../ckeditor5-footnotes" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "path": "../ckeditor5-admonition" | ||||||
|     } |     } | ||||||
|   ] |   ] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| export * from "./lib/i18n.js"; | export * from "./lib/i18n.js"; | ||||||
| export * from "./lib/options_interface.js"; | export * from "./lib/options_interface.js"; | ||||||
|  | export * from "./lib/ckeditor_plugin_interface.js"; | ||||||
| export * from "./lib/keyboard_actions_interface.js"; | export * from "./lib/keyboard_actions_interface.js"; | ||||||
| export * from "./lib/hidden_subtree.js"; | export * from "./lib/hidden_subtree.js"; | ||||||
| export * from "./lib/rows.js"; | export * from "./lib/rows.js"; | ||||||
|   | |||||||
							
								
								
									
										163
									
								
								packages/commons/src/lib/ckeditor_plugin_interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								packages/commons/src/lib/ckeditor_plugin_interface.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | |||||||
|  | /** | ||||||
|  |  * @module CKEditor Plugin Interface | ||||||
|  |  *  | ||||||
|  |  * This module defines the TypeScript interfaces and types for managing | ||||||
|  |  * CKEditor plugins in Trilium Notes. It provides type-safe configuration | ||||||
|  |  * for plugin enablement, metadata, and dependency management. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Defines the categories of CKEditor plugins available in Trilium. | ||||||
|  |  */ | ||||||
|  | export type PluginCategory =  | ||||||
|  |     | "formatting"    // Text formatting (bold, italic, etc.) | ||||||
|  |     | "structure"     // Document structure (headings, lists, etc.) | ||||||
|  |     | "media"        // Images, files, embeds | ||||||
|  |     | "tables"       // Table-related functionality | ||||||
|  |     | "advanced"     // Advanced features (math, mermaid, etc.) | ||||||
|  |     | "trilium"      // Trilium-specific plugins | ||||||
|  |     | "external";    // Third-party plugins | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Represents the metadata for a CKEditor plugin. | ||||||
|  |  */ | ||||||
|  | export interface PluginMetadata { | ||||||
|  |     /** Unique identifier for the plugin */ | ||||||
|  |     id: string; | ||||||
|  |     /** Human-readable display name */ | ||||||
|  |     name: string; | ||||||
|  |     /** Brief description of the plugin's functionality */ | ||||||
|  |     description: string; | ||||||
|  |     /** Category this plugin belongs to */ | ||||||
|  |     category: PluginCategory; | ||||||
|  |     /** Whether this plugin is enabled by default for new users */ | ||||||
|  |     defaultEnabled: boolean; | ||||||
|  |     /** Array of plugin IDs that this plugin depends on */ | ||||||
|  |     dependencies: string[]; | ||||||
|  |     /** Array of plugin IDs that conflict with this plugin */ | ||||||
|  |     conflicts: string[]; | ||||||
|  |     /** Whether this plugin requires a premium CKEditor license */ | ||||||
|  |     requiresPremium: boolean; | ||||||
|  |     /** Whether this plugin is part of the core editor functionality (cannot be disabled) */ | ||||||
|  |     isCore: boolean; | ||||||
|  |     /** Toolbar items/commands provided by this plugin */ | ||||||
|  |     toolbarItems?: string[]; | ||||||
|  |     /** Commands provided by this plugin */ | ||||||
|  |     commands?: string[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Configuration for a user's CKEditor plugin preferences. | ||||||
|  |  */ | ||||||
|  | export interface PluginConfiguration { | ||||||
|  |     /** Plugin ID */ | ||||||
|  |     id: string; | ||||||
|  |     /** Whether the plugin is enabled for this user */ | ||||||
|  |     enabled: boolean; | ||||||
|  |     /** User-specific configuration for the plugin (if any) */ | ||||||
|  |     config?: Record<string, unknown>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The complete registry of available CKEditor plugins. | ||||||
|  |  */ | ||||||
|  | export interface PluginRegistry { | ||||||
|  |     /** Map of plugin ID to plugin metadata */ | ||||||
|  |     plugins: Record<string, PluginMetadata>; | ||||||
|  |     /** Version of the plugin registry (for cache invalidation) */ | ||||||
|  |     version: string; | ||||||
|  |     /** Last modified timestamp */ | ||||||
|  |     lastModified: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Result of plugin dependency validation. | ||||||
|  |  */ | ||||||
|  | export interface PluginValidationResult { | ||||||
|  |     /** Whether the configuration is valid */ | ||||||
|  |     valid: boolean; | ||||||
|  |     /** Array of validation errors */ | ||||||
|  |     errors: PluginValidationError[]; | ||||||
|  |     /** Array of warnings (non-blocking issues) */ | ||||||
|  |     warnings: PluginValidationWarning[]; | ||||||
|  |     /** Resolved list of plugins that should be enabled */ | ||||||
|  |     resolvedPlugins: string[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validation error for plugin configuration. | ||||||
|  |  */ | ||||||
|  | export interface PluginValidationError { | ||||||
|  |     /** Type of error */ | ||||||
|  |     type: "missing_dependency" | "circular_dependency" | "plugin_conflict" | "premium_required"; | ||||||
|  |     /** Plugin ID that caused the error */ | ||||||
|  |     pluginId: string; | ||||||
|  |     /** Human-readable error message */ | ||||||
|  |     message: string; | ||||||
|  |     /** Additional context about the error */ | ||||||
|  |     details?: Record<string, unknown>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validation warning for plugin configuration. | ||||||
|  |  */ | ||||||
|  | export interface PluginValidationWarning { | ||||||
|  |     /** Type of warning */ | ||||||
|  |     type: "dependency_disabled" | "unused_dependency" | "performance_impact"; | ||||||
|  |     /** Plugin ID that caused the warning */ | ||||||
|  |     pluginId: string; | ||||||
|  |     /** Human-readable warning message */ | ||||||
|  |     message: string; | ||||||
|  |     /** Additional context about the warning */ | ||||||
|  |     details?: Record<string, unknown>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Request to update plugin configuration. | ||||||
|  |  */ | ||||||
|  | export interface UpdatePluginConfigRequest { | ||||||
|  |     /** Array of plugin configurations to update */ | ||||||
|  |     plugins: PluginConfiguration[]; | ||||||
|  |     /** Whether to validate dependencies before saving */ | ||||||
|  |     validate?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Response from updating plugin configuration. | ||||||
|  |  */ | ||||||
|  | export interface UpdatePluginConfigResponse { | ||||||
|  |     /** Whether the update was successful */ | ||||||
|  |     success: boolean; | ||||||
|  |     /** Validation result (if validation was requested) */ | ||||||
|  |     validation?: PluginValidationResult; | ||||||
|  |     /** Updated plugin configurations */ | ||||||
|  |     plugins: PluginConfiguration[]; | ||||||
|  |     /** Any errors that occurred during the update */ | ||||||
|  |     errors?: string[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Options for querying the plugin registry. | ||||||
|  |  */ | ||||||
|  | export interface QueryPluginsOptions { | ||||||
|  |     /** Filter by category */ | ||||||
|  |     category?: PluginCategory; | ||||||
|  |     /** Filter by enabled status */ | ||||||
|  |     enabled?: boolean; | ||||||
|  |     /** Filter by core status */ | ||||||
|  |     coreOnly?: boolean; | ||||||
|  |     /** Include user configuration in results */ | ||||||
|  |     includeConfig?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Result of querying plugins. | ||||||
|  |  */ | ||||||
|  | export interface QueryPluginsResult { | ||||||
|  |     /** Array of plugin metadata */ | ||||||
|  |     plugins: (PluginMetadata & { enabled?: boolean; config?: Record<string, unknown> })[]; | ||||||
|  |     /** Total count of plugins (before filtering) */ | ||||||
|  |     totalCount: number; | ||||||
|  |     /** Categories available in the registry */ | ||||||
|  |     categories: PluginCategory[]; | ||||||
|  | } | ||||||
| @@ -150,6 +150,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi | |||||||
|     codeOpenAiModel: string; |     codeOpenAiModel: string; | ||||||
|     aiSelectedProvider: string; |     aiSelectedProvider: string; | ||||||
|     seenCallToActions: string; |     seenCallToActions: string; | ||||||
|  |      | ||||||
|  |     // CKEditor plugin options | ||||||
|  |     ckeditorEnabledPlugins: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| export type OptionNames = keyof OptionDefinitions; | export type OptionNames = keyof OptionDefinitions; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user