Implement Toolbar

This commit is contained in:
gsb
2026-04-29 07:11:31 +00:00
parent 98719ec8cd
commit 1f523cbc0f
8 changed files with 694 additions and 122 deletions
+6 -1
View File
@@ -18,7 +18,7 @@
* )
*/
import type { Tag, SourceToken, Converter, MatchContext } from './types';
import type { Tag, Converter, ToolbarButton } from './types';
import { escapeHtml } from './tags';
export interface MacroDef {
@@ -37,6 +37,11 @@ export interface MacroDef {
content?: string;
convert: Converter;
}) => string;
/**
* Toolbar button. Set to false to hide from the macros dropdown.
* Default: auto-generated from the macro name.
*/
button?: ToolbarButton | false;
}
interface ParsedMacro {
+5 -5
View File
@@ -5,7 +5,7 @@
import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme';
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { type MacroDef } from './macros';
/**
@@ -31,10 +31,10 @@ export class RibbitEditor extends Ribbit {
};
this.#bindEvents();
this.plugins().forEach(plugin => {
plugin.setEditable();
});
this.element.classList.add('loaded');
if (this.autoToolbar) {
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
}
this.view();
}
@@ -241,10 +241,10 @@ export class RibbitEditor extends Ribbit {
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
export { RibbitEditor as Editor };
export { Ribbit as Viewer };
export { RibbitPlugin as Plugin };
export { HopDown };
export { inlineTag };
export { defaultTags, defaultBlockTags, defaultInlineTags };
export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export { ToolbarManager } from './toolbar';
export type { MacroDef };
+18 -87
View File
@@ -7,57 +7,24 @@ import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events';
import { type MacroDef } from './macros';
import type { RibbitTheme } from './types';
import { ToolbarManager } from './toolbar';
import type { RibbitTheme, ToolbarSlot } from './types';
export interface RibbitSettings {
api?: unknown;
editorId?: string;
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
currentTheme?: string;
themes?: RibbitTheme[];
themesPath?: string;
macros?: MacroDef[];
toolbar?: ToolbarSlot[];
/** Set to false to prevent auto-rendering the toolbar. Default true. */
autoToolbar?: boolean;
on?: Partial<RibbitEventMap>;
}
/**
* Base class for editor plugins. Subclass and override toHTML/toMarkdown
* to add custom processing hooks.
*/
export class RibbitPlugin {
name: string;
wiki: Ribbit;
precedence: number;
constructor(settings: { name: string; wiki: Ribbit }) {
this.name = settings.name;
this.wiki = settings.wiki;
this.precedence = 50;
}
setEditable(): void {
}
toMarkdown(html: string): string {
return html;
}
toHTML(md: string): string {
return md;
}
}
/**
* Read-only markdown viewer. Renders markdown content into an HTML element.
*
* Usage:
* const viewer = new Ribbit({
* editorId: 'my-element',
* on: {
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
* },
* });
* viewer.run();
*/
export class Ribbit {
api: unknown;
@@ -67,11 +34,12 @@ export class Ribbit {
cachedMarkdown: string | null;
state: string | null;
changed: boolean;
enabledPlugins: Record<string, RibbitPlugin>;
theme: RibbitTheme;
themes: ThemeManager;
converter: HopDown;
themesPath: string;
toolbar: ToolbarManager;
protected autoToolbar: boolean;
private emitter: RibbitEmitter;
private macros: MacroDef[];
@@ -88,7 +56,6 @@ export class Ribbit {
this.cachedMarkdown = null;
this.state = null;
this.changed = false;
this.enabledPlugins = {};
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
this.theme = theme;
@@ -117,13 +84,6 @@ export class Ribbit {
? new HopDown({ tags: this.theme.tags, macros: this.macros })
: new HopDown({ macros: this.macros });
(settings.plugins || []).forEach(plugin => {
this.enabledPlugins[plugin.name] = new plugin({
name: plugin.name,
wiki: this,
});
});
if (settings.on) {
for (const [event, handler] of Object.entries(settings.on)) {
if (handler) {
@@ -131,30 +91,29 @@ export class Ribbit {
}
}
}
this.toolbar = new ToolbarManager(
this,
this.theme.tags || {},
this.macros,
settings.toolbar,
);
this.autoToolbar = settings.autoToolbar !== false;
}
/**
* Register a callback for an event.
*
* editor.on('save', ({ markdown }) => {
* fetch('/api/save', { method: 'POST', body: markdown });
* });
*/
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.on(event, callback);
}
/**
* Remove a previously registered callback.
*
* editor.off('change', myHandler);
*/
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.off(event, callback);
}
run(): void {
this.element.classList.add('loaded');
if (this.autoToolbar) {
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
}
this.view();
this.emitter.emit('ready', {
markdown: this.getMarkdown(),
@@ -164,10 +123,6 @@ export class Ribbit {
});
}
plugins(): RibbitPlugin[] {
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
}
getState(): string | null {
return this.state;
}
@@ -203,12 +158,6 @@ export class Ribbit {
return this.cachedMarkdown;
}
/**
* Request a save. Fires the 'save' event with the current content.
* The consumer's callback handles persistence.
*
* editor.save(); // triggers on.save({ markdown, html })
*/
save(): void {
this.emitter.emit('save', {
markdown: this.getMarkdown(),
@@ -223,20 +172,12 @@ export class Ribbit {
this.element.contentEditable = 'false';
}
/**
* Invalidate cached markdown and HTML. Called when content changes.
* The next call to getMarkdown() or getHTML() will recompute.
*/
invalidateCache(): void {
this.changed = true;
this.cachedMarkdown = null;
this.cachedHTML = null;
}
/**
* Notify that content has changed. Called internally by the editor
* on input events. Fires the 'change' event with current content.
*/
notifyChange(): void {
this.emitter.emit('change', {
markdown: this.getMarkdown(),
@@ -245,10 +186,6 @@ export class Ribbit {
}
}
/**
* Convert a string to title case, splitting on whitespace.
* Returns an array of capitalized words.
*/
export function camelCase(words: string): string[] {
return words.trim().split(/\s+/g).map(word => {
const lc = word.toLowerCase();
@@ -256,18 +193,12 @@ export function camelCase(words: string): string[] {
});
}
/**
* Decode HTML entities in a string using a textarea element.
*/
export function decodeHtmlEntities(html: string): string {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
}
/**
* Encode HTML-significant characters as numeric entities.
*/
export function encodeHtmlEntities(str: string): string {
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
}
+28 -1
View File
@@ -5,7 +5,7 @@
* with rules for matching, converting to HTML, and converting back.
*/
import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, InlineTagDef } from './types';
import type { Tag, Converter, ListItem, ListResult, InlineTagDef } from './types';
/**
* Create a Tag from a shorthand inline definition.
@@ -35,6 +35,13 @@ export function inlineTag(def: InlineTagDef): Tag {
pattern: globalPattern,
openPattern,
delimiter: def.delimiter,
template: `${def.delimiter}text${def.delimiter}`,
replaceSelection: true,
button: def.button === false ? undefined : {
show: true,
label: def.name.charAt(0).toUpperCase() + def.name.slice(1),
...(typeof def.button === 'object' ? def.button : {}),
},
match: (context) => {
const matched = context.text.slice(context.offset).match(matchPattern);
if (!matched) {
@@ -180,6 +187,9 @@ export const defaultBlockTags: Record<string, Tag> = {
* ```
*/
name: 'fencedCode',
button: { show: true, label: 'Code Block' },
template: '```\ncode\n```',
replaceSelection: true,
match: (context) => {
const matched = context.lines[context.index].match(/^(`{3,})(.*)/);
if (!matched) return null;
@@ -215,6 +225,9 @@ export const defaultBlockTags: Record<string, Tag> = {
* ___
*/
name: 'hr',
button: { show: true, label: 'Divider' },
template: '---',
replaceSelection: false,
match: (context) => {
if (!/^(\*{3,}|-{3,}|_{3,})\s*$/.test(context.lines[context.index])) {
return null;
@@ -237,6 +250,7 @@ export const defaultBlockTags: Record<string, Tag> = {
* ### Heading 3
*/
name: 'heading',
button: { show: false, label: 'Heading' },
match: (context) => {
const matched = context.lines[context.index].match(/^(#{1,6})\s+(.*)/);
if (!matched) return null;
@@ -262,6 +276,9 @@ export const defaultBlockTags: Record<string, Tag> = {
* > more quoted text
*/
name: 'blockquote',
button: { show: true, label: 'Quote' },
template: '> Quote\n> continues here',
replaceSelection: true,
match: (context) => {
if (!/^>\s?/.test(context.lines[context.index])) return null;
const lines: string[] = [];
@@ -290,6 +307,7 @@ export const defaultBlockTags: Record<string, Tag> = {
* 2. ordered item
*/
name: 'list',
button: { show: false, label: 'List' },
match: (context) => {
const line = context.lines[context.index];
if (!/^[*\-]\s/.test(line) && !/^\d+\.\s/.test(line)) return null;
@@ -312,6 +330,9 @@ export const defaultBlockTags: Record<string, Tag> = {
* | cell 1 | cell 2 |
*/
name: 'table',
button: { show: true, label: 'Table' },
template: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |',
replaceSelection: false,
match: (context) => {
const { lines, index } = context;
if (lines[index].indexOf('|') === -1 || index + 1 >= lines.length) return null;
@@ -405,6 +426,7 @@ export const defaultInlineTags: Record<string, Tag> = {
htmlTag: 'code',
precedence: 10,
recursive: false,
button: { show: true, label: 'Code', shortcut: 'Ctrl+E' },
}),
'A': {
@@ -412,6 +434,9 @@ export const defaultInlineTags: Record<string, Tag> = {
* [link text](http://example.com)
*/
name: 'link',
button: { show: true, label: 'Link', shortcut: 'Ctrl+K' },
template: '[text](url)',
replaceSelection: true,
match: (context) => {
const matched = context.text.slice(context.offset).match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (!matched) {
@@ -458,6 +483,7 @@ export const defaultInlineTags: Record<string, Tag> = {
htmlTag: 'strong',
aliases: 'B',
precedence: 40,
button: { show: true, label: 'Bold', shortcut: 'Ctrl+B' },
}),
'EM,I': inlineTag({
@@ -469,6 +495,7 @@ export const defaultInlineTags: Record<string, Tag> = {
htmlTag: 'em',
aliases: 'I',
precedence: 50,
button: { show: true, label: 'Italic', shortcut: 'Ctrl+I' },
}),
};
+315
View File
@@ -0,0 +1,315 @@
/*
* 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));
}
}
+44 -17
View File
@@ -1,5 +1,5 @@
/*
* types.ts — shared types for the hopdown converter.
* types.ts — shared types for the ribbit editor.
*/
export interface SourceToken {
@@ -23,28 +23,27 @@ export interface MatchContext {
offset: number;
}
export interface ToolbarButton {
show: boolean;
label: string;
icon?: string;
shortcut?: string;
}
export interface Tag {
name: string;
match: (context: MatchContext) => SourceToken | null;
toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string;
/**
* The regex pattern that matches an unclosed opening delimiter.
* Used by the live preview to speculatively close incomplete syntax.
* Auto-generated by inlineTag().
*/
openPattern?: RegExp;
/**
* The markdown delimiter string. Auto-generated by inlineTag().
*/
delimiter?: string;
/** Lower runs first in inline processing. Default 50. Auto-generated by inlineTag(). */
precedence?: number;
/** Whether inner content is processed for nested markdown. Auto-generated by inlineTag(). */
recursive?: boolean;
/** Global regex for matching this tag's delimiter pair. Auto-generated by inlineTag(). */
pattern?: RegExp;
template?: string;
replaceSelection?: boolean;
button?: ToolbarButton;
}
export interface ListItem {
@@ -59,22 +58,50 @@ export interface ListResult {
export interface InlineTagDef {
name: string;
/** The markdown delimiter, e.g. '**' or '`' or '~~' */
delimiter: string;
/** The HTML tag to wrap with, e.g. 'strong' or 'code' */
htmlTag: string;
/** Additional HTML selectors for reverse matching, e.g. 'B' for bold */
aliases?: string;
/** Lower runs first. Default 50. */
precedence?: number;
/** Process inner content for nested markdown? Default true. False for code spans. */
recursive?: boolean;
button?: ToolbarButton | false;
}
export interface RibbitThemeFeatures {
sourceMode?: boolean;
}
/**
* A slot in the toolbar layout.
*
* 'bold' — single button
* '' — spacer
* 'macros' — auto-populated macro dropdown
* { group: 'Heading', items: ['h1', ...] } — dropdown group
*/
export type ToolbarSlot =
| string
| { group: string; items: string[] };
/**
* A resolved toolbar button with methods for interaction.
*/
export interface 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;
click(): void;
hide(): void;
show(): void;
}
export interface RibbitTheme {
name: string;
tags?: Record<string, Tag>;