feat(ckeditor/include_note): add a way to change size after creation (closes #3705)

This commit is contained in:
Elian Doran
2026-04-11 12:06:23 +03:00
parent 602bebe498
commit 461abf768c
4 changed files with 240 additions and 1 deletions

View File

@@ -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,
];
/**

View File

@@ -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<ListDropdownButtonDefinition> {
const editor = this.editor;
const command = editor.commands.get(BOX_SIZE_COMMAND_NAME) as Command & { value: BoxSizeValue | null };
const itemDefinitions = new Collection<ListDropdownButtonDefinition>();
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;
}
}

View File

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

View File

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