From 9748d12ede493fd918daad85174973bf35f8e948 Mon Sep 17 00:00:00 2001 From: evilchili Date: Fri, 15 May 2026 12:27:22 -0700 Subject: [PATCH] wip --- src/static/ribbit-core.css | 72 --- src/ts/ribbit-editor.ts | 1100 +----------------------------------- src/ts/vim.ts | 337 ----------- test/editor.test.ts | 47 +- test/vim.test.ts | 150 ----- 5 files changed, 18 insertions(+), 1688 deletions(-) delete mode 100644 src/ts/vim.ts delete mode 100644 test/vim.test.ts diff --git a/src/static/ribbit-core.css b/src/static/ribbit-core.css index 924acc0..9aedb73 100644 --- a/src/static/ribbit-core.css +++ b/src/static/ribbit-core.css @@ -146,75 +146,3 @@ #ribbit.vim-insert { border-left: 3px solid #4f4; } -/* - * ribbit-core.css — functional editor styles. Always load this. - * These styles control editor state visibility and behavior. - * They should not be overridden by themes. - */ - -#ribbit { - display: none; -} - -#ribbit.loaded { - display: block; -} - -#ribbit.edit { - font-family: monospace; - white-space: pre; -} - -#ribbit.wysiwyg .md { - opacity: 0.5; -} - -.ribbit-editing::before, -.ribbit-editing::after { - opacity: 0.3; - font-weight: normal; - font-style: normal; - font-family: monospace; - font-size: 0.85em; -} - -[data-speculative]::before, -[data-speculative]::after { - content: none !important; -} - -#ribbit.wysiwyg strong.ribbit-editing::before, -#ribbit.wysiwyg strong.ribbit-editing::after { - content: "**"; -} - -#ribbit.wysiwyg em.ribbit-editing::before, -#ribbit.wysiwyg em.ribbit-editing::after { - content: "*"; -} - -#ribbit.wysiwyg code.ribbit-editing::before, -#ribbit.wysiwyg code.ribbit-editing::after { - content: "\`"; -} - -#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; } -#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; } -#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; } -#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; } -#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; } -#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; } - -#ribbit.wysiwyg blockquote.ribbit-editing::before { - content: "> "; -} - -#ribbit.vim-normal { - cursor: default; - caret-color: transparent; - border-left: 3px solid #4af; -} - -#ribbit.vim-insert { - border-left: 3px solid #4f4; -} diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 10d06c8..578a5df 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -27,7 +27,6 @@ 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 { Tag } from './types'; import { type MacroDef } from './macros'; @@ -180,7 +179,6 @@ const INLINE_RULES: InlineRule[] = [ * editor.wysiwyg(); */ export class RibbitEditor extends Ribbit { - private vim?: VimHandler; // The formatting span the cursor was last inside. Tracked so we // can remove EDITING_CONTEXT_CLASS when the cursor moves away. @@ -201,17 +199,7 @@ export class RibbitEditor extends Ribbit { }; 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'); - } - }); + // TODO } this.#bindEvents(); @@ -287,7 +275,6 @@ export class RibbitEditor extends Ribbit { return; } this.invalidateCache(); - this.vim?.detach(); this.collaboration?.connect(); this.element.innerHTML = ''; this.element.appendChild(this.#markdownToStyledDOM(this.getMarkdown())); @@ -848,1090 +835,5 @@ 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 }; -/* - * 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 { DelimiterMatch } from './types'; -import { type MacroDef } from './macros'; - -/** - * WYSIWYG markdown editor. Extends Ribbit's read-only viewer with - * contentEditable support, live inline transforms (typing `**bold**` - * immediately wraps in ``), and source editing mode. - * - * const editor = new RibbitEditor({ editorId: 'my-element' }); - * editor.run(); - * editor.wysiwyg(); - */ -export class RibbitEditor extends Ribbit { - private vim?: VimHandler; - - // Elements that must not be nested inside each other. - // Used by transformInline and rebuildBlock to prevent - // invalid structures like inside . - private static readonly forbiddenNesting: Record = { - 'strong': ['strong', 'b'], - 'em': ['em', 'i'], - 'del': ['del', 's', 'strike'], - 'code': ['code', 'strong', 'b', 'em', 'i', 'a', 'del'], - }; - - /** - * Initialize the editor with all three modes (view/edit/wysiwyg), - * bind DOM events, and optionally attach vim keybindings. - * - * const editor = new RibbitEditor({ editorId: 'content' }); - * editor.run(); - */ - 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', (event: KeyboardEvent) => { - if (this.state !== this.states.WYSIWYG) { - return; - } - this.dispatchKeydown(event); - }); - - this.element.addEventListener('keyup', (event: KeyboardEvent) => { - if (this.state !== this.states.WYSIWYG) { - return; - } - if (event.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', (event: MouseEvent) => { - if (this.state !== this.states.WYSIWYG) { - return; - } - if (!this.element.contains(event.target as Node)) { - this.closeAllSpeculative(); - } - }); - - document.addEventListener('selectionchange', () => { - if (this.state !== this.states.WYSIWYG) { - return; - } - this.closeOrphanedSpeculative(); - this.updateEditingContext(); - }); - } - - /** - * Browsers create bare
and
elements in contentEditable - * that aren't valid markdown block containers. Convert them to

