diff --git a/src/examples/images.tsx b/src/examples/images.tsx index 17bb535a..4fac0013 100644 --- a/src/examples/images.tsx +++ b/src/examples/images.tsx @@ -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 @@ -69,3 +69,24 @@ export function ImageWithPreviewHook() { ) } + +export function ImageDialogButtonExample() { + return ( + <> + Promise.resolve('https://picsum.photos/200/300?grayscale'), + imageAutocompleteSuggestions: ['https://via.placeholder.com/150', 'https://via.placeholder.com/250'] + }), + diffSourcePlugin(), + jsxPlugin(), + toolbarPlugin({ toolbarContents: () => }) + ]} + onChange={console.log} + /> + + ) +} diff --git a/src/plugins/image/ImageEditor.tsx b/src/plugins/image/ImageEditor.tsx index 54535f1d..9f8a14b3 100644 --- a/src/plugins/image/ImageEditor.tsx +++ b/src/plugins/image/ImageEditor.tsx @@ -28,6 +28,7 @@ import { imagePluginHooks } from '.' export interface ImageEditorProps { nodeKey: string src: string + alt?: string title?: string width: number | 'inherit' height: number | 'inherit' @@ -50,6 +51,7 @@ function useSuspenseImage(src: string) { function LazyImage({ title, + alt, className, imageRef, src, @@ -57,6 +59,7 @@ function LazyImage({ height }: { title: string + alt: string className: string | null imageRef: { current: null | HTMLImageElement } src: string @@ -64,10 +67,21 @@ function LazyImage({ height: number | 'inherit' }): JSX.Element { useSuspenseImage(src) - return + return ( + {alt} + ) } -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) const buttonRef = React.useRef(null) const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey) @@ -235,6 +249,7 @@ export function ImageEditor({ src, title, nodeKey, width, height }: ImageEditorP })} src={imageSource} title={title || ''} + alt={alt || ''} imageRef={imageRef} /> diff --git a/src/plugins/image/ImageNode.tsx b/src/plugins/image/ImageNode.tsx index ae83e8b5..7a86ac33 100644 --- a/src/plugins/image/ImageNode.tsx +++ b/src/plugins/image/ImageNode.tsx @@ -171,7 +171,16 @@ export class ImageNode extends DecoratorNode { } decorate(_parentEditor: LexicalEditor): JSX.Element { - return + return ( + + ) } } diff --git a/src/plugins/image/index.ts b/src/plugins/image/index.ts index 77635875..c524afef 100644 --- a/src/plugins/image/index.ts +++ b/src/plugins/image/index.ts @@ -31,23 +31,42 @@ export * from './ImageNode' export type ImageUploadHandler = ((image: File) => Promise) | null export type ImagePreviewHandler = ((imageSource: string) => Promise) | null +export interface InsertImageFormValues { + src?: string + altText?: string + title?: string + file?: File +} + /** @internal */ export const imageSystem = system( (r, [{ rootEditor }]) => { - const insertImage = r.node() + const insertImage = r.node() const imageAutocompleteSuggestions = r.node([]) const disableImageResize = r.node(false) const imageUploadHandler = r.node(null) const imagePreviewHandler = r.node(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) => { diff --git a/src/plugins/link-dialog/LinkDialog.tsx b/src/plugins/link-dialog/LinkDialog.tsx index 94e582fa..8b3b0e8b 100644 --- a/src/plugins/link-dialog/LinkDialog.tsx +++ b/src/plugins/link-dialog/LinkDialog.tsx @@ -102,7 +102,7 @@ export function LinkEditForm({ initialUrl, initialTitle, onSubmit, onCancel, lin -
+
    {items.map((item, index: number) => (
  • >((_, 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() return ( - } - autocompleteSuggestions={imageAutocompleteSuggestions} - /> + + + + + + + + + + + e.preventDefault()}> +
    { + insertImage(data) + setOpen(false) + })} + className={styles.multiFieldForm} + > +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + + +
    +
    +
    +
    +
    ) }) + +const DownshiftAutoComplete: React.FC<{ suggestions: string[]; control: Control; 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 ( +
    +
    + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const downshiftSrcProps = getInputProps({ + onSelect: field.onChange, + onBlur: field.onBlur, + ref: field.ref + }) + return ( + + ) + }} + /> + {enableAutoComplete && ( + + )} +
    + +
    +
      + {items.map((item, index: number) => ( +
    • + {item} +
    • + ))} +
    +
    +
    + ) +} diff --git a/src/plugins/toolbar/primitives/DialogButton.tsx b/src/plugins/toolbar/primitives/DialogButton.tsx index 2cb5c9fa..c3a2f91d 100644 --- a/src/plugins/toolbar/primitives/DialogButton.tsx +++ b/src/plugins/toolbar/primitives/DialogButton.tsx @@ -171,7 +171,7 @@ const DialogForm: React.FC<{ )}
-
+
    {items.map((item, index: number) => (
  • button { + @mixin clear-form-element; + padding-right: var(--spacing-2); + } +} + +.downshiftInput { + @mixin clear-form-element; + width: 20rem; + padding: var(--spacing-2) var(--spacing-3); + font-size: var(--text-sm); + &::placeholder { + color: var(--baseBorder); + } +} + +.downshiftAutocompleteContainer { + position: relative; + + & ul { + all: unset; + box-sizing: border-box; + position: absolute; + font-size: var(--text-sm); + width: 100%; + display: none; + border-bottom-left-radius: var(--radius-medium) ; + border-bottom-right-radius: var(--radius-medium) ; + max-height: var(--spacing-48); + overflow-x: hidden; + overflow-y: auto; + border: 1px solid var(--baseBorder); + border-top-width: 0; + background-color: var(--baseBase); + + &[data-visible=true] { + display: block; + } + + & li { + padding: var(--spacing-2) var(--spacing-3); + white-space: nowrap; + margin-bottom: var(--spacing-1); + overflow-x: hidden; + text-overflow: ellipsis; + &[data-selected=true] { + background-color: var(--baseBgSubtle); + } + + &[data-highlighted=true] { + background-color: var(--baseBgHover); + } + + &:last-of-type { + border-bottom-left-radius: var(--radius-medium) ; + border-bottom-right-radius: var(--radius-medium) ; + } + } + } +} + +.textInput { + all: unset; + border-radius: var(--radius-base); + border:1px solid var(--baseBorder); + padding: var(--spacing-2) var(--spacing-3); +} + +form.multiFieldForm { + display: flex; + flex-direction: column; + padding: var(--spacing-4); + gap: var(--spacing-4); + + .formField { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + } +} + +