From cc0e15d28434190e72e0b42134f353e0c3ff259d Mon Sep 17 00:00:00 2001 From: Ron Radtke Date: Sat, 23 May 2026 01:11:09 +0200 Subject: [PATCH 1/4] wysiwyg started --- __tests__/MarkdownWysiwygEditor.test.tsx | 103 ++++++++++ .../editor/MarkdownWysiwygEditor/index.tsx | 187 ++++++++++++++++++ src/lib/editor/MarkdownWysiwygEditor/style.ts | 47 +++++ src/lib/editor/index.ts | 3 + src/lib/editor/types.ts | 14 ++ src/lib/editor/utils/wysiwygEditing.ts | 51 +++++ 6 files changed, 405 insertions(+) create mode 100644 __tests__/MarkdownWysiwygEditor.test.tsx create mode 100644 src/lib/editor/MarkdownWysiwygEditor/index.tsx create mode 100644 src/lib/editor/MarkdownWysiwygEditor/style.ts create mode 100644 src/lib/editor/utils/wysiwygEditing.ts diff --git a/__tests__/MarkdownWysiwygEditor.test.tsx b/__tests__/MarkdownWysiwygEditor.test.tsx new file mode 100644 index 00000000..292e0152 --- /dev/null +++ b/__tests__/MarkdownWysiwygEditor.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import {TextInput} from 'react-native'; +import renderer from 'react-test-renderer'; + +import { + insertTextAtSelection, + MarkdownWysiwygEditor, + removeTextBeforeSelection, +} from '../src'; + +describe('MarkdownWysiwygEditor', () => { + test('inserts hidden input text at the current markdown selection', () => { + const onChangeText = jest.fn(); + const onSelectionChange = jest.fn(); + let tree: renderer.ReactTestRenderer | undefined; + + renderer.act(() => { + tree = renderer.create( + , + ); + }); + + if (!tree) { + throw new Error('Failed to render MarkdownWysiwygEditor'); + } + + const input = tree.root.findByType(TextInput); + + renderer.act(() => { + input.props.onChangeText(','); + }); + + expect(onChangeText).toHaveBeenCalledWith('Hello, world'); + expect(onSelectionChange).toHaveBeenCalledWith({end: 6, start: 6}); + }); + + test('deletes text before the markdown selection on backspace', () => { + const onChangeText = jest.fn(); + const onSelectionChange = jest.fn(); + let tree: renderer.ReactTestRenderer | undefined; + + renderer.act(() => { + tree = renderer.create( + , + ); + }); + + if (!tree) { + throw new Error('Failed to render MarkdownWysiwygEditor'); + } + + const input = tree.root.findByType(TextInput); + + renderer.act(() => { + input.props.onKeyPress({ + nativeEvent: { + key: 'Backspace', + }, + }); + }); + + expect(onChangeText).toHaveBeenCalledWith('Hell'); + expect(onSelectionChange).toHaveBeenCalledWith({end: 4, start: 4}); + }); +}); + +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/src/lib/editor/MarkdownWysiwygEditor/index.tsx b/src/lib/editor/MarkdownWysiwygEditor/index.tsx new file mode 100644 index 00000000..f2b9e8e1 --- /dev/null +++ b/src/lib/editor/MarkdownWysiwygEditor/index.tsx @@ -0,0 +1,187 @@ +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import { + Animated, + type NativeSyntheticEvent, + Pressable, + Text, + TextInput, + type TextInputKeyPressEventData, + View, +} from 'react-native'; + +import styles from './style'; +import {normalizeSelection} from '../utils/selection'; +import { + insertTextAtSelection, + removeTextBeforeSelection, +} from '../utils/wysiwygEditing'; + +import type {MarkdownSelection, MarkdownWysiwygEditorProps} from '../types'; + +const setForwardedRef = ( + forwardedRef: React.ForwardedRef, + value: TextInput | null, +): void => { + if (typeof forwardedRef === 'function') { + forwardedRef(value); + return; + } + + if (forwardedRef) { + forwardedRef.current = value; + } +}; + +const MarkdownWysiwygEditor = React.forwardRef< + TextInput, + MarkdownWysiwygEditorProps +>(function MarkdownWysiwygEditor( + { + cursorBlinkEnabled = true, + cursorStyle, + editorStyle, + onChangeText, + onSelectionChange, + placeholder, + selection, + textStyle, + value, + ...textInputProps + }, + ref, +) { + const inputRef = useRef(null); + const cursorOpacity = useMemo(() => new Animated.Value(1), []); + const [internalSelection, setInternalSelection] = useState(() => + normalizeSelection(value, selection), + ); + + const normalizedSelection = useMemo( + () => normalizeSelection(value, selection ?? internalSelection), + [internalSelection, selection, value], + ); + + useEffect(() => { + if (!cursorBlinkEnabled) { + cursorOpacity.setValue(1); + return undefined; + } + + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(cursorOpacity, { + duration: 500, + toValue: 0, + useNativeDriver: false, + }), + Animated.timing(cursorOpacity, { + duration: 500, + toValue: 1, + useNativeDriver: false, + }), + ]), + ); + + animation.start(); + + return () => { + animation.stop(); + }; + }, [cursorBlinkEnabled, cursorOpacity]); + + const updateSelection = (nextSelection: MarkdownSelection): void => { + if (!selection) { + setInternalSelection(nextSelection); + } + + onSelectionChange?.(nextSelection); + }; + + const applyResult = ( + result: ReturnType, + ): void => { + updateSelection(result.selection); + onChangeText(result.value); + }; + + const focusInput = (): void => { + inputRef.current?.focus(); + }; + + const moveCursorToEnd = (): void => { + updateSelection({ + end: value.length, + start: value.length, + }); + focusInput(); + }; + + const handleHiddenInputChange = (typedText: string): void => { + if (typedText.length === 0) { + return; + } + + applyResult(insertTextAtSelection(value, normalizedSelection, typedText)); + }; + + const handleKeyPress = ( + event: NativeSyntheticEvent, + ): void => { + textInputProps.onKeyPress?.(event); + + if (event.nativeEvent.key === 'Backspace') { + applyResult(removeTextBeforeSelection(value, normalizedSelection)); + } + }; + + const visibleBeforeCursor = value.slice(0, normalizedSelection.start); + const visibleAfterCursor = value.slice(normalizedSelection.end); + const showPlaceholder = value.length === 0 && placeholder; + + return ( + + {showPlaceholder ? ( + {placeholder} + ) : ( + + {visibleBeforeCursor} + + {visibleAfterCursor} + + )} + { + inputRef.current = input; + setForwardedRef(ref, input); + }} + selection={{end: 0, start: 0}} + style={styles.hiddenInput} + value="" + /> + + ); +}); + +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..57db10f9 --- /dev/null +++ b/src/lib/editor/MarkdownWysiwygEditor/style.ts @@ -0,0 +1,47 @@ +import {StyleSheet} from 'react-native'; + +const styles = StyleSheet.create({ + container: { + borderColor: '#C7CCD1', + borderRadius: 8, + borderWidth: 1, + minHeight: 44, + overflow: 'hidden', + paddingHorizontal: 12, + paddingVertical: 10, + position: 'relative', + width: '100%', + }, + cursor: { + backgroundColor: '#0A66C2', + height: 20, + marginHorizontal: 1, + width: 2, + }, + cursorRow: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + minHeight: 24, + }, + hiddenInput: { + height: 1, + left: 0, + opacity: 0, + position: 'absolute', + top: 0, + width: 1, + }, + placeholder: { + color: '#7A8694', + fontSize: 16, + lineHeight: 22, + }, + text: { + color: '#1F2933', + fontSize: 16, + lineHeight: 22, + }, +}); + +export default styles; diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts index b88d085d..5ddaeb49 100644 --- a/src/lib/editor/index.ts +++ b/src/lib/editor/index.ts @@ -11,8 +11,10 @@ export { export {default as MarkdownComposer} from './MarkdownComposer'; 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 type { MarkdownBlockFormat, @@ -39,4 +41,5 @@ export type { MarkdownTextInputProps, MarkdownToolbarItem, MarkdownToolbarWrapAction, + MarkdownWysiwygEditorProps, } from './types'; diff --git a/src/lib/editor/types.ts b/src/lib/editor/types.ts index 217f2e12..8dbff3bf 100644 --- a/src/lib/editor/types.ts +++ b/src/lib/editor/types.ts @@ -177,6 +177,20 @@ export interface MarkdownComposerProps extends Omit< textInputStyle?: StyleProp; } +export interface MarkdownWysiwygEditorProps extends Omit< + TextInputProps, + 'onChangeText' | 'onSelectionChange' | 'selection' | 'style' | 'value' +> { + cursorBlinkEnabled?: boolean; + cursorStyle?: StyleProp; + editorStyle?: StyleProp; + onChangeText: (value: string) => void; + onSelectionChange?: (selection: MarkdownSelection) => void; + selection?: MarkdownSelection; + textStyle?: StyleProp; + value: string; +} + export interface MarkdownPreviewProps extends MarkdownRenderOptions { emptyState?: string; label?: ReactNode; diff --git a/src/lib/editor/utils/wysiwygEditing.ts b/src/lib/editor/utils/wysiwygEditing.ts new file mode 100644 index 00000000..df8c57ae --- /dev/null +++ b/src/lib/editor/utils/wysiwygEditing.ts @@ -0,0 +1,51 @@ +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)}`, + }; +}; From 92fdc38f3c168b82a2327da3ccc0e1bc7e9db867 Mon Sep 17 00:00:00 2001 From: Ron Radtke Date: Tue, 15 Sep 2026 12:28:04 +0200 Subject: [PATCH 2/4] bugfixes --- __tests__/MarkdownEditor.test.tsx | 170 +++ __tests__/MarkdownTextInput.test.tsx | 74 ++ __tests__/MarkdownWysiwygEditor.test.tsx | 169 ++- __tests__/wysiwygModel.test.ts | 408 ++++++ doc/markdown-editor-architecture.md | 50 +- example/App.tsx | 24 +- src/lib/editor/EditorToolbar/index.tsx | 201 +++ src/lib/editor/EditorToolbar/style.ts | 61 + src/lib/editor/MarkdownEditor/index.tsx | 259 ++++ src/lib/editor/MarkdownEditor/style.ts | 44 + src/lib/editor/MarkdownTextInput/index.tsx | 85 +- .../editor/MarkdownWysiwygEditor/index.tsx | 251 ++-- src/lib/editor/MarkdownWysiwygEditor/style.ts | 82 +- src/lib/editor/commands/executeCommand.ts | 59 + src/lib/editor/index.ts | 25 + src/lib/editor/types.ts | 43 +- src/lib/editor/utils/useCommittedValue.ts | 16 + src/lib/editor/utils/wysiwygModel.ts | 1094 +++++++++++++++++ src/lib/view/StreamingMarkdown.tsx | 15 +- 19 files changed, 2867 insertions(+), 263 deletions(-) create mode 100644 __tests__/MarkdownEditor.test.tsx create mode 100644 __tests__/wysiwygModel.test.ts create mode 100644 src/lib/editor/EditorToolbar/index.tsx create mode 100644 src/lib/editor/EditorToolbar/style.ts create mode 100644 src/lib/editor/MarkdownEditor/index.tsx create mode 100644 src/lib/editor/MarkdownEditor/style.ts create mode 100644 src/lib/editor/commands/executeCommand.ts create mode 100644 src/lib/editor/utils/useCommittedValue.ts create mode 100644 src/lib/editor/utils/wysiwygModel.ts diff --git a/__tests__/MarkdownEditor.test.tsx b/__tests__/MarkdownEditor.test.tsx new file mode 100644 index 00000000..aa5deba9 --- /dev/null +++ b/__tests__/MarkdownEditor.test.tsx @@ -0,0 +1,170 @@ +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'); }); + + expect(onChangeText).toHaveBeenCalledWith('Hi**text**'); + }); + + 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 index 292e0152..d1917fd6 100644 --- a/__tests__/MarkdownWysiwygEditor.test.tsx +++ b/__tests__/MarkdownWysiwygEditor.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {TextInput} from 'react-native'; +import {Text, TextInput} from 'react-native'; import renderer from 'react-test-renderer'; import { @@ -8,71 +8,162 @@ import { 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('inserts hidden input text at the current markdown selection', () => { + test.each([true, false])('restores rejected native edits and preserves focus=%s', (focused) => { + const ref = React.createRef(); const onChangeText = jest.fn(); - const onSelectionChange = jest.fn(); - let tree: renderer.ReactTestRenderer | undefined; - + 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 = renderer.create( - , - ); + 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 MarkdownWysiwygEditor'); + 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}); + }); - renderer.act(() => { - input.props.onChangeText(','); + test('hides heading markers and renders list bullets', () => { + const tree = renderEditor({ + onChangeText: jest.fn(), + value: '# Title\n- Item', }); - expect(onChangeText).toHaveBeenCalledWith('Hello, world'); - expect(onSelectionChange).toHaveBeenCalledWith({end: 6, start: 6}); + const texts = tree.root.findAllByType(Text); + const renderedText = texts.map((node) => node.props.children).join(''); + + expect(renderedText).toBe('Title\n• Item'); }); - test('deletes text before the markdown selection on backspace', () => { + test('maps an edit on the display text back onto the markdown source', () => { const onChangeText = jest.fn(); const onSelectionChange = jest.fn(); - let tree: renderer.ReactTestRenderer | undefined; + const tree = renderEditor({ + onChangeText, + onSelectionChange, + selection: {end: 5, start: 5}, + value: '**Hello**', + }); + + const input = tree.root.findByType(TextInput); renderer.act(() => { - tree = renderer.create( - , - ); + input.props.onChangeText('Hello!'); }); - if (!tree) { - throw new Error('Failed to render MarkdownWysiwygEditor'); - } + 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.onKeyPress({ - nativeEvent: { - key: 'Backspace', - }, + 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('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('Hell'); - expect(onSelectionChange).toHaveBeenCalledWith({end: 4, start: 4}); + expect(onChangeText).toHaveBeenCalledWith('**a**'); + expect(onSelectionChange).toHaveBeenLastCalledWith({start: 0, end: 0}); }); }); diff --git a/__tests__/wysiwygModel.test.ts b/__tests__/wysiwygModel.test.ts new file mode 100644 index 00000000..3a0d5906 --- /dev/null +++ b/__tests__/wysiwygModel.test.ts @@ -0,0 +1,408 @@ +import MarkdownIt from 'markdown-it'; +import { + applyDisplayEdit, + applyInlineFormatWysiwyg, + 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('**_++~~~~++_**')).toBe(''); + }); + + 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..4e726e28 100644 --- a/doc/markdown-editor-architecture.md +++ b/doc/markdown-editor-architecture.md @@ -56,10 +56,58 @@ 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: + +- `utils/wysiwygModel.ts` is a pure module that parses the source into a display + string plus a per-character source-offset map and grouped styled segments. +- `applyDisplayEdit` uses the previous display selection to locate edits, + falling back to a common prefix/suffix diff for native replacements elsewhere. + It preserves paired delimiters around surviving content, removes spans emptied + by the edit, and joins matching spans when their separating text is deleted. + Line breaks close and reopen inline formatting, and joining blocks removes the + second block's prefix. Lines whose delimiter boundaries become invalid are + serialized again; untouched valid syntax and literal code remain unchanged. + Its optional third argument accepts the previous display selection, and the + native editor supplies it automatically. +- `applyInlineFormatWysiwyg` toggles formats on selected display characters and + serializes the affected lines. It keeps block prefixes outside inline formats, + preserves nested styles, and treats code contents as literal text. Delimiter + choice, escaped punctuation, and character references keep the stored Markdown + valid at word boundaries without adding visible characters. +- `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. + +Known limitations (next steps): + +- Headings hide their prefixes and lists show synthetic markers. Quotes, tables, + links, and fenced code blocks are still edited as literal visible text. +- Native styling, selection, and IME behavior need validation on iOS and Android + devices. Replacing the input after a rejected edit can reset native undo and + composition state; tests cover the component recovery path using native mocks. +- Intraword underscores are treated as italic markers when balanced. + ## Design Principles - Native inputs only 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/src/lib/editor/EditorToolbar/index.tsx b/src/lib/editor/EditorToolbar/index.tsx new file mode 100644 index 00000000..d6bf2f90 --- /dev/null +++ b/src/lib/editor/EditorToolbar/index.tsx @@ -0,0 +1,201 @@ +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..d07fe468 --- /dev/null +++ b/src/lib/editor/EditorToolbar/style.ts @@ -0,0 +1,61 @@ +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..53b9d322 --- /dev/null +++ b/src/lib/editor/MarkdownEditor/index.tsx @@ -0,0 +1,259 @@ +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, + 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..29e8f07a --- /dev/null +++ b/src/lib/editor/MarkdownEditor/style.ts @@ -0,0 +1,44 @@ +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 045a8e3f..3a5dc4e3 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< MarkdownCommand, @@ -78,39 +80,6 @@ const renderToolbarLabel = (label: MarkdownToolbarItem['label']): React.ReactNod {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( { @@ -142,6 +111,16 @@ const MarkdownTextInput = React.forwardRef( () => 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, @@ -167,30 +146,39 @@ const MarkdownTextInput = React.forwardRef( 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; @@ -199,17 +187,17 @@ const MarkdownTextInput = React.forwardRef( setOpenMenuIndex(null); const result = applyToolbarAction( - value, - normalizedSelection, + 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 = ( @@ -285,7 +273,8 @@ const MarkdownTextInput = React.forwardRef( item, )} accessibilityRole="button" - accessibilityState={{expanded: isMenuOpen}} + accessibilityState={{disabled, expanded: isMenuOpen}} + disabled={disabled} onPress={() => setOpenMenuIndex((currentIndex) => currentIndex === index @@ -310,6 +299,8 @@ const MarkdownTextInput = React.forwardRef( menuItem, )} accessibilityRole="button" + accessibilityState={{disabled}} + disabled={disabled} key={getToolbarButtonKey( menuItem, menuItemIndex, @@ -334,6 +325,8 @@ const MarkdownTextInput = React.forwardRef( item, )} accessibilityRole="button" + accessibilityState={{disabled}} + disabled={disabled} key={getToolbarButtonKey(item, index)} onPress={() => { handleToolbarButtonPress(item); diff --git a/src/lib/editor/MarkdownWysiwygEditor/index.tsx b/src/lib/editor/MarkdownWysiwygEditor/index.tsx index f2b9e8e1..d8787cde 100644 --- a/src/lib/editor/MarkdownWysiwygEditor/index.tsx +++ b/src/lib/editor/MarkdownWysiwygEditor/index.tsx @@ -1,22 +1,28 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react'; import { - Animated, type NativeSyntheticEvent, - Pressable, + type StyleProp, Text, TextInput, - type TextInputKeyPressEventData, - View, + type TextInputSelectionChangeEventData, + type TextStyle, } from 'react-native'; import styles from './style'; import {normalizeSelection} from '../utils/selection'; import { - insertTextAtSelection, - removeTextBeforeSelection, -} from '../utils/wysiwygEditing'; - -import type {MarkdownSelection, MarkdownWysiwygEditorProps} from '../types'; + applyDisplayEdit, + buildWysiwygModel, + type WysiwygBlock, + type WysiwygInlineFormat, + type WysiwygSegment, +} from '../utils/wysiwygModel'; + +import type { + MarkdownSelection, + MarkdownWysiwygEditorProps, + MarkdownWysiwygFormatStyles, +} from '../types'; const setForwardedRef = ( forwardedRef: React.ForwardedRef, @@ -32,17 +38,67 @@ const setForwardedRef = ( } }; +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< TextInput, MarkdownWysiwygEditorProps >(function MarkdownWysiwygEditor( { - cursorBlinkEnabled = true, - cursorStyle, editorStyle, + formatStyles, onChangeText, onSelectionChange, - placeholder, selection, textStyle, value, @@ -50,44 +106,48 @@ const MarkdownWysiwygEditor = React.forwardRef< }, ref, ) { + const model = useMemo(() => buildWysiwygModel(value), [value]); const inputRef = useRef(null); - const cursorOpacity = useMemo(() => new Animated.Value(1), []); - const [internalSelection, setInternalSelection] = useState(() => - normalizeSelection(value, selection), + 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(value, selection ?? internalSelection), - [internalSelection, selection, value], + () => normalizeSelection(model.display, selection ?? internalSelection), + [internalSelection, model.display, selection], ); - useEffect(() => { - if (!cursorBlinkEnabled) { - cursorOpacity.setValue(1); - return undefined; + 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 animation = Animated.loop( - Animated.sequence([ - Animated.timing(cursorOpacity, { - duration: 500, - toValue: 0, - useNativeDriver: false, - }), - Animated.timing(cursorOpacity, { - duration: 500, - toValue: 1, - useNativeDriver: false, - }), - ]), - ); - - animation.start(); - - return () => { - animation.stop(); - }; - }, [cursorBlinkEnabled, cursorOpacity]); + 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) { @@ -97,88 +157,47 @@ const MarkdownWysiwygEditor = React.forwardRef< onSelectionChange?.(nextSelection); }; - const applyResult = ( - result: ReturnType, - ): void => { - updateSelection(result.selection); - onChangeText(result.value); - }; - - const focusInput = (): void => { - inputRef.current?.focus(); - }; - - const moveCursorToEnd = (): void => { - updateSelection({ - end: value.length, - start: value.length, - }); - focusInput(); - }; + const handleChangeText = (nextDisplay: string): void => { + const result = applyDisplayEdit(value, nextDisplay, normalizedSelection); + const focused = inputRef.current?.isFocused() ?? false; - const handleHiddenInputChange = (typedText: string): void => { - if (typedText.length === 0) { - return; - } + setNativeState((current) => ({ + ...current, + pending: {display: nextDisplay, previousDisplay: model.display, previousSelection: normalizedSelection, focused}, + })); - applyResult(insertTextAtSelection(value, normalizedSelection, typedText)); + updateSelection(result.selection); + onChangeText(result.value); }; - const handleKeyPress = ( - event: NativeSyntheticEvent, + const handleSelectionChange = ( + event: NativeSyntheticEvent, ): void => { - textInputProps.onKeyPress?.(event); - - if (event.nativeEvent.key === 'Backspace') { - applyResult(removeTextBeforeSelection(value, normalizedSelection)); - } + updateSelection(event.nativeEvent.selection); }; - const visibleBeforeCursor = value.slice(0, normalizedSelection.start); - const visibleAfterCursor = value.slice(normalizedSelection.end); - const showPlaceholder = value.length === 0 && placeholder; - return ( - - {showPlaceholder ? ( - {placeholder} - ) : ( - - {visibleBeforeCursor} - - {visibleAfterCursor} - - )} - { - inputRef.current = input; - setForwardedRef(ref, input); - }} - selection={{end: 0, start: 0}} - style={styles.hiddenInput} - value="" - /> - + {model.display.length > 0 + ? model.segments.map((segment, index) => ( + + {segment.text} + + )) + : null} + ); }); diff --git a/src/lib/editor/MarkdownWysiwygEditor/style.ts b/src/lib/editor/MarkdownWysiwygEditor/style.ts index 57db10f9..15f86359 100644 --- a/src/lib/editor/MarkdownWysiwygEditor/style.ts +++ b/src/lib/editor/MarkdownWysiwygEditor/style.ts @@ -1,46 +1,56 @@ -import {StyleSheet} from 'react-native'; +import {Platform, StyleSheet} from 'react-native'; const styles = StyleSheet.create({ - container: { - borderColor: '#C7CCD1', - borderRadius: 8, - borderWidth: 1, - minHeight: 44, - overflow: 'hidden', - paddingHorizontal: 12, - paddingVertical: 10, - position: 'relative', - width: '100%', + bold: { + fontWeight: '700', }, - cursor: { - backgroundColor: '#0A66C2', - height: 20, - marginHorizontal: 1, - width: 2, - }, - cursorRow: { - alignItems: 'center', - flexDirection: 'row', - flexWrap: 'wrap', - minHeight: 24, - }, - hiddenInput: { - height: 1, - left: 0, - opacity: 0, - position: 'absolute', - top: 0, - width: 1, - }, - placeholder: { - color: '#7A8694', - fontSize: 16, - lineHeight: 22, + 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, }, - text: { + 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', }, }); diff --git a/src/lib/editor/commands/executeCommand.ts b/src/lib/editor/commands/executeCommand.ts new file mode 100644 index 00000000..1d01cd0f --- /dev/null +++ b/src/lib/editor/commands/executeCommand.ts @@ -0,0 +1,59 @@ +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 5ddaeb49..bc84548e 100644 --- a/src/lib/editor/index.ts +++ b/src/lib/editor/index.ts @@ -8,13 +8,34 @@ export { 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, + buildWysiwygModel, + cleanEmptyMarkers, + displaySelectionToSource, + getFormatsAtDisplayOffset, + sourceOffsetToDisplay, +} from './utils/wysiwygModel'; + +export type { + WysiwygBlock, + WysiwygEditResult, + WysiwygInlineFormat, + WysiwygModel, + WysiwygSegment, +} from './utils/wysiwygModel'; +export type {EditorToolbarProps} from './EditorToolbar'; export type { MarkdownBlockFormat, @@ -23,6 +44,8 @@ export type { MarkdownCommandResult, MarkdownComposerMode, MarkdownComposerProps, + MarkdownEditorMode, + MarkdownEditorProps, MarkdownInputComponent, MarkdownInlineFormat, MarkdownLinkPayload, @@ -42,4 +65,6 @@ export type { MarkdownToolbarItem, MarkdownToolbarWrapAction, MarkdownWysiwygEditorProps, + MarkdownWysiwygFormat, + MarkdownWysiwygFormatStyles, } from './types'; diff --git a/src/lib/editor/types.ts b/src/lib/editor/types.ts index 8dbff3bf..0ca9f448 100644 --- a/src/lib/editor/types.ts +++ b/src/lib/editor/types.ts @@ -67,6 +67,7 @@ export interface MarkdownTextInputCommandPayload { table?: MarkdownTablePayload; } +/** Resolved commands use the editor's current value and selection when they finish. */ export type MarkdownCommandPayloadResolver = ( command: MarkdownCommand, ) => @@ -177,17 +178,53 @@ export interface MarkdownComposerProps extends Omit< textInputStyle?: StyleProp; } +export type MarkdownWysiwygFormat = + | 'bold' + | 'italic' + | 'underline' + | 'strikethrough' + | 'inline-code'; + +export type MarkdownWysiwygFormatStyles = Partial< + Record> +>; + export interface MarkdownWysiwygEditorProps extends Omit< TextInputProps, - 'onChangeText' | 'onSelectionChange' | 'selection' | 'style' | 'value' + 'children' | 'onChangeText' | 'onSelectionChange' | 'selection' | 'style' | 'value' > { - cursorBlinkEnabled?: boolean; - cursorStyle?: StyleProp; 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< + TextInputProps, + 'children' | 'onChangeText' | 'onSelectionChange' | 'selection' | 'style' | 'value' +> { + 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; } diff --git a/src/lib/editor/utils/useCommittedValue.ts b/src/lib/editor/utils/useCommittedValue.ts new file mode 100644 index 00000000..34f4e01e --- /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/wysiwygModel.ts b/src/lib/editor/utils/wysiwygModel.ts new file mode 100644 index 00000000..49983628 --- /dev/null +++ b/src/lib/editor/utils/wysiwygModel.ts @@ -0,0 +1,1094 @@ +import {normalizeSelection} from './selection'; + +import type { + MarkdownCommandResult, + 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; + +interface MarkerDefinition { + format: WysiwygInlineFormat; + marker: string; +} + +/** + * Marker definitions are scanned longest-first so that `**` is matched before a + * stray single character could be mistaken for another marker. + */ +const MARKER_DEFINITIONS: readonly MarkerDefinition[] = [ + {format: 'bold', marker: '**'}, + {format: 'bold', marker: '__'}, + {format: 'underline', marker: '++'}, + {format: 'strikethrough', marker: '~~'}, + {format: 'inline-code', marker: '`'}, + {format: 'italic', marker: '_'}, + {format: 'italic', marker: '*'}, +]; + +interface InlineOpening extends MarkerDefinition { + displayStart: number; + sourceStart: number; +} + +interface InlineSpan extends InlineOpening { + closingStart: number; + displayEnd: number; +} + +interface BlockMarker { + sourceStart: number; + sourceEnd: number; + displayStart: number; + displayEnd: number; +} + +const HEADING_BLOCK_KEYS: readonly WysiwygBlock[] = [ + 'heading-one', + 'heading-two', + 'heading-three', +]; + +const BULLET_GLYPH = '• '; + +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; +} + +export interface WysiwygModel { + /** + * 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; +} + +interface BlockInfo { + blockKey: WysiwygBlock; + markerLen: number; + ordinal?: number; + type: 'heading' | 'bullet' | 'ordered'; +} + +const detectBlock = (lineText: string): BlockInfo | null => { + const heading = /^(#{1,6}) /.exec(lineText); + + if (heading) { + const level = heading[1]!.length; + + return { + blockKey: HEADING_BLOCK_KEYS[Math.min(level, 3) - 1]!, + markerLen: level + 1, + type: 'heading', + }; + } + + if (/^[-*+] /.test(lineText)) { + return {blockKey: null, markerLen: 2, type: 'bullet'}; + } + + const ordered = /^(\d+)\. /.exec(lineText); + + if (ordered) { + return { + blockKey: null, + markerLen: ordered[0]!.length, + ordinal: Number(ordered[1]), + type: 'ordered', + }; + } + + return null; +}; + +const matchMarkerAt = ( + source: string, + index: number, +): MarkerDefinition | null => { + for (const definition of MARKER_DEFINITIONS) { + if (source.startsWith(definition.marker, index)) { + return definition; + } + } + + return null; +}; + +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. Unbalanced inline markers are kept as literal + * text so nothing disappears unexpectedly while typing. + */ +const parseWysiwygModel = ( + source: string, + inlineSpans: InlineSpan[], + blockMarkers: BlockMarker[] = [], +): WysiwygModel => { + const displayChars: string[] = []; + const charSource: number[] = []; + const charSynthetic: boolean[] = []; + const charMarkerStart: number[] = []; + const charMarkerEnd: number[] = []; + const charBlock: WysiwygBlock[] = []; + const charFormats: WysiwygInlineFormat[][] = []; + + const pushReal = ( + index: number, + block: WysiwygBlock, + formats: readonly WysiwygInlineFormat[], + ): void => { + displayChars.push(source[index]!); + charSource.push(index); + charSynthetic.push(false); + charMarkerStart.push(index); + charMarkerEnd.push(index + 1); + charBlock.push(block); + charFormats.push([...formats]); + }; + + const pushSynthetic = ( + glyph: string, + markerStart: number, + markerEnd: number, + ): void => { + for (const character of glyph) { + displayChars.push(character); + charSource.push(markerEnd); + charSynthetic.push(true); + charMarkerStart.push(markerStart); + charMarkerEnd.push(markerEnd); + charBlock.push('list-marker'); + charFormats.push([]); + } + }; + + let index = 0; + let fence: string | null = null; + + while (index < source.length) { + const lineStart = index; + const newlineIndex = source.indexOf('\n', lineStart); + const lineEnd = newlineIndex === -1 ? source.length : newlineIndex; + const lineText = source.slice(lineStart, lineEnd); + const possibleFence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(lineText); + const fenceMatch = possibleFence?.[1]?.[0] === '`' && possibleFence[2]!.includes('`') + ? null + : possibleFence; + const literalLine = fence !== null || fenceMatch !== null; + + // Fenced blocks remain literal; their contents must not become editable + // inline delimiter pairs, including runs such as **** inside code. + 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 block = literalLine ? null : detectBlock(lineText); + + let contentStart = lineStart; + let contentBlock: WysiwygBlock = null; + + if (block) { + const displayStart = displayChars.length; + contentStart = lineStart + block.markerLen; + + if (block.type === 'heading') { + contentBlock = block.blockKey; + } else { + const glyph = + block.type === 'bullet' + ? BULLET_GLYPH + : `${block.ordinal}. `; + + pushSynthetic(glyph, lineStart, contentStart); + } + + blockMarkers.push({sourceStart: lineStart, sourceEnd: contentStart, displayStart, displayEnd: displayChars.length}); + } + + const active: InlineOpening[] = []; + let scanIndex = contentStart; + + while (scanIndex < lineEnd) { + const formats = active.map((opening) => opening.format); + const entity = !literalLine && /^&#(?:([0-9]{1,7})|x([0-9a-f]{1,6}));/i.exec(source.slice(scanIndex, lineEnd)); + if (entity) { + const codePoint = entity[1] ? Number(entity[1]) : parseInt(entity[2]!, 16); + if (codePoint > 0 && codePoint <= 0x10ffff && (codePoint < 0xd800 || codePoint > 0xdfff)) { + const decoded = String.fromCodePoint(codePoint); + for (let offset = 0; offset < decoded.length; offset += 1) { + pushReal(scanIndex, contentBlock, formats); + displayChars[displayChars.length - 1] = decoded[offset]!; + charMarkerEnd[charMarkerEnd.length - 1] = scanIndex + entity[0].length; + } + scanIndex += entity[0].length; + continue; + } + } + if (!literalLine && source[scanIndex] === '\\' && /[\\`*_{}[\]()#+.!<>~=-]/.test(source[scanIndex + 1] ?? '')) { + pushReal(scanIndex + 1, contentBlock, formats); + charSource[charSource.length - 1] = scanIndex; + charMarkerStart[charMarkerStart.length - 1] = scanIndex; + scanIndex += 2; + continue; + } + const definition = literalLine ? null : matchMarkerAt(source, scanIndex); + + if (!definition) { + pushReal(scanIndex, contentBlock, formats); + scanIndex += 1; + continue; + } + + if (definition.format === 'inline-code') { + const marker = /^`+/.exec(source.slice(scanIndex, lineEnd))![0]; + let closingIndex = source.indexOf(marker, scanIndex + marker.length); + + while ( + closingIndex !== -1 && closingIndex < lineEnd && + (source[closingIndex - 1] === '`' || source[closingIndex + marker.length] === '`') + ) { + closingIndex = source.indexOf(marker, closingIndex + marker.length); + } + + if (closingIndex === -1 || closingIndex >= lineEnd) { + for (let offset = 0; offset < marker.length; offset += 1) { + pushReal(scanIndex + offset, contentBlock, formats); + } + scanIndex += marker.length; + continue; + } + + const displayStart = displayChars.length; + const contentStartIndex = scanIndex + marker.length; + const content = source.slice(contentStartIndex, closingIndex); + const padding = content.startsWith(' ') && content.endsWith(' ') && + content.trim().length > 0 ? 1 : 0; + + for (let codeIndex = contentStartIndex + padding; codeIndex < closingIndex - padding; codeIndex += 1) { + pushReal(codeIndex, contentBlock, [...formats, 'inline-code']); + } + + inlineSpans.push({ + closingStart: closingIndex, + displayEnd: displayChars.length, + displayStart, + format: 'inline-code', + marker, + sourceStart: scanIndex, + }); + scanIndex = closingIndex + marker.length; + continue; + } + + const activeIndex = active.findIndex((opening) => opening.marker === definition.marker); + + if (activeIndex !== -1) { + const opening = active.splice(activeIndex, 1)[0]!; + + inlineSpans.push({ + ...opening, + closingStart: scanIndex, + displayEnd: displayChars.length, + }); + scanIndex += definition.marker.length; + continue; + } + + const closingIndex = source.indexOf( + definition.marker, + scanIndex + definition.marker.length, + ); + + if (closingIndex !== -1 && closingIndex < lineEnd) { + active.push({ + ...definition, + displayStart: displayChars.length, + sourceStart: scanIndex, + }); + scanIndex += definition.marker.length; + continue; + } + + for (let offset = 0; offset < definition.marker.length; offset += 1) { + pushReal(scanIndex + offset, contentBlock, formats); + } + scanIndex += definition.marker.length; + } + + if (newlineIndex !== -1) { + pushReal(newlineIndex, null, []); + index = newlineIndex + 1; + } else { + index = source.length; + } + } + + const display = displayChars.join(''); + 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, synthetic, text: displayChars[charIndex]!}); + currentKey = key; + continue; + } + + segments[segments.length - 1]!.text += displayChars[charIndex]!; + } + + return { + charMarkerEnd, + charMarkerStart, + charSource, + charSynthetic, + display, + segments, + source, + }; +}; + +export const buildWysiwygModel = (source: string): WysiwygModel => + parseWysiwygModel(source, []); + +/** + * Source index to use when inserting text at the given display offset. Inserting + * inside a synthetic marker goes into the following content so the list marker + * is preserved; inserting at the very end inherits the preceding character's + * formatting rather than escaping any trailing markers. + */ +const displayOffsetToSourceInsert = ( + model: WysiwygModel, + displayOffset: number, +): number => { + const length = model.charSource.length; + + if (displayOffset < length) { + return model.charSynthetic[displayOffset] + ? model.charMarkerEnd[displayOffset]! + : model.charSource[displayOffset]!; + } + + if (length === 0) { + return model.source.length; + } + + return model.charMarkerEnd[length - 1]!; +}; + +/** Source index immediately after the display character at the given index. */ +const charSourceEnd = (model: WysiwygModel, charIndex: number): number => + model.charMarkerEnd[charIndex]!; + +/** Source index at the start of the display character at the given index. */ +const charSourceStart = (model: WysiwygModel, charIndex: number): number => + model.charMarkerStart[charIndex]!; + +/** + * 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 => { + selection = normalizeSelection(model.display, selection); + const start = + selection.start < model.charSource.length + ? charSourceStart(model, selection.start) + : displayOffsetToSourceInsert(model, selection.start); + const end = + selection.end > selection.start + ? charSourceEnd(model, selection.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 count = 0; + + for (let charIndex = 0; charIndex < model.charSource.length; charIndex += 1) { + const isBefore = model.charMarkerEnd[charIndex]! <= sourceOffset; + + if (!isBefore) { + break; + } + + count += 1; + } + + return count; +}; + +/** + * Remove parsed inline pairs with no display content, preserving literal code + * and unmatched delimiters. Normal edits instead remove only newly emptied + * pairs using the previous model, so untouched source stays intact. + */ +export const cleanEmptyMarkers = (source: string): string => { + const spans: InlineSpan[] = []; + parseWysiwygModel(source, spans); + const removed = new Uint8Array(source.length); + + for (const span of spans) { + if (span.displayStart === span.displayEnd) { + removed.fill(1, span.sourceStart, span.sourceStart + span.marker.length); + removed.fill(1, span.closingStart, span.closingStart + span.marker.length); + } + } + + return source.split('').filter((_character, index) => !removed[index]).join(''); +}; + +/** + * 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.segments.length === 0) { + return []; + } + + const target = Math.max(displayOffset - 1, 0); + let consumed = 0; + + for (const segment of model.segments) { + consumed += segment.text.length; + + if (target < consumed) { + return segment.formats; + } + } + + return model.segments[model.segments.length - 1]!.formats; +}; + +export interface WysiwygEditResult extends MarkdownCommandResult { + /** Display text the surface should show after the edit. */ + display: string; +} + +interface DisplayEdit extends MarkdownSelection { + inserted: string; +} + +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), + }; +}; + +/** Merge only delimiter boundaries brought together by this deletion. */ +const joinAdjacentSpans = ( + spans: readonly InlineSpan[], + removed: Uint8Array, + insertionIndex: number, + inserted: string, +): InlineSpan[] => { + const owners = new Map(spans.map((span) => [span, span])); + const surviving = new Set(spans); + const boundaries = spans.flatMap((span) => [ + {opening: true, span, start: span.sourceStart}, + {opening: false, span, start: span.closingStart}, + ]).sort((left, right) => left.start - right.start); + + for (let index = 1; index < boundaries.length; index += 1) { + const left = boundaries[index - 1]!; + const right = boundaries[index]!; + const gapStart = left.start + left.span.marker.length; + + if ( + left.opening || !right.opening || left.span.marker !== right.span.marker || + gapStart >= right.start || + (inserted.length > 0 && insertionIndex >= gapStart && insertionIndex <= right.start) + ) { + continue; + } + + let gapRemoved = true; + + for (let offset = gapStart; offset < right.start; offset += 1) { + if (!removed[offset]) { + gapRemoved = false; + break; + } + } + + if (gapRemoved) { + const merged = owners.get(left.span)!; + merged.closingStart = right.span.closingStart; + merged.displayEnd = right.span.displayEnd; + owners.set(right.span, merged); + surviving.delete(right.span); + removed.fill(1, left.start, gapStart); + removed.fill(1, right.start, right.start + right.span.marker.length); + } + } + + return [...surviving]; +}; + +const serializeCode = (content: string, previousMarker: string): {prefix: string; value: string} => { + let marker = previousMarker; + + // A deletion can join two literal backtick runs. Pick a delimiter that + // cannot be mistaken for content, and separate edge backticks with padding. + 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 {prefix: marker + padding, value: marker + padding + content + padding + marker}; +}; + +interface InlineCharacter { + text: string; + formats: readonly WysiwygInlineFormat[]; + literal: boolean; +} + +const getDisplayCharacters = (model: WysiwygModel): InlineCharacter[] => { + const characters: InlineCharacter[] = []; + + for (const segment of model.segments) { + for (let index = 0; index < segment.text.length; index += 1) { + const offset = characters.length; + characters.push({ + text: segment.text[index]!, + formats: segment.formats, + literal: segment.formats.includes('inline-code') || + (!segment.synthetic && model.charMarkerEnd[offset]! - model.charMarkerStart[offset]! > 1), + }); + } + } + + return characters; +}; + +const INLINE_ORDER: readonly WysiwygInlineFormat[] = ['bold', 'italic', 'underline', 'strikethrough']; + +type SerializedInlinePart = + | {kind: 'text' | 'code'; text: string} + | {kind: 'delimiter'; text: string; opening: boolean}; + +const validDelimiterBoundary = (marker: string, opening: boolean, before: string, after: string): boolean => { + const beforeWhitespace = before === '' || /\s/.test(before); + const afterWhitespace = after === '' || /\s/.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); +}; + +const serializeInlineParts = (parts: SerializedInlinePart[]): string => { + 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)) { + // CommonMark disallows boundaries such as **`code`**word. + // Encode the adjacent letter as a character reference so the + // source delimiter is valid without inserting visible text. + 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; + } + } + } + } + return parts.map((part) => part.text).join(''); +}; + +/** Serialize actual inline formatting, keeping code atomic and newlines outside markers. */ +const serializeInlineCharacters = (characters: readonly InlineCharacter[]): string => { + const parts: SerializedInlinePart[] = []; + let active: WysiwygInlineFormat[] = []; + const intrawordTransition = characters.some((character, index) => { + const previous = characters[index - 1]; + return previous && /[\p{L}\p{N}]/u.test(previous.text) && /[\p{L}\p{N}]/u.test(character.text) && + formatsKey(previous.formats) !== formatsKey(character.formats); + }); + const alternateEmphasis = intrawordTransition && characters.some((character) => character.formats.includes('italic')); + const marker = (format: WysiwygInlineFormat): string => { + if (alternateEmphasis && format === 'italic') { + return '*'; + } + if (alternateEmphasis && format === 'bold') { + return '__'; + } + return MARKER_DEFINITIONS.find((definition) => definition.format === format)!.marker; + }; + + 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', text: marker(active[index]!), opening: false}); + } + for (let index = common; index < formats.length; index += 1) { + parts.push({kind: 'delimiter', text: marker(formats[index]!), opening: true}); + } + active = formats; + }; + + for (let index = 0; index < characters.length; index += 1) { + const character = characters[index]!; + if (character.text === '\n') { + transition([]); + parts.push({kind: 'text', text: '\n'}); + continue; + } + + const formats = INLINE_ORDER.filter((format) => character.formats.includes(format)); + if (/\s/.test(character.text) && !character.formats.includes('inline-code')) { + // Emphasis cannot open or close against whitespace. Keep only the + // stack shared by both sides of a space; reopen formats at text. + let nextIndex = index + 1; + while (nextIndex < characters.length && /[^\S\n]/.test(characters[nextIndex]!.text)) { + nextIndex += 1; + } + const next = characters[nextIndex]; + const nextFormats = next && next.text !== '\n' + ? INLINE_ORDER.filter((format) => next.formats.includes(format)) + : []; + let common = 0; + while (common < active.length && active[common] === formats[common] && active[common] === nextFormats[common]) { + common += 1; + } + transition(active.slice(0, common)); + } else { + transition(formats); + } + if (character.formats.includes('inline-code')) { + let content = character.text; + while (index + 1 < characters.length) { + const next = characters[index + 1]!; + if (next.text === '\n' || formatsKey(next.formats) !== formatsKey(character.formats)) { + break; + } + content += next.text; + index += 1; + } + parts.push({kind: 'code', text: serializeCode(content, '`').value}); + } else { + let text = character.text; + const next = characters[index + 1]; + if (/^[\uD800-\uDBFF]$/.test(text) && next && /^[\uDC00-\uDFFF]$/.test(next.text) && + formatsKey(character.formats) === formatsKey(next.formats)) { + text += next.text; + index += 1; + } + const escape = /[\\`*_+~]/.test(character.text) || + (character.literal && /[{}[\]()#!<>.=-]/.test(character.text)); + parts.push({kind: 'text', text: (escape ? '\\' : '') + text}); + } + } + transition([]); + return serializeInlineParts(parts); +}; + +/** Keep typed spaces and punctuation from turning existing formatting into literals. */ +const normalizeInlineBoundaries = (source: string): WysiwygModel => { + const spans: InlineSpan[] = []; + const blocks: BlockMarker[] = []; + const model = parseWysiwygModel(source, spans, blocks); + const invalidLines = new Set(); + for (const span of spans) { + if (span.format === 'inline-code') { + continue; + } + for (const offset of [span.sourceStart, span.closingStart]) { + const before = Array.from(source.slice(Math.max(0, offset - 2), offset)).pop() ?? ''; + const after = Array.from(source.slice(offset + span.marker.length, offset + span.marker.length + 2))[0] ?? ''; + if (!validDelimiterBoundary(span.marker, offset === span.sourceStart, before, after)) { + invalidLines.add(offset === 0 ? 0 : source.lastIndexOf('\n', offset - 1) + 1); + } + } + } + if (invalidLines.size === 0) { + return model; + } + const characters = getDisplayCharacters(model); + let offset = 0; + const value = source.split('\n').map((line) => { + const start = offset; + offset += line.length + 1; + if (!invalidLines.has(start)) { + return line; + } + const block = blocks.find((candidate) => candidate.sourceStart === start); + const displayStart = block?.displayEnd ?? sourceOffsetToDisplay(model, start); + const displayEnd = sourceOffsetToDisplay(model, start + line.length); + const prefix = block ? source.slice(block.sourceStart, block.sourceEnd) : ''; + return prefix + serializeInlineCharacters(characters.slice(displayStart, displayEnd)); + }).join('\n'); + return buildWysiwygModel(value); +}; + +const applyMultilineDisplayEdit = ( + model: WysiwygModel, + spans: readonly InlineSpan[], + blocks: readonly BlockMarker[], + edit: DisplayEdit, + sourceStart: number, + sourceEnd: number, +): WysiwygEditResult | null => { + if (!edit.inserted.includes('\n')) { + return null; + } + + const lineStart = sourceStart === 0 ? 0 : model.source.lastIndexOf('\n', sourceStart - 1) + 1; + const nextNewline = model.source.indexOf('\n', sourceStart); + const lineEnd = nextNewline === -1 ? model.source.length : nextNewline; + if (!spans.some((span) => span.sourceStart < Math.max(lineEnd, sourceEnd) && span.closingStart >= lineStart)) { + return null; + } + if (sourceEnd > lineEnd) { + // First join the selected blocks, then split the resulting line. + const deleted = applyDisplayEdit(model.source, model.display.slice(0, edit.start) + model.display.slice(edit.end), edit); + const caret = deleted.selection.start; + return applyDisplayEdit(deleted.value, deleted.display.slice(0, caret) + edit.inserted + deleted.display.slice(caret), deleted.selection); + } + + const block = blocks.find((candidate) => candidate.sourceStart === lineStart); + const displayStart = block?.displayEnd ?? sourceOffsetToDisplay(model, lineStart); + const displayEnd = sourceOffsetToDisplay(model, lineEnd); + const characters = getDisplayCharacters(model).slice(displayStart, displayEnd); + const start = Math.max(edit.start - displayStart, 0); + const end = Math.max(edit.end - displayStart, 0); + const formats = spans.filter((span) => sourceStart >= span.sourceStart + span.marker.length && sourceStart <= span.closingStart) + .map((span) => span.format); + const inserted = edit.inserted.split('').map((text): InlineCharacter => ({text, formats, literal: formats.includes('inline-code')})); + const prefix = block ? model.source.slice(block.sourceStart, block.sourceEnd) : ''; + const value = model.source.slice(0, lineStart) + prefix + + serializeInlineCharacters([...characters.slice(0, start), ...inserted, ...characters.slice(end)]) + model.source.slice(lineEnd); + const nextModel = buildWysiwygModel(value); + const caret = Math.min(displayStart + start + edit.inserted.length, nextModel.display.length); + + return {value, display: nextModel.display, selection: {start: caret, end: caret}}; +}; + +/** + * 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. Keep paired + * inline delimiters around surviving content, removing only newly emptied spans. + * Deleting a synthetic marker removes the whole raw marker it stands in for. + */ +export const applyDisplayEdit = ( + previousSource: string, + nextDisplay: string, + previousSelection?: MarkdownSelection, +): WysiwygEditResult => { + const spans: InlineSpan[] = []; + const blocks: BlockMarker[] = []; + const model = parseWysiwygModel(previousSource, spans, blocks); + + if (nextDisplay === model.display) { + return { + display: model.display, + selection: normalizeSelection(model.display, previousSelection), + value: previousSource, + }; + } + + const edit = findDisplayEdit(model.display, nextDisplay, previousSelection); + const sourceStart = edit.end > edit.start + ? charSourceStart(model, edit.start) + : displayOffsetToSourceInsert(model, edit.start); + const sourceEnd = edit.end > edit.start + ? charSourceEnd(model, edit.end - 1) + : sourceStart; + const multiline = applyMultilineDisplayEdit(model, spans, blocks, edit, sourceStart, sourceEnd); + if (multiline) { + return multiline; + } + const removed = new Uint8Array(previousSource.length); + removed.fill(1, sourceStart, sourceEnd); + for (const block of blocks) { + if (block.sourceStart > 0 && removed[block.sourceStart - 1]) { + removed.fill(1, block.sourceStart, block.sourceEnd); + } + } + const keptSpans: InlineSpan[] = []; + + for (const span of spans) { + const keepsContent = span.displayStart < edit.start || span.displayEnd > edit.end; + const inheritsInsertion = edit.inserted.length > 0 && + sourceStart >= span.sourceStart + span.marker.length && + sourceStart <= span.closingStart; + const newlyEmpty = span.displayStart < span.displayEnd && + !keepsContent && !inheritsInsertion; + const deletesEmptyPair = sourceStart <= span.sourceStart && + sourceEnd >= span.closingStart + span.marker.length; + const removePair = newlyEmpty || (deletesEmptyPair && !inheritsInsertion); + + if (removePair) { + // Code spans may also contain hidden padding around their content. + removed.fill(1, span.sourceStart, span.closingStart + span.marker.length); + } else { + removed.fill(0, span.sourceStart, span.sourceStart + span.marker.length); + removed.fill(0, span.closingStart, span.closingStart + span.marker.length); + keptSpans.push(span); + } + } + + const joinedSpans = joinAdjacentSpans(keptSpans, removed, sourceStart, edit.inserted); + const codeReplacements = new Map(); + let insertionInCode = false; + + for (const span of joinedSpans) { + if (span.format !== 'inline-code') { + continue; + } + + const inheritsInsertion = sourceStart >= span.sourceStart + span.marker.length && + sourceStart <= span.closingStart; + const deletesContent = edit.start < span.displayEnd && edit.end > span.displayStart; + + if (!deletesContent && !(inheritsInsertion && edit.inserted.length > 0)) { + continue; + } + + const content = model.display.slice(span.displayStart, span.displayEnd); + const start = Math.min(Math.max(edit.start - span.displayStart, 0), content.length); + const end = Math.min(Math.max(edit.end - span.displayStart, 0), content.length); + const inserted = inheritsInsertion ? edit.inserted : ''; + const serialized = serializeCode(content.slice(0, start) + inserted + content.slice(end), span.marker); + + codeReplacements.set(span.sourceStart, { + value: serialized.value, + caret: inheritsInsertion ? serialized.prefix.length + start + inserted.length : null, + }); + removed.fill(1, span.sourceStart, span.closingStart + span.marker.length); + insertionInCode ||= inheritsInsertion; + } + + let nextSource = ''; + let caretSource = 0; + + for (let index = 0; index <= previousSource.length; index += 1) { + if (index === sourceStart && !insertionInCode) { + nextSource += edit.inserted; + caretSource = nextSource.length; + } + + const code = codeReplacements.get(index); + + if (code) { + if (code.caret !== null) { + caretSource = nextSource.length + code.caret; + } + + nextSource += code.value; + } + + if (index < previousSource.length && !removed[index]) { + nextSource += previousSource[index]!; + } + } + + const caret = sourceOffsetToDisplay(buildWysiwygModel(nextSource), caretSource); + const nextModel = normalizeInlineBoundaries(nextSource); + + return { + display: nextModel.display, + selection: {end: caret, start: caret}, + value: nextModel.source, + }; +}; + +/** + * Apply an inline format toggle from a toolbar while editing in WYSIWYG mode. + * Toggle the selected display characters' formats, serializing only affected + * lines. Block prefixes are kept separate from editable inline content. + */ +export const applyInlineFormatWysiwyg = ( + source: string, + displaySelection: MarkdownSelection, + format: WysiwygInlineFormat, +): WysiwygEditResult => { + const blocks: BlockMarker[] = []; + const model = parseWysiwygModel(source, [], blocks); + const selection = normalizeSelection(model.display, displaySelection); + + if (selection.start === selection.end) { + const inserted = applyDisplayEdit(source, model.display.slice(0, selection.start) + 'text' + model.display.slice(selection.end), selection); + return applyInlineFormatWysiwyg(inserted.value, {start: inserted.selection.start - 4, end: inserted.selection.end}, format); + } + + const characters = getDisplayCharacters(model); + const selected = characters.filter((character, index) => index >= selection.start && index < selection.end && + !model.charSynthetic[index] && (format === 'inline-code' ? character.text !== '\n' : !/\s/.test(character.text))); + if (selected.length === 0) { + return {value: source, display: model.display, selection}; + } + const remove = selected.every((character) => character.formats.includes(format)); + let sourceOffset = 0; + let displayOffset = 0; + const lines = source.split('\n').map((line) => { + const newline = model.display.indexOf('\n', displayOffset); + const displayEnd = newline === -1 ? model.display.length : newline; + const block = blocks.find((candidate) => candidate.sourceStart === sourceOffset); + const contentStart = block?.displayEnd ?? displayOffset; + let result = line; + if (Math.max(selection.start, contentStart) < Math.min(selection.end, displayEnd)) { + const content = characters.slice(contentStart, displayEnd).map((character, index) => { + const offset = contentStart + index; + if (offset < selection.start || offset >= selection.end) { + return character; + } + const formats = character.formats.filter((active) => active !== format); + return {...character, formats: remove ? formats : [...formats, format]}; + }); + result = (block ? source.slice(block.sourceStart, block.sourceEnd) : '') + serializeInlineCharacters(content); + } + sourceOffset += line.length + 1; + displayOffset = displayEnd + 1; + return result; + }); + const value = lines.join('\n'); + return {value, display: buildWysiwygModel(value).display, selection}; +}; diff --git a/src/lib/view/StreamingMarkdown.tsx b/src/lib/view/StreamingMarkdown.tsx index e4cd9632..f5b665a7 100644 --- a/src/lib/view/StreamingMarkdown.tsx +++ b/src/lib/view/StreamingMarkdown.tsx @@ -1,5 +1,5 @@ -import React, {useEffect, useMemo, useRef} from 'react'; -import {Animated, Text, View} from 'react-native'; +import React, {useEffect, useMemo, useState} from 'react'; +import {Animated, StyleSheet, Text, View} from 'react-native'; import AstRenderer from './AstRenderer'; import {createMarkdownIt} from './createMarkdownIt'; @@ -13,6 +13,7 @@ import type {MarkdownParser, MarkdownStyleMap, OnCopyCode, OnLinkPress, RenderRu const CURSOR_BLINK_ON_MS = 600; const CURSOR_BLINK_OFF_MS = 600; +const cursorStyles = StyleSheet.create({cursor: {height: 16, marginTop: 4, width: 2}}); interface StreamingCursorProps { color: string; @@ -20,7 +21,7 @@ interface StreamingCursorProps { } function StreamingCursor({color, style}: StreamingCursorProps): React.JSX.Element { - const opacity = useRef(new Animated.Value(1)).current; + const [opacity] = useState(() => new Animated.Value(1)); useEffect(() => { const animation = Animated.loop( @@ -38,12 +39,8 @@ function StreamingCursor({color, style}: StreamingCursorProps): React.JSX.Elemen return ( Date: Tue, 22 Sep 2026 10:37:30 +0200 Subject: [PATCH 3/4] Make the WYSIWYG editor CommonMark-faithful and fidelity-safe The rich-text surface rewrote typed text: the inline scanner paired any marker with a later identical one, so identifiers, math and code such as snake_case_name, 2 * 3 * 4 or C++ were turned into emphasis and the source was re-serialized around them. For an LLM chat input the value sent must equal what the user typed, so the model is rebuilt around that contract. - Parser: port of markdown-it's inline rules (delimiter runs with flanking, rule of three, backtick runs, escapes, entities, ~~ and the ins plugin's ++) using markdown-it's own character classes, so the editor hides exactly the markers the renderer consumes. Lists keep indentation and accept `1)`; CRLF fences are recognised. Lines are parsed independently and cached, and models are cached per source, so a keystroke re-parses only its line. - Edits: new wysiwygEdit module applies display edits between the surviving source bytes and verifies by re-parsing that every span survived. Repairs escalate from escaping typed markers to swapping _/* families to re-serializing only the edited line. Untouched lines stay byte-identical. Typed markdown takes effect once valid and the caret continues outside the closed span (`caretOutside`). Code spans are not extended by typing at their end; Enter continues bullet, ordered and quote prefixes and exits an empty one; trailing whitespace moves outside closing delimiters. - Toolbar toggles format the selection, or the word around a collapsed caret, and never split a surrogate pair. - Tests: wysiwygFidelity.test.ts asserts typed/pasted text reaches the value unchanged and that other lines are never rewritten. - Docs: MarkdownEditor section in INPUT.md, architecture notes, STATUS and a device validation checklist. The lint scripts drop the env prefix so they run on Windows. Co-Authored-By: Claude Fable 5.1 --- INPUT.md | 48 +- STATUS.md | 2 + __tests__/MarkdownEditor.test.tsx | 3 +- __tests__/MarkdownWysiwygEditor.test.tsx | 29 + __tests__/wysiwygFidelity.test.ts | 263 +++ __tests__/wysiwygModel.test.ts | 7 +- doc/markdown-editor-architecture.md | 76 +- doc/wysiwyg-device-checklist.md | 73 + package.json | 4 +- src/lib/editor/MarkdownEditor/index.tsx | 2 +- .../editor/MarkdownWysiwygEditor/index.tsx | 17 +- src/lib/editor/index.ts | 8 +- src/lib/editor/utils/wysiwygEdit.ts | 1081 +++++++++++ src/lib/editor/utils/wysiwygModel.ts | 1695 ++++++++--------- 14 files changed, 2403 insertions(+), 905 deletions(-) create mode 100644 __tests__/wysiwygFidelity.test.ts create mode 100644 doc/wysiwyg-device-checklist.md create mode 100644 src/lib/editor/utils/wysiwygEdit.ts 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/STATUS.md b/STATUS.md index 0f4a6b98..93b6197b 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): | 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 index aa5deba9..8c6a899f 100644 --- a/__tests__/MarkdownEditor.test.tsx +++ b/__tests__/MarkdownEditor.test.tsx @@ -64,7 +64,8 @@ describe('MarkdownEditor', () => { }); await renderer.act(async () => { await tree.root.findByType(EditorToolbar).props.onCommand('bold'); }); - expect(onChangeText).toHaveBeenCalledWith('Hi**text**'); + // 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) => { diff --git a/__tests__/MarkdownWysiwygEditor.test.tsx b/__tests__/MarkdownWysiwygEditor.test.tsx index d1917fd6..521c7e78 100644 --- a/__tests__/MarkdownWysiwygEditor.test.tsx +++ b/__tests__/MarkdownWysiwygEditor.test.tsx @@ -148,6 +148,35 @@ describe('MarkdownWysiwygEditor', () => { 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(); 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 index 3a0d5906..ade87904 100644 --- a/__tests__/wysiwygModel.test.ts +++ b/__tests__/wysiwygModel.test.ts @@ -1,7 +1,6 @@ import MarkdownIt from 'markdown-it'; +import {applyDisplayEdit, applyInlineFormatWysiwyg} from '../src/lib/editor/utils/wysiwygEdit'; import { - applyDisplayEdit, - applyInlineFormatWysiwyg, buildWysiwygModel, cleanEmptyMarkers, displaySelectionToSource, @@ -136,7 +135,7 @@ describe('offset mapping', () => { describe('cleanEmptyMarkers', () => { test('removes empty marker pairs', () => { expect(cleanEmptyMarkers('****')).toBe(''); - expect(cleanEmptyMarkers('**_++~~~~++_**')).toBe(''); + expect(cleanEmptyMarkers('a ____ b ++++ c ~~~~')).toBe('a b c '); }); test.each([ @@ -372,7 +371,7 @@ describe('applyInlineFormatWysiwyg', () => { ['__Hello__', 'bold', 'Hello', 'Hello'], ['**_Hi_**', 'bold', '_Hi_', 'Hi'], ['**_Hi_**', 'italic', '**Hi**', 'Hi'], - ['``a`b``', 'inline-code', 'a\\`b', 'a`b'], + ['``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; diff --git a/doc/markdown-editor-architecture.md b/doc/markdown-editor-architecture.md index 4e726e28..d720ebae 100644 --- a/doc/markdown-editor-architecture.md +++ b/doc/markdown-editor-architecture.md @@ -70,24 +70,44 @@ prop (`'plaintext' | 'wysiwyg'`) plus an optional built-in toggle: 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: - -- `utils/wysiwygModel.ts` is a pure module that parses the source into a display - string plus a per-character source-offset map and grouped styled segments. -- `applyDisplayEdit` uses the previous display selection to locate edits, - falling back to a common prefix/suffix diff for native replacements elsewhere. - It preserves paired delimiters around surviving content, removes spans emptied - by the edit, and joins matching spans when their separating text is deleted. - Line breaks close and reopen inline formatting, and joining blocks removes the - second block's prefix. Lines whose delimiter boundaries become invalid are - serialized again; untouched valid syntax and literal code remain unchanged. - Its optional third argument accepts the previous display selection, and the - native editor supplies it automatically. -- `applyInlineFormatWysiwyg` toggles formats on selected display characters and - serializes the affected lines. It keeps block prefixes outside inline formats, - preserves nested styles, and treats code contents as literal text. Delimiter - choice, escaped punctuation, and character references keep the stored Markdown - valid at word boundaries without adding visible characters. +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. @@ -98,15 +118,23 @@ the markdown source authoritative while presenting marker-free display text: 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. Quotes, tables, - links, and fenced code blocks are still edited as literal visible text. -- Native styling, selection, and IME behavior need validation on iOS and Android - devices. Replacing the input after a rejected edit can reset native undo and - composition state; tests cover the component recovery path using native mocks. -- Intraword underscores are treated as italic markers when balanced. +- 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 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/package.json b/package.json index 46e0b7c4..67e74353 100644 --- a/package.json +++ b/package.json @@ -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/MarkdownEditor/index.tsx b/src/lib/editor/MarkdownEditor/index.tsx index 53b9d322..d5dd398d 100644 --- a/src/lib/editor/MarkdownEditor/index.tsx +++ b/src/lib/editor/MarkdownEditor/index.tsx @@ -10,8 +10,8 @@ import MarkdownWysiwygEditor from '../MarkdownWysiwygEditor'; import styles from './style'; import {normalizeSelection} from '../utils/selection'; import {useCommittedValue} from '../utils/useCommittedValue'; +import {applyInlineFormatWysiwyg} from '../utils/wysiwygEdit'; import { - applyInlineFormatWysiwyg, buildWysiwygModel, displaySelectionToSource, getFormatsAtDisplayOffset, diff --git a/src/lib/editor/MarkdownWysiwygEditor/index.tsx b/src/lib/editor/MarkdownWysiwygEditor/index.tsx index d8787cde..d606fa06 100644 --- a/src/lib/editor/MarkdownWysiwygEditor/index.tsx +++ b/src/lib/editor/MarkdownWysiwygEditor/index.tsx @@ -10,8 +10,8 @@ import { import styles from './style'; import {normalizeSelection} from '../utils/selection'; +import {applyDisplayEdit} from '../utils/wysiwygEdit'; import { - applyDisplayEdit, buildWysiwygModel, type WysiwygBlock, type WysiwygInlineFormat, @@ -108,6 +108,8 @@ const MarkdownWysiwygEditor = React.forwardRef< ) { 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; @@ -158,9 +160,14 @@ const MarkdownWysiwygEditor = React.forwardRef< }; const handleChangeText = (nextDisplay: string): void => { - const result = applyDisplayEdit(value, nextDisplay, normalizedSelection); + 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}, @@ -173,6 +180,12 @@ const MarkdownWysiwygEditor = React.forwardRef< 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); }; diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts index bc84548e..f535cc3d 100644 --- a/src/lib/editor/index.ts +++ b/src/lib/editor/index.ts @@ -18,9 +18,8 @@ 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 { - applyDisplayEdit, - applyInlineFormatWysiwyg, buildWysiwygModel, cleanEmptyMarkers, displaySelectionToSource, @@ -28,12 +27,15 @@ export { sourceOffsetToDisplay, } from './utils/wysiwygModel'; +export type {WysiwygEditOptions, WysiwygEditResult} from './utils/wysiwygEdit'; export type { WysiwygBlock, - WysiwygEditResult, + WysiwygBlockMarker, + WysiwygBlockType, WysiwygInlineFormat, WysiwygModel, WysiwygSegment, + WysiwygSpan, } from './utils/wysiwygModel'; export type {EditorToolbarProps} from './EditorToolbar'; diff --git a/src/lib/editor/utils/wysiwygEdit.ts b/src/lib/editor/utils/wysiwygEdit.ts new file mode 100644 index 00000000..4d60e95b --- /dev/null +++ b/src/lib/editor/utils/wysiwygEdit.ts @@ -0,0 +1,1081 @@ +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/wysiwygModel.ts b/src/lib/editor/utils/wysiwygModel.ts index 49983628..47a91b1c 100644 --- a/src/lib/editor/utils/wysiwygModel.ts +++ b/src/lib/editor/utils/wysiwygModel.ts @@ -1,10 +1,8 @@ +import MarkdownIt from 'markdown-it'; + import {normalizeSelection} from './selection'; -import type { - MarkdownCommandResult, - MarkdownInlineFormat, - MarkdownSelection, -} from '../types'; +import type {MarkdownInlineFormat, MarkdownSelection} from '../types'; /** * Inline formats that the WYSIWYG surface renders with hidden markdown markers. @@ -22,59 +20,55 @@ export type WysiwygBlock = | 'list-marker' | null; -interface MarkerDefinition { - format: WysiwygInlineFormat; - marker: string; -} - -/** - * Marker definitions are scanned longest-first so that `**` is matched before a - * stray single character could be mistaken for another marker. - */ -const MARKER_DEFINITIONS: readonly MarkerDefinition[] = [ - {format: 'bold', marker: '**'}, - {format: 'bold', marker: '__'}, - {format: 'underline', marker: '++'}, - {format: 'strikethrough', marker: '~~'}, - {format: 'inline-code', marker: '`'}, - {format: 'italic', marker: '_'}, - {format: 'italic', marker: '*'}, -]; - -interface InlineOpening extends MarkerDefinition { - displayStart: number; - sourceStart: number; +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; } -interface InlineSpan extends InlineOpening { - closingStart: number; +/** 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; -} - -interface BlockMarker { - sourceStart: number; - sourceEnd: number; displayStart: number; - displayEnd: number; + format: WysiwygInlineFormat; + /** Source range of the opening delimiter (code spans include stripped padding). */ + openEnd: number; + openStart: number; } -const HEADING_BLOCK_KEYS: readonly WysiwygBlock[] = [ - 'heading-one', - 'heading-two', - 'heading-three', -]; - -const BULLET_GLYPH = '• '; +export type WysiwygBlockType = 'heading' | 'bullet' | 'ordered'; -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 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. @@ -91,1004 +85,971 @@ export interface WysiwygModel { segments: readonly WysiwygSegment[]; /** Original markdown source the model was built from. */ source: string; + /** Recognised inline spans in source order. */ + spans: readonly WysiwygSpan[]; } -interface BlockInfo { - blockKey: WysiwygBlock; - markerLen: number; - ordinal?: number; - type: 'heading' | 'bullet' | 'ordered'; +/** + * 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; } -const detectBlock = (lineText: string): BlockInfo | null => { - const heading = /^(#{1,6}) /.exec(lineText); +type TokenRole = 'literal' | 'open' | 'close' | 'hidden'; - if (heading) { - const level = heading[1]!.length; +interface TextToken { + end: number; + kind: 'text'; + lone: boolean; + start: number; + text: string; +} - return { - blockKey: HEADING_BLOCK_KEYS[Math.min(level, 3) - 1]!, - markerLen: level + 1, - type: 'heading', - }; - } +interface CodeToken { + contentEnd: number; + contentStart: number; + end: number; + kind: 'code'; + start: number; +} - if (/^[-*+] /.test(lineText)) { - return {blockKey: null, markerLen: 2, type: 'bullet'}; - } +interface DelimiterToken { + end: number; + format: WysiwygInlineFormat; + kind: 'delim'; + role: TokenRole; + start: number; + text: string; +} - const ordered = /^(\d+)\. /.exec(lineText); +type LineToken = TextToken | CodeToken | DelimiterToken; - if (ordered) { - return { - blockKey: null, - markerLen: ordered[0]!.length, - ordinal: Number(ordered[1]), - type: 'ordered', - }; +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; } - return null; + 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, + }; }; -const matchMarkerAt = ( - source: string, - index: number, -): MarkerDefinition | null => { - for (const definition of MARKER_DEFINITIONS) { - if (source.startsWith(definition.marker, index)) { - return definition; - } +/** Port of markdown-it's `balance_pairs` rule. */ +const processDelimiters = (delimiters: Delimiter[]): void => { + const max = delimiters.length; + + if (max === 0) { + return; } - return null; -}; + const openersBottom = new Map(); + const jumps: number[] = []; + let headerIdx = 0; + let lastTokenIdx = -2; -const formatsKey = (formats: readonly WysiwygInlineFormat[]): string => - [...formats].sort().join('|'); + for (let closerIdx = 0; closerIdx < max; closerIdx += 1) { + const closer = delimiters[closerIdx]!; -/** - * 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. Unbalanced inline markers are kept as literal - * text so nothing disappears unexpectedly while typing. - */ -const parseWysiwygModel = ( - source: string, - inlineSpans: InlineSpan[], - blockMarkers: BlockMarker[] = [], -): WysiwygModel => { - const displayChars: string[] = []; - const charSource: number[] = []; - const charSynthetic: boolean[] = []; - const charMarkerStart: number[] = []; - const charMarkerEnd: number[] = []; - const charBlock: WysiwygBlock[] = []; - const charFormats: WysiwygInlineFormat[][] = []; - - const pushReal = ( - index: number, - block: WysiwygBlock, - formats: readonly WysiwygInlineFormat[], - ): void => { - displayChars.push(source[index]!); - charSource.push(index); - charSynthetic.push(false); - charMarkerStart.push(index); - charMarkerEnd.push(index + 1); - charBlock.push(block); - charFormats.push([...formats]); - }; + jumps.push(0); - const pushSynthetic = ( - glyph: string, - markerStart: number, - markerEnd: number, - ): void => { - for (const character of glyph) { - displayChars.push(character); - charSource.push(markerEnd); - charSynthetic.push(true); - charMarkerStart.push(markerStart); - charMarkerEnd.push(markerEnd); - charBlock.push('list-marker'); - charFormats.push([]); + if (delimiters[headerIdx]!.marker !== closer.marker || lastTokenIdx !== closer.token - 1) { + headerIdx = closerIdx; } - }; - let index = 0; - let fence: string | null = null; + lastTokenIdx = closer.token; - while (index < source.length) { - const lineStart = index; - const newlineIndex = source.indexOf('\n', lineStart); - const lineEnd = newlineIndex === -1 ? source.length : newlineIndex; - const lineText = source.slice(lineStart, lineEnd); - const possibleFence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(lineText); - const fenceMatch = possibleFence?.[1]?.[0] === '`' && possibleFence[2]!.includes('`') - ? null - : possibleFence; - const literalLine = fence !== null || fenceMatch !== null; + if (!closer.close) { + continue; + } - // Fenced blocks remain literal; their contents must not become editable - // inline delimiter pairs, including runs such as **** inside code. - if (fenceMatch) { - const marker = fenceMatch[1]!; + let bottoms = openersBottom.get(closer.marker); - if (fence === null) { - fence = marker; - } else if ( - marker[0] === fence[0] && - marker.length >= fence.length && - fenceMatch[2]!.trim().length === 0 - ) { - fence = null; + 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; + } } } - const block = literalLine ? null : detectBlock(lineText); + 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; - let contentStart = lineStart; - let contentBlock: WysiwygBlock = null; + while (searchFrom < line.length) { + const candidate = line.indexOf('`', searchFrom); - if (block) { - const displayStart = displayChars.length; - contentStart = lineStart + block.markerLen; + if (candidate === -1) { + return -1; + } - if (block.type === 'heading') { - contentBlock = block.blockKey; - } else { - const glyph = - block.type === 'bullet' - ? BULLET_GLYPH - : `${block.ordinal}. `; + let closerEnd = candidate; - pushSynthetic(glyph, lineStart, contentStart); - } + while (closerEnd < line.length && line.charCodeAt(closerEnd) === CODE_MARKER) { + closerEnd += 1; + } - blockMarkers.push({sourceStart: lineStart, sourceEnd: contentStart, displayStart, displayEnd: displayChars.length}); + if (closerEnd - candidate === length) { + return candidate; } - const active: InlineOpening[] = []; - let scanIndex = contentStart; + searchFrom = closerEnd; + } - while (scanIndex < lineEnd) { - const formats = active.map((opening) => opening.format); - const entity = !literalLine && /^&#(?:([0-9]{1,7})|x([0-9a-f]{1,6}));/i.exec(source.slice(scanIndex, lineEnd)); - if (entity) { - const codePoint = entity[1] ? Number(entity[1]) : parseInt(entity[2]!, 16); - if (codePoint > 0 && codePoint <= 0x10ffff && (codePoint < 0xd800 || codePoint > 0xdfff)) { - const decoded = String.fromCodePoint(codePoint); - for (let offset = 0; offset < decoded.length; offset += 1) { - pushReal(scanIndex, contentBlock, formats); - displayChars[displayChars.length - 1] = decoded[offset]!; - charMarkerEnd[charMarkerEnd.length - 1] = scanIndex + entity[0].length; - } - scanIndex += entity[0].length; - continue; - } - } - if (!literalLine && source[scanIndex] === '\\' && /[\\`*_{}[\]()#+.!<>~=-]/.test(source[scanIndex + 1] ?? '')) { - pushReal(scanIndex + 1, contentBlock, formats); - charSource[charSource.length - 1] = scanIndex; - charMarkerStart[charMarkerStart.length - 1] = scanIndex; - scanIndex += 2; + 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; } - const definition = literalLine ? null : matchMarkerAt(source, scanIndex); - if (!definition) { - pushReal(scanIndex, contentBlock, formats); - scanIndex += 1; + 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 (definition.format === 'inline-code') { - const marker = /^`+/.exec(source.slice(scanIndex, lineEnd))![0]; - let closingIndex = source.indexOf(marker, scanIndex + marker.length); - - while ( - closingIndex !== -1 && closingIndex < lineEnd && - (source[closingIndex - 1] === '`' || source[closingIndex + marker.length] === '`') - ) { - closingIndex = source.indexOf(marker, closingIndex + marker.length); - } + if (code === CODE_MARKER) { + let runEnd = pos; - if (closingIndex === -1 || closingIndex >= lineEnd) { - for (let offset = 0; offset < marker.length; offset += 1) { - pushReal(scanIndex + offset, contentBlock, formats); - } - scanIndex += marker.length; - continue; - } + while (runEnd < line.length && line.charCodeAt(runEnd) === CODE_MARKER) { + runEnd += 1; + } - const displayStart = displayChars.length; - const contentStartIndex = scanIndex + marker.length; - const content = source.slice(contentStartIndex, closingIndex); - const padding = content.startsWith(' ') && content.endsWith(' ') && - content.trim().length > 0 ? 1 : 0; + const length = runEnd - pos; + const closer = findCodeCloser(line, pos, length); - for (let codeIndex = contentStartIndex + padding; codeIndex < closingIndex - padding; codeIndex += 1) { - pushReal(codeIndex, contentBlock, [...formats, 'inline-code']); + if (closer === -1) { + for (let offset = pos; offset < runEnd; offset += 1) { + pushText(offset, offset + 1, '`'); } - inlineSpans.push({ - closingStart: closingIndex, - displayEnd: displayChars.length, - displayStart, - format: 'inline-code', - marker, - sourceStart: scanIndex, - }); - scanIndex = closingIndex + marker.length; + pos = runEnd; continue; } - const activeIndex = active.findIndex((opening) => opening.marker === definition.marker); + const content = line.slice(runEnd, closer); + const padding = /^ (.+) $/.test(content) ? 1 : 0; - if (activeIndex !== -1) { - const opening = active.splice(activeIndex, 1)[0]!; + tokens.push({ + contentEnd: closer - padding, + contentStart: runEnd + padding, + end: closer + length, + kind: 'code', + start: pos, + }); + pos = closer + length; + continue; + } - inlineSpans.push({ - ...opening, - closingStart: scanIndex, - displayEnd: displayChars.length, + 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, }); - scanIndex += definition.marker.length; - continue; } - const closingIndex = source.indexOf( - definition.marker, - scanIndex + definition.marker.length, - ); + pos += scanned.length; + continue; + } - if (closingIndex !== -1 && closingIndex < lineEnd) { - active.push({ - ...definition, - displayStart: displayChars.length, - sourceStart: scanIndex, - }); - scanIndex += definition.marker.length; + 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; } - for (let offset = 0; offset < definition.marker.length; offset += 1) { - pushReal(scanIndex + offset, contentBlock, formats); + let offset = 0; + + if (scanned.length % 2 === 1) { + pushText(pos, pos + 1, character, true); + offset = 1; } - scanIndex += definition.marker.length; - } - if (newlineIndex !== -1) { - pushReal(newlineIndex, null, []); - index = newlineIndex + 1; - } else { - index = source.length; - } - } + 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, + }); - const display = displayChars.join(''); - const segments: WysiwygSegment[] = []; - let currentKey: string | null = null; + // markdown-it-ins skips delimiters that can neither open nor close. + if (code === PLUS && !scanned.canOpen && !scanned.canClose) { + continue; + } - 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'}`; + delimiters.push({ + close: scanned.canClose, + end: -1, + length: 0, + marker: code, + open: scanned.canOpen, + token: tokens.length - 1, + }); + } - if (key !== currentKey) { - segments.push({block, formats, synthetic, text: displayChars[charIndex]!}); - currentKey = key; + pos += scanned.length; continue; } - segments[segments.length - 1]!.text += displayChars[charIndex]!; + pushText(pos, pos + 1, line[pos]!); + pos += 1; } - return { - charMarkerEnd, - charMarkerStart, - charSource, - charSynthetic, - display, - segments, - source, - }; + return {delimiters, tokens}; }; -export const buildWysiwygModel = (source: string): WysiwygModel => - parseWysiwygModel(source, []); +const delimiterToken = (tokens: LineToken[], index: number): DelimiterToken => { + const token = tokens[index]; -/** - * Source index to use when inserting text at the given display offset. Inserting - * inside a synthetic marker goes into the following content so the list marker - * is preserved; inserting at the very end inherits the preceding character's - * formatting rather than escaping any trailing markers. - */ -const displayOffsetToSourceInsert = ( - model: WysiwygModel, - displayOffset: number, -): number => { - const length = model.charSource.length; - - if (displayOffset < length) { - return model.charSynthetic[displayOffset] - ? model.charMarkerEnd[displayOffset]! - : model.charSource[displayOffset]!; + if (!token || token.kind !== 'delim') { + throw new Error('Delimiter bookkeeping is out of sync with the token stream'); } - if (length === 0) { - return model.source.length; - } - - return model.charMarkerEnd[length - 1]!; + return token; }; -/** Source index immediately after the display character at the given index. */ -const charSourceEnd = (model: WysiwygModel, charIndex: number): number => - model.charMarkerEnd[charIndex]!; - -/** Source index at the start of the display character at the given index. */ -const charSourceStart = (model: WysiwygModel, charIndex: number): number => - model.charMarkerStart[charIndex]!; +/** Assign opener/closer roles following markdown-it's post-processing rules. */ +const pairDelimiters = (tokens: LineToken[], delimiters: Delimiter[]): void => { + processDelimiters(delimiters); -/** - * 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 => { - selection = normalizeSelection(model.display, selection); - const start = - selection.start < model.charSource.length - ? charSourceStart(model, selection.start) - : displayOffsetToSourceInsert(model, selection.start); - const end = - selection.end > selection.start - ? charSourceEnd(model, selection.end - 1) - : start; + for (let index = delimiters.length - 1; index >= 0; index -= 1) { + const start = delimiters[index]!; - return {end, start}; -}; + if (start.end === -1) { + continue; + } -/** - * 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 count = 0; + const end = delimiters[start.end]!; + const opener = delimiterToken(tokens, start.token); + const closer = delimiterToken(tokens, end.token); - for (let charIndex = 0; charIndex < model.charSource.length; charIndex += 1) { - const isBefore = model.charMarkerEnd[charIndex]! <= sourceOffset; + if (start.marker !== STAR && start.marker !== UNDERSCORE) { + opener.role = 'open'; + closer.role = 'close'; + continue; + } - if (!isBefore) { - break; + 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; } - count += 1; + opener.role = 'open'; + closer.role = 'close'; } - - return count; }; -/** - * Remove parsed inline pairs with no display content, preserving literal code - * and unmatched delimiters. Normal edits instead remove only newly emptied - * pairs using the previous model, so untouched source stays intact. - */ -export const cleanEmptyMarkers = (source: string): string => { - const spans: InlineSpan[] = []; - parseWysiwygModel(source, spans); - const removed = new Uint8Array(source.length); - - for (const span of spans) { - if (span.displayStart === span.displayEnd) { - removed.fill(1, span.sourceStart, span.sourceStart + span.marker.length); - removed.fill(1, span.closingStart, span.closingStart + span.marker.length); - } +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', + }; } - return source.split('').filter((_character, index) => !removed[index]).join(''); -}; + const list = LIST_RE.exec(line); -/** - * 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.segments.length === 0) { - return []; + if (!list) { + return null; } - const target = Math.max(displayOffset - 1, 0); - let consumed = 0; + const indent = list[1]!; + const marker = list[2]!; + const ordered = /^\d/.test(marker); - for (const segment of model.segments) { - consumed += segment.text.length; + 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', + }; +}; - if (target < consumed) { - return segment.formats; +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, + }); + } } } - return model.segments[model.segments.length - 1]!.formats; -}; + 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]!}); + } -export interface WysiwygEditResult extends MarkdownCommandResult { - /** Display text the surface should show after the edit. */ - display: string; -} + return {block, chars, spans}; + } -interface DisplayEdit extends MarkdownSelection { - inserted: string; -} + 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(); -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; + if (!open) { + throw new Error('Closing delimiter without an opener'); } - return {start, end, inserted: next.slice(start, start + insertedLength)}; + 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); }; - if (selection) { - const {start, end} = normalizeSelection(previous, selection); - const replacement = matchRange(start, end); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; - 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 (token.kind === 'code') { + const codeFormats = [...formats, 'inline-code' as const]; - if (deletion) { - return deletion; + 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; } - } - // 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; + if (token.kind === 'text') { + if (token.lone) { + // markdown-it moves a lone marker behind the closers that follow it. + let next = index + 1; - while (start < maxPrefix && previous[start] === next[start]) { - start += 1; - } + while (next < tokens.length) { + const candidate = tokens[next]!; - let suffix = 0; + if (candidate.kind !== 'delim' || candidate.role !== 'close' || candidate.text[0] !== token.text) { + break; + } - while ( - suffix < Math.min(previous.length - start, next.length - start) && - previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix] - ) { - suffix += 1; - } + closeSpan(candidate); + next += 1; + } - return { - start, - end: previous.length - suffix, - inserted: next.slice(start, next.length - suffix), - }; -}; + index = next - 1; + } -/** Merge only delimiter boundaries brought together by this deletion. */ -const joinAdjacentSpans = ( - spans: readonly InlineSpan[], - removed: Uint8Array, - insertionIndex: number, - inserted: string, -): InlineSpan[] => { - const owners = new Map(spans.map((span) => [span, span])); - const surviving = new Set(spans); - const boundaries = spans.flatMap((span) => [ - {opening: true, span, start: span.sourceStart}, - {opening: false, span, start: span.closingStart}, - ]).sort((left, right) => left.start - right.start); - - for (let index = 1; index < boundaries.length; index += 1) { - const left = boundaries[index - 1]!; - const right = boundaries[index]!; - const gapStart = left.start + left.span.marker.length; - - if ( - left.opening || !right.opening || left.span.marker !== right.span.marker || - gapStart >= right.start || - (inserted.length > 0 && insertionIndex >= gapStart && insertionIndex <= right.start) - ) { + pushChar(token.text, token.start, token.end, formats); continue; } - let gapRemoved = true; - - for (let offset = gapStart; offset < right.start; offset += 1) { - if (!removed[offset]) { - gapRemoved = false; + 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; - } - } - - if (gapRemoved) { - const merged = owners.get(left.span)!; - merged.closingStart = right.span.closingStart; - merged.displayEnd = right.span.displayEnd; - owners.set(right.span, merged); - surviving.delete(right.span); - removed.fill(1, left.start, gapStart); - removed.fill(1, right.start, right.start + right.span.marker.length); + case 'close': + closeSpan(token); + break; + case 'hidden': + break; + default: + pushChar(token.text, token.start, token.end, formats); } } - return [...surviving]; + spans.sort((left, right) => left.openStart - right.openStart); + + return {block, chars, spans}; }; -const serializeCode = (content: string, previousMarker: string): {prefix: string; value: string} => { - let marker = previousMarker; +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); - // A deletion can join two literal backtick runs. Pick a delimiter that - // cannot be mistaken for content, and separate edge backticks with padding. - for (const run of content.match(/`+/g) ?? []) { - if (run.length >= marker.length) { - marker = '`'.repeat(run.length + 1); + if (cache.size > limit) { + const oldest = cache.keys().next(); + + if (!oldest.done) { + cache.delete(oldest.value); } } - const needsPadding = content.startsWith('`') || content.endsWith('`') || - (content.startsWith(' ') && content.endsWith(' ') && content.trim().length > 0); - const padding = needsPadding ? ' ' : ''; - - return {prefix: marker + padding, value: marker + padding + content + padding + marker}; + return value; }; -interface InlineCharacter { - text: string; - formats: readonly WysiwygInlineFormat[]; - literal: boolean; -} +const parseLineCached = (line: string, literal: boolean): ParsedLine => { + const key = (literal ? 'L' : 'N') + line; + const cached = lineCache.get(key); -const getDisplayCharacters = (model: WysiwygModel): InlineCharacter[] => { - const characters: InlineCharacter[] = []; - - for (const segment of model.segments) { - for (let index = 0; index < segment.text.length; index += 1) { - const offset = characters.length; - characters.push({ - text: segment.text[index]!, - formats: segment.formats, - literal: segment.formats.includes('inline-code') || - (!segment.synthetic && model.charMarkerEnd[offset]! - model.charMarkerStart[offset]! > 1), - }); - } + if (cached) { + return cached; } - return characters; + return remember(lineCache, key, parseLine(line, literal), LINE_CACHE_LIMIT); }; -const INLINE_ORDER: readonly WysiwygInlineFormat[] = ['bold', 'italic', 'underline', 'strikethrough']; - -type SerializedInlinePart = - | {kind: 'text' | 'code'; text: string} - | {kind: 'delimiter'; text: string; opening: boolean}; - -const validDelimiterBoundary = (marker: string, opening: boolean, before: string, after: string): boolean => { - const beforeWhitespace = before === '' || /\s/.test(before); - const afterWhitespace = after === '' || /\s/.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); -}; +const formatsKey = (formats: readonly WysiwygInlineFormat[]): string => + [...formats].sort().join('|'); -const serializeInlineParts = (parts: SerializedInlinePart[]): string => { - 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)) { - // CommonMark disallows boundaries such as **`code`**word. - // Encode the adjacent letter as a character reference so the - // source delimiter is valid without inserting visible text. - 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; - } - } - } +/** + * 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; } - return parts.map((part) => part.text).join(''); -}; -/** Serialize actual inline formatting, keeping code atomic and newlines outside markers. */ -const serializeInlineCharacters = (characters: readonly InlineCharacter[]): string => { - const parts: SerializedInlinePart[] = []; - let active: WysiwygInlineFormat[] = []; - const intrawordTransition = characters.some((character, index) => { - const previous = characters[index - 1]; - return previous && /[\p{L}\p{N}]/u.test(previous.text) && /[\p{L}\p{N}]/u.test(character.text) && - formatsKey(previous.formats) !== formatsKey(character.formats); - }); - const alternateEmphasis = intrawordTransition && characters.some((character) => character.formats.includes('italic')); - const marker = (format: WysiwygInlineFormat): string => { - if (alternateEmphasis && format === 'italic') { - return '*'; - } - if (alternateEmphasis && format === 'bold') { - return '__'; - } - return MARKER_DEFINITIONS.find((definition) => definition.format === format)!.marker; + 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); }; - const transition = (formats: WysiwygInlineFormat[]): void => { - let common = 0; - while (common < active.length && active[common] === formats[common]) { - common += 1; + 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; + } } - for (let index = active.length - 1; index >= common; index -= 1) { - parts.push({kind: 'delimiter', text: marker(active[index]!), opening: false}); + + 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 (let index = common; index < formats.length; index += 1) { - parts.push({kind: 'delimiter', text: marker(formats[index]!), opening: true}); + + for (const char of parsed.chars) { + pushChar(char.text, lineStart + char.start, lineStart + char.end, char.synthetic, char.literal, char.formats, char.block); } - active = formats; - }; - for (let index = 0; index < characters.length; index += 1) { - const character = characters[index]!; - if (character.text === '\n') { - transition([]); - parts.push({kind: 'text', text: '\n'}); - continue; + 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, + }); } - const formats = INLINE_ORDER.filter((format) => character.formats.includes(format)); - if (/\s/.test(character.text) && !character.formats.includes('inline-code')) { - // Emphasis cannot open or close against whitespace. Keep only the - // stack shared by both sides of a space; reopen formats at text. - let nextIndex = index + 1; - while (nextIndex < characters.length && /[^\S\n]/.test(characters[nextIndex]!.text)) { - nextIndex += 1; - } - const next = characters[nextIndex]; - const nextFormats = next && next.text !== '\n' - ? INLINE_ORDER.filter((format) => next.formats.includes(format)) - : []; - let common = 0; - while (common < active.length && active[common] === formats[common] && active[common] === nextFormats[common]) { - common += 1; - } - transition(active.slice(0, common)); - } else { - transition(formats); + if (hasCarriageReturn) { + pushChar('\r', textEnd, lineEnd, false, false, NO_FORMATS, null); } - if (character.formats.includes('inline-code')) { - let content = character.text; - while (index + 1 < characters.length) { - const next = characters[index + 1]!; - if (next.text === '\n' || formatsKey(next.formats) !== formatsKey(character.formats)) { - break; - } - content += next.text; - index += 1; - } - parts.push({kind: 'code', text: serializeCode(content, '`').value}); - } else { - let text = character.text; - const next = characters[index + 1]; - if (/^[\uD800-\uDBFF]$/.test(text) && next && /^[\uDC00-\uDFFF]$/.test(next.text) && - formatsKey(character.formats) === formatsKey(next.formats)) { - text += next.text; - index += 1; - } - const escape = /[\\`*_+~]/.test(character.text) || - (character.literal && /[{}[\]()#!<>.=-]/.test(character.text)); - parts.push({kind: 'text', text: (escape ? '\\' : '') + text}); + + if (newlineIndex === -1) { + break; } + + pushChar('\n', newlineIndex, newlineIndex + 1, false, false, NO_FORMATS, null); + lineStart = newlineIndex + 1; } - transition([]); - return serializeInlineParts(parts); -}; -/** Keep typed spaces and punctuation from turning existing formatting into literals. */ -const normalizeInlineBoundaries = (source: string): WysiwygModel => { - const spans: InlineSpan[] = []; - const blocks: BlockMarker[] = []; - const model = parseWysiwygModel(source, spans, blocks); - const invalidLines = new Set(); - for (const span of spans) { - if (span.format === 'inline-code') { + 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; } - for (const offset of [span.sourceStart, span.closingStart]) { - const before = Array.from(source.slice(Math.max(0, offset - 2), offset)).pop() ?? ''; - const after = Array.from(source.slice(offset + span.marker.length, offset + span.marker.length + 2))[0] ?? ''; - if (!validDelimiterBoundary(span.marker, offset === span.sourceStart, before, after)) { - invalidLines.add(offset === 0 ? 0 : source.lastIndexOf('\n', offset - 1) + 1); - } - } - } - if (invalidLines.size === 0) { - return model; + + segments[segments.length - 1]!.text += displayChars[charIndex]!; } - const characters = getDisplayCharacters(model); - let offset = 0; - const value = source.split('\n').map((line) => { - const start = offset; - offset += line.length + 1; - if (!invalidLines.has(start)) { - return line; - } - const block = blocks.find((candidate) => candidate.sourceStart === start); - const displayStart = block?.displayEnd ?? sourceOffsetToDisplay(model, start); - const displayEnd = sourceOffsetToDisplay(model, start + line.length); - const prefix = block ? source.slice(block.sourceStart, block.sourceEnd) : ''; - return prefix + serializeInlineCharacters(characters.slice(displayStart, displayEnd)); - }).join('\n'); - return buildWysiwygModel(value); + + return remember(modelCache, source, { + blocks, + charFormats, + charLiteral, + charMarkerEnd, + charMarkerStart, + charSource, + charSynthetic, + display: displayChars.join(''), + segments, + source, + spans, + }, MODEL_CACHE_LIMIT); }; -const applyMultilineDisplayEdit = ( +/** + * 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, - spans: readonly InlineSpan[], - blocks: readonly BlockMarker[], - edit: DisplayEdit, - sourceStart: number, - sourceEnd: number, -): WysiwygEditResult | null => { - if (!edit.inserted.includes('\n')) { - return null; - } - - const lineStart = sourceStart === 0 ? 0 : model.source.lastIndexOf('\n', sourceStart - 1) + 1; - const nextNewline = model.source.indexOf('\n', sourceStart); - const lineEnd = nextNewline === -1 ? model.source.length : nextNewline; - if (!spans.some((span) => span.sourceStart < Math.max(lineEnd, sourceEnd) && span.closingStart >= lineStart)) { - return null; - } - if (sourceEnd > lineEnd) { - // First join the selected blocks, then split the resulting line. - const deleted = applyDisplayEdit(model.source, model.display.slice(0, edit.start) + model.display.slice(edit.end), edit); - const caret = deleted.selection.start; - return applyDisplayEdit(deleted.value, deleted.display.slice(0, caret) + edit.inserted + deleted.display.slice(caret), deleted.selection); - } + 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; - const block = blocks.find((candidate) => candidate.sourceStart === lineStart); - const displayStart = block?.displayEnd ?? sourceOffsetToDisplay(model, lineStart); - const displayEnd = sourceOffsetToDisplay(model, lineEnd); - const characters = getDisplayCharacters(model).slice(displayStart, displayEnd); - const start = Math.max(edit.start - displayStart, 0); - const end = Math.max(edit.end - displayStart, 0); - const formats = spans.filter((span) => sourceStart >= span.sourceStart + span.marker.length && sourceStart <= span.closingStart) - .map((span) => span.format); - const inserted = edit.inserted.split('').map((text): InlineCharacter => ({text, formats, literal: formats.includes('inline-code')})); - const prefix = block ? model.source.slice(block.sourceStart, block.sourceEnd) : ''; - const value = model.source.slice(0, lineStart) + prefix + - serializeInlineCharacters([...characters.slice(0, start), ...inserted, ...characters.slice(end)]) + model.source.slice(lineEnd); - const nextModel = buildWysiwygModel(value); - const caret = Math.min(displayStart + start + edit.inserted.length, nextModel.display.length); - - return {value, display: nextModel.display, selection: {start: caret, end: caret}}; + return {end, start}; }; /** - * 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. Keep paired - * inline delimiters around surviving content, removing only newly emptied spans. - * Deleting a synthetic marker removes the whole raw marker it stands in for. + * Map a markdown source offset onto a display offset by counting display + * characters that end at or before it. */ -export const applyDisplayEdit = ( - previousSource: string, - nextDisplay: string, - previousSelection?: MarkdownSelection, -): WysiwygEditResult => { - const spans: InlineSpan[] = []; - const blocks: BlockMarker[] = []; - const model = parseWysiwygModel(previousSource, spans, blocks); - - if (nextDisplay === model.display) { - return { - display: model.display, - selection: normalizeSelection(model.display, previousSelection), - value: previousSource, - }; - } +export const sourceOffsetToDisplay = ( + model: WysiwygModel, + sourceOffset: number, +): number => { + let low = 0; + let high = model.charMarkerEnd.length; - const edit = findDisplayEdit(model.display, nextDisplay, previousSelection); - const sourceStart = edit.end > edit.start - ? charSourceStart(model, edit.start) - : displayOffsetToSourceInsert(model, edit.start); - const sourceEnd = edit.end > edit.start - ? charSourceEnd(model, edit.end - 1) - : sourceStart; - const multiline = applyMultilineDisplayEdit(model, spans, blocks, edit, sourceStart, sourceEnd); - if (multiline) { - return multiline; - } - const removed = new Uint8Array(previousSource.length); - removed.fill(1, sourceStart, sourceEnd); - for (const block of blocks) { - if (block.sourceStart > 0 && removed[block.sourceStart - 1]) { - removed.fill(1, block.sourceStart, block.sourceEnd); - } - } - const keptSpans: InlineSpan[] = []; - - for (const span of spans) { - const keepsContent = span.displayStart < edit.start || span.displayEnd > edit.end; - const inheritsInsertion = edit.inserted.length > 0 && - sourceStart >= span.sourceStart + span.marker.length && - sourceStart <= span.closingStart; - const newlyEmpty = span.displayStart < span.displayEnd && - !keepsContent && !inheritsInsertion; - const deletesEmptyPair = sourceStart <= span.sourceStart && - sourceEnd >= span.closingStart + span.marker.length; - const removePair = newlyEmpty || (deletesEmptyPair && !inheritsInsertion); - - if (removePair) { - // Code spans may also contain hidden padding around their content. - removed.fill(1, span.sourceStart, span.closingStart + span.marker.length); + while (low < high) { + const middle = Math.floor((low + high) / 2); + + if (model.charMarkerEnd[middle]! <= sourceOffset) { + low = middle + 1; } else { - removed.fill(0, span.sourceStart, span.sourceStart + span.marker.length); - removed.fill(0, span.closingStart, span.closingStart + span.marker.length); - keptSpans.push(span); + high = middle; } } - const joinedSpans = joinAdjacentSpans(keptSpans, removed, sourceStart, edit.inserted); - const codeReplacements = new Map(); - let insertionInCode = false; + return low; +}; - for (const span of joinedSpans) { - if (span.format !== 'inline-code') { - continue; - } +/** + * 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 inheritsInsertion = sourceStart >= span.sourceStart + span.marker.length && - sourceStart <= span.closingStart; - const deletesContent = edit.start < span.displayEnd && edit.end > span.displayStart; + const target = Math.min(Math.max(displayOffset - 1, 0), model.charFormats.length - 1); - if (!deletesContent && !(inheritsInsertion && edit.inserted.length > 0)) { - continue; - } + return model.charFormats[target]!; +}; - const content = model.display.slice(span.displayStart, span.displayEnd); - const start = Math.min(Math.max(edit.start - span.displayStart, 0), content.length); - const end = Math.min(Math.max(edit.end - span.displayStart, 0), content.length); - const inserted = inheritsInsertion ? edit.inserted : ''; - const serialized = serializeCode(content.slice(0, start) + inserted + content.slice(end), span.marker); +const DOUBLED_MARKER_RE = /^[*_+~]$/; - codeReplacements.set(span.sourceStart, { - value: serialized.value, - caret: inheritsInsertion ? serialized.prefix.length + start + inserted.length : null, - }); - removed.fill(1, span.sourceStart, span.closingStart + span.marker.length); - insertionInCode ||= inheritsInsertion; - } +/** + * 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; + } + } - let nextSource = ''; - let caretSource = 0; + // 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; + } - for (let index = 0; index <= previousSource.length; index += 1) { - if (index === sourceStart && !insertionInCode) { - nextSource += edit.inserted; - caretSource = nextSource.length; - } + if (runEnd === runStart) { + runStart += 1; + continue; + } - const code = codeReplacements.get(index); + const length = runEnd - runStart; + const dropped = length - (length % 4); - if (code) { - if (code.caret !== null) { - caretSource = nextSource.length + code.caret; + if (dropped > 0) { + removed.fill(1, model.charMarkerStart[runStart]!, model.charMarkerStart[runStart]! + dropped); + changed = true; } - nextSource += code.value; + runStart = runEnd; } - if (index < previousSource.length && !removed[index]) { - nextSource += previousSource[index]!; + if (!changed) { + return current; } - } - const caret = sourceOffsetToDisplay(buildWysiwygModel(nextSource), caretSource); - const nextModel = normalizeInlineBoundaries(nextSource); + let next = ''; - return { - display: nextModel.display, - selection: {end: caret, start: caret}, - value: nextModel.source, - }; -}; + for (let index = 0; index < current.length; index += 1) { + if (!removed[index]) { + next += current[index]!; + } + } -/** - * Apply an inline format toggle from a toolbar while editing in WYSIWYG mode. - * Toggle the selected display characters' formats, serializing only affected - * lines. Block prefixes are kept separate from editable inline content. - */ -export const applyInlineFormatWysiwyg = ( - source: string, - displaySelection: MarkdownSelection, - format: WysiwygInlineFormat, -): WysiwygEditResult => { - const blocks: BlockMarker[] = []; - const model = parseWysiwygModel(source, [], blocks); - const selection = normalizeSelection(model.display, displaySelection); - - if (selection.start === selection.end) { - const inserted = applyDisplayEdit(source, model.display.slice(0, selection.start) + 'text' + model.display.slice(selection.end), selection); - return applyInlineFormatWysiwyg(inserted.value, {start: inserted.selection.start - 4, end: inserted.selection.end}, format); + current = next; } - const characters = getDisplayCharacters(model); - const selected = characters.filter((character, index) => index >= selection.start && index < selection.end && - !model.charSynthetic[index] && (format === 'inline-code' ? character.text !== '\n' : !/\s/.test(character.text))); - if (selected.length === 0) { - return {value: source, display: model.display, selection}; - } - const remove = selected.every((character) => character.formats.includes(format)); - let sourceOffset = 0; - let displayOffset = 0; - const lines = source.split('\n').map((line) => { - const newline = model.display.indexOf('\n', displayOffset); - const displayEnd = newline === -1 ? model.display.length : newline; - const block = blocks.find((candidate) => candidate.sourceStart === sourceOffset); - const contentStart = block?.displayEnd ?? displayOffset; - let result = line; - if (Math.max(selection.start, contentStart) < Math.min(selection.end, displayEnd)) { - const content = characters.slice(contentStart, displayEnd).map((character, index) => { - const offset = contentStart + index; - if (offset < selection.start || offset >= selection.end) { - return character; - } - const formats = character.formats.filter((active) => active !== format); - return {...character, formats: remove ? formats : [...formats, format]}; - }); - result = (block ? source.slice(block.sourceStart, block.sourceEnd) : '') + serializeInlineCharacters(content); - } - sourceOffset += line.length + 1; - displayOffset = displayEnd + 1; - return result; - }); - const value = lines.join('\n'); - return {value, display: buildWysiwygModel(value).display, selection}; + return current; }; From 5d12ee3e7f5aada87e4ef653f62105d82f339669 Mon Sep 17 00:00:00 2001 From: Ron Radtke Date: Thu, 8 Oct 2026 17:17:11 +0200 Subject: [PATCH 4/4] Release 9.1.0: version bump and README entry for MarkdownEditor Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MAgGTHfWZVVvwSK1rJYbVn --- README.md | 5 +++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) 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/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 98380900..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",