diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts
index 609681b..262408d 100644
--- a/src/ts/ribbit-editor.ts
+++ b/src/ts/ribbit-editor.ts
@@ -37,35 +37,68 @@ import { type MacroDef } from './macros';
const EDITING_CONTEXT_CLASS = 'ribbit-editing';
// CSS class prefix for all styled-source block divs.
-// Each block div gets one of: md-paragraph, md-h1…md-h6,
-// md-blockquote, md-list-item, md-ol-list-item, md-pre.
const BLOCK_CLASS_PREFIX = 'md-';
// CSS class applied to all delimiter spans (e.g. the ** in **bold**).
-// CSS hides these in viewing state, reveals them in editing state.
const DELIM_CLASS = 'md-delim';
// CSS class applied to list-item prefix spans (e.g. "- " or "1. ").
-// Kept in textContent so getMarkdown() sees the marker; CSS hides
-// it in viewing state and replaces it with a real list-item bullet.
const LIST_PREFIX_CLASS = 'md-list-prefix';
-// data- attribute on inline formatting spans, used to identify them
-// during selectionchange so we can toggle EDITING_CONTEXT_CLASS.
+// data- attribute on inline formatting spans.
const INLINE_SPAN_ATTR = 'data-md-span';
+// Characters this implementation inserts purely as internal bookkeeping
+// (never something the user typed, never part of real markdown content):
+// \u200B — placeholder text node so an empty block-prefix line (e.g.
+// "# " with nothing after it) still has a text node for the
+// caret to land in.
+// \u200C — boundary marker inserted by disambiguateAsteriskRuns
+// between two adjacent closing delimiters that would
+// otherwise form an ambiguous run (e.g. "***" closing both
+// an inner "*" and an outer "**").
+// These are stripped entirely from getMarkdown() output and skipped
+// (never counted) by caret-offset math in both directions. \u00A0 is
+// handled separately by normalizeLineText below — it is NOT an
+// internal marker, since it stands in for a real space the user typed.
+const INTERNAL_MARKER_CHARS = '\u200B\u200C';
+const INTERNAL_MARKER_PATTERN = new RegExp(`[${INTERNAL_MARKER_CHARS}]`, 'g');
+const INTERNAL_MARKER_SINGLE = new RegExp(`^[${INTERNAL_MARKER_CHARS}]$`);
+
+/**
+ * Remove all internal bookkeeping markers from a string.
+ * stripInternalMarkers('**bold *italic*\u200C**') // '**bold *italic***'
+ */
+function stripInternalMarkers(text: string): string {
+ return text.replace(INTERNAL_MARKER_PATTERN, '');
+}
+
+/**
+ * True if a single character is purely internal bookkeeping.
+ * isInternalMarker('\u200C') // true
+ */
+function isInternalMarker(character: string): boolean {
+ return INTERNAL_MARKER_SINGLE.test(character);
+}
+
+/**
+ * Normalize a block's raw textContent into clean markdown source:
+ * convert NBSP (inserted for Chromium caret-stability when a line ends
+ * in a space — never deleted, since it represents a real space the
+ * user typed) back to a plain space, then strip internal bookkeeping
+ * markers (never user content, always deleted entirely).
+ *
+ * normalizeLineText('#\u00A0Title\u200C') // '# Title'
+ */
+function normalizeLineText(text: string): string {
+ return stripInternalMarkers(text.replace(/\u00A0/g, ' '));
+}
+
// ─── Block classification ─────────────────────────────────────────────────────
-// A block rule maps a test against a raw markdown line to a CSS class
-// and the length of the prefix that should be wrapped in .md-delim.
-// Rules are checked in order; the first match wins.
interface BlockRule {
- // Name used to build the CSS class: md-{name}
name: string;
- // Returns the length of the block prefix (e.g. 3 for "## "),
- // or null if this rule does not match the line.
prefixLength: (line: string) => number | null;
- // True if the prefix should use LIST_PREFIX_CLASS instead of DELIM_CLASS.
isList?: boolean;
}
@@ -74,7 +107,6 @@ const BLOCKQUOTE_PATTERN = /^> /;
const UNORDERED_LIST_PATTERN = /^[-*+] /;
const ORDERED_LIST_PATTERN = /^\d+\. /;
-// Block rules in priority order. Paragraph is the implicit fallback.
const BLOCK_RULES: BlockRule[] = [
{
name: 'pre',
@@ -117,13 +149,101 @@ const BLOCK_RULES: BlockRule[] = [
},
];
+// ─── Inline delimiter disambiguation ───────────────────────────────────────────
+
+/**
+ * Pre-pass over a line of markdown text that disambiguates runs of 3
+ * consecutive asterisks (e.g. "**bold *italic***") into two separate,
+ * unambiguous tokens, separated by a sentinel.
+ *
+ * Markdown allows * and ** to nest (one inside the other) but this
+ * implementation does not support nesting a delimiter inside itself,
+ * so at most two distinct asterisk-based delimiters can be open at
+ * once. A run of exactly 3 asterisks therefore always means "open or
+ * close the most-recently-opened one first, then the other."
+ *
+ * Handles both directions of the ambiguity:
+ * - closing-side: "**bold *italic***" — the trailing run of 3
+ * closes italic (innermost) then bold.
+ * - opening-side: "***italic* bold**" — the leading run of 3 opens
+ * italic then bold (by convention, single-char delimiter is
+ * treated as inner/most-recently-opened, matching the closing
+ * rule, for symmetry).
+ *
+ * disambiguateAsteriskRuns('**bold *italic***')
+ * // '**bold *italic*\u200C**'
+ */
+function disambiguateAsteriskRuns(line: string): string {
+ const SENTINEL = '\u200C';
+ const ASTERISK_RUN = /\*{1,3}/g;
+ const stack: ('*' | '**' | '***')[] = [];
+ let result = '';
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ const bold = '**';
+ const italic = '*';
+
+ let lastSequence = '';
+
+ while ((match = ASTERISK_RUN.exec(line)) !== null) {
+ result += line.slice(lastIndex, match.index);
+ const sequence = match[0];
+
+ if (sequence.length === 3) {
+
+ // split *** into opening ** and *
+ if (stack.length === 0) {
+ result += sequence;
+ stack.push('***');
+
+ // closing ***, close the stack
+ } else if (stack.length === 1 && stack[0] === sequence) {
+ result = result.replace('***', bold + SENTINEL + italic);
+ result += italic + SENTINEL + bold;
+ stack.pop();
+
+ } else if (stack.length === 2) {
+ const inner = stack.pop();
+ const outer = stack.pop();
+ result += inner + SENTINEL + outer;
+
+ } else if (stack.length === 1) {
+ console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
+
+ } else {
+ console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
+ }
+ } else if (stack.length) {
+ if (stack[stack.length - 1] === sequence) {
+ result += stack.pop();
+ } else if (stack.length === 1 && stack[0] === '***') {
+ const opener = stack[0].substring(0, stack[0].length - sequence.length);
+ result = result.replace('***', opener + SENTINEL + sequence);
+ result += sequence;
+ stack[0] = opener as '*' | '**';
+ } else {
+ stack.push(sequence as '*' | '**');
+ result += sequence;
+ }
+ } else {
+ stack.push(sequence as '*' | '**');
+ result += sequence;
+ }
+
+ lastIndex = match.index + sequence.length;
+ }
+
+ result += line.slice(lastIndex);
+ return result;
+}
+
// ─── RibbitEditor ─────────────────────────────────────────────────────────────
/**
* Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with
- * contentEditable support. The user always edits raw markdown; CSS renders
- * it visually. Replaces the old flatten→rebuild pipeline with a per-line
- * incremental DOM update [see STYLED_SOURCE_DESIGN.md].
+ * contentEditable support. The user always edits raw markdown; CSS
+ * renders it visually. Per-line incremental DOM update.
*
* const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run();
@@ -131,41 +251,14 @@ const BLOCK_RULES: BlockRule[] = [
*/
export class RibbitEditor extends Ribbit {
- // The formatting span the cursor was last inside. Tracked so we
- // can remove EDITING_CONTEXT_CLASS when the cursor moves away.
private activeFormattingSpan: HTMLElement | null = null;
- /**
- * Initialize the editor with view/wysiwyg states, bind DOM events,
- * and optionally attach vim keybindings.
- *
- * const editor = new RibbitEditor({ editorId: 'content' });
- * editor.run();
- * editor.wysiwyg(); // enter styled-source editing
- */
run(): void {
this.states = {
VIEW: 'view',
WYSIWYG: 'wysiwyg',
};
- if (this.theme.features?.vim) {
- // TODO
- /*
- 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) {
@@ -175,8 +268,6 @@ export class RibbitEditor extends Ribbit {
this.emitReady();
}
- // ── Event binding ──────────────────────────────────────────────────────────
-
#bindEvents(): void {
let debounceTimer: number | undefined;
@@ -191,31 +282,23 @@ export class RibbitEditor extends Ribbit {
}, 300);
});
- /*
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
- this.#dispatchKeydown(event);
- });
- */
-
- this.element.addEventListener('keydown', (event: KeyboardEvent) => {
- if (this.state !== this.states.WYSIWYG) return;
if (event.key === 'Backspace') {
- // Check if editor is already empty or about to become a bare
const children = Array.from(this.element.children);
const onlyChild = children.length === 1 ? children[0] as HTMLElement : null;
- const isEmpty = onlyChild &&
- !onlyChild.textContent!.replace(/\u200B/g, '').trim() &&
+ const isEmpty = onlyChild &&
+ !stripInternalMarkers(onlyChild.textContent!).trim() &&
onlyChild.querySelector('br');
if (isEmpty) {
event.preventDefault();
return;
}
}
-
+
this.#dispatchKeydown(event);
});
@@ -245,29 +328,16 @@ export class RibbitEditor extends Ribbit {
});
}
- // ── Mode switching ─────────────────────────────────────────────────────────
-
- /**
- * Switch to styled-source editing mode. Renders the current markdown
- * as a styled DOM (one block div per line) and enables contentEditable.
- *
- * editor.wysiwyg();
- * // user now edits markdown directly with CSS rendering
- */
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) {
return;
}
- // Capture markdown before building the styled DOM, so getMarkdown()
- // in wysiwyg state reads from the live styled DOM rather than
- // sourceMarkdown (which belongs to view state).
const markdown = this.getMarkdown();
this.sourceMarkdown = null;
this.collaboration?.connect();
this.element.innerHTML = '';
this.element.appendChild(this.#markdownToStyledDOM(markdown));
this.element.contentEditable = 'true';
- // Macro islands are non-editable; their source is in data-source
for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) {
const htmlMacro = macroElement as HTMLElement;
if (htmlMacro.dataset.editable === 'false') {
@@ -277,36 +347,15 @@ export class RibbitEditor extends Ribbit {
this.setState(this.states.WYSIWYG);
}
- /**
- * Convert the editor's current styled DOM back to markdown.
- * In wysiwyg state reads directly from the styled DOM — because
- * every delimiter lives in a .md-delim text node, textContent
- * always equals the original markdown source [see STYLED_SOURCE_DESIGN.md].
- * In view state delegates to the base class which reads sourceMarkdown.
- *
- * const markdown = editor.getMarkdown(); // "**hello** world"
- */
getMarkdown(): string {
if (this.getState() === this.states.WYSIWYG) {
- // Each block div's textContent is one markdown line.
- // Macro islands emit their data-source value instead.
return Array.from(this.element.children)
.map((block) => this.#blockToMarkdown(block as HTMLElement))
.join('\n');
}
- // VIEW state: delegate to base class, which reads sourceMarkdown
- // (set by view() before rendering) or falls back to textContent.
return super.getMarkdown();
}
- /**
- * 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);
@@ -318,15 +367,6 @@ export class RibbitEditor extends Ribbit {
selection.addRange(range);
}
- // ── Styled DOM construction ────────────────────────────────────────────────
-
- /**
- * Convert a full markdown string to a styled-source DocumentFragment.
- * One block