@furystack/shades-common-components 12.2.0 → 12.3.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 +26 -0
- 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 +1 -1
- 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,167 @@
|
|
|
1
|
+
import { Shade, createComponent } from '@furystack/shades'
|
|
2
|
+
import { cssVariableTheme } from '../../services/css-variable-theme.js'
|
|
3
|
+
import { Tabs } from '../tabs.js'
|
|
4
|
+
import { MarkdownDisplay } from './markdown-display.js'
|
|
5
|
+
import { MarkdownInput } from './markdown-input.js'
|
|
6
|
+
|
|
7
|
+
export type MarkdownEditorLayout = 'side-by-side' | 'tabs' | 'above-below'
|
|
8
|
+
|
|
9
|
+
export type MarkdownEditorProps = {
|
|
10
|
+
/** The current Markdown string */
|
|
11
|
+
value: string
|
|
12
|
+
/** Called when the value changes (from either the input or checkbox toggle) */
|
|
13
|
+
onValueChange?: (newValue: string) => void
|
|
14
|
+
/** Layout mode for the editor. Defaults to 'side-by-side'. */
|
|
15
|
+
layout?: MarkdownEditorLayout
|
|
16
|
+
/** Maximum image file size in bytes for base64 paste */
|
|
17
|
+
maxImageSizeBytes?: number
|
|
18
|
+
/** When true, the editor is read-only */
|
|
19
|
+
readOnly?: boolean
|
|
20
|
+
/** Inline styles applied to the host element */
|
|
21
|
+
style?: Partial<CSSStyleDeclaration>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type TabType = 'edit' | 'preview'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Combined Markdown editor with an input pane and a live preview pane.
|
|
28
|
+
* Supports three layouts: side-by-side, tabs (Edit/Preview), or above-below.
|
|
29
|
+
*/
|
|
30
|
+
export const MarkdownEditor = Shade<MarkdownEditorProps>({
|
|
31
|
+
shadowDomName: 'shade-markdown-editor',
|
|
32
|
+
css: {
|
|
33
|
+
display: 'flex',
|
|
34
|
+
flexDirection: 'column',
|
|
35
|
+
border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
|
|
36
|
+
borderRadius: cssVariableTheme.shape.borderRadius.md,
|
|
37
|
+
overflow: 'hidden',
|
|
38
|
+
minHeight: '0',
|
|
39
|
+
|
|
40
|
+
'& .md-editor-split': {
|
|
41
|
+
display: 'flex',
|
|
42
|
+
flex: '1',
|
|
43
|
+
minHeight: '0',
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
'& .md-editor-split[data-layout="side-by-side"]': {
|
|
47
|
+
flexDirection: 'row',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
'& .md-editor-split[data-layout="above-below"]': {
|
|
51
|
+
flexDirection: 'column',
|
|
52
|
+
minHeight: 'auto',
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
'& .md-editor-pane': {
|
|
56
|
+
flex: '1',
|
|
57
|
+
minWidth: '0',
|
|
58
|
+
minHeight: '0',
|
|
59
|
+
overflow: 'auto',
|
|
60
|
+
display: 'flex',
|
|
61
|
+
flexDirection: 'column',
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
'& .md-editor-pane-input': {
|
|
65
|
+
borderRight: 'none',
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
'& .md-editor-split[data-layout="side-by-side"] .md-editor-pane-input': {
|
|
69
|
+
borderRight: `1px solid ${cssVariableTheme.action.subtleBorder}`,
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
'& .md-editor-split[data-layout="above-below"] .md-editor-pane-input': {
|
|
73
|
+
borderBottom: `1px solid ${cssVariableTheme.action.subtleBorder}`,
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
'& .md-editor-split[data-layout="above-below"] .md-editor-pane': {
|
|
77
|
+
flex: 'none',
|
|
78
|
+
overflow: 'visible',
|
|
79
|
+
minHeight: 'auto',
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
'& .md-editor-split[data-layout="above-below"] shade-markdown-input textarea': {
|
|
83
|
+
overflow: 'hidden',
|
|
84
|
+
fieldSizing: 'content',
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
'& .md-editor-pane-preview': {
|
|
88
|
+
padding: cssVariableTheme.spacing.md,
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
'& shade-markdown-input': {
|
|
92
|
+
marginBottom: '0',
|
|
93
|
+
flex: '1',
|
|
94
|
+
display: 'flex',
|
|
95
|
+
flexDirection: 'column',
|
|
96
|
+
},
|
|
97
|
+
'& shade-markdown-input label': {
|
|
98
|
+
border: 'none',
|
|
99
|
+
borderRadius: '0',
|
|
100
|
+
flex: '1',
|
|
101
|
+
display: 'flex',
|
|
102
|
+
flexDirection: 'column',
|
|
103
|
+
},
|
|
104
|
+
'& shade-markdown-input textarea': {
|
|
105
|
+
flex: '1',
|
|
106
|
+
resize: 'none',
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
'& shade-tabs': {
|
|
110
|
+
flex: '1',
|
|
111
|
+
minHeight: '0',
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
'& .md-editor-tab-content': {
|
|
115
|
+
padding: cssVariableTheme.spacing.md,
|
|
116
|
+
overflow: 'auto',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
render: ({ props, useState, useHostProps }) => {
|
|
120
|
+
const layout = props.layout ?? 'side-by-side'
|
|
121
|
+
|
|
122
|
+
useHostProps({
|
|
123
|
+
...(props.style ? { style: props.style as Record<string, string> } : {}),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const [activeTab, setActiveTab] = useState<TabType>('activeTab', 'edit')
|
|
127
|
+
|
|
128
|
+
const inputPane = (
|
|
129
|
+
<MarkdownInput
|
|
130
|
+
value={props.value}
|
|
131
|
+
onValueChange={props.onValueChange}
|
|
132
|
+
maxImageSizeBytes={props.maxImageSizeBytes}
|
|
133
|
+
readOnly={props.readOnly}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const previewPane = <MarkdownDisplay content={props.value} readOnly={false} onChange={props.onValueChange} />
|
|
138
|
+
|
|
139
|
+
if (layout === 'tabs') {
|
|
140
|
+
return (
|
|
141
|
+
<Tabs
|
|
142
|
+
activeKey={activeTab}
|
|
143
|
+
onTabChange={(key) => setActiveTab(key as TabType)}
|
|
144
|
+
tabs={[
|
|
145
|
+
{
|
|
146
|
+
header: <>Edit</>,
|
|
147
|
+
hash: 'edit',
|
|
148
|
+
component: <div className="md-editor-tab-content">{inputPane}</div>,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
header: <>Preview</>,
|
|
152
|
+
hash: 'preview',
|
|
153
|
+
component: <div className="md-editor-tab-content">{previewPane}</div>,
|
|
154
|
+
},
|
|
155
|
+
]}
|
|
156
|
+
/>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="md-editor-split" data-layout={layout}>
|
|
162
|
+
<div className="md-editor-pane md-editor-pane-input">{inputPane}</div>
|
|
163
|
+
<div className="md-editor-pane md-editor-pane-preview">{previewPane}</div>
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
},
|
|
167
|
+
})
|
|
@@ -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
|
+
})
|