@domenico-esposito/react-native-markdown-editor 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.eslintrc.js +5 -0
  2. package/README.md +265 -0
  3. package/build/MarkdownRenderer.d.ts +12 -0
  4. package/build/MarkdownRenderer.d.ts.map +1 -0
  5. package/build/MarkdownRenderer.js +165 -0
  6. package/build/MarkdownRenderer.js.map +1 -0
  7. package/build/MarkdownTextInput.d.ts +10 -0
  8. package/build/MarkdownTextInput.d.ts.map +1 -0
  9. package/build/MarkdownTextInput.js +233 -0
  10. package/build/MarkdownTextInput.js.map +1 -0
  11. package/build/MarkdownToolbar.d.ts +11 -0
  12. package/build/MarkdownToolbar.d.ts.map +1 -0
  13. package/build/MarkdownToolbar.js +98 -0
  14. package/build/MarkdownToolbar.js.map +1 -0
  15. package/build/index.d.ts +14 -0
  16. package/build/index.d.ts.map +1 -0
  17. package/build/index.js +11 -0
  18. package/build/index.js.map +1 -0
  19. package/build/markdownCore.types.d.ts +321 -0
  20. package/build/markdownCore.types.d.ts.map +1 -0
  21. package/build/markdownCore.types.js +2 -0
  22. package/build/markdownCore.types.js.map +1 -0
  23. package/build/markdownHighlight.d.ts +31 -0
  24. package/build/markdownHighlight.d.ts.map +1 -0
  25. package/build/markdownHighlight.js +378 -0
  26. package/build/markdownHighlight.js.map +1 -0
  27. package/build/markdownHighlight.types.d.ts +48 -0
  28. package/build/markdownHighlight.types.d.ts.map +1 -0
  29. package/build/markdownHighlight.types.js +9 -0
  30. package/build/markdownHighlight.types.js.map +1 -0
  31. package/build/markdownParser.d.ts +16 -0
  32. package/build/markdownParser.d.ts.map +1 -0
  33. package/build/markdownParser.js +309 -0
  34. package/build/markdownParser.js.map +1 -0
  35. package/build/markdownRendererDefaults.d.ts +113 -0
  36. package/build/markdownRendererDefaults.d.ts.map +1 -0
  37. package/build/markdownRendererDefaults.js +174 -0
  38. package/build/markdownRendererDefaults.js.map +1 -0
  39. package/build/markdownSegment.types.d.ts +22 -0
  40. package/build/markdownSegment.types.d.ts.map +1 -0
  41. package/build/markdownSegment.types.js +2 -0
  42. package/build/markdownSegment.types.js.map +1 -0
  43. package/build/markdownSegmentDefaults.d.ts +43 -0
  44. package/build/markdownSegmentDefaults.d.ts.map +1 -0
  45. package/build/markdownSegmentDefaults.js +176 -0
  46. package/build/markdownSegmentDefaults.js.map +1 -0
  47. package/build/markdownSyntaxUtils.d.ts +58 -0
  48. package/build/markdownSyntaxUtils.d.ts.map +1 -0
  49. package/build/markdownSyntaxUtils.js +98 -0
  50. package/build/markdownSyntaxUtils.js.map +1 -0
  51. package/build/markdownToolbarActions.d.ts +12 -0
  52. package/build/markdownToolbarActions.d.ts.map +1 -0
  53. package/build/markdownToolbarActions.js +212 -0
  54. package/build/markdownToolbarActions.js.map +1 -0
  55. package/build/useMarkdownEditor.d.ts +10 -0
  56. package/build/useMarkdownEditor.d.ts.map +1 -0
  57. package/build/useMarkdownEditor.js +219 -0
  58. package/build/useMarkdownEditor.js.map +1 -0
  59. package/jest.config.js +10 -0
  60. package/package.json +45 -0
  61. package/src/MarkdownRenderer.tsx +240 -0
  62. package/src/MarkdownTextInput.tsx +263 -0
  63. package/src/MarkdownToolbar.tsx +126 -0
  64. package/src/index.ts +31 -0
  65. package/src/markdownCore.types.ts +405 -0
  66. package/src/markdownHighlight.ts +413 -0
  67. package/src/markdownHighlight.types.ts +75 -0
  68. package/src/markdownParser.ts +345 -0
  69. package/src/markdownRendererDefaults.tsx +207 -0
  70. package/src/markdownSegment.types.ts +24 -0
  71. package/src/markdownSegmentDefaults.tsx +208 -0
  72. package/src/markdownSyntaxUtils.ts +139 -0
  73. package/src/markdownToolbarActions.ts +296 -0
  74. package/src/useMarkdownEditor.ts +265 -0
  75. package/tsconfig.json +9 -0
  76. package/tsconfig.test.json +8 -0
