UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

360 lines (358 loc) • 12.5 kB
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { bind, bindAll } from 'bind-event-listener'; import { akEditorDefaultLayoutWidth, akEditorFullWidthLayoutWidth, akEditorGutterPadding, akEditorGutterPaddingDynamic, akEditorGutterPaddingReduced, akEditorFullPageNarrowBreakout } from '@atlaskit/editor-shared-styles'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '../analytics'; import { LAYOUT_COLUMN_PADDING, LAYOUT_SECTION_MARGIN } from '../styles'; import { getBrowserInfo } from '../utils/browser'; import Resizer from './Resizer'; import { ResizerBreakoutModeLabel } from './ResizerBreakoutModeLabel'; import { SNAP_GAP, useBreakoutGuidelines } from './useBreakoutGuidelines'; const breakoutSupportedNodes = ['layoutSection', 'expand', 'codeBlock']; const getHandleStyle = (node, hidden) => { const layoutMarginOffset = 12; if (hidden) { return { left: { display: 'none' }, right: { display: 'none' } }; } switch (node) { case 'codeBlock': return { left: { left: '-12px' }, right: { right: '-12px' } }; // expand and layout section elements have a negative margin applied default: const handleOffset = fg('platform_editor_nested_dnd_styles_changes') ? LAYOUT_SECTION_MARGIN * 2 + layoutMarginOffset : LAYOUT_COLUMN_PADDING * 2; return { left: { left: `-${handleOffset}px`, height: 'calc(100% - 8px)', bottom: '0px', top: 'unset' }, right: { right: `-${handleOffset}px`, height: 'calc(100% - 8px)', bottom: '0px', top: 'unset' } }; } }; export const ignoreResizerMutations = mutation => { if (mutation.target instanceof Element) { return mutation.target.classList.contains('resizer-item') || mutation.type === 'attributes' && mutation.attributeName === 'style'; } return mutation.type === 'attributes' && mutation.attributeName === 'style'; }; const resizingStyles = { left: '50%', transform: 'translateX(-50%)', display: 'grid' }; // Apply grid to stop drag handles rendering inside .resizer-item affecting its height const defaultStyles = { display: 'grid' }; const RESIZE_STEP_VALUE = 10; const RESIZER_ENABLE_HANDLES = { left: true, right: true }; /** * BreakoutResizer is a common component used to resize nodes that support the 'Breakout' mark, so it requires * correct ADF support. * * use experiment platform_editor_advanced_layouts * @param root0 * @param root0.editorView * @param root0.nodeType * @param root0.getPos * @param root0.getRef * @param root0.disabled * @param root0.getEditorWidth * @param root0.parentRef * @param root0.displayGuidelines * @param root0.editorAnalyticsApi * @param root0.displayGapCursor * @param root0.onResizeStart * @param root0.dynamicFullWidthGuidelineOffset * @param root0.hidden Hide the resizer handles without outright unrendering them * @returns BreakoutResizer component * @example */ const BreakoutResizer = ({ editorView, nodeType, getPos, getRef, disabled, getEditorWidth, parentRef, displayGuidelines, editorAnalyticsApi, displayGapCursor, onResizeStart, dynamicFullWidthGuidelineOffset, hidden = false }) => { const [{ minWidth, maxWidth, isResizing }, setResizingState] = useState({ minWidth: undefined, maxWidth: undefined, isResizing: false }); const areResizeMetaKeysPressed = useRef(false); const resizerRef = useRef(null); const { snaps, currentLayout, guidelines, setCurrentWidth } = useBreakoutGuidelines(getEditorWidth, isResizing && editorExperiment('single_column_layouts', true), dynamicFullWidthGuidelineOffset); const browser = getBrowserInfo(); useEffect(() => { if (displayGuidelines) { displayGuidelines(guidelines || []); } }, [displayGuidelines, guidelines]); // Relying on re-renders caused by selection changes inside/around node const isSelectionInNode = useMemo(() => { const pos = getPos(); if (pos === undefined) { return false; } const node = editorView.state.doc.nodeAt(pos); if (node === null) { return false; } const endPos = pos + node.nodeSize; const startPos = pos; const { $from, $to } = editorView.state.selection; return $from.pos >= startPos && endPos >= $to.pos; }, [editorView.state.doc, editorView.state.selection, getPos]); const handleResizeStart = useCallback(() => { onResizeStart === null || onResizeStart === void 0 ? void 0 : onResizeStart(); let newMinWidth; let newMaxWidth; const widthState = getEditorWidth(); const { dispatch, state } = editorView; displayGapCursor(false); if (widthState !== undefined && widthState.lineLength !== undefined && widthState.width !== undefined) { const padding = widthState.width <= akEditorFullPageNarrowBreakout && editorExperiment('platform_editor_preview_panel_responsiveness', true, { exposure: true }) ? akEditorGutterPaddingReduced : akEditorGutterPaddingDynamic(); newMaxWidth = Math.min(widthState.width - padding * 2 - akEditorGutterPadding, akEditorFullWidthLayoutWidth); newMinWidth = Math.min(widthState.lineLength, akEditorDefaultLayoutWidth, newMaxWidth); } setResizingState({ isResizing: true, minWidth: newMinWidth, maxWidth: newMaxWidth }); dispatch(state.tr.setMeta('is-resizer-resizing', true)); }, [onResizeStart, getEditorWidth, editorView, displayGapCursor]); const handleResize = useCallback((originalState, delta) => { if (editorExperiment('single_column_layouts', true)) { const newWidth = originalState.width + delta.width; setCurrentWidth(newWidth); } }, [setCurrentWidth]); const handleResizeStop = useCallback((originalState, delta) => { const newWidth = originalState.width + delta.width; const pos = getPos(); if (pos === undefined) { return; } const { state, dispatch } = editorView; const { breakout } = state.schema.marks; const node = state.doc.nodeAt(pos); const newTr = state.tr; if (node && breakoutSupportedNodes.includes(node.type.name)) { if (currentLayout && ['wide', 'full-width'].includes(currentLayout) && editorExperiment('single_column_layouts', true)) { newTr.setNodeMarkup(pos, node.type, node.attrs, [breakout.create({ mode: currentLayout, width: null })]); } else { const newBreakoutWidth = Math.max(newWidth, akEditorDefaultLayoutWidth); newTr.setNodeMarkup(pos, node.type, node.attrs, [breakout.create({ width: newBreakoutWidth })]); const breakoutResizePayload = { action: ACTION.RESIZED, actionSubject: ACTION_SUBJECT.ELEMENT, eventType: EVENT_TYPE.TRACK, attributes: { nodeType: node.type.name, prevWidth: originalState.width, newWidth: newBreakoutWidth } }; editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent(breakoutResizePayload)(newTr); } } newTr.setMeta('is-resizer-resizing', false).setMeta('scrollIntoView', false); displayGapCursor(true); dispatch(newTr); setResizingState({ isResizing: false, minWidth: undefined, maxWidth: undefined }); setCurrentWidth(null); }, [getPos, editorView, displayGapCursor, setCurrentWidth, currentLayout, editorAnalyticsApi]); const handleEscape = useCallback(() => { editorView === null || editorView === void 0 ? void 0 : editorView.focus(); }, [editorView]); const handleLayoutSizeChangeOnKeypress = useCallback(step => { if (!parentRef) { return; } const resizerItem = parentRef.closest('.resizer-item'); if (!(resizerItem instanceof HTMLElement)) { return; } const newWidth = resizerItem.offsetWidth + step; if (maxWidth && newWidth > maxWidth || minWidth && newWidth < minWidth) { return; } handleResizeStop({ width: resizerItem.offsetWidth, x: 0, y: 0, height: 0 }, { width: step, height: 0 }); }, [handleResizeStop, maxWidth, minWidth, parentRef]); const resizeHandleKeyDownHandler = useCallback(event => { const isBracketKey = event.code === 'BracketRight' || event.code === 'BracketLeft'; const metaKey = browser.mac ? event.metaKey : event.ctrlKey; if (event.altKey || metaKey || event.shiftKey) { areResizeMetaKeysPressed.current = true; } if (event.altKey && metaKey) { if (isBracketKey) { event.preventDefault(); handleLayoutSizeChangeOnKeypress(event.code === 'BracketRight' ? RESIZE_STEP_VALUE : -RESIZE_STEP_VALUE); } } else if (!areResizeMetaKeysPressed.current) { handleEscape(); } }, [handleEscape, handleLayoutSizeChangeOnKeypress, browser]); const resizeHandleKeyUpHandler = useCallback(event => { if (event.altKey || event.metaKey) { areResizeMetaKeysPressed.current = false; } return; }, [areResizeMetaKeysPressed]); const resizerGlobalKeyDownHandler = useCallback(event => { if (!resizerRef.current) { return; } const resizeHandleThumbEl = resizerRef.current.getResizerThumbEl(); const metaKey = browser.mac ? event.metaKey : event.ctrlKey; const isTargetResizeHandle = event.target instanceof HTMLElement && event.target.classList.contains('resizer-handle-thumb'); if (event.altKey && event.shiftKey && metaKey && event.code === 'KeyR' || isTargetResizeHandle && (event.altKey || metaKey || event.shiftKey)) { event.preventDefault(); if (!resizeHandleThumbEl) { return; } resizeHandleThumbEl.focus(); resizeHandleThumbEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } }, [resizerRef, browser]); useLayoutEffect(() => { if (!resizerRef.current || !editorView) { return; } const resizeHandleThumbEl = resizerRef.current.getResizerThumbEl(); if (!resizeHandleThumbEl) { return; } const editorViewDom = editorView.dom; const unbindEditorViewDom = bind(editorViewDom, { type: 'keydown', listener: resizerGlobalKeyDownHandler }); const unbindResizeHandle = bindAll(resizeHandleThumbEl, [{ type: 'keydown', listener: resizeHandleKeyDownHandler }, { type: 'keyup', listener: resizeHandleKeyUpHandler }]); return () => { unbindEditorViewDom(); unbindResizeHandle(); }; }, [editorView, resizerGlobalKeyDownHandler, resizeHandleKeyDownHandler, resizeHandleKeyUpHandler]); if (disabled) { return /*#__PURE__*/React.createElement("div", { "data-testid": "breakout-resizer-editor-view-wrapper", ref: ref => getRef && getRef(ref) }); } return /*#__PURE__*/React.createElement(Resizer, { ref: resizerRef, enable: expValEquals('platform_editor_perf_lint_cleanup', 'isEnabled', true) ? RESIZER_ENABLE_HANDLES : { left: true, right: true }, snap: snaps || undefined, snapGap: SNAP_GAP, handleStyles: getHandleStyle(nodeType, hidden), minWidth: minWidth, maxWidth: maxWidth // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop , style: isResizing ? resizingStyles : defaultStyles, handleResizeStart: handleResizeStart, handleResizeStop: handleResizeStop, handleResize: handleResize, childrenDOMRef: getRef, resizeRatio: 2, isHandleVisible: isSelectionInNode, handleSize: "clamped", handleHighlight: "full-height", handlePositioning: "adjacent", handleAlignmentMethod: "sticky", labelComponent: currentLayout && editorExperiment('single_column_layouts', true) && ['full-width', 'wide'].includes(currentLayout || '') && /*#__PURE__*/React.createElement(ResizerBreakoutModeLabel, { layout: currentLayout }) }); }; export { BreakoutResizer };