Skip to content

Commit 1ca4924

Browse files
committed
feat: image dialog supports upload
Various improvements across dialogs Fixes #105
1 parent dedc614 commit 1ca4924

File tree

13 files changed

+438
-354
lines changed

13 files changed

+438
-354
lines changed

src/examples/link-dialog.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
directivesPlugin,
88
headingsPlugin,
99
quotePlugin,
10-
listsPlugin
10+
listsPlugin,
11+
toolbarPlugin,
12+
CreateLink
1113
} from '../'
1214
import admonitionMarkdown from './assets/admonition.md?raw'
1315

@@ -49,3 +51,32 @@ export function ParentOffsetOfAnchor() {
4951
</div>
5052
)
5153
}
54+
55+
export function EditorInAForm() {
56+
return (
57+
<div className="App">
58+
<form
59+
onSubmit={(evt) => {
60+
evt.preventDefault()
61+
alert('main form submitted')
62+
}}
63+
>
64+
<MDXEditor
65+
markdown="[Link](http://www.example.com)"
66+
plugins={[
67+
linkPlugin(),
68+
linkDialogPlugin(),
69+
toolbarPlugin({
70+
toolbarContents: () => (
71+
<>
72+
<CreateLink />
73+
</>
74+
)
75+
})
76+
]}
77+
/>
78+
<button type="submit">Submit</button>
79+
</form>
80+
</div>
81+
)
82+
}

src/plugins/core/PropertyPopover.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ export const PropertyPopover: React.FC<PropertyPopoverProps> = ({ title, propert
4242
<PopoverPortal>
4343
<PopoverContent>
4444
<form
45-
onSubmit={handleSubmit((values) => {
46-
onChange(values)
45+
onSubmit={(e) => {
46+
void handleSubmit(onChange)(e)
4747
setOpen(false)
48-
})}
48+
e.nativeEvent.stopImmediatePropagation()
49+
}}
4950
>
5051
<h3 className={styles.propertyPanelTitle}>{title} Attributes</h3>
5152
<table className={styles.propertyEditorTable}>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useCombobox } from 'downshift'
2+
import React from 'react'
3+
import { Control, UseFormSetValue, Controller } from 'react-hook-form'
4+
import styles from '../../../styles/ui.module.css'
5+
import DropDownIcon from '../../../icons/arrow_drop_down.svg'
6+
7+
const MAX_SUGGESTIONS = 20
8+
9+
export const DownshiftAutoComplete: React.FC<{
10+
suggestions: string[]
11+
control: Control<any, any>
12+
setValue: UseFormSetValue<any>
13+
placeholder: string
14+
inputName: string
15+
autofocus?: boolean
16+
initialInputValue: string
17+
}> = ({ autofocus, suggestions, control, inputName, placeholder, initialInputValue, setValue }) => {
18+
const [items, setItems] = React.useState(suggestions.slice(0, MAX_SUGGESTIONS))
19+
20+
const enableAutoComplete = suggestions.length > 0
21+
22+
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, highlightedIndex, getItemProps, selectedItem } = useCombobox({
23+
initialInputValue,
24+
onInputValueChange({ inputValue }) {
25+
inputValue = inputValue?.toLowerCase() || ''
26+
setValue(inputName, inputValue)
27+
const matchingItems = []
28+
for (const suggestion of suggestions) {
29+
if (suggestion.toLowerCase().includes(inputValue)) {
30+
matchingItems.push(suggestion)
31+
if (matchingItems.length >= MAX_SUGGESTIONS) {
32+
break
33+
}
34+
}
35+
}
36+
setItems(matchingItems)
37+
},
38+
items,
39+
itemToString(item) {
40+
return item ?? ''
41+
}
42+
})
43+
44+
const dropdownIsVisible = isOpen && items.length > 0
45+
return (
46+
<div className={styles.downshiftAutocompleteContainer}>
47+
<div data-visible-dropdown={dropdownIsVisible} className={styles.downshiftInputWrapper}>
48+
<Controller
49+
name={inputName}
50+
control={control}
51+
render={({ field }) => {
52+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
53+
const downshiftSrcProps = getInputProps()
54+
return (
55+
<input
56+
{...downshiftSrcProps}
57+
name={field.name}
58+
placeholder={placeholder}
59+
className={styles.downshiftInput}
60+
size={30}
61+
data-editor-dialog={true}
62+
autoFocus={autofocus}
63+
/>
64+
)
65+
}}
66+
/>
67+
{enableAutoComplete && (
68+
<button aria-label="toggle menu" type="button" {...getToggleButtonProps()}>
69+
<DropDownIcon />
70+
</button>
71+
)}
72+
</div>
73+
74+
<div className={styles.downshiftAutocompleteContainer}>
75+
<ul {...getMenuProps()} data-visible={dropdownIsVisible}>
76+
{items.map((item, index: number) => (
77+
<li
78+
data-selected={selectedItem === item}
79+
data-highlighted={highlightedIndex === index}
80+
key={`${item}${index}`}
81+
{...getItemProps({ item, index })}
82+
>
83+
{item}
84+
</li>
85+
))}
86+
</ul>
87+
</div>
88+
</div>
89+
)
90+
}

