fix: markdown links (#800)

This commit is contained in:
Daniel Lautzenheiser 2023-05-12 10:44:49 -04:00 committed by GitHub
parent d6a44bede0
commit e633c75bbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 143 additions and 67 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { openMediaLibrary, removeInsertedMedia } from '@staticcms/core/actions/mediaLibrary'; import { openMediaLibrary, removeInsertedMedia } from '@staticcms/core/actions/mediaLibrary';
@ -39,17 +39,15 @@ export default function useMediaInsert<T extends string | string[], F extends Me
const finalControlID = useMemo(() => controlID ?? uuid(), [controlID]); const finalControlID = useMemo(() => controlID ?? uuid(), [controlID]);
const mediaPathSelector = useMemo(() => selectMediaPath(finalControlID), [finalControlID]); const mediaPathSelector = useMemo(() => selectMediaPath(finalControlID), [finalControlID]);
const mediaPath = useAppSelector(mediaPathSelector); const mediaPath = useAppSelector(mediaPathSelector);
const [selected, setSelected] = useState(false);
useEffect(() => { useEffect(() => {
if (!selected && mediaPath && (mediaPath.path !== value.path || mediaPath.alt !== value.alt)) { if (mediaPath && (mediaPath.path !== value.path || mediaPath.alt !== value.alt)) {
setSelected(true);
setTimeout(() => { setTimeout(() => {
callback(mediaPath as MediaPath<T>); callback(mediaPath as MediaPath<T>);
dispatch(removeInsertedMedia(finalControlID)); dispatch(removeInsertedMedia(finalControlID));
}); });
} }
}, [callback, finalControlID, dispatch, mediaPath, value, selected]); }, [callback, finalControlID, dispatch, mediaPath, value]);
const handleOpenMediaLibrary = useCallback( const handleOpenMediaLibrary = useCallback(
(e?: MouseEvent, { replaceIndex }: { replaceIndex?: number } = {}) => { (e?: MouseEvent, { replaceIndex }: { replaceIndex?: number } = {}) => {
@ -69,7 +67,6 @@ export default function useMediaInsert<T extends string | string[], F extends Me
insertOptions, insertOptions,
}), }),
); );
setSelected(false);
}, },
[ [
dispatch, dispatch,

View File

@ -1,4 +1,4 @@
const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i'); const absolutePath = new RegExp('^(?:(?:[a-z]+:)?//)|(?:mailto:)|(?:tel:)', 'i');
function normalizePath(path: string) { function normalizePath(path: string) {
return path.replace(/[\\/]+/g, '/'); return path.replace(/[\\/]+/g, '/');

View File

@ -249,42 +249,44 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
left: `${selectionBoundingClientRect?.x}px`, left: `${selectionBoundingClientRect?.x}px`,
}} }}
/> />
<PopperUnstyled {groups.length > 0 || debouncedGroups.length > 0 ? (
open={Boolean(debouncedOpen && anchorEl.current)} <PopperUnstyled
component="div" open={Boolean(debouncedOpen && anchorEl.current)}
placement="top" component="div"
anchorEl={anchorEl.current ?? null} placement="top"
onFocus={handleFocus} anchorEl={anchorEl.current ?? null}
onBlur={handleBlur} onFocus={handleFocus}
tabIndex={0} onBlur={handleBlur}
className=" tabIndex={0}
absolute
max-h-60
overflow-auto
rounded-md
bg-white
p-1
text-base
shadow-lg
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
"
>
<div
data-testid="balloon-toolbar"
className=" className="
flex absolute
gap-0.5 max-h-60
overflow-auto
rounded-md
bg-white
p-1
text-base
shadow-lg
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
" "
> >
{groups.length > 0 ? groups : debouncedGroups} <div
</div> data-testid="balloon-toolbar"
</PopperUnstyled> className="
flex
gap-0.5
"
>
{groups.length > 0 ? groups : debouncedGroups}
</div>
</PopperUnstyled>
) : null}
</> </>
); );
}; };

View File

