Skip to content

Commit

Permalink
feat: image dialog supports upload
Browse files Browse the repository at this point in the history
Various improvements across dialogs

Fixes #105
  • Loading branch information
petyosi committed Oct 5, 2023
1 parent dedc614 commit 1ca4924
Show file tree
Hide file tree
Showing 13 changed files with 438 additions and 354 deletions.
33 changes: 32 additions & 1 deletion src/examples/link-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
directivesPlugin,
headingsPlugin,
quotePlugin,
listsPlugin
listsPlugin,
toolbarPlugin,
CreateLink
} from '../'
import admonitionMarkdown from './assets/admonition.md?raw'

Expand Down Expand Up @@ -49,3 +51,32 @@ export function ParentOffsetOfAnchor() {
</div>
)
}

export function EditorInAForm() {
return (
<div className="App">
<form
onSubmit={(evt) => {
evt.preventDefault()
alert('main form submitted')
}}
>
<MDXEditor
markdown="[Link](http://www.example.com)"
plugins={[
linkPlugin(),
linkDialogPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<CreateLink />
</>
)
})
]}
/>
<button type="submit">Submit</button>
</form>
</div>
)
}
7 changes: 4 additions & 3 deletions src/plugins/core/PropertyPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ export const PropertyPopover: React.FC<PropertyPopoverProps> = ({ title, propert
<PopoverPortal>
<PopoverContent>
<form
onSubmit={handleSubmit((values) => {
onChange(values)
onSubmit={(e) => {
void handleSubmit(onChange)(e)
setOpen(false)
})}
e.nativeEvent.stopImmediatePropagation()
}}
>
<h3 className={styles.propertyPanelTitle}>{title} Attributes</h3>
<table className={styles.propertyEditorTable}>
Expand Down
90 changes: 90 additions & 0 deletions src/plugins/core/ui/DownshiftAutoComplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useCombobox } from 'downshift'
import React from 'react'
import { Control, UseFormSetValue, Controller } from 'react-hook-form'
import styles from '../../../styles/ui.module.css'
import DropDownIcon from '../../../icons/arrow_drop_down.svg'

const MAX_SUGGESTIONS = 20

export const DownshiftAutoComplete: React.FC<{
suggestions: string[]
control: Control<any, any>
setValue: UseFormSetValue<any>
placeholder: string
inputName: string
autofocus?: boolean
initialInputValue: string
}> = ({ autofocus, suggestions, control, inputName, placeholder, initialInputValue, setValue }) => {
const [items, setItems] = React.useState(suggestions.slice(0, MAX_SUGGESTIONS))

const enableAutoComplete = suggestions.length > 0

const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, highlightedIndex, getItemProps, selectedItem } = useCombobox({
initialInputValue,
onInputValueChange({ inputValue }) {
inputValue = inputValue?.toLowerCase() || ''
setValue(inputName, inputValue)
const matchingItems = []
for (const suggestion of suggestions) {
if (suggestion.toLowerCase().includes(inputValue)) {
matchingItems.push(suggestion)
if (matchingItems.length >= MAX_SUGGESTIONS) {
break
}
}
}
setItems(matchingItems)
},
items,
itemToString(item) {
return item ?? ''
}
})

const dropdownIsVisible = isOpen && items.length > 0
return (
<div className={styles.downshiftAutocompleteContainer}>
<div data-visible-dropdown={dropdownIsVisible} className={styles.downshiftInputWrapper}>
<Controller
name={inputName}
control={control}
render={({ field }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const downshiftSrcProps = getInputProps()
return (
<input
{...downshiftSrcProps}
name={field.name}
placeholder={placeholder}
className={styles.downshiftInput}
size={30}
data-editor-dialog={true}
autoFocus={autofocus}
/>
)
}}
/>
{enableAutoComplete && (
<button aria-label="toggle menu" type="button" {...getToggleButtonProps()}>
<DropDownIcon />
</button>
)}
</div>

<div className={styles.downshiftAutocompleteContainer}>
<ul {...getMenuProps()} data-visible={dropdownIsVisible}>
{items.map((item, index: number) => (
<li
data-selected={selectedItem === item}
data-highlighted={highlightedIndex === index}
key={`${item}${index}`}
{...getItemProps({ item, index })}
>
{item}
</li>
))}
</ul>
</div>
</div>
)
}
14 changes: 10 additions & 4 deletions src/plugins/frontmatter/FrontmatterEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) =>
<Dialog.Overlay className={styles.dialogOverlay} />
<Dialog.Content className={styles.largeDialogContent} data-editor-type="frontmatter">
<Dialog.Title className={styles.dialogTitle}>Edit document frontmatter</Dialog.Title>
<form onSubmit={handleSubmit(onSubmit)} onReset={() => setFrontmatterDialogOpen(false)}>
<form
onSubmit={(e) => {
void handleSubmit(onSubmit)(e)
e.nativeEvent.stopImmediatePropagation()
}}
onReset={() => setFrontmatterDialogOpen(false)}
>
<table className={styles.propertyEditorTable}>
<colgroup>
<col />
Expand Down Expand Up @@ -116,12 +122,12 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) =>
</tfoot>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-2)' }}>
<button type="reset" className={styles.secondaryButton}>
Cancel
</button>
<button type="submit" className={styles.primaryButton}>
Save
</button>
<button type="reset" className={styles.secondaryButton}>
Cancel
</button>
</div>
</form>
<Dialog.Close asChild>
Expand Down
96 changes: 96 additions & 0 deletions src/plugins/image/ImageDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as Dialog from '@radix-ui/react-dialog'
import classNames from 'classnames'
import React from 'react'
import { useForm } from 'react-hook-form'
import styles from '../../styles/ui.module.css'
import { corePluginHooks } from '../core/index'
import { imagePluginHooks } from './index'
import { DownshiftAutoComplete } from '../core/ui/DownshiftAutoComplete'

