UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

396 lines (388 loc) 21.7 kB
/** * @jsxRuntime classic * @jsx jsx */ import React, { useCallback, useEffect, useState } from 'react'; // eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled, @typescript-eslint/consistent-type-imports import { css, jsx } from '@emotion/react'; import { bind } from 'bind-event-listener'; import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks'; import { ToolTipContent } from '@atlaskit/editor-common/keymaps'; import { blockControlsMessages as messages } from '@atlaskit/editor-common/messages'; import { tableControlsSpacing } from '@atlaskit/editor-common/styles'; import { TextSelection } from '@atlaskit/editor-prosemirror/state'; import { findParentNode, findParentNodeOfType } from '@atlaskit/editor-prosemirror/utils'; import { relativeSizeToBaseFontSize } from '@atlaskit/editor-shared-styles'; import { CellSelection } from '@atlaskit/editor-tables/cell-selection'; import AddIcon from '@atlaskit/icon/core/add'; import { fg } from '@atlaskit/platform-feature-flags'; // eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- to be migrated to @atlaskit/primitives/compiled – go/akcss import { Box, Pressable, xcss } from '@atlaskit/primitives'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import Tooltip from '@atlaskit/tooltip'; import { getNodeTypeWithLevel } from '../pm-plugins/decorations-common'; import { getControlBottomCSSValue, getControlHeightCSSValue, getNodeHeight, getTopPosition, shouldBeSticky } from '../pm-plugins/utils/drag-handle-positions'; import { getLeftPositionForRootElement } from '../pm-plugins/utils/widget-positions'; import { QUICK_INSERT_DIMENSIONS, QUICK_INSERT_HEIGHT, QUICK_INSERT_LEFT_OFFSET, QUICK_INSERT_WIDTH, rootElementGap, STICKY_CONTROLS_TOP_MARGIN_FOR_STICKY_HEADER, topPositionAdjustment } from './consts'; import { refreshAnchorName } from './utils/anchor-name'; import { isInTextSelection, isNestedNodeSelected, isNonEditableBlock, isSelectionInNode } from './utils/document-checks'; import { getAnchorAttrName } from './utils/dom-attr-name'; import { createNewLine } from './utils/editor-commands'; import { VisibilityContainer } from './visibility-container'; const TEXT_PARENT_TYPES = ['paragraph', 'heading', 'blockquote', 'taskItem', 'decisionItem']; const stickyButtonStyles = xcss({ top: '0', position: 'sticky', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: "var(--ds-space-300, 24px)", width: "var(--ds-space-300, 24px)", border: 'none', backgroundColor: 'color.background.neutral.subtle', borderRadius: 'radius.full', zIndex: 'card', outline: 'none', ':hover': { backgroundColor: 'color.background.neutral.subtle.hovered' }, ':active': { backgroundColor: 'color.background.neutral.subtle.pressed' }, ':focus': { outline: `${"var(--ds-border-width-focused, 2px)"} solid ${"var(--ds-border-focused, #4688EC)"}` } }); // Calculate scaled dimensions based on the base font size using CSS calc() // Default font size is 16px, scale proportionally // Standard: 16px -> 24px x 24px, Dense: 13px -> ~18.5px x ~18.5px const stickyButtonDenseModeStyles = xcss({ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values height: relativeSizeToBaseFontSize(QUICK_INSERT_HEIGHT), // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values width: relativeSizeToBaseFontSize(QUICK_INSERT_WIDTH) }); const containerStaticStyles = xcss({ position: 'absolute', zIndex: 'card' }); const tooltipContainerStyles = css({ top: '8px', bottom: '-8px', position: 'sticky', display: 'block', zIndex: 'card' }); const tooltipContainerStylesStickyHeader = css({ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-blocks-quick-insert-container]:has(~ [data-prosemirror-node-name="table"] .pm-table-with-controls tr.sticky) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: tableControlsSpacing } }); const tooltipContainerImprovedStylesStickyHeader = css({ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-blocks-quick-insert-container]:has(~ [data-prosemirror-node-name="table"] .pm-table-with-controls tr.sticky) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: tableControlsSpacing }, // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-blocks-quick-insert-container]:has(~ [data-prosemirror-mark-name="fragment"] >[data-prosemirror-node-name="table"] tr.pm-table-row-native-sticky.pm-table-row-native-sticky-active) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: `${STICKY_CONTROLS_TOP_MARGIN_FOR_STICKY_HEADER}px` }, // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-blocks-quick-insert-container]:has(~ [data-prosemirror-node-name="table"] tr.pm-table-row-native-sticky.pm-table-row-native-sticky-active) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: `${STICKY_CONTROLS_TOP_MARGIN_FOR_STICKY_HEADER}px` }, // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-blocks-quick-insert-container]:has(~ [data-prosemirror-mark-name="fragment"] >[data-prosemirror-node-name="table"] .pm-table-with-controls tr.sticky) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: tableControlsSpacing } }); // We need this to work around adjacent breakout marks wrapping the controls widget decorations const tooltipContainerStylesStickyHeaderWithMarksFix = css({ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-prosemirror-mark-name="breakout"]:has([data-blocks-quick-insert-container]):has(~ [data-prosemirror-node-name="table"] .pm-table-with-controls tr.sticky) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: tableControlsSpacing } }); // We need this to work around adjacent breakout marks wrapping the controls widget decorations const tooltipContainerStylesImprovedStickyHeaderWithMarksFix = css({ // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-prosemirror-mark-name="breakout"]:has([data-blocks-quick-insert-container]):has(~ [data-prosemirror-node-name="table"] .pm-table-with-controls tr.sticky) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: tableControlsSpacing }, // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-prosemirror-mark-name="breakout"]:has([data-blocks-quick-insert-container]):has(~ [data-prosemirror-mark-name="fragment"] >[data-prosemirror-node-name="table"] .pm-table-with-controls tr.sticky) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: tableControlsSpacing }, // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-prosemirror-mark-name="breakout"]:has([data-blocks-quick-insert-container]):has(~ [data-prosemirror-node-name="table"] tr.pm-table-row-native-sticky.pm-table-row-native-sticky-active) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: `${STICKY_CONTROLS_TOP_MARGIN_FOR_STICKY_HEADER}px` }, // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors, @atlaskit/ui-styling-standard/no-unsafe-selectors '[data-prosemirror-mark-name="breakout"]:has([data-blocks-quick-insert-container]):has(~ [data-prosemirror-mark-name="fragment"] >[data-prosemirror-node-name="table"] tr.pm-table-row-native-sticky.pm-table-row-native-sticky-active) &': { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values top: `${STICKY_CONTROLS_TOP_MARGIN_FOR_STICKY_HEADER}px` } }); // TODO: ED-26959 - Share prop types between DragHandle - generic enough to create a type for block control decoration export const TypeAheadControl = ({ view, api, formatMessage, getPos, anchorName, rootAnchorName, rootNodeType, anchorRectCache }) => { const { macroInteractionUpdates } = useSharedPluginStateWithSelector(api, ['featureFlags'], states => { var _states$featureFlagsS; return { macroInteractionUpdates: (_states$featureFlagsS = states.featureFlagsState) === null || _states$featureFlagsS === void 0 ? void 0 : _states$featureFlagsS.macroInteractionUpdates }; }); const [positionStyles, setPositionStyles] = useState({ display: 'none' }); // Adapted from `src/ui/drag-handle.tsx` as positioning logic is similar // CHANGES - added an offset so quick insert button can be positioned beside drag handle // CHANGES - removed `editorExperiment('nested-dnd', true)` check and rootNodeType calculation // CHANGES - replace anchorName with rootAnchorName // CHANGES - `removed editorExperiment('advanced_layouts', true) && isLayoutColumn` checks as quick insert button will not be positioned for layout column const calculatePosition = useCallback(() => { const supportsAnchor = CSS.supports('top', `anchor(${rootAnchorName} start)`) && CSS.supports('left', `anchor(${rootAnchorName} start)`); const pos = getPos(); const node = pos !== undefined ? view.state.doc.nodeAt(pos) : undefined; const safeAnchorName = refreshAnchorName({ getPos, view, anchorName: rootAnchorName }); const dom = view.dom.querySelector(`[${getAnchorAttrName()}="${safeAnchorName}"]`); const hasResizer = rootNodeType === 'table' || rootNodeType === 'mediaSingle'; const isExtension = rootNodeType === 'extension' || rootNodeType === 'bodiedExtension'; const isBlockCard = rootNodeType === 'blockCard'; const isEmbedCard = rootNodeType === 'embedCard'; const isMacroInteractionUpdates = macroInteractionUpdates && isExtension; let innerContainer = null; if (dom) { if (isEmbedCard) { innerContainer = dom.querySelector('.rich-media-item'); } else if (hasResizer) { innerContainer = dom.querySelector('.resizer-item'); } else if (isExtension) { innerContainer = dom.querySelector('.extension-container[data-layout]'); } else if (isBlockCard) { //specific to datasource blockCard innerContainer = dom.querySelector('.datasourceView-content-inner-wrap'); } } const isEdgeCase = (hasResizer || isExtension || isEmbedCard || isBlockCard) && innerContainer; const isSticky = shouldBeSticky(rootNodeType); const bottom = getControlBottomCSSValue(safeAnchorName || anchorName, isSticky, true); if (supportsAnchor) { return { left: isEdgeCase ? `calc(anchor(${safeAnchorName} start) + ${getLeftPositionForRootElement(dom, rootNodeType, QUICK_INSERT_DIMENSIONS, innerContainer, isMacroInteractionUpdates)} + -${QUICK_INSERT_LEFT_OFFSET}px)` : `calc(anchor(${safeAnchorName} start) - ${QUICK_INSERT_DIMENSIONS.width}px - ${rootElementGap(rootNodeType)}px + -${QUICK_INSERT_LEFT_OFFSET}px)`, // small text requires further tweaking to positioning, re-using existing methods to calculate to keep it unified with drag handle top: `calc(anchor(${safeAnchorName} start) + ${topPositionAdjustment(node && node.type.name === 'paragraph' && expValEquals('platform_editor_small_font_size', 'isEnabled', true) ? getNodeTypeWithLevel(node) : rootNodeType)}px)`, ...bottom }; } // expensive, calls offsetHeight const nodeHeight = getNodeHeight(dom, safeAnchorName || anchorName, anchorRectCache) || 0; const height = getControlHeightCSSValue(nodeHeight, isSticky, true, "var(--ds-space-300, 24px)"); return { left: isEdgeCase ? `calc(${(dom === null || dom === void 0 ? void 0 : dom.offsetLeft) || 0}px + ${getLeftPositionForRootElement(dom, rootNodeType, QUICK_INSERT_DIMENSIONS, innerContainer, isMacroInteractionUpdates)} + -${QUICK_INSERT_LEFT_OFFSET}px)` : `calc(${getLeftPositionForRootElement(dom, rootNodeType, QUICK_INSERT_DIMENSIONS, innerContainer, isMacroInteractionUpdates)} + -${QUICK_INSERT_LEFT_OFFSET}px)`, top: getTopPosition(dom, rootNodeType), ...height }; }, [rootAnchorName, getPos, view, rootNodeType, macroInteractionUpdates, anchorName, anchorRectCache]); useEffect(() => { let cleanUpTransitionListener; if (rootNodeType === 'extension' || rootNodeType === 'embedCard') { const dom = view.dom.querySelector(`[${getAnchorAttrName()}="${rootAnchorName}"]`); if (!dom) { return; } cleanUpTransitionListener = bind(dom, { type: 'transitionend', listener: () => { setPositionStyles(calculatePosition()); } }); } const calcPos = requestAnimationFrame(() => { setPositionStyles(calculatePosition()); }); return () => { var _cleanUpTransitionLis; cancelAnimationFrame(calcPos); (_cleanUpTransitionLis = cleanUpTransitionListener) === null || _cleanUpTransitionLis === void 0 ? void 0 : _cleanUpTransitionLis(); }; }, [calculatePosition, view.dom, rootAnchorName, rootNodeType]); const handleQuickInsert = useCallback(() => { var _api$quickInsert; // if the selection is not within the node this decoration is rendered at // then insert a newline and trigger quick insert const start = getPos(); if (start !== undefined) { // if the selection is not within the node this decoration is rendered at // or the node is non-editable, then insert a newline and trigger quick insert const isSelectionInsideNode = isSelectionInNode(start, view); if (!isSelectionInsideNode || isNonEditableBlock(start, view)) { api.core.actions.execute(createNewLine(start)); } const { codeBlock } = view.state.schema.nodes; const { selection } = view.state; const codeBlockParentNode = findParentNodeOfType(codeBlock)(selection); if (codeBlockParentNode) { // Slash command is not meant to be triggered inside code block, hence always insert slash in a new line following api.core.actions.execute(createNewLine(codeBlockParentNode.pos)); } else if (isSelectionInsideNode) { // text or element with be deselected and the / added immediately after the paragraph // unless the selection is empty const currentSelection = view.state.selection; if (isInTextSelection(view) && currentSelection.from !== currentSelection.to) { const currentParagraphNode = findParentNode(node => TEXT_PARENT_TYPES.includes(node.type.name))(currentSelection); if (currentParagraphNode) { const newPos = //if the current selection is selected from right to left, then set the selection to the start of the paragraph currentSelection.anchor === currentSelection.to ? currentParagraphNode.pos : currentParagraphNode.pos + currentParagraphNode.node.nodeSize - 1; api.core.actions.execute(({ tr }) => { tr.setSelection(TextSelection.create(view.state.selection.$from.doc, newPos)); return tr; }); } } if (isNestedNodeSelected(view)) { // if the nested selected node is non-editable, then insert a newline below the selected node if (isNonEditableBlock(view.state.selection.from, view)) { api.core.actions.execute(createNewLine(view.state.selection.from)); } else { // otherwise need to force the selection to be at the start of the node, because // prosemirror is keeping it as NodeSelection for nested nodes. Do this to keep it // consistent NodeSelection for root level nodes. api.core.actions.execute(({ tr }) => { createNewLine(view.state.selection.from)({ tr }); tr.setSelection(TextSelection.create(tr.doc, view.state.selection.from)); return tr; }); } } if (currentSelection instanceof CellSelection) { // find the last inline position in the selection const lastInlinePosition = TextSelection.near(view.state.selection.$to, -1); lastInlinePosition && api.core.actions.execute(({ tr }) => { if (!(lastInlinePosition instanceof TextSelection)) { // this will create a new line after the node createNewLine(lastInlinePosition.from)({ tr }); // this will find the next valid text position after the node tr.setSelection(TextSelection.create(tr.doc, lastInlinePosition.to)); } else { tr.setSelection(lastInlinePosition); } return tr; }); } } } (_api$quickInsert = api.quickInsert) === null || _api$quickInsert === void 0 ? void 0 : _api$quickInsert.actions.openTypeAhead('blockControl', true); }, [api, getPos, view]); const handleMouseDown = useCallback(() => { var _api$typeAhead; // close typeahead if it is open, must happen in mouseDown otherwise typeAhead popup will be dismissed and text is left if ((_api$typeAhead = api.typeAhead) !== null && _api$typeAhead !== void 0 && _api$typeAhead.actions.isOpen(view.state)) { api.typeAhead.actions.close({ insertCurrentQueryAsRawText: false }); } }, [api, view.state]); const tooltipPressable = () => jsx(Tooltip, { position: "top", content: jsx(ToolTipContent, { description: formatMessage(messages.insert) }) }, jsx(Pressable, { testId: "editor-quick-insert-button", type: "button", "aria-label": formatMessage(messages.insert) // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , xcss: [stickyButtonStyles, (expValEquals('confluence_compact_text_format', 'isEnabled', true) || expValEquals('cc_editor_ai_content_mode', 'variant', 'test') && fg('platform_editor_content_mode_button_mvp')) && stickyButtonDenseModeStyles], onClick: handleQuickInsert, onMouseDown: handleMouseDown }, jsx(AddIcon, { label: "add", color: "var(--ds-icon-subtle, #505258)", size: "small" }))); return jsx(Box // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop , { style: positionStyles // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , xcss: [containerStaticStyles] }, jsx("span", { css: [tooltipContainerStyles, expValEquals('platform_editor_table_sticky_header_improvements', 'cohort', 'test_with_overflow') && fg('platform_editor_table_sticky_header_patch_6') ? tooltipContainerImprovedStylesStickyHeader : tooltipContainerStylesStickyHeader, expValEquals('platform_editor_table_sticky_header_improvements', 'cohort', 'test_with_overflow') && fg('platform_editor_table_sticky_header_patch_6') ? tooltipContainerStylesImprovedStickyHeaderWithMarksFix : tooltipContainerStylesStickyHeaderWithMarksFix] }, tooltipPressable())); }; export const QuickInsertWithVisibility = ({ view, api, formatMessage, getPos, nodeType, anchorName, rootAnchorName, rootNodeType, anchorRectCache }) => { const rightSideControlsEnabled = useSharedPluginStateWithSelector(api, ['blockControls'], states => { var _states$blockControls, _states$blockControls2; return { rightSideControlsEnabled: (_states$blockControls = (_states$blockControls2 = states.blockControlsState) === null || _states$blockControls2 === void 0 ? void 0 : _states$blockControls2.rightSideControlsEnabled) !== null && _states$blockControls !== void 0 ? _states$blockControls : false }; }).rightSideControlsEnabled; return jsx(VisibilityContainer, { api: api, controlSide: rightSideControlsEnabled ? 'left' : undefined }, jsx(TypeAheadControl, { view: view, api: api, formatMessage: formatMessage, getPos: getPos, nodeType: nodeType, anchorName: anchorName, rootAnchorName: rootAnchorName, rootNodeType: rootNodeType, anchorRectCache: anchorRectCache })); };