@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
360 lines (358 loc) • 12.5 kB
JavaScript
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 };