src/plugins/frontmatter/FrontmatterEditor.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) =>
6565
<Dialog.Overlay className={styles.dialogOverlay} />
6666
<Dialog.Content className={styles.largeDialogContent} data-editor-type="frontmatter">
6767
<Dialog.Title className={styles.dialogTitle}>Edit document frontmatter</Dialog.Title>
68-
<form onSubmit={handleSubmit(onSubmit)} onReset={() => setFrontmatterDialogOpen(false)}>
68+
<form
69+
onSubmit={(e) => {
70+
void handleSubmit(onSubmit)(e)
71+
e.nativeEvent.stopImmediatePropagation()
72+
}}
73+
onReset={() => setFrontmatterDialogOpen(false)}
74+
>
6975
<table className={styles.propertyEditorTable}>
7076
<colgroup>
7177
<col />
@@ -116,12 +122,12 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) =>
116122
</tfoot>
117123
</table>
118124
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-2)' }}>
119-
<button type="reset" className={styles.secondaryButton}>
120-
Cancel
121-
</button>
122125
<button type="submit" className={styles.primaryButton}>
123126
Save
124127
</button>
128+
<button type="reset" className={styles.secondaryButton}>
129+
Cancel
130+
</button>
125131
</div>
126132
</form>
127133
<Dialog.Close asChild>

src/plugins/image/ImageDialog.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as Dialog from '@radix-ui/react-dialog'
2+
import classNames from 'classnames'
3+
import React from 'react'
4+
import { useForm } from 'react-hook-form'
5+
import styles from '../../styles/ui.module.css'
6+
import { corePluginHooks } from '../core/index'
7+
import { imagePluginHooks } from './index'
8+
import { DownshiftAutoComplete } from '../core/ui/DownshiftAutoComplete'
9+
10+
interface ImageFormFields {
11+
src: string
12+
title: string
13+
altText: string
14+
file: FileList
15+
}
16+
17+
export const ImageDialog: React.FC = () => {
18+
const [imageAutocompleteSuggestions, state] = imagePluginHooks.useEmitterValues('imageAutocompleteSuggestions', 'imageDialogState')
19+
const saveImage = imagePluginHooks.usePublisher('saveImage')
20+
const [editorRootElementRef] = corePluginHooks.useEmitterValues('editorRootElementRef')
21+
const closeImageDialog = imagePluginHooks.usePublisher('closeImageDialog')
22+
23+
const { register, handleSubmit, control, setValue, reset } = useForm<ImageFormFields>({
24+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
25+
values: state.type === 'editing' ? (state.initialValues as any) : {}
26+
})
27+
28+
return (
29+
<Dialog.Root
30+
open={state.type !== 'inactive'}
31+
onOpenChange={(open) => {
32+
if (!open) {
33+
closeImageDialog(true)
34+
reset({ src: '', title: '', altText: '' })
35+
}
36+
}}
37+
>
38+
<Dialog.Portal container={editorRootElementRef?.current}>
39+
<Dialog.Overlay className={styles.dialogOverlay} />
40+
<Dialog.Content
41+
className={styles.dialogContent}
42+
onOpenAutoFocus={(e) => {
43+
e.preventDefault()
44+
}}
45+
>
46+
<form
47+
onSubmit={(e) => {
48+
void handleSubmit(saveImage)(e)
49+
reset({ src: '', title: '', altText: '' })
50+
e.nativeEvent.stopImmediatePropagation()
51+
}}
52+
className={styles.multiFieldForm}
53+
>
54+
<div className={styles.formField}>
55+
<label htmlFor="file">Upload an image from your device:</label>
56+
<input type="file" {...register('file')} />
57+
</div>
58+
59+
<div className={styles.formField}>
60+
<label htmlFor="src">Or add an image from an URL:</label>
61+
<DownshiftAutoComplete
62+
initialInputValue={state.type === 'editing' ? state.initialValues.src || '' : ''}
63+
inputName="src"
64+
suggestions={imageAutocompleteSuggestions}
65+
setValue={setValue}
66+
control={control}
67+
placeholder="Select or paste an image src"
68+
/>
69+
</div>
70+
71+
<div className={styles.formField}>
72+
<label htmlFor="alt">Alt:</label>
73+
<input type="text" {...register('altText')} className={styles.textInput} />
74+
</div>
75+
76+
<div className={styles.formField}>
77+
<label htmlFor="title">Title:</label>
78+
<input type="text" {...register('title')} className={styles.textInput} />
79+
</div>
80+
81+
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-2)' }}>
82+
<button type="submit" title="Save" aria-label="Save" className={classNames(styles.primaryButton)}>
83+
Save
84+
</button>
85+
<Dialog.Close asChild>
86+
<button type="reset" title="Cancel" aria-label="Cancel" className={classNames(styles.secondaryButton)}>
87+
Cancel
88+
</button>
89+
</Dialog.Close>
90+
</div>
91+
</form>
92+
</Dialog.Content>
93+
</Dialog.Portal>
94+
</Dialog.Root>
95+
)
96+
}

