ribbit/src/ts/toolbar.ts

536 lines
17 KiB
TypeScript
Raw Normal View History

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';
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;
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
}
/**
* 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();
}
/**
* 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) {
this.element.style.display = CSS_DISPLAY_NONE;
2026-04-28 23:08:20 -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 = '';
}
}
}
/**
* 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();
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-28 23:08:20 -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}`,
action: 'prefix',
delimiter: '#'.repeat(level) + ' ',
replaceSelection: true,
});
}
}
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;
}
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-28 23:08:20 -07:00
private registerUtilityButtons(): void {
2026-04-28 23:08:20 -07:00
this.register('save', {
label: 'Save',
shortcut: 'Ctrl+S',
action: 'custom',
2026-04-28 23:08:20 -07:00
handler: () => this.editor.save(),
});
this.register('toggle', {
label: 'Edit',
shortcut: 'Ctrl+Shift+V',
action: 'custom',
2026-04-28 23:08:20 -07:00
handler: () => {
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', {
label: 'Source',
shortcut: 'Ctrl+/',
action: 'custom',
2026-04-28 23:08:20 -07:00
handler: () => {
if (this.editor.getState() === EDITOR_STATE_EDIT) {
this.editor.wysiwyg();
} else {
this.editor.edit();
}
2026-04-28 23:08:20 -07:00
},
});
}
/**
* Builds a keyboard shortcut lookup and dispatches matching
* button actions on keydown events.
*/
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) => {
const combo = this.buildKeyCombo(event);
const button = shortcutMap.get(combo);
if (button) {
event.preventDefault();
this.executeAction(button);
}
});
2026-04-28 23:08:20 -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;
}
this.buttons.set(id, new ButtonImpl({ id, ...definition }));
2026-04-28 23:08:20 -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()) {
if (UTILITY_BUTTON_IDS.includes(id)) {
2026-04-28 23:08:20 -07:00
continue;
}
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('');
slots.push({
group: 'Macros',
items: macroIds,
});
2026-04-28 23:08:20 -07:00
}
slots.push('', 'markdown', 'save', 'toggle');
return slots;
}
/**
* 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) {
button.element?.classList.toggle(CSS_CLASS_ACTIVE, activeTagNames.includes(id));
2026-04-28 23:08:20 -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()) {
button.element?.classList.remove(CSS_CLASS_DISABLED);
2026-04-28 23:08:20 -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()) {
button.element?.classList.add(CSS_CLASS_DISABLED);
2026-04-28 23:08:20 -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');
nav.className = CSS_CLASS_TOOLBAR;
const list = document.createElement('ul');
2026-04-28 23:08:20 -07:00
for (const slot of this.layout) {
const element = this.renderSlot(slot);
if (element) {
list.appendChild(element);
2026-04-28 23:08:20 -07:00
}
}
nav.appendChild(list);
2026-04-28 23:08:20 -07:00
return nav;
}
/** 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 {
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) {
listItem.style.display = CSS_DISPLAY_NONE;
2026-04-28 23:08:20 -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 {
const listItem = document.createElement('li');
2026-04-28 23:08:20 -07:00
const toggle = document.createElement('button');
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');
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) {
const buttonElement = this.renderDropdownItem(button, menu);
menu.appendChild(buttonElement);
2026-04-28 23:08:20 -07:00
}
toggle.addEventListener('click', () => {
menu.style.display = menu.style.display === CSS_DISPLAY_NONE ? '' : CSS_DISPLAY_NONE;
2026-04-28 23:08:20 -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();
}
/** Wraps the current selection with the given delimiter on both sides. */
2026-04-28 23:08:20 -07:00
private wrapSelection(delimiter: string): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
2026-04-28 23:08:20 -07:00
return;
}
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));
}
/** 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 {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
2026-04-28 23:08:20 -07:00
return;
}
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));
}
}