757 lines
27 KiB
TypeScript
757 lines
27 KiB
TypeScript
/*
|
|
* 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, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
|
import { VimHandler } from './vim';
|
|
import { type MacroDef } from './macros';
|
|
|
|
/**
|
|
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
|
*
|
|
* Extends Ribbit with contentEditable support and bidirectional
|
|
* markdown↔HTML 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 {
|
|
private vim?: VimHandler;
|
|
|
|
run(): void {
|
|
this.states = {
|
|
VIEW: 'view',
|
|
EDIT: 'edit',
|
|
WYSIWYG: 'wysiwyg'
|
|
};
|
|
|
|
if (this.theme.features?.vim) {
|
|
this.vim = new VimHandler((mode) => {
|
|
if (mode === 'normal') {
|
|
this.toolbar.disable();
|
|
this.element.classList.add('vim-normal');
|
|
this.element.classList.remove('vim-insert');
|
|
} else {
|
|
this.toolbar.enable();
|
|
this.element.classList.add('vim-insert');
|
|
this.element.classList.remove('vim-normal');
|
|
}
|
|
});
|
|
}
|
|
|
|
this.#bindEvents();
|
|
this.element.classList.add('loaded');
|
|
if (this.autoToolbar) {
|
|
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
|
|
}
|
|
this.view();
|
|
this.emitReady();
|
|
}
|
|
|
|
#bindEvents(): void {
|
|
let debounceTimer: number | undefined;
|
|
|
|
this.element.addEventListener('input', () => {
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
return;
|
|
}
|
|
this.ensureBlockStructure();
|
|
this.transformCurrentBlock();
|
|
this.updateEditingContext();
|
|
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = window.setTimeout(() => {
|
|
this.notifyChange();
|
|
}, 300);
|
|
});
|
|
|
|
this.element.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
return;
|
|
}
|
|
if (e.key === 'Enter') {
|
|
this.handleEnter(e);
|
|
}
|
|
});
|
|
|
|
this.element.addEventListener('keyup', (e: KeyboardEvent) => {
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
return;
|
|
}
|
|
if (e.key.startsWith('Arrow')) {
|
|
this.closeOrphanedSpeculative();
|
|
this.updateEditingContext();
|
|
}
|
|
});
|
|
|
|
this.element.addEventListener('blur', () => {
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
return;
|
|
}
|
|
this.closeOrphanedSpeculative();
|
|
});
|
|
|
|
this.element.addEventListener('focusout', () => {
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
return;
|
|
}
|
|
this.closeOrphanedSpeculative();
|
|
});
|
|
|
|
document.addEventListener('click', (e: MouseEvent) => {
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
return;
|
|
}
|
|
if (!this.element.contains(e.target as Node)) {
|
|
this.closeAllSpeculative();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('selectionchange', () => {
|
|
if (this.state !== this.states.WYSIWYG) {
|
|
return;
|
|
}
|
|
this.closeOrphanedSpeculative();
|
|
this.updateEditingContext();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find the block-level element containing the cursor.
|
|
*/
|
|
/**
|
|
* Ensure the editor contains valid block structure.
|
|
* Wraps bare <br> and <div> elements in <p> tags.
|
|
*/
|
|
private ensureBlockStructure(): void {
|
|
for (const child of Array.from(this.element.childNodes)) {
|
|
if (child.nodeType === 1) {
|
|
const element = child as HTMLElement;
|
|
if (element.tagName === 'BR') {
|
|
const p = document.createElement('p');
|
|
p.innerHTML = '<br>';
|
|
element.replaceWith(p);
|
|
} else if (element.tagName === 'DIV') {
|
|
const p = document.createElement('p');
|
|
while (element.firstChild) {
|
|
p.appendChild(element.firstChild);
|
|
}
|
|
if (!p.firstChild) {
|
|
p.innerHTML = '<br>';
|
|
}
|
|
element.replaceWith(p);
|
|
// Restore cursor inside the new <p>
|
|
const sel = window.getSelection();
|
|
if (sel && sel.rangeCount > 0) {
|
|
const range = document.createRange();
|
|
const target = p.lastChild || p;
|
|
if (target.nodeType === 3) {
|
|
range.setStart(target, target.textContent?.length || 0);
|
|
} else {
|
|
range.selectNodeContents(target);
|
|
range.collapse(false);
|
|
}
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!this.element.firstChild) {
|
|
this.element.innerHTML = '<p><br></p>';
|
|
}
|
|
}
|
|
|
|
private findCurrentBlock(): HTMLElement | null {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) {
|
|
return null;
|
|
}
|
|
let node: Node | null = sel.anchorNode;
|
|
|
|
// If cursor is in a text node directly inside the editor,
|
|
// wrap it in a <p> first (browsers don't always do this).
|
|
if (node && node.nodeType === 3 && node.parentNode === this.element) {
|
|
const p = document.createElement('p');
|
|
node.parentNode.insertBefore(p, node);
|
|
p.appendChild(node);
|
|
// Restore cursor inside the new <p>
|
|
const range = document.createRange();
|
|
range.setStart(node, sel.anchorOffset);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
return p;
|
|
}
|
|
|
|
while (node && node !== this.element) {
|
|
if (node.nodeType === 1) {
|
|
const element = node as HTMLElement;
|
|
if (element.tagName === 'LI' || element.parentNode === this.element) {
|
|
return element;
|
|
}
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check the current block's text for markdown patterns and
|
|
* transform the DOM element in-place if a pattern matches.
|
|
*/
|
|
private transformCurrentBlock(): void {
|
|
const block = this.findCurrentBlock();
|
|
if (!block) {
|
|
return;
|
|
}
|
|
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
|
|
|
|
// Heading: # through ######
|
|
const headingMatch = text.match(/^(#{1,6})\s/);
|
|
if (headingMatch) {
|
|
const level = headingMatch[1].length;
|
|
const targetTag = 'H' + level;
|
|
if (block.tagName !== targetTag) {
|
|
this.replaceBlock(block, targetTag, headingMatch[0].length);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Blockquote: >
|
|
if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') {
|
|
this.replaceBlock(block, 'BLOCKQUOTE', 2);
|
|
return;
|
|
}
|
|
|
|
// Horizontal rule: --- or *** or ___
|
|
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) {
|
|
const hr = document.createElement('hr');
|
|
const p = document.createElement('p');
|
|
p.innerHTML = '<br>';
|
|
block.replaceWith(hr, p);
|
|
const range = document.createRange();
|
|
range.setStart(p, 0);
|
|
range.collapse(true);
|
|
const sel = window.getSelection()!;
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
return;
|
|
}
|
|
|
|
// Unordered list: - or *
|
|
if (/^[-*]\s/.test(text) && block.tagName !== 'LI') {
|
|
this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1);
|
|
return;
|
|
}
|
|
|
|
// Ordered list: 1.
|
|
if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') {
|
|
this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1);
|
|
return;
|
|
}
|
|
|
|
// Fenced code: ```
|
|
if (text.startsWith('```') && block.tagName !== 'PRE') {
|
|
const pre = document.createElement('pre');
|
|
const code = document.createElement('code');
|
|
code.textContent = '';
|
|
pre.appendChild(code);
|
|
block.replaceWith(pre);
|
|
const range = document.createRange();
|
|
range.setStart(code, 0);
|
|
range.collapse(true);
|
|
const sel = window.getSelection()!;
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
return;
|
|
}
|
|
|
|
// Inline transforms: flatten to markdown, transform, rebuild DOM
|
|
this.transformInline(block);
|
|
}
|
|
|
|
/**
|
|
* Convert a block's DOM children to a mixed string where completed
|
|
* inline elements are preserved as HTML and only speculative/text
|
|
* content is flattened to markdown. Completed elements are wrapped
|
|
* in sentinel markers so the regex engine skips them.
|
|
*/
|
|
private blockToMarkdown(block: HTMLElement): string {
|
|
let md = '';
|
|
for (const child of Array.from(block.childNodes)) {
|
|
md += this.nodeToMarkdown(child);
|
|
}
|
|
return md;
|
|
}
|
|
|
|
private nodeToMarkdown(node: Node): string {
|
|
if (node.nodeType === 3) {
|
|
return (node.textContent || '').replace(/\u200B/g, '');
|
|
}
|
|
if (node.nodeType !== 1) { return ''; }
|
|
const element = node as HTMLElement;
|
|
const specDelim = element.getAttribute('data-speculative');
|
|
|
|
if (specDelim) {
|
|
// Speculative: restore opener delimiter + flatten children
|
|
let inner = '';
|
|
for (const child of Array.from(element.childNodes)) {
|
|
inner += this.nodeToMarkdown(child);
|
|
}
|
|
return specDelim + inner;
|
|
}
|
|
|
|
const tag = this.findTagForElement(element);
|
|
if (tag?.delimiter) {
|
|
// Completed element: preserve as HTML, wrapped in sentinels
|
|
// so the complete-pair regex won't match across it
|
|
return '\x01' + element.outerHTML + '\x02';
|
|
}
|
|
|
|
// Unknown element: flatten children
|
|
let inner = '';
|
|
for (const child of Array.from(element.childNodes)) {
|
|
inner += this.nodeToMarkdown(child);
|
|
}
|
|
return inner;
|
|
}
|
|
|
|
/**
|
|
* Find the Tag definition that matches an HTML element.
|
|
*/
|
|
private findTagForElement(el: HTMLElement): { delimiter?: string; name: string } | null {
|
|
const inlineTags = this.converter.getInlineTags();
|
|
for (const tag of inlineTags) {
|
|
if (!tag.delimiter) continue;
|
|
if (typeof tag.selector === 'string') {
|
|
const selectors = tag.selector.split(',');
|
|
if (selectors.some(s => el.tagName === s.trim())) {
|
|
return tag;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Flatten the block to markdown, find and apply inline transforms,
|
|
* then rebuild the DOM from the result.
|
|
*/
|
|
private transformInline(block: HTMLElement): void {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) return;
|
|
|
|
let md = this.blockToMarkdown(block);
|
|
if (md.replace(/\s/g, '').length < 2) return;
|
|
|
|
const inlineTags = this.converter.getInlineTags();
|
|
const sorted = [...inlineTags]
|
|
.filter(tag => tag.delimiter)
|
|
.sort((a, b) => (a.precedence ?? 50) - (b.precedence ?? 50));
|
|
|
|
// Build regex for each tag with exact-delimiter matching.
|
|
// [^\x01\x02] prevents matching across preserved HTML elements.
|
|
const tagRegexes = sorted.map(tag => {
|
|
const delim = tag.delimiter!;
|
|
const escaped = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const ec = delim[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
return {
|
|
tag,
|
|
complete: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+?)(?<!${ec})${escaped}`),
|
|
open: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+)$`),
|
|
};
|
|
});
|
|
|
|
// Apply complete pairs repeatedly until none match
|
|
const forbiddenChildren: Record<string, string[]> = {
|
|
'strong': ['strong', 'b'],
|
|
'em': ['em', 'i'],
|
|
'code': ['code', 'strong', 'b', 'em', 'i', 'a'],
|
|
};
|
|
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const { tag, complete } of tagRegexes) {
|
|
const match = md.match(complete);
|
|
if (match && match.index !== undefined) {
|
|
const tagName = tag.name === 'boldItalic' ? 'em' : (tag.selector as string).split(',')[0].toLowerCase();
|
|
|
|
// Skip if wrapping would create forbidden nesting
|
|
const banned = forbiddenChildren[tagName];
|
|
if (banned && banned.some(t => match[1].includes('<' + t))) {
|
|
continue;
|
|
}
|
|
|
|
const content = tagName === 'code'
|
|
? match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
: match[1];
|
|
const inner = tag.name === 'boldItalic'
|
|
? `\x01<${tagName}><strong>${content}</strong></${tagName}>\x02`
|
|
: `\x01<${tagName}>${content}</${tagName}>\x02`;
|
|
md = md.slice(0, match.index) + inner + md.slice(match.index + match[0].length);
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strip sentinel markers now that complete-pair matching is done
|
|
md = md.replace(/[\x01\x02]/g, '');
|
|
|
|
// Check for one unclosed opener (speculative)
|
|
let speculativeTag: typeof sorted[0] | null = null;
|
|
let speculativeMatch: RegExpMatchArray | null = null;
|
|
for (const { tag, open } of tagRegexes) {
|
|
const match = md.match(open);
|
|
if (match && match.index !== undefined) {
|
|
// Make sure this isn't inside an HTML tag we just created
|
|
const before = md.slice(0, match.index);
|
|
if (!before.endsWith('<') && !before.endsWith('/')) {
|
|
speculativeTag = tag;
|
|
speculativeMatch = match;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rebuild the DOM
|
|
if (speculativeMatch && speculativeTag) {
|
|
const tagName = speculativeTag.name === 'boldItalic' ? 'em' : (speculativeTag.selector as string).split(',')[0].toLowerCase();
|
|
const inside = md.slice(speculativeMatch.index! + speculativeTag.delimiter!.length);
|
|
|
|
// Check for forbidden nesting before wrapping
|
|
const probe = document.createElement('div');
|
|
probe.innerHTML = inside;
|
|
const banned = forbiddenChildren[tagName];
|
|
const wouldNest = banned && banned.some(tag => probe.querySelector(tag));
|
|
|
|
if (!wouldNest) {
|
|
const before = md.slice(0, speculativeMatch.index!);
|
|
const wrapper = document.createElement(tagName);
|
|
wrapper.classList.add('ribbit-editing');
|
|
wrapper.setAttribute('data-speculative', speculativeTag.delimiter!);
|
|
wrapper.innerHTML = inside;
|
|
this.sanitizeNesting(wrapper);
|
|
|
|
block.innerHTML = '';
|
|
if (before) {
|
|
block.appendChild(document.createTextNode(before));
|
|
}
|
|
block.appendChild(wrapper);
|
|
// ZWS after wrapper so arrow-right can escape the element
|
|
block.appendChild(document.createTextNode('\u200B'));
|
|
|
|
// Cursor at end of speculative element
|
|
this.placeCursorAtEnd(wrapper);
|
|
} else {
|
|
// Forbidden nesting — fall through to plain innerHTML
|
|
block.innerHTML = md;
|
|
this.sanitizeNesting(block);
|
|
if (block.lastChild && block.lastChild.nodeType === 1) {
|
|
block.appendChild(document.createTextNode('\u200B'));
|
|
}
|
|
this.placeCursorAtEnd(block);
|
|
}
|
|
} else {
|
|
block.innerHTML = md;
|
|
this.sanitizeNesting(block);
|
|
// If the block ends with an HTML element, append a ZWS text
|
|
// node so the cursor lands outside the element, not inside it.
|
|
if (block.lastChild && block.lastChild.nodeType === 1) {
|
|
block.appendChild(document.createTextNode('\u200B'));
|
|
}
|
|
this.placeCursorAtEnd(block);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Place the cursor at the end of an element's content.
|
|
*/
|
|
private placeCursorAtEnd(el: HTMLElement): void {
|
|
const sel = window.getSelection();
|
|
if (!sel) return;
|
|
const range = document.createRange();
|
|
// Find the deepest last text node
|
|
let target: Node = el;
|
|
while (target.lastChild) {
|
|
target = target.lastChild;
|
|
}
|
|
if (target.nodeType === 3) {
|
|
range.setStart(target, target.textContent?.length || 0);
|
|
} else {
|
|
range.selectNodeContents(target);
|
|
range.collapse(false);
|
|
}
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
/**
|
|
|
|
/**
|
|
* Replace a block element with a new tag, stripping the prefix
|
|
* and preserving cursor position.
|
|
*/
|
|
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
|
|
const newEl = document.createElement(newTag);
|
|
const content = (block.textContent || '').slice(prefixLength);
|
|
if (content) {
|
|
newEl.textContent = content;
|
|
} else {
|
|
newEl.innerHTML = '<br>';
|
|
}
|
|
block.replaceWith(newEl);
|
|
newEl.classList.add('ribbit-editing');
|
|
|
|
// Place cursor at start of content
|
|
const range = document.createRange();
|
|
if (newEl.firstChild && newEl.firstChild.nodeType === 3) {
|
|
range.setStart(newEl.firstChild, 0);
|
|
} else {
|
|
range.setStart(newEl, 0);
|
|
}
|
|
range.collapse(true);
|
|
const sel = window.getSelection()!;
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
/**
|
|
* Replace a block element with a list (ul/ol) containing one item.
|
|
*/
|
|
private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void {
|
|
const list = document.createElement(listTag);
|
|
const li = document.createElement('li');
|
|
const content = (block.textContent || '').slice(prefixLength);
|
|
if (content) {
|
|
li.textContent = content;
|
|
} else {
|
|
li.innerHTML = '<br>';
|
|
}
|
|
list.appendChild(li);
|
|
block.replaceWith(list);
|
|
|
|
const range = document.createRange();
|
|
if (li.firstChild && li.firstChild.nodeType === 3) {
|
|
range.setStart(li.firstChild, 0);
|
|
} else {
|
|
range.setStart(li, 0);
|
|
}
|
|
range.collapse(true);
|
|
const sel = window.getSelection()!;
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
/**
|
|
* Handle Enter key: strip syntax decorations from the current
|
|
* block before the browser creates a new line.
|
|
*/
|
|
private handleEnter(e: KeyboardEvent): void {
|
|
const prev = this.element.querySelector('.ribbit-editing');
|
|
if (prev) {
|
|
prev.classList.remove('ribbit-editing');
|
|
prev.removeAttribute('data-speculative');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close any speculative elements that the cursor is no longer inside.
|
|
* Called on every selection change — handles arrow keys, clicks,
|
|
* tab switches, and any other cursor movement.
|
|
*/
|
|
/**
|
|
* Unwrap a speculative element, replacing it with its children.
|
|
* An orphaned speculative element was never completed — it should
|
|
* not become permanent formatting.
|
|
*/
|
|
private unwrapSpeculative(element: HTMLElement): void {
|
|
this.unwrapElement(element);
|
|
}
|
|
|
|
/**
|
|
* Replace an element with its children, preserving content.
|
|
*/
|
|
private unwrapElement(element: HTMLElement): void {
|
|
const parent = element.parentNode;
|
|
if (!parent) { return; }
|
|
while (element.firstChild) {
|
|
parent.insertBefore(element.firstChild, element);
|
|
}
|
|
parent.removeChild(element);
|
|
}
|
|
|
|
/**
|
|
* Remove forbidden nesting from a block element.
|
|
* For example, <em> inside <em>, <strong> inside <code>, etc.
|
|
*/
|
|
private sanitizeNesting(block: HTMLElement): void {
|
|
const rules: Record<string, string[]> = {
|
|
'STRONG': ['STRONG', 'B'],
|
|
'B': ['STRONG', 'B'],
|
|
'EM': ['EM', 'I'],
|
|
'I': ['EM', 'I'],
|
|
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'],
|
|
};
|
|
let found = true;
|
|
while (found) {
|
|
found = false;
|
|
for (const [parent, forbidden] of Object.entries(rules)) {
|
|
const parents = block.querySelectorAll(parent.toLowerCase());
|
|
for (const parentEl of Array.from(parents)) {
|
|
for (const tag of forbidden) {
|
|
const nested = parentEl.querySelector(tag.toLowerCase());
|
|
if (nested && nested !== parentEl) {
|
|
this.unwrapElement(nested as HTMLElement);
|
|
found = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private closeAllSpeculative(): void {
|
|
for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) {
|
|
this.unwrapSpeculative(element as HTMLElement);
|
|
}
|
|
}
|
|
|
|
private closeOrphanedSpeculative(): void {
|
|
const speculative = this.element.querySelectorAll('[data-speculative]');
|
|
if (speculative.length === 0) { return; }
|
|
|
|
const sel = window.getSelection();
|
|
const anchor = sel?.anchorNode;
|
|
|
|
for (const el of Array.from(speculative)) {
|
|
const htmlEl = el as HTMLElement;
|
|
let inside = false;
|
|
let node: Node | null = anchor || null;
|
|
while (node) {
|
|
if (node === htmlEl) {
|
|
inside = true;
|
|
break;
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
if (!inside) {
|
|
this.unwrapSpeculative(htmlEl);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track which formatting element contains the cursor and toggle
|
|
* the .ribbit-editing class so CSS ::before/::after show delimiters.
|
|
*/
|
|
private updateEditingContext(): void {
|
|
const prev = this.element.querySelector('.ribbit-editing');
|
|
if (prev) {
|
|
prev.classList.remove('ribbit-editing');
|
|
}
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) {
|
|
return;
|
|
}
|
|
let node: Node | null = sel.anchorNode;
|
|
while (node && node !== this.element) {
|
|
if (node.nodeType === 1) {
|
|
const el = node as HTMLElement;
|
|
if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
|
|
el.classList.add('ribbit-editing');
|
|
return;
|
|
}
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
}
|
|
|
|
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');
|
|
return decodeHtmlEntities(html);
|
|
} else if (this.getState() === this.states.WYSIWYG) {
|
|
return this.htmlToMarkdown(this.element.innerHTML);
|
|
}
|
|
return this.element.textContent || '';
|
|
}
|
|
|
|
wysiwyg(): void {
|
|
if (this.getState() === this.states.WYSIWYG) return;
|
|
const wasEditing = this.getState() === this.states.EDIT;
|
|
this.vim?.detach();
|
|
this.collaboration?.connect();
|
|
if (wasEditing && this.collaboration?.isPaused()) {
|
|
this.collaboration.resume(this.getMarkdown());
|
|
}
|
|
this.element.contentEditable = 'true';
|
|
this.element.innerHTML = this.getHTML();
|
|
// Ensure there's at least one block element for the cursor
|
|
if (!this.element.firstElementChild) {
|
|
this.element.innerHTML = '<p><br></p>';
|
|
}
|
|
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.element.contentEditable = 'true';
|
|
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
|
this.vim?.attach(this.element);
|
|
this.collaboration?.connect();
|
|
this.collaboration?.pause(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);
|
|
}
|
|
}
|
|
|
|
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
|
export { RibbitEditor as Editor };
|
|
export { Ribbit as Viewer };
|
|
export { HopDown };
|
|
export { inlineTag };
|
|
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
|
export { defaultTheme };
|
|
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
|
export { ToolbarManager } from './toolbar';
|
|
export { VimHandler } from './vim';
|
|
export { CollaborationManager } from './collaboration';
|
|
export type { MacroDef };
|