@@ -0,0 +1,208 @@
1
+ import * as React from 'react';
2
+ import { Text } from 'react-native';
3
+ import type { TextStyle } from 'react-native';
4
+
5
+ import type { HighlightSegmentType } from './markdownHighlight.types';
6
+ import type { SegmentComponentProps } from './markdownSegment.types';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Default styling values
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const COLOR_TEXT = '#1f1f1f';
13
+ const COLOR_DELIMITER = '#a0a0a0';
14
+ const COLOR_CODE = '#c7254e';
15
+ const COLOR_CODE_BG = '#f9f2f4';
16
+ const COLOR_LINK = '#2d5eff';
17
+ const COLOR_LINK_URL = '#8a8a8a';
18
+ const COLOR_IMAGE = '#2d5eff';
19
+ const COLOR_QUOTE = '#6a737d';
20
+ const COLOR_QUOTE_MARKER = '#a0a0a0';
21
+ const COLOR_LIST_MARKER = '#2d5eff';
22
+ const COLOR_HORIZONTAL_RULE = '#a0a0a0';
23
+
24
+ const FONT_SIZE = 16;
25
+ const FONT_SIZE_HEADING = Math.round(FONT_SIZE * 1.75);
26
+ const FONT_SIZE_CODE = Math.round(FONT_SIZE * 0.875);
27
+ const LINE_HEIGHT = computeLineHeight(FONT_SIZE);
28
+ const LINE_HEIGHT_CODE = computeLineHeight(FONT_SIZE_CODE);
29
+
30
+ function computeLineHeight(fontSize: number): number {
31
+ return Math.ceil(fontSize * 1.5);
32
+ }
33
+
34
+ function getHeadingFontSize(level: number): number {
35
+ if (level < 1 || level > 6) return FONT_SIZE;
36
+ const t = (level - 1) / 5;
37
+ return Math.round(FONT_SIZE_HEADING + t * (FONT_SIZE - FONT_SIZE_HEADING));
38
+ }
39
+
40
+ function getHeadingMetrics(meta?: Record<string, string>) {
41
+ const level = Number.parseInt(meta?.headingLevel ?? '1', 10);
42
+ const fontSize = getHeadingFontSize(Number.isNaN(level) ? 1 : level);
43
+ return {
44
+ fontSize,
45
+ lineHeight: computeLineHeight(fontSize),
46
+ };
47
+ }
48
+
49
+ export function getDefaultSegmentStyle(type: HighlightSegmentType, meta?: Record<string, string>): TextStyle {
50
+ const style: TextStyle = {
51
+ color: COLOR_TEXT,
52
+ fontSize: FONT_SIZE,
53
+ lineHeight: LINE_HEIGHT,
54
+ includeFontPadding: false,
55
+ };
56
+
57
+ switch (type) {
58
+ case 'delimiter':
59
+ style.color = COLOR_DELIMITER;
60
+ break;
61
+ case 'heading':
62
+ style.fontWeight = 'bold';
63
+ break;
64
+ case 'bold':
65
+ style.fontWeight = 'bold';
66
+ break;
67
+ case 'italic':
68
+ style.fontStyle = 'italic';
69
+ break;
70
+ case 'strikethrough':
71
+ style.textDecorationLine = 'line-through';
72
+ break;
73
+ case 'code':
74
+ style.color = COLOR_CODE;
75
+ style.backgroundColor = COLOR_CODE_BG;
76
+ style.fontFamily = 'Courier';
77
+ style.fontSize = FONT_SIZE_CODE;
78
+ style.lineHeight = LINE_HEIGHT_CODE;
79
+ break;
80
+ case 'codeBlock':
81
+ style.color = COLOR_CODE;
82
+ style.fontFamily = 'Courier';
83
+ style.fontSize = FONT_SIZE_CODE;
84
+ style.lineHeight = LINE_HEIGHT_CODE;
85
+ break;
86
+ case 'link':
87
+ style.color = COLOR_LINK;
88
+ break;
89
+ case 'linkUrl':
90
+ style.color = COLOR_LINK_URL;
91
+ break;
92
+ case 'image':
93
+ style.color = COLOR_IMAGE;
94
+ break;
95
+ case 'quote':
96
+ style.color = COLOR_QUOTE;
97
+ break;
98
+ case 'quoteMarker':
99
+ style.color = COLOR_QUOTE_MARKER;
100
+ break;
101
+ case 'listMarker':
102
+ style.color = COLOR_LIST_MARKER;
103
+ style.fontWeight = 'bold';
104
+ break;
105
+ case 'horizontalRule':
106
+ style.color = COLOR_HORIZONTAL_RULE;
107
+ style.letterSpacing = 4;
108
+ style.textAlign = 'center';
109
+ break;
110
+ case 'text':
111
+ default:
112
+ break;
113
+ }
114
+
115
+ const lineContext = meta?.lineContext;
116
+ if (lineContext === 'heading') {
117
+ const { fontSize, lineHeight } = getHeadingMetrics(meta);
118
+ style.fontSize = fontSize;
119
+ style.lineHeight = lineHeight;
120
+ style.fontWeight = 'bold';
121
+ }
122
+
123
+ if (lineContext === 'quote' && (type === 'text' || type === 'bold' || type === 'italic' || type === 'strikethrough')) {
124
+ style.color = COLOR_QUOTE;
125
+ }
126
+
127
+ if (lineContext === 'codeFence' && type === 'delimiter') {
128
+ style.fontSize = FONT_SIZE_CODE;
129
+ style.lineHeight = LINE_HEIGHT_CODE;
130
+ }
131
+
132
+ return style;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Default components
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /** Default renderer - a plain Text with the default style for the segment type. */
140
+ function DefaultTextSegment({ type, meta, children }: SegmentComponentProps) {
141
+ return <Text style={getDefaultSegmentStyle(type, meta)}>{children}</Text>;
142
+ }
143
+
144
+ export const DefaultSegment = DefaultTextSegment;
145
+
146
+ /** Default bold - renders bold text. */
147
+ export const DefaultBoldSegment = DefaultTextSegment;
148
+
149
+ /** Default heading - renders heading text. */
150
+ export const DefaultHeadingSegment = DefaultTextSegment;
151
+
152
+ /** Default italic - renders italic text. */
153
+ export const DefaultItalicSegment = DefaultTextSegment;
154
+
155
+ /** Default strikethrough - renders strikethrough text. */
156
+ export const DefaultStrikethroughSegment = DefaultTextSegment;
157
+
158
+ /** Default inline code - renders code text. */
159
+ export const DefaultCodeSegment = DefaultTextSegment;
160
+
161
+ /** Default code block - renders code block content. */
162
+ export const DefaultCodeBlockSegment = DefaultTextSegment;
163
+
164
+ /** Default link label - renders link text. */
165
+ export const DefaultLinkSegment = DefaultTextSegment;
166
+
167
+ /** Default link URL - renders link URL text. */
168
+ export const DefaultLinkUrlSegment = DefaultTextSegment;
169
+
170
+ /** Default image - renders image markdown text. */
171
+ export const DefaultImageSegment = DefaultTextSegment;
172
+
173
+ /** Default blockquote - renders quote text. */
174
+ export const DefaultQuoteSegment = DefaultTextSegment;
175
+
176
+ /** Default quote marker - renders the > character. */
177
+ export const DefaultQuoteMarkerSegment = DefaultTextSegment;
178
+
179
+ /** Default delimiter - renders markdown syntax characters. */
180
+ export const DefaultDelimiterSegment = DefaultTextSegment;
181
+
182
+ /** Default list marker - renders list bullet/number. */
183
+ export const DefaultListMarkerSegment = DefaultTextSegment;
184
+
185
+ /** Default horizontal rule - renders the rule markers (---, ***, ___). */
186
+ export const DefaultHorizontalRuleSegment = DefaultTextSegment;
187
+
188
+ /**
189
+ * Complete map of default segment components.
190
+ * Used as fallback when no custom component is provided for a type.
191
+ */
192
+ export const DEFAULT_SEGMENT_COMPONENTS: Record<HighlightSegmentType, React.ComponentType<SegmentComponentProps>> = {
193
+ text: DefaultSegment,
194
+ delimiter: DefaultDelimiterSegment,
195
+ heading: DefaultHeadingSegment,
196
+ bold: DefaultBoldSegment,
197
+ italic: DefaultItalicSegment,
198
+ strikethrough: DefaultStrikethroughSegment,
199
+ code: DefaultCodeSegment,
200
+ codeBlock: DefaultCodeBlockSegment,
201
+ link: DefaultLinkSegment,
202
+ linkUrl: DefaultLinkUrlSegment,
203
+ image: DefaultImageSegment,
204
+ quote: DefaultQuoteSegment,
205
+ quoteMarker: DefaultQuoteMarkerSegment,
206
+ listMarker: DefaultListMarkerSegment,
207
+ horizontalRule: DefaultHorizontalRuleSegment,
208
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Low-level helpers shared by the markdown parser and the syntax highlighter.
3
+ *
4
+ * These utilities deal with escape sequences, token scanning, and
5
+ * image-source extraction — concerns common to both modules.
6
+ */
7
+
8
+ import type { MarkdownToolbarAction } from './markdownCore.types';
9
+
10
+ /** Matches a backslash followed by a markdown-special character. */
11
+ const ESCAPED_MARKDOWN_PATTERN = /\\([\\`*_~[\]()#+.!>{}-])/g;
12
+
13
+ /**
14
+ * Scans `input` for the first occurrence of `token` starting at `startIndex`,
15
+ * skipping any occurrence preceded by an odd number of backslashes.
16
+ *
17
+ * @returns The index of the token, or `-1` if not found.
18
+ */
19
+ export function findUnescapedToken(input: string, token: string, startIndex: number): number {
20
+ if (token.length === 0) {
21
+ return -1;
22
+ }
23
+
24
+ for (let index = startIndex; index <= input.length - token.length; index += 1) {
25
+ if (input.slice(index, index + token.length) !== token) {
26
+ continue;
27
+ }
28
+ if (!isEscaped(input, index)) {
29
+ return index;
30
+ }
31
+ }
32
+
33
+ return -1;
34
+ }
35
+
36
+ /**
37
+ * Returns `true` when the character at `index` is preceded by an
38
+ * odd number of backslashes (i.e. it is escaped).
39
+ */
40
+ export function isEscaped(value: string, index: number): boolean {
41
+ let backslashes = 0;
42
+ for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor -= 1) {
43
+ backslashes += 1;
44
+ }
45
+ return backslashes % 2 === 1;
46
+ }
47
+
48
+ /**
49
+ * Strips markdown escape backslashes from `value`.
50
+ * E.g. `"\\*hello\\*"` → `"*hello*"`.
51
+ */
52
+ export function unescapeMarkdown(value: string): string {
53
+ return value.replace(ESCAPED_MARKDOWN_PATTERN, '$1');
54
+ }
55
+
56
+ /**
57
+ * Parses the raw content inside `(…)` of an image markdown.
58
+ *
59
+ * Supports:
60
+ * - `url` → `{ src: "url" }`
61
+ * - `url "title"` or `url 'title'` → `{ src: "url", title: "title" }`
62
+ *
63
+ * @param raw - The string between the parentheses.
64
+ * @param options.unescapeSrc - Whether to unescape the source URL (default `true`).
65
+ */
66
+ export function parseImageSourceAndTitle(
67
+ raw: string,
68
+ options: { unescapeSrc?: boolean } = {}
69
+ ): { src: string; title?: string } {
70
+ const { unescapeSrc = true } = options;
71
+ const normalizeSrc = (value: string) => (unescapeSrc ? unescapeMarkdown(value) : value);
72
+ const trimmed = raw.trim();
73
+ const titleMatch = trimmed.match(/^(.+?)\s+(?:"([^"]*)"|'([^']*)')$/);
74
+
75
+ if (titleMatch) {
76
+ return {
77
+ src: normalizeSrc(titleMatch[1].trim()),
78
+ title: titleMatch[2] ?? titleMatch[3],
79
+ };
80
+ }
81
+
82
+ return { src: normalizeSrc(trimmed) };
83
+ }
84
+
85
+ /**
86
+ * Checks whether a markdown feature is enabled based on the provided features list.
87
+ * If `features` is `undefined`, all features are considered enabled (backward compatible).
88
+ *
89
+ * Heading features (`heading`, `heading1`–`heading6`) are grouped: if any heading
90
+ * feature is present the `'heading'` feature is considered enabled.
91
+ */
92
+ export type MarkdownFeature =
93
+ | 'bold'
94
+ | 'italic'
95
+ | 'strikethrough'
96
+ | 'code'
97
+ | 'codeBlock'
98
+ | 'heading'
99
+ | 'quote'
100
+ | 'unorderedList'
101
+ | 'orderedList'
102
+ | 'divider'
103
+ | 'image';
104
+
105
+ export function isMarkdownFeatureEnabled(
106
+ features: MarkdownToolbarAction[] | undefined,
107
+ feature: MarkdownFeature,
108
+ ): boolean {
109
+ if (!features) return true;
110
+ if (feature === 'heading') {
111
+ return features.some(
112
+ (a) =>
113
+ a === 'heading' ||
114
+ a === 'heading1' ||
115
+ a === 'heading2' ||
116
+ a === 'heading3' ||
117
+ a === 'heading4' ||
118
+ a === 'heading5' ||
119
+ a === 'heading6',
120
+ );
121
+ }
122
+ return features.includes(feature as MarkdownToolbarAction);
123
+ }
124
+
125
+ /**
126
+ * Checks whether a specific heading level is enabled.
127
+ *
128
+ * - If `features` is `undefined` → all levels enabled.
129
+ * - If `features` contains `'heading'` (generic) → all levels enabled.
130
+ * - Otherwise only the explicitly listed levels (`'heading1'`…`'heading6'`) are enabled.
131
+ */
132
+ export function isHeadingLevelEnabled(
133
+ features: MarkdownToolbarAction[] | undefined,
134
+ level: number,
135
+ ): boolean {
136
+ if (!features) return true;
137
+ if (features.includes('heading')) return true;
138
+ return features.includes(`heading${level}` as MarkdownToolbarAction);
139
+ }
@@ -0,0 +1,296 @@
1
+ import type {
2
+ MarkdownInlineToolbarAction,
3
+ MarkdownSelection,
4
+ MarkdownToolbarAction,
5
+ MarkdownToolbarActionResult,
6
+ } from './markdownCore.types';
7
+
8
+ export const DEFAULT_MARKDOWN_FEATURES: MarkdownToolbarAction[] = [
9
+ 'bold',
10
+ 'italic',
11
+ 'strikethrough',
12
+ 'code',
13
+ 'codeBlock',
14
+ 'heading1',
15
+ 'heading2',
16
+ 'heading3',
17
+ 'heading4',
18
+ 'heading5',
19
+ 'heading6',
20
+ 'quote',
21
+ 'unorderedList',
22
+ 'orderedList',
23
+ 'divider',
24
+ 'image',
25
+ ];
26
+
27
+ const INLINE_ACTION_MARKERS: Record<MarkdownInlineToolbarAction, { open: string; close: string }> = {
28
+ bold: { open: '**', close: '**' },
29
+ italic: { open: '_', close: '_' },
30
+ strikethrough: { open: '~~', close: '~~' },
31
+ code: { open: '`', close: '`' },
32
+ };
33
+
34
+ /** Parameters accepted by {@link applyMarkdownToolbarAction}. */
35
+ type ApplyMarkdownToolbarActionParams = {
36
+ action: MarkdownToolbarAction;
37
+ text: string;
38
+ selection: MarkdownSelection;
39
+ activeInlineActions?: MarkdownInlineToolbarAction[];
40
+ };
41
+
42
+ export function applyMarkdownToolbarAction({
43
+ action,
44
+ text,
45
+ selection,
46
+ activeInlineActions = [],
47
+ }: ApplyMarkdownToolbarActionParams): MarkdownToolbarActionResult {
48
+ if (action === 'image') {
49
+ return applyImageAction({ text, selection, activeInlineActions });
50
+ }
51
+
52
+ if (isInlineToolbarAction(action)) {
53
+ return applyInlineAction({
54
+ action,
55
+ text,
56
+ selection,
57
+ activeInlineActions,
58
+ });
59
+ }
60
+
61
+ return applyBlockAction({
62
+ action,
63
+ text,
64
+ selection,
65
+ activeInlineActions,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Wraps or unwraps the selected text (or inserts markers at cursor)
71
+ * for an inline formatting action (bold, italic, strikethrough, code).
72
+ */
73
+ function applyInlineAction({
74
+ action,
75
+ text,
76
+ selection,
77
+ activeInlineActions,
78
+ }: {
79
+ action: MarkdownInlineToolbarAction;
80
+ text: string;
81
+ selection: MarkdownSelection;
82
+ activeInlineActions: MarkdownInlineToolbarAction[];
83
+ }): MarkdownToolbarActionResult {
84
+ const marker = INLINE_ACTION_MARKERS[action];
85
+ if (!marker) {
86
+ return {
87
+ text,
88
+ selection,
89
+ activeInlineActions,
90
+ };
91
+ }
92
+
93
+ if (selection.start !== selection.end) {
94
+ const selectedText = text.slice(selection.start, selection.end);
95
+ const nextText = `${text.slice(0, selection.start)}${marker.open}${selectedText}${marker.close}${text.slice(selection.end)}`;
96
+ const markerSize = marker.open.length;
97
+
98
+ return {
99
+ text: nextText,
100
+ selection: {
101
+ start: selection.start + markerSize,
102
+ end: selection.end + markerSize,
103
+ },
104
+ activeInlineActions: activeInlineActions.filter((value) => value !== action),
105
+ };
106
+ }
107
+
108
+ const cursor = selection.start;
109
+ const isActive = activeInlineActions.includes(action);
110
+ const nextMarker = isActive ? marker.close : marker.open;
111
+ const nextText = `${text.slice(0, cursor)}${nextMarker}${text.slice(cursor)}`;
112
+ const nextSelection = {
113
+ start: cursor + nextMarker.length,
114
+ end: cursor + nextMarker.length,
115
+ };
116
+ const nextActiveInlineActions = isActive
117
+ ? activeInlineActions.filter((value) => value !== action)
118
+ : [...activeInlineActions, action];
119
+
120
+ return {
121
+ text: nextText,
122
+ selection: nextSelection,
123
+ activeInlineActions: nextActiveInlineActions,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Applies a block-level toolbar action (heading, quote, list, divider, code block).
129
+ * Identifies the lines covered by the current selection and transforms them.
130
+ */
131
+ function applyBlockAction({
132
+ action,
133
+ text,
134
+ selection,
135
+ activeInlineActions,
136
+ }: {
137
+ action: Exclude<MarkdownToolbarAction, MarkdownInlineToolbarAction>;
138
+ text: string;
139
+ selection: MarkdownSelection;
140
+ activeInlineActions: MarkdownInlineToolbarAction[];
141
+ }): MarkdownToolbarActionResult {
142
+ const lineRange = getSelectedLineRange(text, selection);
143
+ const lines = lineRange.content.split('\n');
144
+ const nextLines = transformLinesByAction(action, lines);
145
+ const nextContent = nextLines.join('\n');
146
+ const nextText = `${text.slice(0, lineRange.start)}${nextContent}${text.slice(lineRange.end)}`;
147
+
148
+ if (selection.start === selection.end) {
149
+ const baseOffset = selection.start - lineRange.start;
150
+ const nextOffset = Math.max(0, Math.min(nextContent.length, baseOffset + (nextContent.length - lineRange.content.length)));
151
+ const cursor = lineRange.start + nextOffset;
152
+ return {
153
+ text: nextText,
154
+ selection: { start: cursor, end: cursor },
155
+ activeInlineActions,
156
+ };
157
+ }
158
+
159
+ return {
160
+ text: nextText,
161
+ selection: {
162
+ start: lineRange.start,
163
+ end: lineRange.start + nextContent.length,
164
+ },
165
+ activeInlineActions,
166
+ };
167
+ }
168
+
169
+ /** Dispatches per-action line transformations. */
170
+ function transformLinesByAction(
171
+ action: Exclude<MarkdownToolbarAction, MarkdownInlineToolbarAction>,
172
+ lines: string[]
173
+ ): string[] {
174
+ switch (action) {
175
+ case 'heading':
176
+ return togglePrefix(lines, /^#{1,6}\s+/, () => '# ');
177
+ case 'heading1':
178
+ return setHeadingLevel(lines, 1);
179
+ case 'heading2':
180
+ return setHeadingLevel(lines, 2);
181
+ case 'heading3':
182
+ return setHeadingLevel(lines, 3);
183
+ case 'heading4':
184
+ return setHeadingLevel(lines, 4);
185
+ case 'heading5':
186
+ return setHeadingLevel(lines, 5);
187
+ case 'heading6':
188
+ return setHeadingLevel(lines, 6);
189
+ case 'quote':
190
+ return togglePrefix(lines, /^>\s?/, () => '> ');
191
+ case 'unorderedList':
192
+ return togglePrefix(lines, /^[-*+]\s+/, () => '- ');
193
+ case 'orderedList':
194
+ return togglePrefix(lines, /^\d+\.\s+/, (index) => `${index + 1}. `);
195
+ case 'divider':
196
+ return lines.every((line) => /^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/.test(line))
197
+ ? lines.map(() => '')
198
+ : lines.map(() => '---');
199
+ case 'codeBlock':
200
+ return toggleCodeBlock(lines);
201
+ default:
202
+ return lines;
203
+ }
204
+ }
205
+
206
+ /** Sets (or removes) a specific heading level on the given lines. */
207
+ function setHeadingLevel(lines: string[], level: 1 | 2 | 3 | 4 | 5 | 6): string[] {
208
+ const prefix = `${'#'.repeat(level)} `;
209
+ const stripHeading = /^#{1,6}\s+/;
210
+ const hasSameLevel = lines.every((line) => new RegExp(`^#{${level}}\\s+`).test(line));
211
+ if (hasSameLevel) {
212
+ return lines.map((line) => line.replace(stripHeading, ''));
213
+ }
214
+ return lines.map((line) => `${prefix}${line.replace(stripHeading, '')}`);
215
+ }
216
+
217
+ /** Toggles a line prefix (e.g. `> `, `- `, `1. `) on every line. */
218
+ function togglePrefix(
219
+ lines: string[],
220
+ stripPattern: RegExp,
221
+ prefixFactory: (index: number) => string
222
+ ): string[] {
223
+ const hasPrefix = lines.every((line) => stripPattern.test(line));
224
+
225
+ if (hasPrefix) {
226
+ return lines.map((line) => line.replace(stripPattern, ''));
227
+ }
228
+
229
+ return lines.map((line, index) => `${prefixFactory(index)}${line}`);
230
+ }
231
+
232
+ /** Wraps or unwraps lines in a fenced code block (` ``` `). */
233
+ function toggleCodeBlock(lines: string[]): string[] {
234
+ const hasCodeBlockMarker = (line: string) => /^```/.test(line);
235
+ const isWrappedInCodeBlock =
236
+ lines.length >= 2 &&
237
+ hasCodeBlockMarker(lines[0]) &&
238
+ hasCodeBlockMarker(lines[lines.length - 1]);
239
+
240
+ if (isWrappedInCodeBlock) {
241
+ return lines.slice(1, -1);
242
+ }
243
+
244
+ return ['```', ...lines, '```'];
245
+ }
246
+
247
+ /**
248
+ * Expands the selection to cover complete lines and returns the
249
+ * start/end offsets together with the extracted content.
250
+ */
251
+ function getSelectedLineRange(text: string, selection: MarkdownSelection): {
252
+ start: number;
253
+ end: number;
254
+ content: string;
255
+ } {
256
+ const safeStart = Math.max(0, Math.min(selection.start, text.length));
257
+ const safeEnd = Math.max(0, Math.min(selection.end, text.length));
258
+ const start = text.lastIndexOf('\n', Math.max(safeStart - 1, 0));
259
+ const end = text.indexOf('\n', safeEnd);
260
+
261
+ const rangeStart = start === -1 ? 0 : start + 1;
262
+ const rangeEnd = end === -1 ? text.length : end;
263
+
264
+ return {
265
+ start: rangeStart,
266
+ end: rangeEnd,
267
+ content: text.slice(rangeStart, rangeEnd),
268
+ };
269
+ }
270
+
271
+ /** Type guard: returns `true` for inline formatting actions. */
272
+ function isInlineToolbarAction(action: MarkdownToolbarAction): action is MarkdownInlineToolbarAction {
273
+ return action === 'bold' || action === 'italic' || action === 'strikethrough' || action === 'code';
274
+ }
275
+
276
+ /** Inserts an empty image markdown template at the current cursor position. */
277
+ function applyImageAction({
278
+ text,
279
+ selection,
280
+ activeInlineActions,
281
+ }: {
282
+ text: string;
283
+ selection: MarkdownSelection;
284
+ activeInlineActions: MarkdownInlineToolbarAction[];
285
+ }): MarkdownToolbarActionResult {
286
+ const template = '![](url)';
287
+ const cursor = selection.start;
288
+ const nextText = `${text.slice(0, cursor)}${template}${text.slice(cursor)}`;
289
+ // Place cursor inside the alt text brackets: ![|](url)
290
+ const nextCursor = cursor + 2;
291
+ return {
292
+ text: nextText,
293
+ selection: { start: nextCursor, end: nextCursor },
294
+ activeInlineActions,
295
+ };
296
+ }