UNPKG

@atlaskit/editor-plugin-hyperlink

Version:

Hyperlink plugin for @atlaskit/editor-core

315 lines (311 loc) • 12 kB
// eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead import { v4 as uuid } from 'uuid'; import { isSafeUrl } from '@atlaskit/adf-schema/is-safe-url'; import { InsertStatus, LinkAction } from '@atlaskit/editor-common/link'; import { getActiveLinkMark } from '@atlaskit/editor-common/link/utils'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { canLinkBeCreatedInRange, shallowEqual } from '@atlaskit/editor-common/utils'; import { PluginKey, TextSelection } from '@atlaskit/editor-prosemirror/state'; const mapTransactionToState = (state, tr) => { if (!state) { return undefined; } else if (state.type === InsertStatus.EDIT_LINK_TOOLBAR || state.type === InsertStatus.EDIT_INSERTED_TOOLBAR) { const { pos, deleted } = tr.mapping.mapResult(state.pos, 1); const node = tr.doc.nodeAt(pos); // If the position was not deleted & it is still a link if (!deleted && !!node.type.schema.marks.link.isInSet(node.marks)) { if (node === state.node && pos === state.pos) { return state; } return { ...state, pos, node }; } // If the position has been deleted, then require a navigation to show the toolbar again return; } else if (state.type === InsertStatus.INSERT_LINK_TOOLBAR) { return { ...state, from: tr.mapping.map(state.from), to: tr.mapping.map(state.to) }; } return; }; const toState = (state, action, editorState) => { // Show insert or edit toolbar if (!state) { switch (action) { case LinkAction.SHOW_INSERT_TOOLBAR: { const { from, to } = editorState.selection; if (canLinkBeCreatedInRange(from, to)(editorState)) { return { type: InsertStatus.INSERT_LINK_TOOLBAR, from, to }; } return undefined; } case LinkAction.SELECTION_CHANGE: // If the user has moved their cursor, see if they're in a link const link = getActiveLinkMark(editorState); if (link) { return { ...link, type: InsertStatus.EDIT_LINK_TOOLBAR }; } return undefined; default: return undefined; } } // Update toolbar state if selection changes, or if toolbar is hidden if (state.type === InsertStatus.EDIT_LINK_TOOLBAR) { switch (action) { case LinkAction.EDIT_INSERTED_TOOLBAR: { const link = getActiveLinkMark(editorState); if (link) { if (link.pos === state.pos && link.node === state.node) { return { ...state, type: InsertStatus.EDIT_INSERTED_TOOLBAR }; } return { ...link, type: InsertStatus.EDIT_INSERTED_TOOLBAR }; } return undefined; } case LinkAction.SELECTION_CHANGE: const link = getActiveLinkMark(editorState); if (link) { if (link.pos === state.pos && link.node === state.node) { // Make sure we return the same object, if it's the same link return state; } return { ...link, type: InsertStatus.EDIT_LINK_TOOLBAR }; } return undefined; case LinkAction.HIDE_TOOLBAR: return undefined; default: return state; } } // Remove toolbar if user changes selection or toolbar is hidden if (state.type === InsertStatus.INSERT_LINK_TOOLBAR) { switch (action) { case LinkAction.SELECTION_CHANGE: case LinkAction.HIDE_TOOLBAR: return undefined; default: return state; } } return; }; const getActiveText = selection => { const currentSlice = selection.content(); if (currentSlice.size === 0) { return; } if (currentSlice.content.childCount === 1 && currentSlice.content.firstChild && selection instanceof TextSelection) { return currentSlice.content.firstChild.textContent; } return; }; export const stateKey = new PluginKey('hyperlinkPlugin'); export const plugin = (dispatch, intl, editorAppearance, _pluginInjectionApi, _onClickCallback, __livePage) => new SafePlugin({ state: { init(_, state) { const canInsertLink = canLinkBeCreatedInRange(state.selection.from, state.selection.to)(state); return { activeText: getActiveText(state.selection), canInsertLink, timesViewed: 0, activeLinkMark: toState(undefined, LinkAction.SELECTION_CHANGE, state), editorAppearance }; }, apply(tr, pluginState, oldState, newState) { let state = pluginState; const action = tr.getMeta(stateKey) && tr.getMeta(stateKey).type; const inputMethod = tr.getMeta(stateKey) && tr.getMeta(stateKey).inputMethod; if (tr.docChanged) { state = { activeText: state.activeText, canInsertLink: canLinkBeCreatedInRange(newState.selection.from, newState.selection.to)(newState), timesViewed: state.timesViewed, inputMethod, activeLinkMark: mapTransactionToState(state.activeLinkMark, tr), editorAppearance }; } if (action) { const stateForAnalytics = [LinkAction.SHOW_INSERT_TOOLBAR, LinkAction.EDIT_INSERTED_TOOLBAR].includes(action) ? { timesViewed: ++state.timesViewed, // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead searchSessionId: uuid() } : { timesViewed: state.timesViewed, searchSessionId: state.searchSessionId }; state = { activeText: state.activeText, canInsertLink: state.canInsertLink, inputMethod, activeLinkMark: toState(state.activeLinkMark, action, newState), editorAppearance, ...stateForAnalytics }; } const hasPositionChanged = oldState.selection.from !== newState.selection.from || oldState.selection.to !== newState.selection.to; if (tr.selectionSet && hasPositionChanged) { state = { activeText: getActiveText(newState.selection), canInsertLink: canLinkBeCreatedInRange(newState.selection.from, newState.selection.to)(newState), activeLinkMark: toState(state.activeLinkMark, LinkAction.SELECTION_CHANGE, newState), timesViewed: state.timesViewed, searchSessionId: state.searchSessionId, inputMethod, editorAppearance }; } if (!shallowEqual(state, pluginState)) { dispatch(stateKey, state); } return state; } }, key: stateKey, props: { handleDOMEvents: { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any auxclick: (_, event) => { if (event.button !== 1) { return false; } const anchor = getLinkAnchor(event); if (!anchor) { return false; } // Browsers do not follow a link clicked inside contenteditable, so a link in // editable text never opens on middle-click and has to be opened here. Cards // render their anchor inside a contenteditable="false" node view, and a // read-only editor is itself contenteditable="false" — in both cases the // browser opens the tab, so doing it again here would open two. if (anchor.closest('[contenteditable="false"]')) { return false; } const href = anchor.getAttribute('href'); if (!href || !isSafeUrl(href)) { return false; } window.open(href, '_blank', 'noopener,noreferrer'); return true; }, // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseup: (_, event) => { // this prevents redundant selection transaction when clicking on link // link state will be update on slection change which happens on mousedown if (isLinkDirectTarget(event)) { // Leave middle-click alone so the browser can still open the link in a new tab. if (event.button === 1) { return false; } event.preventDefault(); return true; } return false; }, // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any mousedown: (view, event) => { // Middle-click on a link: skip ProseMirror's own handling so it does not move // the selection into the link or select the card node, which is what opens // the toolbar. const middleClickAnchor = event.button === 1 ? getLinkAnchor(event) : null; if (middleClickAnchor) { // Returning true only stops ProseMirror. The browser still places a caret // at the click point inside contenteditable, and a caret inside the link is // itself enough to open the toolbar, so the default has to be suppressed // too. The auxclick handler above opens the tab for these links. // // Anchors inside a contenteditable="false" node view (cards, and read-only // editors) are deliberately left alone: they get no caret, and it is the // browser's default that opens their new tab. if (!middleClickAnchor.closest('[contenteditable="false"]')) { event.preventDefault(); } return true; } // since link clicks are disallowed by browsers inside contenteditable // so we need to handle shift+click selection ourselves in this case if (!event.shiftKey || !isLinkDirectTarget(event)) { return false; } const { state } = view; const { selection: { $anchor } } = state; const newPosition = view.posAtCoords({ left: event.clientX, top: event.clientY }); if ((newPosition === null || newPosition === void 0 ? void 0 : newPosition.pos) != null && newPosition.pos !== $anchor.pos) { const tr = state.tr.setSelection(TextSelection.create(state.doc, $anchor.pos, newPosition.pos)); view.dispatch(tr); return true; } return false; } } } }); function isLinkDirectTarget(event) { return (event === null || event === void 0 ? void 0 : event.target) instanceof HTMLElement && event.target.tagName === 'A'; } /** * Whether the event happened anywhere inside a link, rather than on the anchor itself. * * Matching on the anchor alone is not enough. Every smart link variant renders its anchor * with nested content inside it — inline cards nest icon, title and lozenge spans, block * and embed cards render anchors via the flexible card `base-link-element` and * `ExpandedFrame` — so the direct target of a real click is almost never the anchor. * * Being DOM based rather than node based, this covers every variant that renders a real * anchor: link marks, inline cards, block cards, embed card headers and datasource cell * links. None of the card node views swallow `mousedown` (their `stopEvent` only returns * true for `dragstart`, or for form elements in the datasource case), so the event does * reach this handler in all of those cases. The exception is content inside an embed * card's iframe, which is a separate document we cannot observe. * * `isLinkDirectTarget` is deliberately left alone: widening it would also change * shift+click and mouseup handling for every nested element inside a link. */ function getLinkAnchor(event) { return (event === null || event === void 0 ? void 0 : event.target) instanceof Element ? event.target.closest('a[href]') : null; }