ribbit/src/ts/toolbar.ts

379 lines
12 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';
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(def: Partial<Button> & { id: string }) {
this.id = def.id;
this.label = def.label || def.id;
this.icon = def.icon;
this.shortcut = def.shortcut;
this.action = def.action || 'insert';
this.delimiter = def.delimiter;
this.template = def.template;
this.replaceSelection = def.replaceSelection ?? true;
this.visible = def.visible ?? true;
this.handler = def.handler;
}
click(): void {
this.element?.click();
}
hide(): void {
this.visible = false;
if (this.element) {
this.element.style.display = 'none';
}
}
show(): void {
this.visible = true;
if (this.element) {
this.element.style.display = '';
}
}
}
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();
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,
});
}
// Heading and list variants (derived from their parent tags)
for (let i = 1; i <= 6; i++) {
this.register(`h${i}`, {
label: `H${i}`,
shortcut: `Ctrl+${i}`,
action: 'prefix',
delimiter: '#'.repeat(i) + ' ',
replaceSelection: true,
});
}
this.register('ul', {
label: 'Bullet List',
shortcut: 'Ctrl+Shift+8',
action: 'insert',
template: '- Item 1\n- Item 2\n- Item 3',
replaceSelection: false,
});
this.register('ol', {
label: 'Numbered List',
shortcut: 'Ctrl+Shift+7',
action: 'insert',
template: '1. Item 1\n2. Item 2\n3. Item 3',
replaceSelection: false,
});
2026-04-28 23:08:20 -07:00
for (const macro of macros) {
if (macro.button === false) {
continue;
}
const btn = typeof macro.button === 'object' ? macro.button : null;
this.register(`macro:${macro.name}`, {
label: btn?.label || macro.name.charAt(0).toUpperCase() + macro.name.slice(1),
icon: btn?.icon,
action: 'insert',
template: `@${macro.name}`,
replaceSelection: false,
});
}
this.register('save', {
label: 'Save', shortcut: 'Ctrl+S', action: 'custom',
handler: () => this.editor.save(),
});
this.register('toggle', {
label: 'Edit', shortcut: 'Ctrl+Shift+V', action: 'custom',
2026-04-28 23:08:20 -07:00
handler: () => {
this.editor.getState() === 'view'
? this.editor.wysiwyg()
: this.editor.view();
},
});
this.register('markdown', {
label: 'Source', shortcut: 'Ctrl+/', action: 'custom',
2026-04-28 23:08:20 -07:00
handler: () => {
this.editor.getState() === 'edit'
? this.editor.wysiwyg()
: this.editor.edit();
},
});
this.layout = layout || this.defaultLayout();
this.bindShortcuts();
}
/**
* Listen for keyboard shortcuts on the document and dispatch
* to the matching toolbar button.
*/
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 parts: string[] = [];
if (event.ctrlKey || event.metaKey) parts.push('ctrl');
if (event.shiftKey) parts.push('shift');
if (event.altKey) parts.push('alt');
let key = event.key;
if (key === '/') key = '/';
else if (key === '.') key = '.';
else if (key === '-') key = '-';
else key = key.toLowerCase();
parts.push(key);
const combo = parts.join('+');
const button = shortcutMap.get(combo);
if (button) {
event.preventDefault();
this.executeAction(button);
}
});
2026-04-28 23:08:20 -07:00
}
private register(id: string, def: Partial<Button>): void {
if (this.buttons.has(id)) {
return;
}
this.buttons.set(id, new ButtonImpl({ id, ...def }));
}
private defaultLayout(): ToolbarSlot[] {
const tagIds: string[] = [];
const macroIds: string[] = [];
for (const id of this.buttons.keys()) {
if (['save', 'toggle', 'markdown'].includes(id)) {
continue;
}
if (id.startsWith('macro:')) {
macroIds.push(id);
} else {
tagIds.push(id);
}
}
const slots: ToolbarSlot[] = [...tagIds];
if (macroIds.length > 0) {
slots.push('');
slots.push({ group: 'Macros', items: macroIds });
}
slots.push('', 'markdown', 'save', 'toggle');
return slots;
}
/**
* Update .active class on buttons matching the cursor's formatting context.
*/
updateActiveState(activeTagNames: string[]): void {
for (const [id, button] of this.buttons) {
button.element?.classList.toggle('active', activeTagNames.includes(id));
}
}
/**
* Enable all toolbar buttons.
*/
enable(): void {
for (const button of this.buttons.values()) {
button.element?.classList.remove('disabled');
}
}
/**
* Disable all toolbar buttons.
*/
disable(): void {
for (const button of this.buttons.values()) {
button.element?.classList.add('disabled');
}
}
/**
* Build the toolbar DOM and return it. Caller inserts it.
*/
render(): HTMLElement {
const nav = document.createElement('nav');
nav.className = 'ribbit-toolbar';
const ul = document.createElement('ul');
for (const slot of this.layout) {
if (slot === '') {
const li = document.createElement('li');
li.className = 'spacer';
ul.appendChild(li);
} else if (typeof slot === 'string') {
if (slot === 'macros') {
const items = [...this.buttons.values()].filter(b => b.id.startsWith('macro:'));
if (items.length > 0) {
ul.appendChild(this.renderGroup({ label: 'Macros', items }));
}
} else {
const button = this.buttons.get(slot);
if (button) {
ul.appendChild(this.renderButton(button));
}
}
} else {
const items = slot.items
.map(id => this.buttons.get(id))
.filter((b): b is Button => b !== undefined);
if (items.length > 0) {
ul.appendChild(this.renderGroup({ label: slot.group, items }));
}
}
}
nav.appendChild(ul);
return nav;
}
private renderButton(button: Button): HTMLElement {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = `ribbit-btn-${button.id}`;
btn.textContent = button.label;
2026-04-28 23:08:20 -07:00
btn.setAttribute('aria-label', button.label);
btn.title = button.shortcut
? `${button.label} (${button.shortcut})`
: button.label;
if (!button.visible) {
li.style.display = 'none';
}
btn.addEventListener('click', () => this.executeAction(button));
button.element = btn;
li.appendChild(btn);
return li;
}
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
const li = document.createElement('li');
const toggle = document.createElement('button');
toggle.className = 'ribbit-btn-group';
toggle.textContent = group.label + ' ▾';
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 = 'ribbit-dropdown';
menu.style.display = 'none';
for (const button of group.items) {
const btn = document.createElement('button');
btn.className = `ribbit-btn-${button.id}`;
btn.setAttribute('aria-label', button.label);
btn.title = button.label;
btn.textContent = button.label;
if (!button.visible) {
btn.style.display = 'none';
}
btn.addEventListener('click', () => {
this.executeAction(button);
menu.style.display = 'none';
});
button.element = btn;
menu.appendChild(btn);
}
toggle.addEventListener('click', () => {
menu.style.display = menu.style.display === 'none' ? '' : 'none';
});
li.appendChild(toggle);
li.appendChild(menu);
return li;
}
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();
}
private wrapSelection(delimiter: string): void {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
const range = sel.getRangeAt(0);
const text = range.toString();
range.deleteContents();
range.insertNode(document.createTextNode(delimiter + text + delimiter));
}
private insertText(text: string, replaceSelection: boolean): void {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
const range = sel.getRangeAt(0);
if (replaceSelection) {
range.deleteContents();
} else {
range.collapse(false);
}
range.insertNode(document.createTextNode(text));
}
}