src/plugins/image/ImageEditor.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { GridSelection, LexicalEditor, NodeSelection, RangeSelection } from
55
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
66
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
77
import { mergeRegister } from '@lexical/utils'
8+
import classNames from 'classnames'
89
import {
910
$getNodeByKey,
1011
$getSelection,
@@ -19,11 +20,11 @@ import {
1920
KEY_ESCAPE_COMMAND,
2021
SELECTION_CHANGE_COMMAND
2122
} from 'lexical'
23+
import { imagePluginHooks } from '.'
24+
import SettingsIcon from '../../icons/settings.svg'
2225
import styles from '../../styles/ui.module.css'
23-
import classNames from 'classnames'
2426
import { $isImageNode } from './ImageNode'
2527
import ImageResizer from './ImageResizer'
26-
import { imagePluginHooks } from '.'
2728

2829
export interface ImageEditorProps {
2930
nodeKey: string
@@ -92,6 +93,7 @@ export function ImageEditor({ src, title, alt, nodeKey, width, height }: ImageEd
9293
const [disableImageResize] = imagePluginHooks.useEmitterValues('disableImageResize')
9394
const [imagePreviewHandler] = imagePluginHooks.useEmitterValues('imagePreviewHandler')
9495
const [imageSource, setImageSource] = React.useState<string | null>(null)
96+
const openEditImageDialog = imagePluginHooks.usePublisher('openEditImageDialog')
9597

9698
const onDelete = React.useCallback(
9799
(payload: KeyboardEvent) => {
@@ -256,6 +258,22 @@ export function ImageEditor({ src, title, alt, nodeKey, width, height }: ImageEd
256258
{draggable && isFocused && !disableImageResize && (
257259
<ImageResizer editor={editor} imageRef={imageRef} onResizeStart={onResizeStart} onResizeEnd={onResizeEnd} />
258260
)}
261+
<button
262+
className={classNames(styles.iconButton, styles.editImageButton)}
263+
title="Edit image"
264+
onClick={() => {
265+
openEditImageDialog({
266+
nodeKey: nodeKey,
267+
initialValues: {
268+
src: imageSource,
269+
title: title || '',
270+
altText: alt || ''
271+
}
272+
})
273+
}}
274+
>
275+
<SettingsIcon />
276+
</button>
259277
</div>
260278
</React.Suspense>
261279
) : null

src/plugins/image/ImageNode.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
5050
__height: 'inherit' | number
5151

5252
static getType(): string {
53-
return 'image'
53+
return 'icage'
5454
}
5555

5656
static clone(node: ImageNode): ImageNode {
@@ -170,6 +170,14 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
170170
this.getWritable().__title = title
171171
}
172172

173+
setSrc(src: string): void {
174+
this.getWritable().__src = src
175+
}
176+
177+
setAltText(altText: string | undefined): void {
178+
this.getWritable().__altText = altText ?? ''
179+
}
180+
173181
decorate(_parentEditor: LexicalEditor): JSX.Element {
174182
return (
175183
<ImageEditor

0 commit comments

Comments
 (0)