Skip to content

Commit

Permalink
feat: support alt/title/upload in dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
petyosi committed Oct 3, 2023
1 parent 5738226 commit dedc614
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 71 deletions.
23 changes: 22 additions & 1 deletion src/examples/images.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import { DiffSourceToggleWrapper, MDXEditor, diffSourcePlugin, imagePlugin, jsxPlugin, toolbarPlugin } from '../'
import { DiffSourceToggleWrapper, InsertImage, MDXEditor, diffSourcePlugin, imagePlugin, jsxPlugin, toolbarPlugin } from '../'

const markdownWithHtmlImages = `
Hello world
Expand Down Expand Up @@ -69,3 +69,24 @@ export function ImageWithPreviewHook() {
</>
)
}

export function ImageDialogButtonExample() {
return (
<>
<MDXEditor
markdown=""
plugins={[
imagePlugin({
disableImageResize: true,
imageUploadHandler: async () => Promise.resolve('https://picsum.photos/200/300?grayscale'),
imageAutocompleteSuggestions: ['https://via.placeholder.com/150', 'https://via.placeholder.com/250']
}),
diffSourcePlugin(),
jsxPlugin(),
toolbarPlugin({ toolbarContents: () => <InsertImage /> })
]}
onChange={console.log}
/>
</>
)
}
19 changes: 17 additions & 2 deletions src/plugins/image/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { imagePluginHooks } from '.'
export interface ImageEditorProps {
nodeKey: string
src: string
alt?: string
title?: string
width: number | 'inherit'
height: number | 'inherit'
Expand All @@ -50,24 +51,37 @@ function useSuspenseImage(src: string) {

function LazyImage({
title,
alt,
className,
imageRef,
src,
width,
height
}: {
title: string
alt: string
className: string | null
imageRef: { current: null | HTMLImageElement }
src: string
width: number | 'inherit'
height: number | 'inherit'
}): JSX.Element {
useSuspenseImage(src)
return <img className={className || undefined} src={src} title={title} ref={imageRef} draggable="false" width={width} height={height} />
return (
<img
className={className || undefined}
alt={alt}
src={src}
title={title}
ref={imageRef}
draggable="false"
width={width}
height={height}
/>
)
}

export function ImageEditor({ src, title, nodeKey, width, height }: ImageEditorProps): JSX.Element | null {
export function ImageEditor({ src, title, alt, nodeKey, width, height }: ImageEditorProps): JSX.Element | null {
const imageRef = React.useRef<null | HTMLImageElement>(null)
const buttonRef = React.useRef<HTMLButtonElement | null>(null)
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
Expand Down Expand Up @@ -235,6 +249,7 @@ export function ImageEditor({ src, title, nodeKey, width, height }: ImageEditorP
})}
src={imageSource}
title={title || ''}
alt={alt || ''}
imageRef={imageRef}
/>
</div>
Expand Down
11 changes: 10 additions & 1 deletion src/plugins/image/ImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,16 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
}

decorate(_parentEditor: LexicalEditor): JSX.Element {
return <ImageEditor src={this.getSrc()} title={this.getTitle()} nodeKey={this.getKey()} width={this.__width} height={this.__height} />
return (
<ImageEditor
src={this.getSrc()}
title={this.getTitle()}
nodeKey={this.getKey()}
width={this.__width}
height={this.__height}
alt={this.__altText}
/>
)
}
}

Expand Down
37 changes: 28 additions & 9 deletions src/plugins/image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,42 @@ export * from './ImageNode'
export type ImageUploadHandler = ((image: File) => Promise<string>) | null
export type ImagePreviewHandler = ((imageSource: string) => Promise<string>) | null

export interface InsertImageFormValues {
src?: string
altText?: string
title?: string
file?: File
}

/** @internal */
export const imageSystem = system(
(r, [{ rootEditor }]) => {
const insertImage = r.node<string>()
const insertImage = r.node<InsertImageFormValues>()
const imageAutocompleteSuggestions = r.node<string[]>([])
const disableImageResize = r.node<boolean>(false)
const imageUploadHandler = r.node<ImageUploadHandler>(null)
const imagePreviewHandler = r.node<ImagePreviewHandler>(null)

r.sub(r.pipe(insertImage, r.o.withLatestFrom(rootEditor)), ([src, theEditor]) => {
theEditor?.update(() => {
const imageNode = $createImageNode({ altText: '', src, title: '' })
$insertNodes([imageNode])
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
}
})
r.sub(r.pipe(insertImage, r.o.withLatestFrom(rootEditor, imageUploadHandler)), ([values, theEditor, imageUploadHandler]) => {
function insertImage(src: string) {
theEditor?.update(() => {
const imageNode = $createImageNode({ altText: values.altText ?? '', src, title: values.title ?? '' })
$insertNodes([imageNode])
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
}
})
}

if (values.file) {
imageUploadHandler?.(values.file)
.then(insertImage)
.catch((e) => {
throw e
})
} else if (values.src) {
insertImage(values.src)
}
})

r.sub(rootEditor, (editor) => {
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/link-dialog/LinkDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function LinkEditForm({ initialUrl, initialTitle, onSubmit, onCancel, lin
</button>
</div>

<div className={styles.linkDialogAutocompleteContainer}>
<div className={styles.downshiftAutocompleteContainer}>
<ul {...getMenuProps()} data-visible={dropdownIsVisible}>
{items.map((item, index: number) => (
<li
Expand Down
173 changes: 163 additions & 10 deletions src/plugins/toolbar/components/InsertImage.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,178 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import React from 'react'
import { imagePluginHooks } from '../../image'
import { DialogButton } from '.././primitives/DialogButton'
import * as RadixToolbar from '@radix-ui/react-toolbar'
import * as Dialog from '@radix-ui/react-dialog'
import AddPhotoIcon from '../../../icons/add_photo.svg'
import styles from '../../../styles/ui.module.css'
import { corePluginHooks } from '../../core/index'
import { TooltipWrap } from '../primitives/TooltipWrap'
import { useCombobox } from 'downshift'
import classNames from 'classnames'
import DropDownIcon from '../../../icons/arrow_drop_down.svg'
import { useForm, Controller, Control } from 'react-hook-form'

interface ImageFormFields {
src?: string
title: string
altText?: string
file?: File
}

const MAX_SUGGESTIONS = 20
/**
* A toolbar button that allows the user to insert an image from an URL.
* For the button to work, you need to have the `imagePlugin` plugin enabled.
*/
export const InsertImage = React.forwardRef<HTMLButtonElement, Record<string, never>>((_, forwardedRef) => {
const [imageAutocompleteSuggestions] = imagePluginHooks.useEmitterValues('imageAutocompleteSuggestions')
const insertImage = imagePluginHooks.usePublisher('insertImage')
const [editorRootElementRef, readOnly] = corePluginHooks.useEmitterValues('editorRootElementRef', 'readOnly')
const [open, setOpen] = React.useState(false)
const { register, handleSubmit, control } = useForm<ImageFormFields>()

return (
<DialogButton
ref={forwardedRef}
submitButtonTitle="Insert Image"
dialogInputPlaceholder="Paste or select image URL"
tooltipTitle="Insert image"
onSubmit={insertImage}
buttonContent={<AddPhotoIcon />}
autocompleteSuggestions={imageAutocompleteSuggestions}
/>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<RadixToolbar.Button className={styles.toolbarButton} ref={forwardedRef} disabled={readOnly}>
<TooltipWrap title="Insert image">
<AddPhotoIcon />
</TooltipWrap>
</RadixToolbar.Button>
</Dialog.Trigger>
<Dialog.Portal container={editorRootElementRef?.current}>
<Dialog.Overlay className={styles.dialogOverlay} />
<Dialog.Content className={styles.dialogContent} onOpenAutoFocus={(e) => e.preventDefault()}>
<form
onSubmit={handleSubmit((data) => {
insertImage(data)
setOpen(false)
})}
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
inputName="src"
suggestions={imageAutocompleteSuggestions}
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)' }}>
<Dialog.Close className={styles.actionButton} asChild>
<button type="reset" title="Cancel change" aria-label="Cancel change" className={classNames(styles.secondaryButton)}>
Cancel
</button>
</Dialog.Close>
<button type="submit" title="Set URL" aria-label="Set URL" className={classNames(styles.primaryButton)}>
Save
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
})

const DownshiftAutoComplete: React.FC<{ suggestions: string[]; control: Control<any, any>; placeholder: string; inputName: string }> = ({
suggestions,
control,
inputName,
placeholder
}) => {
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() || ''
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({
onSelect: field.onChange,
onBlur: field.onBlur,
ref: field.ref
})
return (
<input
{...downshiftSrcProps}
name={field.name}
placeholder={placeholder}
className={styles.downshiftInput}
autoFocus
size={30}
data-editor-dialog={true}
/>
)
}}
/>
{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>
)
}
2 changes: 1 addition & 1 deletion src/plugins/toolbar/primitives/DialogButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const DialogForm: React.FC<{
)}
</div>

<div className={styles.linkDialogAutocompleteContainer}>
<div className={styles.downshiftAutocompleteContainer}>
<ul {...getMenuProps()} data-visible={dropdownIsVisible}>
{items.map((item, index: number) => (
<li
Expand Down
Loading

0 comments on commit dedc614

Please sign in to comment.