2026-04-28 23:08:20 -07:00
|
|
|
/*
|
|
|
|
|
* toolbar.ts — toolbar manager for the ribbit editor.
|
|
|
|
|
*
|
|
|
|
|
* Resolves tags and macros into toolbar buttons. Renders the toolbar
|
|
|
|
|
* DOM. Manages button state (active/disabled/visible).
|
|
|
|
|
*
|
|
|
|
|
* Usage:
|
|
|
|
|
* const toolbar = editor.toolbar;
|
|
|
|
|
* toolbar.buttons.get('bold').click();
|
|
|
|
|
* toolbar.buttons.get('table').hide();
|
|
|
|
|
* document.body.prepend(toolbar.render());
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { Tag, ToolbarSlot, Button } from './types';
|
|
|
|
|
import type { MacroDef } from './macros';
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
const CSS_CLASS_ACTIVE = 'active';
|
|
|
|
|
const CSS_CLASS_DISABLED = 'disabled';
|
|
|
|
|
const CSS_CLASS_TOOLBAR = 'ribbit-toolbar';
|
|
|
|
|
const CSS_CLASS_SPACER = 'spacer';
|
|
|
|
|
const CSS_CLASS_GROUP = 'ribbit-btn-group';
|
|
|
|
|
const CSS_CLASS_DROPDOWN = 'ribbit-dropdown';
|
|
|
|
|
const CSS_DISPLAY_NONE = 'none';
|
|
|
|
|
const MACRO_ID_PREFIX = 'macro:';
|
|
|
|
|
const DROPDOWN_INDICATOR = ' ▾';
|
|
|
|
|
|
|
|
|
|
/** IDs of buttons that belong in the utility section, not the tag/macro area. */
|
|
|
|
|
const UTILITY_BUTTON_IDS = ['save', 'toggle', 'markdown'];
|
|
|
|
|
|
|
|
|
|
const MAX_HEADING_LEVEL = 6;
|
|
|
|
|
|
|
|
|
|
const EDITOR_STATE_VIEW = 'view';
|
|
|
|
|
const EDITOR_STATE_EDIT = 'edit';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Concrete implementation of the Button interface.
|
|
|
|
|
*
|
|
|
|
|
* Wraps a button definition with DOM element tracking and
|
|
|
|
|
* visibility toggling. Created internally by ToolbarManager.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* const button = new ButtonImpl({ id: 'bold', label: 'Bold', action: 'wrap', delimiter: '**' });
|
|
|
|
|
* button.hide();
|
|
|
|
|
* button.show();
|
|
|
|
|
*/
|
2026-04-28 23:08:20 -07:00
|
|
|
class ButtonImpl implements Button {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
icon?: string;
|
|
|
|
|
shortcut?: string;
|
|
|
|
|
action: 'wrap' | 'prefix' | 'insert' | 'custom';
|
|
|
|
|
delimiter?: string;
|
|
|
|
|
template?: string;
|
|
|
|
|
replaceSelection: boolean;
|
|
|
|
|
visible: boolean;
|
|
|
|
|
element?: HTMLElement;
|
|
|
|
|
handler?: () => void;
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
constructor(definition: Partial<Button> & { id: string }) {
|
|
|
|
|
this.id = definition.id;
|
|
|
|
|
this.label = definition.label || definition.id;
|
|
|
|
|
this.icon = definition.icon;
|
|
|
|
|
this.shortcut = definition.shortcut;
|
|
|
|
|
this.action = definition.action || 'insert';
|
|
|
|
|
this.delimiter = definition.delimiter;
|
|
|
|
|
this.template = definition.template;
|
|
|
|
|
this.replaceSelection = definition.replaceSelection ?? true;
|
|
|
|
|
this.visible = definition.visible ?? true;
|
|
|
|
|
this.handler = definition.handler;
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Programmatically trigger this button's click event.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* toolbar.buttons.get('bold')?.click();
|
|
|
|
|
*/
|
2026-04-28 23:08:20 -07:00
|
|
|
click(): void {
|
|
|
|
|
this.element?.click();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Hide this button from the toolbar.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* toolbar.buttons.get('table')?.hide();
|
|
|
|
|
*/
|
2026-04-28 23:08:20 -07:00
|
|
|
hide(): void {
|
|
|
|
|
this.visible = false;
|
|
|
|
|
if (this.element) {
|
2026-04-29 15:48:36 -07:00
|
|
|
this.element.style.display = CSS_DISPLAY_NONE;
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Show this button in the toolbar.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* toolbar.buttons.get('table')?.show();
|
|
|
|
|
*/
|
2026-04-28 23:08:20 -07:00
|
|
|
show(): void {
|
|
|
|
|
this.visible = true;
|
|
|
|
|
if (this.element) {
|
|
|
|
|
this.element.style.display = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Manages the editor toolbar: registers buttons from tags and macros,
|
|
|
|
|
* renders the toolbar DOM, handles keyboard shortcuts, and tracks
|
|
|
|
|
* active/disabled state.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* const manager = new ToolbarManager(editor, tags, macros);
|
|
|
|
|
* document.body.prepend(manager.render());
|
|
|
|
|
* manager.updateActiveState(['bold', 'italic']);
|
|
|
|
|
*/
|
2026-04-28 23:08:20 -07:00
|
|
|
export class ToolbarManager {
|
|
|
|
|
buttons: Map<string, Button>;
|
|
|
|
|
private layout: ToolbarSlot[];
|
|
|
|
|
private editor: any;
|
|
|
|
|
|
|
|
|
|
constructor(editor: any, tags: Record<string, Tag>, macros: MacroDef[], layout?: ToolbarSlot[]) {
|
|
|
|
|
this.editor = editor;
|
|
|
|
|
this.buttons = new Map();
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
this.registerTagButtons(tags);
|
|
|
|
|
this.registerHeadingButtons();
|
|
|
|
|
this.registerListButtons();
|
|
|
|
|
this.registerMacroButtons(macros);
|
|
|
|
|
this.registerUtilityButtons();
|
|
|
|
|
|
|
|
|
|
this.layout = layout || this.buildDefaultLayout();
|
|
|
|
|
this.bindShortcuts();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Register buttons for tags that have button config enabled. */
|
|
|
|
|
private registerTagButtons(tags: Record<string, Tag>): void {
|
2026-04-28 23:08:20 -07:00
|
|
|
for (const tag of Object.values(tags)) {
|
|
|
|
|
if (!tag.button || !tag.button.show) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
this.register(tag.name, {
|
|
|
|
|
label: tag.button.label,
|
|
|
|
|
icon: tag.button.icon,
|
|
|
|
|
shortcut: tag.button.shortcut,
|
|
|
|
|
action: tag.delimiter ? 'wrap' : 'insert',
|
|
|
|
|
delimiter: tag.delimiter,
|
|
|
|
|
template: tag.template,
|
|
|
|
|
replaceSelection: tag.replaceSelection,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
}
|
2026-04-28 23:08:20 -07:00
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/** Heading levels are derived from a single pattern rather than repeated blocks. */
|
|
|
|
|
private registerHeadingButtons(): void {
|
|
|
|
|
for (let level = 1; level <= MAX_HEADING_LEVEL; level++) {
|
|
|
|
|
this.register(`h${level}`, {
|
|
|
|
|
label: `H${level}`,
|
|
|
|
|
shortcut: `Ctrl+${level}`,
|
2026-04-29 00:22:00 -07:00
|
|
|
action: 'prefix',
|
2026-04-29 15:48:36 -07:00
|
|
|
delimiter: '#'.repeat(level) + ' ',
|
2026-04-29 00:22:00 -07:00
|
|
|
replaceSelection: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
}
|
2026-04-29 00:22:00 -07:00
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
private registerListButtons(): void {
|
|
|
|
|
const listDefinitions: Array<{ id: string; label: string; shortcut: string; template: string }> = [
|
|
|
|
|
{
|
|
|
|
|
id: 'ul',
|
|
|
|
|
label: 'Bullet List',
|
|
|
|
|
shortcut: 'Ctrl+Shift+8',
|
|
|
|
|
template: '- Item 1\n- Item 2\n- Item 3',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'ol',
|
|
|
|
|
label: 'Numbered List',
|
|
|
|
|
shortcut: 'Ctrl+Shift+7',
|
|
|
|
|
template: '1. Item 1\n2. Item 2\n3. Item 3',
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const definition of listDefinitions) {
|
|
|
|
|
this.register(definition.id, {
|
|
|
|
|
label: definition.label,
|
|
|
|
|
shortcut: definition.shortcut,
|
|
|
|
|
action: 'insert',
|
|
|
|
|
template: definition.template,
|
|
|
|
|
replaceSelection: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private registerMacroButtons(macros: MacroDef[]): void {
|
2026-04-28 23:08:20 -07:00
|
|
|
for (const macro of macros) {
|
|
|
|
|
if (macro.button === false) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
const buttonConfig = typeof macro.button === 'object' ? macro.button : null;
|
|
|
|
|
const capitalizedName = macro.name.charAt(0).toUpperCase() + macro.name.slice(1);
|
|
|
|
|
this.register(`${MACRO_ID_PREFIX}${macro.name}`, {
|
|
|
|
|
label: buttonConfig?.label || capitalizedName,
|
|
|
|
|
icon: buttonConfig?.icon,
|
2026-04-28 23:08:20 -07:00
|
|
|
action: 'insert',
|
|
|
|
|
template: `@${macro.name}`,
|
|
|
|
|
replaceSelection: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
}
|
2026-04-28 23:08:20 -07:00
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
private registerUtilityButtons(): void {
|
2026-04-28 23:08:20 -07:00
|
|
|
this.register('save', {
|
2026-04-29 15:48:36 -07:00
|
|
|
label: 'Save',
|
|
|
|
|
shortcut: 'Ctrl+S',
|
|
|
|
|
action: 'custom',
|
2026-04-28 23:08:20 -07:00
|
|
|
handler: () => this.editor.save(),
|
|
|
|
|
});
|
|
|
|
|
this.register('toggle', {
|
2026-04-29 15:48:36 -07:00
|
|
|
label: 'Edit',
|
|
|
|
|
shortcut: 'Ctrl+Shift+V',
|
|
|
|
|
action: 'custom',
|
2026-04-28 23:08:20 -07:00
|
|
|
handler: () => {
|
2026-04-29 15:48:36 -07:00
|
|
|
if (this.editor.getState() === EDITOR_STATE_VIEW) {
|
|
|
|
|
this.editor.wysiwyg();
|
|
|
|
|
} else {
|
|
|
|
|
this.editor.view();
|
|
|
|
|
}
|
2026-04-28 23:08:20 -07:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
this.register('markdown', {
|
2026-04-29 15:48:36 -07:00
|
|
|
label: 'Source',
|
|
|
|
|
shortcut: 'Ctrl+/',
|
|
|
|
|
action: 'custom',
|
2026-04-28 23:08:20 -07:00
|
|
|
handler: () => {
|
2026-04-29 15:48:36 -07:00
|
|
|
if (this.editor.getState() === EDITOR_STATE_EDIT) {
|
|
|
|
|
this.editor.wysiwyg();
|
|
|
|
|
} else {
|
|
|
|
|
this.editor.edit();
|
|
|
|
|
}
|
2026-04-28 23:08:20 -07:00
|
|
|
},
|
|
|
|
|
});
|
2026-04-29 00:22:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 15:48:36 -07:00
|
|
|
* Builds a keyboard shortcut lookup and dispatches matching
|
|
|
|
|
* button actions on keydown events.
|
2026-04-29 00:22:00 -07:00
|
|
|
*/
|
|
|
|
|
private bindShortcuts(): void {
|
|
|
|
|
const shortcutMap = new Map<string, Button>();
|
|
|
|
|
for (const button of this.buttons.values()) {
|
|
|
|
|
if (button.shortcut) {
|
|
|
|
|
shortcutMap.set(button.shortcut.toLowerCase(), button);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
2026-04-29 15:48:36 -07:00
|
|
|
const combo = this.buildKeyCombo(event);
|
2026-04-29 00:22:00 -07:00
|
|
|
const button = shortcutMap.get(combo);
|
|
|
|
|
if (button) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.executeAction(button);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/** Normalizes a KeyboardEvent into a comparable shortcut string like "ctrl+shift+b". */
|
|
|
|
|
private buildKeyCombo(event: KeyboardEvent): string {
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
|
|
|
parts.push('ctrl');
|
|
|
|
|
}
|
|
|
|
|
if (event.shiftKey) {
|
|
|
|
|
parts.push('shift');
|
|
|
|
|
}
|
|
|
|
|
if (event.altKey) {
|
|
|
|
|
parts.push('alt');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Special keys pass through as-is; letter keys are lowercased
|
|
|
|
|
const specialKeys = ['/', '.', '-'];
|
|
|
|
|
const key = specialKeys.includes(event.key) ? event.key : event.key.toLowerCase();
|
|
|
|
|
|
|
|
|
|
parts.push(key);
|
|
|
|
|
return parts.join('+');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private register(id: string, definition: Partial<Button>): void {
|
2026-04-28 23:08:20 -07:00
|
|
|
if (this.buttons.has(id)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
this.buttons.set(id, new ButtonImpl({ id, ...definition }));
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
private buildDefaultLayout(): ToolbarSlot[] {
|
2026-04-28 23:08:20 -07:00
|
|
|
const tagIds: string[] = [];
|
|
|
|
|
const macroIds: string[] = [];
|
|
|
|
|
for (const id of this.buttons.keys()) {
|
2026-04-29 15:48:36 -07:00
|
|
|
if (UTILITY_BUTTON_IDS.includes(id)) {
|
2026-04-28 23:08:20 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
if (id.startsWith(MACRO_ID_PREFIX)) {
|
2026-04-28 23:08:20 -07:00
|
|
|
macroIds.push(id);
|
|
|
|
|
} else {
|
|
|
|
|
tagIds.push(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const slots: ToolbarSlot[] = [...tagIds];
|
|
|
|
|
if (macroIds.length > 0) {
|
|
|
|
|
slots.push('');
|
2026-04-29 15:48:36 -07:00
|
|
|
slots.push({
|
|
|
|
|
group: 'Macros',
|
|
|
|
|
items: macroIds,
|
|
|
|
|
});
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
slots.push('', 'markdown', 'save', 'toggle');
|
|
|
|
|
return slots;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 15:48:36 -07:00
|
|
|
* Toggle the active CSS class on buttons whose IDs appear in the
|
|
|
|
|
* given list of currently-active tag names.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* manager.updateActiveState(['bold', 'italic']);
|
2026-04-28 23:08:20 -07:00
|
|
|
*/
|
|
|
|
|
updateActiveState(activeTagNames: string[]): void {
|
|
|
|
|
for (const [id, button] of this.buttons) {
|
2026-04-29 15:48:36 -07:00
|
|
|
button.element?.classList.toggle(CSS_CLASS_ACTIVE, activeTagNames.includes(id));
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 15:48:36 -07:00
|
|
|
* Enable all toolbar buttons by removing the disabled CSS class.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* manager.enable();
|
2026-04-28 23:08:20 -07:00
|
|
|
*/
|
|
|
|
|
enable(): void {
|
|
|
|
|
for (const button of this.buttons.values()) {
|
2026-04-29 15:48:36 -07:00
|
|
|
button.element?.classList.remove(CSS_CLASS_DISABLED);
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 15:48:36 -07:00
|
|
|
* Disable all toolbar buttons by adding the disabled CSS class.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* manager.disable();
|
2026-04-28 23:08:20 -07:00
|
|
|
*/
|
|
|
|
|
disable(): void {
|
|
|
|
|
for (const button of this.buttons.values()) {
|
2026-04-29 15:48:36 -07:00
|
|
|
button.element?.classList.add(CSS_CLASS_DISABLED);
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 15:48:36 -07:00
|
|
|
* Build the toolbar DOM tree and return the root element.
|
|
|
|
|
* The caller is responsible for inserting it into the document.
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* document.body.prepend(manager.render());
|
2026-04-28 23:08:20 -07:00
|
|
|
*/
|
|
|
|
|
render(): HTMLElement {
|
|
|
|
|
const nav = document.createElement('nav');
|
2026-04-29 15:48:36 -07:00
|
|
|
nav.className = CSS_CLASS_TOOLBAR;
|
|
|
|
|
const list = document.createElement('ul');
|
2026-04-28 23:08:20 -07:00
|
|
|
|
|
|
|
|
for (const slot of this.layout) {
|
2026-04-29 15:48:36 -07:00
|
|
|
const element = this.renderSlot(slot);
|
|
|
|
|
if (element) {
|
|
|
|
|
list.appendChild(element);
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
nav.appendChild(list);
|
2026-04-28 23:08:20 -07:00
|
|
|
return nav;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/** Dispatches a single layout slot to the appropriate renderer. */
|
|
|
|
|
private renderSlot(slot: ToolbarSlot): HTMLElement | null {
|
|
|
|
|
if (slot === '') {
|
|
|
|
|
return this.renderSpacer();
|
|
|
|
|
}
|
|
|
|
|
if (typeof slot === 'string') {
|
|
|
|
|
return this.renderStringSlot(slot);
|
|
|
|
|
}
|
|
|
|
|
return this.renderGroupSlot(slot);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderSpacer(): HTMLElement {
|
|
|
|
|
const listItem = document.createElement('li');
|
|
|
|
|
listItem.className = CSS_CLASS_SPACER;
|
|
|
|
|
return listItem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderStringSlot(slot: string): HTMLElement | null {
|
|
|
|
|
if (slot === 'macros') {
|
|
|
|
|
const items = [...this.buttons.values()].filter(button => button.id.startsWith(MACRO_ID_PREFIX));
|
|
|
|
|
if (items.length > 0) {
|
|
|
|
|
return this.renderGroup({
|
|
|
|
|
label: 'Macros',
|
|
|
|
|
items,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const button = this.buttons.get(slot);
|
|
|
|
|
if (button) {
|
|
|
|
|
return this.renderButton(button);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderGroupSlot(slot: { group: string; items: string[] }): HTMLElement | null {
|
|
|
|
|
const items = slot.items
|
|
|
|
|
.map(id => this.buttons.get(id))
|
|
|
|
|
.filter((button): button is Button => button !== undefined);
|
|
|
|
|
if (items.length > 0) {
|
|
|
|
|
return this.renderGroup({
|
|
|
|
|
label: slot.group,
|
|
|
|
|
items,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 23:08:20 -07:00
|
|
|
private renderButton(button: Button): HTMLElement {
|
2026-04-29 15:48:36 -07:00
|
|
|
const listItem = document.createElement('li');
|
|
|
|
|
const buttonElement = document.createElement('button');
|
|
|
|
|
buttonElement.className = `ribbit-btn-${button.id}`;
|
|
|
|
|
buttonElement.textContent = button.label;
|
|
|
|
|
buttonElement.setAttribute('aria-label', button.label);
|
|
|
|
|
buttonElement.title = button.shortcut
|
2026-04-28 23:08:20 -07:00
|
|
|
? `${button.label} (${button.shortcut})`
|
|
|
|
|
: button.label;
|
|
|
|
|
if (!button.visible) {
|
2026-04-29 15:48:36 -07:00
|
|
|
listItem.style.display = CSS_DISPLAY_NONE;
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
buttonElement.addEventListener('click', () => this.executeAction(button));
|
|
|
|
|
button.element = buttonElement;
|
|
|
|
|
listItem.appendChild(buttonElement);
|
|
|
|
|
return listItem;
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
|
2026-04-29 15:48:36 -07:00
|
|
|
const listItem = document.createElement('li');
|
2026-04-28 23:08:20 -07:00
|
|
|
const toggle = document.createElement('button');
|
2026-04-29 15:48:36 -07:00
|
|
|
toggle.className = CSS_CLASS_GROUP;
|
|
|
|
|
toggle.textContent = group.label + DROPDOWN_INDICATOR;
|
2026-04-28 23:08:20 -07:00
|
|
|
toggle.setAttribute('aria-label', group.label);
|
|
|
|
|
toggle.title = group.label;
|
|
|
|
|
|
|
|
|
|
const menu = document.createElement('div');
|
2026-04-29 15:48:36 -07:00
|
|
|
menu.className = CSS_CLASS_DROPDOWN;
|
|
|
|
|
menu.style.display = CSS_DISPLAY_NONE;
|
2026-04-28 23:08:20 -07:00
|
|
|
|
|
|
|
|
for (const button of group.items) {
|
2026-04-29 15:48:36 -07:00
|
|
|
const buttonElement = this.renderDropdownItem(button, menu);
|
|
|
|
|
menu.appendChild(buttonElement);
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggle.addEventListener('click', () => {
|
2026-04-29 15:48:36 -07:00
|
|
|
menu.style.display = menu.style.display === CSS_DISPLAY_NONE ? '' : CSS_DISPLAY_NONE;
|
2026-04-28 23:08:20 -07:00
|
|
|
});
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
listItem.appendChild(toggle);
|
|
|
|
|
listItem.appendChild(menu);
|
|
|
|
|
return listItem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Creates a single button element inside a dropdown menu. */
|
|
|
|
|
private renderDropdownItem(button: Button, menu: HTMLElement): HTMLElement {
|
|
|
|
|
const buttonElement = document.createElement('button');
|
|
|
|
|
buttonElement.className = `ribbit-btn-${button.id}`;
|
|
|
|
|
buttonElement.setAttribute('aria-label', button.label);
|
|
|
|
|
buttonElement.title = button.label;
|
|
|
|
|
buttonElement.textContent = button.label;
|
|
|
|
|
if (!button.visible) {
|
|
|
|
|
buttonElement.style.display = CSS_DISPLAY_NONE;
|
|
|
|
|
}
|
|
|
|
|
buttonElement.addEventListener('click', () => {
|
|
|
|
|
this.executeAction(button);
|
|
|
|
|
menu.style.display = CSS_DISPLAY_NONE;
|
|
|
|
|
});
|
|
|
|
|
button.element = buttonElement;
|
|
|
|
|
return buttonElement;
|
2026-04-28 23:08:20 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private executeAction(button: Button): void {
|
|
|
|
|
if (!button.visible) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (button.handler) {
|
|
|
|
|
button.handler();
|
|
|
|
|
this.editor.element.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (button.action === 'wrap' && button.delimiter) {
|
|
|
|
|
this.wrapSelection(button.delimiter);
|
|
|
|
|
} else if (button.action === 'insert' && button.template) {
|
|
|
|
|
this.insertText(button.template, button.replaceSelection);
|
|
|
|
|
}
|
|
|
|
|
this.editor.invalidateCache();
|
|
|
|
|
this.editor.element.focus();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/** Wraps the current selection with the given delimiter on both sides. */
|
2026-04-28 23:08:20 -07:00
|
|
|
private wrapSelection(delimiter: string): void {
|
2026-04-29 15:48:36 -07:00
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (!selection || selection.rangeCount === 0) {
|
2026-04-28 23:08:20 -07:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
const range = selection.getRangeAt(0);
|
2026-04-28 23:08:20 -07:00
|
|
|
const text = range.toString();
|
|
|
|
|
range.deleteContents();
|
|
|
|
|
range.insertNode(document.createTextNode(delimiter + text + delimiter));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/** Inserts text at the cursor, optionally replacing the current selection. */
|
2026-04-28 23:08:20 -07:00
|
|
|
private insertText(text: string, replaceSelection: boolean): void {
|
2026-04-29 15:48:36 -07:00
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (!selection || selection.rangeCount === 0) {
|
2026-04-28 23:08:20 -07:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 15:48:36 -07:00
|
|
|
const range = selection.getRangeAt(0);
|
2026-04-28 23:08:20 -07:00
|
|
|
if (replaceSelection) {
|
|
|
|
|
range.deleteContents();
|
|
|
|
|
} else {
|
|
|
|
|
range.collapse(false);
|
|
|
|
|
}
|
|
|
|
|
range.insertNode(document.createTextNode(text));
|
|
|
|
|
}
|
|
|
|
|
}
|