From 461abf768c7d723f6f811ca62da75580a93d7a7a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 11 Apr 2026 12:06:23 +0300 Subject: [PATCH] feat(ckeditor/include_note): add a way to change size after creation (closes #3705) --- packages/ckeditor5/src/plugins.ts | 4 + .../plugins/include_note_box_size_dropdown.ts | 76 +++++++++++++ .../src/plugins/include_note_toolbar.ts | 56 ++++++++++ packages/ckeditor5/src/plugins/includenote.ts | 105 +++++++++++++++++- 4 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 packages/ckeditor5/src/plugins/include_note_box_size_dropdown.ts create mode 100644 packages/ckeditor5/src/plugins/include_note_toolbar.ts diff --git a/packages/ckeditor5/src/plugins.ts b/packages/ckeditor5/src/plugins.ts index 2d0f0de054..8407bd7026 100644 --- a/packages/ckeditor5/src/plugins.ts +++ b/packages/ckeditor5/src/plugins.ts @@ -34,6 +34,8 @@ import InlineCodeNoSpellcheck from "./plugins/inline_code_no_spellcheck.js"; import InlineCodeToolbar from "./plugins/inline_code_toolbar.js"; import AdmonitionTypeDropdown from "./plugins/admonition_type_dropdown.js"; import AdmonitionToolbar from "./plugins/admonition_toolbar.js"; +import IncludeNoteBoxSizeDropdown from "./plugins/include_note_box_size_dropdown.js"; +import IncludeNoteToolbar from "./plugins/include_note_toolbar.js"; /** * 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. @@ -59,6 +61,8 @@ const TRILIUM_PLUGINS: typeof Plugin[] = [ InlineCodeToolbar, AdmonitionTypeDropdown, AdmonitionToolbar, + IncludeNoteBoxSizeDropdown, + IncludeNoteToolbar, ]; /** diff --git a/packages/ckeditor5/src/plugins/include_note_box_size_dropdown.ts b/packages/ckeditor5/src/plugins/include_note_box_size_dropdown.ts new file mode 100644 index 0000000000..80776add45 --- /dev/null +++ b/packages/ckeditor5/src/plugins/include_note_box_size_dropdown.ts @@ -0,0 +1,76 @@ +import { Plugin, type ListDropdownButtonDefinition, Collection, ViewModel, createDropdown, addListToDropdown, DropdownButtonView, type Command } from "ckeditor5"; +import IncludeNote, { BOX_SIZE_COMMAND_NAME, BOX_SIZES, type BoxSizeValue } from "./includenote.js"; + +/** + * Toolbar item which displays the list of box sizes for include notes in a dropdown. + */ +export default class IncludeNoteBoxSizeDropdown extends Plugin { + + static get requires() { + return [IncludeNote] as const; + } + + public init() { + const editor = this.editor; + const componentFactory = editor.ui.componentFactory; + + console.log("[IncludeNoteBoxSizeDropdown] Initializing"); + + const itemDefinitions = this._getBoxSizeListItemDefinitions(); + const command = editor.commands.get(BOX_SIZE_COMMAND_NAME) as Command & { value: BoxSizeValue | null }; + + console.log("[IncludeNoteBoxSizeDropdown] Command:", command); + + componentFactory.add("includeNoteBoxSizeDropdown", _locale => { + console.log("[IncludeNoteBoxSizeDropdown] Creating dropdown component"); + const dropdownView = createDropdown(editor.locale, DropdownButtonView); + dropdownView.buttonView.set({ + withText: true, + tooltip: true, + label: "Box size" + }); + dropdownView.bind("isEnabled").to(command, "isEnabled"); + dropdownView.buttonView.bind("label").to(command, "value", (value) => { + if (!value) return "Box size"; + const sizeDef = BOX_SIZES.find(s => s.value === value); + return sizeDef?.label ?? value; + }); + dropdownView.on("execute", evt => { + const source = evt.source as any; + editor.execute(BOX_SIZE_COMMAND_NAME, { + value: source._boxSizeValue + }); + editor.editing.view.focus(); + }); + addListToDropdown(dropdownView, itemDefinitions); + return dropdownView; + }); + } + + private _getBoxSizeListItemDefinitions(): Collection { + const editor = this.editor; + const command = editor.commands.get(BOX_SIZE_COMMAND_NAME) as Command & { value: BoxSizeValue | null }; + const itemDefinitions = new Collection(); + + for (const sizeDef of BOX_SIZES) { + const definition: ListDropdownButtonDefinition = { + type: "button", + model: new ViewModel({ + _boxSizeValue: sizeDef.value, + label: sizeDef.label, + role: "menuitemradio", + withText: true + }) + }; + + definition.model.bind("isOn").to(command, "value", value => { + return value === sizeDef.value; + }); + + itemDefinitions.add(definition); + } + + return itemDefinitions; + } + +} diff --git a/packages/ckeditor5/src/plugins/include_note_toolbar.ts b/packages/ckeditor5/src/plugins/include_note_toolbar.ts new file mode 100644 index 0000000000..c32a5193b3 --- /dev/null +++ b/packages/ckeditor5/src/plugins/include_note_toolbar.ts @@ -0,0 +1,56 @@ +import { Plugin, WidgetToolbarRepository, isWidget, type ViewElement } from "ckeditor5"; +import IncludeNote from "./includenote.js"; +import IncludeNoteBoxSizeDropdown from "./include_note_box_size_dropdown.js"; + +export default class IncludeNoteToolbar extends Plugin { + + static get requires() { + return [WidgetToolbarRepository, IncludeNote, IncludeNoteBoxSizeDropdown] as const; + } + + afterInit() { + const editor = this.editor; + const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository); + + console.log("[IncludeNoteToolbar] Registering toolbar"); + + widgetToolbarRepository.register("includeNote", { + items: [ + "includeNoteBoxSizeDropdown" + ], + balloonClassName: "ck-toolbar-container include-note-toolbar", + getRelatedElement(selection) { + const selectedElement = selection.getSelectedElement(); + console.log("[IncludeNoteToolbar] getRelatedElement called, selectedElement:", selectedElement); + + if (selectedElement) { + console.log("[IncludeNoteToolbar] Element name:", selectedElement.name); + console.log("[IncludeNoteToolbar] Element classes:", selectedElement.getAttribute("class")); + console.log("[IncludeNoteToolbar] isWidget:", isWidget(selectedElement)); + } + + if (selectedElement && isIncludeNoteWidget(selectedElement)) { + console.log("[IncludeNoteToolbar] Found include note widget, returning element"); + return selectedElement; + } + + console.log("[IncludeNoteToolbar] No include note widget found"); + return null; + } + }); + } + +} + +function isIncludeNoteWidget(element: ViewElement): boolean { + if (!isWidget(element)) { + return false; + } + + if (!element.is("element", "section")) { + return false; + } + + const classes = element.getAttribute("class") || ""; + return typeof classes === "string" && classes.includes("include-note"); +} diff --git a/packages/ckeditor5/src/plugins/includenote.ts b/packages/ckeditor5/src/plugins/includenote.ts index 1ce633f357..41c35ce7e7 100644 --- a/packages/ckeditor5/src/plugins/includenote.ts +++ b/packages/ckeditor5/src/plugins/includenote.ts @@ -2,6 +2,15 @@ import { ButtonView, Command, Plugin, toWidget, Widget, type Editor, type Observ import noteIcon from '../icons/note.svg?raw'; export const COMMAND_NAME = 'insertIncludeNote'; +export const BOX_SIZE_COMMAND_NAME = 'includeNoteBoxSize'; + +export const BOX_SIZES = [ + { value: 'small', label: 'Small' }, + { value: 'medium', label: 'Medium' }, + { value: 'full', label: 'Full' } +] as const; + +export type BoxSizeValue = typeof BOX_SIZES[number]['value']; export default class IncludeNote extends Plugin { static get requires() { @@ -54,6 +63,7 @@ class IncludeNoteEditing extends Plugin { this._defineConverters(); this.editor.commands.add( COMMAND_NAME, new InsertIncludeNoteCommand( this.editor ) ); + this.editor.commands.add( BOX_SIZE_COMMAND_NAME, new IncludeNoteBoxSizeCommand( this.editor ) ); } _defineSchema() { @@ -133,6 +143,29 @@ class IncludeNoteEditing extends Plugin { return toWidget( section, viewWriter, { label: 'include note widget' } ); } } ); + + // Handle boxSize attribute changes on existing elements + conversion.for( 'editingDowncast' ).add( dispatcher => { + dispatcher.on( 'attribute:boxSize:includeNote', ( evt, data, conversionApi ) => { + const viewElement = conversionApi.mapper.toViewElement( data.item ); + if ( !viewElement ) { + return; + } + + const viewWriter = conversionApi.writer; + const oldBoxSize = data.attributeOldValue as string; + const newBoxSize = data.attributeNewValue as string; + + // Remove old class and add new class + if ( oldBoxSize ) { + viewWriter.removeClass( 'box-size-' + oldBoxSize, viewElement ); + } + if ( newBoxSize ) { + viewWriter.addClass( 'box-size-' + newBoxSize, viewElement ); + viewWriter.setAttribute( 'data-box-size', newBoxSize, viewElement ); + } + } ); + } ); } } @@ -154,6 +187,43 @@ class InsertIncludeNoteCommand extends Command { } } +class IncludeNoteBoxSizeCommand extends Command { + declare value: BoxSizeValue | null; + + override execute( options: { value: BoxSizeValue } ) { + console.log("[IncludeNoteBoxSizeCommand] execute called with:", options); + const model = this.editor.model; + const includeNoteElement = this._getSelectedIncludeNote(); + + if ( includeNoteElement ) { + model.change( writer => { + writer.setAttribute( 'boxSize', options.value, includeNoteElement ); + } ); + } + } + + override refresh() { + const includeNoteElement = this._getSelectedIncludeNote(); + + this.isEnabled = !!includeNoteElement; + this.value = includeNoteElement?.getAttribute( 'boxSize' ) as BoxSizeValue | null ?? null; + console.log("[IncludeNoteBoxSizeCommand] refresh - isEnabled:", this.isEnabled, "value:", this.value); + } + + private _getSelectedIncludeNote() { + const selection = this.editor.model.document.selection; + const selectedElement = selection.getSelectedElement(); + + if ( selectedElement?.name === 'includeNote' ) { + return selectedElement; + } + + // Check if we're inside an include note + const firstPosition = selection.getFirstPosition(); + return firstPosition?.findAncestor( 'includeNote' ) ?? null; + } +} + /** * Hack coming from https://github.com/ckeditor/ckeditor5/issues/4465 * Source issue: https://github.com/zadam/trilium/issues/1117 @@ -163,7 +233,15 @@ function preventCKEditorHandling( domElement: HTMLElement, editor: Editor ) { // commenting out click events to allow link click handler to still work //domElement.addEventListener( 'click', stopEventPropagationAndHackRendererFocus, { capture: true } ); - domElement.addEventListener( 'mousedown', stopEventPropagationAndHackRendererFocus, { capture: true } ); + domElement.addEventListener( 'mousedown', ( evt: Event ) => { + evt.stopPropagation(); + // This prevents rendering changed view selection thus preventing to changing DOM selection while inside a widget. + //@ts-expect-error: We are accessing a private field. + editor.editing.view._renderer.isFocused = false; + + // Select the widget when clicking inside it + selectIncludeNoteWidget( domElement, editor ); + }, { capture: true } ); domElement.addEventListener( 'focus', stopEventPropagationAndHackRendererFocus, { capture: true } ); // Prevents TAB handling or other editor keys listeners which might be executed on editors selection. @@ -176,3 +254,28 @@ function preventCKEditorHandling( domElement: HTMLElement, editor: Editor ) { editor.editing.view._renderer.isFocused = false; } } + +function selectIncludeNoteWidget( domElement: HTMLElement, editor: Editor ) { + // Find the parent section element (the widget container) + const sectionElement = domElement.closest( 'section.include-note' ) as HTMLElement | null; + if ( !sectionElement ) { + return; + } + + // Get the view element from the DOM element + const viewElement = editor.editing.view.domConverter.mapDomToView( sectionElement ); + if ( !viewElement || !viewElement.is( 'element' ) ) { + return; + } + + // Get the model element from the view element + const modelElement = editor.editing.mapper.toModelElement( viewElement ); + if ( !modelElement ) { + return; + } + + // Select the model element + editor.model.change( writer => { + writer.setSelection( modelElement, 'on' ); + } ); +}