wip
This commit is contained in:
parent
818ee418d5
commit
2bbb0ba25f
|
|
@ -1,3 +1,151 @@
|
|||
/*
|
||||
* ribbit-core.css — functional editor styles. Always load this.
|
||||
*
|
||||
* These styles control editor state visibility and the styled-source
|
||||
* rendering. They should not be overridden by themes.
|
||||
*
|
||||
* Two CSS states (not modes):
|
||||
* .wysiwyg — contentEditable, delimiters revealed on cursor focus
|
||||
* .view — read-only, all delimiters hidden, full block styling
|
||||
*
|
||||
* The DOM is identical in both states; only CSS changes.
|
||||
*/
|
||||
|
||||
/* ── Visibility ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
#ribbit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ribbit.loaded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Delimiter visibility ───────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Delimiters are always present in the DOM as text nodes inside
|
||||
* .md-delim spans. In view state they are hidden; in wysiwyg state
|
||||
* they are hidden by default and revealed only for the span the
|
||||
* cursor is currently inside (.ribbit-editing).
|
||||
*
|
||||
* This means getMarkdown() = element.textContent at all times —
|
||||
* no conversion is needed.
|
||||
*/
|
||||
|
||||
.md-delim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg .ribbit-editing > .md-delim {
|
||||
display: inline;
|
||||
opacity: 0.4;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* List prefixes use a separate class so CSS can replace them with
|
||||
real list bullets in view state while keeping them in textContent */
|
||||
.md-list-prefix {
|
||||
display: inline;
|
||||
opacity: 0.4;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#ribbit.view .md-list-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Inline formatting ──────────────────────────────────────────────────────── */
|
||||
|
||||
.md-bold,
|
||||
.md-bold-italic {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.md-italic,
|
||||
.md-bold-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.md-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.md-code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.md-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-link-text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Block-level styling ────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Block divs use .md-{name} classes. In view state they render as
|
||||
* their visual equivalents. In wysiwyg state they use monospace so
|
||||
* the user can see the raw markdown while the formatting is applied.
|
||||
*/
|
||||
|
||||
#ribbit.wysiwyg {
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.md-h1 { font-size: 2em; font-weight: bold; }
|
||||
.md-h2 { font-size: 1.5em; font-weight: bold; }
|
||||
.md-h3 { font-size: 1.17em; font-weight: bold; }
|
||||
.md-h4 { font-size: 1em; font-weight: bold; }
|
||||
.md-h5 { font-size: 0.83em; font-weight: bold; }
|
||||
.md-h6 { font-size: 0.67em; font-weight: bold; }
|
||||
|
||||
.md-blockquote {
|
||||
border-left: 3px solid currentColor;
|
||||
opacity: 0.7;
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
/*
|
||||
* List items: in wysiwyg state the .md-list-prefix span shows the
|
||||
* raw markdown marker ("- " or "1. "). In view state we hide the
|
||||
* prefix and use display:list-item to get a real browser bullet.
|
||||
*/
|
||||
#ribbit.view .md-list-item {
|
||||
display: list-item;
|
||||
margin-left: 1.5em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
#ribbit.view .md-ol-list-item {
|
||||
display: list-item;
|
||||
margin-left: 1.5em;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.md-pre {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ── Vim mode indicators ────────────────────────────────────────────────────── */
|
||||
|
||||
#ribbit.vim-normal {
|
||||
cursor: default;
|
||||
caret-color: transparent;
|
||||
border-left: 3px solid #4af;
|
||||
}
|
||||
|
||||
#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.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,856 @@
|
|||
/*
|
||||
* ribbit-editor.ts — Styled-source editing extension for Ribbit.
|
||||
*
|
||||
* The editor is always a markdown text editor. There is no separate
|
||||
* WYSIWYG mode — the user edits markdown directly, but CSS styling
|
||||
* makes it look like rendered output. Delimiters (**, *, `, etc.)
|
||||
* are hidden when the cursor is outside their span, and revealed
|
||||
* when the cursor enters it.
|
||||
*
|
||||
* Two CSS states replace the old three-mode system:
|
||||
* editing: contentEditable="true", delimiters revealed on focus
|
||||
* viewing: contentEditable="false", all delimiters hidden
|
||||
*
|
||||
* The DOM is identical in both states — only CSS changes. This
|
||||
* eliminates all conversion-during-editing bugs and removes the
|
||||
* flatten→rebuild pipeline entirely.
|
||||
*
|
||||
* getMarkdown() reads element.textContent directly. Because every
|
||||
* delimiter character lives in a text node inside a .md-delim span,
|
||||
* textContent always equals the original markdown source — no
|
||||
* conversion is needed.
|
||||
*
|
||||
* getHTML() runs the existing HopDown tokenizer on demand (export,
|
||||
* save, API calls) — never during editing.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
// CSS class applied to the formatting span the cursor is currently inside.
|
||||
// CSS uses this to reveal .md-delim children for that span only.
|
||||
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.
|
||||
const INLINE_SPAN_ATTR = 'data-md-span';
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
const HEADING_PATTERN = /^(?<hashes>#{1,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',
|
||||
prefixLength: (line) => {
|
||||
const FENCE_PATTERN = /^(?<fence>`{3,}|~{3,})/;
|
||||
const match = line.match(FENCE_PATTERN);
|
||||
return match ? match[0].length : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'heading',
|
||||
prefixLength: (line) => {
|
||||
const match = line.match(HEADING_PATTERN);
|
||||
return match ? match[0].length : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'blockquote',
|
||||
prefixLength: (line) => BLOCKQUOTE_PATTERN.test(line) ? 2 : null,
|
||||
},
|
||||
{
|
||||
name: 'list-item',
|
||||
prefixLength: (line) => {
|
||||
if (!UNORDERED_LIST_PATTERN.test(line)) {
|
||||
return null;
|
||||
}
|
||||
return line.indexOf(' ') + 1;
|
||||
},
|
||||
isList: true,
|
||||
},
|
||||
{
|
||||
name: 'ol-list-item',
|
||||
prefixLength: (line) => {
|
||||
if (!ORDERED_LIST_PATTERN.test(line)) {
|
||||
return null;
|
||||
}
|
||||
return line.indexOf(' ') + 1;
|
||||
},
|
||||
isList: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Inline rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
// An inline rule describes how to detect and wrap a delimiter pair
|
||||
// in a single line of text. Rules are applied left-to-right.
|
||||
interface InlineRule {
|
||||
// The CSS class applied to the wrapper span (e.g. 'md-bold').
|
||||
cls: string;
|
||||
// The delimiter string on both sides (e.g. '**').
|
||||
delimiter: string;
|
||||
// The regex to match a complete delimited run. Must have a
|
||||
// named capture group 'content' for the text between delimiters.
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
// Link is special: it has two delimiters and an href, so it gets
|
||||
// its own handling in parseInline rather than going through InlineRule.
|
||||
const LINK_PATTERN = /\[(?<text>[^\]]+)\]\((?<href>[^)]+)\)/g;
|
||||
|
||||
// Inline rules in priority order (longer/higher-precedence delimiters first
|
||||
// so *** is tried before ** which is tried before *). Derived from the
|
||||
// existing tag definitions in defaultInlineTags wherever possible [C10].
|
||||
const INLINE_RULES: InlineRule[] = [
|
||||
{
|
||||
cls: 'md-bold-italic',
|
||||
delimiter: '***',
|
||||
pattern: /\*\*\*(?<content>.+?)\*\*\*/g,
|
||||
},
|
||||
{
|
||||
cls: 'md-bold',
|
||||
delimiter: '**',
|
||||
pattern: /\*\*(?<content>.+?)\*\*/g,
|
||||
},
|
||||
{
|
||||
cls: 'md-italic',
|
||||
delimiter: '*',
|
||||
pattern: /\*(?<content>.+?)\*/g,
|
||||
},
|
||||
{
|
||||
cls: 'md-strikethrough',
|
||||
delimiter: '~~',
|
||||
pattern: /~~(?<content>.+?)~~/g,
|
||||
},
|
||||
{
|
||||
cls: 'md-code',
|
||||
delimiter: '`',
|
||||
pattern: /`(?<content>[^`]+)`/g,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── 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].
|
||||
*
|
||||
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
||||
* editor.run();
|
||||
* 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.
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Event binding ──────────────────────────────────────────────────────────
|
||||
|
||||
#bindEvents(): void {
|
||||
let debounceTimer: number | undefined;
|
||||
|
||||
this.element.addEventListener('input', () => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.#updateCurrentBlock();
|
||||
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.#updateEditingContext();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('selectionchange', () => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.#updateEditingContext();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event: MouseEvent) => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
if (!this.element.contains(event.target as Node)) {
|
||||
this.#clearEditingContext();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mode switching ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Switch to styled-source editing mode. Renders the current markdown
|
||||
* as a styled DOM (one block div per line) and enables contentEditable.
|
||||
* The DOM is never rebuilt on mode switch — only CSS changes.
|
||||
*
|
||||
* editor.wysiwyg();
|
||||
* // user now edits markdown directly with CSS rendering
|
||||
*/
|
||||
wysiwyg(): void {
|
||||
if (this.getState() === this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.invalidateCache();
|
||||
this.vim?.detach();
|
||||
this.collaboration?.connect();
|
||||
this.element.innerHTML = '';
|
||||
this.element.appendChild(this.#markdownToStyledDOM(this.getMarkdown()));
|
||||
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') {
|
||||
htmlMacro.contentEditable = 'false';
|
||||
}
|
||||
}
|
||||
this.setState(this.states.WYSIWYG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the editor's current styled DOM back to markdown.
|
||||
* Because delimiter characters live in text nodes inside .md-delim
|
||||
* spans, element.textContent == the original markdown source.
|
||||
* No conversion needed [see STYLED_SOURCE_DESIGN.md §getMarkdown()].
|
||||
*
|
||||
* 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: element contains rendered HTML — fall back to
|
||||
// the cached markdown that was used to render it.
|
||||
if (this.cachedMarkdown !== null) {
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
return this.element.textContent || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const line of markdown.split('\n')) {
|
||||
fragment.appendChild(this.#buildBlock(line));
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-heading">
|
||||
* // <span class="md-delim">## </span>
|
||||
* // Hello <span class="md-bold">…</span>
|
||||
* // </div>
|
||||
*/
|
||||
#buildBlock(line: string): HTMLDivElement {
|
||||
const block = document.createElement('div');
|
||||
|
||||
if (line === '') {
|
||||
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
|
||||
block.appendChild(document.createElement('br'));
|
||||
return block;
|
||||
}
|
||||
|
||||
for (const rule of BLOCK_RULES) {
|
||||
const prefixLength = rule.prefixLength(line);
|
||||
if (prefixLength === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings carry their level in the class name [C10]
|
||||
if (rule.name === 'heading') {
|
||||
const match = line.match(HEADING_PATTERN)!;
|
||||
block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`;
|
||||
} else {
|
||||
block.className = `${BLOCK_CLASS_PREFIX}${rule.name}`;
|
||||
}
|
||||
|
||||
const prefixSpan = document.createElement('span');
|
||||
prefixSpan.className = rule.isList ? LIST_PREFIX_CLASS : DELIM_CLASS;
|
||||
prefixSpan.textContent = line.slice(0, prefixLength);
|
||||
block.appendChild(prefixSpan);
|
||||
block.appendChild(this.#parseInline(line.slice(prefixLength)));
|
||||
return block;
|
||||
}
|
||||
|
||||
// Fallback: plain paragraph
|
||||
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
|
||||
block.appendChild(this.#parseInline(line));
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Stage 1: tokenise into raw-text segments and matched parts.
|
||||
// We walk all rules left-to-right, splitting segments as we go.
|
||||
// Each segment is either raw (unmatched) or a matched inline rule.
|
||||
interface RawSegment { raw: true; text: string }
|
||||
interface RuleMatch { raw: false; rule: InlineRule; content: string; fullMatch: string }
|
||||
interface LinkMatch { raw: false; isLink: true; text: string; href: string; fullMatch: string }
|
||||
type Segment = RawSegment | RuleMatch | LinkMatch;
|
||||
|
||||
let segments: Segment[] = [{ raw: true, text }];
|
||||
|
||||
for (const rule of INLINE_RULES) {
|
||||
const nextSegments: Segment[] = [];
|
||||
for (const segment of segments) {
|
||||
if (!segment.raw) {
|
||||
nextSegments.push(segment);
|
||||
continue;
|
||||
}
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
rule.pattern.lastIndex = 0;
|
||||
while ((match = rule.pattern.exec(segment.text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nextSegments.push({ raw: true, text: segment.text.slice(lastIndex, match.index) });
|
||||
}
|
||||
nextSegments.push({
|
||||
raw: false,
|
||||
rule,
|
||||
content: match.groups!.content,
|
||||
fullMatch: match[0],
|
||||
});
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < segment.text.length) {
|
||||
nextSegments.push({ raw: true, text: segment.text.slice(lastIndex) });
|
||||
}
|
||||
}
|
||||
segments = nextSegments;
|
||||
}
|
||||
|
||||
// Handle links in a second pass over raw segments only [C14]
|
||||
const withLinks: Segment[] = [];
|
||||
for (const segment of segments) {
|
||||
if (!segment.raw) {
|
||||
withLinks.push(segment);
|
||||
continue;
|
||||
}
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
LINK_PATTERN.lastIndex = 0;
|
||||
while ((match = LINK_PATTERN.exec(segment.text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
withLinks.push({ raw: true, text: segment.text.slice(lastIndex, match.index) });
|
||||
}
|
||||
withLinks.push({
|
||||
raw: false,
|
||||
isLink: true,
|
||||
text: match.groups!.text,
|
||||
href: match.groups!.href,
|
||||
fullMatch: match[0],
|
||||
});
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < segment.text.length) {
|
||||
withLinks.push({ raw: true, text: segment.text.slice(lastIndex) });
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: build DOM nodes from the token list
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const segment of withLinks) {
|
||||
if (segment.raw) {
|
||||
fragment.appendChild(document.createTextNode(segment.text));
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute(INLINE_SPAN_ATTR, '1');
|
||||
|
||||
if ('isLink' in segment) {
|
||||
// Link: [text](href)
|
||||
// All three parts go into .md-delim spans so textContent
|
||||
// reproduces the full markdown [( href )] syntax
|
||||
span.className = 'md-link';
|
||||
span.appendChild(this.#makeDelimSpan('['));
|
||||
const linkTextNode = document.createElement('span');
|
||||
linkTextNode.className = 'md-link-text';
|
||||
linkTextNode.textContent = segment.text;
|
||||
span.appendChild(linkTextNode);
|
||||
span.appendChild(this.#makeDelimSpan(`](${segment.href})`));
|
||||
} else {
|
||||
// Standard delimiter pair: **content**
|
||||
span.className = segment.rule.cls;
|
||||
span.appendChild(this.#makeDelimSpan(segment.rule.delimiter));
|
||||
span.appendChild(document.createTextNode(segment.content));
|
||||
span.appendChild(this.#makeDelimSpan(segment.rule.delimiter));
|
||||
}
|
||||
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/** Create a <span class="md-delim"> with the given text content. */
|
||||
#makeDelimSpan(text: string): HTMLSpanElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = DELIM_CLASS;
|
||||
span.textContent = text;
|
||||
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 {
|
||||
const block = this.#findCurrentBlock();
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve the caret position across the rebuild
|
||||
const caretOffset = this.#getCaretOffset(block);
|
||||
|
||||
// Normalize that browsers insert in contentEditable.
|
||||
// Without this, NBSP characters in textContent break pattern
|
||||
// matching for block classifiers like "## " or "> ".
|
||||
const lineText = block.textContent!.replace(/\u00A0/g, ' ');
|
||||
|
||||
const newBlock = this.#buildBlock(lineText);
|
||||
block.className = newBlock.className;
|
||||
block.innerHTML = '';
|
||||
while (newBlock.firstChild) {
|
||||
block.appendChild(newBlock.firstChild);
|
||||
}
|
||||
|
||||
this.#restoreCaret(block, caretOffset);
|
||||
this.#updateEditingContext();
|
||||
}
|
||||
|
||||
// ── Keyboard handling ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle Enter and Backspace ourselves; route all other keys to the
|
||||
* block tag's handleKeydown if it has one. This replaces the old
|
||||
* dispatchKeydown which routed through the full tag system [C14].
|
||||
*/
|
||||
#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();
|
||||
if (block) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const tagForBlock = this.converter.getBlockTags().find((tag) => {
|
||||
if (typeof tag.selector !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return tag.selector.split(',').some(
|
||||
(selector) => block.tagName === selector.trim()
|
||||
);
|
||||
});
|
||||
if (tagForBlock?.handleKeydown) {
|
||||
const handled = tagForBlock.handleKeydown(block, event, selection, this);
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.#handleEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
if (this.#handleBackspace()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const block = this.#findCurrentBlock();
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = this.#getCaretOffset(block);
|
||||
const text = block.textContent!.replace(/\u00A0/g, ' ');
|
||||
const before = text.slice(0, offset);
|
||||
const after = text.slice(offset);
|
||||
|
||||
const firstBlock = this.#buildBlock(before);
|
||||
const secondBlock = this.#buildBlock(after || '');
|
||||
|
||||
block.className = firstBlock.className;
|
||||
block.innerHTML = '';
|
||||
while (firstBlock.firstChild) {
|
||||
block.appendChild(firstBlock.firstChild);
|
||||
}
|
||||
|
||||
block.after(secondBlock);
|
||||
this.#restoreCaret(secondBlock, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const block = this.#findCurrentBlock();
|
||||
if (!block) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const offset = this.#getCaretOffset(block);
|
||||
if (offset !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousBlock = block.previousElementSibling as HTMLElement | null;
|
||||
if (!previousBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousLength = previousBlock.textContent!.length;
|
||||
const merged = previousBlock.textContent! + block.textContent!.replace(/\u00A0/g, ' ');
|
||||
const mergedBlock = this.#buildBlock(merged);
|
||||
|
||||
previousBlock.className = mergedBlock.className;
|
||||
previousBlock.innerHTML = '';
|
||||
while (mergedBlock.firstChild) {
|
||||
previousBlock.appendChild(mergedBlock.firstChild);
|
||||
}
|
||||
|
||||
block.remove();
|
||||
this.#restoreCaret(previousBlock, previousLength);
|
||||
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 {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
let node: Node | null = selection.anchorNode;
|
||||
while (node && node !== this.element) {
|
||||
if (node.nodeType === 1 && node.parentNode === this.element) {
|
||||
return node as HTMLElement;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
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 {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(block, 0);
|
||||
range.setEnd(selection.anchorNode!, selection.anchorOffset);
|
||||
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 {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
let remaining = offset;
|
||||
|
||||
const placed = this.#walkForCaret(block, range, remaining);
|
||||
if (!placed) {
|
||||
range.selectNodeContents(block);
|
||||
range.collapse(false);
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk text nodes in the subtree rooted at `node`,
|
||||
* decrementing `remaining` for each character encountered. When
|
||||
* remaining reaches zero, sets range.start and returns true.
|
||||
*/
|
||||
#walkForCaret(node: Node, range: Range, remaining: number): boolean {
|
||||
if (node.nodeType === 3) {
|
||||
const textNode = node as Text;
|
||||
if (remaining <= textNode.length) {
|
||||
range.setStart(textNode, remaining);
|
||||
range.collapse(true);
|
||||
return true;
|
||||
}
|
||||
// Mutate remaining via closure — TypeScript doesn't allow
|
||||
// reassigning a parameter across recursive calls cleanly,
|
||||
// so we use the return-value protocol: false = not placed yet,
|
||||
// the caller subtracts and recurses.
|
||||
return false;
|
||||
}
|
||||
let consumed = 0;
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === 3) {
|
||||
const textNode = child as Text;
|
||||
if (remaining - consumed <= textNode.length) {
|
||||
range.setStart(textNode, remaining - consumed);
|
||||
range.collapse(true);
|
||||
return true;
|
||||
}
|
||||
consumed += textNode.length;
|
||||
} else {
|
||||
const childLength = (child.textContent || '').length;
|
||||
if (remaining - consumed <= childLength) {
|
||||
// Recurse into this subtree with adjusted remaining
|
||||
const placed = this.#walkForCaret(child, range, remaining - consumed);
|
||||
if (placed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
consumed += childLength;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
this.#clearEditingContext();
|
||||
|
||||
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;
|
||||
if (element.hasAttribute(INLINE_SPAN_ATTR)) {
|
||||
element.classList.add(EDITING_CONTEXT_CLASS);
|
||||
this.activeFormattingSpan = element;
|
||||
return;
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove EDITING_CONTEXT_CLASS from the previously active span. */
|
||||
#clearEditingContext(): void {
|
||||
if (this.activeFormattingSpan) {
|
||||
this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS);
|
||||
this.activeFormattingSpan = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
// Empty block (just a <br>) → blank line
|
||||
if (!block.textContent!.trim() && block.querySelector('br')) {
|
||||
return '';
|
||||
}
|
||||
// Macro islands store their source text in data-source
|
||||
if (block.dataset.macro) {
|
||||
return block.dataset.source || '';
|
||||
}
|
||||
return block.textContent || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Public API — matches the previous export shape so consumers don't break.
|
||||
export { RibbitEditor as Editor };
|
||||
export { Ribbit as Viewer };
|
||||
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 };
|
||||
/*
|
||||
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user