Compare commits
3 Commits
9f03721f86
...
57361f8c55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57361f8c55 | ||
|
|
a2519a1282 | ||
|
|
f27378dc04 |
|
|
@ -37,35 +37,68 @@ import { type MacroDef } from './macros';
|
||||||
const EDITING_CONTEXT_CLASS = 'ribbit-editing';
|
const EDITING_CONTEXT_CLASS = 'ribbit-editing';
|
||||||
|
|
||||||
// CSS class prefix for all styled-source block divs.
|
// 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-';
|
const BLOCK_CLASS_PREFIX = 'md-';
|
||||||
|
|
||||||
// CSS class applied to all delimiter spans (e.g. the ** in **bold**).
|
// 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';
|
const DELIM_CLASS = 'md-delim';
|
||||||
|
|
||||||
// CSS class applied to list-item prefix spans (e.g. "- " or "1. ").
|
// 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';
|
const LIST_PREFIX_CLASS = 'md-list-prefix';
|
||||||
|
|
||||||
// data- attribute on inline formatting spans, used to identify them
|
// data- attribute on inline formatting spans.
|
||||||
// during selectionchange so we can toggle EDITING_CONTEXT_CLASS.
|
|
||||||
const INLINE_SPAN_ATTR = 'data-md-span';
|
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 ─────────────────────────────────────────────────────
|
// ─── 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 {
|
interface BlockRule {
|
||||||
// Name used to build the CSS class: md-{name}
|
|
||||||
name: string;
|
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;
|
prefixLength: (line: string) => number | null;
|
||||||
// True if the prefix should use LIST_PREFIX_CLASS instead of DELIM_CLASS.
|
|
||||||
isList?: boolean;
|
isList?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +107,6 @@ const BLOCKQUOTE_PATTERN = /^> /;
|
||||||
const UNORDERED_LIST_PATTERN = /^[-*+] /;
|
const UNORDERED_LIST_PATTERN = /^[-*+] /;
|
||||||
const ORDERED_LIST_PATTERN = /^\d+\. /;
|
const ORDERED_LIST_PATTERN = /^\d+\. /;
|
||||||
|
|
||||||
// Block rules in priority order. Paragraph is the implicit fallback.
|
|
||||||
const BLOCK_RULES: BlockRule[] = [
|
const BLOCK_RULES: BlockRule[] = [
|
||||||
{
|
{
|
||||||
name: 'pre',
|
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 ─────────────────────────────────────────────────────────────
|
// ─── RibbitEditor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with
|
* Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with
|
||||||
* contentEditable support. The user always edits raw markdown; CSS renders
|
* contentEditable support. The user always edits raw markdown; CSS
|
||||||
* it visually. Replaces the old flatten→rebuild pipeline with a per-line
|
* renders it visually. Per-line incremental DOM update.
|
||||||
* incremental DOM update [see STYLED_SOURCE_DESIGN.md].
|
|
||||||
*
|
*
|
||||||
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
||||||
* editor.run();
|
* editor.run();
|
||||||
|
|
@ -131,41 +251,14 @@ const BLOCK_RULES: BlockRule[] = [
|
||||||
*/
|
*/
|
||||||
export class RibbitEditor extends Ribbit {
|
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;
|
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 {
|
run(): void {
|
||||||
this.states = {
|
this.states = {
|
||||||
VIEW: 'view',
|
VIEW: 'view',
|
||||||
WYSIWYG: 'wysiwyg',
|
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.#bindEvents();
|
||||||
this.element.classList.add('loaded');
|
this.element.classList.add('loaded');
|
||||||
if (this.autoToolbar) {
|
if (this.autoToolbar) {
|
||||||
|
|
@ -175,8 +268,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
this.emitReady();
|
this.emitReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Event binding ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#bindEvents(): void {
|
#bindEvents(): void {
|
||||||
let debounceTimer: number | undefined;
|
let debounceTimer: number | undefined;
|
||||||
|
|
||||||
|
|
@ -191,24 +282,16 @@ export class RibbitEditor extends Ribbit {
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
|
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
if (this.state !== this.states.WYSIWYG) {
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#dispatchKeydown(event);
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|
||||||
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
||||||
if (this.state !== this.states.WYSIWYG) return;
|
|
||||||
|
|
||||||
if (event.key === 'Backspace') {
|
if (event.key === 'Backspace') {
|
||||||
// Check if editor is already empty or about to become a bare <br>
|
|
||||||
const children = Array.from(this.element.children);
|
const children = Array.from(this.element.children);
|
||||||
const onlyChild = children.length === 1 ? children[0] as HTMLElement : null;
|
const onlyChild = children.length === 1 ? children[0] as HTMLElement : null;
|
||||||
const isEmpty = onlyChild &&
|
const isEmpty = onlyChild &&
|
||||||
!onlyChild.textContent!.replace(/\u200B/g, '').trim() &&
|
!stripInternalMarkers(onlyChild.textContent!).trim() &&
|
||||||
onlyChild.querySelector('br');
|
onlyChild.querySelector('br');
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -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 {
|
wysiwyg(): void {
|
||||||
if (this.getState() === this.states.WYSIWYG) {
|
if (this.getState() === this.states.WYSIWYG) {
|
||||||
return;
|
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();
|
const markdown = this.getMarkdown();
|
||||||
this.sourceMarkdown = null;
|
this.sourceMarkdown = null;
|
||||||
this.collaboration?.connect();
|
this.collaboration?.connect();
|
||||||
this.element.innerHTML = '';
|
this.element.innerHTML = '';
|
||||||
this.element.appendChild(this.#markdownToStyledDOM(markdown));
|
this.element.appendChild(this.#markdownToStyledDOM(markdown));
|
||||||
this.element.contentEditable = 'true';
|
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'))) {
|
for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) {
|
||||||
const htmlMacro = macroElement as HTMLElement;
|
const htmlMacro = macroElement as HTMLElement;
|
||||||
if (htmlMacro.dataset.editable === 'false') {
|
if (htmlMacro.dataset.editable === 'false') {
|
||||||
|
|
@ -277,36 +347,15 @@ export class RibbitEditor extends Ribbit {
|
||||||
this.setState(this.states.WYSIWYG);
|
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 {
|
getMarkdown(): string {
|
||||||
if (this.getState() === this.states.WYSIWYG) {
|
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)
|
return Array.from(this.element.children)
|
||||||
.map((block) => this.#blockToMarkdown(block as HTMLElement))
|
.map((block) => this.#blockToMarkdown(block as HTMLElement))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
// VIEW state: delegate to base class, which reads sourceMarkdown
|
|
||||||
// (set by view() before rendering) or falls back to textContent.
|
|
||||||
return super.getMarkdown();
|
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 {
|
insertAtCursor(node: Node): void {
|
||||||
const selection = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
|
|
@ -318,15 +367,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Styled DOM construction ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a full markdown string to a styled-source DocumentFragment.
|
|
||||||
* One block <div> per line; inline delimiters wrapped in .md-delim spans.
|
|
||||||
* Called once on wysiwyg() entry and after paste.
|
|
||||||
*
|
|
||||||
* editor.element.appendChild(editor.#markdownToStyledDOM('# Hello\n**bold**'));
|
|
||||||
*/
|
|
||||||
#markdownToStyledDOM(markdown: string): DocumentFragment {
|
#markdownToStyledDOM(markdown: string): DocumentFragment {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const line of markdown.split('\n')) {
|
for (const line of markdown.split('\n')) {
|
||||||
|
|
@ -335,71 +375,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-pass over a line of markdown text that disambiguates closing
|
|
||||||
* runs of 3 consecutive asterisks (e.g. "**bold *italic***") into two
|
|
||||||
* separate, unambiguous closing tokens, separated by a sentinel.
|
|
||||||
*
|
|
||||||
* Markdown allows *and* ** to nest (one inside the other) but never a
|
|
||||||
* delimiter inside itself, so at most two distinct asterisk-based
|
|
||||||
* delimiters can be open at once. A run of exactly 3 closing asterisks
|
|
||||||
* therefore always means "close the most-recently-opened one (top of
|
|
||||||
* stack), then close the other" — there is no other valid reading.
|
|
||||||
*
|
|
||||||
* disambiguateAsteriskRuns('**bold *italic***')
|
|
||||||
* // '**bold *italic*\u200C**' (sentinel inserted between closers)
|
|
||||||
*/
|
|
||||||
#disambiguateAsteriskRuns(line: string): string {
|
|
||||||
const SENTINEL = '\u200C'; // zero-width non-joiner, distinct from
|
|
||||||
// the \u200B already used for caret placeholders
|
|
||||||
const ASTERISK_RUN = /\*{1,3}/g;
|
|
||||||
const stack: ('*' | '**')[] = [];
|
|
||||||
let result = '';
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
|
||||||
result += line.slice(lastIndex, match.index);
|
|
||||||
const run = match[0];
|
|
||||||
|
|
||||||
if (run.length === 3 && stack.length === 2) {
|
|
||||||
// Ambiguous: split into [top-of-stack closer][sentinel][remaining closer]
|
|
||||||
const innerCloser = stack.pop()!; // most recently opened
|
|
||||||
const outerCloser = stack.pop()!; // closes after inner
|
|
||||||
result += innerCloser + SENTINEL + outerCloser;
|
|
||||||
} else if (run.length === 3) {
|
|
||||||
// *** as a single atomic bold-italic token (open or close,
|
|
||||||
// not part of an ambiguous nested pair) — pass through.
|
|
||||||
result += run;
|
|
||||||
} else if (stack.length > 0 && stack[stack.length - 1] === run) {
|
|
||||||
// Closing the most recently opened delimiter of this exact length
|
|
||||||
stack.pop();
|
|
||||||
result += run;
|
|
||||||
} else {
|
|
||||||
// Opening a new delimiter
|
|
||||||
stack.push(run as '*' | '**');
|
|
||||||
result += run;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = match.index + run.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
result += line.slice(lastIndex);
|
|
||||||
console.log(`DEBUG:\n input: ${line}\n output: ${result}`);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a single styled block <div> from one markdown line.
|
|
||||||
* Classifies the line, wraps the block prefix in a .md-delim span,
|
|
||||||
* and parses inline formatting for the remaining content.
|
|
||||||
*
|
|
||||||
* this.#buildBlock('## Hello **world**')
|
|
||||||
* // <div class="md-h2">
|
|
||||||
* // <span class="md-delim">## </span>
|
|
||||||
* // Hello <span class="md-bold">…</span>
|
|
||||||
* // </div>
|
|
||||||
*/
|
|
||||||
#buildBlock(line: string): HTMLDivElement {
|
#buildBlock(line: string): HTMLDivElement {
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
|
|
||||||
|
|
@ -415,7 +390,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Headings carry their level in the class name [C10]
|
|
||||||
if (rule.name === 'heading') {
|
if (rule.name === 'heading') {
|
||||||
const match = line.match(HEADING_PATTERN)!;
|
const match = line.match(HEADING_PATTERN)!;
|
||||||
block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`;
|
block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`;
|
||||||
|
|
@ -437,7 +411,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: plain paragraph
|
|
||||||
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
|
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
|
||||||
block.appendChild(this.#parseInline(line));
|
block.appendChild(this.#parseInline(line));
|
||||||
return block;
|
return block;
|
||||||
|
|
@ -448,8 +421,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
span.className = className;
|
span.className = className;
|
||||||
span.setAttribute(INLINE_SPAN_ATTR, '1');
|
span.setAttribute(INLINE_SPAN_ATTR, '1');
|
||||||
|
|
||||||
if (span.className == 'md-link') {
|
if (span.className === 'md-link') {
|
||||||
// Link: [label](href)
|
|
||||||
span.appendChild(this.#makeDelimSpan('['));
|
span.appendChild(this.#makeDelimSpan('['));
|
||||||
const linkTextNode = document.createElement('span');
|
const linkTextNode = document.createElement('span');
|
||||||
linkTextNode.className = 'md-link-text';
|
linkTextNode.className = 'md-link-text';
|
||||||
|
|
@ -459,14 +431,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delimiter run: **content**, *content*, `content`, ~~content~~
|
|
||||||
span.appendChild(this.#makeDelimSpan(match.groups!.delimiter));
|
span.appendChild(this.#makeDelimSpan(match.groups!.delimiter));
|
||||||
|
|
||||||
// Recurse: content may itself contain nested delimiters
|
|
||||||
// (e.g. "*italic*" inside a "**bold ... **" span). Each
|
|
||||||
// recursive call only ever sees the substring between this
|
|
||||||
// span's own delimiters, so nesting depth is naturally
|
|
||||||
// bounded by the original text's structure.
|
|
||||||
span.appendChild(this.#parseInline(match.groups!.content));
|
span.appendChild(this.#parseInline(match.groups!.content));
|
||||||
if (match.groups!.closer) {
|
if (match.groups!.closer) {
|
||||||
span.appendChild(this.#makeDelimSpan(match.groups!.closer));
|
span.appendChild(this.#makeDelimSpan(match.groups!.closer));
|
||||||
|
|
@ -475,30 +440,28 @@ export class RibbitEditor extends Ribbit {
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse an inline markdown string into a DocumentFragment of text
|
|
||||||
* nodes and styled <span> elements. Each span wraps its delimiters
|
|
||||||
* in .md-delim children so that span.textContent == the original
|
|
||||||
* markdown source (enabling getMarkdown() = textContent).
|
|
||||||
*
|
|
||||||
* this.#parseInline('hello **world** and `code`')
|
|
||||||
*/
|
|
||||||
#parseInline(text: string): DocumentFragment {
|
#parseInline(text: string): DocumentFragment {
|
||||||
|
var t = text;
|
||||||
|
text = disambiguateAsteriskRuns(text);
|
||||||
const classes: Record<string, string> = {
|
const classes: Record<string, string> = {
|
||||||
'***': 'md-bold-italic',
|
|
||||||
'**': 'md-bold',
|
'**': 'md-bold',
|
||||||
'*': 'md-italic',
|
'*': 'md-italic',
|
||||||
'~~': 'md-strikethrough',
|
'~~': 'md-strikethrough',
|
||||||
'`': 'md-code',
|
'`': 'md-code',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combined pattern: try a link first, then a delimiter run.
|
// ***bold italic***
|
||||||
// Named groups are mutually exclusive per match (only one branch fires).
|
// ***italic* bold**
|
||||||
|
// **bold *italic***
|
||||||
|
// ***bold** italic* x
|
||||||
|
// *italic **bold*** x
|
||||||
|
|
||||||
const INLINE_PATTERN = new RegExp(
|
const INLINE_PATTERN = new RegExp(
|
||||||
'\\[(?<linkLabel>[^\\]]+)\\]\\((?<linkHref>[^)]+)\\)' +
|
'\\[(?<linkLabel>[^\\]]+)\\]\\((?<linkHref>[^)]+)\\)' +
|
||||||
'|' +
|
'|' +
|
||||||
'(?<delimiter>\\*{1,3}|~~|`)(?<content>.+?)(?<closer>\\k<delimiter>|$)',
|
'(?<delimiter>\\*{2}|\\*|~~|`)' +
|
||||||
|
'(?<content>[^*].+?)' +
|
||||||
|
'(?<closer>\\k<delimiter>(?!\\*))',
|
||||||
'g'
|
'g'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -506,9 +469,8 @@ export class RibbitEditor extends Ribbit {
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
text = this.#disambiguateAsteriskRuns(text);
|
|
||||||
|
|
||||||
while ((match = INLINE_PATTERN.exec(text)) !== null) {
|
while ((match = INLINE_PATTERN.exec(text)) !== null) {
|
||||||
|
console.log(`DELIM: ${match.groups!.delimiter} CONTENT: ${match.groups!.content} CLOSER: ${match.groups!.closer}`);
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
|
||||||
}
|
}
|
||||||
|
|
@ -521,10 +483,10 @@ export class RibbitEditor extends Ribbit {
|
||||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`'${t}' => '${text.replace('\u200C', '|')}`, fragment.children);
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a <span class="md-delim"> with the given text content. */
|
|
||||||
#makeDelimSpan(text: string): HTMLSpanElement {
|
#makeDelimSpan(text: string): HTMLSpanElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = DELIM_CLASS;
|
span.className = DELIM_CLASS;
|
||||||
|
|
@ -532,22 +494,14 @@ export class RibbitEditor extends Ribbit {
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-line incremental update ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On each input event, find the block div containing the cursor,
|
|
||||||
* read its textContent (which is valid markdown), and rebuild only
|
|
||||||
* that block's children. The rest of the document is untouched.
|
|
||||||
*
|
|
||||||
* Complexity: O(block length) per keystroke, not O(document length).
|
|
||||||
*/
|
|
||||||
|
|
||||||
#updateCurrentBlock(): void {
|
#updateCurrentBlock(): void {
|
||||||
const block = this.#findCurrentBlock();
|
const block = this.#findCurrentBlock();
|
||||||
if (!block) return;
|
if (!block) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const caretOffset = this.#getCaretOffset(block);
|
const caretOffset = this.#getCaretOffset(block);
|
||||||
const lineText = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, '');
|
const lineText = normalizeLineText(block.textContent!);
|
||||||
|
|
||||||
const newBlock = this.#buildBlock(lineText);
|
const newBlock = this.#buildBlock(lineText);
|
||||||
block.className = newBlock.className;
|
block.className = newBlock.className;
|
||||||
|
|
@ -556,18 +510,27 @@ export class RibbitEditor extends Ribbit {
|
||||||
block.appendChild(newBlock.firstChild);
|
block.appendChild(newBlock.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chromium drops the caret-positioning effect of a trailing plain
|
/*
|
||||||
// space in some cases; swapping it for a non-breaking space on the
|
|
||||||
// live text node (never via innerHTML) keeps the caret sticky
|
|
||||||
// without touching how lineText is computed on the next keystroke.
|
|
||||||
if (lineText.endsWith(' ')) {
|
if (lineText.endsWith(' ')) {
|
||||||
const lastChild = block.lastChild;
|
const lastChild = block.lastChild;
|
||||||
if (lastChild) {
|
if (lastChild && lastChild.nodeType === 3) {
|
||||||
lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0');
|
lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
if (lineText.endsWith(' ')) {
|
||||||
|
// Find the last text node in the block (may be nested inside spans)
|
||||||
|
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||||
|
let lastTextNode: Text | null = null;
|
||||||
|
let node: Text | null;
|
||||||
|
while ((node = walker.nextNode() as Text | null)) {
|
||||||
|
lastTextNode = node;
|
||||||
|
}
|
||||||
|
if (lastTextNode) {
|
||||||
|
lastTextNode.textContent = lastTextNode.textContent!.replace(/ $/, '\u00A0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Place caret after any prefix span, never inside it
|
|
||||||
const prefixSpan = block.firstElementChild;
|
const prefixSpan = block.firstElementChild;
|
||||||
const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) ||
|
const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) ||
|
||||||
prefixSpan?.classList.contains(LIST_PREFIX_CLASS))
|
prefixSpan?.classList.contains(LIST_PREFIX_CLASS))
|
||||||
|
|
@ -592,15 +555,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
this.#updateEditingContext();
|
this.#updateEditingContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboard handling ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle Enter and Backspace ourselves; route all other keys to the
|
|
||||||
* block tag's handleKeydown if it has one.
|
|
||||||
*/
|
|
||||||
#dispatchKeydown(event: KeyboardEvent): void {
|
#dispatchKeydown(event: KeyboardEvent): void {
|
||||||
// Dispatch to the block tag's own key handler first, so that
|
|
||||||
// tags like HeadingTag and ListTag can override Enter/Backspace.
|
|
||||||
const block = this.#findCurrentBlock();
|
const block = this.#findCurrentBlock();
|
||||||
if (block) {
|
if (block) {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
@ -636,25 +591,20 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter: split the current block at the caret into two block divs.
|
|
||||||
* Before: one div with text "hello|world"
|
|
||||||
* After: two divs — "hello" and "world"
|
|
||||||
*/
|
|
||||||
#handleEnter(): void {
|
#handleEnter(): void {
|
||||||
const block = this.#findCurrentBlock();
|
const block = this.#findCurrentBlock();
|
||||||
if (!block) return;
|
if (!block) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const offset = this.#getCaretOffset(block);
|
const offset = this.#getCaretOffset(block);
|
||||||
const text = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, '');
|
const text = normalizeLineText(block.textContent!);
|
||||||
|
|
||||||
// Detect prefix
|
const prefixSpan = block.firstElementChild;
|
||||||
const prefixSpan = block.firstElementChild;
|
const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS);
|
||||||
const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS);
|
const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) &&
|
||||||
const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) &&
|
|
||||||
block.classList.contains('md-blockquote');
|
block.classList.contains('md-blockquote');
|
||||||
|
|
||||||
// Determine what prefix the next line should carry
|
|
||||||
let prefix = '';
|
let prefix = '';
|
||||||
if (isListPrefix) {
|
if (isListPrefix) {
|
||||||
const orderedMatch = prefixSpan!.textContent!.match(/^(?<num>\d+)\. /);
|
const orderedMatch = prefixSpan!.textContent!.match(/^(?<num>\d+)\. /);
|
||||||
|
|
@ -668,17 +618,16 @@ export class RibbitEditor extends Ribbit {
|
||||||
prefix = prefixSpan!.textContent!;
|
prefix = prefixSpan!.textContent!;
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log('handleEnter text:', JSON.stringify(text), 'prefix:', JSON.stringify(prefix));
|
const currentPrefix = stripInternalMarkers(prefixSpan?.textContent ?? '');
|
||||||
|
|
||||||
// Current prefix (before incrementing)
|
if ((isListPrefix || isBlockquote) &&
|
||||||
const currentPrefix = prefixSpan?.textContent?.replace(/\u200B/g, '') ?? '';
|
(text === currentPrefix || text.trim() === currentPrefix.trim())) {
|
||||||
|
|
||||||
// Double Enter on empty prefixed line exits
|
|
||||||
if (prefix && (text === currentPrefix || text.trim() === currentPrefix.trim())) {
|
|
||||||
const emptyBlock = this.#buildBlock('');
|
const emptyBlock = this.#buildBlock('');
|
||||||
block.className = emptyBlock.className;
|
block.className = emptyBlock.className;
|
||||||
block.innerHTML = '';
|
block.innerHTML = '';
|
||||||
while (emptyBlock.firstChild) block.appendChild(emptyBlock.firstChild);
|
while (emptyBlock.firstChild) {
|
||||||
|
block.appendChild(emptyBlock.firstChild);
|
||||||
|
}
|
||||||
this.#restoreCaret(block, 0);
|
this.#restoreCaret(block, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -691,11 +640,12 @@ export class RibbitEditor extends Ribbit {
|
||||||
|
|
||||||
block.className = firstBlock.className;
|
block.className = firstBlock.className;
|
||||||
block.innerHTML = '';
|
block.innerHTML = '';
|
||||||
while (firstBlock.firstChild) block.appendChild(firstBlock.firstChild);
|
while (firstBlock.firstChild) {
|
||||||
|
block.appendChild(firstBlock.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
block.after(secondBlock);
|
block.after(secondBlock);
|
||||||
|
|
||||||
// Place caret after prefix in new block
|
|
||||||
const newPrefixSpan = secondBlock.firstElementChild;
|
const newPrefixSpan = secondBlock.firstElementChild;
|
||||||
if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) ||
|
if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) ||
|
||||||
newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) {
|
newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) {
|
||||||
|
|
@ -715,11 +665,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Backspace at offset 0: merge the current block with the previous one.
|
|
||||||
* Returns true if we handled it (caller should preventDefault), false
|
|
||||||
* to let the browser handle it normally.
|
|
||||||
*/
|
|
||||||
#handleBackspace(): boolean {
|
#handleBackspace(): boolean {
|
||||||
const block = this.#findCurrentBlock();
|
const block = this.#findCurrentBlock();
|
||||||
if (!block) {
|
if (!block) {
|
||||||
|
|
@ -736,8 +681,9 @@ export class RibbitEditor extends Ribbit {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousLength = previousBlock.textContent!.length;
|
const previousLength = stripInternalMarkers(previousBlock.textContent!).length;
|
||||||
const merged = previousBlock.textContent! + block.textContent!.replace(/\u00A0/g, ' ');
|
const merged = stripInternalMarkers(previousBlock.textContent!) +
|
||||||
|
normalizeLineText(block.textContent!);
|
||||||
const mergedBlock = this.#buildBlock(merged);
|
const mergedBlock = this.#buildBlock(merged);
|
||||||
|
|
||||||
previousBlock.className = mergedBlock.className;
|
previousBlock.className = mergedBlock.className;
|
||||||
|
|
@ -751,14 +697,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cursor tracking ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk up from the cursor to find the direct child of the editor
|
|
||||||
* element that contains it. That is the current block div.
|
|
||||||
*
|
|
||||||
* const block = this.#findCurrentBlock(); // <div class="md-paragraph">
|
|
||||||
*/
|
|
||||||
#findCurrentBlock(): HTMLElement | null {
|
#findCurrentBlock(): HTMLElement | null {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
|
@ -774,13 +712,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk the block's text nodes to get the total character offset
|
|
||||||
* from the start of the block to the cursor. Survives DOM rebuilds
|
|
||||||
* because it works on character counts, not node references.
|
|
||||||
*
|
|
||||||
* const offset = this.#getCaretOffset(block); // 7
|
|
||||||
*/
|
|
||||||
#getCaretOffset(block: HTMLElement): number {
|
#getCaretOffset(block: HTMLElement): number {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
|
@ -792,22 +723,13 @@ export class RibbitEditor extends Ribbit {
|
||||||
return range.toString().length;
|
return range.toString().length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk the block's text nodes and place the cursor at the given
|
|
||||||
* character offset. Called after every block rebuild to restore
|
|
||||||
* the caret to where the user was typing.
|
|
||||||
*
|
|
||||||
* this.#restoreCaret(block, 7);
|
|
||||||
*/
|
|
||||||
#restoreCaret(block: HTMLElement, offset: number): void {
|
#restoreCaret(block: HTMLElement, offset: number): void {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
let remaining = offset;
|
const placed = this.#walkForCaret(block, range, offset);
|
||||||
|
|
||||||
const placed = this.#walkForCaret(block, range, remaining);
|
|
||||||
|
|
||||||
if (!placed) {
|
if (!placed) {
|
||||||
range.selectNodeContents(block);
|
range.selectNodeContents(block);
|
||||||
|
|
@ -819,49 +741,65 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively walk text nodes in the subtree rooted at `node`,
|
* Recursively walk text nodes counting only "real" characters
|
||||||
* decrementing `remaining` for each character encountered. When
|
* toward `remaining` — internal bookkeeping markers are skipped
|
||||||
* remaining reaches zero, sets range.start and returns true.
|
* over entirely and never consume a unit of `remaining`.
|
||||||
*/
|
*/
|
||||||
#walkForCaret(node: Node, range: Range, remaining: number): boolean {
|
#walkForCaret(node: Node, range: Range, remaining: number): boolean {
|
||||||
if (node.nodeType === 3) {
|
if (node.nodeType === 3) {
|
||||||
const textNode = node as Text;
|
const textNode = node as Text;
|
||||||
if (remaining <= textNode.length) {
|
const text = textNode.textContent!;
|
||||||
range.setStart(textNode, remaining);
|
|
||||||
|
let domOffset = 0;
|
||||||
|
for (const character of text) {
|
||||||
|
if (isInternalMarker(character)) {
|
||||||
|
domOffset += character.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStart(textNode, domOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
domOffset += character.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStart(textNode, domOffset);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let consumed = 0;
|
let consumed = 0;
|
||||||
for (const child of Array.from(node.childNodes)) {
|
for (const child of Array.from(node.childNodes)) {
|
||||||
if (child.nodeType === 3) {
|
if (child.nodeType === 3) {
|
||||||
const textNode = child as Text;
|
const textNode = child as Text;
|
||||||
if (remaining - consumed <= textNode.length) {
|
const realLength = stripInternalMarkers(textNode.textContent!).length;
|
||||||
range.setStart(textNode, remaining - consumed);
|
if (remaining - consumed <= realLength) {
|
||||||
range.collapse(true);
|
const placed = this.#walkForCaret(textNode, range, remaining - consumed);
|
||||||
return true;
|
if (placed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
consumed += textNode.length;
|
consumed += realLength;
|
||||||
} else {
|
} else {
|
||||||
const childLength = (child.textContent || '').length;
|
const childRealLength = stripInternalMarkers(child.textContent || '').length;
|
||||||
if (remaining - consumed <= childLength) {
|
if (remaining - consumed <= childRealLength) {
|
||||||
const placed = this.#walkForCaret(child, range, remaining - consumed);
|
const placed = this.#walkForCaret(child, range, remaining - consumed);
|
||||||
if (placed) {
|
if (placed) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
consumed += childLength;
|
consumed += childRealLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* On selectionchange, find the nearest ancestor that is an inline
|
|
||||||
* formatting span and add EDITING_CONTEXT_CLASS to it so CSS reveals
|
|
||||||
* its delimiters. Remove it from the previous span.
|
|
||||||
*/
|
|
||||||
#updateEditingContext(): void {
|
#updateEditingContext(): void {
|
||||||
this.#clearEditingContext();
|
this.#clearEditingContext();
|
||||||
|
|
||||||
|
|
@ -884,7 +822,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove EDITING_CONTEXT_CLASS from the previously active span. */
|
|
||||||
#clearEditingContext(): void {
|
#clearEditingContext(): void {
|
||||||
if (this.activeFormattingSpan) {
|
if (this.activeFormattingSpan) {
|
||||||
this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS);
|
this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS);
|
||||||
|
|
@ -892,29 +829,17 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── getMarkdown helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize a single block div to its markdown line. Macro islands
|
|
||||||
* emit their data-source value; all other blocks emit textContent.
|
|
||||||
*
|
|
||||||
* this.#blockToMarkdown(block) // "## Hello **world**"
|
|
||||||
*/
|
|
||||||
#blockToMarkdown(block: HTMLElement): string {
|
#blockToMarkdown(block: HTMLElement): string {
|
||||||
// Empty block (just a <br>) → blank line
|
|
||||||
if (!block.textContent!.trim() && block.querySelector('br')) {
|
if (!block.textContent!.trim() && block.querySelector('br')) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
// Macro islands store their source text in data-source
|
|
||||||
if (block.dataset.macro) {
|
if (block.dataset.macro) {
|
||||||
return block.dataset.source || '';
|
return block.dataset.source || '';
|
||||||
}
|
}
|
||||||
return block.textContent!.replace(/\u200B/g, '').replace(/\u00A0/g, ' ');
|
return normalizeLineText(block.textContent!);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API — matches the previous export shape so consumers don't break.
|
|
||||||
export { RibbitEditor as Editor };
|
export { RibbitEditor as Editor };
|
||||||
export { Ribbit as Viewer };
|
export { Ribbit as Viewer };
|
||||||
export { inlineTag };
|
export { inlineTag };
|
||||||
|
|
|
||||||
149
test/disambiguate.ts
Normal file
149
test/disambiguate.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
console.log(`DISAMBIGUATE: '${line}'`);
|
||||||
|
|
||||||
|
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
||||||
|
|
||||||
|
result += line.slice(lastIndex, match.index);
|
||||||
|
const run = match[0];
|
||||||
|
|
||||||
|
console.log(`DISAMBIGUATE: run == '${run}', stack == ${stack}`);
|
||||||
|
|
||||||
|
if (run.length === 3 && stack.length === 2) {
|
||||||
|
const innerCloser = stack.pop()!;
|
||||||
|
const outerCloser = stack.pop()!;
|
||||||
|
result += innerCloser + SENTINEL + outerCloser;
|
||||||
|
} else if (run.length === 3 && stack.length === 0) {
|
||||||
|
if (match.index == 0) {
|
||||||
|
result += '***';
|
||||||
|
} else {
|
||||||
|
result += '*' + SENTINEL + '**';
|
||||||
|
stack.push('**');
|
||||||
|
stack.push('*');
|
||||||
|
}
|
||||||
|
} else if (run.length === 3) {
|
||||||
|
result += run;
|
||||||
|
} else if (stack.length > 0 && stack[stack.length - 1] === run) {
|
||||||
|
stack.pop();
|
||||||
|
result += run;
|
||||||
|
} else {
|
||||||
|
stack.push(run as '*' | '**');
|
||||||
|
result += run;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + run.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += line.slice(lastIndex);
|
||||||
|
console.log(`DISAMBIGUATE: '${result}'`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) { throw new Error(message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewrite(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 {
|
||||||
|
result += sequence;
|
||||||
|
stack.push(sequence as '*' | '**');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stack.push(sequence as '*' | '**');
|
||||||
|
result += sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + sequence.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += line.slice(lastIndex);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function test_it() {
|
||||||
|
console.log(rewrite('*italic **bold***'));
|
||||||
|
let cases = [
|
||||||
|
{
|
||||||
|
'input': '***bold-italic***',
|
||||||
|
'expected': '**|*bold-italic*|**'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '***bold** italic*',
|
||||||
|
'expected': '*|**bold** italic*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '***italic* bold**',
|
||||||
|
'expected': '**|*italic* bold**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '**bold, *italic***',
|
||||||
|
'expected': '**bold, *italic*|**'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '*italic, **bold***',
|
||||||
|
'expected': '*italic, **bold**|*'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
let result = rewrite(testCase.input);
|
||||||
|
assert(result == testCase.expected, `'${result}' (${result.length}) != '${testCase.expected}' (${testCase.expected.length})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test_it();
|
||||||
|
|
@ -125,11 +125,7 @@ async function resetEditor() {
|
||||||
*/
|
*/
|
||||||
async function typeString(text) {
|
async function typeString(text) {
|
||||||
for (const character of text) {
|
for (const character of text) {
|
||||||
if (character == ' ') {
|
await page.keyboard.insertText(character);
|
||||||
await page.keyboard.press('Space');
|
|
||||||
} else {
|
|
||||||
await page.keyboard.insertText(character);
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(DELAY);
|
await page.waitForTimeout(DELAY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -284,11 +280,12 @@ async function runTests() {
|
||||||
assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
|
assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test('***bold-italic*** produces md-bold-italic span', async () => {
|
await test('***bold-italic*** produces md-bold anad md-italic spans', async () => {
|
||||||
await resetEditor();
|
await resetEditor();
|
||||||
await typeString('***both***');
|
await typeString('***both***');
|
||||||
const html = await getHTML();
|
const html = await getHTML();
|
||||||
assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`);
|
assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`);
|
||||||
|
assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
|
||||||
const markdown = await getMarkdown();
|
const markdown = await getMarkdown();
|
||||||
assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
|
assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
|
||||||
});
|
});
|
||||||
|
|
@ -327,12 +324,14 @@ async function runTests() {
|
||||||
await typeString(testCase.markdown);
|
await typeString(testCase.markdown);
|
||||||
|
|
||||||
const markdown = await getMarkdown();
|
const markdown = await getMarkdown();
|
||||||
|
const html = await getHTML();
|
||||||
|
console.log(`MARKDOWN: ${markdown}`);
|
||||||
|
console.log(`HTML: ${html}`);
|
||||||
assert(
|
assert(
|
||||||
markdown === testCase.markdown,
|
markdown === testCase.markdown,
|
||||||
`Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
|
`Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
const html = await getHTML();
|
|
||||||
const outerIndex = html.indexOf(testCase.outerClass);
|
const outerIndex = html.indexOf(testCase.outerClass);
|
||||||
const innerIndex = html.indexOf(testCase.innerClass);
|
const innerIndex = html.indexOf(testCase.innerClass);
|
||||||
assert(
|
assert(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user