316 lines
9.8 KiB
TypeScript
316 lines
9.8 KiB
TypeScript
|
|
/*
|
||
|
|
* 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,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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', action: 'custom',
|
||
|
|
handler: () => {
|
||
|
|
this.editor.getState() === 'view'
|
||
|
|
? this.editor.wysiwyg()
|
||
|
|
: this.editor.view();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
this.register('markdown', {
|
||
|
|
label: 'Source', action: 'custom',
|
||
|
|
handler: () => {
|
||
|
|
this.editor.getState() === 'edit'
|
||
|
|
? this.editor.wysiwyg()
|
||
|
|
: this.editor.edit();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
this.layout = layout || this.defaultLayout();
|
||
|
|
}
|
||
|
|
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
}
|