import { MarkdownRenderer } from './markdown-render'; import { MarkdownEditorSelection, MarkdownTextChange } from './types'; import { BlockView } from './block-view'; import { RichReveal } from './rich-reveal'; /** * The rich (WYSIWYG) surface: a contenteditable rendering of the Markdown * that reports every edit as a change to the source. * * Typing is left to the browser. After each input the blocks it touched are * serialized back to Markdown, and the difference against the previous source * is the change reported to the shell. Structure the browser would get wrong * (Markdown shortcuts, code blocks, list indentation, paste) is edited in the * source instead, and the touched blocks re-render; see `rich-behaviors.ts`. * * Changes from elsewhere (a peer, undo, a toolbar command) arrive as source * changes and patch only the blocks whose source changed. While an IME * composition is open the DOM belongs to the IME: changes that arrive then are * queued and rebased over the composed text when it ends. */ export type RichChangeKind = 'input' | 'rule' | 'structure' | 'paste' | 'cut' | 'drop' | 'toggle'; export type RichCommand = 'undo' | 'redo' | 'bold' | 'italic' | 'strike' | 'highlight' | 'code' | 'link' | 'comment' | 'math' | `heading${0 | 1 | 2 | 3 | 4 | 5 | 6}`; export interface RichSurfaceHost { /** A local edit, already applied to the surface, against the host's current document. */ onChange: (changes: MarkdownTextChange[], before: MarkdownEditorSelection | null, after: MarkdownEditorSelection | null, kind: RichChangeKind) => void; onCommand: (command: RichCommand) => void; onSelectionChange: () => void; /** Image files pasted or dropped at a source position; false when nothing takes them. */ onFiles: (files: File[], at: number) => boolean; /** A formula was clicked: edit it. */ onAtom: (atom: HTMLElement, from: number, to: number) => void; /** Keys for an open completion list; return true when handled. */ onKeydown: (event: KeyboardEvent) => boolean; /** After every input, for `:` completion. */ onTyped: () => void; } export interface RichSurfaceOptions { math: boolean; highlight: boolean; /** Typing `[!NOTE]` on a quote's first line makes it a callout. */ callouts: boolean; emoji: () => ReadonlyMap | null; decorateUpload?: (card: HTMLElement, id: string) => void; } export declare const ZWSP = "\u200B"; export declare function isElement(node: Node | null | undefined): node is HTMLElement; export declare function isText(node: Node | null | undefined): node is Text; export interface CaretPoint { node: Node; offset: number; } export declare class RichSurface { readonly root: HTMLElement; readonly view: BlockView; readonly renderer: MarkdownRenderer; readonly host: RichSurfaceHost; readonly options: RichSurfaceOptions; /** The Markdown of the formatted text at the caret; see `rich-reveal.ts`. */ readonly reveal: RichReveal; editable: boolean; checkable: boolean; /** Set while the surface runs a native formatting command itself. */ nativeFormat: boolean; private readonly observer; private readonly touched; /** Blocks typed into since they were rendered; re-rendered once the caret leaves them. */ private readonly dirty; private composing; private compositionDoc; private queued; private selectionBefore; /** The empty paragraph `openLine` opened, until the command's edit comes back. */ private opened; private activeItem; private selectionInside; private readonly cleanups; constructor(root: HTMLElement, renderer: MarkdownRenderer, host: RichSurfaceHost, options: RichSurfaceOptions); destroy(): void; get doc(): string; get isComposing(): boolean; setEditable(editable: boolean, checkable: boolean): void; private touch; /** DOM writes that are not edits: rendering, step-out spaces. */ withoutObserver(write: () => void): void; private flushRecords; /** * Removes the newlines marked writes between elements. A caret placed in one * sits outside every item, and text typed there lands in the list itself. */ private dropLayoutWhitespace; /** An empty block has no height in a contenteditable: give it a break to hold the caret. */ private fillEmpty; emptyParagraph(): HTMLParagraphElement; setDoc(doc: string): void; private ensureNotEmpty; /** * Changes the shell applied from another origin; `doc` is the result. The * caret stays where it was, or goes to `selection` when given. Edits typed * but not yet reported are rebased over them and reported afterwards. */ applyChanges(changes: MarkdownTextChange[], doc: string, selection?: MarkdownEditorSelection | null): void; private hasPendingInput; /** Brings concurrent changes into a DOM that may hold unreported local edits. */ private mergeRemote; /** Patches to `doc` and puts the selection back, by DOM point when its block survived. */ private patchKeepingSelection; private pruneDirty; /** KaTeX or the emoji set arrived. */ refreshAssets(): void; hasFocus(): boolean; focus(): void; getSelection(): MarkdownEditorSelection | null; setSelection(selection: MarkdownEditorSelection): void; /** Puts the caret at a source position, stepping out of formatting the position is outside of. */ placeCaret(pos: number): void; /** * A text position just outside `element`, through a zero-width space: the * caret there types plain text, where the browser would extend the element. */ stepOut(element: Element, side: 'before' | 'after'): CaretPoint; /** The caret at the root between two blocks: into a neighbour, or a new empty paragraph. */ private caretBetweenBlocks; caretPoint(): CaretPoint | null; closest(node: Node | null, tags: readonly string[]): HTMLElement | null; /** Source range of a DOM range inside one text node. */ textRange(node: Text, start: number, end: number): [number, number]; /** The text of the caret's text node up to the caret, for completions. */ textBeforeCaret(): { node: Text; offset: number; text: string; } | null; /** * The empty paragraph Enter made has no line in the source until something * is typed into it, so a command run there would land on the line before. * The change that gives it one, and the caret on that line; null when the * caret at `selection` is not in such a paragraph. When the command's edit * comes back, what it made replaces the paragraph. */ openLine(selection: MarkdownEditorSelection): { changes: MarkdownTextChange[]; caret: number; } | null; /** Bounding box of the DOM selection, or of the caret. */ selectionRect(): DOMRect | null; /** Formats in effect at the caret, for the toolbar's pressed states. */ activeFormats(): Set; private onSelectionChange; /** Re-renders blocks that were typed into, except `keep`. */ private tidy; /** Renders the block holding `node` afresh from its source, keeping the selection. */ rerender(node: Node): void; /** Re-renders the block holding the caret now, keeping the caret: after merges leave browser debris. */ tidyActive(): void; /** Wraps text and inline nodes that ended up directly under the root into paragraphs. */ private normalize; /** Serializes what changed in the DOM; returns the new items and document without committing. */ private collect; /** Commits DOM edits as a source change and reports it. */ reconcile(kind: RichChangeKind): MarkdownTextChange | null; /** Reports pending DOM edits; the shell calls this before computing a command. */ flush(): void; /** * Commits a source change the surface made itself (a shortcut, a toggle, * paste) and re-renders what it touched. */ commit(changes: MarkdownTextChange[], after: MarkdownEditorSelection | null, kind: RichChangeKind): void; /** Replaces a source range, e.g. a completed `:shortcode`. */ replaceRange(from: number, to: number, insert: string, kind?: RichChangeKind): void; /** Inserts Markdown over the selection, padding block content onto its own lines. */ insertMarkdown(markdown: string, kind: RichChangeKind, at?: MarkdownEditorSelection): void; /** Mod+B and friends on a collapsed caret: the browser's typing style, serialized on the next input. */ toggleTypingStyle(format: 'bold' | 'italic' | 'strike'): void; private onBeforeInput; private onInput; private onCompositionStart; private onCompositionEnd; }