- * so every editor child is a recognized block element. - */ - 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 = '
'; - 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 = '
'; - } - element.replaceWith(p); - // Cursor must follow the content into the new

, - // otherwise the next keystroke creates another

- const selection = window.getSelection(); - if (selection && selection.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); - } - selection.removeAllRanges(); - selection.addRange(range); - } - } - } - } - if (!this.element.firstChild) { - this.element.innerHTML = '


'; - } - } - - /** - * Walk up from the cursor to find the nearest block-level ancestor. - * Returns
  • for list items (not the
      /
        ) because list items - * are the editable unit inside a list. - */ - private findCurrentBlock(): HTMLElement | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - let node: Node | null = selection.anchorNode; - - // Bare text nodes in contentEditable cause cursor issues; - // wrap in

        before the browser can create a

        around it - 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

        so typing continues there - const range = document.createRange(); - range.setStart(node, selection.anchorOffset); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - return p; - } - - while (node && node !== this.element) { - if (node.nodeType === 1) { - const element = node as HTMLElement; - if (element.tagName === 'LI' - || (element.tagName === 'P' && element.parentElement?.tagName === 'BLOCKQUOTE') - || element.parentNode === this.element) { - return element; - } - } - node = node.parentNode; - } - return null; - } - - /** - * Detect markdown block syntax at the start of the current line - * and transform the DOM element in-place. Runs on every input event. - * Non-breaking spaces are normalized because browsers insert   - * in contentEditable instead of regular spaces. - */ - private transformCurrentBlock(): void { - const block = this.findCurrentBlock(); - if (!block) { - return; - } - // Normalize   → space so patterns like "- " and "> " match - const text = (block.textContent || '').replace(/\u00A0/g, ' '); - - // If the block contains
        elements, check the current line - // (text after the last
        before the cursor). Block-level - // patterns at the start of a line after
        should split the - // block and transform the new portion. - const currentLineText = this.getCurrentLineText(block); - if (currentLineText !== null && currentLineText !== text) { - if (this.tryBlockTransformOnCurrentLine(block, currentLineText)) { - return; - } - } - - 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; - } - } - - if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') { - this.replaceBlock(block, 'BLOCKQUOTE', 2); - return; - } - - if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) { - const hr = document.createElement('hr'); - const p = document.createElement('p'); - p.innerHTML = '
        '; - block.replaceWith(hr, p); - const range = document.createRange(); - range.setStart(p, 0); - range.collapse(true); - const selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - return; - } - - if (/^[-*+]\s/.test(text) && block.tagName !== 'LI') { - this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1); - return; - } - - if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') { - this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1); - return; - } - - if ((text.startsWith('```') || 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 selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - return; - } - - this.transformInline(block); - } - - /** - * Serialize a block's children into a mixed string of markdown text - * and sentinel-wrapped HTML. Completed inline elements (e.g. a - * finished ``) are preserved as HTML between \x01...\x02 - * markers so the transform regex won't re-match their delimiters. - * Speculative elements restore only their opening delimiter. - */ - private blockToMarkdown(block: HTMLElement): string { - let markdown = ''; - for (const child of Array.from(block.childNodes)) { - markdown += this.nodeToMarkdown(child); - } - return markdown; - } - - 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; - - // Preserve
        as a newline so blockquote line breaks survive - if (element.tagName === 'BR') { - return '\n'; - } - - const specDelim = element.getAttribute('data-speculative'); - - if (specDelim) { - 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) { - return '\x01' + element.outerHTML + '\x02'; - } - - let inner = ''; - for (const child of Array.from(element.childNodes)) { - inner += this.nodeToMarkdown(child); - } - return inner; - } - - /** - * Look up the Tag definition for an HTML element by matching its - * tagName against registered inline tag selectors. Returns null - * for elements that aren't delimiter-based inline formatting. - */ - private findTagForElement(element: HTMLElement): { delimiter?: string; name: string } | null { - return this.converter.getTagForElement(element); - } - - /** - * The core WYSIWYG pipeline: flatten → match → rebuild. - * - * 1. Flatten the block's DOM to a markdown string (preserving - * completed elements as sentinel-wrapped HTML) - * 2. Match complete delimiter pairs and replace with HTML tags - * 3. Find one unclosed opener for speculative preview - * 4. Rebuild the block's DOM from the result string - * - * Sentinel markers (\x01...\x02) prevent the regex from matching - * delimiters that belong to already-transformed elements. - */ - /** - * Get the text of the current line within a block — the text - * after the last
        before the cursor. Returns null if there - * are no
        elements (single-line block). - */ - private getCurrentLineText(block: HTMLElement): string | null { - const hasBr = block.querySelector('br'); - if (!hasBr) { - return null; - } - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - // Collect text from the cursor's text node backward to the - // nearest
        or start of block - let node: Node | null = selection.anchorNode; - if (!node || !block.contains(node)) { - return null; - } - // Get text from cursor position to start of current text node - let lineText = ''; - if (node.nodeType === 3) { - lineText = (node.textContent || '').slice(0, selection.anchorOffset); - } - // Walk backward through siblings collecting text until we hit a
        - let sibling: Node | null = node.nodeType === 3 - ? node.previousSibling - : null; - while (sibling) { - if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') { - break; - } - lineText = (sibling.textContent || '') + lineText; - sibling = sibling.previousSibling; - } - // Only return if we actually found a
        (meaning this is a - // subsequent line, not the first line of the block) - if (!sibling) { - return null; - } - return lineText.replace(/\u00A0/g, ' ').replace(/\u200B/g, ''); - } - - /** - * Check if a block-level pattern matches on the current line text. - * If so, split the block at the
        before this line and transform - * the new block. - */ - private tryBlockTransformOnCurrentLine(block: HTMLElement, lineText: string): boolean { - let newTag: string | null = null; - let prefixLength = 0; - - const headingMatch = lineText.match(/^(#{1,6})\s/); - if (headingMatch) { - newTag = 'H' + headingMatch[1].length; - prefixLength = headingMatch[0].length; - } else if (lineText.startsWith('> ')) { - newTag = 'BLOCKQUOTE'; - prefixLength = 2; - } else if (/^[-*+]\s/.test(lineText)) { - return this.splitAndTransformList(block, 'ul', lineText); - } else if (/^\d+\.\s/.test(lineText)) { - return this.splitAndTransformList(block, 'ol', lineText); - } else if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(lineText)) { - this.splitAtCurrentLine(block); - // The split created a new

        with the hr text — transform it - const newBlock = block.nextElementSibling as HTMLElement; - if (newBlock) { - const hr = document.createElement('hr'); - const paragraph = document.createElement('p'); - paragraph.innerHTML = '
        '; - newBlock.replaceWith(hr, paragraph); - const range = document.createRange(); - range.setStart(paragraph, 0); - range.collapse(true); - const selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - } - return true; - } - - if (!newTag) { - return false; - } - - this.splitAtCurrentLine(block); - const newBlock = block.nextElementSibling as HTMLElement; - if (newBlock) { - this.replaceBlock(newBlock, newTag, prefixLength); - } - return true; - } - - /** - * Split a block at the
        before the current line. Everything - * after the
        becomes a new

        element after the original block. - */ - private splitAtCurrentLine(block: HTMLElement): void { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; - } - let node: Node | null = selection.anchorNode; - - // Find the
        before the current line - let brNode: Node | null = null; - if (node?.nodeType === 3) { - let sibling: Node | null = node.previousSibling; - while (sibling) { - if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') { - brNode = sibling; - break; - } - sibling = sibling.previousSibling; - } - } - - if (!brNode) { - return; - } - - // Collect all nodes after the
        into a new

        - const newParagraph = document.createElement('p'); - let nextNode: Node | null = brNode.nextSibling; - while (nextNode) { - const following = nextNode.nextSibling; - newParagraph.appendChild(nextNode); - nextNode = following; - } - // Strip leading ZWS from the first text node — it was a cursor - // anchor from the Enter handler, not real content - const firstChild = newParagraph.firstChild; - if (firstChild && firstChild.nodeType === 3) { - firstChild.textContent = (firstChild.textContent || '').replace(/^\u200B+/, ''); - } - brNode.parentNode?.removeChild(brNode); - - // Remove trailing empty text nodes and
        from the original block - while (block.lastChild - && ((block.lastChild.nodeType === 3 - && (block.lastChild.textContent || '').replace(/\u200B/g, '').trim() === '') - || (block.lastChild.nodeType === 1 - && (block.lastChild as HTMLElement).tagName === 'BR'))) { - block.removeChild(block.lastChild); - } - - block.after(newParagraph); - } - - private splitAndTransformList(block: HTMLElement, listTag: string, lineText: string): boolean { - const prefixLength = lineText.indexOf(' ') + 1; - this.splitAtCurrentLine(block); - const newBlock = block.nextElementSibling as HTMLElement; - if (newBlock) { - this.replaceBlockWithList(newBlock, listTag, prefixLength); - } - return true; - } - - private transformInline(block: HTMLElement): void { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; - } - - let markdown = this.blockToMarkdown(block); - if (markdown.replace(/\s/g, '').length < 2) { - return; - } - - // Normalize flanking underscores to asterisks so _ triggers - // the same live preview as *. The CSS pseudo-elements will - // show * even though the user typed _. - markdown = this.normalizeUnderscores(markdown); - - // Nesting rules: which elements must not appear inside which - const forbiddenChildren = RibbitEditor.forbiddenNesting; - - // Apply complete pairs until stable (each match restarts - // because the replacement may enable new matches) - let changed = true; - while (changed) { - changed = false; - const pair = this.converter.findCompletePair(markdown); - if (!pair) { - break; - } - - const banned = forbiddenChildren[pair.htmlTag]; - if (banned && banned.some(tag => pair.content.includes('<' + tag))) { - break; - } - - // HTML entities in code content would be parsed as - // real elements by innerHTML (e.g. `

        ` → actual
        ) - const content = pair.htmlTag === 'code' - ? pair.content.replace(/&/g, '&').replace(//g, '>') - : pair.content; - const inner = pair.tag.name === 'boldItalic' - ? `\x01<${pair.htmlTag}>${content}\x02` - : `\x01<${pair.htmlTag}>${content}\x02`; - markdown = markdown.slice(0, pair.index) + inner + markdown.slice(pair.index + pair.length); - changed = true; - } - - // Strip sentinels now — the speculative check below needs to - // see the actual HTML tags to detect forbidden nesting - markdown = markdown.replace(/[\x01\x02]/g, ''); - - const opener = this.converter.findUnmatchedOpener(markdown); - - this.rebuildBlock(block, markdown, opener, forbiddenChildren); - } - - /** - * Rebuild a block's DOM from the transformed markdown string. - * If an unclosed opener was found, wrap the trailing content in - * a speculative element; otherwise set innerHTML directly. - */ - /** - * Convert flanking underscore runs to asterisks so the delimiter - * matching treats _ the same as *. Non-flanking underscores (like - * foo_bar) are left alone. Backslash-escaped underscores (\_) are - * protected from normalization. - */ - private normalizeUnderscores(text: string): string { - const escapePlaceholder = '\x00U\x00'; - const safeText = text.replace(/\\_/g, escapePlaceholder); - - const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|*\`]`; - const openRun = new RegExp( - `(?<=^|${punctuation})` + - `(_+)` + - `(?=\\S)`, - 'g' - ); - const closeRun = new RegExp( - `(?<=\\S)` + - `(_+)` + - `(?=$|${punctuation})`, - 'g' - ); - const toAsterisks = (_match: string, run: string) => - '*'.repeat(run.length); - const normalized = safeText - .replace(openRun, toAsterisks) - .replace(closeRun, toAsterisks); - - return normalized.replace(/\x00U\x00/g, '\\_'); - } - - private rebuildBlock( - block: HTMLElement, - markdown: string, - opener: DelimiterMatch | null, - forbiddenChildren: Record, - ): void { - if (!opener) { - block.innerHTML = markdown.replace(/\n/g, '
        \u200B'); - this.sanitizeNesting(block); - this.appendZwsIfNeeded(block); - this.placeCursorAtEnd(block); - return; - } - - const inside = markdown.slice(opener.index + opener.delimiter.length); - const banned = forbiddenChildren[opener.htmlTag]; - - // Check for forbidden nesting before wrapping - const probe = document.createElement('div'); - probe.innerHTML = inside; - if (banned && banned.some(tag => probe.querySelector(tag))) { - block.innerHTML = markdown.replace(/\n/g, '
        \u200B'); - this.sanitizeNesting(block); - this.appendZwsIfNeeded(block); - this.placeCursorAtEnd(block); - return; - } - - const before = markdown.slice(0, opener.index); - const wrapper = document.createElement(opener.htmlTag); - wrapper.classList.add('ribbit-editing'); - wrapper.setAttribute('data-speculative', opener.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')); - this.placeCursorAtEnd(wrapper); - } - - /** - * Append a zero-width space after the last child if it's an element, - * so the cursor can land outside it instead of inside. - */ - private appendZwsIfNeeded(block: HTMLElement): void { - if (block.lastChild && block.lastChild.nodeType === 1) { - block.appendChild(document.createTextNode('\u200B')); - } - } - - /** - * Place the cursor at the deepest last text node inside an element. - * Used after DOM rebuilds to restore the cursor to where the user - * was typing. - */ - private placeCursorAtEnd(element: HTMLElement): void { - const selection = window.getSelection(); - if (!selection) { - return; - } - const range = document.createRange(); - let target: Node = element; - 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); - selection.removeAllRanges(); - selection.addRange(range); - } - - /** - * Replace a block element with a different tag (e.g.

        ), - * stripping the markdown prefix (e.g. "# ") from the content. - */ - private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void { - const newEl = document.createElement(newTag); - const content = (block.textContent || '').slice(prefixLength); - // Blockquotes need inner

        elements so Enter can create - // sibling paragraphs within the quote - if (newTag === 'BLOCKQUOTE') { - const paragraph = document.createElement('p'); - if (content) { - paragraph.textContent = content; - } else { - paragraph.innerHTML = '
        '; - } - newEl.appendChild(paragraph); - block.replaceWith(newEl); - newEl.classList.add('ribbit-editing'); - const range = document.createRange(); - if (paragraph.firstChild && paragraph.firstChild.nodeType === 3) { - range.setStart(paragraph.firstChild, 0); - } else { - range.setStart(paragraph, 0); - } - range.collapse(true); - const selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - return; - } - if (content) { - newEl.textContent = content; - } else { - newEl.innerHTML = '
        '; - } - block.replaceWith(newEl); - newEl.classList.add('ribbit-editing'); - - // Cursor at start so the user sees the content, not the prefix - 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 selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - } - - /** - * Replace a block element with a list containing one item. - * Triggered when the user types "- " or "1. " at the start of a line. - */ - 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 = '
        '; - } - 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 selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - } - - /** - * On Enter, strip editing decorations from the current block so - * the browser's default newline behavior creates a clean element. - */ - /** - * Dispatch a keydown event to the tag that contains the cursor. - * Walks up from the cursor to find a tag with handleKeydown, - * which routes to named handlers in the tag's eventHandlers map. - * If no tag handles the event, the browser's default runs. - */ - private dispatchKeydown(event: KeyboardEvent): void { - // Strip editing decorations on Enter regardless of tag handling - if (event.key === 'Enter') { - const prev = this.element.querySelector('.ribbit-editing'); - if (prev) { - prev.classList.remove('ribbit-editing'); - prev.removeAttribute('data-speculative'); - } - } - - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; - } - - // Walk up from the cursor to find a tag with handleKeydown. - // Skip

        elements inside container blocks (blockquote, li) - // so the container's handler runs instead. - const containerTags = new Set(['BLOCKQUOTE', 'LI']); - let node: Node | null = selection.anchorNode; - while (node && node !== this.element) { - if (node.nodeType === 1) { - const element = node as HTMLElement; - // Skip

        inside containers — let the container handle it - if (element.tagName === 'P' && element.parentElement - && containerTags.has(element.parentElement.tagName)) { - node = node.parentNode; - continue; - } - const tag = this.converter.getBlockTags().find( - blockTag => typeof blockTag.selector === 'string' - && blockTag.selector.split(',').some( - selector => element.tagName === selector.trim() - ) - ); - if (tag?.handleKeydown) { - const handled = tag.handleKeydown(element, event, selection, this); - if (handled) { - event.preventDefault(); - return; - } - } - } - node = node.parentNode; - } - } - - /** - * Replace an element with its children. Used to dissolve speculative - * wrappers and fix forbidden nesting — the formatting is removed - * but the text content is preserved. - */ - 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 (e.g. inside , inside - * ) by unwrapping the inner element. Runs as a post-processing - * pass after innerHTML is set, catching cases the regex guards miss. - */ - private sanitizeNesting(block: HTMLElement): void { - const rules: Record = { - 'STRONG': ['STRONG', 'B'], - 'B': ['STRONG', 'B'], - 'EM': ['EM', 'I'], - 'I': ['EM', 'I'], - 'DEL': ['DEL', 'S', 'STRIKE'], - 'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A', 'DEL'], - }; - 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; - } - } - } - } - } - } - - /** - * Unwrap all speculative elements. Called when the user clicks - * outside the editor — nothing should remain speculative. - */ - private closeAllSpeculative(): void { - for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) { - this.unwrapElement(element as HTMLElement); - } - } - - /** - * Unwrap speculative elements the cursor has left. An orphaned - * speculative element was never completed by the user, so it - * should not become permanent formatting. - */ - private closeOrphanedSpeculative(): void { - const speculative = this.element.querySelectorAll('[data-speculative]'); - if (speculative.length === 0) { - return; - } - - const selection = window.getSelection(); - const anchor = selection?.anchorNode; - - for (const element of Array.from(speculative)) { - const htmlElement = element as HTMLElement; - let inside = false; - let node: Node | null = anchor || null; - while (node) { - if (node === htmlElement) { - inside = true; - break; - } - node = node.parentNode; - } - if (!inside) { - this.unwrapElement(htmlElement); - } - } - } - - /** - * Toggle .ribbit-editing on the formatting element containing the - * cursor. CSS uses this class to show delimiter pseudo-elements - * (::before/::after) so the user sees the markdown syntax. - */ - private updateEditingContext(): void { - const prev = this.element.querySelector('.ribbit-editing'); - if (prev) { - prev.classList.remove('ribbit-editing'); - } - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; - } - let node: Node | null = selection.anchorNode; - while (node && node !== this.element) { - if (node.nodeType === 1) { - const element = node as HTMLElement; - // Derive the selector list from registered tags so it - // stays in sync when tags are added or removed - if (element.matches(this.converter.getEditableSelector())) { - element.classList.add('ribbit-editing'); - return; - } - } - node = node.parentNode; - } - } - - /** - * Convert the editor's current HTML back to markdown. - * - * const md = editor.htmlToMarkdown(); - * const md2 = editor.htmlToMarkdown('

        hi

        '); - */ - htmlToMarkdown(html?: string): string { - return this.converter.toMarkdown(html || this.element.innerHTML); - } - - /** - * Get the markdown representation of the current content. - * Behavior depends on mode: edit mode decodes HTML entities from - * the raw source; wysiwyg mode converts the DOM back to markdown. - * - * const md = editor.getMarkdown(); - */ - 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); - } - if (this.getState() === this.states.WYSIWYG || this.getState() === this.states.VIEW) { - // Normalize non-breaking spaces and strip zero-width spaces - // that the WYSIWYG transform inserts for cursor positioning - return this.htmlToMarkdown(this.element.innerHTML) - .replace(/\u00A0/g, ' ') - .replace(/\u200B/g, ''); - } - // Before run() — element has raw markdown as text - return this.element.textContent || ''; - } - - /** - * Switch to WYSIWYG mode with live inline transforms. - * - * editor.wysiwyg(); - * // now typing **bold** immediately wraps in - */ - wysiwyg(): void { - if (this.getState() === this.states.WYSIWYG) return; - const wasEditing = this.getState() === this.states.EDIT; - // Invalidate cache so getHTML() re-converts from current content - this.invalidateCache(); - 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 a block element for the cursor to land in - if (!this.element.firstElementChild) { - this.element.innerHTML = '


        '; - } - Array.from(this.element.querySelectorAll('.macro')).forEach(macroElement => { - const htmlMacro = macroElement as HTMLElement; - if (htmlMacro.dataset.editable === 'false') { - htmlMacro.contentEditable = 'false'; - htmlMacro.style.opacity = '0.5'; - } - }); - this.setState(this.states.WYSIWYG); - } - - /** - * Switch to source editing mode (raw markdown). Requires the theme - * to have sourceMode enabled. Attaches vim keybindings if configured. - * - * editor.edit(); - */ - 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); - } - - /** - * Insert a DOM node at the current cursor position. Used by toolbar - * buttons and macros to inject content. - * - * const img = document.createElement('img'); - * img.src = '/photo.jpg'; - * editor.insertAtCursor(img); - */ - insertAtCursor(node: Node): void { - const selection = window.getSelection()!; - const range = selection.getRangeAt(0); - range.deleteContents(); - range.insertNode(node); - range.setStartAfter(node); - this.element.focus(); - selection.removeAllRanges(); - selection.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 }; diff --git a/src/ts/vim.ts b/src/ts/vim.ts deleted file mode 100644 index 34bfa9a..0000000 --- a/src/ts/vim.ts +++ /dev/null @@ -1,337 +0,0 @@ -/* - * vim.ts — vim keybinding handler for ribbit source edit mode. - * - * Two modes: normal and insert. Activated in source (edit) mode only. - * Esc enters normal mode, i/a/o/O enter insert mode. - * - * Normal mode commands: - * h/j/k/l — cursor movement - * w/b — word forward/back - * 0/$ — line start/end - * gg/G — document start/end - * i — insert before cursor - * a — insert after cursor - * o — new line below, insert - * O — new line above, insert - * x — delete char under cursor - * dd — delete line - * u — undo - * Ctrl+r — redo - */ - -type VimMode = 'normal' | 'insert'; - -/** Direction constants for cursor movement to avoid magic strings. */ -const DIRECTION = { - LEFT: 'left' as const, - RIGHT: 'right' as const, - UP: 'up' as const, - DOWN: 'down' as const, -}; - -/** Selection API direction mappings. */ -const SELECTION_DIRECTION = { - BACKWARD: 'backward' as const, - FORWARD: 'forward' as const, -}; - -/** Selection API granularity mappings. */ -const SELECTION_GRANULARITY = { - CHARACTER: 'character' as const, - LINE: 'line' as const, - WORD: 'word' as const, - LINE_BOUNDARY: 'lineboundary' as const, -}; - -/** Regex to match digit keys for count prefix accumulation. */ -const DIGIT_PATTERN = /^[0-9]$/; - -/** Default repeat count when no count prefix is given. */ -const DEFAULT_REPEAT_COUNT = '1'; - -/** Radix for parsing count prefix strings. */ -const DECIMAL_RADIX = 10; - -/** - * Handles vim-style keybindings in ribbit's source edit mode. - * - * Supports normal and insert modes with standard vim motions, - * editing commands, and count prefixes. - * - * @example - * const vim = new VimHandler((mode) => { - * statusBar.textContent = mode; - * }); - * vim.attach(editorElement); - */ -export class VimHandler { - mode: VimMode; - private element: HTMLElement | null; - private listener: ((event: KeyboardEvent) => void) | null; - private pending: string; - private count: string; - private onModeChange: (mode: VimMode) => void; - - constructor(onModeChange: (mode: VimMode) => void) { - this.mode = 'insert'; - this.element = null; - this.listener = null; - this.pending = ''; - this.count = ''; - this.onModeChange = onModeChange; - } - - /** - * Bind vim keybindings to a DOM element. - * - * @example - * vim.attach(document.getElementById('editor')); - */ - attach(element: HTMLElement): void { - this.detach(); - this.element = element; - this.pending = ''; - this.listener = (event: KeyboardEvent) => this.handleKey(event); - this.element.addEventListener('keydown', this.listener); - this.setMode('insert'); - } - - /** - * Remove vim keybindings from the current element. - * - * @example - * vim.detach(); - */ - detach(): void { - if (this.element && this.listener) { - this.element.removeEventListener('keydown', this.listener); - this.element.classList.remove('vim-normal', 'vim-insert'); - } - this.element = null; - this.listener = null; - this.mode = 'insert'; - this.pending = ''; - } - - private setMode(mode: VimMode): void { - this.mode = mode; - this.pending = ''; - this.count = ''; - this.onModeChange(mode); - } - - /** - * Routes keystrokes to insert-mode or normal-mode handling. - * Insert mode only intercepts Escape; normal mode handles - * all vim commands and suppresses default text input. - */ - private handleKey(event: KeyboardEvent): void { - if (this.mode === 'insert') { - if (event.key === 'Escape') { - event.preventDefault(); - this.setMode('normal'); - } - return; - } - - // Suppress default text input in normal mode - event.preventDefault(); - - if (event.ctrlKey) { - if (event.key === 'r') { - document.execCommand('redo'); - } - return; - } - - const key = event.key; - - // Accumulate count prefix — 0 as first char is line-start, not count - if (DIGIT_PATTERN.test(key) && (this.count || key !== '0')) { - this.count += key; - return; - } - - const repeat = parseInt(this.count || DEFAULT_REPEAT_COUNT, DECIMAL_RADIX); - this.count = ''; - - if (this.pending) { - const combo = this.pending + key; - this.pending = ''; - for (let step = 0; step < repeat; step++) { - this.handlePending(combo); - } - return; - } - - this.dispatchNormalKey(key, repeat); - } - - /** - * Dispatches a normal-mode key to the appropriate command. - * Separated from handleKey to keep nesting shallow. - */ - private dispatchNormalKey(key: string, repeat: number): void { - switch (key) { - case 'i': - this.setMode('insert'); - break; - case 'a': - this.moveCursor(DIRECTION.RIGHT); - this.setMode('insert'); - break; - case 'o': - this.endOfLine(); - this.insertNewline(); - this.setMode('insert'); - break; - case 'O': - this.startOfLine(); - this.insertNewline(); - this.moveCursor(DIRECTION.UP); - this.setMode('insert'); - break; - - case 'h': - for (let step = 0; step < repeat; step++) { - this.moveCursor(DIRECTION.LEFT); - } - break; - case 'j': - for (let step = 0; step < repeat; step++) { - this.moveCursor(DIRECTION.DOWN); - } - break; - case 'k': - for (let step = 0; step < repeat; step++) { - this.moveCursor(DIRECTION.UP); - } - break; - case 'l': - for (let step = 0; step < repeat; step++) { - this.moveCursor(DIRECTION.RIGHT); - } - break; - case 'w': - for (let step = 0; step < repeat; step++) { - this.wordForward(); - } - break; - case 'b': - for (let step = 0; step < repeat; step++) { - this.wordBack(); - } - break; - case '0': - this.startOfLine(); - break; - case '$': - this.endOfLine(); - break; - case 'G': - this.endOfDocument(); - break; - - case 'x': - for (let step = 0; step < repeat; step++) { - this.deleteChar(); - } - break; - case 'u': - for (let step = 0; step < repeat; step++) { - document.execCommand('undo'); - } - break; - - // Two-char commands — preserve count for the second key - case 'd': - case 'g': - this.pending = key; - if (repeat > 1) { - this.count = String(repeat); - } - break; - } - } - - private handlePending(combo: string): void { - switch (combo) { - case 'dd': - this.deleteLine(); - break; - case 'gg': - this.startOfDocument(); - break; - } - } - - private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void { - const selection = window.getSelection(); - if (!selection) { - return; - } - const selectionDirection = (direction === DIRECTION.LEFT || direction === DIRECTION.UP) - ? SELECTION_DIRECTION.BACKWARD - : SELECTION_DIRECTION.FORWARD; - const granularity = (direction === DIRECTION.UP || direction === DIRECTION.DOWN) - ? SELECTION_GRANULARITY.LINE - : SELECTION_GRANULARITY.CHARACTER; - selection.modify('move', selectionDirection, granularity); - } - - private wordForward(): void { - window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.WORD); - } - - private wordBack(): void { - window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.WORD); - } - - private startOfLine(): void { - window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.LINE_BOUNDARY); - } - - private endOfLine(): void { - window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY); - } - - private startOfDocument(): void { - const selection = window.getSelection(); - if (!selection || !this.element) { - return; - } - const range = document.createRange(); - range.setStart(this.element, 0); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } - - private endOfDocument(): void { - const selection = window.getSelection(); - if (!selection || !this.element) { - return; - } - const range = document.createRange(); - range.selectNodeContents(this.element); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - - private deleteChar(): void { - document.execCommand('forwardDelete'); - } - - private deleteLine(): void { - this.startOfLine(); - window.getSelection()?.modify('extend', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY); - document.execCommand('delete'); - // Remove the trailing newline left after deleting line content - document.execCommand('forwardDelete'); - } - - private insertNewline(): void { - document.execCommand('insertLineBreak'); - } -} diff --git a/test/editor.test.ts b/test/editor.test.ts index 21138a0..9eff86f 100644 --- a/test/editor.test.ts +++ b/test/editor.test.ts @@ -107,14 +107,6 @@ describe('RibbitEditor modes', () => { expect(editor.element.contentEditable).toBe('true'); }); - it('switches to edit', () => { - const editor = new lib.Editor({}); - editor.run(); - editor.wysiwyg(); - editor.edit(); - expect(editor.getState()).toBe('edit'); - }); - it('switches back to view', () => { const editor = new lib.Editor({}); editor.run(); @@ -135,25 +127,10 @@ describe('RibbitEditor modes', () => { }); editor.run(); editor.wysiwyg(); - editor.edit(); editor.view(); expect(modes).toEqual(['view', 'wysiwyg', 'edit', 'view']); }); - it('sourceMode disabled blocks edit', () => { - resetDOM(); - const editor = new lib.Editor({ - currentTheme: 'no-source', - themes: [{ - name: 'no-source', - features: { sourceMode: false }, - }], - }); - editor.run(); - editor.wysiwyg(); - editor.edit(); - expect(editor.getState()).toBe('wysiwyg'); - }); }); describe('ThemeManager', () => { @@ -229,7 +206,6 @@ describe('defaultTheme', () => { it('has correct shape', () => { expect(lib.defaultTheme.name).toBe('ribbit-default'); expect(lib.defaultTheme.tags).toBeDefined(); - expect(lib.defaultTheme.features.sourceMode).toBe(true); }); }); @@ -252,17 +228,28 @@ describe('Utility functions', () => { }); describe('Editor htmlToMarkdown', () => { - beforeEach(() => resetDOM()); - - it('converts strong', () => { + it('returns markdown in view state', () => { + resetDOM('**bold**'); const editor = new lib.Editor({}); + console.log(editor.getMarkdown()); editor.run(); - expect(editor.htmlToMarkdown('bold')).toBe('**bold**'); + console.log(editor.getMarkdown()); + expect(editor.getMarkdown()).toBe('**bold**'); }); - it('converts em', () => { + it('returns markdown in wysiwyg state', () => { + resetDOM('**bold**'); const editor = new lib.Editor({}); editor.run(); - expect(editor.htmlToMarkdown('italic')).toBe('*italic*'); + editor.wysiwyg(); + expect(editor.getMarkdown()).toBe('**bold**'); + }); + + it('round-trips inline formatting', () => { + resetDOM('hello **world** and *italic*'); + const editor = new lib.Editor({}); + editor.run(); + editor.wysiwyg(); + expect(editor.getMarkdown()).toBe('hello **world** and *italic*'); }); }); diff --git a/test/vim.test.ts b/test/vim.test.ts deleted file mode 100644 index 7172c56..0000000 --- a/test/vim.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { ribbit, resetDOM } from './setup'; - -const lib = ribbit(); - -describe('VimHandler', () => { - beforeEach(() => resetDOM('hello world')); - - it('starts in insert mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - expect(editor.element.classList.contains('vim-insert')).toBe(true); - }); - - it('Esc enters normal mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - expect(editor.element.classList.contains('vim-normal')).toBe(true); - expect(editor.element.classList.contains('vim-insert')).toBe(false); - }); - - it('i returns to insert mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - // Enter normal mode - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - // Back to insert - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' })); - expect(editor.element.classList.contains('vim-insert')).toBe(true); - expect(editor.element.classList.contains('vim-normal')).toBe(false); - }); - - it('disables toolbar in normal mode', () => { - const editor = new lib.Editor({ - autoToolbar: false, - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.toolbar.render(); - editor.edit(); - editor.toolbar.enable(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - const bold = editor.toolbar.buttons.get('bold'); - expect(bold?.element?.classList.contains('disabled')).toBe(true); - }); - - it('re-enables toolbar in insert mode', () => { - const editor = new lib.Editor({ - autoToolbar: false, - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.toolbar.render(); - editor.edit(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' })); - const bold = editor.toolbar.buttons.get('bold'); - expect(bold?.element?.classList.contains('disabled')).toBe(false); - }); - - it('detaches when leaving edit mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - expect(editor.element.classList.contains('vim-normal')).toBe(true); - editor.wysiwyg(); - // vim classes should be gone after mode switch - expect(editor.element.classList.contains('vim-normal')).toBe(false); - expect(editor.element.classList.contains('vim-insert')).toBe(false); - }); - - it('only activates in edit mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.wysiwyg(); - // Esc in wysiwyg should not add vim classes - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - expect(editor.element.classList.contains('vim-normal')).toBe(false); - }); -});