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 { 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,
|
||||||
|
@ -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, '/');
|
||||||
|
@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user