interface ImageFormFields {
src: string
title: string
altText: string
file: FileList
}

export const ImageDialog: React.FC = () => {
const [imageAutocompleteSuggestions, state] = imagePluginHooks.useEmitterValues('imageAutocompleteSuggestions', 'imageDialogState')
const saveImage = imagePluginHooks.usePublisher('saveImage')
const [editorRootElementRef] = corePluginHooks.useEmitterValues('editorRootElementRef')
const closeImageDialog = imagePluginHooks.usePublisher('closeImageDialog')

const { register, handleSubmit, control, setValue, reset } = useForm<ImageFormFields>({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
values: state.type === 'editing' ? (state.initialValues as any) : {}
})

return (
<Dialog.Root
open={state.type !== 'inactive'}
onOpenChange={(open) => {
if (!open) {
closeImageDialog(true)
reset({ src: '', title: '', altText: '' })
}
}}
>
<Dialog.Portal container={editorRootElementRef?.current}>
<Dialog.Overlay className={styles.dialogOverlay} />
<Dialog.Content
className={styles.dialogContent}
onOpenAutoFocus={(e) => {
e.preventDefault()
}}
>
<form
onSubmit={(e) => {
void handleSubmit(saveImage)(e)
reset({ src: '', title: '', altText: '' })
e.nativeEvent.stopImmediatePropagation()
}}
className={styles.multiFieldForm}
>
<div className={styles.formField}>
<label htmlFor="file">Upload an image from your device:</label>
<input type="file" {...register('file')} />
</div>

<div className={styles.formField}>
<label htmlFor="src">Or add an image from an URL:</label>
<DownshiftAutoComplete
initialInputValue={state.type === 'editing' ? state.initialValues.src || '' : ''}
inputName="src"
suggestions={imageAutocompleteSuggestions}
setValue={setValue}
control={control}
placeholder="Select or paste an image src"
/>
</div>

<div className={styles.formField}>
<label htmlFor="alt">Alt:</label>
<input type="text" {...register('altText')} className={styles.textInput} />
</div>

<div className={styles.formField}>
<label htmlFor="title">Title:</label>
<input type="text" {...register('title')} className={styles.textInput} />
</div>

<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-2)' }}>
<button type="submit" title="Save" aria-label="Save" className={classNames(styles.primaryButton)}>
Save
</button>
<Dialog.Close asChild>
<button type="reset" title="Cancel" aria-label="Cancel" className={classNames(styles.secondaryButton)}>
Cancel
</button>
</Dialog.Close>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
22 changes: 20 additions & 2 deletions src/plugins/image/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { GridSelection, LexicalEditor, NodeSelection, RangeSelection } from
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
import { mergeRegister } from '@lexical/utils'
import classNames from 'classnames'
import {
$getNodeByKey,
$getSelection,
Expand All @@ -19,11 +20,11 @@ import {
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND
} from 'lexical'
import { imagePluginHooks } from '.'
import SettingsIcon from '../../icons/settings.svg'
import styles from '../../styles/ui.module.css'
import classNames from 'classnames'
import { $isImageNode } from './ImageNode'
import ImageResizer from './ImageResizer'
import { imagePluginHooks } from '.'

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

const onDelete = React.useCallback(
(payload: KeyboardEvent) => {
Expand Down Expand Up @@ -256,6 +258,22 @@ export function ImageEditor({ src, title, alt, nodeKey, width, height }: ImageEd
{draggable && isFocused && !disableImageResize && (
<ImageResizer editor={editor} imageRef={imageRef} onResizeStart={onResizeStart} onResizeEnd={onResizeEnd} />
)}
<button
className={classNames(styles.iconButton, styles.editImageButton)}
title="Edit image"
onClick={() => {
openEditImageDialog({
nodeKey: nodeKey,
initialValues: {
src: imageSource,
title: title || '',
altText: alt || ''
}
})
}}
>
<SettingsIcon />
</button>
</div>
</React.Suspense>
) : null
Expand Down
10 changes: 9 additions & 1 deletion src/plugins/image/ImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
__height: 'inherit' | number

static getType(): string {
return 'image'
return 'icage'
}

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

setSrc(src: string): void {
this.getWritable().__src = src
}

setAltText(altText: string | undefined): void {
this.getWritable().__altText = altText ?? ''
}

decorate(_parentEditor: LexicalEditor): JSX.Element {
return (
<ImageEditor
Expand Down
Loading

0 comments on commit 1ca4924

Please sign in to comment.