diff --git a/INPUT.md b/INPUT.md index 3cd88166..debefcd6 100644 --- a/INPUT.md +++ b/INPUT.md @@ -542,12 +542,58 @@ Composer-specific props: | `mergeStyle` | `true` | Merge preview styles with defaults instead of replacing them | | `onLinkPress` | default link opener | Override link handling in preview | +## Rich Text Mode (`MarkdownEditor`) + +`MarkdownEditor` combines the plain-text input with a WYSIWYG surface and a built-in mode toggle. The value is markdown in both modes; the rich mode hides inline markers and heading prefixes, shows list bullets, and styles bold, italic, underline, strikethrough and inline code in place. + +```tsx +import React, {useState} from 'react'; +import {MarkdownEditor, type MarkdownEditorMode} from '@ronradtke/react-native-markdown-display'; + +export function Composer() { + const [value, setValue] = useState(''); + const [mode, setMode] = useState('wysiwyg'); + + return ( + + ); +} +``` + +| Prop | Default | Description | +| --- | --- | --- | +| `value` | required | Markdown string; commit it synchronously, like a controlled `TextInput` | +| `onChangeText` | required | Called with the updated markdown | +| `mode` | internal, starts in `'plaintext'` | Controlled mode: `'plaintext'` or `'wysiwyg'` | +| `onModeChange` | `undefined` | Called when the built-in toggle changes the mode | +| `showModeToggle` | `true` | Renders the plain/rich toggle | +| `toolbarItems` | expanded toolbar | Toolbar items for both modes | +| `formatStyles` | built-in | Per-format `TextStyle` overrides for the rich surface | +| `editorStyle` / `textStyle` / `containerStyle` | `undefined` | Styles for the surface, its text and the outer container | +| `resolveCommandPayload` | `undefined` | Async or sync payload resolver used before applying a command | + +What the rich mode guarantees: + +- Typed and pasted text reaches `value` unchanged. Identifiers such as `my_var`, expressions such as `2 * 3 * 4`, paths, URLs and code are never rewritten. +- Markdown typed by hand takes effect as soon as it is valid: typing `**bold**` shows bold text, and the keystrokes stay in the value. Typing continues after the closed span. +- Lines you do not touch are never rewritten. Only the edited line is adjusted when a delimiter must move to stay valid. +- Enter continues bullet, ordered and quote prefixes and exits an empty one, as in plain-text mode. +- Toolbar commands format the selection, or the word around the caret when nothing is selected, and rewrite only those lines. + +Quotes, tables, links, images, HTML and fenced code blocks are shown as literal text in rich mode. `MarkdownWysiwygEditor` is also exported on its own for custom layouts, together with the pure helpers `buildWysiwygModel`, `applyDisplayEdit`, `applyInlineFormatWysiwyg`, `displaySelectionToSource` and `sourceOffsetToDisplay`. + ## Current Editing Model The editor stays within standard markdown text and selection transforms. That keeps it portable and deterministic, but it also means: - the stored value is always markdown text - formatting is applied by inserting/removing markdown markers -- preview is the place where formatted output is rendered +- preview and the rich-text surface render formatted output; the value never changes shape If you need architecture-level background, see [doc/markdown-editor-architecture.md](doc/markdown-editor-architecture.md). diff --git a/README.md b/README.md index c0de353c..3dcba748 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Markdown rendering and editing primitives for React Native, running only in JS. - ⚑ **Native rendering pipeline**: markdown is parsed with `markdown-it`, normalized into an AST, and rendered with React Native components. - 🧱 **No WebView dependency**: content participates in your React Native layout, styling, theming, navigation, and event handling. - 🧩 **Typed customization surface**: override styles, render rules, link handling, parser behavior, and image handling with TypeScript-friendly APIs. -- ✍️ **Viewer and editor pieces**: use `` for display, `MarkdownStream` for streaming text, or `MarkdownTextInput` / `MarkdownComposer` for authoring. +- ✍️ **Viewer and editor pieces**: use `` for display, `MarkdownStream` for streaming text, or `MarkdownTextInput` / `MarkdownComposer` / `MarkdownEditor` for authoring. - πŸ”Œ **Extensible markdown support**: bring your own `markdown-it` instance or opt into bundled plugins such as underline. This package is intended as a modern replacement for `react-native-markdown-renderer`, with stricter typing and a maintained native rendering architecture. @@ -88,6 +88,7 @@ export default function App(): React.JSX.Element { | ⏱️ Render actively streaming markdown text | `MarkdownStream` | | ⌨️ Add a markdown-aware text input | `MarkdownTextInput` | | 🧰 Ship an opinionated markdown composer with toolbar and preview | `MarkdownComposer` | +| πŸ–‹οΈ Edit markdown in a rich-text (WYSIWYG) surface with a plain-text toggle | `MarkdownEditor` | | πŸ”Ž Preview an editor value with the same renderer | `MarkdownPreview` | ## πŸ“ Supported Markdown @@ -118,7 +119,7 @@ The viewer is designed to be customized at the right layer: ## πŸ“š Documentation - [Viewer guide](VIEWER.md): rendering, styles, custom rules, `MarkdownIt`, preprocessing, and streaming -- [Input guide](INPUT.md): `MarkdownTextInput`, `MarkdownComposer`, toolbars, prompts, shortcuts, and previews +- [Input guide](INPUT.md): `MarkdownTextInput`, `MarkdownComposer`, `MarkdownEditor` rich text mode, toolbars, prompts, shortcuts, and previews - [Editor architecture note](doc/markdown-editor-architecture.md): internal editor model and design notes - [Example app](example/README.md): local example project diff --git a/STATUS.md b/STATUS.md index f66f0db0..eb1784e7 100644 --- a/STATUS.md +++ b/STATUS.md @@ -41,6 +41,8 @@ This library serves a dual role in an AI chat interface (similar to Claude or Ch | Table dialog | Done | Built-in native `Alert`-based prompt; dimension validation | | `onCommand` hook | Done | Intercept any toolbar command before it applies | | `toolbarItems` prop | Done | Restrict or reorder toolbar buttons | +| `MarkdownEditor` (rich text mode) | Model done, device validation pending | Native `TextInput` with hidden markers; CommonMark-faithful parser shared with the renderer's rules; typed/pasted text reaches `value` unchanged; see `doc/wysiwyg-device-checklist.md` | +| `MarkdownWysiwygEditor` | Same | Standalone rich surface used by `MarkdownEditor` | ### Example App diff --git a/__tests__/MarkdownEditor.test.tsx b/__tests__/MarkdownEditor.test.tsx new file mode 100644 index 00000000..8c6a899f --- /dev/null +++ b/__tests__/MarkdownEditor.test.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import {TextInput} from 'react-native'; +import renderer from 'react-test-renderer'; + +import {EditorToolbar, MarkdownEditor} from '../src'; +import type {MarkdownEditorMode, MarkdownTextInputCommandPayload} from '../src'; + +const findPressableByLabel = ( + tree: renderer.ReactTestRenderer, + label: string, +): renderer.ReactTestInstance => + tree.root.find( + (node) => + node.props.accessibilityLabel === label && + typeof node.props.onPress === 'function', + ); + +const renderEditor = ( + props: Partial> = {}, +): {onChangeText: jest.Mock; tree: renderer.ReactTestRenderer} => { + const onChangeText = jest.fn(); + let tree: renderer.ReactTestRenderer | undefined; + + renderer.act(() => { + tree = renderer.create( + , + ); + }); + + if (!tree) { + throw new Error('Failed to render MarkdownEditor'); + } + + return {onChangeText, tree}; +}; + +describe('MarkdownEditor', () => { + test.each(['plaintext', 'wysiwyg'])('pending commands preserve intervening edits in %s', async (mode) => { + let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {}; + const pendingPayload = new Promise((resolve) => { resolvePayload = resolve; }); + const resolveCommandPayload = () => pendingPayload; + const {tree, onChangeText} = renderEditor({mode, value: 'Hello', resolveCommandPayload}); + let pendingCommand: Promise | undefined; + + renderer.act(() => { pendingCommand = tree.root.findByType(EditorToolbar).props.onCommand('link'); }); + renderer.act(() => { + tree.update(); + }); + await renderer.act(async () => { + resolvePayload({command: 'link', link: {url: 'https://example.com'}}); + await pendingCommand; + }); + + expect(onChangeText.mock.calls[0]?.[0]).toContain('!'); + }); + + test('clamps the selection after an external value change', async () => { + const {tree, onChangeText} = renderEditor({mode: 'wysiwyg', value: 'Hello world'}); + renderer.act(() => { + tree.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: 2, end: 8}}}); + }); + renderer.act(() => { + tree.update(); + }); + await renderer.act(async () => { await tree.root.findByType(EditorToolbar).props.onCommand('bold'); }); + + // The stale range is clamped to a caret, which formats the word around it. + expect(onChangeText).toHaveBeenCalledWith('**Hi**'); + }); + + test.each([{editable: false}, {readOnly: true}])('disables commands and custom actions with %s', async (props) => { + const {tree, onChangeText} = renderEditor({mode: 'wysiwyg', value: 'Hello', ...props}); + const bold = findPressableByLabel(tree, 'Bold'); + expect(bold.props.disabled).toBe(true); + await renderer.act(async () => { + bold.props.onPress(); + await tree.root.findByType(EditorToolbar).props.onCommand('bold'); + tree.root.findByType(EditorToolbar).props.onAction({type: 'insert', markdown: '!'}); + }); + + expect(onChangeText).not.toHaveBeenCalled(); + }); + + test.each(['disabled', 'unmounted'])('does not apply a pending command after the editor is %s', async (change) => { + let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {}; + const pendingPayload = new Promise((resolve) => { resolvePayload = resolve; }); + const {tree, onChangeText} = renderEditor({value: 'Hello', resolveCommandPayload: () => pendingPayload}); + let pendingCommand: Promise | undefined; + renderer.act(() => { pendingCommand = tree.root.findByType(EditorToolbar).props.onCommand('bold'); }); + renderer.act(() => { + if (change === 'disabled') { + tree.update(); + } else { + tree.unmount(); + } + }); + await renderer.act(async () => { + resolvePayload({command: 'bold'}); + await pendingCommand; + }); + + expect(onChangeText).not.toHaveBeenCalled(); + }); + + test('translates a selection when the parent changes the controlled mode', () => { + const {tree, onChangeText} = renderEditor({mode: 'plaintext', value: '**Hello** world'}); + renderer.act(() => { + tree.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: 2, end: 7}}}); + }); + renderer.act(() => { + tree.update(); + }); + expect(tree.root.findByType(TextInput).props.selection).toEqual({start: 0, end: 5}); + renderer.act(() => { + tree.update(); + }); + expect(tree.root.findByType(TextInput).props.selection).toEqual({start: 2, end: 7}); + }); + + test('uses the resolved command in rich mode', async () => { + const {tree, onChangeText} = renderEditor({mode: 'wysiwyg', value: 'Hello', resolveCommandPayload: () => ({command: 'italic'})}); + renderer.act(() => { + tree.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: 0, end: 5}}}); + }); + await renderer.act(async () => { await tree.root.findByType(EditorToolbar).props.onCommand('bold'); }); + + expect(onChangeText).toHaveBeenCalledWith('_Hello_'); + }); + + test('wraps the caret with markers in plain-text mode', async () => { + const {onChangeText, tree} = renderEditor({value: ''}); + + await renderer.act(async () => { + findPressableByLabel(tree, 'Bold').props.onPress(); + }); + + expect(onChangeText).toHaveBeenCalledWith('**text**'); + }); + + test('formats the display selection in wysiwyg mode', async () => { + const {onChangeText, tree} = renderEditor({ + mode: 'wysiwyg', + value: 'Hello', + }); + + const input = tree.root.findByType(TextInput); + + renderer.act(() => { + input.props.onSelectionChange({ + nativeEvent: {selection: {end: 5, start: 0}}, + }); + }); + + await renderer.act(async () => { + findPressableByLabel(tree, 'Bold').props.onPress(); + }); + + expect(onChangeText).toHaveBeenCalledWith('**Hello**'); + }); + + test('renders the mode toggle and reports mode changes', () => { + const onModeChange = jest.fn(); + const {tree} = renderEditor({onModeChange}); + + renderer.act(() => { + findPressableByLabel(tree, 'Rich text').props.onPress(); + }); + + expect(onModeChange).toHaveBeenCalledWith('wysiwyg'); + }); +}); diff --git a/__tests__/MarkdownTextInput.test.tsx b/__tests__/MarkdownTextInput.test.tsx index 3757771c..2e08cdf7 100644 --- a/__tests__/MarkdownTextInput.test.tsx +++ b/__tests__/MarkdownTextInput.test.tsx @@ -3,8 +3,82 @@ import renderer from 'react-test-renderer'; import {Text, TextInput} from 'react-native'; import {type MarkdownManagedTextInputProps, MarkdownTextInput} from '../src'; +import type {MarkdownTextInputCommandPayload} from '../src'; + +const renderInput = (props: React.ComponentProps): renderer.ReactTestRenderer => { + let tree: renderer.ReactTestRenderer | undefined; + renderer.act(() => { tree = renderer.create(); }); + if (!tree) { + throw new Error('Failed to render MarkdownTextInput'); + } + return tree; +}; describe('MarkdownTextInput', () => { + test('uses the current value, selection, and callbacks when a pending command resolves', async () => { + let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {}; + const pendingPayload = new Promise((resolve) => { resolvePayload = resolve; }); + const oldOnChangeText = jest.fn(); + const onChangeText = jest.fn(); + const onCommand = jest.fn(); + const tree = renderInput({value: 'Hello', onChangeText: oldOnChangeText, resolveCommandPayload: () => pendingPayload}); + const button = tree.root.find((node) => node.props.accessibilityLabel === 'Bold' && typeof node.props.onPress === 'function'); + renderer.act(() => { button.props.onPress(); }); + renderer.act(() => { + tree.update(); + }); + await renderer.act(async () => { + resolvePayload({command: 'bold'}); + await pendingPayload; + }); + + expect(oldOnChangeText).not.toHaveBeenCalled(); + expect(onChangeText).toHaveBeenCalledWith('Hello**!**'); + expect(onCommand).toHaveBeenCalledWith({command: 'bold'}, {value: 'Hello**!**', selection: {start: 7, end: 8}}); + }); + + test.each([{editable: false}, {readOnly: true}])('disables toolbar commands and actions with %s', async (props) => { + const onChangeText = jest.fn(); + const resolveCommandPayload = jest.fn(); + const tree = renderInput({ + ...props, + value: 'Hello', + onChangeText, + resolveCommandPayload, + toolbarItems: [{command: 'bold', label: 'Bold'}, {action: {type: 'insert', markdown: '!'}, label: 'Insert'}], + }); + const buttons = tree.root.findAll((node) => typeof node.props.onPress === 'function'); + await renderer.act(async () => { + for (const button of buttons) { + expect(button.props.disabled).toBe(true); + button.props.onPress(); + } + }); + expect(resolveCommandPayload).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + }); + + test.each(['disabled', 'unmounted'])('cancels pending input commands when %s', async (change) => { + let resolvePayload: (payload: MarkdownTextInputCommandPayload | null) => void = () => {}; + const pendingPayload = new Promise((resolve) => { resolvePayload = resolve; }); + const onChangeText = jest.fn(); + const tree = renderInput({value: 'Hello', onChangeText, resolveCommandPayload: () => pendingPayload}); + const button = tree.root.find((node) => node.props.accessibilityLabel === 'Bold' && typeof node.props.onPress === 'function'); + renderer.act(() => { button.props.onPress(); }); + renderer.act(() => { + if (change === 'disabled') { + tree.update(); + } else { + tree.unmount(); + } + }); + await renderer.act(async () => { + resolvePayload({command: 'bold'}); + await pendingPayload; + }); + expect(onChangeText).not.toHaveBeenCalled(); + }); + test('uses the command payload resolver for link commands', async () => { const onChangeText = jest.fn(); const resolveCommandPayload = jest.fn(async () => ({ diff --git a/__tests__/MarkdownWysiwygEditor.test.tsx b/__tests__/MarkdownWysiwygEditor.test.tsx new file mode 100644 index 00000000..521c7e78 --- /dev/null +++ b/__tests__/MarkdownWysiwygEditor.test.tsx @@ -0,0 +1,223 @@ +import React from 'react'; +import {Text, TextInput} from 'react-native'; +import renderer from 'react-test-renderer'; + +import { + insertTextAtSelection, + MarkdownWysiwygEditor, + removeTextBeforeSelection, +} from '../src'; + +const renderEditor = ( + props: React.ComponentProps, +): renderer.ReactTestRenderer => { + let tree: renderer.ReactTestRenderer | undefined; + + renderer.act(() => { + tree = renderer.create(); + }); + + if (!tree) { + throw new Error('Failed to render MarkdownWysiwygEditor'); + } + + return tree; +}; + +describe('MarkdownWysiwygEditor', () => { + test.each([true, false])('restores rejected native edits and preserves focus=%s', (focused) => { + const ref = React.createRef(); + const onChangeText = jest.fn(); + const tree = renderEditor({ref, onChangeText, value: '**Hello** world'}); + const previousInput = ref.current!; + const focus = jest.spyOn(previousInput, 'focus').mockClear(); + const isFocused = jest.spyOn(previousInput, 'isFocused').mockReturnValue(focused); + renderer.act(() => { + tree.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: 3, end: 3}}}); + }); + renderer.act(() => { tree.root.findByType(TextInput).props.onChangeText('Hel!lo world'); }); + + expect(onChangeText).toHaveBeenCalledWith('**Hel!lo** world'); + expect(ref.current).not.toBe(previousInput); + expect(tree.root.findByType(TextInput).props.selection).toEqual({start: 3, end: 3}); + expect(tree.root.findAllByType(Text).map((node) => node.props.children).join('')).toBe('Hello world'); + expect(focus).toHaveBeenCalledTimes(focused ? 1 : 0); + isFocused.mockRestore(); + focus.mockRestore(); + }); + + test('keeps the native input and ref when the parent accepts an edit', () => { + const ref = React.createRef(); + function ControlledEditor(): React.JSX.Element { + const [value, setValue] = React.useState('**Hello**'); + return ; + } + let tree: renderer.ReactTestRenderer | undefined; + renderer.act(() => { tree = renderer.create(); }); + if (!tree) { + throw new Error('Failed to render controlled editor'); + } + const previousInput = ref.current; + const input = tree.root.findByType(TextInput); + renderer.act(() => { input.props.onChangeText('Hello!'); }); + + expect(ref.current).toBe(previousInput); + expect(tree.root.findAllByType(Text).map((node) => node.props.children).join('')).toBe('Hello!'); + expect(input.props.selection).toEqual({start: 6, end: 6}); + }); + + test('renders markdown as styled segments without showing the markers', () => { + const tree = renderEditor({ + onChangeText: jest.fn(), + value: '**Hello** world', + }); + + const texts = tree.root.findAllByType(Text); + const renderedText = texts.map((node) => node.props.children).join(''); + + // Markers are hidden; only the display text is shown. + expect(renderedText).toBe('Hello world'); + + const input = tree.root.findByType(TextInput); + expect(input.props.selection).toEqual({end: 11, start: 11}); + }); + + test('hides heading markers and renders list bullets', () => { + const tree = renderEditor({ + onChangeText: jest.fn(), + value: '# Title\n- Item', + }); + + const texts = tree.root.findAllByType(Text); + const renderedText = texts.map((node) => node.props.children).join(''); + + expect(renderedText).toBe('Title\nβ€’ Item'); + }); + + test('maps an edit on the display text back onto the markdown source', () => { + const onChangeText = jest.fn(); + const onSelectionChange = jest.fn(); + const tree = renderEditor({ + onChangeText, + onSelectionChange, + selection: {end: 5, start: 5}, + value: '**Hello**', + }); + + const input = tree.root.findByType(TextInput); + + renderer.act(() => { + input.props.onChangeText('Hello!'); + }); + + expect(onChangeText).toHaveBeenCalledWith('**Hello!**'); + expect(onSelectionChange).toHaveBeenCalledWith({end: 6, start: 6}); + }); + + test('reports selection changes in display coordinates', () => { + const onSelectionChange = jest.fn(); + const tree = renderEditor({ + onChangeText: jest.fn(), + onSelectionChange, + value: '**Hello**', + }); + + const input = tree.root.findByType(TextInput); + + renderer.act(() => { + input.props.onSelectionChange({ + nativeEvent: {selection: {end: 5, start: 0}}, + }); + }); + + expect(onSelectionChange).toHaveBeenCalledWith({end: 5, start: 0}); + }); + + test('uses the controlled selection when deleting repeated display characters', () => { + const onChangeText = jest.fn(); + const tree = renderEditor({ + onChangeText, + selection: {start: 0, end: 1}, + value: 'a**a**', + }); + + renderer.act(() => { + tree.root.findByType(TextInput).props.onChangeText('a'); + }); + + expect(onChangeText).toHaveBeenCalledWith('**a**'); + }); + + test('continues typing outside a span closed by typed markdown', () => { + const ref = React.createRef(); + function ControlledEditor(): React.JSX.Element { + const [value, setValue] = React.useState(''); + return ; + } + let tree: renderer.ReactTestRenderer | undefined; + renderer.act(() => { tree = renderer.create(); }); + if (!tree) { + throw new Error('Failed to render controlled editor'); + } + const type = (display: string, caret: number): void => { + renderer.act(() => { tree!.root.findByType(TextInput).props.onChangeText(display); }); + renderer.act(() => { + tree!.root.findByType(TextInput).props.onSelectionChange({nativeEvent: {selection: {start: caret, end: caret}}}); + }); + }; + + type('*', 1); + type('*x', 2); + // The closing marker converts the text; native then reports the caret the surface set. + type('*x*', 1); + expect(tree.root.findAllByType(Text).map((node) => node.props.children).join('')).toBe('x'); + expect(tree.root.findByType(TextInput).props.selection).toEqual({start: 1, end: 1}); + type('xy', 2); + expect(tree.root.findAllByType(Text).map((node) => node.props.children).join('')).toBe('xy'); + expect(tree.root.findAllByType(Text).map((node) => node.props.children)).toEqual(['x', 'y']); + }); + + test('uses the native caret when deleting repeated display characters', () => { + const onChangeText = jest.fn(); + const onSelectionChange = jest.fn(); + const tree = renderEditor({onChangeText, onSelectionChange, value: 'a**a**'}); + + renderer.act(() => { + tree.root.findByType(TextInput).props.onSelectionChange({ + nativeEvent: {selection: {start: 1, end: 1}}, + }); + }); + renderer.act(() => { + tree.root.findByType(TextInput).props.onChangeText('a'); + }); + + expect(onChangeText).toHaveBeenCalledWith('**a**'); + expect(onSelectionChange).toHaveBeenLastCalledWith({start: 0, end: 0}); + }); +}); + +describe('wysiwyg editing utilities', () => { + test('inserts text through a selected range', () => { + expect( + insertTextAtSelection('Hello world', {end: 11, start: 6}, 'reader'), + ).toEqual({ + selection: { + end: 12, + start: 12, + }, + value: 'Hello reader', + }); + }); + + test('removes the selected range before deleting a single character', () => { + expect( + removeTextBeforeSelection('Hello world', {end: 11, start: 6}), + ).toEqual({ + selection: { + end: 6, + start: 6, + }, + value: 'Hello ', + }); + }); +}); diff --git a/__tests__/wysiwygFidelity.test.ts b/__tests__/wysiwygFidelity.test.ts new file mode 100644 index 00000000..7e63e4bc --- /dev/null +++ b/__tests__/wysiwygFidelity.test.ts @@ -0,0 +1,263 @@ +import MarkdownIt from 'markdown-it'; +import ins from 'markdown-it-ins'; + +import {applyDisplayEdit, applyInlineFormatWysiwyg} from '../src/lib/editor/utils/wysiwygEdit'; +import {buildWysiwygModel} from '../src/lib/editor/utils/wysiwygModel'; + +import type {MarkdownSelection} from '../src'; + +/** + * Fidelity contract for an LLM chat input: whatever the user types or pastes + * must reach `value` unchanged, and the editor must hide exactly the markers + * the renderer consumes so the message bubble shows what the editor showed. + */ +const md = new MarkdownIt().use(ins); + +const plainText = (source: string): string => { + const out: string[] = []; + const walk = (tokens: ReturnType): void => { + for (const token of tokens) { + if (token.children) { + walk(token.children); + } else if (token.type === 'text' || token.type === 'code_inline' || token.type === 'fence' || token.type === 'code_block') { + out.push(token.content); + } else if (token.type === 'softbreak' || token.type === 'hardbreak') { + out.push('\n'); + } + } + }; + + walk(md.parse(source, {})); + + return out.join(''); +}; + +/** Simulate typing one character at a time at the caret, as the editor component does. */ +const typeText = (text: string, source = '', caret?: number): {caret: number; value: string} => { + let value = source; + let position = caret ?? buildWysiwygModel(value).display.length; + let caretOutside = false; + + for (const character of text) { + const display = buildWysiwygModel(value).display; + const result = applyDisplayEdit( + value, + display.slice(0, position) + character + display.slice(position), + {start: position, end: position}, + {caretOutside}, + ); + + value = result.value; + position = result.selection.start; + caretOutside = result.caretOutside; + } + + return {caret: position, value}; +}; + +const pasteText = (text: string, source = ''): string => { + const display = buildWysiwygModel(source).display; + + return applyDisplayEdit(source, display + text, {start: display.length, end: display.length}).value; +}; + +describe('typing fidelity', () => { + test.each([ + 'snake_case_name and other_var_name', + 'call my_func(a_b, c_d) please', + '2 * 3 * 4 = 24', + 'use __init__ and __main__', + 'path C:\\Users\\ron\\my_file_name.txt', + 'https://example.com/a_b_c?x=1_2', + 'a*b*c', + 'rate is 5~~ish~~ ok', + 'C++ and D++ are languages', + 'x = a ** b ** c', + 'regex `a*b*` vs a*b*', + 'run `my_func(a_b)` now', + 'glob `**/*.ts` and `*.js`', + 'email me_at_home@x.com', + 'AT&T A & html', + 'for i in range(n): total += a_b * c_d * 2', + '**bold** then plain', + '**bold**, then plain', + 'say `code` and more', + 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ family πŸ‡©πŸ‡ͺ πŸ˜€*x*πŸ˜€', + 'Χ©ΧœΧ•Χ *Χ’Χ•ΧœΧ* δ½ ε₯½**δΈ–η•Œ**', + ])('typing %s keeps the value identical to the keystrokes', (text) => { + const {value} = typeText(text); + + expect(value).toBe(text); + expect(buildWysiwygModel(value).display).toBe(plainText(value)); + }); + + test('typing markdown syntax converts it once the pair closes', () => { + expect(typeText('some *emph* text').value).toBe('some *emph* text'); + expect(buildWysiwygModel('some *emph* text').display).toBe('some emph text'); + expect(typeText('**bo').value).toBe('**bo'); + expect(buildWysiwygModel('**bo').display).toBe('**bo'); + }); + + test('typing after a code span continues outside it', () => { + expect(typeText(' and more', 'say `code`').value).toBe('say `code` and more'); + expect(typeText('more', 'say `code`').value).toBe('say `code`more'); + }); + + test('typing a space at the end of emphasis keeps the closing delimiter valid', () => { + expect(typeText(' next', '**bold**').value).toBe('**bold** next'); + expect(typeText(' next', '_it_').value).toBe('_it_ next'); + expect(md.renderInline(typeText(' next', '**bold**').value)).toBe('bold next'); + }); + + test('typing a letter before an underscore span switches the delimiter family', () => { + const result = applyDisplayEdit('plain __bold__', 'plainx bold', {start: 5, end: 5}); + + expect(result.value).toBe('plainx __bold__'); + expect(result.display).toBe('plainx bold'); + expect(applyDisplayEdit('a _b_', 'a yb', {start: 2, end: 2}).value).toBe('a y*b*'); + }); + + test('typing a marker inside a span keeps the keystroke and shows the markdown result', () => { + const single = applyDisplayEdit('**bold**', 'bo*ld', {start: 2, end: 2}); + + expect(single.value).toBe('**bo*ld**'); + expect(single.display).toBe('bo*ld'); + expect(md.renderInline(single.value)).toBe('bo*ld'); + + const closing = applyDisplayEdit('**bold**', 'bo**ld', {start: 2, end: 2}); + + expect(closing.value).toBe('**bo**ld**'); + expect(closing.display).toBe(plainText(closing.value)); + }); + + test('typing continues outside a span the previous keystroke closed', () => { + expect(typeText('c', 'a*b*', 2).value).toBe('a*bc*'); + + const closed = applyDisplayEdit('a*b', 'a*b*', {start: 3, end: 3}); + + expect(closed.caretOutside).toBe(true); + expect(applyDisplayEdit(closed.value, 'abc', closed.selection, {caretOutside: true}).value).toBe('a*b*c'); + expect(typeText('o', '**Hell**', 4).value).toBe('**Hello**'); + }); +}); + +describe('paste fidelity', () => { + test.each([ + 'snake_case_name and other_var_name', + 'def my_func(a_b):\n return a_b * 2 * a_b\n', + 'Traceback:\n File "my_file.py", line 3, in my_func\n x = some_var * other_var * 2', + 'Here is my code:\n```python\ndef my_func(a_b):\n return a_b ** 2\n```\nwhy does it fail?', + '| a | b |\n|---|---|\n| 1 | 2 |', + '[link text](https://example.com/a_b_c) and ![img](x_y_z.png)', + ' - nested item\n - deeper *item*', + '1) paren list\n10. ten\n', + '***bold italic*** and **bold *nested* bold**', + '# Title\r\n- item\r\n```\r\ncode_a_b * c * d\r\n```\r\n', + ])('pasting %s keeps the value identical', (text) => { + expect(pasteText(text)).toBe(text); + }); + + test('pasted code and identifiers stay visible verbatim', () => { + const display = buildWysiwygModel(pasteText('def my_func(a_b):\n return a_b * 2 * a_b\n')).display; + + expect(display).toBe('def my_func(a_b):\n return a_b * 2 * a_b\n'); + }); + + test('pasting into the middle of a span keeps the surrounding formatting', () => { + const result = applyDisplayEdit('**bold** end', 'boX\nYld end', {start: 2, end: 2}); + + expect(result.value).toBe('**boX**\n**Yld** end'); + expect(result.display).toBe('boX\nYld end'); + }); + + test('recognises fences with Windows line endings', () => { + const model = buildWysiwygModel('```\r\na_b * c * d\r\n```\r\n'); + + expect(model.display).toBe('```\r\na_b * c * d\r\n```\r\n'); + expect(model.spans).toHaveLength(0); + }); +}); + +describe('untouched text', () => { + const seed = 'keep my_var_name and 2 * 3 * 4\nsecond **bold** line\n- item _x_\n```\ncode * a_b *\n```'; + + test.each(['x', ' ', '*', '_', '`', '\n', 'πŸ˜€', 'a_b'])('inserting %j anywhere leaves other lines byte-identical', (inserted) => { + const model = buildWysiwygModel(seed); + const lines = seed.split('\n'); + + for (let at = 0; at <= model.display.length; at += 1) { + const next = model.display.slice(0, at) + inserted + model.display.slice(at); + const result = applyDisplayEdit(seed, next, {start: at, end: at}); + const lineIndex = model.display.slice(0, at).split('\n').length - 1; + const resultLines = result.value.split('\n'); + + expect(resultLines.slice(0, lineIndex)).toEqual(lines.slice(0, lineIndex)); + expect(resultLines.slice(resultLines.length - (lines.length - lineIndex - 1))).toEqual(lines.slice(lineIndex + 1)); + expect(buildWysiwygModel(result.value).display).toBe(result.display); + } + }); + + test('backspace at every position never throws and keeps other lines', () => { + const model = buildWysiwygModel(seed); + const lines = seed.split('\n'); + + for (let at = 1; at <= model.display.length; at += 1) { + const next = model.display.slice(0, at - 1) + model.display.slice(at); + const result = applyDisplayEdit(seed, next, {start: at, end: at}); + const lineIndex = model.display.slice(0, at - 1).split('\n').length - 1; + const removedNewline = model.display[at - 1] === '\n'; + const resultLines = result.value.split('\n'); + + expect(resultLines.slice(0, lineIndex)).toEqual(lines.slice(0, lineIndex)); + + if (!removedNewline) { + expect(resultLines.slice(lineIndex + 1)).toEqual(lines.slice(lineIndex + 1)); + } + } + }); + + test('a toolbar toggle never splits a surrogate pair', () => { + expect(applyInlineFormatWysiwyg('hi πŸ˜€ there', {start: 3, end: 4}, 'bold').value).toBe('hi **πŸ˜€** there'); + expect(applyInlineFormatWysiwyg('hi πŸ˜€ there', {start: 3, end: 5}, 'bold').value).toBe('hi **πŸ˜€** there'); + }); + + test('a toolbar toggle only rewrites the selected line', () => { + const source = 'keep my_var_name * here\nmake **this** bold\nand 2 * 3 * 4 stays'; + const display = buildWysiwygModel(source).display; + const start = display.indexOf('bold'); + const result = applyInlineFormatWysiwyg(source, {start, end: start + 4}, 'italic'); + + expect(result.value).toBe('keep my_var_name * here\nmake **this** _bold_\nand 2 * 3 * 4 stays'); + }); +}); + +describe('enter key', () => { + const enter = (source: string, caret: number): {display: string; selection: MarkdownSelection; value: string} => { + const display = buildWysiwygModel(source).display; + + return applyDisplayEdit(source, display.slice(0, caret) + '\n' + display.slice(caret), {start: caret, end: caret}); + }; + + test('continues bullet, ordered and nested lists', () => { + expect(enter('- first', 7)).toMatchObject({display: 'β€’ first\nβ€’ ', value: '- first\n- '}); + expect(enter('1. first', 8)).toMatchObject({display: '1. first\n2. ', selection: {start: 12, end: 12}, value: '1. first\n2. '}); + expect(enter(' * deep', 8).value).toBe(' * deep\n * '); + expect(enter('3) paren', 8).value).toBe('3) paren\n4) '); + }); + + test('continues quotes and leaves an empty item', () => { + expect(enter('> quote', 7).value).toBe('> quote\n> '); + expect(enter('- ', 2)).toMatchObject({display: '', selection: {start: 0, end: 0}, value: ''}); + expect(enter('1. a\n2. ', 8)).toMatchObject({value: '1. a\n', selection: {start: 5, end: 5}}); + expect(enter('> ', 2).value).toBe(''); + }); + + test('splits an item and keeps inline formatting on both sides', () => { + expect(enter('- a **bc** d', 5)).toMatchObject({display: 'β€’ a b\nβ€’ c d', value: '- a **b**\n- **c** d'}); + }); + + test('does not continue headings or plain lines', () => { + expect(enter('# Title', 5).value).toBe('# Title\n'); + expect(enter('plain', 5).value).toBe('plain\n'); + }); +}); diff --git a/__tests__/wysiwygModel.test.ts b/__tests__/wysiwygModel.test.ts new file mode 100644 index 00000000..ade87904 --- /dev/null +++ b/__tests__/wysiwygModel.test.ts @@ -0,0 +1,407 @@ +import MarkdownIt from 'markdown-it'; +import {applyDisplayEdit, applyInlineFormatWysiwyg} from '../src/lib/editor/utils/wysiwygEdit'; +import { + buildWysiwygModel, + cleanEmptyMarkers, + displaySelectionToSource, + sourceOffsetToDisplay, +} from '../src/lib/editor/utils/wysiwygModel'; + +describe('buildWysiwygModel', () => { + test('hides inline markers and exposes display text', () => { + const model = buildWysiwygModel('**Hello** _world_'); + + expect(model.display).toBe('Hello world'); + }); + + test('tracks the source index of every display character', () => { + const model = buildWysiwygModel('**Hi**'); + + // Display "Hi": H -> source index 2, i -> source index 3. + expect(model.charSource).toEqual([2, 3]); + }); + + test('groups display characters into formatted segments', () => { + const model = buildWysiwygModel('**Hello** world'); + + expect(model.segments).toEqual([ + {block: null, formats: ['bold'], synthetic: false, text: 'Hello'}, + {block: null, formats: [], synthetic: false, text: ' world'}, + ]); + }); + + test('nests formats', () => { + const model = buildWysiwygModel('**_both_**'); + + expect(model.display).toBe('both'); + expect(model.segments[0]?.formats.sort()).toEqual(['bold', 'italic']); + }); + + test('treats code spans as literal content', () => { + const model = buildWysiwygModel('`a*b*c`'); + + expect(model.display).toBe('a*b*c'); + expect(model.segments).toEqual([ + {block: null, formats: ['inline-code'], synthetic: false, text: 'a*b*c'}, + ]); + }); + + test('keeps unbalanced markers as literal text', () => { + const model = buildWysiwygModel('a ** b'); + + expect(model.display).toBe('a ** b'); + }); + + test('does not let inline markers span across lines', () => { + const model = buildWysiwygModel('a **b\nc** d'); + + expect(model.display).toBe('a **b\nc** d'); + }); +}); + +describe('block markers', () => { + test('hides heading markers and styles the line', () => { + const model = buildWysiwygModel('## Title'); + + expect(model.display).toBe('Title'); + expect(model.segments).toEqual([ + {block: 'heading-two', formats: [], synthetic: false, text: 'Title'}, + ]); + }); + + test('renders a synthetic bullet for unordered lists', () => { + const model = buildWysiwygModel('- Item'); + + expect(model.display).toBe('β€’ Item'); + expect(model.segments).toEqual([ + {block: 'list-marker', formats: [], synthetic: true, text: 'β€’ '}, + {block: null, formats: [], synthetic: false, text: 'Item'}, + ]); + }); + + test('renders the ordinal for ordered lists', () => { + const model = buildWysiwygModel('3. Item'); + + expect(model.display).toBe('3. Item'); + expect(model.segments[0]).toEqual({ + block: 'list-marker', + formats: [], + synthetic: true, + text: '3. ', + }); + }); + + test('keeps inline formatting inside a heading', () => { + const model = buildWysiwygModel('# A **B**'); + + expect(model.display).toBe('A B'); + expect(model.segments).toEqual([ + {block: 'heading-one', formats: [], synthetic: false, text: 'A '}, + {block: 'heading-one', formats: ['bold'], synthetic: false, text: 'B'}, + ]); + }); + + test('handles multi-line documents with mixed blocks', () => { + const model = buildWysiwygModel('# Title\n- one\n- two'); + + expect(model.display).toBe('Title\nβ€’ one\nβ€’ two'); + }); +}); + +describe('offset mapping', () => { + test('clamps an outdated display range before source mapping', () => { + expect(displaySelectionToSource(buildWysiwygModel('Hi'), {start: 2, end: 8})).toEqual({start: 2, end: 2}); + expect(displaySelectionToSource(buildWysiwygModel('**Hi**'), {start: -5, end: 8})).toEqual({start: 2, end: 4}); + }); + test('maps a display selection back to the source range', () => { + const model = buildWysiwygModel('**Hello** world'); + + // Select "Hello" in display coords [0, 5]. + expect(displaySelectionToSource(model, {end: 5, start: 0})).toEqual({ + end: 7, + start: 2, + }); + }); + + test('maps a source offset to a display offset', () => { + const model = buildWysiwygModel('**Hello**'); + + // Source offset 7 sits just after the last visible char. + expect(sourceOffsetToDisplay(model, 7)).toBe(5); + expect(sourceOffsetToDisplay(model, 2)).toBe(0); + }); +}); + +describe('cleanEmptyMarkers', () => { + test('removes empty marker pairs', () => { + expect(cleanEmptyMarkers('****')).toBe(''); + expect(cleanEmptyMarkers('a ____ b ++++ c ~~~~')).toBe('a b c '); + }); + + test.each([ + '`a****b`', + '``a`****`b``', + '```js\na****b\n```', + '~~~~\na++++b\n~~~~', + '__bold__', + 'a__b', + '``', + ])('preserves literal content and nonempty formatting in %s', (source) => { + expect(cleanEmptyMarkers(source)).toBe(source); + }); +}); + +describe('applyDisplayEdit', () => { + test.each([ + ['**Hello**', 'Hello ', 5, 'Hello '], + ['**`ab`**cd', 'abxd', 2, 'abxd'], + ['**`ab`**cd', 'abd', 3, 'abd'], + ] as const)('keeps valid emphasis after a boundary edit in %s', (source, display, caret, html) => { + const result = applyDisplayEdit(source, display, {start: caret, end: caret}); + expect(result.display).toBe(display); + expect(new MarkdownIt().renderInline(result.value)).toBe(html); + }); + test.each([ + ['```js\na*b\n```', 8, '```js\na*\nb\n```'], + ['[Hello](test)', 1, '[\nHello](test)'], + ['\nHello', 0, '\n\nHello'], + ] as const)('preserves literal syntax when inserting a newline in %s', (source, caret, expected) => { + const model = buildWysiwygModel(source); + const result = applyDisplayEdit(source, model.display.slice(0, caret) + '\n' + model.display.slice(caret), {start: caret, end: caret}); + expect(result.value).toBe(expected); + expect(result.display).toBe(expected); + }); + test.each([ + ['**Hello**', 5, 'Hello\n', '**Hello**\n'], + ['**Hello**', 3, 'Hel\nlo', '**Hel**\n**lo**'], + ['**_Hello_**', 3, 'Hel\nlo', '**_Hel_**\n**_lo_**'], + ['`Hello`', 3, 'Hel\nlo', '`Hel`\n`lo`'], + ['# **Hello**', 3, 'Hel\nlo', '# **Hel**\n**lo**'], + ] as const)('keeps newlines outside inline markers in %s', (source, caret, display, expected) => { + const result = applyDisplayEdit(source, display, {start: caret, end: caret}); + expect(result.value).toBe(expected); + expect(result.display).toBe(display); + expect(result.selection).toEqual({start: caret + 1, end: caret + 1}); + }); + + test.each([ + ['Hello\n# World', 6, 'HelloWorld', 'HelloWorld', 'HelloWorld'], + ['# One\n# Two', 4, 'OneTwo', '# OneTwo', 'OneTwo'], + ['- One\n- Two', 6, 'β€’ Oneβ€’ Two', '- OneTwo', 'β€’ OneTwo'], + ['1. One\n2. Two', 7, '1. One2. Two', '1. OneTwo', '1. OneTwo'], + ] as const)('removes the second block prefix when joining %s', (source, caret, nextDisplay, expected, display) => { + const result = applyDisplayEdit(source, nextDisplay, {start: caret, end: caret}); + expect(result.value).toBe(expected); + expect(result.display).toBe(display); + }); + test.each([ + '`a****b` end', + '``a`****`b`` end', + '```js\na****b\n```\nend', + '~~~~\na++++b\n~~~~\nend', + '__bold__ end', + 'a__b end', + '**** end', + ])('preserves untouched syntax when appending to %s', (source) => { + const display = buildWysiwygModel(source).display; + const result = applyDisplayEdit(source, `${display}!`); + + expect(result.value).toBe(`${source}!`); + expect(result.display).toBe(`${display}!`); + }); + + test.each([ + ['**Hello** world', 'Held', '**Hel**d'], + ['Hello **world**', 'Held', 'Hel**d**'], + ['**ab** _cd_', 'ad', '**a**_d_'], + ['**a _bc_ d**', 'ad', '**ad**'], + ['**ab** _cd_', '', ''], + ['**_Hi_**', '', ''], + ['`a****b`', '', ''], + ['` a `', '', ''], + ['`` ` ``', '', ''], + ])('preserves delimiter pairs when deleting from %s', (source, display, expected) => { + const result = applyDisplayEdit(source, display); + + expect(result.value).toBe(expected); + expect(result.display).toBe(display); + }); + + test('replaces a selection across formatting boundaries', () => { + const result = applyDisplayEdit('**Hello** world', 'HeXd', {start: 2, end: 10}); + + expect(result.value).toBe('**HeX**d'); + expect(result.display).toBe('HeXd'); + expect(result.selection).toEqual({start: 3, end: 3}); + }); + + test.each([ + ['**a** **b**', '**ab**'], + ['_a_ _b_', '_ab_'], + ['++a++ ++b++', '++ab++'], + ['`a` `b`', '`ab`'], + ])('joins matching spans when deleting the gap in %s', (source, expected) => { + const result = applyDisplayEdit(source, 'ab', {start: 1, end: 2}); + + expect(result.value).toBe(expected); + expect(result.display).toBe('ab'); + expect(result.selection).toEqual({start: 1, end: 1}); + }); + + test.each([ + {start: 0, end: 1}, + {start: 2, end: 6}, + {start: 7, end: 8}, + ])('keeps literal backticks when deleting within code: $start–$end', (selection) => { + const source = '``a`****`b`` end'; + const display = buildWysiwygModel(source).display; + const expected = display.slice(0, selection.start) + display.slice(selection.end); + const result = applyDisplayEdit(source, expected, selection); + + expect(result.display).toBe(expected); + expect(result.selection).toEqual({start: selection.start, end: selection.start}); + }); + + test.each([ + {selection: {start: 0, end: 1}, expected: '**a**', caret: 0}, + {selection: {start: 1, end: 1}, expected: '**a**', caret: 0}, + {selection: {start: 0, end: 0}, expected: '**a**', caret: 0}, + {selection: {start: 1, end: 2}, expected: 'a', caret: 1}, + {selection: {start: 2, end: 2}, expected: 'a', caret: 1}, + ])('uses the previous selection to delete repeated characters: $selection', ({selection, expected, caret}) => { + const result = applyDisplayEdit('a**a**', 'a', selection); + + expect(result.value).toBe(expected); + expect(result.selection).toEqual({start: caret, end: caret}); + }); + + test('uses the caret to insert a repeated character into the correct span', () => { + const result = applyDisplayEdit('a**a**', 'aaa', {start: 0, end: 0}); + + expect(result.value).toBe('aa**a**'); + expect(result.selection).toEqual({start: 1, end: 1}); + }); + + test('falls back to the text diff when an edit does not match the previous selection', () => { + const result = applyDisplayEdit('**teh** end', 'the end', {start: 7, end: 7}); + + expect(result.value).toBe('**the** end'); + expect(result.display).toBe('the end'); + }); + + test('keeps the source and selection on an unchanged native text event', () => { + const result = applyDisplayEdit('`a****b`', 'a****b', {start: 2, end: 2}); + + expect(result.value).toBe('`a****b`'); + expect(result.selection).toEqual({start: 2, end: 2}); + }); + + test('inserts a typed character inside a bold span keeping it bold', () => { + // Display "Hello", caret after "Hell" -> type "o" already there; insert "!" + const result = applyDisplayEdit('**Hello**', 'Hello!'); + + expect(result.value).toBe('**Hello!**'); + expect(result.display).toBe('Hello!'); + expect(result.selection).toEqual({end: 6, start: 6}); + }); + + test('deleting the last visible character keeps the closing markers', () => { + const result = applyDisplayEdit('**Hello**', 'Hell'); + + expect(result.value).toBe('**Hell**'); + expect(result.display).toBe('Hell'); + }); + + test('clearing all bold text removes the now empty markers', () => { + const result = applyDisplayEdit('**Hi**', ''); + + expect(result.value).toBe(''); + expect(result.display).toBe(''); + }); + + test('appending at the end of a span inherits its formatting', () => { + const result = applyDisplayEdit('**Hello**', 'Hello world'); + + expect(result.value).toBe('**Hello world**'); + }); + + test('typing inside a list item keeps the marker', () => { + // Display "β€’ Item" -> insert "s" after "Item". + const result = applyDisplayEdit('- Item', 'β€’ Items'); + + expect(result.value).toBe('- Items'); + expect(result.display).toBe('β€’ Items'); + }); + + test('deleting the synthetic bullet removes the whole list marker', () => { + // Backspace the bullet space: display "β€’ Item" -> "β€’Item". + const result = applyDisplayEdit('- Item', 'β€’Item'); + + expect(result.value).toBe('Item'); + expect(result.display).toBe('Item'); + }); + + test('typing inside a heading keeps it hidden', () => { + const result = applyDisplayEdit('## Title', 'Titles'); + + expect(result.value).toBe('## Titles'); + expect(result.display).toBe('Titles'); + }); +}); + +describe('applyInlineFormatWysiwyg', () => { + test.each([ + ['Hello', 1, 4, 'italic', 'Hello'], + [' Hello ', 0, 7, 'bold', ' Hello '], + ['**Hello**', 2, 4, 'bold', 'Hello'], + ['**Hello** world', 2, 8, 'italic', 'Hello world'], + ['**Hello** world', 0, 6, 'bold', 'Hello world'], + ['**_abcd_**', 1, 2, 'italic', 'abcd'], + ['**ab**cd', 1, 3, 'italic', 'abcd'], + ['ab**cd**', 1, 3, 'italic', 'abcd'], + ['**ab**cd', 0, 2, 'inline-code', 'abcd'], + ['ab**cd**', 2, 4, 'inline-code', 'abcd'], + ['**ab**Γ©ζ–‡', 0, 2, 'inline-code', 'abΓ©ζ–‡'], + ] as const)('serializes emphasis at word and whitespace boundaries in %s', (source, start, end, format, expected) => { + const result = applyInlineFormatWysiwyg(source, {start, end}, format); + expect(result.display).toBe(buildWysiwygModel(source).display); + expect(new MarkdownIt().renderInline(result.value)).toBe(expected); + }); + test.each([ + ['__Hello__', 'bold', 'Hello', 'Hello'], + ['**_Hi_**', 'bold', '_Hi_', 'Hi'], + ['**_Hi_**', 'italic', '**Hi**', 'Hi'], + ['``a`b``', 'inline-code', 'a`b', 'a`b'], + ['`Hi`', 'bold', '**`Hi`**', 'Hi'], + ] as const)('toggles parsed formatting in %s', (source, format, expected, html) => { + const display = buildWysiwygModel(source).display; + const result = applyInlineFormatWysiwyg(source, {start: 0, end: display.length}, format); + expect(result.value).toBe(expected); + expect(result.display).toBe(display); + expect(new MarkdownIt().renderInline(result.value)).toBe(html); + }); + + test.each([ + ['- Item', '- **Item**', 'β€’ Item'], + ['3. Item', '3. **Item**', '3. Item'], + ['# Title\n- Item', '# **Title**\n- **Item**', 'Title\nβ€’ Item'], + ])('keeps block markers outside a whole-display inline selection in %s', (source, expected, display) => { + const result = applyInlineFormatWysiwyg(source, {start: 0, end: display.length}, 'bold'); + expect(result.value).toBe(expected); + expect(result.display).toBe(display); + }); + test('wraps the selected display text and selects it in display coords', () => { + const result = applyInlineFormatWysiwyg('Hello', {end: 5, start: 0}, 'bold'); + + expect(result.value).toBe('**Hello**'); + expect(result.display).toBe('Hello'); + expect(result.selection).toEqual({end: 5, start: 0}); + }); + + test('toggles an existing format off', () => { + const result = applyInlineFormatWysiwyg('**Hello**', {end: 5, start: 0}, 'bold'); + + expect(result.value).toBe('Hello'); + expect(result.display).toBe('Hello'); + }); +}); diff --git a/doc/markdown-editor-architecture.md b/doc/markdown-editor-architecture.md index 5c5719a7..d720ebae 100644 --- a/doc/markdown-editor-architecture.md +++ b/doc/markdown-editor-architecture.md @@ -56,10 +56,86 @@ Implemented: Still pending: -- native inline attributed styling - richer keyboard shortcut handling - more advanced built-in dialogs and field-level UX polish +## WYSIWYG Editing + +`MarkdownEditor` is the unified surface and supports two modes via a `mode` +prop (`'plaintext' | 'wysiwyg'`) plus an optional built-in toggle: + +- `plaintext`: edits the raw markdown source through `MarkdownTextInput`. +- `wysiwyg`: edits through `MarkdownWysiwygEditor`, a real multiline + `TextInput` whose inline markers are hidden and whose content is rendered as + styled `Text` spans (bold, italic, underline, strikethrough, inline code). + +Markdown is always the source of truth in both modes. The WYSIWYG surface keeps +the markdown source authoritative while presenting marker-free display text. + +The fidelity contract, enforced by `__tests__/wysiwygFidelity.test.ts`: text the +user types or pastes reaches `value` byte for byte, lines the user did not touch +are never rewritten, and the editor hides exactly the markers the renderer +consumes so the sent message looks like the editor did. + +- `utils/wysiwygModel.ts` parses the source into a display string, per-character + source ranges and formats, grouped styled segments, and the recognised inline + spans and block prefixes. Inline parsing is a port of markdown-it's rules + (delimiter runs with CommonMark flanking, the rule of three, backtick runs, + escapes, entities, `~~` and the `ins` plugin's `++`), using markdown-it's own + character classes, so `snake_case`, `2 * 3`, `C++` and `a*b*c` parse exactly + as the message bubble renders them. Lines are parsed independently and cached + by text, and whole models are cached by source, so a keystroke re-parses only + the line it touched and both editor components share one model per value. +- `utils/wysiwygEdit.ts` translates display edits back onto the source. It + classifies the surviving source bytes into units (display characters, hidden + delimiters, block prefixes, newlines), inserts the typed text between them, + and verifies by re-parsing that every surviving span is still recognised. + Text typed after a character continues its formatting; trailing whitespace + moves outside closing delimiters; code spans are not extended by typing at + their end; a newline closes open spans and reopens them on the next line; + Enter continues list and quote prefixes and exits an empty one; deleting a + glyph or the newline before a prefix removes the whole prefix; spans emptied + by a deletion lose their delimiters and matching neighbours are joined. +- Markdown typed by hand takes effect: when typed marker characters change the + parse without altering visible text, the keystrokes are kept verbatim. The + result's `caretOutside` flag tells the component that the next keystroke at + that caret belongs after the span that was just closed, so `*b*` then `c` + gives `*b*c`. When an edit would break surviving formatting, the pipeline + escapes the typed markers, switches `_`/`*` delimiter families, and only as a + last resort re-serializes the edited lines (choosing markers, escaping, and + finally character references for boundaries CommonMark cannot express, such + as bold code directly followed by a letter). +- `applyInlineFormatWysiwyg` toggles formats on the selected display characters + (or the word around a collapsed caret) and re-serializes only those lines, + keeping block prefixes, other lines and literal characters as they were. +- `displaySelectionToSource` / `sourceOffsetToDisplay` translate selections + between the two coordinate spaces. Selections are clamped after value changes + and translated for both toolbar and parent-controlled mode changes. +- Async command resolvers apply their returned command to the latest committed + value and selection. Pending commands are discarded after unmounting or when + editing is disabled. Both toolbars honor `editable={false}` and `readOnly`. +- The native surface uses styled children without a `TextInput.value`, since + React Native does not support combining both. Accepted edits retain the input + instance. If a parent rejects an edit, the surface replaces its native input + to restore the source text and selection, and restores focus when needed. + Like a controlled React Native `TextInput`, the parent must commit `value` + synchronously; a debounced or asynchronous parent is indistinguishable from a + rejection. + +Known limitations (next steps): + +- Headings hide their prefixes and lists show synthetic markers (with their + indentation). Quotes, tables, links, images, HTML and fenced code blocks are + edited as literal visible text; link URLs and indented code blocks are + inline-parsed like prose, so their display may hide emphasis the renderer + keeps literal. The stored value is never affected. +- Inline spans are matched per line. Emphasis that CommonMark would carry across + a soft line break within a paragraph shows its markers in the editor. +- Native styling, selection, IME, autocorrect and undo behavior need validation + on iOS and Android devices; see `doc/wysiwyg-device-checklist.md`. Replacing + the input after a rejected edit can reset native undo and composition state; + tests cover the component recovery path using native mocks. + ## Design Principles - Native inputs only diff --git a/doc/wysiwyg-device-checklist.md b/doc/wysiwyg-device-checklist.md new file mode 100644 index 00000000..1f17cb62 --- /dev/null +++ b/doc/wysiwyg-device-checklist.md @@ -0,0 +1,73 @@ +# WYSIWYG Device Checklist + +The rich-text surface (`MarkdownWysiwygEditor`) renders styled children inside a +multiline `TextInput` and controls `selection` on every render. The pure model is +covered by Jest, but the native behaviors below can only be verified on hardware. +Run through this list on a physical iOS device and a physical Android device +(plus one Android device with a non-Latin keyboard) before calling the mode +production ready. Use the example app in `example/` with the mode toggle set to +"Rich text". + +## Typing and caret + +- [ ] Type a sentence quickly; no dropped or duplicated characters. +- [ ] Type `**bold** then plain` character by character; "bold" turns bold when the + second `**` is typed and "then plain" stays plain. +- [ ] Type `snake_case_name`, `2 * 3 * 4`, `C++` and a URL with underscores; the + value shown in plain-text mode equals what was typed. +- [ ] Place the caret in the middle of a bold word, type, and confirm the text stays + bold and the caret does not jump. +- [ ] Type a space at the end of a bold word; the bold stays intact and the space is + plain. +- [ ] Type at the end of an inline code span; the new text is not code. +- [ ] Press Enter inside a list item; a new item appears with the caret after the + bullet. Press Enter on the empty item; the bullet disappears. +- [ ] Backspace over a bullet; the whole marker disappears in one step. + +## Selection and toolbar + +- [ ] Select text with the handles and press Bold; the selection stays on the same + text afterwards. +- [ ] Toggle Bold with a collapsed caret inside a word; the word is formatted. +- [ ] Switch between plain and rich mode with a selection active; the selection + covers the same text in both modes. +- [ ] Long-press to select a word; the native menu (Copy, Paste, Select All) works. + +## Input methods + +- [ ] Android autocorrect: type a misspelled word and accept the suggestion inside + bold text; the correction lands in the right place and stays bold. +- [ ] iOS predictive text: accept a QuickType suggestion; no doubled words. +- [ ] CJK input (Japanese or Chinese keyboard): compose a word over several + keystrokes; composition is not interrupted by re-rendering. +- [ ] Dictation: dictate a sentence; text appears once, with correct caret. +- [ ] Emoji keyboard: insert an emoji inside bold text; it renders bold and the + caret advances by one glyph. +- [ ] Hardware keyboard (iPad / Android with keyboard): Shift+Arrow selection, Cmd/Ctrl+Z + undo after typing, and after a toolbar command. + +## Paste and large content + +- [ ] Paste a 20k+ character log or code dump; the paste completes without a + visible freeze and scrolling stays smooth while typing at the end. +- [ ] Paste content copied from a Windows machine (CRLF line endings); fenced code + blocks stay literal. +- [ ] Paste a previous assistant answer containing headings, lists, tables and code + fences; the value sent equals the pasted text. + +## Rejection and controlled value + +- [ ] Wire `onChangeText` to a parent that ignores edits over a length limit; the + surface snaps back and keeps focus. +- [ ] Wire `onChangeText` through a debounce (this is unsupported); observe the + flicker and document it, or remove the debounce. + +## Rendering + +- [ ] Headings render larger with no visible `#`. +- [ ] Underline plus strikethrough on the same text shows both decorations. +- [ ] Dark mode colors from `formatStyles` are applied. +- [ ] Placeholder shows when the value is empty in rich mode. + +Record the device, OS version, React Native version and the outcome of each item +in the pull request that promotes the rich mode. diff --git a/example/App.tsx b/example/App.tsx index 205cd28a..874066f0 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -3,7 +3,7 @@ import {Alert, FlatList, KeyboardAvoidingView, type ListRenderItemInfo, Platform import {MaterialDesignIcons} from '@react-native-vector-icons/material-design-icons'; import markdownItContainer from 'markdown-it-container'; -import Markdown, {createMarkdownIt, MarkdownComposer, MarkdownStream, type MarkdownStyleMap, type MarkdownToolbarItem, type OnCopyCode, type RenderRules} from '../src'; +import Markdown, {createMarkdownIt, MarkdownEditor, type MarkdownEditorMode, MarkdownStream, type MarkdownStyleMap, type MarkdownToolbarItem, type OnCopyCode, type RenderRules} from '../src'; interface ChatMessage { author: 'demo' | 'you'; @@ -237,8 +237,8 @@ function App(): React.JSX.Element { const streamPositionRef = useRef(0); const streamIntervalRef = useRef | null>(null); const [draft, setDraft] = useState(''); + const [editorMode, setEditorMode] = useState('plaintext'); const [messages, setMessages] = useState(INITIAL_MESSAGES); - const composerPreviewStyle = useMemo(() => getMarkdownStyles(false, isDark), [isDark]); const startStreamingDemo = useCallback((): void => { if (streamIntervalRef.current) { @@ -498,18 +498,16 @@ function App(): React.JSX.Element { - diff --git a/package-lock.json b/package-lock.json index 2a0c857d..35e3a658 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ronradtke/react-native-markdown-display", - "version": "9.0.1", + "version": "9.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ronradtke/react-native-markdown-display", - "version": "9.0.1", + "version": "9.1.0", "license": "MIT", "dependencies": { "@react-native-vector-icons/material-design-icons": "^13.0.0", diff --git a/package.json b/package.json index a21af332..8b67bbf0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ronradtke/react-native-markdown-display", - "version": "9.0.3", + "version": "9.1.0", "description": "Markdown renderer for react-native, with CommonMark spec support + adds syntax extensions & sugar (URL autolinking, typographer), originally created by Mient-jan Stelling as react-native-markdown-renderer", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -14,8 +14,8 @@ "build": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true}); require('fs').rmSync('lib', {recursive: true, force: true})\" && tsc -p tsconfig.json", "format": "prettier -w src", "format-check": "prettier --check src", - "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --cache ./src", - "lint-fix": "ESLINT_USE_FLAT_CONFIG=false eslint --fix --cache ./src", + "lint": "eslint --cache ./src", + "lint-fix": "eslint --fix --cache ./src", "test": "jest", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/src/lib/editor/EditorToolbar/index.tsx b/src/lib/editor/EditorToolbar/index.tsx new file mode 100644 index 00000000..a7e256e1 --- /dev/null +++ b/src/lib/editor/EditorToolbar/index.tsx @@ -0,0 +1,107 @@ +import React, {useState} from 'react'; +import {Pressable, Text, View} from 'react-native'; + +import styles from './style'; + +import type {MarkdownCommand, MarkdownToolbarAction, MarkdownToolbarButtonItem, MarkdownToolbarCommandItem, MarkdownToolbarItem, MarkdownToolbarMenuItem} from '../types'; + +const DEFAULT_TOOLBAR_ACCESSIBILITY_LABELS: Record = {bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', 'inline-code': 'Inline code', 'heading-one': 'Heading one', 'heading-two': 'Heading two', 'heading-three': 'Heading three', blockquote: 'Block quote', 'bullet-list': 'Bullet list', 'ordered-list': 'Ordered list', 'code-block': 'Code block', link: 'Insert link', table: 'Insert table'}; + +const isToolbarMenuItem = (item: MarkdownToolbarItem): item is MarkdownToolbarMenuItem => 'items' in item; + +const isToolbarCommandItem = (item: MarkdownToolbarButtonItem): item is MarkdownToolbarCommandItem => 'command' in item; + +const getToolbarMenuAccessibilityLabel = (item: MarkdownToolbarMenuItem): string => item.accessibilityLabel ?? (typeof item.label === 'string' || typeof item.label === 'number' ? `${item.label} menu` : 'Toolbar menu'); + +const getToolbarButtonAccessibilityLabel = (item: MarkdownToolbarButtonItem): string => { + if (item.accessibilityLabel) { + return item.accessibilityLabel; + } + + if (isToolbarCommandItem(item)) { + return DEFAULT_TOOLBAR_ACCESSIBILITY_LABELS[item.command]; + } + + if (typeof item.label === 'string' || typeof item.label === 'number') { + return String(item.label); + } + + return 'Toolbar action'; +}; + +const getToolbarButtonKey = (item: MarkdownToolbarButtonItem, index: number): string => (isToolbarCommandItem(item) ? `command:${item.command}:${index}` : `action:${index}`); + +const renderToolbarLabel = (label: MarkdownToolbarItem['label']): React.ReactNode => (typeof label === 'string' || typeof label === 'number' ? {label} : {label}); + +export interface EditorToolbarProps { + /** Inline commands currently active at the selection, highlighted as toggled on. */ + activeCommands?: ReadonlySet | undefined; + disabled?: boolean; + items: readonly MarkdownToolbarItem[]; + onAction: (action: MarkdownToolbarAction) => void; + onCommand: (command: MarkdownCommand) => void; +} + +/** + * Shared toolbar used by the markdown editor. Renders command buttons, custom + * insert/wrap actions, and grouped dropdown menus. + */ +function EditorToolbar({activeCommands, disabled = false, items, onAction, onCommand}: EditorToolbarProps): React.JSX.Element | null { + const [openMenuIndex, setOpenMenuIndex] = useState(null); + + if (items.length === 0) { + return null; + } + + const handleButtonPress = (item: MarkdownToolbarButtonItem): void => { + if (disabled) { + return; + } + + setOpenMenuIndex(null); + + if (isToolbarCommandItem(item)) { + onCommand(item.command); + return; + } + + onAction(item.action); + }; + + const isCommandActive = (item: MarkdownToolbarButtonItem): boolean => isToolbarCommandItem(item) && activeCommands !== undefined && activeCommands.has(item.command); + + return ( + + {items.map((item, index) => { + if (isToolbarMenuItem(item)) { + const isMenuOpen = openMenuIndex === index; + + return ( + + setOpenMenuIndex(currentIndex => (currentIndex === index ? null : index))} style={[styles.toolbarButton, isMenuOpen ? styles.toolbarButtonActive : null]}> + {renderToolbarLabel(item.label)} + + {isMenuOpen ? ( + + {item.items.map((menuItem, menuItemIndex) => ( + handleButtonPress(menuItem)} style={styles.toolbarMenuButton}> + {renderToolbarLabel(menuItem.label)} + + ))} + + ) : null} + + ); + } + + return ( + handleButtonPress(item)} style={[styles.toolbarButton, isCommandActive(item) ? styles.toolbarButtonActive : null]}> + {renderToolbarLabel(item.label)} + + ); + })} + + ); +} + +export default EditorToolbar; diff --git a/src/lib/editor/EditorToolbar/style.ts b/src/lib/editor/EditorToolbar/style.ts new file mode 100644 index 00000000..ab976dda --- /dev/null +++ b/src/lib/editor/EditorToolbar/style.ts @@ -0,0 +1,5 @@ +import {StyleSheet} from 'react-native'; + +const styles = StyleSheet.create({toolbar: {flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginBottom: 10}, toolbarButton: {alignItems: 'center', backgroundColor: '#FFFFFF', borderColor: '#D8E0E8', borderRadius: 6, borderWidth: 1, height: 34, justifyContent: 'center', minWidth: 34, paddingHorizontal: 8, paddingVertical: 6}, toolbarButtonActive: {backgroundColor: '#EAF3FC', borderColor: '#0A66C2'}, toolbarButtonContent: {alignItems: 'center', justifyContent: 'center'}, toolbarButtonText: {color: '#2B3137', fontSize: 14, fontWeight: '600'}, toolbarMenu: {backgroundColor: '#FFFFFF', borderColor: '#D8E0E8', borderRadius: 8, borderWidth: 1, elevation: 3, gap: 6, left: 0, minWidth: 64, padding: 6, position: 'absolute', top: 38, zIndex: 1}, toolbarMenuButton: {alignItems: 'center', borderRadius: 6, justifyContent: 'center', paddingHorizontal: 10, paddingVertical: 6}, toolbarMenuContainer: {position: 'relative'}}); + +export default styles; diff --git a/src/lib/editor/MarkdownEditor/index.tsx b/src/lib/editor/MarkdownEditor/index.tsx new file mode 100644 index 00000000..66ed8cdc --- /dev/null +++ b/src/lib/editor/MarkdownEditor/index.tsx @@ -0,0 +1,152 @@ +import React, {useMemo, useState} from 'react'; +import {Pressable, Text, TextInput, View} from 'react-native'; + +import EditorToolbar from '../EditorToolbar'; +import {applyToolbarAction} from '../commands/formatMarkdown'; +import {executeCommand, isInlineCommand} from '../commands/executeCommand'; +import {DEFAULT_EXPANDED_TOOLBAR_ITEMS} from '../defaultToolbarItems'; +import MarkdownTextInput from '../MarkdownTextInput'; +import MarkdownWysiwygEditor from '../MarkdownWysiwygEditor'; +import styles from './style'; +import {normalizeSelection} from '../utils/selection'; +import {useCommittedValue} from '../utils/useCommittedValue'; +import {applyInlineFormatWysiwyg} from '../utils/wysiwygEdit'; +import {buildWysiwygModel, displaySelectionToSource, getFormatsAtDisplayOffset, sourceOffsetToDisplay} from '../utils/wysiwygModel'; + +import type {MarkdownCommand, MarkdownCommandResult, MarkdownEditorMode, MarkdownEditorProps, MarkdownSelection, MarkdownToolbarAction} from '../types'; + +const MODE_LABELS: Record = {plaintext: 'Plain text', wysiwyg: 'Rich text'}; + +const MODE_ORDER: readonly MarkdownEditorMode[] = ['plaintext', 'wysiwyg']; + +const MarkdownEditor = React.forwardRef(function MarkdownEditor({containerStyle, editorStyle, formatStyles, mode: controlledMode, onChangeText, onModeChange, resolveCommandPayload, showModeToggle = true, textStyle, toolbarItems = DEFAULT_EXPANDED_TOOLBAR_ITEMS, value, ...textInputProps}, ref) { + const [internalMode, setInternalMode] = useState('plaintext'); + const mode = controlledMode ?? internalMode; + const model = useMemo(() => buildWysiwygModel(value), [value]); + const [selectionState, setSelectionState] = useState<{mode: MarkdownEditorMode; selection: MarkdownSelection}>(() => ({mode, selection: normalizeSelection(mode === 'wysiwyg' ? model.display : value, undefined)})); + const selection = useMemo(() => { + if (selectionState.mode === mode) { + return normalizeSelection(mode === 'wysiwyg' ? model.display : value, selectionState.selection); + } + + if (mode === 'plaintext') { + return displaySelectionToSource(model, selectionState.selection); + } + + const sourceRange = normalizeSelection(value, selectionState.selection); + return {start: sourceOffsetToDisplay(model, sourceRange.start), end: sourceOffsetToDisplay(model, sourceRange.end)}; + }, [mode, model, selectionState, value]); + const disabled = textInputProps.editable === false || textInputProps.readOnly === true; + const latest = useCommittedValue({disabled, mode, onChangeText, resolveCommandPayload, selection, value}); + + const activeCommands = useMemo | undefined>(() => { + if (mode !== 'wysiwyg') { + return undefined; + } + + const formats = getFormatsAtDisplayOffset(model, selection.start); + + return new Set(formats); + }, [selection.start, mode, model]); + + const changeMode = (nextMode: MarkdownEditorMode): void => { + if (nextMode === mode) { + return; + } + + if (controlledMode === undefined) { + setInternalMode(nextMode); + } + + onModeChange?.(nextMode); + }; + + const applyCommand = async (command: MarkdownCommand): Promise => { + const initial = latest.current; + + if (!initial || initial.disabled) { + return; + } + + const resolvedPayload = await initial.resolveCommandPayload?.(command); + const current = latest.current; + + if (resolvedPayload === null || !current || current.disabled) { + return; + } + + const payload = resolvedPayload ?? {command}; + + if (current.mode === 'wysiwyg') { + if (isInlineCommand(payload.command)) { + const result = applyInlineFormatWysiwyg(current.value, current.selection, payload.command); + + setSelectionState({mode: current.mode, selection: result.selection}); + current.onChangeText(result.value); + return; + } + + const currentModel = buildWysiwygModel(current.value); + const sourceRange = displaySelectionToSource(currentModel, current.selection); + const result = executeCommand(current.value, sourceRange, payload); + const nextModel = buildWysiwygModel(result.value); + + setSelectionState({mode: current.mode, selection: {end: sourceOffsetToDisplay(nextModel, result.selection.end), start: sourceOffsetToDisplay(nextModel, result.selection.start)}}); + current.onChangeText(result.value); + return; + } + + const result = executeCommand(current.value, current.selection, payload); + + setSelectionState({mode: current.mode, selection: result.selection}); + current.onChangeText(result.value); + }; + + const applyAction = (action: MarkdownToolbarAction): void => { + const current = latest.current; + + if (!current || current.disabled) { + return; + } + + let result: MarkdownCommandResult; + + if (current.mode === 'wysiwyg') { + const currentModel = buildWysiwygModel(current.value); + const sourceRange = displaySelectionToSource(currentModel, current.selection); + result = applyToolbarAction(current.value, sourceRange, action); + const nextModel = buildWysiwygModel(result.value); + + result.selection = {end: sourceOffsetToDisplay(nextModel, result.selection.end), start: sourceOffsetToDisplay(nextModel, result.selection.start)}; + } else { + result = applyToolbarAction(current.value, current.selection, action); + } + + setSelectionState({mode: current.mode, selection: result.selection}); + current.onChangeText(result.value); + }; + + return ( + + {showModeToggle ? ( + + {MODE_ORDER.map(modeOption => { + const isActive = mode === modeOption; + + return ( + changeMode(modeOption)} style={[styles.modeButton, isActive ? styles.modeButtonActive : null]}> + {MODE_LABELS[modeOption]} + + ); + })} + + ) : null} + + {mode === 'wysiwyg' ? setSelectionState({mode: 'wysiwyg', selection: nextSelection})} ref={ref} selection={selection} textStyle={textStyle} value={value} /> : setSelectionState({mode: 'plaintext', selection: event.nativeEvent.selection})} ref={ref} selection={selection} style={[styles.surface, editorStyle, textStyle]} toolbarItems={[]} value={value} />} + + ); +}); + +MarkdownEditor.displayName = 'MarkdownEditor'; + +export default MarkdownEditor; diff --git a/src/lib/editor/MarkdownEditor/style.ts b/src/lib/editor/MarkdownEditor/style.ts new file mode 100644 index 00000000..b79efa96 --- /dev/null +++ b/src/lib/editor/MarkdownEditor/style.ts @@ -0,0 +1,5 @@ +import {StyleSheet} from 'react-native'; + +const styles = StyleSheet.create({container: {width: '100%'}, modeButton: {borderRadius: 6, paddingHorizontal: 12, paddingVertical: 6}, modeButtonActive: {backgroundColor: '#FFFFFF', elevation: 1}, modeButtonText: {color: '#56606B', fontSize: 13, fontWeight: '600'}, modeButtonTextActive: {color: '#0A66C2'}, modeToggle: {alignSelf: 'flex-start', backgroundColor: '#E9EEF3', borderRadius: 8, flexDirection: 'row', gap: 4, marginBottom: 10, padding: 3}, surface: {backgroundColor: '#FFFFFF', borderColor: '#D8E0E8', borderRadius: 8, borderWidth: 1, minHeight: 44, paddingHorizontal: 12, paddingVertical: 10}}); + +export default styles; diff --git a/src/lib/editor/MarkdownTextInput/index.tsx b/src/lib/editor/MarkdownTextInput/index.tsx index 686d7580..358e1a02 100644 --- a/src/lib/editor/MarkdownTextInput/index.tsx +++ b/src/lib/editor/MarkdownTextInput/index.tsx @@ -1,13 +1,15 @@ import React, {useMemo, useRef, useState} from 'react'; import {type NativeSyntheticEvent, Pressable, Text, TextInput, type TextInputContentSizeChangeEventData, type TextInputSelectionChangeEventData, View} from 'react-native'; -import {applyBlockFormat, applyInlineFormat, applyLinkFormat, applyTableFormat, applyToolbarAction} from '../commands/formatMarkdown'; +import {applyToolbarAction} from '../commands/formatMarkdown'; +import {executeCommand} from '../commands/executeCommand'; import {DEFAULT_TEXT_INPUT_TOOLBAR_ITEMS} from '../defaultToolbarItems'; import styles from './style'; import {applyMarkdownShortcut} from '../utils/shortcuts'; import {normalizeSelection} from '../utils/selection'; +import {useCommittedValue} from '../utils/useCommittedValue'; -import type {MarkdownCommand, MarkdownCommandResult, MarkdownManagedTextInputProps, MarkdownTextInputCommandPayload, MarkdownTextInputProps, MarkdownToolbarButtonItem, MarkdownToolbarCommandItem, MarkdownToolbarItem, MarkdownToolbarMenuItem} from '../types'; +import type {MarkdownCommand, MarkdownManagedTextInputProps, MarkdownTextInputProps, MarkdownToolbarButtonItem, MarkdownToolbarCommandItem, MarkdownToolbarItem, MarkdownToolbarMenuItem} from '../types'; const DEFAULT_TOOLBAR_ACCESSIBILITY_LABELS: Record = {bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', 'inline-code': 'Inline code', 'heading-one': 'Heading one', 'heading-two': 'Heading two', 'heading-three': 'Heading three', blockquote: 'Block quote', 'bullet-list': 'Bullet list', 'ordered-list': 'Ordered list', 'code-block': 'Code block', link: 'Insert link', table: 'Insert table'}; @@ -37,33 +39,6 @@ const getToolbarButtonKey = (item: MarkdownToolbarButtonItem, index: number): st const renderToolbarLabel = (label: MarkdownToolbarItem['label']): React.ReactNode => (typeof label === 'string' || typeof label === 'number' ? {label} : {label}); -const executeCommand = (value: string, selection: MarkdownTextInputProps['selection'], payload: MarkdownTextInputCommandPayload): MarkdownCommandResult => { - switch (payload.command) { - case 'bold': - case 'italic': - case 'underline': - case 'strikethrough': - case 'inline-code': - return applyInlineFormat(value, selection, payload.command); - case 'heading-one': - case 'heading-two': - case 'heading-three': - case 'blockquote': - case 'bullet-list': - case 'ordered-list': - case 'code-block': - return applyBlockFormat(value, selection, payload.command); - case 'link': - return applyLinkFormat(value, selection, payload.link); - case 'table': - return applyTableFormat(value, selection, payload.table); - default: { - const exhaustiveCheck: never = payload.command; - throw new Error(`Unsupported markdown command: ${String(exhaustiveCheck)}`); - } - } -}; - const MarkdownTextInput = React.forwardRef(function MarkdownTextInput({onChangeText, onCommand, onSelectionChange, inputComponent: InputComponent, selection, style, toolbarItems = DEFAULT_TEXT_INPUT_TOOLBAR_ITEMS, compactMaxHeight, enableShortcuts = true, multiline = true, numberOfLines, resolveCommandPayload, value, ...textInputProps}, ref) { const [internalSelection, setInternalSelection] = useState(() => normalizeSelection(value, selection)); const [contentHeight, setContentHeight] = useState(null); @@ -71,6 +46,8 @@ const MarkdownTextInput = React.forwardRef(fu const pendingSelectionValueRef = useRef(null); const normalizedSelection = useMemo(() => normalizeSelection(value, selection ?? internalSelection), [internalSelection, selection, value]); + const disabled = textInputProps.editable === false || textInputProps.readOnly === true; + const latest = useCommittedValue({disabled, normalizedSelection, onChangeText, onCommand, resolveCommandPayload, selection, value}); const handleSelectionChange = (event: NativeSyntheticEvent): void => { const pendingSelectionValue = pendingSelectionValueRef.current; @@ -92,28 +69,37 @@ const MarkdownTextInput = React.forwardRef(fu }; const handleCommandPress = async (command: MarkdownCommand): Promise => { + const initial = latest.current; + if (!initial || initial.disabled) { + return; + } setOpenMenuIndex(null); - const resolvedPayload = await resolveCommandPayload?.(command); + const resolvedPayload = await initial.resolveCommandPayload?.(command); + const current = latest.current; - if (resolvedPayload === null) { + if (resolvedPayload === null || !current || current.disabled) { return; } const payload = resolvedPayload ?? {command}; - const result = executeCommand(value, normalizedSelection, payload); + const result = executeCommand(current.value, current.normalizedSelection, payload); - if (!selection) { + if (!current.selection) { pendingSelectionValueRef.current = result.value; setInternalSelection(result.selection); } - onChangeText(result.value); - onCommand?.(payload, result); + current.onChangeText(result.value); + current.onCommand?.(payload, result); }; const handleToolbarButtonPress = async (item: MarkdownToolbarButtonItem): Promise => { + const current = latest.current; + if (!current || current.disabled) { + return; + } if (isToolbarCommandItem(item)) { await handleCommandPress(item.command); return; @@ -121,14 +107,14 @@ const MarkdownTextInput = React.forwardRef(fu setOpenMenuIndex(null); - const result = applyToolbarAction(value, normalizedSelection, item.action); + const result = applyToolbarAction(current.value, current.normalizedSelection, item.action); - if (!selection) { + if (!current.selection) { pendingSelectionValueRef.current = result.value; setInternalSelection(result.selection); } - onChangeText(result.value); + current.onChangeText(result.value); }; const handleContentSizeChange = (event: NativeSyntheticEvent): void => { @@ -168,7 +154,7 @@ const MarkdownTextInput = React.forwardRef(fu return ( - setOpenMenuIndex(currentIndex => (currentIndex === index ? null : index))} style={[styles.toolbarButton, isMenuOpen ? styles.toolbarButtonActive : null]}> + setOpenMenuIndex(currentIndex => (currentIndex === index ? null : index))} style={[styles.toolbarButton, isMenuOpen ? styles.toolbarButtonActive : null]}> {renderToolbarLabel(item.label)} {isMenuOpen ? ( @@ -177,6 +163,8 @@ const MarkdownTextInput = React.forwardRef(fu { handleToolbarButtonPress(menuItem); @@ -195,6 +183,8 @@ const MarkdownTextInput = React.forwardRef(fu { handleToolbarButtonPress(item); diff --git a/src/lib/editor/MarkdownWysiwygEditor/index.tsx b/src/lib/editor/MarkdownWysiwygEditor/index.tsx new file mode 100644 index 00000000..d86493d1 --- /dev/null +++ b/src/lib/editor/MarkdownWysiwygEditor/index.tsx @@ -0,0 +1,143 @@ +import React, {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {type NativeSyntheticEvent, type StyleProp, Text, TextInput, type TextInputSelectionChangeEventData, type TextStyle} from 'react-native'; + +import styles from './style'; +import {normalizeSelection} from '../utils/selection'; +import {applyDisplayEdit} from '../utils/wysiwygEdit'; +import {buildWysiwygModel, type WysiwygBlock, type WysiwygInlineFormat, type WysiwygSegment} from '../utils/wysiwygModel'; + +import type {MarkdownSelection, MarkdownWysiwygEditorProps, MarkdownWysiwygFormatStyles} from '../types'; + +const setForwardedRef = (forwardedRef: React.ForwardedRef, value: TextInput | null): void => { + if (typeof forwardedRef === 'function') { + forwardedRef(value); + return; + } + + if (forwardedRef) { + forwardedRef.current = value; + } +}; + +const getDefaultFormatStyle = (format: WysiwygInlineFormat, hasUnderline: boolean, hasStrikethrough: boolean): StyleProp => { + switch (format) { + case 'bold': + return styles.bold; + case 'italic': + return styles.italic; + case 'inline-code': + return styles.code; + case 'underline': + return hasStrikethrough ? styles.underlineStrikethrough : styles.underline; + case 'strikethrough': + return hasUnderline ? null : styles.strikethrough; + default: + return null; + } +}; + +const getBlockStyle = (block: WysiwygBlock): StyleProp => { + switch (block) { + case 'heading-one': + return styles.headingOne; + case 'heading-two': + return styles.headingTwo; + case 'heading-three': + return styles.headingThree; + case 'list-marker': + return styles.listMarker; + default: + return null; + } +}; + +const getSegmentStyle = (segment: WysiwygSegment, formatStyles: MarkdownWysiwygFormatStyles | undefined): StyleProp[] => { + const hasUnderline = segment.formats.includes('underline'); + const hasStrikethrough = segment.formats.includes('strikethrough'); + + return [getBlockStyle(segment.block), ...segment.formats.flatMap(format => [getDefaultFormatStyle(format, hasUnderline, hasStrikethrough), formatStyles?.[format] ?? null])]; +}; + +const MarkdownWysiwygEditor = React.forwardRef(function MarkdownWysiwygEditor({editorStyle, formatStyles, onChangeText, onSelectionChange, selection, textStyle, value, ...textInputProps}, ref) { + const model = useMemo(() => buildWysiwygModel(value), [value]); + const inputRef = useRef(null); + // Display caret at which typed markdown just closed a span; typing there continues outside it. + const caretOutsideRef = useRef(null); + const [nativeState, setNativeState] = useState<{generation: number; restoreFocus: boolean; pending: {display: string; previousDisplay: string; previousSelection: MarkdownSelection; focused: boolean} | null}>({generation: 0, restoreFocus: false, pending: null}); + const [internalSelection, setInternalSelection] = useState(() => normalizeSelection(model.display, selection)); + + const normalizedSelection = useMemo(() => normalizeSelection(model.display, selection ?? internalSelection), [internalSelection, model.display, selection]); + + if (nativeState.pending) { + const pending = nativeState.pending; + const rejected = pending.display !== model.display && pending.previousDisplay === model.display; + + // Unchanged attributed children cannot reset native text. Recreate only + // a rejected edit's native surface; normal accepted typing keeps its host. + setNativeState({generation: nativeState.generation + (rejected ? 1 : 0), restoreFocus: rejected ? pending.focused : nativeState.restoreFocus, pending: null}); + if (rejected && selection === undefined) { + setInternalSelection(pending.previousSelection); + } + } + + const setInputRef = useCallback( + (input: TextInput | null): void => { + inputRef.current = input; + setForwardedRef(ref, input); + }, + [ref], + ); + + useLayoutEffect(() => { + if (nativeState.restoreFocus) { + inputRef.current?.focus(); + } + }, [nativeState.generation, nativeState.restoreFocus]); + + const updateSelection = (nextSelection: MarkdownSelection): void => { + if (!selection) { + setInternalSelection(nextSelection); + } + + onSelectionChange?.(nextSelection); + }; + + const handleChangeText = (nextDisplay: string): void => { + const caretOutside = caretOutsideRef.current !== null && normalizedSelection.start === caretOutsideRef.current && normalizedSelection.end === caretOutsideRef.current; + const result = applyDisplayEdit(value, nextDisplay, normalizedSelection, {caretOutside}); + const focused = inputRef.current?.isFocused() ?? false; + + caretOutsideRef.current = result.caretOutside ? result.selection.start : null; + + setNativeState(current => ({...current, pending: {display: nextDisplay, previousDisplay: model.display, previousSelection: normalizedSelection, focused}})); + + updateSelection(result.selection); + onChangeText(result.value); + }; + + const handleSelectionChange = (event: NativeSyntheticEvent): void => { + const {start, end} = event.nativeEvent.selection; + + if (caretOutsideRef.current !== null && (start !== caretOutsideRef.current || end !== start)) { + caretOutsideRef.current = null; + } + + updateSelection(event.nativeEvent.selection); + }; + + return ( + + {model.display.length > 0 + ? model.segments.map((segment, index) => ( + + {segment.text} + + )) + : null} + + ); +}); + +MarkdownWysiwygEditor.displayName = 'MarkdownWysiwygEditor'; + +export default MarkdownWysiwygEditor; diff --git a/src/lib/editor/MarkdownWysiwygEditor/style.ts b/src/lib/editor/MarkdownWysiwygEditor/style.ts new file mode 100644 index 00000000..5142fe76 --- /dev/null +++ b/src/lib/editor/MarkdownWysiwygEditor/style.ts @@ -0,0 +1,5 @@ +import {Platform, StyleSheet} from 'react-native'; + +const styles = StyleSheet.create({bold: {fontWeight: '700'}, code: {backgroundColor: '#EFF4F8', fontFamily: Platform.select({android: 'monospace', default: 'Menlo', ios: 'Menlo'})}, headingOne: {fontSize: 26, fontWeight: '700', lineHeight: 32}, headingThree: {fontSize: 18, fontWeight: '700', lineHeight: 24}, headingTwo: {fontSize: 21, fontWeight: '700', lineHeight: 28}, listMarker: {color: '#56606B'}, input: {color: '#1F2933', fontSize: 16, lineHeight: 22, margin: 0, minHeight: 44, padding: 0, textAlignVertical: 'top', width: '100%'}, italic: {fontStyle: 'italic'}, strikethrough: {textDecorationLine: 'line-through'}, underline: {textDecorationLine: 'underline'}, underlineStrikethrough: {textDecorationLine: 'underline line-through'}}); + +export default styles; diff --git a/src/lib/editor/commands/executeCommand.ts b/src/lib/editor/commands/executeCommand.ts new file mode 100644 index 00000000..bf74bfe4 --- /dev/null +++ b/src/lib/editor/commands/executeCommand.ts @@ -0,0 +1,40 @@ +import {applyBlockFormat, applyInlineFormat, applyLinkFormat, applyTableFormat} from './formatMarkdown'; + +import type {MarkdownCommandResult, MarkdownSelection, MarkdownTextInputCommandPayload} from '../types'; + +/** + * Dispatch a markdown command to the matching pure transform. Shared by the + * plain-text input and the WYSIWYG editor so command behaviour stays identical + * across editing modes. + */ +export const executeCommand = (value: string, selection: MarkdownSelection | undefined, payload: MarkdownTextInputCommandPayload): MarkdownCommandResult => { + switch (payload.command) { + case 'bold': + case 'italic': + case 'underline': + case 'strikethrough': + case 'inline-code': + return applyInlineFormat(value, selection, payload.command); + case 'heading-one': + case 'heading-two': + case 'heading-three': + case 'blockquote': + case 'bullet-list': + case 'ordered-list': + case 'code-block': + return applyBlockFormat(value, selection, payload.command); + case 'link': + return applyLinkFormat(value, selection, payload.link); + case 'table': + return applyTableFormat(value, selection, payload.table); + default: { + const exhaustiveCheck: never = payload.command; + throw new Error(`Unsupported markdown command: ${String(exhaustiveCheck)}`); + } + } +}; + +const INLINE_COMMANDS: ReadonlySet = new Set(['bold', 'italic', 'underline', 'strikethrough', 'inline-code']); + +/** Whether a command maps to an inline (character-level) markdown format. */ +export const isInlineCommand = (command: MarkdownTextInputCommandPayload['command']): command is import('../types').MarkdownInlineFormat => INLINE_COMMANDS.has(command); diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts index 728c8ea3..43251eae 100644 --- a/src/lib/editor/index.ts +++ b/src/lib/editor/index.ts @@ -1,8 +1,19 @@ export {applyToolbarAction, applyToolbarInsertAction, applyToolbarWrapAction, applyBlockFormat, applyInlineFormat, applyLinkFormat, applyTableFormat, createMarkdownTable} from './commands/formatMarkdown'; +export {executeCommand, isInlineCommand} from './commands/executeCommand'; +export {default as EditorToolbar} from './EditorToolbar'; export {default as MarkdownComposer} from './MarkdownComposer'; +export {default as MarkdownEditor} from './MarkdownEditor'; export {default as MarkdownPreview} from './MarkdownPreview'; export {default as MarkdownTextInput} from './MarkdownTextInput'; +export {default as MarkdownWysiwygEditor} from './MarkdownWysiwygEditor'; export {applyMarkdownShortcut} from './utils/shortcuts'; export {getSelectedText, normalizeSelection} from './utils/selection'; +export {insertTextAtSelection, removeTextBeforeSelection} from './utils/wysiwygEditing'; +export {applyDisplayEdit, applyInlineFormatWysiwyg} from './utils/wysiwygEdit'; +export {buildWysiwygModel, cleanEmptyMarkers, displaySelectionToSource, getFormatsAtDisplayOffset, sourceOffsetToDisplay} from './utils/wysiwygModel'; -export type {MarkdownBlockFormat, MarkdownCommand, MarkdownCommandPayloadResolver, MarkdownCommandResult, MarkdownComposerMode, MarkdownComposerProps, MarkdownInputComponent, MarkdownInlineFormat, MarkdownLinkPayload, MarkdownManagedTextInputProps, MarkdownPreviewProps, MarkdownRenderOptions, MarkdownSelection, MarkdownTablePayload, MarkdownToolbarAction, MarkdownToolbarActionItem, MarkdownToolbarButtonItem, MarkdownToolbarCommandItem, MarkdownToolbarInsertAction, MarkdownTextInputCommandPayload, MarkdownToolbarMenuItem, MarkdownTextInputProps, MarkdownToolbarItem, MarkdownToolbarWrapAction} from './types'; +export type {WysiwygEditOptions, WysiwygEditResult} from './utils/wysiwygEdit'; +export type {WysiwygBlock, WysiwygBlockMarker, WysiwygBlockType, WysiwygInlineFormat, WysiwygModel, WysiwygSegment, WysiwygSpan} from './utils/wysiwygModel'; +export type {EditorToolbarProps} from './EditorToolbar'; + +export type {MarkdownBlockFormat, MarkdownCommand, MarkdownCommandPayloadResolver, MarkdownCommandResult, MarkdownComposerMode, MarkdownComposerProps, MarkdownEditorMode, MarkdownEditorProps, MarkdownInputComponent, MarkdownInlineFormat, MarkdownLinkPayload, MarkdownManagedTextInputProps, MarkdownPreviewProps, MarkdownRenderOptions, MarkdownSelection, MarkdownTablePayload, MarkdownToolbarAction, MarkdownToolbarActionItem, MarkdownToolbarButtonItem, MarkdownToolbarCommandItem, MarkdownToolbarInsertAction, MarkdownTextInputCommandPayload, MarkdownToolbarMenuItem, MarkdownTextInputProps, MarkdownToolbarItem, MarkdownToolbarWrapAction, MarkdownWysiwygEditorProps, MarkdownWysiwygFormat, MarkdownWysiwygFormatStyles} from './types'; diff --git a/src/lib/editor/types.ts b/src/lib/editor/types.ts index 2f4a4829..36177f78 100644 --- a/src/lib/editor/types.ts +++ b/src/lib/editor/types.ts @@ -44,6 +44,7 @@ export interface MarkdownTextInputCommandPayload { table?: MarkdownTablePayload; } +/** Resolved commands use the editor's current value and selection when they finish. */ export type MarkdownCommandPayloadResolver = (command: MarkdownCommand) => MarkdownTextInputCommandPayload | Promise | null; interface MarkdownToolbarBaseItem { @@ -129,6 +130,43 @@ export interface MarkdownComposerProps extends Omit; } +export type MarkdownWysiwygFormat = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'inline-code'; + +export type MarkdownWysiwygFormatStyles = Partial>>; + +export interface MarkdownWysiwygEditorProps extends Omit { + editorStyle?: StyleProp; + /** Per-format text styles applied to rendered inline spans. */ + formatStyles?: MarkdownWysiwygFormatStyles | undefined; + /** Receives markdown source after an edit to the rendered display text. */ + onChangeText: (value: string) => void; + /** Reports the caret/selection in display (marker-free) coordinates. */ + onSelectionChange?: (selection: MarkdownSelection) => void; + /** Controlled display selection, also used to locate edits in repeated text. */ + selection?: MarkdownSelection; + textStyle?: StyleProp; + /** Controlled markdown source. Rejected edits restore the native text and caret. */ + value: string; +} + +export type MarkdownEditorMode = 'plaintext' | 'wysiwyg'; + +export interface MarkdownEditorProps extends Omit { + containerStyle?: StyleProp; + editorStyle?: StyleProp; + formatStyles?: MarkdownWysiwygFormatStyles | undefined; + /** Controlled editing mode. Changes preserve the selection across coordinate spaces. */ + mode?: MarkdownEditorMode; + onChangeText: (value: string) => void; + onModeChange?: (mode: MarkdownEditorMode) => void; + resolveCommandPayload?: MarkdownCommandPayloadResolver; + /** Render a built-in plain-text/rich mode toggle. Defaults to true. */ + showModeToggle?: boolean; + textStyle?: StyleProp; + toolbarItems?: readonly MarkdownToolbarItem[]; + value: string; +} + export interface MarkdownPreviewProps extends MarkdownRenderOptions { emptyState?: string; label?: ReactNode; diff --git a/src/lib/editor/utils/useCommittedValue.ts b/src/lib/editor/utils/useCommittedValue.ts new file mode 100644 index 00000000..4eb6289b --- /dev/null +++ b/src/lib/editor/utils/useCommittedValue.ts @@ -0,0 +1,16 @@ +import {useLayoutEffect, useRef} from 'react'; +import type {RefObject} from 'react'; + +/** Event handlers may resume after an await; never expose an uncommitted render. */ +export const useCommittedValue = (value: T): RefObject => { + const committed = useRef(null); + + useLayoutEffect(() => { + committed.current = value; + return () => { + committed.current = null; + }; + }, [value]); + + return committed; +}; diff --git a/src/lib/editor/utils/wysiwygEdit.ts b/src/lib/editor/utils/wysiwygEdit.ts new file mode 100644 index 00000000..ff764603 --- /dev/null +++ b/src/lib/editor/utils/wysiwygEdit.ts @@ -0,0 +1,962 @@ +import {normalizeSelection} from './selection'; +import {BULLET_GLYPH, buildWysiwygModel, sourceOffsetToDisplay, type WysiwygInlineFormat, type WysiwygModel, type WysiwygSpan} from './wysiwygModel'; + +import type {MarkdownCommandResult, MarkdownSelection} from '../types'; + +export interface WysiwygEditResult extends MarkdownCommandResult { + /** + * True when typed markdown closed a span at the caret. Pass it back as + * `caretOutside` on the next edit so typing continues after the span. + */ + caretOutside: boolean; + /** Display text the surface should show after the edit. */ + display: string; +} + +export interface WysiwygEditOptions { + /** Insert at the caret outside any span that ends there instead of extending it. */ + caretOutside?: boolean; +} + +interface DisplayEdit extends MarkdownSelection { + inserted: string; +} + +const INLINE_ORDER: readonly WysiwygInlineFormat[] = ['bold', 'italic', 'underline', 'strikethrough']; +const MARKER_CLASS_RE = /[\\`*_~+]/; +const WHITESPACE_RE = /\s/; +const WORD_RE = /[\p{L}\p{N}_]/u; + +/** + * The edit pipeline works on a list of units: the surviving bytes of the old + * source classified as display characters, hidden delimiters, block prefixes + * and newlines, plus the pieces inserted by the edit. Concatenating the units + * yields the next source; the classification lets the pipeline verify that + * every surviving span is still recognised and repair the edited lines when + * it is not, leaving untouched lines byte-identical. + */ +interface CharUnit { + /** Display index in the old model, or -1 for inserted characters. */ + displayIndex: number; + formats: readonly WysiwygInlineFormat[]; + inserted: boolean; + kind: 'char'; + literal: boolean; + /** Markdown emitted for the character (escape or entity for literals). */ + source: string; + text: string; +} + +interface RawUnit { + caret: boolean; + /** Display text the unit produces (a glyph for list prefixes). */ + display: string; + kind: 'raw'; + newline: boolean; + text: string; + touched: boolean; +} + +interface DelimiterUnit { + format: WysiwygInlineFormat; + kind: 'open' | 'close'; + span: number; + text: string; +} + +type Unit = CharUnit | RawUnit | DelimiterUnit; + +const raw = (text: string, options: Partial> = {}): RawUnit => ({caret: false, display: '', kind: 'raw', newline: false, text, touched: false, ...options}); + +const newlineUnit = (): RawUnit => raw('\n', {display: '\n', newline: true, touched: true}); + +const stripMarkers = (text: string): string => text.replace(/[\\`*_~+]/g, ''); + +const chars = (text: string, formats: readonly WysiwygInlineFormat[]): CharUnit[] => Array.from({length: text.length}, (_unit, index) => ({displayIndex: -1, formats, inserted: true, kind: 'char' as const, literal: false, source: text[index]!, text: text[index]!})); + +const unitDisplayLength = (unit: Unit): number => (unit.kind === 'char' ? unit.text.length : unit.kind === 'raw' ? unit.display.length : 0); + +const formatsKey = (formats: readonly WysiwygInlineFormat[]): string => [...formats].sort().join('|'); + +const findDisplayEdit = (previous: string, next: string, selection: MarkdownSelection | undefined): DisplayEdit => { + const lengthChange = next.length - previous.length; + + const matchRange = (start: number, end: number): DisplayEdit | null => { + const insertedLength = lengthChange + end - start; + + if (start < 0 || end > previous.length || insertedLength < 0 || previous.slice(0, start) !== next.slice(0, start) || previous.slice(end) !== next.slice(start + insertedLength)) { + return null; + } + + return {start, end, inserted: next.slice(start, start + insertedLength)}; + }; + + if (selection) { + const {start, end} = normalizeSelection(previous, selection); + const replacement = matchRange(start, end); + + if (replacement) { + return replacement; + } + + if (start === end && lengthChange < 0) { + // Prefer backspace; try forward delete when the preceding text + // does not match. Both may otherwise look identical in repeated text. + const deletion = matchRange(start + lengthChange, start) ?? matchRange(start, start - lengthChange); + + if (deletion) { + return deletion; + } + } + } + + // Autocorrection and other native replacements may occur away from the + // previous caret. Fall back only when the selection cannot explain the edit. + const maxPrefix = Math.min(previous.length, next.length); + let start = 0; + + while (start < maxPrefix && previous[start] === next[start]) { + start += 1; + } + + let suffix = 0; + + while (suffix < Math.min(previous.length - start, next.length - start) && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) { + suffix += 1; + } + + return {start, end: previous.length - suffix, inserted: next.slice(start, next.length - suffix)}; +}; + +interface Plan { + /** Units inserted before the old source index used as key. */ + inserts: Map; + /** Span index that owns each old span after joins (`-1` when removed). */ + owner: number[]; + removed: Uint8Array; +} + +const createPlan = (model: WysiwygModel): Plan => ({inserts: new Map(), owner: model.spans.map((_span, index) => index), removed: new Uint8Array(model.source.length)}); + +const insertAt = (plan: Plan, index: number, units: Unit[]): void => { + const slot = plan.inserts.get(index); + + if (slot) { + slot.push(...units); + } else { + plan.inserts.set(index, units); + } +}; + +/** Turn the old source plus a plan into the ordered unit list. */ +const unitsFromPlan = (model: WysiwygModel, plan: Plan): Unit[] => { + const {source} = model; + const starts = new Map(); + const ends = new Map(); + const place = (start: number, end: number, unit: Unit): void => { + if (!plan.removed[start]) { + starts.set(start, unit); + ends.set(start, end); + } + }; + + for (let index = 0; index < model.charMarkerStart.length; index += 1) { + if (model.charSynthetic[index]) { + continue; + } + + const start = model.charMarkerStart[index]!; + const end = model.charMarkerEnd[index]!; + const text = model.display[index]!; + + if (text === '\n') { + place(start, end, raw(text, {display: '\n', newline: true})); + continue; + } + + // Decoded entities share one source range; a plain surrogate pair is + // kept as one unit so a selection cannot format half a code point. + const existing = starts.get(start); + + if (existing && existing.kind === 'char' && !plan.removed[start]) { + existing.text += text; + continue; + } + + const previousStart = index > 0 ? model.charMarkerStart[index - 1]! : -1; + const previous = starts.get(previousStart); + + if (previous && previous.kind === 'char' && !plan.removed[start] && ends.get(previousStart) === start && /[\uD800-\uDBFF]$/.test(previous.text) && /^[\uDC00-\uDFFF]/.test(text)) { + previous.text += text; + previous.source += source.slice(start, end); + ends.set(previousStart, end); + continue; + } + + place(start, end, {displayIndex: index, formats: model.charFormats[index]!, inserted: false, kind: 'char', literal: model.charLiteral[index]!, source: source.slice(start, end), text}); + } + + model.spans.forEach((span, index) => { + const owner = plan.owner[index]!; + + if (owner === -1) { + return; + } + + place(span.openStart, span.openEnd, {format: span.format, kind: 'open', span: owner, text: source.slice(span.openStart, span.openEnd)}); + place(span.closeStart, span.closeEnd, {format: span.format, kind: 'close', span: owner, text: source.slice(span.closeStart, span.closeEnd)}); + }); + + for (const block of model.blocks) { + place(block.sourceStart, block.sourceEnd, raw(source.slice(block.sourceStart, block.sourceEnd), {display: model.display.slice(block.displayStart, block.displayEnd)})); + } + + const units: Unit[] = []; + + for (let index = 0; index <= source.length; index += 1) { + const inserted = plan.inserts.get(index); + + if (inserted) { + units.push(...inserted); + } + + const unit = starts.get(index); + + if (unit) { + units.push(unit); + index = ends.get(index)! - 1; + } + } + + return units; +}; + +const unitText = (unit: Unit): string => (unit.kind === 'char' ? unit.source : unit.text); + +const assemble = (units: readonly Unit[]): {caret: number; starts: number[]; value: string} => { + const starts: number[] = []; + let value = ''; + let caret = -1; + + for (const unit of units) { + starts.push(value.length); + + if (unit.kind === 'raw' && unit.caret) { + caret = value.length; + } + + value += unitText(unit); + } + + return {caret: caret === -1 ? value.length : caret, starts, value}; +}; + +/** Spans the unit list promises, as `open:close:format` keys. */ +const expectedSpanKeys = (units: readonly Unit[], starts: readonly number[]): Map => { + const pending = new Map(); + const expected = new Map(); + + units.forEach((unit, index) => { + if (unit.kind === 'open') { + const stack = pending.get(unit.span) ?? []; + stack.push(index); + pending.set(unit.span, stack); + } else if (unit.kind === 'close') { + const opener = pending.get(unit.span)?.pop(); + + if (opener !== undefined) { + expected.set(`${starts[opener]}:${starts[index]}:${unit.format}`, [opener, index]); + } + } + }); + + return expected; +}; + +const actualSpanKeys = (model: WysiwygModel): Set => new Set(model.spans.map(span => `${span.openStart}:${span.closeStart}:${span.format}`)); + +/** Unit indexes of delimiters whose span the parser no longer recognises. */ +const missingDelimiters = (units: readonly Unit[]): number[] => { + const {starts, value} = assemble(units); + const actual = actualSpanKeys(buildWysiwygModel(value)); + const missing: number[] = []; + + for (const [key, indexes] of expectedSpanKeys(units, starts)) { + if (!actual.has(key)) { + missing.push(...indexes); + } + } + + return missing; +}; + +const richKeyOfModel = (model: WysiwygModel): string => { + let key = ''; + + for (let index = 0; index < model.display.length; index += 1) { + if (!model.charSynthetic[index]) { + key += `${model.display[index]}\u0001${formatsKey(model.charFormats[index]!)}\u0002`; + } + } + + return key; +}; + +const richKeyOfUnits = (units: readonly Unit[]): string => { + let key = ''; + + for (const unit of units) { + if (unit.kind === 'char') { + for (let index = 0; index < unit.text.length; index += 1) { + key += `${unit.text[index]}\u0001${formatsKey(unit.formats)}\u0002`; + } + } else if (unit.kind === 'raw' && unit.newline) { + key += '\n\u0001\u0002'; + } + } + + return key; +}; + +interface SerializeOptions { + bold: '**' | '__'; + /** Encode letters at invalid delimiter boundaries as character references. */ + encodeBoundaries: boolean; + escapeAll: boolean; + italic: '*' | '_'; +} + +const MARKER_CHOICES: readonly {bold: '**' | '__'; italic: '*' | '_'}[] = [ + {bold: '**', italic: '_'}, + {bold: '**', italic: '*'}, + {bold: '__', italic: '_'}, + {bold: '__', italic: '*'}, +]; + +/** Cheapest rewrites first: plain markers, then escaping, then boundary encoding. */ +const SERIALIZE_OPTIONS: readonly SerializeOptions[] = [...MARKER_CHOICES.map(choice => ({...choice, encodeBoundaries: false, escapeAll: false})), ...MARKER_CHOICES.map(choice => ({...choice, encodeBoundaries: false, escapeAll: true})), ...MARKER_CHOICES.map(choice => ({...choice, encodeBoundaries: true, escapeAll: true}))]; + +type SerializedPart = {kind: 'text' | 'code'; text: string} | {kind: 'delimiter'; opening: boolean; text: string}; + +/** Approximation of markdown-it's flanking rules for a delimiter between two characters. */ +const validDelimiterBoundary = (marker: string, opening: boolean, before: string, after: string): boolean => { + const beforeWhitespace = before === '' || WHITESPACE_RE.test(before); + const afterWhitespace = after === '' || WHITESPACE_RE.test(after); + const beforePunctuation = /[\p{P}\p{S}]/u.test(before); + const afterPunctuation = /[\p{P}\p{S}]/u.test(after); + const leftFlanking = !afterWhitespace && (!afterPunctuation || beforeWhitespace || beforePunctuation); + const rightFlanking = !beforeWhitespace && (!beforePunctuation || afterWhitespace || afterPunctuation); + const underscore = marker[0] === '_'; + + return opening ? leftFlanking && (!underscore || !rightFlanking || beforePunctuation) : rightFlanking && (!underscore || !leftFlanking || afterPunctuation); +}; + +/** + * CommonMark forbids boundaries such as **`code`**word. As a last resort the + * adjacent letter is written as a character reference, which renders the same + * text without adding a visible character. + */ +const encodeInvalidBoundaries = (parts: SerializedPart[]): void => { + let changed = true; + + while (changed) { + changed = false; + + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]!; + + if (part.kind !== 'delimiter') { + continue; + } + + const previous = parts[index - 1]; + const next = parts[index + 1]; + const before = Array.from(previous?.text ?? '').pop() ?? ''; + const after = Array.from(next?.text ?? '')[0] ?? ''; + + if (validDelimiterBoundary(part.text, part.opening, before, after)) { + continue; + } + + const boundary = part.opening ? previous : next; + + if (boundary?.kind === 'text' && /^[\p{L}\p{N}]$/u.test(boundary.text)) { + boundary.text = `&#${boundary.text.codePointAt(0)!};`; + changed = true; + } + } + } +}; + +const markerFor = (format: WysiwygInlineFormat, options: SerializeOptions): string => { + switch (format) { + case 'bold': + return options.bold; + case 'italic': + return options.italic; + case 'underline': + return '++'; + case 'strikethrough': + return '~~'; + default: + return '`'; + } +}; + +const serializeCode = (content: string): string => { + let marker = '`'; + + for (const run of content.match(/`+/g) ?? []) { + if (run.length >= marker.length) { + marker = '`'.repeat(run.length + 1); + } + } + + const needsPadding = content.startsWith('`') || content.endsWith('`') || (content.startsWith(' ') && content.endsWith(' ') && content.trim().length > 0); + const padding = needsPadding ? ' ' : ''; + + return marker + padding + content + padding + marker; +}; + +const orderedFormats = (formats: readonly WysiwygInlineFormat[]): WysiwygInlineFormat[] => INLINE_ORDER.filter(format => formats.includes(format)); + +/** Serialize one line of display characters with the given marker choice. */ +const serializeChars = (line: readonly CharUnit[], options: SerializeOptions): string => { + const parts: SerializedPart[] = []; + let active: WysiwygInlineFormat[] = []; + const transition = (formats: WysiwygInlineFormat[]): void => { + let common = 0; + + while (common < active.length && active[common] === formats[common]) { + common += 1; + } + + for (let index = active.length - 1; index >= common; index -= 1) { + parts.push({kind: 'delimiter', opening: false, text: markerFor(active[index]!, options)}); + } + + for (let index = common; index < formats.length; index += 1) { + parts.push({kind: 'delimiter', opening: true, text: markerFor(formats[index]!, options)}); + } + + active = formats; + }; + + for (let index = 0; index < line.length; index += 1) { + const unit = line[index]!; + const isCode = unit.formats.includes('inline-code'); + const formats = orderedFormats(unit.formats); + + if (!isCode && WHITESPACE_RE.test(unit.text)) { + // Emphasis cannot open or close against whitespace. Keep only the + // stack shared by both sides of the gap; reopen formats at text. + let next = index + 1; + + while (next < line.length && WHITESPACE_RE.test(line[next]!.text) && !line[next]!.formats.includes('inline-code')) { + next += 1; + } + + const nextFormats = next < line.length ? orderedFormats(line[next]!.formats) : []; + let common = 0; + + while (common < active.length && active[common] === formats[common] && active[common] === nextFormats[common]) { + common += 1; + } + + transition(active.slice(0, common)); + parts.push({kind: 'text', text: unit.literal ? unit.source : unit.text}); + continue; + } + + transition(formats); + + if (isCode) { + let content = ''; + let end = index; + + while (end < line.length && line[end]!.formats.includes('inline-code') && formatsKey(line[end]!.formats) === formatsKey(unit.formats)) { + content += line[end]!.text; + end += 1; + } + + parts.push({kind: 'code', text: serializeCode(content)}); + index = end - 1; + continue; + } + + if (unit.literal) { + parts.push({kind: 'text', text: unit.source}); + } else if (options.escapeAll && MARKER_CLASS_RE.test(unit.text)) { + parts.push({kind: 'text', text: `\\${unit.text}`}); + } else { + parts.push({kind: 'text', text: unit.text}); + } + } + + transition([]); + + if (options.encodeBoundaries) { + encodeInvalidBoundaries(parts); + } + + return parts.map(part => part.text).join(''); +}; + +const splitLines = (units: readonly Unit[]): Unit[][] => { + const lines: Unit[][] = [[]]; + + for (const unit of units) { + lines[lines.length - 1]!.push(unit); + + if (unit.kind === 'raw' && unit.newline) { + lines.push([]); + } + } + + return lines; +}; + +const isTouched = (line: readonly Unit[]): boolean => line.some(unit => (unit.kind === 'char' && unit.inserted) || (unit.kind === 'raw' && unit.touched)); + +/** + * Re-serialize the touched lines from their display characters, trying marker + * choices and escaping until the parser reproduces the intended formatting. + * Untouched lines are emitted byte for byte. + */ +const reserializeTouchedLines = (units: readonly Unit[]): Unit[] => { + const lines = splitLines(units); + const expected = richKeyOfUnits(units); + let fallback: Unit[] | null = null; + + for (const options of SERIALIZE_OPTIONS) { + const result: Unit[] = []; + + for (const line of lines) { + if (!isTouched(line)) { + result.push(...line); + continue; + } + + // Keep the prefix first and the caret marker among the characters + // (which lose their bytes) so display offsets can still be counted. + const prefixes: Unit[] = []; + const collapsed: Unit[] = []; + const lineChars: CharUnit[] = []; + let end: Unit | null = null; + + for (const unit of line) { + if (unit.kind === 'char') { + lineChars.push(unit); + collapsed.push({...unit, source: ''}); + } else if (unit.kind === 'raw' && unit.newline) { + end = unit; + } else if (unit.kind === 'raw' && unit.text.length === 0) { + collapsed.push(unit); + } else if (unit.kind === 'raw') { + prefixes.push(unit); + } + } + + result.push(...prefixes, ...collapsed, raw(serializeChars(lineChars, options))); + + if (end) { + result.push(end); + } + } + + fallback ??= result; + + if (richKeyOfModel(buildWysiwygModel(assemble(result).value)) === expected) { + return result; + } + } + + return fallback ?? [...units]; +}; + +const swapFamily = (text: string): string => (text[0] === '_' ? text.replace(/_/g, '*') : text.replace(/\*/g, '_')); + +/** + * Produce the next source from a unit list: accept it when every promised span + * is recognised or when hand-typed markdown re-shaped the spans without + * altering visible text; otherwise escape typed markers, switch delimiter + * families and finally re-serialize the edited lines. + */ +const realize = (units: Unit[], expectedDisplay: string | null): WysiwygEditResult => { + const typedMarkers = units.some(unit => unit.kind === 'char' && unit.inserted && MARKER_CLASS_RE.test(unit.text)); + let missing = missingDelimiters(units); + + if (missing.length > 0 && typedMarkers && expectedDisplay !== null) { + const display = buildWysiwygModel(assemble(units).value).display; + + if (stripMarkers(display) === stripMarkers(expectedDisplay)) { + missing = []; + } + } + + if (missing.length > 0 && typedMarkers) { + const escaped = units.map((unit): Unit => (unit.kind === 'char' && unit.inserted && MARKER_CLASS_RE.test(unit.text) ? {...unit, literal: true, source: `\\${unit.text}`} : unit)); + const escapedMissing = missingDelimiters(escaped); + + if (escapedMissing.length < missing.length) { + units = escaped; + missing = escapedMissing; + } + } + + for (let attempt = 0; attempt < 2 && missing.length > 0; attempt += 1) { + const swapped = units.map((unit, index): Unit => (missing.includes(index) && (unit.kind === 'open' || unit.kind === 'close') && (unit.format === 'bold' || unit.format === 'italic') ? {...unit, text: swapFamily(unit.text)} : unit)); + const swappedMissing = missingDelimiters(swapped); + + if (swappedMissing.length < missing.length) { + units = swapped; + missing = swappedMissing; + } else { + break; + } + } + + const reserialized = missing.length > 0; + + if (reserialized) { + units = reserializeTouchedLines(units); + } + + const {caret, value} = assemble(units); + const nextModel = buildWysiwygModel(value); + let displayCaret: number; + + if (reserialized) { + // Re-serialized lines carry their bytes in one unit; count display text instead. + displayCaret = 0; + + for (const unit of units) { + if (unit.kind === 'raw' && unit.caret) { + break; + } + + displayCaret += unitDisplayLength(unit); + } + + displayCaret = Math.min(displayCaret, nextModel.display.length); + } else { + displayCaret = sourceOffsetToDisplay(nextModel, caret); + } + + return {caretOutside: typedMarkers && expectedDisplay !== null && nextModel.display !== expectedDisplay, display: nextModel.display, selection: {end: displayCaret, start: displayCaret}, value}; +}; + +const spanFormats = (spans: readonly WysiwygSpan[]): WysiwygInlineFormat[] => spans.map(span => span.format); + +const lineStartOf = (source: string, index: number): number => (index === 0 ? 0 : source.lastIndexOf('\n', index - 1) + 1); + +const lineEndOf = (source: string, index: number): number => { + const newline = source.indexOf('\n', index); + + return newline === -1 ? source.length : newline; +}; + +const QUOTE_RE = /^([ \t]*>[ \t]?)(.*)$/; + +interface Continuation { + /** Display text of the new prefix (a glyph for lists, the marker for quotes). */ + display: string; + exitEnd: number; + exitStart: number; + prefix: string; +} + +/** List and quote continuation for a single Enter, mirroring the plain-text shortcuts. */ +const findContinuation = (model: WysiwygModel, lineStart: number): Continuation | null => { + const lineEnd = lineEndOf(model.source, lineStart); + const block = model.blocks.find(candidate => candidate.sourceStart === lineStart); + + if (block && block.type !== 'heading') { + const empty = model.source.slice(block.sourceEnd, lineEnd).trim().length === 0; + const ordinal = block.ordinal === null ? null : block.ordinal + 1; + const marker = ordinal === null ? `${block.marker} ` : `${ordinal}${block.marker.slice(-1)} `; + + return {display: block.indent + (ordinal === null ? BULLET_GLYPH : marker), exitEnd: empty ? block.sourceEnd : -1, exitStart: block.sourceStart, prefix: block.indent + marker}; + } + + const quote = QUOTE_RE.exec(model.source.slice(lineStart, lineEnd).replace(/\r$/, '')); + + if (!quote) { + return null; + } + + const marker = quote[1]!; + const empty = quote[2]!.trim().length === 0; + const prefix = marker.endsWith(' ') ? marker : `${marker} `; + + return {display: prefix, exitEnd: empty ? lineStart + marker.length : -1, exitStart: lineStart, prefix}; +}; + +const trailingWhitespaceStart = (text: string): number => { + let index = text.length; + + while (index > 0 && WHITESPACE_RE.test(text[index - 1]!)) { + index -= 1; + } + + return index; +}; + +const leadingWhitespaceEnd = (text: string): number => { + let index = 0; + + while (index < text.length && WHITESPACE_RE.test(text[index]!)) { + index += 1; + } + + return index; +}; + +/** + * Translate a display edit into a plan on the old source. Text typed after a + * character continues its formatting; trailing whitespace moves outside the + * closing delimiters so they stay valid; code spans are not extended by typing + * at their end; newlines close open spans and reopen them on the next line. + */ +const planDisplayEdit = (model: WysiwygModel, edit: DisplayEdit, caretOutside: boolean): Plan => { + const plan = createPlan(model); + const {source, spans} = model; + const replacing = edit.end > edit.start; + const containing = (displayIndex: number): WysiwygSpan[] => spans.filter(span => span.displayStart <= displayIndex && displayIndex < span.displayEnd); + + // 1. Deleted characters, joined block prefixes and emptied spans. + let sourceStart: number; + let sourceEnd: number; + let context: WysiwygSpan[]; + + if (replacing) { + sourceStart = model.charMarkerStart[edit.start]!; + sourceEnd = model.charMarkerEnd[edit.end - 1]!; + plan.removed.fill(1, sourceStart, sourceEnd); + + for (const block of model.blocks) { + if (block.sourceStart > 0 && plan.removed[block.sourceStart - 1]) { + plan.removed.fill(1, block.sourceStart, block.sourceEnd); + } + } + + spans.forEach((span, index) => { + if (span.displayStart >= edit.start && span.displayEnd <= edit.end) { + plan.removed.fill(1, span.openStart, span.openEnd); + plan.removed.fill(1, span.closeStart, span.closeEnd); + plan.owner[index] = -1; + } else { + plan.removed.fill(0, span.openStart, span.openEnd); + plan.removed.fill(0, span.closeStart, span.closeEnd); + } + }); + + context = containing(edit.start).filter(span => plan.owner[spans.indexOf(span)] !== -1); + } else { + const previous = edit.start - 1; + const previousText = previous >= 0 ? model.display[previous]! : '\n'; + + if (previousText === '\n' || (previous >= 0 && model.charSynthetic[previous])) { + // Start of a line: type plain text after the block prefix. + if (previous >= 0 && model.charSynthetic[previous]) { + sourceStart = model.charMarkerEnd[previous]!; + } else { + const lineStart = edit.start < model.display.length ? lineStartOf(source, model.charMarkerStart[edit.start]!) : lineStartOf(source, source.length); + const block = model.blocks.find(candidate => candidate.sourceStart === lineStart); + + sourceStart = block ? block.sourceEnd : lineStart; + } + + context = []; + } else { + sourceStart = model.charMarkerEnd[previous]!; + context = containing(previous); + + // Code spans are not extended by typing at their end, and neither + // is any span once typed markdown has just closed it. + const exits = (span: WysiwygSpan): boolean => span.displayEnd === edit.start && (caretOutside || span.format === 'inline-code'); + + for (const span of context) { + if (exits(span)) { + sourceStart = Math.max(sourceStart, span.closeEnd); + } + } + + context = context.filter(span => !exits(span)); + } + + sourceEnd = sourceStart; + } + + // 2. Pure deletions join spans separated only by the deleted text. + if (replacing && edit.inserted.length === 0) { + spans.forEach((left, leftIndex) => { + if (plan.owner[leftIndex] === -1 || left.displayEnd > edit.start) { + return; + } + + const rightIndex = spans.findIndex((right, index) => plan.owner[index] === index && right.displayStart >= edit.end && right.format === left.format && source.slice(right.openStart, right.openEnd) === source.slice(left.openStart, left.openEnd) && right.openStart >= left.closeEnd && plan.removed.slice(left.closeEnd, right.openStart).every(flag => flag === 1) && formatsKey(model.charFormats[left.displayEnd - 1]!) === formatsKey(model.charFormats[right.displayStart]!)); + + if (rightIndex === -1) { + return; + } + + plan.removed.fill(1, left.closeStart, left.closeEnd); + plan.removed.fill(1, spans[rightIndex]!.openStart, spans[rightIndex]!.openEnd); + plan.owner[rightIndex] = plan.owner[leftIndex]!; + }); + } + + // 3. Where the inserted text goes relative to the surrounding delimiters. + const closing = context.filter(span => span.displayEnd <= edit.end && span.format !== 'inline-code'); + const straddling = context.filter(span => span.displayEnd > edit.end); + let closersEnd = sourceStart; + + for (const span of closing) { + closersEnd = Math.max(closersEnd, span.closeEnd); + } + + // Openers kept inside a replaced range must stay behind the relocated text. + for (const span of spans) { + if (span.openStart >= sourceStart && span.openStart < sourceEnd && !plan.removed[span.openStart]) { + closersEnd = Math.min(closersEnd, span.openStart); + } + } + + closersEnd = Math.max(closersEnd, sourceStart); + + const headFormats = spanFormats(context); + const parts = edit.inserted.split('\n'); + const first = parts[0]!; + const caret = raw('', {caret: true, touched: true}); + + if (parts.length === 1) { + const split = closing.length > 0 ? trailingWhitespaceStart(first) : first.length; + + insertAt(plan, sourceStart, chars(first.slice(0, split), headFormats)); + insertAt(plan, closersEnd, [...chars(first.slice(split), spanFormats(straddling)), caret]); + + if (first.length === 0) { + insertAt(plan, sourceStart, [raw('', {touched: true})]); + } + + return plan; + } + + const lineStart = lineStartOf(source, sourceStart); + const continuation = edit.inserted === '\n' ? findContinuation(model, lineStart) : null; + + if (continuation && continuation.exitEnd !== -1) { + plan.removed.fill(1, continuation.exitStart, continuation.exitEnd); + insertAt(plan, continuation.exitEnd, [caret]); + + return plan; + } + + const last = parts[parts.length - 1]!; + const firstSplit = trailingWhitespaceStart(first); + const lastSplit = leadingWhitespaceEnd(last); + const innerFirst = [...straddling].reverse(); + const middle: Unit[] = []; + + for (const span of innerFirst) { + middle.push({format: span.format, kind: 'close', span: spans.indexOf(span), text: source.slice(span.closeStart, span.closeEnd)}); + } + + middle.push(...chars(first.slice(firstSplit), []), newlineUnit()); + + if (continuation) { + middle.push(raw(continuation.prefix, {display: continuation.display, touched: true})); + } + + for (const part of parts.slice(1, -1)) { + middle.push(...chars(part, []), newlineUnit()); + } + + middle.push(...chars(last.slice(0, lastSplit), [])); + + for (const span of straddling) { + middle.push({format: span.format, kind: 'open', span: spans.indexOf(span), text: source.slice(span.openStart, span.openEnd)}); + } + + insertAt(plan, sourceStart, chars(first.slice(0, firstSplit), headFormats)); + insertAt(plan, closersEnd, middle); + insertAt(plan, Math.max(sourceEnd, closersEnd), [...chars(last.slice(lastSplit), spanFormats(straddling)), caret]); + + return plan; +}; + +/** + * Translate a change made to the visible display text back onto the markdown + * source. The previous display selection disambiguates repeated characters; + * callers without one retain the common prefix/suffix fallback. Bytes outside + * the edit are preserved unless a delimiter has to move or be repaired to keep + * the surviving formatting valid; only the edited lines are ever rewritten. + */ +export const applyDisplayEdit = (previousSource: string, nextDisplay: string, previousSelection?: MarkdownSelection, options: WysiwygEditOptions = {}): WysiwygEditResult => { + const model = buildWysiwygModel(previousSource); + + if (nextDisplay === model.display) { + return {caretOutside: options.caretOutside === true, display: model.display, selection: normalizeSelection(model.display, previousSelection), value: previousSource}; + } + + const edit = findDisplayEdit(model.display, nextDisplay, previousSelection); + + return realize(unitsFromPlan(model, planDisplayEdit(model, edit, options.caretOutside === true)), nextDisplay); +}; + +/** + * Apply an inline format toggle from a toolbar while editing in WYSIWYG mode. + * The selected display characters gain or lose the format and only the lines + * they sit on are serialized again; block prefixes and other lines are kept. + * A collapsed selection formats the word around the caret. + */ +export const applyInlineFormatWysiwyg = (source: string, displaySelection: MarkdownSelection, format: WysiwygInlineFormat): WysiwygEditResult => { + const model = buildWysiwygModel(source); + let selection = normalizeSelection(model.display, displaySelection); + + if (selection.start === selection.end) { + let {start, end} = selection; + + while (start > 0 && WORD_RE.test(model.display[start - 1]!) && !model.charSynthetic[start - 1]) { + start -= 1; + } + + while (end < model.display.length && WORD_RE.test(model.display[end]!) && !model.charSynthetic[end]) { + end += 1; + } + + selection = {end, start}; + } + + const selected = new Set(); + + for (let index = selection.start; index < selection.end; index += 1) { + const text = model.display[index]!; + const skip = model.charSynthetic[index] || (format === 'inline-code' ? text === '\n' || text === '\r' : WHITESPACE_RE.test(text)); + + if (!skip) { + selected.add(index); + } + } + + if (selected.size === 0) { + return {caretOutside: false, display: model.display, selection, value: source}; + } + + const remove = [...selected].every(index => model.charFormats[index]!.includes(format)); + const units = unitsFromPlan(model, createPlan(model)); + const toggled = units.map((unit): Unit => { + if (unit.kind !== 'char' || !selected.has(unit.displayIndex)) { + return unit; + } + + const formats = unit.formats.filter(active => active !== format); + + return {...unit, formats: remove ? formats : [...formats, format], inserted: true}; + }); + + const {value} = assemble(reserializeTouchedLines(toggled)); + + return {caretOutside: false, display: buildWysiwygModel(value).display, selection, value}; +}; diff --git a/src/lib/editor/utils/wysiwygEditing.ts b/src/lib/editor/utils/wysiwygEditing.ts new file mode 100644 index 00000000..697fb7f2 --- /dev/null +++ b/src/lib/editor/utils/wysiwygEditing.ts @@ -0,0 +1,27 @@ +import {normalizeSelection} from './selection'; + +import type {MarkdownCommandResult, MarkdownSelection} from '../types'; + +export const insertTextAtSelection = (value: string, selection: MarkdownSelection | undefined, text: string): MarkdownCommandResult => { + const normalizedSelection = normalizeSelection(value, selection); + const nextValue = `${value.slice(0, normalizedSelection.start)}${text}${value.slice(normalizedSelection.end)}`; + const nextOffset = normalizedSelection.start + text.length; + + return {selection: {end: nextOffset, start: nextOffset}, value: nextValue}; +}; + +export const removeTextBeforeSelection = (value: string, selection: MarkdownSelection | undefined): MarkdownCommandResult => { + const normalizedSelection = normalizeSelection(value, selection); + + if (normalizedSelection.start !== normalizedSelection.end) { + return insertTextAtSelection(value, normalizedSelection, ''); + } + + if (normalizedSelection.start === 0) { + return {selection: normalizedSelection, value}; + } + + const nextOffset = normalizedSelection.start - 1; + + return {selection: {end: nextOffset, start: nextOffset}, value: `${value.slice(0, nextOffset)}${value.slice(normalizedSelection.end)}`}; +}; diff --git a/src/lib/editor/utils/wysiwygModel.ts b/src/lib/editor/utils/wysiwygModel.ts new file mode 100644 index 00000000..50af648f --- /dev/null +++ b/src/lib/editor/utils/wysiwygModel.ts @@ -0,0 +1,880 @@ +import MarkdownIt from 'markdown-it'; + +import {normalizeSelection} from './selection'; + +import type {MarkdownInlineFormat, MarkdownSelection} from '../types'; + +/** + * Inline formats that the WYSIWYG surface renders with hidden markdown markers. + */ +export type WysiwygInlineFormat = MarkdownInlineFormat; + +/** + * Block style applied to a run of display characters. Heading levels and the + * synthetic list marker each have their own key; everything else is `null`. + */ +export type WysiwygBlock = 'heading-one' | 'heading-two' | 'heading-three' | 'list-marker' | null; + +export interface WysiwygSegment { + block: WysiwygBlock; + formats: readonly WysiwygInlineFormat[]; + /** True when the run is a synthetic marker (bullet/number) with no source. */ + synthetic: boolean; + text: string; +} + +/** A recognised inline span with the source ranges of both delimiters. */ +export interface WysiwygSpan { + /** Source range of the closing delimiter (code spans include stripped padding). */ + closeEnd: number; + closeStart: number; + /** Display range of the span content. */ + displayEnd: number; + displayStart: number; + format: WysiwygInlineFormat; + /** Source range of the opening delimiter (code spans include stripped padding). */ + openEnd: number; + openStart: number; +} + +export type WysiwygBlockType = 'heading' | 'bullet' | 'ordered'; + +/** A recognised block prefix at the start of a line. */ +export interface WysiwygBlockMarker { + /** Display range of the synthetic glyph; empty for headings. */ + displayEnd: number; + displayStart: number; + /** Leading whitespace before a list marker. */ + indent: string; + /** Heading level, or 0 for lists. */ + level: number; + /** The marker text without indentation or trailing space, e.g. `-`, `1.`, `##`. */ + marker: string; + ordinal: number | null; + /** Source range of the prefix, from the line start to the first content character. */ + sourceEnd: number; + sourceStart: number; + type: WysiwygBlockType; +} + +export interface WysiwygModel { + /** Recognised block prefixes in source order. */ + blocks: readonly WysiwygBlockMarker[]; + /** Inline formats active for each display character. */ + charFormats: readonly (readonly WysiwygInlineFormat[])[]; + /** Whether each display character is literal source: an escape, character reference or fenced code. */ + charLiteral: readonly boolean[]; + /** + * Source range covered by each display character, including any escape or + * character reference. Synthetic glyphs cover their raw block marker. + */ + charMarkerEnd: readonly number[]; + charMarkerStart: readonly number[]; + /** Anchor source index used for inserting at each display character. */ + charSource: readonly number[]; + /** Whether each display character is a synthetic marker glyph. */ + charSynthetic: readonly boolean[]; + /** Display text with all recognised markers removed or replaced. */ + display: string; + /** Display text grouped into runs that share the same style. */ + segments: readonly WysiwygSegment[]; + /** Original markdown source the model was built from. */ + source: string; + /** Recognised inline spans in source order. */ + spans: readonly WysiwygSpan[]; +} + +/** + * The scanner mirrors markdown-it's inline rules (emphasis, strikethrough, the + * `ins` plugin, backticks, escapes and entities) so that the editor hides + * exactly the markers the renderer would consume. Delimiter flanking uses the + * same character classes markdown-it uses. + */ +const utils = new MarkdownIt().utils; + +const ESCAPABLE = '\\!"#$%&\'()*+,./:;<=>?@[]^_`{|}~-'; +const DIGITAL_ENTITY_RE = /^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i; +const NAMED_ENTITY_RE = /^&([a-z][a-z0-9]{1,31});/i; +const FENCE_RE = /^[ \t]*(`{3,}|~{3,})(.*)$/; +const HEADING_RE = /^(#{1,6}) /; +const LIST_RE = /^([ \t]*)([-*+]|\d{1,9}[.)]) /; + +const HEADING_BLOCK_KEYS: readonly WysiwygBlock[] = ['heading-one', 'heading-two', 'heading-three']; + +export const BULLET_GLYPH = 'β€’ '; + +const NO_FORMATS: readonly WysiwygInlineFormat[] = []; + +const CODE_MARKER = 0x60; +const STAR = 0x2a; +const UNDERSCORE = 0x5f; +const TILDE = 0x7e; +const PLUS = 0x2b; + +const DELIMITER_FORMATS: Record = {[PLUS]: 'underline', [STAR]: 'italic', [TILDE]: 'strikethrough', [UNDERSCORE]: 'italic'}; + +interface Delimiter { + close: boolean; + end: number; + length: number; + marker: number; + open: boolean; + token: number; +} + +type TokenRole = 'literal' | 'open' | 'close' | 'hidden'; + +interface TextToken { + end: number; + kind: 'text'; + lone: boolean; + start: number; + text: string; +} + +interface CodeToken { + contentEnd: number; + contentStart: number; + end: number; + kind: 'code'; + start: number; +} + +interface DelimiterToken { + end: number; + format: WysiwygInlineFormat; + kind: 'delim'; + role: TokenRole; + start: number; + text: string; +} + +type LineToken = TextToken | CodeToken | DelimiterToken; + +interface LineChar { + block: WysiwygBlock; + end: number; + formats: readonly WysiwygInlineFormat[]; + literal: boolean; + start: number; + synthetic: boolean; + text: string; +} + +interface LineBlock { + displayLength: number; + indent: string; + key: WysiwygBlock; + level: number; + marker: string; + markerLength: number; + ordinal: number | null; + type: WysiwygBlockType; +} + +interface ParsedLine { + block: LineBlock | null; + chars: readonly LineChar[]; + spans: readonly WysiwygSpan[]; +} + +const scanDelims = (line: string, start: number, canSplitWord: boolean): {canClose: boolean; canOpen: boolean; length: number} => { + const max = line.length; + const marker = line.charCodeAt(start); + const lastChar = start > 0 ? line.charCodeAt(start - 1) : 0x20; + let pos = start; + + while (pos < max && line.charCodeAt(pos) === marker) { + pos += 1; + } + + const nextChar = pos < max ? line.charCodeAt(pos) : 0x20; + const isLastPunct = utils.isMdAsciiPunct(lastChar) || utils.isPunctChar(String.fromCharCode(lastChar)); + const isNextPunct = utils.isMdAsciiPunct(nextChar) || utils.isPunctChar(String.fromCharCode(nextChar)); + const isLastWhiteSpace = utils.isWhiteSpace(lastChar); + const isNextWhiteSpace = utils.isWhiteSpace(nextChar); + const leftFlanking = !isNextWhiteSpace && (!isNextPunct || isLastWhiteSpace || isLastPunct); + const rightFlanking = !isLastWhiteSpace && (!isLastPunct || isNextWhiteSpace || isNextPunct); + + return {canClose: rightFlanking && (canSplitWord || !leftFlanking || isNextPunct), canOpen: leftFlanking && (canSplitWord || !rightFlanking || isLastPunct), length: pos - start}; +}; + +/** Port of markdown-it's `balance_pairs` rule. */ +const processDelimiters = (delimiters: Delimiter[]): void => { + const max = delimiters.length; + + if (max === 0) { + return; + } + + const openersBottom = new Map(); + const jumps: number[] = []; + let headerIdx = 0; + let lastTokenIdx = -2; + + for (let closerIdx = 0; closerIdx < max; closerIdx += 1) { + const closer = delimiters[closerIdx]!; + + jumps.push(0); + + if (delimiters[headerIdx]!.marker !== closer.marker || lastTokenIdx !== closer.token - 1) { + headerIdx = closerIdx; + } + + lastTokenIdx = closer.token; + + if (!closer.close) { + continue; + } + + let bottoms = openersBottom.get(closer.marker); + + if (!bottoms) { + bottoms = [-1, -1, -1, -1, -1, -1]; + openersBottom.set(closer.marker, bottoms); + } + + const minOpenerIdx = bottoms[(closer.open ? 3 : 0) + (closer.length % 3)]!; + let openerIdx = headerIdx - jumps[headerIdx]! - 1; + let newMinOpenerIdx = openerIdx; + + for (; openerIdx > minOpenerIdx; openerIdx -= jumps[openerIdx]! + 1) { + const opener = delimiters[openerIdx]!; + + if (opener.marker !== closer.marker) { + continue; + } + + if (opener.open && opener.end < 0) { + const isOddMatch = (opener.close || closer.open) && (opener.length + closer.length) % 3 === 0 && (opener.length % 3 !== 0 || closer.length % 3 !== 0); + + if (!isOddMatch) { + const lastJump = openerIdx > 0 && !delimiters[openerIdx - 1]!.open ? jumps[openerIdx - 1]! + 1 : 0; + + jumps[closerIdx] = closerIdx - openerIdx + lastJump; + jumps[openerIdx] = lastJump; + closer.open = false; + opener.end = closerIdx; + opener.close = false; + newMinOpenerIdx = -1; + lastTokenIdx = -2; + break; + } + } + } + + if (newMinOpenerIdx !== -1) { + bottoms[(closer.open ? 3 : 0) + (closer.length % 3)] = newMinOpenerIdx; + } + } +}; + +const findCodeCloser = (line: string, start: number, length: number): number => { + let searchFrom = start + length; + + while (searchFrom < line.length) { + const candidate = line.indexOf('`', searchFrom); + + if (candidate === -1) { + return -1; + } + + let closerEnd = candidate; + + while (closerEnd < line.length && line.charCodeAt(closerEnd) === CODE_MARKER) { + closerEnd += 1; + } + + if (closerEnd - candidate === length) { + return candidate; + } + + searchFrom = closerEnd; + } + + return -1; +}; + +const decodeEntity = (line: string, start: number): {end: number; text: string} | null => { + if (line.charCodeAt(start + 1) === 0x23) { + const match = DIGITAL_ENTITY_RE.exec(line.slice(start)); + + if (!match) { + return null; + } + + const digits = match[1]!; + const code = digits[0]!.toLowerCase() === 'x' ? parseInt(digits.slice(1), 16) : parseInt(digits, 10); + + return {end: start + match[0].length, text: utils.isValidEntityCode(code) ? utils.fromCodePoint(code) : utils.fromCodePoint(0xfffd)}; + } + + const match = NAMED_ENTITY_RE.exec(line.slice(start)); + + if (!match) { + return null; + } + + const decoded = utils.unescapeAll(match[0]); + + return decoded === match[0] ? null : {end: start + match[0].length, text: decoded}; +}; + +const tokenizeInline = (line: string, contentStart: number): {delimiters: Delimiter[]; tokens: LineToken[]} => { + const tokens: LineToken[] = []; + const delimiters: Delimiter[] = []; + const pushText = (start: number, end: number, text: string, lone = false): void => { + tokens.push({end, kind: 'text', lone, start, text}); + }; + let pos = contentStart; + + while (pos < line.length) { + const code = line.charCodeAt(pos); + + if (code === 0x5c) { + const next = line[pos + 1]; + + if (next !== undefined && ESCAPABLE.includes(next)) { + pushText(pos, pos + 2, next); + pos += 2; + continue; + } + + pushText(pos, pos + 1, '\\'); + pos += 1; + continue; + } + + if (code === 0x26) { + const entity = decodeEntity(line, pos); + + if (entity) { + pushText(pos, entity.end, entity.text); + pos = entity.end; + continue; + } + } + + if (code === CODE_MARKER) { + let runEnd = pos; + + while (runEnd < line.length && line.charCodeAt(runEnd) === CODE_MARKER) { + runEnd += 1; + } + + const length = runEnd - pos; + const closer = findCodeCloser(line, pos, length); + + if (closer === -1) { + for (let offset = pos; offset < runEnd; offset += 1) { + pushText(offset, offset + 1, '`'); + } + + pos = runEnd; + continue; + } + + const content = line.slice(runEnd, closer); + const padding = /^ (.+) $/.test(content) ? 1 : 0; + + tokens.push({contentEnd: closer - padding, contentStart: runEnd + padding, end: closer + length, kind: 'code', start: pos}); + pos = closer + length; + continue; + } + + if (code === STAR || code === UNDERSCORE) { + const scanned = scanDelims(line, pos, code === STAR); + + for (let offset = 0; offset < scanned.length; offset += 1) { + tokens.push({end: pos + offset + 1, format: DELIMITER_FORMATS[code]!, kind: 'delim', role: 'literal', start: pos + offset, text: String.fromCharCode(code)}); + delimiters.push({close: scanned.canClose, end: -1, length: scanned.length, marker: code, open: scanned.canOpen, token: tokens.length - 1}); + } + + pos += scanned.length; + continue; + } + + if (code === TILDE || code === PLUS) { + const scanned = scanDelims(line, pos, true); + const character = String.fromCharCode(code); + + if (scanned.length < 2) { + pushText(pos, pos + 1, character); + pos += 1; + continue; + } + + let offset = 0; + + if (scanned.length % 2 === 1) { + pushText(pos, pos + 1, character, true); + offset = 1; + } + + for (; offset < scanned.length; offset += 2) { + tokens.push({end: pos + offset + 2, format: DELIMITER_FORMATS[code]!, kind: 'delim', role: 'literal', start: pos + offset, text: character + character}); + + // markdown-it-ins skips delimiters that can neither open nor close. + if (code === PLUS && !scanned.canOpen && !scanned.canClose) { + continue; + } + + delimiters.push({close: scanned.canClose, end: -1, length: 0, marker: code, open: scanned.canOpen, token: tokens.length - 1}); + } + + pos += scanned.length; + continue; + } + + pushText(pos, pos + 1, line[pos]!); + pos += 1; + } + + return {delimiters, tokens}; +}; + +const delimiterToken = (tokens: LineToken[], index: number): DelimiterToken => { + const token = tokens[index]; + + if (!token || token.kind !== 'delim') { + throw new Error('Delimiter bookkeeping is out of sync with the token stream'); + } + + return token; +}; + +/** Assign opener/closer roles following markdown-it's post-processing rules. */ +const pairDelimiters = (tokens: LineToken[], delimiters: Delimiter[]): void => { + processDelimiters(delimiters); + + for (let index = delimiters.length - 1; index >= 0; index -= 1) { + const start = delimiters[index]!; + + if (start.end === -1) { + continue; + } + + const end = delimiters[start.end]!; + const opener = delimiterToken(tokens, start.token); + const closer = delimiterToken(tokens, end.token); + + if (start.marker !== STAR && start.marker !== UNDERSCORE) { + opener.role = 'open'; + closer.role = 'close'; + continue; + } + + const previous = index > 0 ? delimiters[index - 1]! : null; + const following = delimiters[start.end + 1]; + const isStrong = previous !== null && following !== undefined && previous.end === start.end + 1 && previous.marker === start.marker && previous.token === start.token - 1 && following.token === end.token + 1; + + if (isStrong) { + const strongOpener = delimiterToken(tokens, previous.token); + const strongCloser = delimiterToken(tokens, following.token); + + strongOpener.role = 'open'; + strongOpener.format = 'bold'; + strongOpener.end = opener.end; + strongOpener.text += opener.text; + opener.role = 'hidden'; + closer.role = 'close'; + closer.format = 'bold'; + closer.end = strongCloser.end; + closer.text += strongCloser.text; + strongCloser.role = 'hidden'; + index -= 1; + continue; + } + + opener.role = 'open'; + closer.role = 'close'; + } +}; + +const detectBlock = (line: string): LineBlock | null => { + const heading = HEADING_RE.exec(line); + + if (heading) { + const level = heading[1]!.length; + + return {displayLength: 0, indent: '', key: HEADING_BLOCK_KEYS[Math.min(level, 3) - 1]!, level, marker: heading[1]!, markerLength: level + 1, ordinal: null, type: 'heading'}; + } + + const list = LIST_RE.exec(line); + + if (!list) { + return null; + } + + const indent = list[1]!; + const marker = list[2]!; + const ordered = /^\d/.test(marker); + + return {displayLength: indent.length + (ordered ? marker.length + 1 : BULLET_GLYPH.length), indent, key: null, level: 0, marker, markerLength: list[0].length, ordinal: ordered ? Number(marker.slice(0, -1)) : null, type: ordered ? 'ordered' : 'bullet'}; +}; + +const parseLine = (line: string, literal: boolean): ParsedLine => { + const chars: LineChar[] = []; + const spans: WysiwygSpan[] = []; + const block = literal ? null : detectBlock(line); + const blockKey: WysiwygBlock = block?.key ?? null; + let contentStart = 0; + + if (block) { + contentStart = block.markerLength; + + if (block.type !== 'heading') { + const glyph = block.indent + (block.type === 'ordered' ? `${block.marker} ` : BULLET_GLYPH); + + for (const character of glyph) { + chars.push({block: 'list-marker', end: contentStart, formats: NO_FORMATS, literal: false, start: 0, synthetic: true, text: character}); + } + } + } + + if (literal) { + for (let index = 0; index < line.length; index += 1) { + chars.push({block: null, end: index + 1, formats: NO_FORMATS, literal: true, start: index, synthetic: false, text: line[index]!}); + } + + return {block, chars, spans}; + } + + const {delimiters, tokens} = tokenizeInline(line, contentStart); + + pairDelimiters(tokens, delimiters); + + const stack: {displayStart: number; format: WysiwygInlineFormat; openEnd: number; openStart: number}[] = []; + let formats: readonly WysiwygInlineFormat[] = NO_FORMATS; + const pushChar = (text: string, start: number, end: number, charFormats: readonly WysiwygInlineFormat[]): void => { + const plain = end - start === text.length && line.slice(start, end) === text; + + // Escapes and character references share one source range; plain runs + // (such as an unmatched ~~) map every character to its own byte. + for (let offset = 0; offset < text.length; offset += 1) { + chars.push({block: blockKey, end: plain ? start + offset + 1 : end, formats: charFormats, literal: !plain, start: plain ? start + offset : start, synthetic: false, text: text[offset]!}); + } + }; + const closeSpan = (token: DelimiterToken): void => { + const open = stack.pop(); + + if (!open) { + throw new Error('Closing delimiter without an opener'); + } + + spans.push({closeEnd: token.end, closeStart: token.start, displayEnd: chars.length, displayStart: open.displayStart, format: open.format, openEnd: open.openEnd, openStart: open.openStart}); + formats = stack.map(entry => entry.format); + }; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + + if (token.kind === 'code') { + const codeFormats = [...formats, 'inline-code' as const]; + + for (let offset = token.contentStart; offset < token.contentEnd; offset += 1) { + pushChar(line[offset]!, offset, offset + 1, codeFormats); + } + + spans.push({closeEnd: token.end, closeStart: token.contentEnd, displayEnd: chars.length, displayStart: chars.length - (token.contentEnd - token.contentStart), format: 'inline-code', openEnd: token.contentStart, openStart: token.start}); + continue; + } + + if (token.kind === 'text') { + if (token.lone) { + // markdown-it moves a lone marker behind the closers that follow it. + let next = index + 1; + + while (next < tokens.length) { + const candidate = tokens[next]!; + + if (candidate.kind !== 'delim' || candidate.role !== 'close' || candidate.text[0] !== token.text) { + break; + } + + closeSpan(candidate); + next += 1; + } + + index = next - 1; + } + + pushChar(token.text, token.start, token.end, formats); + continue; + } + + switch (token.role) { + case 'open': + stack.push({displayStart: chars.length, format: token.format, openEnd: token.end, openStart: token.start}); + formats = stack.map(entry => entry.format); + break; + case 'close': + closeSpan(token); + break; + case 'hidden': + break; + default: + pushChar(token.text, token.start, token.end, formats); + } + } + + spans.sort((left, right) => left.openStart - right.openStart); + + return {block, chars, spans}; +}; + +const LINE_CACHE_LIMIT = 4000; +const MODEL_CACHE_LIMIT = 16; +const lineCache = new Map(); +const modelCache = new Map(); + +const remember = (cache: Map, key: string, value: T, limit: number): T => { + cache.set(key, value); + + if (cache.size > limit) { + const oldest = cache.keys().next(); + + if (!oldest.done) { + cache.delete(oldest.value); + } + } + + return value; +}; + +const parseLineCached = (line: string, literal: boolean): ParsedLine => { + const key = (literal ? 'L' : 'N') + line; + const cached = lineCache.get(key); + + if (cached) { + return cached; + } + + return remember(lineCache, key, parseLine(line, literal), LINE_CACHE_LIMIT); +}; + +const formatsKey = (formats: readonly WysiwygInlineFormat[]): string => [...formats].sort().join('|'); + +/** + * Parse markdown into a WYSIWYG model. Inline markers are hidden, heading + * prefixes are hidden and their line styled, and list markers are replaced with + * a synthetic bullet/number glyph. Fenced code stays literal. Lines are parsed + * independently and cached, so an edit only re-parses the lines it touches. + */ +export const buildWysiwygModel = (source: string): WysiwygModel => { + const cached = modelCache.get(source); + + if (cached) { + return cached; + } + + const displayChars: string[] = []; + const charSource: number[] = []; + const charSynthetic: boolean[] = []; + const charLiteral: boolean[] = []; + const charMarkerStart: number[] = []; + const charMarkerEnd: number[] = []; + const charFormats: (readonly WysiwygInlineFormat[])[] = []; + const charBlock: WysiwygBlock[] = []; + const spans: WysiwygSpan[] = []; + const blocks: WysiwygBlockMarker[] = []; + const pushChar = (text: string, start: number, end: number, synthetic: boolean, literal: boolean, formats: readonly WysiwygInlineFormat[], block: WysiwygBlock): void => { + displayChars.push(text); + charSource.push(synthetic ? end : start); + charSynthetic.push(synthetic); + charLiteral.push(literal); + charMarkerStart.push(start); + charMarkerEnd.push(end); + charFormats.push(formats); + charBlock.push(block); + }; + + let lineStart = 0; + let fence: string | null = null; + + while (lineStart <= source.length) { + const newlineIndex = source.indexOf('\n', lineStart); + const lineEnd = newlineIndex === -1 ? source.length : newlineIndex; + const hasCarriageReturn = lineEnd > lineStart && source[lineEnd - 1] === '\r'; + const textEnd = hasCarriageReturn ? lineEnd - 1 : lineEnd; + const lineText = source.slice(lineStart, textEnd); + const possibleFence = FENCE_RE.exec(lineText); + const fenceMatch = possibleFence?.[1]?.[0] === '`' && possibleFence[2]!.includes('`') ? null : possibleFence; + const literal = fence !== null || fenceMatch !== null; + + if (fenceMatch) { + const marker = fenceMatch[1]!; + + if (fence === null) { + fence = marker; + } else if (marker[0] === fence[0] && marker.length >= fence.length && fenceMatch[2]!.trim().length === 0) { + fence = null; + } + } + + const parsed = parseLineCached(lineText, literal); + const displayOffset = displayChars.length; + + if (parsed.block) { + blocks.push({displayEnd: displayOffset + parsed.block.displayLength, displayStart: displayOffset, indent: parsed.block.indent, level: parsed.block.level, marker: parsed.block.marker, ordinal: parsed.block.ordinal, sourceEnd: lineStart + parsed.block.markerLength, sourceStart: lineStart, type: parsed.block.type}); + } + + for (const char of parsed.chars) { + pushChar(char.text, lineStart + char.start, lineStart + char.end, char.synthetic, char.literal, char.formats, char.block); + } + + for (const span of parsed.spans) { + spans.push({closeEnd: lineStart + span.closeEnd, closeStart: lineStart + span.closeStart, displayEnd: displayOffset + span.displayEnd, displayStart: displayOffset + span.displayStart, format: span.format, openEnd: lineStart + span.openEnd, openStart: lineStart + span.openStart}); + } + + if (hasCarriageReturn) { + pushChar('\r', textEnd, lineEnd, false, false, NO_FORMATS, null); + } + + if (newlineIndex === -1) { + break; + } + + pushChar('\n', newlineIndex, newlineIndex + 1, false, false, NO_FORMATS, null); + lineStart = newlineIndex + 1; + } + + const segments: WysiwygSegment[] = []; + let currentKey: string | null = null; + + for (let charIndex = 0; charIndex < displayChars.length; charIndex += 1) { + const formats = charFormats[charIndex]!; + const block = charBlock[charIndex]!; + const synthetic = charSynthetic[charIndex]!; + const key = `${formatsKey(formats)}\u0000${block ?? ''}\u0000${synthetic ? '1' : '0'}`; + + if (key !== currentKey) { + segments.push({block, formats: [...formats], synthetic, text: displayChars[charIndex]!}); + currentKey = key; + continue; + } + + segments[segments.length - 1]!.text += displayChars[charIndex]!; + } + + return remember(modelCache, source, {blocks, charFormats, charLiteral, charMarkerEnd, charMarkerStart, charSource, charSynthetic, display: displayChars.join(''), segments, source, spans}, MODEL_CACHE_LIMIT); +}; + +/** + * Map a display selection onto a markdown source range. Visible characters in + * the selection are covered without swallowing the hidden markers that wrap + * them; a selection edge on a synthetic marker covers the whole raw marker. + */ +export const displaySelectionToSource = (model: WysiwygModel, selection: MarkdownSelection): MarkdownSelection => { + const normalized = normalizeSelection(model.display, selection); + const length = model.charSource.length; + const start = normalized.start < length ? model.charMarkerStart[normalized.start]! : length === 0 ? model.source.length : model.charMarkerEnd[length - 1]!; + const end = normalized.end > normalized.start ? model.charMarkerEnd[normalized.end - 1]! : start; + + return {end, start}; +}; + +/** + * Map a markdown source offset onto a display offset by counting display + * characters that end at or before it. + */ +export const sourceOffsetToDisplay = (model: WysiwygModel, sourceOffset: number): number => { + let low = 0; + let high = model.charMarkerEnd.length; + + while (low < high) { + const middle = Math.floor((low + high) / 2); + + if (model.charMarkerEnd[middle]! <= sourceOffset) { + low = middle + 1; + } else { + high = middle; + } + } + + return low; +}; + +/** + * Active inline formats at a display offset, used to highlight toolbar buttons. + * The format of the character before the caret is preferred so that the toolbar + * reflects the span the caret sits at the end of. + */ +export const getFormatsAtDisplayOffset = (model: WysiwygModel, displayOffset: number): readonly WysiwygInlineFormat[] => { + if (model.charFormats.length === 0) { + return NO_FORMATS; + } + + const target = Math.min(Math.max(displayOffset - 1, 0), model.charFormats.length - 1); + + return model.charFormats[target]!; +}; + +const DOUBLED_MARKER_RE = /^[*_+~]$/; + +/** + * Remove inline pairs with no display content, such as `**` `**` left behind + * after deleting a span's text in the plain-text editor. Literal code, fenced + * blocks and unmatched delimiters are preserved. + */ +export const cleanEmptyMarkers = (source: string): string => { + let current = source; + + for (let pass = 0; pass < 8; pass += 1) { + const model = buildWysiwygModel(current); + const removed = new Uint8Array(current.length); + let changed = false; + + for (const span of model.spans) { + if (span.displayStart === span.displayEnd) { + removed.fill(1, span.openStart, span.openEnd); + removed.fill(1, span.closeStart, span.closeEnd); + changed = true; + } + } + + // Runs such as **** are literal text in CommonMark, yet they are what a + // deletion leaves behind. Drop them in groups of two markers. + let runStart = 0; + + while (runStart < model.display.length) { + const character = model.display[runStart]!; + let runEnd = runStart; + + while (runEnd < model.display.length && model.display[runEnd] === character && DOUBLED_MARKER_RE.test(character) && !model.charLiteral[runEnd] && !model.charFormats[runEnd]!.includes('inline-code') && (runEnd === runStart || model.charMarkerStart[runEnd] === model.charMarkerEnd[runEnd - 1])) { + runEnd += 1; + } + + if (runEnd === runStart) { + runStart += 1; + continue; + } + + const length = runEnd - runStart; + const dropped = length - (length % 4); + + if (dropped > 0) { + removed.fill(1, model.charMarkerStart[runStart]!, model.charMarkerStart[runStart]! + dropped); + changed = true; + } + + runStart = runEnd; + } + + if (!changed) { + return current; + } + + let next = ''; + + for (let index = 0; index < current.length; index += 1) { + if (!removed[index]) { + next += current[index]!; + } + } + + current = next; + } + + return current; +};