UNPKG

@atlaskit/editor-plugin-expand

Version:

Expand plugin for @atlaskit/editor-core

694 lines (690 loc) 27.9 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import React from 'react'; // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead import uuid from 'uuid/v4'; import { keyName } from 'w3c-keyname'; import { expandedState, isExpandCollapsed } from '@atlaskit/editor-common/expand'; import { GapCursorSelection, RelativeSelectionPos, Side } from '@atlaskit/editor-common/selection'; import { expandClassNames } from '@atlaskit/editor-common/styles'; import { closestElement, isEmptyNode } from '@atlaskit/editor-common/utils'; import { DOMSerializer } from '@atlaskit/editor-prosemirror/model'; import { NodeSelection, Selection } from '@atlaskit/editor-prosemirror/state'; import { redo, undo } from '@atlaskit/prosemirror-history'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { renderExpandButton } from '../../ui/renderExpandButton'; import { deleteExpand, setSelectionInsideExpand, toggleExpandExpanded, updateExpandTitle } from '../commands'; import { ExpandButton } from '../ui/ExpandButton'; import { buildExpandClassName, toDOM } from '../ui/NodeView'; import { findReplaceExpandDecorations } from '../utils'; export class ExpandNodeView { constructor(_node, view, getPos, getIntl, isMobile, selectNearNode, api, nodeViewPortalProviderAPI, allowInteractiveExpand = true, __livePage = false, cleanUpEditorDisabledOnChange, isExpanded = { expanded: false }) { var _api$editorDisabled, _api$editorDisabled$s, _this$api8; _defineProperty(this, "allowInteractiveExpand", true); _defineProperty(this, "isMobile", false); _defineProperty(this, "focusTitle", () => { if (this.input) { const { state, dispatch } = this.view; if (this.selectNearNode) { const tr = this.selectNearNode({ selectionRelativeToNode: RelativeSelectionPos.Start })(state); if (dispatch) { dispatch(tr); } } const pos = this.getPos(); if (typeof pos === 'number') { setSelectionInsideExpand(pos)(state, dispatch, this.view); } this.input.focus(); } }); _defineProperty(this, "handleIconKeyDown", event => { switch (keyName(event)) { case 'Tab': event.preventDefault(); this.focusTitle(); break; case 'Enter': event.preventDefault(); this.handleClick(event); break; } }); _defineProperty(this, "handleClick", event => { const pos = this.getPos(); if (typeof pos !== 'number') { return; } // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting const target = event.target; if (closestElement(target, `.${expandClassNames.icon}`)) { var _this$api, _this$api$analytics; if (!this.allowInteractiveExpand) { return; } event.stopPropagation(); // We blur the editorView, to prevent any keyboard showing on mobile // When we're interacting with the expand toggle if (this.view.dom instanceof HTMLElement) { this.view.dom.blur(); } toggleExpandExpanded({ editorAnalyticsAPI: (_this$api = this.api) === null || _this$api === void 0 ? void 0 : (_this$api$analytics = _this$api.analytics) === null || _this$api$analytics === void 0 ? void 0 : _this$api$analytics.actions, pos, node: this.node })(this.view.state, this.view.dispatch); this.updateExpandToggleIcon(this.node); this.updateDisplayStyle(this.node); return; } if (target === this.input) { event.stopPropagation(); this.focusTitle(); return; } }); _defineProperty(this, "handleInput", event => { const pos = this.getPos(); if (typeof pos !== 'number') { return; } const target = event.target; if (target === this.input) { event.stopPropagation(); const { state, dispatch } = this.view; updateExpandTitle({ title: target.value, pos, nodeType: this.node.type })(state, dispatch); } }); _defineProperty(this, "handleFocus", event => { event.stopImmediatePropagation(); }); _defineProperty(this, "handleInputFocus", () => { var _this$api2, _this$api2$selectionM, _this$api2$selectionM2; this.decorationCleanup = (_this$api2 = this.api) === null || _this$api2 === void 0 ? void 0 : (_this$api2$selectionM = _this$api2.selectionMarker) === null || _this$api2$selectionM === void 0 ? void 0 : (_this$api2$selectionM2 = _this$api2$selectionM.actions) === null || _this$api2$selectionM2 === void 0 ? void 0 : _this$api2$selectionM2.hideDecoration(); }); _defineProperty(this, "handleBlur", () => { var _this$decorationClean; (_this$decorationClean = this.decorationCleanup) === null || _this$decorationClean === void 0 ? void 0 : _this$decorationClean.call(this); }); _defineProperty(this, "handleTitleKeydown", event => { // Handle Ctrl+Shift+H to select the expand node for drag handle // Note: If changing this implementation in singlePlayer expand, please also update the implementation in legacyExpand if (expValEquals('platform_editor_dnd_accessibility_fixes_expand', 'isEnabled', true) && (event.ctrlKey || event.metaKey) && event.shiftKey && (event.key === 'H' || event.key === 'h')) { event.preventDefault(); const pos = this.getPos(); if (typeof pos === 'number') { // Blur the input first to remove focus from the title if (this.input) { this.input.blur(); } // Use requestAnimationFrame to ensure blur completes before setting selection requestAnimationFrame(() => { var _this$api3; const { state } = this.view; this.view.focus(); (_this$api3 = this.api) === null || _this$api3 === void 0 ? void 0 : _this$api3.core.actions.execute(({ tr }) => { tr.setSelection(NodeSelection.create(state.doc, pos)); // Show the drag handle on the selected expand node const node = state.doc.nodeAt(pos); if (node) { // Find the anchor name from the DOM const dom = this.view.nodeDOM(pos); if (dom instanceof HTMLElement) { const anchorName = expValEquals('platform_editor_native_anchor_with_dnd', 'isEnabled', true) ? dom.getAttribute('data-node-anchor') : dom.getAttribute('data-drag-handler-anchor-name'); // Only proceed if we found a valid anchor name if (anchorName) { var _this$api4, _this$api4$blockContr; const command = (_this$api4 = this.api) === null || _this$api4 === void 0 ? void 0 : (_this$api4$blockContr = _this$api4.blockControls) === null || _this$api4$blockContr === void 0 ? void 0 : _this$api4$blockContr.commands.showDragHandleAt(pos, anchorName, node.type.name, { isFocused: true }); if (command) { return command({ tr }); } } } } return null; }); }); } return; } switch (keyName(event)) { case 'Enter': this.toggleExpand(); break; case 'Tab': case 'ArrowDown': this.moveToOutsideOfTitle(event); break; case 'ArrowRight': this.handleArrowRightFromTitle(event); break; case 'ArrowLeft': this.handleArrowLeftFromTitle(event); break; case 'ArrowUp': if (expValEquals('platform_editor_lovability_navigation_fixes', 'isEnabled', true)) { this.moveToPreviousLine(event); } else { this.setLeftGapCursor(event); } break; case 'Backspace': this.deleteEmptyExpand(); break; } // 'Ctrl-y', 'Mod-Shift-z'); if (event.ctrlKey && event.key === 'y' || (event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'z') { this.handleRedoFromTitle(event); return; } // 'Mod-z' if ((event.ctrlKey || event.metaKey) && event.key === 'z') { this.handleUndoFromTitle(event); return; } if (expValEquals('platform_editor_breakout_resizing', 'isEnabled', true)) { if ((event.ctrlKey || event.metaKey) && event.altKey && (event.code === 'BracketLeft' || event.code === 'BracketRight')) { this.view.dispatchEvent(new KeyboardEvent('keydown', { key: event.key, code: event.code, metaKey: event.metaKey, ctrlKey: event.ctrlKey, altKey: event.altKey })); } } }); _defineProperty(this, "deleteEmptyExpand", () => { const { state } = this.view; const expandNode = this.node; if (!this.input) { return; } const { selectionStart, selectionEnd } = this.input; if (selectionStart !== selectionEnd || selectionStart !== 0) { return; } if (expandNode && isEmptyNode(state.schema)(expandNode)) { var _this$api5, _this$api5$analytics; deleteExpand((_this$api5 = this.api) === null || _this$api5 === void 0 ? void 0 : (_this$api5$analytics = _this$api5.analytics) === null || _this$api5$analytics === void 0 ? void 0 : _this$api5$analytics.actions)(state, this.view.dispatch); } }); _defineProperty(this, "toggleExpand", () => { const pos = this.getPos(); if (typeof pos !== 'number') { return; } if (this.allowInteractiveExpand) { var _this$api6, _this$api6$analytics; const { state, dispatch } = this.view; toggleExpandExpanded({ editorAnalyticsAPI: (_this$api6 = this.api) === null || _this$api6 === void 0 ? void 0 : (_this$api6$analytics = _this$api6.analytics) === null || _this$api6$analytics === void 0 ? void 0 : _this$api6$analytics.actions, pos, node: this.node })(state, dispatch); this.updateExpandToggleIcon(this.node); this.updateDisplayStyle(this.node); } }); _defineProperty(this, "moveToOutsideOfTitle", event => { event.preventDefault(); const { state, dispatch } = this.view; const expandPos = this.getPos(); if (typeof expandPos !== 'number') { return; } let pos = expandPos; if (this.isCollapsed()) { pos = expandPos + this.node.nodeSize; } const resolvedPos = state.doc.resolve(pos); if (!resolvedPos) { return; } if (this.isCollapsed() && resolvedPos.nodeAfter && ['expand', 'nestedExpand'].indexOf(resolvedPos.nodeAfter.type.name) > -1) { return this.setRightGapCursor(event); } const sel = Selection.findFrom(resolvedPos, 1, true); if (sel) { // If the input has focus, ProseMirror doesn't // Give PM focus back before changing our selection this.view.focus(); dispatch(state.tr.setSelection(sel)); } }); _defineProperty(this, "isCollapsed", () => { return !expandedState.get(this.node); }); _defineProperty(this, "setRightGapCursor", event => { if (!this.input) { return; } const pos = this.getPos(); if (typeof pos !== 'number') { return; } const { value, selectionStart, selectionEnd } = this.input; const selectionStartExists = selectionStart !== null && selectionStart !== undefined; const selectionEndExists = selectionEnd !== null && selectionEnd !== undefined; const selectionStartInsideTitle = selectionStartExists && selectionStart >= 0 && selectionStart <= value.length; const selectionEndInsideTitle = selectionEndExists && selectionEnd >= 0 && selectionEnd <= value.length; if (selectionStartInsideTitle && selectionEndInsideTitle) { const { state, dispatch } = this.view; event.preventDefault(); this.view.focus(); dispatch(state.tr.setSelection(new GapCursorSelection(state.doc.resolve(this.node.nodeSize + pos), Side.RIGHT))); } }); _defineProperty(this, "moveToPreviousLine", event => { if (!this.input) { return; } const pos = this.getPos(); if (typeof pos !== 'number') { return; } const { selectionStart, selectionEnd } = this.input; if (selectionStart === selectionEnd && selectionStart === 0) { event.preventDefault(); const { state, dispatch } = this.view; const resolvedPos = state.doc.resolve(pos); if (!resolvedPos) { return; } if (resolvedPos.pos === 0) { this.setLeftGapCursor(event); return; } const sel = Selection.findFrom(resolvedPos, -1); if (sel) { this.view.focus(); dispatch(state.tr.setSelection(sel)); } } }); _defineProperty(this, "setLeftGapCursor", event => { if (!this.input) { return; } const pos = this.getPos(); if (typeof pos !== 'number') { return; } const { selectionStart, selectionEnd } = this.input; if (selectionStart === selectionEnd && selectionStart === 0) { event.preventDefault(); const { state, dispatch } = this.view; this.view.focus(); dispatch(state.tr.setSelection(new GapCursorSelection(state.doc.resolve(pos), Side.LEFT))); } }); _defineProperty(this, "handleArrowRightFromTitle", event => { if (!this.input || !this.selectNearNode) { return; } const pos = this.getPos(); if (typeof pos !== 'number') { return; } const { value, selectionStart, selectionEnd } = this.input; if (selectionStart === selectionEnd && selectionStart === value.length) { event.preventDefault(); const { state, dispatch } = this.view; this.view.focus(); const tr = this.selectNearNode({ selectionRelativeToNode: RelativeSelectionPos.End, selection: NodeSelection.create(state.doc, pos) })(state); if (dispatch) { dispatch(tr); } } }); _defineProperty(this, "handleArrowLeftFromTitle", event => { if (!this.input || !this.selectNearNode) { return; } const pos = this.getPos(); if (typeof pos !== 'number') { return; } const { selectionStart, selectionEnd } = this.input; if (selectionStart === selectionEnd && selectionStart === 0) { var _this$api7, _this$api7$selection; event.preventDefault(); const { state, dispatch } = this.view; this.view.focus(); const selectionSharedState = ((_this$api7 = this.api) === null || _this$api7 === void 0 ? void 0 : (_this$api7$selection = _this$api7.selection) === null || _this$api7$selection === void 0 ? void 0 : _this$api7$selection.sharedState.currentState()) || {}; // selectionRelativeToNode is undefined when user clicked to select node, then hit left to get focus in title // This is a special case where we want to bypass node selection and jump straight to gap cursor if ((selectionSharedState === null || selectionSharedState === void 0 ? void 0 : selectionSharedState.selectionRelativeToNode) === undefined) { const tr = this.selectNearNode({ selectionRelativeToNode: undefined, selection: new GapCursorSelection(state.doc.resolve(pos), Side.LEFT) })(state); if (dispatch) { dispatch(tr); } } else { const tr = this.selectNearNode({ selectionRelativeToNode: RelativeSelectionPos.Start, selection: NodeSelection.create(state.doc, pos) })(state); if (dispatch) { dispatch(tr); } } } }); _defineProperty(this, "handleUndoFromTitle", event => { const { state, dispatch } = this.view; undo(state, dispatch); event.preventDefault(); return; }); _defineProperty(this, "handleRedoFromTitle", event => { const { state, dispatch } = this.view; redo(state, dispatch); event.preventDefault(); return; }); _defineProperty(this, "getContentEditable", node => { const contentEditable = !isExpandCollapsed(node); if (this.api && this.api.editorDisabled) { var _this$api$editorDisab; return !((_this$api$editorDisab = this.api.editorDisabled.sharedState.currentState()) !== null && _this$api$editorDisab !== void 0 && _this$api$editorDisab.editorDisabled) && contentEditable; } return contentEditable; }); _defineProperty(this, "renderIcon", (icon, expanded) => { if (!icon) { return; } this.nodeViewPortalProviderAPI.render(() => /*#__PURE__*/React.createElement(ExpandButton, { intl: this.intl, allowInteractiveExpand: this.allowInteractiveExpand, expanded: expanded }), icon, this.renderKey); }); this.selectNearNode = selectNearNode; this.__livePage = __livePage; this.cleanUpEditorDisabledOnChange = cleanUpEditorDisabledOnChange; this.isExpanded = isExpanded; this.intl = getIntl(); this.nodeViewPortalProviderAPI = nodeViewPortalProviderAPI; this.allowInteractiveExpand = allowInteractiveExpand; this.getPos = getPos; this.view = view; this.node = _node; if (editorExperiment('platform_editor_block_menu', true, { exposure: true })) { var _expandedState$get; this.isExpanded.expanded = (_expandedState$get = expandedState.get(_node)) !== null && _expandedState$get !== void 0 ? _expandedState$get : false; this.isExpanded.localId = _node.attrs.localId; } const { dom: _dom, contentDOM } = DOMSerializer.renderSpec(document, toDOM(_node, this.__livePage, this.intl, api === null || api === void 0 ? void 0 : (_api$editorDisabled = api.editorDisabled) === null || _api$editorDisabled === void 0 ? void 0 : (_api$editorDisabled$s = _api$editorDisabled.sharedState.currentState()) === null || _api$editorDisabled$s === void 0 ? void 0 : _api$editorDisabled$s.editorDisabled)); // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting this.dom = _dom; // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting this.contentDOM = contentDOM; this.isMobile = isMobile; this.api = api; this.icon = this.dom.querySelector(`.${expandClassNames.icon}`); this.input = this.dom.querySelector(`.${expandClassNames.titleInput}`); this.titleContainer = this.dom.querySelector(`.${expandClassNames.titleContainer}`); // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead this.renderKey = uuid(); this.content = this.dom.querySelector(`.${expandClassNames.content}`); if (!expandedState.has(this.node)) { expandedState.set(this.node, false); } if (expValEquals('platform_editor_vc90_transition_expand_icon', 'isEnabled', true)) { this.renderNativeIcon(this.node); } else { this.renderIcon(this.icon, !isExpandCollapsed(this.node)); } if (!this.input || !this.titleContainer || !this.icon) { return; } // Add event listeners /* eslint-disable @repo/internal/dom-events/no-unsafe-event-listeners*/ this.dom.addEventListener('click', this.handleClick); this.dom.addEventListener('input', this.handleInput); this.input.addEventListener('keydown', this.handleTitleKeydown); this.input.addEventListener('blur', this.handleBlur); this.input.addEventListener('focus', this.handleInputFocus); // If the user interacts in our title bar (either toggle or input) // Prevent ProseMirror from getting a focus event (causes weird selection issues). this.titleContainer.addEventListener('focus', this.handleFocus); this.icon.addEventListener('keydown', this.handleIconKeyDown); if ((_this$api8 = this.api) !== null && _this$api8 !== void 0 && _this$api8.editorDisabled) { this.cleanUpEditorDisabledOnChange = this.api.editorDisabled.sharedState.onChange(sharedState => { const editorDisabled = sharedState.nextSharedState.editorDisabled; if (this.input) { if (editorDisabled) { this.input.setAttribute('readonly', 'true'); } else { this.input.removeAttribute('readonly'); } } if (this.content) { this.content.setAttribute('contenteditable', this.getContentEditable(this.node) ? 'true' : 'false'); } }); } } stopEvent(event) { // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting const target = event.target; return target === this.input || target === this.icon || !!closestElement(target, `.${expandClassNames.icon}`); } ignoreMutation(mutationRecord) { // ME-1931: Mobile relies on composition which creates dom mutations. If we ignore them, prosemirror // does not recognise the changes and reverts them. if (this.isMobile && (mutationRecord.type === 'characterData' || mutationRecord.type === 'childList')) { return false; } if (mutationRecord.type === 'selection') { return false; } return true; } update(node, _decorations) { if (this.node.type === node.type) { var _expandedState$get2; // During a collab session the title doesn't sync with other users // since we're intentionally being less aggressive about re-rendering. // We also apply a rAF to avoid abrupt continuous replacement of the title. window.requestAnimationFrame(() => { if (this.input && this.node.attrs.title !== this.input.value) { this.input.value = this.node.attrs.title; } }); // This checks if the node has been replaced with a different version // and updates the state of the new node to match the old one // Eg. typing in a node changes it to a new node so it must be updated // in the expandedState weak map if (this.node !== node) { const wasExpanded = expandedState.get(this.node); if (wasExpanded) { expandedState.set(node, wasExpanded); } } this.node = node; const currentExpanded = (_expandedState$get2 = expandedState.get(node)) !== null && _expandedState$get2 !== void 0 ? _expandedState$get2 : false; const hasChanged = editorExperiment('platform_editor_block_menu', true, { exposure: true }) ? this.isExpanded.expanded !== currentExpanded && this.isExpanded.localId === node.attrs.localId : this.isExpanded.expanded !== currentExpanded; if (hasChanged) { this.updateExpandToggleIcon(node); this.updateDisplayStyle(node); } return true; } return false; } updateExpandToggleIcon(node) { const expanded = expandedState.get(node) ? expandedState.get(node) : false; if (this.dom && expanded !== undefined) { if (expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true)) { const classes = this.dom.className.split(' '); // find & replace styles might be applied to the expand title and we need to keep them const findReplaceDecorationsApplied = classes.filter(className => findReplaceExpandDecorations.includes(className)).join(' '); this.dom.className = findReplaceDecorationsApplied ? buildExpandClassName(node.type.name, expanded) + ` ${findReplaceDecorationsApplied}` : buildExpandClassName(node.type.name, expanded); } else { this.dom.className = buildExpandClassName(node.type.name, expanded); } // Re-render the icon to update the aria-expanded attribute if (expValEquals('platform_editor_vc90_transition_expand_icon', 'isEnabled', true)) { this.renderNativeIcon(node); } else { var _expandedState$get3; this.renderIcon(this.icon ? this.icon : null, (_expandedState$get3 = expandedState.get(node)) !== null && _expandedState$get3 !== void 0 ? _expandedState$get3 : false); } } this.updateExpandBodyContentEditable(); this.isExpanded = editorExperiment('platform_editor_block_menu', true, { exposure: true }) ? { expanded: expanded !== null && expanded !== void 0 ? expanded : false, localId: node.attrs.localId } : { expanded: expanded !== null && expanded !== void 0 ? expanded : false }; } updateDisplayStyle(node) { if (this.content) { if (isExpandCollapsed(node)) { this.content.classList.add(expandClassNames.contentCollapsed); } else { this.content.classList.remove(expandClassNames.contentCollapsed); } } } updateExpandBodyContentEditable() { // Disallow interaction/selection inside expand body when collapsed. if (this.content) { this.content.setAttribute('contenteditable', this.getContentEditable(this.node) ? 'true' : 'false'); } } renderNativeIcon(node) { if (!this.icon) { return; } renderExpandButton(this.icon, { expanded: !isExpandCollapsed(node), allowInteractiveExpand: this.allowInteractiveExpand, intl: this.intl }); } destroy() { var _this$decorationClean2; if (!this.dom || !this.input || !this.titleContainer || !this.icon) { return; } this.dom.removeEventListener('click', this.handleClick); this.dom.removeEventListener('input', this.handleInput); this.input.removeEventListener('keydown', this.handleTitleKeydown); this.input.removeEventListener('blur', this.handleBlur); this.input.removeEventListener('focus', this.handleInputFocus); this.titleContainer.removeEventListener('focus', this.handleFocus); this.icon.removeEventListener('keydown', this.handleIconKeyDown); (_this$decorationClean2 = this.decorationCleanup) === null || _this$decorationClean2 === void 0 ? void 0 : _this$decorationClean2.call(this); if (this.cleanUpEditorDisabledOnChange) { this.cleanUpEditorDisabledOnChange(); } this.nodeViewPortalProviderAPI.remove(this.renderKey); } } export default function ({ getIntl, isMobile, api, nodeViewPortalProviderAPI, allowInteractiveExpand = true, __livePage }) { return (node, view, getPos) => { var _api$selection, _api$selection$action; return new ExpandNodeView(node, view, getPos, getIntl, isMobile, api === null || api === void 0 ? void 0 : (_api$selection = api.selection) === null || _api$selection === void 0 ? void 0 : (_api$selection$action = _api$selection.actions) === null || _api$selection$action === void 0 ? void 0 : _api$selection$action.selectNearNode, api, nodeViewPortalProviderAPI, allowInteractiveExpand, __livePage); }; }