Add themes support
Usage:
const editor = new RibbitEditor({
themes: [
{ name: 'dark', features: { sourceMode: false } },
{ name: 'minimal', tags: minimalTags },
],
currentTheme: 'dark',
});
The built-in theme is 'ribbit-default' and is always available.
Additional themes from the themes array are registered on top.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
|
||||
*/
|
||||
|
||||
import { HopDown } from './hopdown';
|
||||
import { defaultTheme } from './default-theme';
|
||||
import { ThemeManager } from './theme-manager';
|
||||
import type { RibbitTheme } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' });
|
||||
* viewer.run();
|
||||
*/
|
||||
export class Ribbit {
|
||||
api: unknown;
|
||||
element: HTMLElement;
|
||||
states: Record<string, string>;
|
||||
cachedHTML: string | null;
|
||||
cachedMarkdown: string | null;
|
||||
state: string | null;
|
||||
changed: boolean;
|
||||
enabledPlugins: Record<string, RibbitPlugin>;
|
||||
theme: RibbitTheme;
|
||||
themes: ThemeManager;
|
||||
converter: HopDown;
|
||||
themesPath: string;
|
||||
|
||||
constructor(settings: RibbitSettings) {
|
||||
this.api = settings.api || null;
|
||||
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
||||
this.themesPath = settings.themesPath || './themes';
|
||||
this.states = {
|
||||
VIEW: 'view',
|
||||
};
|
||||
this.cachedHTML = null;
|
||||
this.cachedMarkdown = null;
|
||||
this.state = null;
|
||||
this.changed = false;
|
||||
this.enabledPlugins = {};
|
||||
|
||||
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme) => {
|
||||
this.theme = theme;
|
||||
this.converter = theme.tags
|
||||
? new HopDown({ tags: theme.tags })
|
||||
: new HopDown();
|
||||
this.cachedHTML = null;
|
||||
if (this.getState() === this.states.VIEW) {
|
||||
this.state = null;
|
||||
this.view();
|
||||
}
|
||||
});
|
||||
|
||||
(settings.themes || []).forEach(theme => {
|
||||
this.themes.add(theme);
|
||||
});
|
||||
|
||||
const activeName = settings.currentTheme || defaultTheme.name;
|
||||
this.themes.set(activeName);
|
||||
this.theme = this.themes.current();
|
||||
this.converter = this.theme.tags
|
||||
? new HopDown({ tags: this.theme.tags })
|
||||
: new HopDown();
|
||||
|
||||
(settings.plugins || []).forEach(plugin => {
|
||||
this.enabledPlugins[plugin.name] = new plugin({
|
||||
name: plugin.name,
|
||||
wiki: this,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run(): void {
|
||||
this.element.classList.add('loaded');
|
||||
this.view();
|
||||
}
|
||||
|
||||
plugins(): RibbitPlugin[] {
|
||||
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
|
||||
}
|
||||
|
||||
getState(): string | null {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setState(newState: string): void {
|
||||
this.state = newState;
|
||||
Object.values(this.states).forEach(state => {
|
||||
if (state === newState) {
|
||||
this.element.classList.add(state);
|
||||
} else {
|
||||
this.element.classList.remove(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
markdownToHTML(md: string): string {
|
||||
return this.converter.toHTML(md);
|
||||
}
|
||||
|
||||
getHTML(): string {
|
||||
if (this.changed || !this.cachedHTML) {
|
||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
||||
}
|
||||
return this.cachedHTML;
|
||||
}
|
||||
|
||||
getMarkdown(): string {
|
||||
if (!this.cachedMarkdown) {
|
||||
this.cachedMarkdown = this.element.textContent || '';
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
|
||||
view(): void {
|
||||
if (this.getState() === this.states.VIEW) return;
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.states.VIEW);
|
||||
this.element.contentEditable = 'false';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
return lc.charAt(0).toUpperCase() + lc.slice(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) + ';');
|
||||
}
|
||||
Reference in New Issue
Block a user