@furystack/shades-common-components 12.2.0 → 12.4.0
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/CHANGELOG.md +56 -0
- package/esm/components/form.d.ts +5 -2
- package/esm/components/form.d.ts.map +1 -1
- package/esm/components/form.js +28 -6
- package/esm/components/form.js.map +1 -1
- package/esm/components/form.spec.js +207 -0
- package/esm/components/form.spec.js.map +1 -1
- package/esm/components/index.d.ts +1 -0
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +1 -0
- package/esm/components/index.js.map +1 -1
- package/esm/components/markdown/index.d.ts +5 -0
- package/esm/components/markdown/index.d.ts.map +1 -0
- package/esm/components/markdown/index.js +5 -0
- package/esm/components/markdown/index.js.map +1 -0
- package/esm/components/markdown/markdown-display.d.ts +19 -0
- package/esm/components/markdown/markdown-display.d.ts.map +1 -0
- package/esm/components/markdown/markdown-display.js +149 -0
- package/esm/components/markdown/markdown-display.js.map +1 -0
- package/esm/components/markdown/markdown-display.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-display.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-display.spec.js +191 -0
- package/esm/components/markdown/markdown-display.spec.js.map +1 -0
- package/esm/components/markdown/markdown-editor.d.ts +25 -0
- package/esm/components/markdown/markdown-editor.d.ts.map +1 -0
- package/esm/components/markdown/markdown-editor.js +113 -0
- package/esm/components/markdown/markdown-editor.js.map +1 -0
- package/esm/components/markdown/markdown-editor.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-editor.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-editor.spec.js +111 -0
- package/esm/components/markdown/markdown-editor.spec.js.map +1 -0
- package/esm/components/markdown/markdown-input.d.ts +29 -0
- package/esm/components/markdown/markdown-input.d.ts.map +1 -0
- package/esm/components/markdown/markdown-input.js +100 -0
- package/esm/components/markdown/markdown-input.js.map +1 -0
- package/esm/components/markdown/markdown-input.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-input.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-input.spec.js +215 -0
- package/esm/components/markdown/markdown-input.spec.js.map +1 -0
- package/esm/components/markdown/markdown-parser.d.ts +82 -0
- package/esm/components/markdown/markdown-parser.d.ts.map +1 -0
- package/esm/components/markdown/markdown-parser.js +274 -0
- package/esm/components/markdown/markdown-parser.js.map +1 -0
- package/esm/components/markdown/markdown-parser.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-parser.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-parser.spec.js +229 -0
- package/esm/components/markdown/markdown-parser.spec.js.map +1 -0
- package/esm/components/styles.d.ts +1 -0
- package/esm/components/styles.d.ts.map +1 -1
- package/esm/components/styles.js.map +1 -1
- package/esm/components/typography.d.ts.map +1 -1
- package/esm/components/typography.js +26 -14
- package/esm/components/typography.js.map +1 -1
- package/esm/services/css-variable-theme.d.ts +3 -0
- package/esm/services/css-variable-theme.d.ts.map +1 -1
- package/esm/services/css-variable-theme.js +3 -0
- package/esm/services/css-variable-theme.js.map +1 -1
- package/esm/services/css-variable-theme.spec.js +3 -0
- package/esm/services/css-variable-theme.spec.js.map +1 -1
- package/esm/services/default-dark-palette.d.ts +8 -0
- package/esm/services/default-dark-palette.d.ts.map +1 -0
- package/esm/services/default-dark-palette.js +56 -0
- package/esm/services/default-dark-palette.js.map +1 -0
- package/esm/services/default-dark-theme.d.ts +3 -0
- package/esm/services/default-dark-theme.d.ts.map +1 -1
- package/esm/services/default-dark-theme.js +7 -4
- package/esm/services/default-dark-theme.js.map +1 -1
- package/esm/services/default-light-theme.d.ts +3 -0
- package/esm/services/default-light-theme.d.ts.map +1 -1
- package/esm/services/default-light-theme.js +3 -0
- package/esm/services/default-light-theme.js.map +1 -1
- package/esm/services/index.d.ts +1 -0
- package/esm/services/index.d.ts.map +1 -1
- package/esm/services/index.js +1 -0
- package/esm/services/index.js.map +1 -1
- package/esm/services/theme-provider-service.d.ts +10 -1
- package/esm/services/theme-provider-service.d.ts.map +1 -1
- package/esm/services/theme-provider-service.js.map +1 -1
- package/package.json +2 -2
- package/src/components/form.spec.tsx +309 -0
- package/src/components/form.tsx +31 -8
- package/src/components/index.ts +1 -0
- package/src/components/markdown/index.ts +4 -0
- package/src/components/markdown/markdown-display.spec.tsx +243 -0
- package/src/components/markdown/markdown-display.tsx +202 -0
- package/src/components/markdown/markdown-editor.spec.tsx +142 -0
- package/src/components/markdown/markdown-editor.tsx +167 -0
- package/src/components/markdown/markdown-input.spec.tsx +274 -0
- package/src/components/markdown/markdown-input.tsx +143 -0
- package/src/components/markdown/markdown-parser.spec.ts +258 -0
- package/src/components/markdown/markdown-parser.ts +333 -0
- package/src/components/styles.tsx +1 -0
- package/src/components/typography.tsx +28 -15
- package/src/services/css-variable-theme.spec.ts +3 -0
- package/src/services/css-variable-theme.ts +3 -0
- package/src/services/default-dark-palette.ts +57 -0
- package/src/services/default-dark-theme.ts +7 -4
- package/src/services/default-light-theme.ts +3 -0
- package/src/services/index.ts +1 -0
- package/src/services/theme-provider-service.ts +7 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject'
|
|
2
|
+
import { createComponent, initializeShadeRoot } from '@furystack/shades'
|
|
3
|
+
import { sleepAsync, usingAsync } from '@furystack/utils'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { MarkdownInput } from './markdown-input.js'
|
|
6
|
+
|
|
7
|
+
describe('MarkdownInput', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
document.body.innerHTML = '<div id="root"></div>'
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = ''
|
|
14
|
+
vi.restoreAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should render with shadow DOM', async () => {
|
|
18
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
19
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
20
|
+
|
|
21
|
+
initializeShadeRoot({
|
|
22
|
+
injector,
|
|
23
|
+
rootElement,
|
|
24
|
+
jsxElement: <MarkdownInput value="" />,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await sleepAsync(50)
|
|
28
|
+
|
|
29
|
+
const el = document.querySelector('shade-markdown-input')
|
|
30
|
+
expect(el).not.toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should render a textarea with the given value', async () => {
|
|
35
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
36
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
37
|
+
|
|
38
|
+
initializeShadeRoot({
|
|
39
|
+
injector,
|
|
40
|
+
rootElement,
|
|
41
|
+
jsxElement: <MarkdownInput value="# Hello" />,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await sleepAsync(50)
|
|
45
|
+
|
|
46
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
47
|
+
expect(textarea).not.toBeNull()
|
|
48
|
+
expect(textarea.value).toBe('# Hello')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should render the label title', async () => {
|
|
53
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
54
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
55
|
+
|
|
56
|
+
initializeShadeRoot({
|
|
57
|
+
injector,
|
|
58
|
+
rootElement,
|
|
59
|
+
jsxElement: <MarkdownInput value="" labelTitle="Markdown Content" />,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
await sleepAsync(50)
|
|
63
|
+
|
|
64
|
+
const label = document.querySelector('shade-markdown-input label')
|
|
65
|
+
expect(label?.textContent).toContain('Markdown Content')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should set placeholder on textarea', async () => {
|
|
70
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
71
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
72
|
+
|
|
73
|
+
initializeShadeRoot({
|
|
74
|
+
injector,
|
|
75
|
+
rootElement,
|
|
76
|
+
jsxElement: <MarkdownInput value="" placeholder="Type markdown..." />,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await sleepAsync(50)
|
|
80
|
+
|
|
81
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
82
|
+
expect(textarea.placeholder).toBe('Type markdown...')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should set data-disabled when disabled', async () => {
|
|
87
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
88
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
89
|
+
|
|
90
|
+
initializeShadeRoot({
|
|
91
|
+
injector,
|
|
92
|
+
rootElement,
|
|
93
|
+
jsxElement: <MarkdownInput value="" disabled />,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
await sleepAsync(50)
|
|
97
|
+
|
|
98
|
+
const wrapper = document.querySelector('shade-markdown-input') as HTMLElement
|
|
99
|
+
expect(wrapper.hasAttribute('data-disabled')).toBe(true)
|
|
100
|
+
|
|
101
|
+
const textarea = wrapper.querySelector('textarea') as HTMLTextAreaElement
|
|
102
|
+
expect(textarea.disabled).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should set readOnly on textarea', async () => {
|
|
107
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
108
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
109
|
+
|
|
110
|
+
initializeShadeRoot({
|
|
111
|
+
injector,
|
|
112
|
+
rootElement,
|
|
113
|
+
jsxElement: <MarkdownInput value="" readOnly />,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await sleepAsync(50)
|
|
117
|
+
|
|
118
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
119
|
+
expect(textarea.readOnly).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should call onValueChange on input event', async () => {
|
|
124
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
125
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
126
|
+
const onValueChange = vi.fn()
|
|
127
|
+
|
|
128
|
+
initializeShadeRoot({
|
|
129
|
+
injector,
|
|
130
|
+
rootElement,
|
|
131
|
+
jsxElement: <MarkdownInput value="" onValueChange={onValueChange} />,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await sleepAsync(50)
|
|
135
|
+
|
|
136
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
137
|
+
textarea.value = '# New content'
|
|
138
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
139
|
+
|
|
140
|
+
await sleepAsync(50)
|
|
141
|
+
|
|
142
|
+
expect(onValueChange).toHaveBeenCalledWith('# New content')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should use custom rows prop', async () => {
|
|
147
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
148
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
149
|
+
|
|
150
|
+
initializeShadeRoot({
|
|
151
|
+
injector,
|
|
152
|
+
rootElement,
|
|
153
|
+
jsxElement: <MarkdownInput value="" rows={20} />,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
await sleepAsync(50)
|
|
157
|
+
|
|
158
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
159
|
+
expect(textarea.rows).toBe(20)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('image paste', () => {
|
|
164
|
+
const createPasteEvent = (items: Array<{ type: string; file: File | null }>) => {
|
|
165
|
+
const pasteEvent = new Event('paste', { bubbles: true, cancelable: true })
|
|
166
|
+
Object.defineProperty(pasteEvent, 'clipboardData', {
|
|
167
|
+
value: {
|
|
168
|
+
items: Object.assign(
|
|
169
|
+
items.map((item) => ({
|
|
170
|
+
type: item.type,
|
|
171
|
+
getAsFile: () => item.file,
|
|
172
|
+
})),
|
|
173
|
+
{ length: items.length },
|
|
174
|
+
),
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
return pasteEvent
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
it('should inline a pasted image as base64 Markdown', async () => {
|
|
181
|
+
const originalFileReader = globalThis.FileReader
|
|
182
|
+
try {
|
|
183
|
+
const fakeBase64 = 'data:image/png;base64,dGVzdA=='
|
|
184
|
+
globalThis.FileReader = class {
|
|
185
|
+
result: string | null = fakeBase64
|
|
186
|
+
onload: (() => void) | null = null
|
|
187
|
+
onerror: (() => void) | null = null
|
|
188
|
+
public readAsDataURL() {
|
|
189
|
+
queueMicrotask(() => this.onload?.())
|
|
190
|
+
}
|
|
191
|
+
} as unknown as typeof FileReader
|
|
192
|
+
|
|
193
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
194
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
195
|
+
const onValueChange = vi.fn()
|
|
196
|
+
|
|
197
|
+
initializeShadeRoot({
|
|
198
|
+
injector,
|
|
199
|
+
rootElement,
|
|
200
|
+
jsxElement: <MarkdownInput value="Hello " onValueChange={onValueChange} />,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
await sleepAsync(50)
|
|
204
|
+
|
|
205
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
206
|
+
textarea.selectionStart = 6
|
|
207
|
+
textarea.selectionEnd = 6
|
|
208
|
+
|
|
209
|
+
const file = new File(['png-data'], 'test.png', { type: 'image/png' })
|
|
210
|
+
const pasteEvent = createPasteEvent([{ type: 'image/png', file }])
|
|
211
|
+
textarea.dispatchEvent(pasteEvent)
|
|
212
|
+
|
|
213
|
+
await sleepAsync(100)
|
|
214
|
+
|
|
215
|
+
expect(onValueChange).toHaveBeenCalledOnce()
|
|
216
|
+
const result = onValueChange.mock.calls[0][0] as string
|
|
217
|
+
expect(result).toContain('
|
|
218
|
+
expect(result.startsWith('Hello ')).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
} finally {
|
|
221
|
+
globalThis.FileReader = originalFileReader
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('should ignore pasted images exceeding maxImageSizeBytes', async () => {
|
|
226
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
227
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
228
|
+
const onValueChange = vi.fn()
|
|
229
|
+
|
|
230
|
+
initializeShadeRoot({
|
|
231
|
+
injector,
|
|
232
|
+
rootElement,
|
|
233
|
+
jsxElement: <MarkdownInput value="" onValueChange={onValueChange} maxImageSizeBytes={5} />,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await sleepAsync(50)
|
|
237
|
+
|
|
238
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
239
|
+
const file = new File(['this-is-larger-than-5-bytes'], 'big.png', { type: 'image/png' })
|
|
240
|
+
const pasteEvent = createPasteEvent([{ type: 'image/png', file }])
|
|
241
|
+
textarea.dispatchEvent(pasteEvent)
|
|
242
|
+
|
|
243
|
+
await sleepAsync(100)
|
|
244
|
+
|
|
245
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should not interfere with non-image paste', async () => {
|
|
250
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
251
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
252
|
+
const onValueChange = vi.fn()
|
|
253
|
+
|
|
254
|
+
initializeShadeRoot({
|
|
255
|
+
injector,
|
|
256
|
+
rootElement,
|
|
257
|
+
jsxElement: <MarkdownInput value="" onValueChange={onValueChange} />,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
await sleepAsync(50)
|
|
261
|
+
|
|
262
|
+
const textarea = document.querySelector('shade-markdown-input textarea') as HTMLTextAreaElement
|
|
263
|
+
const file = new File(['text content'], 'note.txt', { type: 'text/plain' })
|
|
264
|
+
const pasteEvent = createPasteEvent([{ type: 'text/plain', file }])
|
|
265
|
+
const wasDefaultPrevented = !textarea.dispatchEvent(pasteEvent)
|
|
266
|
+
|
|
267
|
+
await sleepAsync(100)
|
|
268
|
+
|
|
269
|
+
expect(wasDefaultPrevented).toBe(false)
|
|
270
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Shade, createComponent } from '@furystack/shades'
|
|
2
|
+
import { cssVariableTheme } from '../../services/css-variable-theme.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_IMAGE_SIZE = 256 * 1024
|
|
5
|
+
|
|
6
|
+
export type MarkdownInputProps = {
|
|
7
|
+
/** The current Markdown string */
|
|
8
|
+
value: string
|
|
9
|
+
/** Called when the value changes */
|
|
10
|
+
onValueChange?: (newValue: string) => void
|
|
11
|
+
/** Maximum image file size in bytes for base64 paste. Defaults to 256KB. */
|
|
12
|
+
maxImageSizeBytes?: number
|
|
13
|
+
/** Whether the textarea is read-only */
|
|
14
|
+
readOnly?: boolean
|
|
15
|
+
/** Whether the textarea is disabled */
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
/** Placeholder text */
|
|
18
|
+
placeholder?: string
|
|
19
|
+
/** Label shown above the textarea */
|
|
20
|
+
labelTitle?: string
|
|
21
|
+
/** Number of visible text rows */
|
|
22
|
+
rows?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Markdown text input with base64 image paste support.
|
|
27
|
+
* When the user pastes an image below the configured size limit,
|
|
28
|
+
* it is inlined as a `` Markdown image.
|
|
29
|
+
*/
|
|
30
|
+
export const MarkdownInput = Shade<MarkdownInputProps>({
|
|
31
|
+
shadowDomName: 'shade-markdown-input',
|
|
32
|
+
css: {
|
|
33
|
+
display: 'block',
|
|
34
|
+
marginBottom: '1em',
|
|
35
|
+
|
|
36
|
+
'& label': {
|
|
37
|
+
display: 'flex',
|
|
38
|
+
flexDirection: 'column',
|
|
39
|
+
alignItems: 'flex-start',
|
|
40
|
+
fontSize: cssVariableTheme.typography.fontSize.xs,
|
|
41
|
+
color: cssVariableTheme.text.secondary,
|
|
42
|
+
padding: '1em',
|
|
43
|
+
borderRadius: cssVariableTheme.shape.borderRadius.md,
|
|
44
|
+
border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
|
|
45
|
+
transition: `color ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.default}`,
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
'&[data-disabled] label': {
|
|
49
|
+
color: cssVariableTheme.text.disabled,
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
'&:focus-within label': {
|
|
53
|
+
color: cssVariableTheme.palette.primary.main,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
'& textarea': {
|
|
57
|
+
border: 'none',
|
|
58
|
+
backgroundColor: 'transparent',
|
|
59
|
+
outline: 'none',
|
|
60
|
+
fontSize: cssVariableTheme.typography.fontSize.sm,
|
|
61
|
+
fontFamily: 'monospace',
|
|
62
|
+
width: '100%',
|
|
63
|
+
resize: 'vertical',
|
|
64
|
+
color: cssVariableTheme.text.primary,
|
|
65
|
+
boxShadow: '0px 0px 0px rgba(128,128,128,0.1)',
|
|
66
|
+
transition: `box-shadow ${cssVariableTheme.transitions.duration.normal} ease`,
|
|
67
|
+
lineHeight: cssVariableTheme.typography.lineHeight.relaxed,
|
|
68
|
+
padding: `${cssVariableTheme.spacing.sm} 0`,
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
'&:focus-within textarea': {
|
|
72
|
+
boxShadow: `0px 3px 0px ${cssVariableTheme.palette.primary.main}`,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
render: ({ props, useHostProps, useRef }) => {
|
|
76
|
+
const maxSize = props.maxImageSizeBytes ?? DEFAULT_MAX_IMAGE_SIZE
|
|
77
|
+
const textareaRef = useRef<HTMLTextAreaElement>('textarea')
|
|
78
|
+
|
|
79
|
+
useHostProps({
|
|
80
|
+
'data-disabled': props.disabled ? '' : undefined,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const handleInput = (ev: Event) => {
|
|
84
|
+
const target = ev.target as HTMLTextAreaElement
|
|
85
|
+
props.onValueChange?.(target.value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const handlePaste = (ev: ClipboardEvent) => {
|
|
89
|
+
const items = ev.clipboardData?.items
|
|
90
|
+
if (!items) return
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < items.length; i++) {
|
|
93
|
+
const item = items[i]
|
|
94
|
+
if (item.type.startsWith('image/')) {
|
|
95
|
+
const file = item.getAsFile()
|
|
96
|
+
if (!file || file.size > maxSize) continue
|
|
97
|
+
|
|
98
|
+
ev.preventDefault()
|
|
99
|
+
|
|
100
|
+
const reader = new FileReader()
|
|
101
|
+
reader.onload = () => {
|
|
102
|
+
const base64 = reader.result as string
|
|
103
|
+
const textarea = textareaRef.current
|
|
104
|
+
if (!textarea) return
|
|
105
|
+
|
|
106
|
+
const start = textarea.selectionStart
|
|
107
|
+
const end = textarea.selectionEnd
|
|
108
|
+
const before = textarea.value.slice(0, start)
|
|
109
|
+
const after = textarea.value.slice(end)
|
|
110
|
+
const imageMarkdown = ``
|
|
111
|
+
const newValue = before + imageMarkdown + after
|
|
112
|
+
|
|
113
|
+
textarea.value = newValue
|
|
114
|
+
const cursorPos = start + imageMarkdown.length
|
|
115
|
+
textarea.setSelectionRange(cursorPos, cursorPos)
|
|
116
|
+
props.onValueChange?.(newValue)
|
|
117
|
+
}
|
|
118
|
+
reader.onerror = () => {
|
|
119
|
+
console.warn('Failed to read pasted image file')
|
|
120
|
+
}
|
|
121
|
+
reader.readAsDataURL(file)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<label>
|
|
129
|
+
{props.labelTitle ? <span>{props.labelTitle}</span> : null}
|
|
130
|
+
<textarea
|
|
131
|
+
ref={textareaRef}
|
|
132
|
+
value={props.value}
|
|
133
|
+
oninput={handleInput}
|
|
134
|
+
onpaste={handlePaste}
|
|
135
|
+
readOnly={props.readOnly}
|
|
136
|
+
disabled={props.disabled}
|
|
137
|
+
placeholder={props.placeholder}
|
|
138
|
+
rows={props.rows ?? 10}
|
|
139
|
+
/>
|
|
140
|
+
</label>
|
|
141
|
+
)
|
|
142
|
+
},
|
|
143
|
+
})
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseInline, parseMarkdown, toggleCheckbox } from './markdown-parser.js'
|
|
3
|
+
|
|
4
|
+
describe('parseInline', () => {
|
|
5
|
+
it('should parse plain text', () => {
|
|
6
|
+
const result = parseInline('hello world')
|
|
7
|
+
expect(result).toEqual([{ type: 'text', content: 'hello world' }])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should parse inline code', () => {
|
|
11
|
+
const result = parseInline('use `const x = 1` here')
|
|
12
|
+
expect(result).toEqual([
|
|
13
|
+
{ type: 'text', content: 'use ' },
|
|
14
|
+
{ type: 'code', content: 'const x = 1' },
|
|
15
|
+
{ type: 'text', content: ' here' },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should parse bold with **', () => {
|
|
20
|
+
const result = parseInline('this is **bold** text')
|
|
21
|
+
expect(result).toEqual([
|
|
22
|
+
{ type: 'text', content: 'this is ' },
|
|
23
|
+
{ type: 'bold', children: [{ type: 'text', content: 'bold' }] },
|
|
24
|
+
{ type: 'text', content: ' text' },
|
|
25
|
+
])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should parse italic with *', () => {
|
|
29
|
+
const result = parseInline('this is *italic* text')
|
|
30
|
+
expect(result).toEqual([
|
|
31
|
+
{ type: 'text', content: 'this is ' },
|
|
32
|
+
{ type: 'italic', children: [{ type: 'text', content: 'italic' }] },
|
|
33
|
+
{ type: 'text', content: ' text' },
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should parse bold+italic with ***', () => {
|
|
38
|
+
const result = parseInline('this is ***bold italic*** text')
|
|
39
|
+
expect(result).toEqual([
|
|
40
|
+
{ type: 'text', content: 'this is ' },
|
|
41
|
+
{ type: 'bold', children: [{ type: 'italic', children: [{ type: 'text', content: 'bold italic' }] }] },
|
|
42
|
+
{ type: 'text', content: ' text' },
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should parse links', () => {
|
|
47
|
+
const result = parseInline('click [here](https://example.com) now')
|
|
48
|
+
expect(result).toEqual([
|
|
49
|
+
{ type: 'text', content: 'click ' },
|
|
50
|
+
{ type: 'link', href: 'https://example.com', children: [{ type: 'text', content: 'here' }] },
|
|
51
|
+
{ type: 'text', content: ' now' },
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should parse images', () => {
|
|
56
|
+
const result = parseInline('see  here')
|
|
57
|
+
expect(result).toEqual([
|
|
58
|
+
{ type: 'text', content: 'see ' },
|
|
59
|
+
{ type: 'image', src: 'image.png', alt: 'alt text' },
|
|
60
|
+
{ type: 'text', content: ' here' },
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should parse nested bold in link', () => {
|
|
65
|
+
const result = parseInline('[**bold link**](url)')
|
|
66
|
+
expect(result).toEqual([
|
|
67
|
+
{
|
|
68
|
+
type: 'link',
|
|
69
|
+
href: 'url',
|
|
70
|
+
children: [{ type: 'bold', children: [{ type: 'text', content: 'bold link' }] }],
|
|
71
|
+
},
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should handle empty string', () => {
|
|
76
|
+
expect(parseInline('')).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('parseMarkdown', () => {
|
|
81
|
+
it('should return empty array for empty input', () => {
|
|
82
|
+
expect(parseMarkdown('')).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should parse headings level 1–6', () => {
|
|
86
|
+
const md = '# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6'
|
|
87
|
+
const result = parseMarkdown(md)
|
|
88
|
+
expect(result).toHaveLength(6)
|
|
89
|
+
for (let i = 0; i < 6; i++) {
|
|
90
|
+
expect(result[i]).toMatchObject({ type: 'heading', level: i + 1 })
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should parse a paragraph', () => {
|
|
95
|
+
const result = parseMarkdown('Hello world\nthis is a paragraph.')
|
|
96
|
+
expect(result).toEqual([
|
|
97
|
+
{
|
|
98
|
+
type: 'paragraph',
|
|
99
|
+
children: [{ type: 'text', content: 'Hello world this is a paragraph.' }],
|
|
100
|
+
},
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should split paragraphs on blank lines', () => {
|
|
105
|
+
const result = parseMarkdown('First paragraph.\n\nSecond paragraph.')
|
|
106
|
+
expect(result).toHaveLength(2)
|
|
107
|
+
expect(result[0]).toMatchObject({ type: 'paragraph' })
|
|
108
|
+
expect(result[1]).toMatchObject({ type: 'paragraph' })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should parse fenced code blocks', () => {
|
|
112
|
+
const md = '```typescript\nconst x = 1\nconsole.log(x)\n```'
|
|
113
|
+
const result = parseMarkdown(md)
|
|
114
|
+
expect(result).toEqual([
|
|
115
|
+
{
|
|
116
|
+
type: 'codeBlock',
|
|
117
|
+
language: 'typescript',
|
|
118
|
+
content: 'const x = 1\nconsole.log(x)',
|
|
119
|
+
},
|
|
120
|
+
])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should parse fenced code blocks without language', () => {
|
|
124
|
+
const md = '```\nhello\n```'
|
|
125
|
+
const result = parseMarkdown(md)
|
|
126
|
+
expect(result).toEqual([
|
|
127
|
+
{
|
|
128
|
+
type: 'codeBlock',
|
|
129
|
+
language: undefined,
|
|
130
|
+
content: 'hello',
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should parse horizontal rules', () => {
|
|
136
|
+
for (const rule of ['---', '***', '___', '----', '****']) {
|
|
137
|
+
const result = parseMarkdown(rule)
|
|
138
|
+
expect(result).toEqual([{ type: 'horizontalRule' }])
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should parse unordered lists', () => {
|
|
143
|
+
const md = '- Item 1\n- Item 2\n- Item 3'
|
|
144
|
+
const result = parseMarkdown(md)
|
|
145
|
+
expect(result).toHaveLength(1)
|
|
146
|
+
expect(result[0]).toMatchObject({ type: 'list', ordered: false })
|
|
147
|
+
const list = result[0] as { type: 'list'; items: unknown[] }
|
|
148
|
+
expect(list.items).toHaveLength(3)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should parse ordered lists', () => {
|
|
152
|
+
const md = '1. First\n2. Second\n3. Third'
|
|
153
|
+
const result = parseMarkdown(md)
|
|
154
|
+
expect(result).toHaveLength(1)
|
|
155
|
+
expect(result[0]).toMatchObject({ type: 'list', ordered: true })
|
|
156
|
+
const list = result[0] as { type: 'list'; items: unknown[] }
|
|
157
|
+
expect(list.items).toHaveLength(3)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('should parse checkboxes in unordered lists', () => {
|
|
161
|
+
const md = '- [x] Done task\n- [ ] Todo task\n- Regular item'
|
|
162
|
+
const result = parseMarkdown(md)
|
|
163
|
+
expect(result).toHaveLength(1)
|
|
164
|
+
const list = result[0] as { type: 'list'; items: Array<{ checkbox?: string }> }
|
|
165
|
+
expect(list.items[0].checkbox).toBe('checked')
|
|
166
|
+
expect(list.items[1].checkbox).toBe('unchecked')
|
|
167
|
+
expect(list.items[2].checkbox).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should track sourceLineIndex on list items', () => {
|
|
171
|
+
const md = '- [x] Done\n- [ ] Todo'
|
|
172
|
+
const result = parseMarkdown(md)
|
|
173
|
+
const list = result[0] as { type: 'list'; items: Array<{ sourceLineIndex: number }> }
|
|
174
|
+
expect(list.items[0].sourceLineIndex).toBe(0)
|
|
175
|
+
expect(list.items[1].sourceLineIndex).toBe(1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should parse blockquotes', () => {
|
|
179
|
+
const md = '> This is a quote\n> Second line'
|
|
180
|
+
const result = parseMarkdown(md)
|
|
181
|
+
expect(result).toHaveLength(1)
|
|
182
|
+
expect(result[0]).toMatchObject({ type: 'blockquote' })
|
|
183
|
+
const bq = result[0] as { type: 'blockquote'; children: unknown[] }
|
|
184
|
+
expect(bq.children).toHaveLength(1)
|
|
185
|
+
expect(bq.children[0]).toMatchObject({ type: 'paragraph' })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('should parse a complex document', () => {
|
|
189
|
+
const md = [
|
|
190
|
+
'# Title',
|
|
191
|
+
'',
|
|
192
|
+
'A paragraph with **bold** and *italic*.',
|
|
193
|
+
'',
|
|
194
|
+
'## List Section',
|
|
195
|
+
'',
|
|
196
|
+
'- [x] Task done',
|
|
197
|
+
'- [ ] Task pending',
|
|
198
|
+
'',
|
|
199
|
+
'> A blockquote',
|
|
200
|
+
'',
|
|
201
|
+
'---',
|
|
202
|
+
'',
|
|
203
|
+
'```js',
|
|
204
|
+
'console.log("hi")',
|
|
205
|
+
'```',
|
|
206
|
+
].join('\n')
|
|
207
|
+
|
|
208
|
+
const result = parseMarkdown(md)
|
|
209
|
+
expect(result[0]).toMatchObject({ type: 'heading', level: 1 })
|
|
210
|
+
expect(result[1]).toMatchObject({ type: 'paragraph' })
|
|
211
|
+
expect(result[2]).toMatchObject({ type: 'heading', level: 2 })
|
|
212
|
+
expect(result[3]).toMatchObject({ type: 'list', ordered: false })
|
|
213
|
+
expect(result[4]).toMatchObject({ type: 'blockquote' })
|
|
214
|
+
expect(result[5]).toMatchObject({ type: 'horizontalRule' })
|
|
215
|
+
expect(result[6]).toMatchObject({ type: 'codeBlock', language: 'js' })
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('toggleCheckbox', () => {
|
|
220
|
+
it('should toggle an unchecked checkbox to checked', () => {
|
|
221
|
+
const source = '- [ ] Todo item'
|
|
222
|
+
const result = toggleCheckbox(source, 0)
|
|
223
|
+
expect(result).toBe('- [x] Todo item')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('should toggle a checked checkbox to unchecked', () => {
|
|
227
|
+
const source = '- [x] Done item'
|
|
228
|
+
const result = toggleCheckbox(source, 0)
|
|
229
|
+
expect(result).toBe('- [ ] Done item')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should toggle the correct line in a multi-line document', () => {
|
|
233
|
+
const source = '- [x] Done\n- [ ] Todo\n- [ ] Another'
|
|
234
|
+
const result = toggleCheckbox(source, 1)
|
|
235
|
+
expect(result).toBe('- [x] Done\n- [x] Todo\n- [ ] Another')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should return original string for out-of-bounds index', () => {
|
|
239
|
+
const source = '- [ ] Todo'
|
|
240
|
+
expect(toggleCheckbox(source, 5)).toBe(source)
|
|
241
|
+
expect(toggleCheckbox(source, -1)).toBe(source)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('should return original string for non-checkbox line', () => {
|
|
245
|
+
const source = 'Regular text'
|
|
246
|
+
expect(toggleCheckbox(source, 0)).toBe(source)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should not toggle [ ] that appears outside a list item', () => {
|
|
250
|
+
const source = 'This line has [ ] in it but is not a list item'
|
|
251
|
+
expect(toggleCheckbox(source, 0)).toBe(source)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should toggle checkboxes with * list marker', () => {
|
|
255
|
+
const source = '* [ ] Star item'
|
|
256
|
+
expect(toggleCheckbox(source, 0)).toBe('* [x] Star item')
|
|
257
|
+
})
|
|
258
|
+
})
|