UNPKG

@atlaskit/editor-plugin-hyperlink

Version:

Hyperlink plugin for @atlaskit/editor-core

294 lines (290 loc) 15.4 kB
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; };