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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 00:22:00 -07:00
|
|
|
// 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', {
|
2026-04-29 00:22:00 -07:00
|
|
|
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', {
|
2026-04-29 00:22:00 -07:00
|
|
|
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();
|
2026-04-29 00:22:00 -07:00
|
|
|
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.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.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));
|
|
|
|
|
}
|
|
|
|
|
}
|