@wordpress/format-library
Version:
Format library for the WordPress editor.
246 lines (232 loc) • 8.46 kB
JavaScript
/**
* WordPress dependencies
*/
import { useMemo, createInterpolateElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import { Popover } from '@wordpress/components';
import { prependHTTP } from '@wordpress/url';
import { create, insert, isCollapsed, applyFormat, removeFormat, slice, replace, split, concat, useAnchor } from '@wordpress/rich-text';
import { LinkControl, store as blockEditorStore } from '@wordpress/block-editor';
import { useDispatch, useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { createLinkFormat, isValidHref, getFormatBoundary } from './utils';
import { link as settings } from './index';
import { jsx as _jsx } from "react/jsx-runtime";
const LINK_SETTINGS = [...LinkControl.DEFAULT_LINK_SETTINGS, {
id: 'nofollow',
title: __('Mark as nofollow')
}];
function InlineLinkUI({
isActive,
activeAttributes,
value,
onChange,
onFocusOutside,
stopAddingLink,
contentRef,
focusOnMount
}) {
const richLinkTextValue = getRichTextValueFromSelection(value, isActive);
// Get the text content minus any HTML tags.
const richTextText = richLinkTextValue.text;
const {
selectionChange
} = useDispatch(blockEditorStore);
const {
createPageEntity,
userCanCreatePages,
selectionStart
} = useSelect(select => {
const {
getSettings,
getSelectionStart
} = select(blockEditorStore);
const _settings = getSettings();
return {
createPageEntity: _settings.__experimentalCreatePageEntity,
userCanCreatePages: _settings.__experimentalUserCanCreatePages,
selectionStart: getSelectionStart()
};
}, []);
const linkValue = useMemo(() => ({
url: activeAttributes.url,
type: activeAttributes.type,
id: activeAttributes.id,
opensInNewTab: activeAttributes.target === '_blank',
nofollow: activeAttributes.rel?.includes('nofollow'),
title: richTextText
}), [activeAttributes.id, activeAttributes.rel, activeAttributes.target, activeAttributes.type, activeAttributes.url, richTextText]);
function removeLink() {
const newValue = removeFormat(value, 'core/link');
onChange(newValue);
stopAddingLink();
speak(__('Link removed.'), 'assertive');
}
function onChangeLink(nextValue) {
const hasLink = linkValue?.url;
const isNewLink = !hasLink;
// Merge the next value with the current link value.
nextValue = {
...linkValue,
...nextValue
};
const newUrl = prependHTTP(nextValue.url);
const linkFormat = createLinkFormat({
url: newUrl,
type: nextValue.type,
id: nextValue.id !== undefined && nextValue.id !== null ? String(nextValue.id) : undefined,
opensInNewWindow: nextValue.opensInNewTab,
nofollow: nextValue.nofollow
});
const newText = nextValue.title || newUrl;
// Scenario: we have any active text selection or an active format.
let newValue;
if (isCollapsed(value) && !isActive) {
// Scenario: we don't have any actively selected text or formats.
const inserted = insert(value, newText);
newValue = applyFormat(inserted, linkFormat, value.start, value.start + newText.length);
onChange(newValue);
// Close the Link UI.
stopAddingLink();
// Move the selection to the end of the inserted link outside of the format boundary
// so the user can continue typing after the link.
selectionChange({
clientId: selectionStart.clientId,
identifier: selectionStart.attributeKey,
start: value.start + newText.length + 1
});
return;
} else if (newText === richTextText) {
newValue = applyFormat(value, linkFormat);
} else {
// Scenario: Editing an existing link.
// Create new RichText value for the new text in order that we
// can apply formats to it.
newValue = create({
text: newText
});
// Apply the new Link format to this new text value.
newValue = applyFormat(newValue, linkFormat, 0, newText.length);
// Get the boundaries of the active link format.
const boundary = getFormatBoundary(value, {
type: 'core/link'
});
// Split the value at the start of the active link format.
// Passing "start" as the 3rd parameter is required to ensure
// the second half of the split value is split at the format's
// start boundary and avoids relying on the value's "end" property
// which may not correspond correctly.
const [valBefore, valAfter] = split(value, boundary.start, boundary.start);
// Update the original (full) RichTextValue replacing the
// target text with the *new* RichTextValue containing:
// 1. The new text content.
// 2. The new link format.
// As "replace" will operate on the first match only, it is
// run only against the second half of the value which was
// split at the active format's boundary. This avoids a bug
// with incorrectly targeted replacements.
// See: https://github.com/WordPress/gutenberg/issues/41771.
// Note original formats will be lost when applying this change.
// That is expected behaviour.
// See: https://github.com/WordPress/gutenberg/pull/33849#issuecomment-936134179.
const newValAfter = replace(valAfter, richTextText, newValue);
newValue = concat(valBefore, newValAfter);
}
onChange(newValue);
// Focus should only be returned to the rich text on submit if this link is not
// being created for the first time. If it is then focus should remain within the
// Link UI because it should remain open for the user to modify the link they have
// just created.
if (!isNewLink) {
stopAddingLink();
}
if (!isValidHref(newUrl)) {
speak(__('Warning: the link has been inserted but may have errors. Please test it.'), 'assertive');
} else if (isActive) {
speak(__('Link edited.'), 'assertive');
} else {
speak(__('Link inserted.'), 'assertive');
}
}
const popoverAnchor = useAnchor({
editableContentElement: contentRef.current,
settings: {
...settings,
isActive
}
});
async function handleCreate(pageTitle) {
const page = await createPageEntity({
title: pageTitle,
status: 'draft'
});
return {
id: page.id,
type: page.type,
title: page.title.rendered,
url: page.link,
kind: 'post-type'
};
}
function createButtonText(searchTerm) {
return createInterpolateElement(sprintf(/* translators: %s: search term. */
__('Create page: <mark>%s</mark>'), searchTerm), {
mark: /*#__PURE__*/_jsx("mark", {})
});
}
return /*#__PURE__*/_jsx(Popover, {
anchor: popoverAnchor,
animate: false,
onClose: stopAddingLink,
onFocusOutside: onFocusOutside,
placement: "bottom",
offset: 8,
shift: true,
focusOnMount: focusOnMount,
constrainTabbing: true,
children: /*#__PURE__*/_jsx(LinkControl, {
value: linkValue,
onChange: onChangeLink,
onRemove: removeLink,
hasRichPreviews: true,
createSuggestion: createPageEntity && handleCreate,
withCreateSuggestion: userCanCreatePages,
createSuggestionButtonText: createButtonText,
hasTextControl: true,
settings: LINK_SETTINGS,
showInitialSuggestions: true,
suggestionsQuery: {
// always show Pages as initial suggestions
initialSuggestionsSearchOptions: {
type: 'post',
subtype: 'page',
perPage: 20
}
}
})
});
}
function getRichTextValueFromSelection(value, isActive) {
// Default to the selection ranges on the RichTextValue object.
let textStart = value.start;
let textEnd = value.end;
// If the format is currently active then the rich text value
// should always be taken from the bounds of the active format
// and not the selected text.
if (isActive) {
const boundary = getFormatBoundary(value, {
type: 'core/link'
});
textStart = boundary.start;
// Text *selection* always extends +1 beyond the edge of the format.
// We account for that here.
textEnd = boundary.end + 1;
}
// Get a RichTextValue containing the selected text content.
return slice(value, textStart, textEnd);
}
export default InlineLinkUI;
//# sourceMappingURL=inline.js.map