Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions apps/client/src/services/ckeditor_plugin_config.ts
Original file line number Diff line number Diff line change
@@ -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
};
37 changes: 37 additions & 0 deletions apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,43 @@
"multiline-toolbar": "Display the toolbar on multiple lines if it doesn't fit."
}
},
"ckeditor_plugins": {
"title": "Editor Plugins",
"description": "Configure which CKEditor plugins are enabled. Changes take effect when the editor is reloaded.",
"loading": "Loading plugin configuration...",
"load_failed": "Failed to load plugin configuration.",
"load_error": "Error loading plugins",
"retry": "Retry",
"category_formatting": "Text Formatting",
"category_structure": "Document Structure",
"category_media": "Media & Files",
"category_tables": "Tables",
"category_advanced": "Advanced Features",
"category_trilium": "Trilium Features",
"category_external": "External Plugins",
"stats_enabled": "Enabled",
"stats_total": "Total",
"stats_core": "Core",
"stats_premium": "Premium",
"no_license": "no license",
"premium": "Premium",
"premium_required": "Requires premium CKEditor license",
"has_dependencies": "Dependencies",
"depends_on": "Depends on",
"toolbar_items": "Toolbar items",
"validate": "Validate",
"validation_error": "Validation failed",
"validation_errors": "Configuration Errors:",
"validation_warnings": "Configuration Warnings:",
"save": "Save Changes",
"save_success": "Plugin configuration saved successfully",
"save_error": "Failed to save configuration",
"reload_editor_notice": "Please reload any open text notes to apply changes",
"reset_defaults": "Reset to Defaults",
"reset_confirm": "Are you sure you want to reset all plugin settings to their default values?",
"reset_success": "Plugin configuration reset to defaults",
"reset_error": "Failed to reset configuration"
},
"electron_context_menu": {
"add-term-to-dictionary": "Add \"{{term}}\" to dictionary",
"cut": "Cut",
Expand Down
18 changes: 15 additions & 3 deletions apps/client/src/widgets/type_widgets/ckeditor/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import noteAutocompleteService, { type Suggestion } from "../../../services/note
import mimeTypesService from "../../../services/mime_types.js";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { buildToolbarConfig } from "./toolbar.js";
import ckeditorPluginConfigService from "../../../services/ckeditor_plugin_config.js";

export const OPEN_SOURCE_LICENSE_KEY = "GPL";

Expand Down Expand Up @@ -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.
language: "en",
removePlugins: getDisabledPlugins()
removePlugins: await getDisabledPlugins()
};

// Set up content language.
Expand Down Expand Up @@ -203,9 +204,11 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
];
}

const toolbarConfig = await buildToolbarConfig(opts.isClassicEditor);

return {
...config,
...buildToolbarConfig(opts.isClassicEditor)
...toolbarConfig
};
}

Expand Down Expand Up @@ -237,9 +240,18 @@ function getLicenseKey() {
return premiumLicenseKey;
}

function getDisabledPlugins() {
async function getDisabledPlugins() {
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") {
disabledPlugins.push("EmojiMention");
}
Expand Down
Loading
Loading