Skip to content

Commit

Permalink
Merge pull request #565 from prezly/feature/care-5911-implement-prede…
Browse files Browse the repository at this point in the history
…fined-links-into-the-linking-component

[CARE-5911] Introduce "type" dropdown into the link menu, allow predefined values as links
  • Loading branch information
kudlajz authored Aug 8, 2024
2 parents ec582f5 + 2e9b5d0 commit b2c93e0
Show file tree
Hide file tree
Showing 14 changed files with 203 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ import {
normalizeRedundantLinkAttributes,
parseSerializedLinkElement,
} from './lib';
import type { InlineLinksExtensionConfiguration } from './types';

export const EXTENSION_ID = 'InlineLinksExtension';

export const InlineLinksExtension = (): Extension => ({
export const InlineLinksExtension = ({
predefinedLinks,
}: InlineLinksExtensionConfiguration = {}): Extension => ({
id: EXTENSION_ID,
deserialize: {
element: composeElementDeserializer({
Expand All @@ -46,7 +49,11 @@ export const InlineLinksExtension = (): Extension => ({
renderElement: ({ attributes, children, element }: RenderElementProps) => {
if (isLinkNode(element)) {
return (
<LinkElement attributes={attributes} element={element}>
<LinkElement
attributes={attributes}
element={element}
predefinedLinks={predefinedLinks}
>
{children}
</LinkElement>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import { ReactEditor, useSlateStatic } from 'slate-react';

import { LinkWithTooltip } from '#modules/components';

import type { InlineLinksExtensionConfiguration } from '../types';

import styles from './LinkElement.module.scss';

interface Props extends RenderElementProps {
element: LinkNode;
predefinedLinks: InlineLinksExtensionConfiguration['predefinedLinks'];
}

export function LinkElement({ attributes, children, element }: Props) {
export function LinkElement({ attributes, children, element, predefinedLinks }: Props) {
const editor = useSlateStatic();
const predefinedLink = predefinedLinks?.options.find(({ value }) => value === element.href);

function onMouseUp() {
if (editor.selection && Range.isCollapsed(editor.selection)) {
Expand All @@ -28,7 +32,10 @@ export function LinkElement({ attributes, children, element }: Props) {
// a failed `ReactEditor.toSlateNode` in Slate's Editable onClick handler.
// For more details, see https://github.com/prezly/prezly/pull/8016#discussion_r454190469
<span {...attributes}>
<LinkWithTooltip href={element.href}>
<LinkWithTooltip
href={predefinedLink?.label ?? element.href}
textOnly={predefinedLink !== undefined}
>
{({ ariaAttributes, onHide, onShow, setReferenceElement }) => (
<a
{...ariaAttributes}
Expand Down
1 change: 1 addition & 0 deletions packages/slate-editor/src/extensions/inline-links/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { InlineLinksExtension, EXTENSION_ID } from './InlineLinksExtension';

export { createLink, unwrapLink, wrapInLink } from './lib';
export type { InlineLinksExtensionConfiguration } from './types';
6 changes: 6 additions & 0 deletions packages/slate-editor/src/extensions/inline-links/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface InlineLinksExtensionConfiguration {
predefinedLinks?: {
label: string;
options: { label: string; value: string }[];
};
}
5 changes: 5 additions & 0 deletions packages/slate-editor/src/lib/humanFriendlyEmailUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const MAILTO_PREFIX = /^(mailto):/;

export function humanFriendlyEmailUrl(url: string): string {
return url.replace(MAILTO_PREFIX, '');
}
11 changes: 10 additions & 1 deletion packages/slate-editor/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { ensureElementInView } from './ensureElementInView';
export { ensureRangeInView } from './ensureRangeInView';
export { formatBytes } from './formatBytes';
export { getScrollParent } from './getScrollParent';
export { humanFriendlyEmailUrl } from './humanFriendlyEmailUrl';
export { humanFriendlyUrl } from './humanFriendlyUrl';
export { isCorsEnabledOrigin } from './isCorsEnabledOrigin';
export { isGoogleDocsWrapper } from './isGoogleDocsWrapper';
Expand All @@ -28,5 +29,13 @@ export { scrollTo } from './scrollTo';
export * from './isDeletingEvent';
export * from './stripTags';
export * as utils from './utils';
export { HREF_REGEXP, URL_WITH_OPTIONAL_PROTOCOL_REGEXP, normalizeHref, matchUrls } from './urls';
export {
EMAIL_REGEXP,
HREF_REGEXP,
MAILTO_REGEXP,
URL_WITH_OPTIONAL_PROTOCOL_REGEXP,
normalizeHref,
normalizeMailtoHref,
matchUrls,
} from './urls';
export { withResetFormattingOnBreak } from './withResetFormattingOnBreak';
12 changes: 10 additions & 2 deletions packages/slate-editor/src/lib/urls.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export const URL_PLACEHOLDER_REGEXP = new RegExp('%release\\.url%|%release\\.shorturl%');
export const MAILTO_REGEXP = new RegExp(
'mailto:[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*',
export const EMAIL_REGEXP = new RegExp(
'[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*',
);
export const MAILTO_REGEXP = new RegExp(`mailto:${EMAIL_REGEXP.source}`);
/**
* @see https://regex101.com/r/W0NkQE/2
* For problems, blame Ivan & Lukas :)
Expand Down Expand Up @@ -40,6 +41,13 @@ export function normalizeHref(href: string): string {
return href;
}

export function normalizeMailtoHref(href: string): string {
if (full(EMAIL_REGEXP).test(href)) {
return `mailto:${href}`;
}
return href;
}

/**
* Convert an unbound RegExp object to the same pattern, bound to the string start and end boundaries.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ interface Props {
children: TooltipV2.TooltipProps['children'];
enabled?: boolean;
href: string;
textOnly?: boolean;
}

export function LinkWithTooltip({ children, enabled = true, href }: Props) {
export function LinkWithTooltip({ children, enabled = true, href, textOnly = false }: Props) {
return (
<TooltipV2.Tooltip
className={styles.LinkWithTooltip}
Expand All @@ -26,9 +27,18 @@ export function LinkWithTooltip({ children, enabled = true, href }: Props) {
placement="bottom"
showDelay={SHOW_DELAY}
tooltip={
<a className={styles.Link} href={href} rel="noreferrer noopener" target="_blank">
{href}
</a>
textOnly ? (
href
) : (
<a
className={styles.Link}
href={href}
rel="noreferrer noopener"
target="_blank"
>
{href}
</a>
)
}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ export function* getEnabledExtensions(parameters: Parameters): Generator<Extensi
}

if (withInlineLinks) {
yield InlineLinksExtension();
const config = withInlineLinks === true ? {} : withInlineLinks;
yield InlineLinksExtension(config);
}

// Since we're overriding the default Tab key behavior
Expand Down
3 changes: 2 additions & 1 deletion packages/slate-editor/src/modules/editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { EmbedExtensionConfiguration } from '#extensions/embed';
import type { ExtensionConfiguration as FloatingAddMenuExtensionConfiguration } from '#extensions/floating-add-menu';
import type { GalleriesExtensionConfiguration } from '#extensions/galleries';
import type { ImageExtensionConfiguration } from '#extensions/image';
import type { InlineLinksExtensionConfiguration } from '#extensions/inline-links';
import type {
FetchOEmbedFn,
PlaceholderNode,
Expand Down Expand Up @@ -124,7 +125,7 @@ export interface EditorProps {
withHeadings?: boolean;
withImages?: false | ImageExtensionConfiguration;
withInlineContacts?: PlaceholdersExtensionParameters['withInlineContactPlaceholders'];
withInlineLinks?: boolean;
withInlineLinks?: boolean | InlineLinksExtensionConfiguration;
withLists?: boolean;
withPlaceholders?: Pick<
PlaceholdersExtensionParameters,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.dropdown {
width: 100% !important;
}
141 changes: 122 additions & 19 deletions packages/slate-editor/src/modules/rich-formatting-menu/LinkMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@ import { useState } from 'react';
import { useRootClose } from 'react-overlays';

import type { OptionsGroupOption } from '#components';
import { Button, Input, OptionsGroup, Toggle, Toolbox, VStack } from '#components';
import { Button, Input, Menu, OptionsGroup, Toggle, Toolbox, VStack } from '#components';
import { Delete, Link } from '#icons';
import { HREF_REGEXP, normalizeHref } from '#lib';
import {
EMAIL_REGEXP,
HREF_REGEXP,
humanFriendlyEmailUrl,
MAILTO_REGEXP,
normalizeHref,
normalizeMailtoHref,
} from '#lib';

import type { FetchOEmbedFn, Presentation } from './types';
import type { InlineLinksExtensionConfiguration } from '#extensions/inline-links';

import styles from './LinkMenu.module.scss';
import type { FetchOEmbedFn, LinkType, Presentation } from './types';

const PRESENTATION_OPTIONS: OptionsGroupOption<Presentation>[] = [
{
Expand All @@ -35,6 +45,7 @@ interface Props {
onClose: () => void;
onConvert?: (presentation: Presentation) => void;
onUnlink: () => void;
predefinedLinks: InlineLinksExtensionConfiguration['predefinedLinks'];
}

export function LinkMenu({
Expand All @@ -47,19 +58,59 @@ export function LinkMenu({
onClose,
onConvert,
onUnlink,
predefinedLinks,
}: Props) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const [href, setHref] = useState(node?.href ?? '');
const [new_tab, setNewTab] = useState(node?.new_tab ?? true);
const [newTab, setNewTab] = useState(node?.new_tab ?? true);
const [type, setType] = useState<LinkType>(detectLinkType(href, predefinedLinks));

const isConvertable = type === 'url';

function handleSave() {
onChange({ href: normalizeHref(href), new_tab });
let normalizedHref = href;
if (type === 'url') {
normalizedHref = normalizeHref(href);
}

if (type === 'email') {
normalizedHref = normalizeMailtoHref(href);
}

onChange({
href: normalizedHref,
new_tab: newTab,
});
}

function handleChangeType(nextType: LinkType) {
if (nextType === 'predefined' && predefinedLinks) {
setType(nextType);
setHref(predefinedLinks.options[0].value);
return;
}

setType(nextType);
setHref('');
}

function getTypeOptions(): OptionsGroupOption<LinkType>[] {
const options: OptionsGroupOption<LinkType>[] = [
{ label: 'Web', value: 'url' },
{ label: 'Email', value: 'email' },
];

if (predefinedLinks) {
return [...options, { label: predefinedLinks.label, value: 'predefined' }];
}

return options;
}

useRootClose(rootRef, onBlur);

return (
<Toolbox.Panel style={{ width: 320 }} ref={rootRef}>
<Toolbox.Panel style={{ width: 280 }} ref={rootRef}>
<Toolbox.Header withCloseButton onCloseClick={onClose}>
Link settings
</Toolbox.Header>
Expand All @@ -73,29 +124,59 @@ export function LinkMenu({
<VStack spacing="2">
<VStack spacing="2-5">
<VStack spacing="1-5">
<Toolbox.Caption>Link</Toolbox.Caption>
<Input
autoFocus
name="href"
value={href}
onChange={setHref}
icon={Link}
pattern={HREF_REGEXP.source}
placeholder="Paste link"
title="Please input a valid URL"
<Toolbox.Caption>Link to</Toolbox.Caption>
<Menu.Dropdown
className={styles.dropdown}
onChange={handleChangeType}
options={getTypeOptions()}
value={type}
variant="light"
/>
{type === 'url' && (
<Input
autoFocus
name="href"
value={href}
onChange={setHref}
icon={Link}
pattern={HREF_REGEXP.source}
placeholder="Paste link"
title="Please input a valid URL"
/>
)}
{type === 'email' && (
<Input
autoFocus
name="email"
value={humanFriendlyEmailUrl(href)}
onChange={setHref}
pattern={EMAIL_REGEXP.source}
placeholder="Paste email address"
type="email"
title="Please input a valid email address"
/>
)}
{type === 'predefined' && predefinedLinks && (
<Menu.Dropdown
className={styles.dropdown}
onChange={setHref}
options={predefinedLinks.options}
value={href}
variant="light"
/>
)}
</VStack>

{withNewTabOption && (
<Toggle name="new_tab" value={new_tab} onChange={setNewTab}>
<Toggle name="new_tab" value={newTab} onChange={setNewTab}>
Open in new tab
</Toggle>
)}
</VStack>
</VStack>
</Toolbox.Section>

{withConversionOptions && onConvert && (
{withConversionOptions && onConvert && isConvertable && (
<Toolbox.Section caption="Change to...">
<OptionsGroup
name="presentation"
Expand All @@ -108,7 +189,14 @@ export function LinkMenu({
)}

<Toolbox.Section>
<Button variant="primary" type="submit" fullWidth round disabled={!href}>
<Button
variant="primary"
type="submit"
fullWidth
round
size="small"
disabled={!href}
>
Save
</Button>
</Toolbox.Section>
Expand All @@ -127,3 +215,18 @@ export function LinkMenu({
</Toolbox.Panel>
);
}

function detectLinkType(
href: string,
predefinedLinks: InlineLinksExtensionConfiguration['predefinedLinks'],
): LinkType {
if (predefinedLinks && predefinedLinks.options.some(({ value }) => href === value)) {
return 'predefined';
}

if (MAILTO_REGEXP.test(href)) {
return 'email';
}

return 'url';
}
Loading

0 comments on commit b2c93e0

Please sign in to comment.