@ -1,6 +1,6 @@
import { Image as ImageIcon } from '@styled-icons/material/Image'; import { Image as ImageIcon } from '@styled-icons/material/Image';
import { insertImage } from '@udecode/plate'; import { ELEMENT_IMAGE, getAboveNode, setNodes } from '@udecode/plate';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert'; import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
@ -8,7 +8,9 @@ import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './common/ToolbarButton'; import ToolbarButton from './common/ToolbarButton';
import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface'; import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface';
import type { MdImageElement } from '@staticcms/markdown/plate/plateTypes';
import type { FC } from 'react'; import type { FC } from 'react';
import type { BaseSelection } from 'slate';
export interface InsertImageToolbarButtonProps { export interface InsertImageToolbarButtonProps {
variant: 'button' | 'menu'; variant: 'button' | 'menu';
@ -25,14 +27,42 @@ const InsertImageToolbarButton: FC<InsertImageToolbarButtonProps> = ({
currentValue, currentValue,
disabled, disabled,
}) => { }) => {
const [selection, setSelection] = useState<BaseSelection>();
const editor = useMdPlateEditorState(); const editor = useMdPlateEditorState();
const handleInsert = useCallback( const handleInsert = useCallback(
(newUrl: MediaPath<string>) => { (newUrl: MediaPath<string>) => {
if (isNotEmpty(newUrl.path)) { if (isNotEmpty(newUrl.path)) {
insertImage(editor, newUrl.path); const image: MdImageElement = {
type: ELEMENT_IMAGE,
url: newUrl.path,
children: [{ text: '' }],
};
const imageAbove = getAboveNode<MdImageElement>(editor, {
at: selection?.focus,
match: { type: ELEMENT_IMAGE },
});
if (imageAbove) {
if (newUrl.path !== imageAbove[0]?.url || newUrl.alt !== imageAbove[0]?.alt) {
setNodes<MdImageElement>(
editor,
{ url: newUrl.path, alt: newUrl.alt },
{
at: imageAbove[1],
},
);
}
return;
}
setNodes<MdImageElement>(editor, image, {
at: selection?.focus,
});
} }
}, },
[editor], [editor, selection],
); );
const chooseUrl = useMemo(() => field.choose_url ?? true, [field.choose_url]); const chooseUrl = useMemo(() => field.choose_url ?? true, [field.choose_url]);
@ -46,12 +76,17 @@ const InsertImageToolbarButton: FC<InsertImageToolbarButtonProps> = ({
handleInsert, handleInsert,
); );
const handleOpenMediaLibrary = useCallback(() => {
setSelection(editor.selection);
openMediaLibrary();
}, [editor.selection, openMediaLibrary]);
return ( return (
<ToolbarButton <ToolbarButton
label="Image" label="Image"
tooltip="Insert image" tooltip="Insert image"
icon={ImageIcon} icon={ImageIcon}
onClick={openMediaLibrary} onClick={handleOpenMediaLibrary}
disabled={disabled} disabled={disabled}
variant={variant} variant={variant}
/> />

View File

@ -1,6 +1,15 @@
import { Link as LinkIcon } from '@styled-icons/material/Link'; import { Link as LinkIcon } from '@styled-icons/material/Link';
import { ELEMENT_LINK, getSelectionText, insertLink, someNode } from '@udecode/plate'; import {
import React, { useCallback, useMemo } from 'react'; ELEMENT_LINK,
getEditorString,
getNode,
getSelectionText,
insertLink,
replaceNodeChildren,
setNodes,
someNode,
} from '@udecode/plate';
import React, { useCallback, useMemo, useState } from 'react';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert'; import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
@ -9,7 +18,10 @@ import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './common/ToolbarButton'; import ToolbarButton from './common/ToolbarButton';
import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface'; import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface';
import type { MdLinkElement } from '@staticcms/markdown/plate/plateTypes';
import type { TText } from '@udecode/plate';
import type { FC } from 'react'; import type { FC } from 'react';
import type { Path } from 'slate';
export interface InsertLinkToolbarButtonProps { export interface InsertLinkToolbarButtonProps {
variant: 'button' | 'menu'; variant: 'button' | 'menu';
@ -26,18 +38,49 @@ const InsertLinkToolbarButton: FC<InsertLinkToolbarButtonProps> = ({
currentValue, currentValue,
disabled, disabled,
}) => { }) => {
const [selection, setSelection] = useState<Path>();
const editor = useMdPlateEditorState(); const editor = useMdPlateEditorState();
const handleInsert = useCallback( const handleInsert = useCallback(
({ path: newUrl, alt: newText }: MediaPath<string>) => { ({ path: newUrl, alt: newText }: MediaPath<string>) => {
if (isNotEmpty(newUrl)) { if (isNotEmpty(newUrl) && selection) {
const text = isNotEmpty(newText) ? newText : newUrl;
const linkAt = getNode<MdLinkElement>(editor, selection);
if (linkAt && linkAt.type === ELEMENT_LINK) {
if (newUrl !== linkAt.url || text !== linkAt.children[0].text) {
setNodes<MdLinkElement>(
editor,
{ url: newUrl, children: [{ text: newText }] },
{ at: selection },
);
if (text !== getEditorString(editor, selection)) {
replaceNodeChildren<TText>(editor, {
at: selection,
nodes: { text },
insertOptions: {
select: true,
},
});
}
}
return;
}
insertLink( insertLink(
editor, editor,
{ url: newUrl, text: isNotEmpty(newText) ? newText : newUrl }, { url: newUrl, text },
{ at: editor.selection ?? editor.prevSelection! }, {
at: selection,
},
); );
const newSelection = [...selection];
const lastIndex = newSelection.pop() ?? 0;
setSelection([...newSelection, lastIndex + 1]);
} }
}, },
[editor], [editor, selection],
); );
const chooseUrl = useMemo(() => field.choose_url ?? true, [field.choose_url]); const chooseUrl = useMemo(() => field.choose_url ?? true, [field.choose_url]);
@ -62,12 +105,17 @@ const InsertLinkToolbarButton: FC<InsertLinkToolbarButtonProps> = ({
handleInsert, handleInsert,
); );
const handleOpenMediaLibrary = useCallback(() => {
setSelection(editor.selection?.focus.path);
openMediaLibrary();
}, [editor.selection, openMediaLibrary]);
return ( return (
<ToolbarButton <ToolbarButton
label="Link" label="Link"
tooltip="Insert link" tooltip="Insert link"
icon={LinkIcon} icon={LinkIcon}
onClick={openMediaLibrary} onClick={handleOpenMediaLibrary}
active={isLink} active={isLink}
disabled={disabled} disabled={disabled}
variant={variant} variant={variant}

View File

@ -6,7 +6,7 @@ import {
setSelection, setSelection,
usePlateSelection, usePlateSelection,
} from '@udecode/plate'; } from '@udecode/plate';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFocused } from 'slate-react'; import { useFocused } from 'slate-react';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
@ -32,8 +32,7 @@ const withImageElement = ({ collection, entry, field }: WithImageElementProps) =
editor, editor,
children, children,
}) => { }) => {
const { url, alt } = element; const { url, alt } = useMemo(() => element, [element]);
const [internalValue, setInternalValue] = useState<MediaPath<string>>({ path: url, alt });
const [popoverHasFocus, setPopoverHasFocus] = useState(false); const [popoverHasFocus, setPopoverHasFocus] = useState(false);
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100); const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
@ -100,7 +99,6 @@ const withImageElement = ({ collection, entry, field }: WithImageElementProps) =
(newValue: MediaPath<string>) => { (newValue: MediaPath<string>) => {
handleChange(newValue.path, 'url'); handleChange(newValue.path, 'url');
handleChange(newValue.alt ?? '', 'alt'); handleChange(newValue.alt ?? '', 'alt');
setInternalValue(newValue);
}, },
[handleChange], [handleChange],
); );
@ -179,8 +177,8 @@ const withImageElement = ({ collection, entry, field }: WithImageElementProps) =
anchorEl={anchorEl} anchorEl={anchorEl}
collection={collection} collection={collection}
field={field} field={field}
url={internalValue.path} url={url}
text={internalValue.alt} text={alt}
onMediaChange={handleMediaChange} onMediaChange={handleMediaChange}
onRemove={handleRemove} onRemove={handleRemove}
forImage forImage

View File

@ -8,7 +8,7 @@ import {
unwrapLink, unwrapLink,
usePlateSelection, usePlateSelection,
} from '@udecode/plate'; } from '@udecode/plate';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFocused } from 'slate-react'; import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
@ -34,13 +34,10 @@ const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
}) => { }) => {
const urlRef = useRef<HTMLAnchorElement | null>(null); const urlRef = useRef<HTMLAnchorElement | null>(null);
const { url } = element;
const path = findNodePath(editor, element); const path = findNodePath(editor, element);
const [internalValue, setInternalValue] = useState<MediaPath<string>>({ const { url } = useMemo(() => element, [element]);
path: url, const alt = useMemo(() => getEditorString(editor, path), [editor, path]);
alt: getEditorString(editor, path),
});
const [popoverHasFocus, setPopoverHasFocus] = useState(false); const [popoverHasFocus, setPopoverHasFocus] = useState(false);
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100); const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
@ -114,7 +111,6 @@ const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
const handleMediaChange = useCallback( const handleMediaChange = useCallback(
(newValue: MediaPath<string>) => { (newValue: MediaPath<string>) => {
handleChange(newValue.path, newValue.alt ?? ''); handleChange(newValue.path, newValue.alt ?? '');
setInternalValue(newValue);
}, },
[handleChange], [handleChange],
); );
@ -217,8 +213,8 @@ const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
anchorEl={anchorEl} anchorEl={anchorEl}
collection={collection} collection={collection}
field={field} field={field}
url={internalValue.path} url={url}
text={internalValue.alt} text={alt}
onMediaChange={handleMediaChange} onMediaChange={handleMediaChange}
onRemove={handleRemove} onRemove={handleRemove}
onFocus={handlePopoverFocus} onFocus={handlePopoverFocus}