Compare commits

...

2 Commits

Author SHA1 Message Date
Jon Fuller
5332f015ef Merge branch 'main' into feat/add-ckeditor-plugin-options 2025-08-24 14:47:44 -07:00
perf3ct
a3d77421fd feat(options): allow for user selection of ckeditor plugins 2025-08-24 05:17:28 +00:00
17 changed files with 2025 additions and 121 deletions

View 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
};

View File

@@ -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",

View File

@@ -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");
} }

View File

@@ -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"
]
}; };
} }

View File

@@ -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 />,

View File

@@ -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>
);
}

View 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
};

View File

@@ -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",

View File

@@ -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";
@@ -220,6 +221,15 @@ function register(app: express.Application) {
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions); apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
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);

View File

@@ -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);
@@ -184,6 +185,9 @@ const defaultOptions: DefaultOption[] = [
{ name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true }, { name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true },
{ 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 },

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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"
} }

View File

@@ -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"
} }
] ]
} }

View File

@@ -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";

View 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[];
}

View File

@@ -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;