UNPKG

@atlaskit/editor-plugin-expand

Version:

Expand plugin for @atlaskit/editor-core

731 lines (727 loc) 28.5 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 { GapCursorSelection, RelativeSelectionPos, Side } from '@atlaskit/editor-common/selection'; import { expandClassNames } from '@atlaskit/editor-common/styles'; import { expandMessages } from '@atlaskit/editor-common/ui'; import { closestElement, isEmptyNode } from '@atlaskit/editor-common/utils'; import { DOMSerializer } from '@atlaskit/editor-prosemirror/model'; import { NodeSelection, Selection } from '@atlaskit/editor-prosemirror/state'; import { fg } from '@atlaskit/platform-feature-flags'; import { redo, undo } from '@atlaskit/prosemirror-history'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { renderExpandButton } from '../../ui/renderExpandButton'; import { deleteExpandAtPos, setSelectionInsideExpand, toggleExpandExpanded, updateExpandTitle } from '../commands'; import { ExpandIconButton } from '../ui/ExpandIconButton'; function buildExpandClassName(type, expanded) { return `${expandClassNames.prefix} ${expandClassNames.type(type)} ${expanded ? expandClassNames.expanded : ''}`; } const toDOM = (node, __livePage, intl, titleReadOnly, contentEditable) => ['div', { // prettier-ignore 'class': buildExpandClassName(node.type.name, __livePage ? !node.attrs.__expanded : node.attrs.__expanded), 'data-node-type': node.type.name, 'data-title': node.attrs.title, ...(fg('platform_editor_adf_with_localid') && { 'data-local-id': node.attrs.localId }) }, ['div', { // prettier-ignore 'class': expandClassNames.titleContainer, contenteditable: 'false', // Element gains access to focus events. // This is needed to prevent PM gaining access // on interacting with our controls. tabindex: '-1' }, // prettier-ignore ['div', { 'class': expandClassNames.icon, style: `display: flex; width: ${"var(--ds-space-300, 24px)"}; height: ${"var(--ds-space-300, 24px)"}` }], ['div', { // prettier-ignore 'class': expandClassNames.inputContainer }, ['input', { // prettier-ignore 'class': expandClassNames.titleInput, 'aria-label': intl && intl.formatMessage(expandMessages.expandArialabel) || expandMessages.expandArialabel.defaultMessage, value: node.attrs.title, placeholder: intl && intl.formatMessage(expandMessages.expandPlaceholderText) || expandMessages.expandPlaceholderText.defaultMessage, type: 'text', readonly: titleReadOnly ? 'true' : undefined }]]], ['div', { // prettier-ignore class: `${expandClassNames.content} ${(__livePage ? !node.attrs.__expanded : node.attrs.__expanded) ? '' : expandClassNames.contentCollapsed}`, contenteditable: contentEditable !== undefined ? contentEditable ? 'true' : 'false' : undefined }, 0]]; export class ExpandNodeView { constructor(_node, view, getPos, getIntl, isMobile, selectNearNode, api, nodeViewPortalProviderAPI, allowInteractiveExpand = true, __livePage = false, cleanUpEditorDisabledOnChange) { var _api$editorDisabled, _api$editorDisabled$s; _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; const { state, dispatch } = this.view; 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, nodeType: this.node.type, __livePage: this.__livePage })(state, dispatch); 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, __livePage: this.__livePage })(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 legacyExpand, please also update the implementation in singlePlayerExpand 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 = dom.getAttribute('data-node-anchor'); // 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; } const keyName_result = keyName(event); switch (keyName_result) { 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.deleteExpand(event); 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, "deleteExpand", _event => { if (!this.input) { return; } const pos = this.getPos(); if (typeof pos !== 'number') { return; } const { selectionStart, selectionEnd } = this.input; if (selectionStart !== selectionEnd || selectionStart !== 0) { return; } const { state } = this.view; const expandNode = this.node; if (expandNode && isEmptyNode(state.schema)(expandNode)) { var _this$api5, _this$api5$analytics; deleteExpandAtPos((_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)(pos, expandNode)(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, nodeType: this.node.type, __livePage: this.__livePage })(state, dispatch); } }); _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", () => { if (this.__livePage) { return this.node.attrs.__expanded; } return !this.node.attrs.__expanded; }); _defineProperty(this, "setRightGapCursor", event => { if (!this.input) { return; } const pos = this.getPos(); if (typeof pos !== 'number') { return; } const { value, selectionStart, selectionEnd } = this.input; if (selectionStart === selectionEnd && selectionStart === value.length) { 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 = this.__livePage ? !node.attrs.__expanded : node.attrs.__expanded; 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; }); this.selectNearNode = selectNearNode; this.__livePage = __livePage; this.cleanUpEditorDisabledOnChange = cleanUpEditorDisabledOnChange; this.intl = getIntl(); 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)); this.nodeViewPortalProviderAPI = nodeViewPortalProviderAPI; this.allowInteractiveExpand = allowInteractiveExpand; this.getPos = getPos; this.view = view; this.node = _node; // 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}`); this.content = this.dom.querySelector(`.${expandClassNames.content}`); // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead this.renderKey = uuid(); if (expValEquals('platform_editor_vc90_transition_expand_icon', 'isEnabled', true)) { this.renderNativeIcon(this.node); } else { this.renderIcon(this.intl); } this.initHandlers(); } initHandlers() { var _this$api8; if (this.dom) { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.dom.addEventListener('click', this.handleClick); // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.dom.addEventListener('input', this.handleInput); } if (this.input) { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.input.addEventListener('keydown', this.handleTitleKeydown); // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.input.addEventListener('blur', this.handleBlur); // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.input.addEventListener('focus', this.handleInputFocus); } if (this.titleContainer) { // If the user interacts in our title bar (either toggle or input) // Prevent ProseMirror from getting a focus event (causes weird selection issues). // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.titleContainer.addEventListener('focus', this.handleFocus); } if (this.icon) { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners 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'); } this.updateDisplayStyle(this.node); }); } } renderNativeIcon(node) { if (!this.icon) { return; } const { __expanded } = node.attrs; renderExpandButton(this.icon, { expanded: this.__livePage ? !__expanded : __expanded, allowInteractiveExpand: this.allowInteractiveExpand, intl: this.intl }); } renderIcon(intl, node) { if (!this.icon) { return; } const { __expanded } = node && node.attrs || this.node.attrs; this.nodeViewPortalProviderAPI.render(() => /*#__PURE__*/React.createElement(ExpandIconButton, { intl: intl, allowInteractiveExpand: this.allowInteractiveExpand, expanded: this.__livePage ? !__expanded : __expanded }), this.icon, this.renderKey); } updateDisplayStyle(node) { if (this.content) { const isCollapsed = this.__livePage ? node.attrs.__expanded : !node.attrs.__expanded; if (isCollapsed) { this.content.classList.add(expandClassNames.contentCollapsed); } else { this.content.classList.remove(expandClassNames.contentCollapsed); } } } 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) { if (this.node.attrs.__expanded !== node.attrs.__expanded) { // Instead of re-rendering the view on an expand toggle // we toggle a class name to hide the content and animate the chevron. if (this.dom) { this.dom.classList.toggle(expandClassNames.expanded); if (expValEquals('platform_editor_vc90_transition_expand_icon', 'isEnabled', true)) { this.renderNativeIcon(node); } else { this.renderIcon(this && this.intl, node); } } if (this.content) { // Disallow interaction/selection inside when collapsed. this.content.setAttribute('contenteditable', this.getContentEditable(node) ? 'true' : 'false'); } this.updateDisplayStyle(node); } // 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.node = node; return true; } return false; } destroy() { var _this$decorationClean2; if (this.dom) { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.dom.removeEventListener('click', this.handleClick); // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.dom.removeEventListener('input', this.handleInput); } if (this.input) { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.input.removeEventListener('keydown', this.handleTitleKeydown); // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.input.removeEventListener('blur', this.handleBlur); // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.input.removeEventListener('focus', this.handleInputFocus); } if (this.titleContainer) { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.titleContainer.removeEventListener('focus', this.handleFocus); } if (this.icon) { // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners this.icon.removeEventListener('keydown', this.handleIconKeyDown); this.nodeViewPortalProviderAPI.remove(this.renderKey); } (_this$decorationClean2 = this.decorationCleanup) === null || _this$decorationClean2 === void 0 ? void 0 : _this$decorationClean2.call(this); if (this.cleanUpEditorDisabledOnChange) { this.cleanUpEditorDisabledOnChange(); } // @ts-ignore - [unblock prosemirror bump] reset non optional prop to undefined to clear reference this.dom = undefined; this.contentDOM = undefined; this.icon = undefined; this.input = undefined; this.titleContainer = undefined; this.content = undefined; this.cleanUpEditorDisabledOnChange = undefined; } } 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); }; }