@atlaskit/editor-plugin-hyperlink
Version:
Hyperlink plugin for @atlaskit/editor-core
294 lines (290 loc) • 15.4 kB
JavaScript
import React, { useRef } from 'react';
import { isSafeUrl } from '@atlaskit/adf-schema';
import { ACTION, ACTION_SUBJECT_ID, INPUT_METHOD, buildVisitedLinkPayload } from '@atlaskit/editor-common/analytics';
import { commandWithMetadata } from '@atlaskit/editor-common/card';
import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks';
import { HyperlinkAddToolbar } from '@atlaskit/editor-common/link';
import { linkMessages, linkToolbarMessages as linkToolbarCommonMessages } from '@atlaskit/editor-common/messages';
import { areToolbarFlagsEnabled } from '@atlaskit/editor-common/toolbar-flag-check';
import { LINKPICKER_HEIGHT_IN_PX, RECENT_SEARCH_HEIGHT_IN_PX, RECENT_SEARCH_WIDTH_IN_PX } from '@atlaskit/editor-common/ui';
import { UserIntentPopupWrapper } from '@atlaskit/editor-common/user-intent';
import { normalizeUrl } from '@atlaskit/editor-common/utils';
import { isOfflineMode } from '@atlaskit/editor-plugin-connectivity';
import { TextSelection } from '@atlaskit/editor-prosemirror/state';
import { findDomRefAtPos } from '@atlaskit/editor-prosemirror/utils';
import EditIcon from '@atlaskit/icon/core/edit';
import LinkBrokenIcon from '@atlaskit/icon/core/link-broken';
import LinkExternalIcon from '@atlaskit/icon/core/link-external';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { editInsertedLink, insertLinkWithAnalytics, onClickAwayCallback, onEscapeCallback, removeLink, updateLink } from '../../editor-commands/commands';
import { stateKey } from '../../pm-plugins/main';
import { toolbarKey } from '../../pm-plugins/toolbar-buttons';
/* type guard for edit links */
function isEditLink(linkMark) {
return linkMark.pos !== undefined;
}
const dispatchAnalytics = (dispatch, state, analyticsBuilder, editorAnalyticsApi) => {
if (dispatch) {
const {
tr
} = state;
editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent(analyticsBuilder(ACTION_SUBJECT_ID.HYPERLINK))(tr);
dispatch(tr);
}
};
const visitHyperlink = editorAnalyticsApi => (state, dispatch) => {
dispatchAnalytics(dispatch, state, buildVisitedLinkPayload, editorAnalyticsApi);
return true;
};
function getLinkText(activeLinkMark, state) {
if (!activeLinkMark.node) {
return undefined;
}
const textToUrl = normalizeUrl(activeLinkMark.node.text);
const linkMark = activeLinkMark.node.marks.find(mark => mark.type === state.schema.marks.link);
const linkHref = linkMark && linkMark.attrs.href;
if (textToUrl === linkHref) {
return undefined;
}
return activeLinkMark.node.text;
}
const selector = states => {
var _states$hyperlinkStat, _states$hyperlinkStat2, _states$hyperlinkStat3;
return {
timesViewed: (_states$hyperlinkStat = states.hyperlinkState) === null || _states$hyperlinkStat === void 0 ? void 0 : _states$hyperlinkStat.timesViewed,
inputMethod: (_states$hyperlinkStat2 = states.hyperlinkState) === null || _states$hyperlinkStat2 === void 0 ? void 0 : _states$hyperlinkStat2.inputMethod,
searchSessionId: (_states$hyperlinkStat3 = states.hyperlinkState) === null || _states$hyperlinkStat3 === void 0 ? void 0 : _states$hyperlinkStat3.searchSessionId
};
};
export function HyperlinkAddToolbarWithState({
linkPickerOptions = {},
onSubmit,
displayText,
displayUrl,
providerFactory,
view,
onCancel,
invokeMethod,
lpLinkPicker,
onClose,
onEscapeCallback,
onClickAwayCallback,
pluginInjectionApi
}) {
var _pluginInjectionApi$c, _pluginInjectionApi$c2;
const {
timesViewed,
inputMethod,
searchSessionId
} = useSharedPluginStateWithSelector(pluginInjectionApi, ['hyperlink'], selector);
// This is constant rather than dynamic - because if someone's already got a hyperlink toolbar open,
// we don't want to dynamically change it on them as this would cause data loss if they've already
// started typing in the fields.
const isOffline = useRef(isOfflineMode(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c = pluginInjectionApi.connectivity) === null || _pluginInjectionApi$c === void 0 ? void 0 : (_pluginInjectionApi$c2 = _pluginInjectionApi$c.sharedState.currentState()) === null || _pluginInjectionApi$c2 === void 0 ? void 0 : _pluginInjectionApi$c2.mode));
return /*#__PURE__*/React.createElement(HyperlinkAddToolbar, {
linkPickerOptions: linkPickerOptions,
onSubmit: onSubmit,
displayText: displayText,
displayUrl: displayUrl,
providerFactory: providerFactory,
view: view,
onCancel: onCancel,
invokeMethod: invokeMethod,
lpLinkPicker: lpLinkPicker,
onClose: onClose,
onEscapeCallback: onEscapeCallback,
onClickAwayCallback: onClickAwayCallback,
timesViewed: timesViewed,
inputMethod: inputMethod,
searchSessionId: searchSessionId,
isOffline: isOffline.current
});
}
export const getToolbarConfig = (options, pluginInjectionApi) => (state, intl, providerFactory) => {
var _pluginInjectionApi$c3, _pluginInjectionApi$a, _options$lpLinkPicker;
if (options.disableFloatingToolbar) {
return;
}
const isToolbarAIFCEnabled = Boolean(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : pluginInjectionApi.toolbar);
const linkState = stateKey.getState(state);
const activeLinkMark = linkState === null || linkState === void 0 ? void 0 : linkState.activeLinkMark;
// If range selection, we don't show hyperlink floating toolbar.
// Text Formattting toolbar is shown instaed.
if (state.selection instanceof TextSelection && state.selection.to !== state.selection.from && (activeLinkMark === null || activeLinkMark === void 0 ? void 0 : activeLinkMark.type) === 'EDIT' && editorExperiment('platform_editor_controls', 'variant1')) {
return;
}
const {
formatMessage
} = intl;
const editorCardActions = pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c3 = pluginInjectionApi.card) === null || _pluginInjectionApi$c3 === void 0 ? void 0 : _pluginInjectionApi$c3.actions;
const editorAnalyticsApi = pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$a = pluginInjectionApi.analytics) === null || _pluginInjectionApi$a === void 0 ? void 0 : _pluginInjectionApi$a.actions;
const lpLinkPicker = (_options$lpLinkPicker = options.lpLinkPicker) !== null && _options$lpLinkPicker !== void 0 ? _options$lpLinkPicker : true;
if (activeLinkMark) {
const hyperLinkToolbar = {
title: 'Hyperlink floating controls',
nodeType: [state.schema.nodes.text, state.schema.nodes.paragraph, state.schema.nodes.heading, state.schema.nodes.taskItem, state.schema.nodes.decisionItem, state.schema.nodes.caption].filter(nodeType => !!nodeType),
// Use only the node types existing in the schema ED-6745
align: 'left',
className: activeLinkMark.type.match('INSERT|EDIT_INSERTED') ? 'hyperlink-floating-toolbar' : '',
// getDomRef by default uses view.state.selection.from to position the toolbar.
// However, when the user clicks in right after the link the view.state.selection.from references to the dom after the selection.
// So instead we want to use the activeLinkMark.pos which has been calculated as the position before the click so that would be the link node
getDomRef: activeLinkMark && (activeLinkMark.type === 'EDIT_INSERTED' || activeLinkMark.type === 'EDIT') && editorExperiment('platform_editor_controls', 'variant1') ? view => {
const domRef = findDomRefAtPos(activeLinkMark.pos, view.domAtPos.bind(view));
return domRef instanceof HTMLElement ? domRef : undefined;
} : undefined
};
switch (activeLinkMark.type) {
case 'EDIT':
{
var _pluginInjectionApi$c4, _cardActions$getStart, _cardActions$getEndin;
const {
pos,
node
} = activeLinkMark;
const linkMark = node.marks.filter(mark => mark.type === state.schema.marks.link);
const link = linkMark[0] && linkMark[0].attrs.href;
const isValidUrl = isSafeUrl(link);
const labelOpenLink = formatMessage(isValidUrl ? linkMessages.openLink : linkToolbarCommonMessages.unableToOpenLink);
// TODO: ED-14403 - investigate why these are not translating?
const labelUnlink = formatMessage(linkToolbarCommonMessages.unlink);
const editLink = formatMessage(linkToolbarCommonMessages.editLink);
const metadata = {
url: link,
title: ''
};
if (activeLinkMark.node.text) {
metadata.title = activeLinkMark.node.text;
}
const areAnyNewToolbarFlagsEnabled = areToolbarFlagsEnabled(Boolean(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : pluginInjectionApi.toolbar));
const cardActions = pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c4 = pluginInjectionApi.card) === null || _pluginInjectionApi$c4 === void 0 ? void 0 : _pluginInjectionApi$c4.actions;
const startingToolbarItems = (_cardActions$getStart = cardActions === null || cardActions === void 0 ? void 0 : cardActions.getStartingToolbarItems(intl, link, editInsertedLink(editorAnalyticsApi), metadata, areAnyNewToolbarFlagsEnabled ? state : undefined)) !== null && _cardActions$getStart !== void 0 ? _cardActions$getStart : [{
id: 'editor.link.edit',
testId: 'editor.link.edit',
type: 'button',
onClick: editInsertedLink(editorAnalyticsApi),
title: editLink,
showTitle: areAnyNewToolbarFlagsEnabled ? false : true,
metadata: metadata,
icon: areAnyNewToolbarFlagsEnabled ? EditIcon : undefined
}, {
type: 'separator'
}];
const openLinkButton = {
id: 'editor.link.openLink',
testId: 'editor.link.openLink',
type: 'button',
disabled: !isValidUrl,
target: '_blank',
href: isValidUrl ? link : undefined,
onClick: visitHyperlink(editorAnalyticsApi),
title: labelOpenLink,
icon: LinkExternalIcon,
className: 'hyperlink-open-link',
metadata: metadata,
tabIndex: null
};
const unlinkButton = {
id: 'editor.link.unlink',
testId: 'editor.link.unlink',
type: 'button',
onClick: commandWithMetadata(removeLink(pos, editorAnalyticsApi), {
inputMethod: INPUT_METHOD.FLOATING_TB
}),
title: labelUnlink,
icon: LinkBrokenIcon,
tabIndex: null
};
const items = [...startingToolbarItems, ...(areAnyNewToolbarFlagsEnabled ? [unlinkButton, {
type: 'separator',
fullHeight: true
}, openLinkButton, {
type: 'separator',
fullHeight: true
}] : [openLinkButton, {
type: 'separator'
}, unlinkButton, {
type: 'separator'
}]), {
type: 'copy-button',
items: [{
state,
formatMessage: formatMessage,
markType: state.schema.marks.link
}]
}, ...((_cardActions$getEndin = cardActions === null || cardActions === void 0 ? void 0 : cardActions.getEndingToolbarItems(intl, link)) !== null && _cardActions$getEndin !== void 0 ? _cardActions$getEndin : [])];
return {
...hyperLinkToolbar,
height: 32,
width: 250,
items,
scrollable: true
};
}
case 'EDIT_INSERTED':
case 'INSERT':
{
let link;
if (isEditLink(activeLinkMark) && activeLinkMark.node) {
const linkMark = activeLinkMark.node.marks.filter(mark => mark.type === state.schema.marks.link);
link = linkMark[0] && linkMark[0].attrs.href;
}
const displayText = isEditLink(activeLinkMark) ? getLinkText(activeLinkMark, state) : linkState.activeText;
const popupHeight = lpLinkPicker ? LINKPICKER_HEIGHT_IN_PX : RECENT_SEARCH_HEIGHT_IN_PX;
// Removing popupWidth to ensure that we the popup always positions setting positon left instead of flipping to position right
// inside of a narrow space like Preview panel
const popupWidth = !lpLinkPicker ? undefined : RECENT_SEARCH_WIDTH_IN_PX;
return {
...hyperLinkToolbar,
preventPopupOverflow: true,
height: popupHeight,
width: popupWidth,
items: [{
type: 'custom',
fallback: [],
disableArrowNavigation: true,
render: (view, idx) => {
if (!view) {
return null;
}
const Toolbar = /*#__PURE__*/React.createElement(HyperlinkAddToolbarWithState, {
pluginInjectionApi: pluginInjectionApi,
view: view,
key: idx,
linkPickerOptions: options === null || options === void 0 ? void 0 : options.linkPicker,
lpLinkPicker: lpLinkPicker,
displayUrl: link,
displayText: displayText || '',
providerFactory: providerFactory
// eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed)
,
onCancel: () => view.focus(),
onEscapeCallback: onEscapeCallback(editorCardActions),
onClickAwayCallback: onClickAwayCallback
// eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed)
,
onSubmit: (href, title = '', displayText, inputMethod, analytic) => {
var _toolbarKey$getState$, _toolbarKey$getState;
const isEdit = isEditLink(activeLinkMark);
const action = isEdit ? ACTION.UPDATED : ACTION.INSERTED;
const skipAnalytics = (_toolbarKey$getState$ = (_toolbarKey$getState = toolbarKey.getState(state)) === null || _toolbarKey$getState === void 0 ? void 0 : _toolbarKey$getState.skipAnalytics) !== null && _toolbarKey$getState$ !== void 0 ? _toolbarKey$getState$ : false;
const command = isEdit ? commandWithMetadata(updateLink(href, displayText || title, activeLinkMark.pos), {
action,
inputMethod,
sourceEvent: analytic
}) : insertLinkWithAnalytics(inputMethod, activeLinkMark.from, activeLinkMark.to, href, editorCardActions, editorAnalyticsApi, title, displayText, skipAnalytics, analytic);
command(view.state, view.dispatch, view);
view.focus();
}
});
return isToolbarAIFCEnabled ? /*#__PURE__*/React.createElement(UserIntentPopupWrapper, {
api: pluginInjectionApi
}, Toolbar) : Toolbar;
}
}]
};
}
}
}
return;
};