diff --git a/src/ts/macros.ts b/src/ts/macros.ts index d909efb..24bdb3d 100644 --- a/src/ts/macros.ts +++ b/src/ts/macros.ts @@ -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 { diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index d9940e1..9c7c2da 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -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 }; diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts index c9c0d71..6dc551c 100644 --- a/src/ts/ribbit.ts +++ b/src/ts/ribbit.ts @@ -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; } -/** - * 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; 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(event: K, callback: RibbitEventMap[K]): void { this.emitter.on(event, callback); } - /** - * Remove a previously registered callback. - * - * editor.off('change', myHandler); - */ off(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) + ';'); } diff --git a/src/ts/tags.ts b/src/ts/tags.ts index 68e1331..4938438 100644 --- a/src/ts/tags.ts +++ b/src/ts/tags.ts @@ -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 = { * ``` */ 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 = { * ___ */ 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 = { * ### 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 = { * > 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 = { * 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 = { * | 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 = { htmlTag: 'code', precedence: 10, recursive: false, + button: { show: true, label: 'Code', shortcut: 'Ctrl+E' }, }), 'A': { @@ -412,6 +434,9 @@ export const defaultInlineTags: Record = { * [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 = { 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 = { htmlTag: 'em', aliases: 'I', precedence: 50, + button: { show: true, label: 'Italic', shortcut: 'Ctrl+I' }, }), }; diff --git a/src/ts/toolbar.ts b/src/ts/toolbar.ts new file mode 100644 index 0000000..ed7e5b6 --- /dev/null +++ b/src/ts/toolbar.ts @@ -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