ribbit/src/ts/ribbit-editor.ts

118 lines
3.8 KiB
TypeScript
Raw Normal View History

/*
* ribbit-editor.ts WYSIWYG editing extension for Ribbit.
*/
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';
/**
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
*
* Extends Ribbit with contentEditable support and bidirectional
* markdownHTML conversion on mode switches.
*
* Usage:
* const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run();
* editor.wysiwyg(); // switch to WYSIWYG mode
* editor.edit(); // switch to source editing mode
* editor.view(); // switch to read-only view
*/
export class RibbitEditor extends Ribbit {
run(): void {
this.states = {
VIEW: 'view',
EDIT: 'edit',
WYSIWYG: 'wysiwyg'
};
this.#bindEvents();
this.plugins().forEach(plugin => {
plugin.setEditable();
});
this.element.classList.add('loaded');
this.view();
}
#bindEvents(): void {
this.element.addEventListener('input', () => {
if (this.state !== this.states.VIEW) {
this.changed = true;
}
});
}
htmlToMarkdown(html?: string): string {
return this.converter.toMarkdown(html || this.element.innerHTML);
}
getMarkdown(): string {
if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, '');
html = html.replace(/<\/div>/ig, '\n');
this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
}
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
}
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return;
this.changed = false;
this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML();
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
const macroEl = el as HTMLElement;
if (macroEl.dataset.editable === 'false') {
macroEl.contentEditable = 'false';
macroEl.style.opacity = '0.5';
}
});
this.setState(this.states.WYSIWYG);
}
edit(): void {
if (!this.theme.features?.sourceMode) {
return;
}
if (this.state === this.states.EDIT) return;
this.changed = false;
this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.setState(this.states.EDIT);
}
insertAtCursor(node: Node): void {
const sel = window.getSelection()!;
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(node);
range.setStartAfter(node);
this.element.focus();
sel.removeAllRanges();
sel.addRange(range);
}
}
// Attach public API to window for <script> tag usage.
(window as any).HopDown = HopDown;
(window as any).inlineTag = inlineTag;
(window as any).defaultTags = defaultTags;
(window as any).defaultBlockTags = defaultBlockTags;
(window as any).defaultInlineTags = defaultInlineTags;
(window as any).defaultTheme = defaultTheme;
(window as any).Ribbit = Ribbit;
(window as any).RibbitEditor = RibbitEditor;
(window as any).RibbitPlugin = RibbitPlugin;
(window as any).camelCase = camelCase;
(window as any).decodeHtmlEntities = decodeHtmlEntities;
(window as any).encodeHtmlEntities = encodeHtmlEntities;