react-native-richify 1.0.3 → 1.0.5
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.
- package/lib/commonjs/components/RenderedOutput.js +168 -0
- package/lib/commonjs/components/RenderedOutput.js.map +1 -0
- package/lib/commonjs/components/RichTextInput.js +196 -52
- package/lib/commonjs/components/RichTextInput.js.map +1 -1
- package/lib/commonjs/components/Toolbar.js +41 -2
- package/lib/commonjs/components/Toolbar.js.map +1 -1
- package/lib/commonjs/constants/defaultStyles.js +81 -2
- package/lib/commonjs/constants/defaultStyles.js.map +1 -1
- package/lib/commonjs/hooks/useFormatting.js +46 -2
- package/lib/commonjs/hooks/useFormatting.js.map +1 -1
- package/lib/commonjs/hooks/useRichText.js +130 -12
- package/lib/commonjs/hooks/useRichText.js.map +1 -1
- package/lib/commonjs/index.d.js +19 -0
- package/lib/commonjs/index.d.js.map +1 -1
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/utils/formatter.js +48 -12
- package/lib/commonjs/utils/formatter.js.map +1 -1
- package/lib/commonjs/utils/parser.js +1 -1
- package/lib/commonjs/utils/parser.js.map +1 -1
- package/lib/commonjs/utils/serializer.d.js +6 -0
- package/lib/commonjs/utils/serializer.d.js.map +1 -0
- package/lib/commonjs/utils/serializer.js +259 -0
- package/lib/commonjs/utils/serializer.js.map +1 -0
- package/lib/commonjs/utils/styleMapper.js +11 -0
- package/lib/commonjs/utils/styleMapper.js.map +1 -1
- package/lib/module/components/RenderedOutput.js +163 -0
- package/lib/module/components/RenderedOutput.js.map +1 -0
- package/lib/module/components/RichTextInput.js +198 -55
- package/lib/module/components/RichTextInput.js.map +1 -1
- package/lib/module/components/Toolbar.js +41 -2
- package/lib/module/components/Toolbar.js.map +1 -1
- package/lib/module/constants/defaultStyles.js +81 -2
- package/lib/module/constants/defaultStyles.js.map +1 -1
- package/lib/module/hooks/useFormatting.js +47 -3
- package/lib/module/hooks/useFormatting.js.map +1 -1
- package/lib/module/hooks/useRichText.js +130 -12
- package/lib/module/hooks/useRichText.js.map +1 -1
- package/lib/module/index.d.js +1 -0
- package/lib/module/index.d.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/formatter.js +46 -12
- package/lib/module/utils/formatter.js.map +1 -1
- package/lib/module/utils/parser.js +1 -1
- package/lib/module/utils/parser.js.map +1 -1
- package/lib/module/utils/serializer.d.js +4 -0
- package/lib/module/utils/serializer.d.js.map +1 -0
- package/lib/module/utils/serializer.js +253 -0
- package/lib/module/utils/serializer.js.map +1 -0
- package/lib/module/utils/styleMapper.js +11 -0
- package/lib/module/utils/styleMapper.js.map +1 -1
- package/lib/typescript/src/components/RenderedOutput.d.ts +9 -0
- package/lib/typescript/src/components/RenderedOutput.d.ts.map +1 -0
- package/lib/typescript/src/components/RichTextInput.d.ts +2 -13
- package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -1
- package/lib/typescript/src/components/Toolbar.d.ts.map +1 -1
- package/lib/typescript/src/constants/defaultStyles.d.ts +3 -0
- package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useFormatting.d.ts +4 -1
- package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types/index.d.ts +112 -1
- package/lib/typescript/src/types/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/formatter.d.ts +9 -1
- package/lib/typescript/src/utils/formatter.d.ts.map +1 -1
- package/lib/typescript/src/utils/parser.d.ts.map +1 -1
- package/lib/typescript/src/utils/serializer.d.ts +14 -0
- package/lib/typescript/src/utils/serializer.d.ts.map +1 -0
- package/lib/typescript/src/utils/styleMapper.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/RenderedOutput.tsx +231 -0
- package/src/components/RichTextInput.d.ts +3 -14
- package/src/components/RichTextInput.tsx +291 -56
- package/src/components/Toolbar.tsx +54 -2
- package/src/constants/defaultStyles.d.ts +3 -0
- package/src/constants/defaultStyles.ts +47 -1
- package/src/hooks/useFormatting.ts +89 -2
- package/src/hooks/useRichText.ts +193 -11
- package/src/index.d.ts +2 -1
- package/src/index.ts +10 -0
- package/src/types/index.d.ts +112 -1
- package/src/types/index.ts +123 -1
- package/src/utils/formatter.ts +60 -10
- package/src/utils/parser.ts +6 -1
- package/src/utils/serializer.d.ts +13 -0
- package/src/utils/serializer.ts +365 -0
- package/src/utils/styleMapper.ts +21 -0
package/src/types/index.ts
CHANGED
|
@@ -22,6 +22,21 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
|
|
|
22
22
|
*/
|
|
23
23
|
export type ListType = 'bullet' | 'ordered' | 'none';
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Paragraph alignment presets.
|
|
27
|
+
*/
|
|
28
|
+
export type TextAlign = 'left' | 'center' | 'right';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Serialized output formats supported by the editor.
|
|
32
|
+
*/
|
|
33
|
+
export type OutputFormat = 'markdown' | 'html';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Output preview modes supported by the editor.
|
|
37
|
+
*/
|
|
38
|
+
export type OutputPreviewMode = 'literal' | 'rendered';
|
|
39
|
+
|
|
25
40
|
// ─── Style Types ─────────────────────────────────────────────────────────────
|
|
26
41
|
|
|
27
42
|
/**
|
|
@@ -37,6 +52,11 @@ export interface FormatStyle {
|
|
|
37
52
|
backgroundColor?: string;
|
|
38
53
|
fontSize?: number;
|
|
39
54
|
heading?: HeadingLevel;
|
|
55
|
+
listType?: ListType;
|
|
56
|
+
textAlign?: TextAlign;
|
|
57
|
+
link?: string;
|
|
58
|
+
imageSrc?: string;
|
|
59
|
+
imageAlt?: string;
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
/**
|
|
@@ -91,6 +111,20 @@ export interface RichTextActions {
|
|
|
91
111
|
) => void;
|
|
92
112
|
/** Apply a heading level to the current line. */
|
|
93
113
|
setHeading: (level: HeadingLevel) => void;
|
|
114
|
+
/** Apply a list style to the current line. */
|
|
115
|
+
setListType: (type: ListType) => void;
|
|
116
|
+
/** Apply paragraph alignment to the current line. */
|
|
117
|
+
setTextAlign: (align: TextAlign) => void;
|
|
118
|
+
/** Apply or clear a hyperlink on the current selection. */
|
|
119
|
+
setLink: (url?: string) => void;
|
|
120
|
+
/** Insert an image placeholder into the document. */
|
|
121
|
+
insertImage: (
|
|
122
|
+
source: string,
|
|
123
|
+
options?: {
|
|
124
|
+
alt?: string;
|
|
125
|
+
placeholder?: string;
|
|
126
|
+
},
|
|
127
|
+
) => void;
|
|
94
128
|
/** Set the text color for the current selection. */
|
|
95
129
|
setColor: (color: string) => void;
|
|
96
130
|
/** Set the background color for the current selection. */
|
|
@@ -105,6 +139,8 @@ export interface RichTextActions {
|
|
|
105
139
|
isFormatActive: (format: FormatType) => boolean;
|
|
106
140
|
/** Get the effective shared style at the current cursor/selection. */
|
|
107
141
|
getSelectionStyle: () => FormatStyle;
|
|
142
|
+
/** Serialize the current content as markdown or HTML. */
|
|
143
|
+
getOutput: (format?: OutputFormat) => string;
|
|
108
144
|
/** Get the full plain text content. */
|
|
109
145
|
getPlainText: () => string;
|
|
110
146
|
/** Export the segments as a serializable JSON array. */
|
|
@@ -135,10 +171,18 @@ export interface RichTextTheme {
|
|
|
135
171
|
containerStyle?: ViewStyle;
|
|
136
172
|
/** Style for the TextInput. */
|
|
137
173
|
inputStyle?: TextStyle;
|
|
138
|
-
/** Style for the overlay text container. */
|
|
174
|
+
/** Style for the legacy overlay text container. */
|
|
139
175
|
overlayContainerStyle?: ViewStyle;
|
|
140
176
|
/** Base text style applied to all segments before formatting. */
|
|
141
177
|
baseTextStyle?: TextStyle;
|
|
178
|
+
/** Style for the serialized output container. */
|
|
179
|
+
outputContainerStyle?: ViewStyle;
|
|
180
|
+
/** Label style for the serialized output header. */
|
|
181
|
+
outputLabelStyle?: TextStyle;
|
|
182
|
+
/** Style for the serialized output text. */
|
|
183
|
+
outputTextStyle?: TextStyle;
|
|
184
|
+
/** Style for the rendered output preview content. */
|
|
185
|
+
renderedOutputStyle?: ViewStyle;
|
|
142
186
|
/** Style for the toolbar container. */
|
|
143
187
|
toolbarStyle?: ViewStyle;
|
|
144
188
|
/** Style for toolbar buttons. */
|
|
@@ -165,6 +209,8 @@ export interface RichTextTheme {
|
|
|
165
209
|
toolbarBackground?: string;
|
|
166
210
|
/** Toolbar border color. */
|
|
167
211
|
toolbarBorder?: string;
|
|
212
|
+
/** Default link color. */
|
|
213
|
+
link?: string;
|
|
168
214
|
/** Cursor / caret color. */
|
|
169
215
|
cursor?: ColorValue;
|
|
170
216
|
};
|
|
@@ -184,6 +230,16 @@ export interface ToolbarItem {
|
|
|
184
230
|
format?: FormatType;
|
|
185
231
|
/** The heading level this button sets. */
|
|
186
232
|
heading?: HeadingLevel;
|
|
233
|
+
/** The list type this button sets. */
|
|
234
|
+
listType?: ListType;
|
|
235
|
+
/** The alignment this button sets. */
|
|
236
|
+
textAlign?: TextAlign;
|
|
237
|
+
/** The output format this button toggles to. */
|
|
238
|
+
outputFormat?: OutputFormat;
|
|
239
|
+
/** The output preview mode this button toggles to. */
|
|
240
|
+
outputPreviewMode?: OutputPreviewMode;
|
|
241
|
+
/** Special toolbar action. */
|
|
242
|
+
actionType?: 'link' | 'image';
|
|
187
243
|
/** Custom action handler (overrides default behavior). */
|
|
188
244
|
onPress?: () => void;
|
|
189
245
|
/** Whether this item is currently active. */
|
|
@@ -208,6 +264,38 @@ export interface ToolbarRenderProps {
|
|
|
208
264
|
items: ToolbarItem[];
|
|
209
265
|
state: RichTextState;
|
|
210
266
|
actions: RichTextActions;
|
|
267
|
+
outputFormat: OutputFormat;
|
|
268
|
+
outputPreviewMode: OutputPreviewMode;
|
|
269
|
+
onOutputFormatChange: (format: OutputFormat) => void;
|
|
270
|
+
onOutputPreviewModeChange: (mode: OutputPreviewMode) => void;
|
|
271
|
+
onRequestLink?: () => void;
|
|
272
|
+
onRequestImage?: () => void;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Payload passed when the built-in link button requests a URL.
|
|
277
|
+
*/
|
|
278
|
+
export interface LinkRequestPayload {
|
|
279
|
+
/** Selected plain text at the time of the request. */
|
|
280
|
+
selectedText: string;
|
|
281
|
+
/** Existing URL on the selection, when present. */
|
|
282
|
+
currentUrl?: string;
|
|
283
|
+
/** Apply or clear the URL on the current selection. */
|
|
284
|
+
applyLink: (url?: string) => void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Payload passed when the built-in image button requests an image source.
|
|
289
|
+
*/
|
|
290
|
+
export interface ImageRequestPayload {
|
|
291
|
+
/** Insert an image placeholder into the document. */
|
|
292
|
+
insertImage: (
|
|
293
|
+
source: string,
|
|
294
|
+
options?: {
|
|
295
|
+
alt?: string;
|
|
296
|
+
placeholder?: string;
|
|
297
|
+
},
|
|
298
|
+
) => void;
|
|
211
299
|
}
|
|
212
300
|
|
|
213
301
|
// ─── Component Props ─────────────────────────────────────────────────────────
|
|
@@ -254,6 +342,18 @@ export interface ToolbarProps {
|
|
|
254
342
|
theme?: RichTextTheme;
|
|
255
343
|
/** Whether to show the toolbar. */
|
|
256
344
|
visible?: boolean;
|
|
345
|
+
/** Currently selected serialized output format. */
|
|
346
|
+
outputFormat?: OutputFormat;
|
|
347
|
+
/** Currently selected preview mode. */
|
|
348
|
+
outputPreviewMode?: OutputPreviewMode;
|
|
349
|
+
/** Called when the output format changes from the toolbar. */
|
|
350
|
+
onOutputFormatChange?: (format: OutputFormat) => void;
|
|
351
|
+
/** Called when the preview mode changes from the toolbar. */
|
|
352
|
+
onOutputPreviewModeChange?: (mode: OutputPreviewMode) => void;
|
|
353
|
+
/** Called when the link button is pressed. */
|
|
354
|
+
onRequestLink?: () => void;
|
|
355
|
+
/** Called when the image button is pressed. */
|
|
356
|
+
onRequestImage?: () => void;
|
|
257
357
|
/** Custom render function for the entire toolbar. */
|
|
258
358
|
renderToolbar?: (props: ToolbarRenderProps) => React.ReactElement | null;
|
|
259
359
|
}
|
|
@@ -282,6 +382,28 @@ export interface RichTextInputProps {
|
|
|
282
382
|
toolbarItems?: ToolbarItem[];
|
|
283
383
|
/** Theme configuration. */
|
|
284
384
|
theme?: RichTextTheme;
|
|
385
|
+
/** Whether to show the serialized output preview below the input. */
|
|
386
|
+
showOutputPreview?: boolean;
|
|
387
|
+
/** Controlled format used for the serialized output preview. */
|
|
388
|
+
outputFormat?: OutputFormat;
|
|
389
|
+
/** Initial format used for the serialized output preview. */
|
|
390
|
+
defaultOutputFormat?: OutputFormat;
|
|
391
|
+
/** Controlled preview mode for the output panel. */
|
|
392
|
+
outputPreviewMode?: OutputPreviewMode;
|
|
393
|
+
/** Initial preview mode for the output panel. */
|
|
394
|
+
defaultOutputPreviewMode?: OutputPreviewMode;
|
|
395
|
+
/** Maximum height for the output preview panel. */
|
|
396
|
+
maxOutputHeight?: number;
|
|
397
|
+
/** Callback when the serialized output changes. */
|
|
398
|
+
onChangeOutput?: (output: string, format: OutputFormat) => void;
|
|
399
|
+
/** Callback when the output format changes. */
|
|
400
|
+
onChangeOutputFormat?: (format: OutputFormat) => void;
|
|
401
|
+
/** Callback when the output preview mode changes. */
|
|
402
|
+
onChangeOutputPreviewMode?: (mode: OutputPreviewMode) => void;
|
|
403
|
+
/** Invoked when the built-in link button needs a URL. */
|
|
404
|
+
onRequestLink?: (payload: LinkRequestPayload) => void;
|
|
405
|
+
/** Invoked when the built-in image button needs an image source. */
|
|
406
|
+
onRequestImage?: (payload: ImageRequestPayload) => void;
|
|
285
407
|
/** Whether multiline input is enabled. */
|
|
286
408
|
multiline?: boolean;
|
|
287
409
|
/** Minimum height for the input area. */
|
package/src/utils/formatter.ts
CHANGED
|
@@ -3,16 +3,16 @@ import type {
|
|
|
3
3
|
FormatType,
|
|
4
4
|
FormatStyle,
|
|
5
5
|
HeadingLevel,
|
|
6
|
+
ListType,
|
|
6
7
|
SelectionRange,
|
|
8
|
+
TextAlign,
|
|
7
9
|
} from '../types';
|
|
8
10
|
import {
|
|
9
11
|
createSegment,
|
|
10
12
|
findPositionInSegments,
|
|
11
|
-
splitSegment,
|
|
12
13
|
mergeAdjacentSegments,
|
|
13
14
|
segmentsToPlainText,
|
|
14
15
|
} from '../utils/parser';
|
|
15
|
-
import { HEADING_FONT_SIZES } from '../constants/defaultStyles';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Toggle an inline format (bold, italic, etc.) on the selected range.
|
|
@@ -69,16 +69,37 @@ export function setHeadingOnLine(
|
|
|
69
69
|
selection: SelectionRange,
|
|
70
70
|
level: HeadingLevel,
|
|
71
71
|
): StyledSegment[] {
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
return setLineStyleOnSelection(segments, selection, {
|
|
73
|
+
heading: level === 'none' ? undefined : level,
|
|
74
|
+
listType: level === 'none' ? undefined : undefined,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Apply a list type to the lines containing the cursor/selection.
|
|
80
|
+
*/
|
|
81
|
+
export function setListTypeOnLine(
|
|
82
|
+
segments: StyledSegment[],
|
|
83
|
+
selection: SelectionRange,
|
|
84
|
+
listType: ListType,
|
|
85
|
+
): StyledSegment[] {
|
|
86
|
+
return setLineStyleOnSelection(segments, selection, {
|
|
87
|
+
listType: listType === 'none' ? undefined : listType,
|
|
88
|
+
heading: listType === 'none' ? undefined : undefined,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
80
91
|
|
|
81
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Apply text alignment to the lines containing the cursor/selection.
|
|
94
|
+
*/
|
|
95
|
+
export function setTextAlignOnLine(
|
|
96
|
+
segments: StyledSegment[],
|
|
97
|
+
selection: SelectionRange,
|
|
98
|
+
textAlign: TextAlign,
|
|
99
|
+
): StyledSegment[] {
|
|
100
|
+
return setLineStyleOnSelection(segments, selection, {
|
|
101
|
+
textAlign,
|
|
102
|
+
});
|
|
82
103
|
}
|
|
83
104
|
|
|
84
105
|
/**
|
|
@@ -142,6 +163,11 @@ export function getSelectionStyle(
|
|
|
142
163
|
result.backgroundColor = undefined;
|
|
143
164
|
if (result.fontSize !== s.fontSize) result.fontSize = undefined;
|
|
144
165
|
if (result.heading !== s.heading) result.heading = undefined;
|
|
166
|
+
if (result.listType !== s.listType) result.listType = undefined;
|
|
167
|
+
if (result.textAlign !== s.textAlign) result.textAlign = undefined;
|
|
168
|
+
if (result.link !== s.link) result.link = undefined;
|
|
169
|
+
if (result.imageSrc !== s.imageSrc) result.imageSrc = undefined;
|
|
170
|
+
if (result.imageAlt !== s.imageAlt) result.imageAlt = undefined;
|
|
145
171
|
}
|
|
146
172
|
|
|
147
173
|
return result;
|
|
@@ -252,6 +278,17 @@ function applyStyleToRange(
|
|
|
252
278
|
return mergeAdjacentSegments(result);
|
|
253
279
|
}
|
|
254
280
|
|
|
281
|
+
function setLineStyleOnSelection(
|
|
282
|
+
segments: StyledSegment[],
|
|
283
|
+
selection: SelectionRange,
|
|
284
|
+
styleDelta: Partial<FormatStyle>,
|
|
285
|
+
): StyledSegment[] {
|
|
286
|
+
const plainText = segmentsToPlainText(segments);
|
|
287
|
+
const { lineStart, lineEnd } = getSelectionLineRange(plainText, selection);
|
|
288
|
+
|
|
289
|
+
return applyStyleToRange(segments, lineStart, lineEnd, styleDelta);
|
|
290
|
+
}
|
|
291
|
+
|
|
255
292
|
/**
|
|
256
293
|
* Get the line start and end positions for the line containing the given position.
|
|
257
294
|
*/
|
|
@@ -272,5 +309,18 @@ function getLineRange(
|
|
|
272
309
|
return { lineStart, lineEnd };
|
|
273
310
|
}
|
|
274
311
|
|
|
312
|
+
function getSelectionLineRange(
|
|
313
|
+
text: string,
|
|
314
|
+
selection: SelectionRange,
|
|
315
|
+
): { lineStart: number; lineEnd: number } {
|
|
316
|
+
const normalized = normalizeSelection(selection);
|
|
317
|
+
const lineStart = getLineRange(text, normalized.start).lineStart;
|
|
318
|
+
const effectiveEnd =
|
|
319
|
+
normalized.end > normalized.start ? normalized.end - 1 : normalized.end;
|
|
320
|
+
const lineEnd = getLineRange(text, effectiveEnd).lineEnd;
|
|
321
|
+
|
|
322
|
+
return { lineStart, lineEnd };
|
|
323
|
+
}
|
|
324
|
+
|
|
275
325
|
// Re-export for convenience
|
|
276
326
|
export { createSegment } from '../utils/parser';
|
package/src/utils/parser.ts
CHANGED
|
@@ -77,7 +77,12 @@ export function areStylesEqual(a: FormatStyle, b: FormatStyle): boolean {
|
|
|
77
77
|
(a.color ?? undefined) === (b.color ?? undefined) &&
|
|
78
78
|
(a.backgroundColor ?? undefined) === (b.backgroundColor ?? undefined) &&
|
|
79
79
|
(a.fontSize ?? undefined) === (b.fontSize ?? undefined) &&
|
|
80
|
-
(a.heading ?? undefined) === (b.heading ?? undefined)
|
|
80
|
+
(a.heading ?? undefined) === (b.heading ?? undefined) &&
|
|
81
|
+
(a.listType ?? undefined) === (b.listType ?? undefined) &&
|
|
82
|
+
(a.textAlign ?? undefined) === (b.textAlign ?? undefined) &&
|
|
83
|
+
(a.link ?? undefined) === (b.link ?? undefined) &&
|
|
84
|
+
(a.imageSrc ?? undefined) === (b.imageSrc ?? undefined) &&
|
|
85
|
+
(a.imageAlt ?? undefined) === (b.imageAlt ?? undefined)
|
|
81
86
|
);
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OutputFormat, StyledSegment } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Serialize styled segments as Markdown or HTML.
|
|
4
|
+
*/
|
|
5
|
+
export declare function serializeSegments(segments: StyledSegment[], format?: OutputFormat): string;
|
|
6
|
+
/**
|
|
7
|
+
* Convenience wrapper for Markdown output.
|
|
8
|
+
*/
|
|
9
|
+
export declare function segmentsToMarkdown(segments: StyledSegment[]): string;
|
|
10
|
+
/**
|
|
11
|
+
* Convenience wrapper for HTML output.
|
|
12
|
+
*/
|
|
13
|
+
export declare function segmentsToHTML(segments: StyledSegment[]): string;
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FormatStyle,
|
|
3
|
+
HeadingLevel,
|
|
4
|
+
ListType,
|
|
5
|
+
OutputFormat,
|
|
6
|
+
StyledSegment,
|
|
7
|
+
TextAlign,
|
|
8
|
+
} from '../types';
|
|
9
|
+
|
|
10
|
+
type LineFragment = Pick<StyledSegment, 'text' | 'styles'>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Serialize styled segments as Markdown or HTML.
|
|
14
|
+
*/
|
|
15
|
+
export function serializeSegments(
|
|
16
|
+
segments: StyledSegment[],
|
|
17
|
+
format: OutputFormat = 'markdown',
|
|
18
|
+
): string {
|
|
19
|
+
const lines = splitSegmentsByLine(segments);
|
|
20
|
+
const blocks: string[] = [];
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < lines.length; ) {
|
|
23
|
+
const line = lines[index];
|
|
24
|
+
const listType = getLineListType(line);
|
|
25
|
+
|
|
26
|
+
if (listType && listType !== 'none') {
|
|
27
|
+
const listLines: LineFragment[][] = [];
|
|
28
|
+
|
|
29
|
+
while (index < lines.length && getLineListType(lines[index]) === listType) {
|
|
30
|
+
listLines.push(lines[index]);
|
|
31
|
+
index++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
blocks.push(serializeListBlock(listLines, format, listType));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
blocks.push(serializeBlockLine(line, format));
|
|
39
|
+
index++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return blocks.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convenience wrapper for Markdown output.
|
|
47
|
+
*/
|
|
48
|
+
export function segmentsToMarkdown(segments: StyledSegment[]): string {
|
|
49
|
+
return serializeSegments(segments, 'markdown');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convenience wrapper for HTML output.
|
|
54
|
+
*/
|
|
55
|
+
export function segmentsToHTML(segments: StyledSegment[]): string {
|
|
56
|
+
return serializeSegments(segments, 'html');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function splitSegmentsByLine(segments: StyledSegment[]): LineFragment[][] {
|
|
60
|
+
const lines: LineFragment[][] = [[]];
|
|
61
|
+
|
|
62
|
+
for (const segment of segments) {
|
|
63
|
+
const parts = segment.text.split('\n');
|
|
64
|
+
|
|
65
|
+
parts.forEach((part, index) => {
|
|
66
|
+
if (part.length > 0 || segment.styles.imageSrc) {
|
|
67
|
+
lines[lines.length - 1]?.push({
|
|
68
|
+
text: part,
|
|
69
|
+
styles: { ...segment.styles },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (index < parts.length - 1) {
|
|
74
|
+
lines.push([]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function serializeListBlock(
|
|
83
|
+
lines: LineFragment[][],
|
|
84
|
+
format: OutputFormat,
|
|
85
|
+
listType: ListType,
|
|
86
|
+
): string {
|
|
87
|
+
if (format === 'html' || lines.some((line) => !!getLineTextAlign(line))) {
|
|
88
|
+
const tag = listType === 'ordered' ? 'ol' : 'ul';
|
|
89
|
+
const items = lines.map((line) => serializeHtmlListItem(line)).join('');
|
|
90
|
+
return `<${tag}>${items}</${tag}>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return lines
|
|
94
|
+
.map((line, index) => {
|
|
95
|
+
const marker = listType === 'ordered' ? `${index + 1}.` : '-';
|
|
96
|
+
const content = serializeLineContent(line, format);
|
|
97
|
+
return content.length > 0 ? `${marker} ${content}` : marker;
|
|
98
|
+
})
|
|
99
|
+
.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function serializeHtmlListItem(line: LineFragment[]): string {
|
|
103
|
+
const content = serializeLineContent(line, 'html');
|
|
104
|
+
const styleAttribute = buildBlockStyle(getLineTextAlign(line));
|
|
105
|
+
return `<li${styleAttribute ? ` style="${styleAttribute}"` : ''}>${content}</li>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function serializeBlockLine(
|
|
109
|
+
line: LineFragment[],
|
|
110
|
+
format: OutputFormat,
|
|
111
|
+
): string {
|
|
112
|
+
const heading = getLineHeading(line);
|
|
113
|
+
const textAlign = getLineTextAlign(line);
|
|
114
|
+
const content = serializeLineContent(line, format, heading);
|
|
115
|
+
|
|
116
|
+
if (format === 'html') {
|
|
117
|
+
const blockTag = heading ?? 'p';
|
|
118
|
+
const styleAttribute = buildBlockStyle(textAlign);
|
|
119
|
+
return `<${blockTag}${styleAttribute ? ` style="${styleAttribute}"` : ''}>${content}</${blockTag}>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (textAlign) {
|
|
123
|
+
return serializeAlignedMarkdownLine(content, heading, textAlign);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const headingPrefix = getHeadingPrefix(heading);
|
|
127
|
+
if (!headingPrefix) {
|
|
128
|
+
return content;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return content.length > 0 ? `${headingPrefix} ${content}` : headingPrefix;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function serializeAlignedMarkdownLine(
|
|
135
|
+
content: string,
|
|
136
|
+
heading: HeadingLevel | undefined,
|
|
137
|
+
textAlign: TextAlign,
|
|
138
|
+
): string {
|
|
139
|
+
const blockTag = heading ?? 'p';
|
|
140
|
+
const styleAttribute = buildBlockStyle(textAlign);
|
|
141
|
+
return `<${blockTag} style="${styleAttribute}">${content}</${blockTag}>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function serializeLineContent(
|
|
145
|
+
line: LineFragment[],
|
|
146
|
+
format: OutputFormat,
|
|
147
|
+
lineHeading?: HeadingLevel,
|
|
148
|
+
): string {
|
|
149
|
+
return line
|
|
150
|
+
.map((fragment) => serializeFragment(fragment, format, lineHeading))
|
|
151
|
+
.join('');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function serializeFragment(
|
|
155
|
+
fragment: LineFragment,
|
|
156
|
+
format: OutputFormat,
|
|
157
|
+
lineHeading?: HeadingLevel,
|
|
158
|
+
): string {
|
|
159
|
+
if (fragment.styles.imageSrc) {
|
|
160
|
+
return serializeImageFragment(fragment, format);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const normalizedStyles: FormatStyle = {
|
|
164
|
+
...fragment.styles,
|
|
165
|
+
heading: undefined,
|
|
166
|
+
listType: undefined,
|
|
167
|
+
textAlign: undefined,
|
|
168
|
+
imageSrc: undefined,
|
|
169
|
+
imageAlt: undefined,
|
|
170
|
+
bold:
|
|
171
|
+
lineHeading && lineHeading !== 'none' ? false : fragment.styles.bold,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return format === 'html'
|
|
175
|
+
? serializeHtmlFragment(fragment.text, normalizedStyles)
|
|
176
|
+
: serializeMarkdownFragment(fragment.text, normalizedStyles);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function serializeImageFragment(
|
|
180
|
+
fragment: LineFragment,
|
|
181
|
+
format: OutputFormat,
|
|
182
|
+
): string {
|
|
183
|
+
const source = fragment.styles.imageSrc ?? '';
|
|
184
|
+
const altText = fragment.styles.imageAlt ?? extractImageAlt(fragment.text);
|
|
185
|
+
|
|
186
|
+
if (format === 'html') {
|
|
187
|
+
return `<img src="${escapeHtml(source)}" alt="${escapeHtml(altText)}" />`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return `})`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function serializeHtmlFragment(text: string, styles: FormatStyle): string {
|
|
194
|
+
let result = escapeHtml(text);
|
|
195
|
+
|
|
196
|
+
if (styles.code) {
|
|
197
|
+
result = `<code>${result}</code>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (styles.bold) {
|
|
201
|
+
result = `<strong>${result}</strong>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (styles.italic) {
|
|
205
|
+
result = `<em>${result}</em>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (styles.underline) {
|
|
209
|
+
result = `<u>${result}</u>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (styles.strikethrough) {
|
|
213
|
+
result = `<s>${result}</s>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const styleAttribute = buildInlineStyle(styles);
|
|
217
|
+
if (styleAttribute) {
|
|
218
|
+
result = `<span style="${styleAttribute}">${result}</span>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (styles.link) {
|
|
222
|
+
result = `<a href="${escapeHtml(styles.link)}">${result}</a>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function serializeMarkdownFragment(text: string, styles: FormatStyle): string {
|
|
229
|
+
let result = escapeMarkdown(text);
|
|
230
|
+
|
|
231
|
+
if (styles.code) {
|
|
232
|
+
result = wrapInlineCode(text);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (styles.bold) {
|
|
236
|
+
result = `**${result}**`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (styles.italic) {
|
|
240
|
+
result = `*${result}*`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (styles.strikethrough) {
|
|
244
|
+
result = `~~${result}~~`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (styles.underline) {
|
|
248
|
+
result = `<u>${result}</u>`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const styleAttribute = buildInlineStyle(styles);
|
|
252
|
+
if (styleAttribute) {
|
|
253
|
+
result = `<span style="${styleAttribute}">${result}</span>`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (styles.link) {
|
|
257
|
+
result = `[${result}](${escapeMarkdownUrl(styles.link)})`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildInlineStyle(styles: FormatStyle): string {
|
|
264
|
+
const cssRules: string[] = [];
|
|
265
|
+
|
|
266
|
+
if (styles.color) {
|
|
267
|
+
cssRules.push(`color: ${styles.color}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (styles.backgroundColor) {
|
|
271
|
+
cssRules.push(`background-color: ${styles.backgroundColor}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (styles.fontSize) {
|
|
275
|
+
cssRules.push(`font-size: ${styles.fontSize}px`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return cssRules.join('; ');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildBlockStyle(textAlign?: TextAlign): string {
|
|
282
|
+
const cssRules: string[] = [];
|
|
283
|
+
|
|
284
|
+
if (textAlign) {
|
|
285
|
+
cssRules.push(`text-align: ${textAlign}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return cssRules.join('; ');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function getLineHeading(line: LineFragment[]): HeadingLevel | undefined {
|
|
292
|
+
for (const fragment of line) {
|
|
293
|
+
if (fragment.styles.heading && fragment.styles.heading !== 'none') {
|
|
294
|
+
return fragment.styles.heading;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function getLineListType(line: LineFragment[]): ListType | undefined {
|
|
302
|
+
for (const fragment of line) {
|
|
303
|
+
if (fragment.styles.listType && fragment.styles.listType !== 'none') {
|
|
304
|
+
return fragment.styles.listType;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getLineTextAlign(line: LineFragment[]): TextAlign | undefined {
|
|
312
|
+
for (const fragment of line) {
|
|
313
|
+
if (fragment.styles.textAlign) {
|
|
314
|
+
return fragment.styles.textAlign;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function getHeadingPrefix(heading?: HeadingLevel): string | undefined {
|
|
322
|
+
switch (heading) {
|
|
323
|
+
case 'h1':
|
|
324
|
+
return '#';
|
|
325
|
+
case 'h2':
|
|
326
|
+
return '##';
|
|
327
|
+
case 'h3':
|
|
328
|
+
return '###';
|
|
329
|
+
default:
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function extractImageAlt(text: string): string {
|
|
335
|
+
const normalized = text.replace(/^\[Image:\s*/i, '').replace(/^\[Image\]/i, '').replace(/\]$/, '').trim();
|
|
336
|
+
return normalized.length > 0 ? normalized : 'image';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function escapeHtml(text: string): string {
|
|
340
|
+
return text
|
|
341
|
+
.replaceAll('&', '&')
|
|
342
|
+
.replaceAll('<', '<')
|
|
343
|
+
.replaceAll('>', '>')
|
|
344
|
+
.replaceAll('"', '"')
|
|
345
|
+
.replaceAll("'", ''');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function escapeMarkdown(text: string): string {
|
|
349
|
+
return text.replace(/([\\`*_~[\]])/g, '\\$1');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function escapeMarkdownUrl(url: string): string {
|
|
353
|
+
return url.replaceAll(' ', '%20').replaceAll(')', '%29');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function wrapInlineCode(text: string): string {
|
|
357
|
+
const matches = text.match(/`+/g);
|
|
358
|
+
const longestBacktickRun = matches?.reduce(
|
|
359
|
+
(max, match) => Math.max(max, match.length),
|
|
360
|
+
0,
|
|
361
|
+
) ?? 0;
|
|
362
|
+
const fence = '`'.repeat(longestBacktickRun + 1);
|
|
363
|
+
|
|
364
|
+
return `${fence}${text}${fence}`;
|
|
365
|
+
}
|