fix: markdown links (#800)
This commit is contained in:
parent
d6a44bede0
commit
e633c75bbc
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
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 mediaPathSelector = useMemo(() => selectMediaPath(finalControlID), [finalControlID]);
|
||||
const mediaPath = useAppSelector(mediaPathSelector);
|
||||
const [selected, setSelected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && mediaPath && (mediaPath.path !== value.path || mediaPath.alt !== value.alt)) {
|
||||
setSelected(true);
|
||||
if (mediaPath && (mediaPath.path !== value.path || mediaPath.alt !== value.alt)) {
|
||||
setTimeout(() => {
|
||||
callback(mediaPath as MediaPath<T>);
|
||||
dispatch(removeInsertedMedia(finalControlID));
|
||||
});
|
||||
}
|
||||
}, [callback, finalControlID, dispatch, mediaPath, value, selected]);
|
||||
}, [callback, finalControlID, dispatch, mediaPath, value]);
|
||||
|
||||
const handleOpenMediaLibrary = useCallback(
|
||||
(e?: MouseEvent, { replaceIndex }: { replaceIndex?: number } = {}) => {
|
||||
@ -69,7 +67,6 @@ export default function useMediaInsert<T extends string | string[], F extends Me
|
||||
insertOptions,
|
||||
}),
|
||||
);
|
||||
setSelected(false);
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
|
@ -1,4 +1,4 @@
|
||||
const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i');
|
||||
const absolutePath = new RegExp('^(?:(?:[a-z]+:)?//)|(?:mailto:)|(?:tel:)', 'i');
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.replace(/[\\/]+/g, '/');
|
||||
|
@ -249,42 +249,44 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
|
||||
left: `${selectionBoundingClientRect?.x}px`,
|
||||
}}
|
||||
/>
|
||||
<PopperUnstyled
|
||||
open={Boolean(debouncedOpen && anchorEl.current)}
|
||||
component="div"
|
||||
placement="top"
|
||||
anchorEl={anchorEl.current ?? null}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className="
|
||||
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"
|
||||
{groups.length > 0 || debouncedGroups.length > 0 ? (
|
||||
<PopperUnstyled
|
||||
open={Boolean(debouncedOpen && anchorEl.current)}
|
||||
component="div"
|
||||
placement="top"
|
||||
anchorEl={anchorEl.current ?? null}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={0}
|
||||
className="
|
||||
flex
|
||||
gap-0.5
|
||||
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
|
||||
"
|
||||
>
|
||||
{groups.length > 0 ? groups : debouncedGroups}
|
||||
</div>
|
||||
</PopperUnstyled>
|
||||
<div
|
||||
data-testid="balloon-toolbar"
|
||||
className="
|
||||
flex
|
||||
gap-0.5
|
||||
"
|
||||
>
|
||||
{groups.length > 0 ? groups : debouncedGroups}
|
||||
</div>
|
||||
</PopperUnstyled>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Image as ImageIcon } from '@styled-icons/material/Image';
|
||||
import { insertImage } from '@udecode/plate';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ELEMENT_IMAGE, getAboveNode, setNodes } from '@udecode/plate';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
|
||||
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 type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface';
|
||||
import type { MdImageElement } from '@staticcms/markdown/plate/plateTypes';
|
||||
import type { FC } from 'react';
|
||||
import type { BaseSelection } from 'slate';
|
||||
|
||||
export interface InsertImageToolbarButtonProps {
|
||||
variant: 'button' | 'menu';
|
||||
@ -25,14 +27,42 @@ const InsertImageToolbarButton: FC<InsertImageToolbarButtonProps> = ({
|
||||
currentValue,
|
||||
disabled,
|
||||
}) => {
|
||||
const [selection, setSelection] = useState<BaseSelection>();
|
||||
const editor = useMdPlateEditorState();
|
||||
const handleInsert = useCallback(
|
||||
(newUrl: MediaPath<string>) => {
|
||||
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]);
|
||||
@ -46,12 +76,17 @@ const InsertImageToolbarButton: FC<InsertImageToolbarButtonProps> = ({
|
||||
handleInsert,
|
||||
);
|
||||
|
||||
const handleOpenMediaLibrary = useCallback(() => {
|
||||
setSelection(editor.selection);
|
||||
openMediaLibrary();
|
||||
}, [editor.selection, openMediaLibrary]);
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
label="Image"
|
||||
tooltip="Insert image"
|
||||
icon={ImageIcon}
|
||||
onClick={openMediaLibrary}
|
||||
onClick={handleOpenMediaLibrary}
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
/>
|
||||
|
@ -1,6 +1,15 @@
|
||||
import { Link as LinkIcon } from '@styled-icons/material/Link';
|
||||
import { ELEMENT_LINK, getSelectionText, insertLink, someNode } from '@udecode/plate';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
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 useUUID from '@staticcms/core/lib/hooks/useUUID';
|
||||
@ -9,7 +18,10 @@ import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
|
||||
import ToolbarButton from './common/ToolbarButton';
|
||||
|
||||
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 { Path } from 'slate';
|
||||
|
||||
export interface InsertLinkToolbarButtonProps {
|
||||
variant: 'button' | 'menu';
|
||||
@ -26,18 +38,49 @@ const InsertLinkToolbarButton: FC<InsertLinkToolbarButtonProps> = ({
|
||||
currentValue,
|
||||
disabled,
|
||||
}) => {
|
||||
const [selection, setSelection] = useState<Path>();
|
||||
const editor = useMdPlateEditorState();
|
||||
const handleInsert = useCallback(
|
||||
({ 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(
|
||||
editor,
|
||||
{ url: newUrl, text: isNotEmpty(newText) ? newText : newUrl },
|
||||
{ at: editor.selection ?? editor.prevSelection! },
|
||||
{ url: newUrl, text },
|
||||
{
|
||||
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]);
|
||||
@ -62,12 +105,17 @@ const InsertLinkToolbarButton: FC<InsertLinkToolbarButtonProps> = ({
|
||||
handleInsert,
|
||||
);
|
||||
|
||||
const handleOpenMediaLibrary = useCallback(() => {
|
||||
setSelection(editor.selection?.focus.path);
|
||||
openMediaLibrary();
|
||||
}, [editor.selection, openMediaLibrary]);
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
label="Link"
|
||||
tooltip="Insert link"
|
||||
icon={LinkIcon}
|
||||
onClick={openMediaLibrary}
|
||||
onClick={handleOpenMediaLibrary}
|
||||
active={isLink}
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
setSelection,
|
||||
usePlateSelection,
|
||||
} 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 useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
||||
@ -32,8 +32,7 @@ const withImageElement = ({ collection, entry, field }: WithImageElementProps) =
|
||||
editor,
|
||||
children,
|
||||
}) => {
|
||||
const { url, alt } = element;
|
||||
const [internalValue, setInternalValue] = useState<MediaPath<string>>({ path: url, alt });
|
||||
const { url, alt } = useMemo(() => element, [element]);
|
||||
const [popoverHasFocus, setPopoverHasFocus] = useState(false);
|
||||
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
|
||||
|
||||
@ -100,7 +99,6 @@ const withImageElement = ({ collection, entry, field }: WithImageElementProps) =
|
||||
(newValue: MediaPath<string>) => {
|
||||
handleChange(newValue.path, 'url');
|
||||
handleChange(newValue.alt ?? '', 'alt');
|
||||
setInternalValue(newValue);
|
||||
},
|
||||
[handleChange],
|
||||
);
|
||||
@ -179,8 +177,8 @@ const withImageElement = ({ collection, entry, field }: WithImageElementProps) =
|
||||
anchorEl={anchorEl}
|
||||
collection={collection}
|
||||
field={field}
|
||||
url={internalValue.path}
|
||||
text={internalValue.alt}
|
||||
url={url}
|
||||
text={alt}
|
||||
onMediaChange={handleMediaChange}
|
||||
onRemove={handleRemove}
|
||||
forImage
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
unwrapLink,
|
||||
usePlateSelection,
|
||||
} 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 useDebounce from '@staticcms/core/lib/hooks/useDebounce';
|
||||
@ -34,13 +34,10 @@ const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
|
||||
}) => {
|
||||
const urlRef = useRef<HTMLAnchorElement | null>(null);
|
||||
|
||||
const { url } = element;
|
||||
const path = findNodePath(editor, element);
|
||||
|
||||
const [internalValue, setInternalValue] = useState<MediaPath<string>>({
|
||||
path: url,
|
||||
alt: getEditorString(editor, path),
|
||||
});
|
||||
const { url } = useMemo(() => element, [element]);
|
||||
const alt = useMemo(() => getEditorString(editor, path), [editor, path]);
|
||||
const [popoverHasFocus, setPopoverHasFocus] = useState(false);
|
||||
const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100);
|
||||
|
||||
@ -114,7 +111,6 @@ const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
|
||||
const handleMediaChange = useCallback(
|
||||
(newValue: MediaPath<string>) => {
|
||||
handleChange(newValue.path, newValue.alt ?? '');
|
||||
setInternalValue(newValue);
|
||||
},
|
||||
[handleChange],
|
||||
);
|
||||
@ -217,8 +213,8 @@ const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
|
||||
anchorEl={anchorEl}
|
||||
collection={collection}
|
||||
field={field}
|
||||
url={internalValue.path}
|
||||
text={internalValue.alt}
|
||||
url={url}
|
||||
text={alt}
|
||||
onMediaChange={handleMediaChange}
|
||||
onRemove={handleRemove}
|
||||
onFocus={handlePopoverFocus}
|
||||
|
Loading…
x
Reference in New Issue
Block a user