UNPKG

@atlaskit/editor-plugin-layout

Version:

Layout plugin for @atlaskit/editor-core

411 lines (405 loc) 19.1 kB
import { ACTION, ACTION_SUBJECT, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { createSelectionClickHandler, GapCursorSelection, Side } from '@atlaskit/editor-common/selection'; import { filterCommand as filter } from '@atlaskit/editor-common/utils'; import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap'; import { Fragment } from '@atlaskit/editor-prosemirror/model'; import { NodeSelection, Selection, TextSelection } from '@atlaskit/editor-prosemirror/state'; import { findParentNodeClosestToPos, findParentNodeOfType } from '@atlaskit/editor-prosemirror/utils'; import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { fixColumnSizes, fixColumnStructure, getSelectedLayout, LAYOUT_COLUMN_INSERT_META } from './actions'; import { getColumnDividerDecorations } from './column-resize-divider'; import { EVEN_DISTRIBUTED_COL_WIDTHS } from './consts'; import { pluginKey } from './plugin-key'; import { pluginKey as layoutResizingPluginKey } from './resizing'; import { getGapCursorTargetForBlankSpaceClick, getMaybeLayoutSection, isParagraphBlankSpaceTarget } from './utils'; import { getSelectedLayoutColumnsFromSelection } from './utils/layout-column-selection'; export const DEFAULT_LAYOUT = 'two_equal'; /** * Shared blank-space gap cursor placement, used by both `handleClick` and `handleClickOn` * (the latter catches clicks on atomic node views that stop propagation before `handleClick`). * Returns `true` when it placed a selection and consumed the click, else `false`. */ const applyBlankSpaceGapCursor = (view, event) => { if (!expValEquals('platform_editor_layout_column_menu', 'isEnabled', true) || !editorExperiment('advanced_layouts', true)) { return false; } const gapTarget = getGapCursorTargetForBlankSpaceClick(view, event); if (gapTarget === undefined) { return false; } const $pos = view.state.doc.resolve(gapTarget.pos); // A paragraph child takes a TextSelection (caret at the edge) rather than a gap cursor. const isParagraphTarget = isParagraphBlankSpaceTarget(view, gapTarget); const nextSelection = isParagraphTarget ? TextSelection.near($pos, gapTarget.side === 'left' ? 1 : -1) : new GapCursorSelection($pos, gapTarget.side === 'left' ? Side.LEFT : Side.RIGHT); // Idempotency guard: `mousedown` already placed this selection, but the browser still // fires `mouseup`, so `handleClick`/`handleClickOn` re-run for the same click. Consume it // without re-dispatching (which would add a redundant undo entry). if (view.state.selection.eq(nextSelection)) { return true; } view.dispatch(view.state.tr.setSelection(nextSelection).scrollIntoView()); return true; }; const isWholeSelectionInsideLayoutColumn = state => { // Since findParentNodeOfType doesn't check if selection.to shares the parent, we do this check ourselves const fromParent = findParentNodeOfType(state.schema.nodes.layoutColumn)(state.selection); if (fromParent) { const isToPosInsideSameLayoutColumn = state.selection.from < fromParent.pos + fromParent.node.nodeSize; return isToPosInsideSameLayoutColumn; } return false; }; const moveCursorToNextColumn = (state, dispatch) => { const { selection } = state; const { schema: { nodes: { layoutColumn, layoutSection } } } = state; // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const section = findParentNodeOfType(layoutSection)(selection); // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const column = findParentNodeOfType(layoutColumn)(selection); if (column.node !== section.node.lastChild) { const $nextColumn = state.doc.resolve(column.pos + column.node.nodeSize); const shiftedSelection = TextSelection.findFrom($nextColumn, 1); if (dispatch) { dispatch(state.tr.setSelection(shiftedSelection)); } } return true; }; const getNodeDecoration = (pos, node) => [Decoration.node(pos, pos + node.nodeSize, { class: 'selected' })]; const getDangerPreviewDecorations = (state, positions) => { var _positions$flatMap; return (_positions$flatMap = positions === null || positions === void 0 ? void 0 : positions.flatMap(pos => { const node = state.doc.nodeAt(pos); if (!node) { return []; } return [Decoration.node(pos, pos + node.nodeSize, { class: 'layout-column-danger-preview' })]; })) !== null && _positions$flatMap !== void 0 ? _positions$flatMap : []; }; const getInitialPluginState = (options, state) => { const maybeLayoutSection = getMaybeLayoutSection(state); const allowBreakout = options.allowBreakout || false; const addSidebarLayouts = options.UNSAFE_addSidebarLayouts || false; const allowSingleColumnLayout = options.UNSAFE_allowSingleColumnLayout || false; const pos = maybeLayoutSection ? maybeLayoutSection.pos : null; const selectedLayout = getSelectedLayout(maybeLayoutSection && maybeLayoutSection.node, DEFAULT_LAYOUT); return { pos, allowBreakout, addSidebarLayouts, selectedLayout, allowSingleColumnLayout, isResizing: false, isLayoutColumnMenuOpen: false, layoutColumnMenuOpenedViaKeyboard: false, layoutColumnMenuAnchorPos: undefined, dangerPreviewLayoutColumnPositions: undefined }; }; const fireLayoutColumnMenuOpenedAnalytics = (editorAnalyticsAPI, state, openedViaKeyboard) => { const selectedLayoutColumnsResult = getSelectedLayoutColumnsFromSelection(state.selection); if (!selectedLayoutColumnsResult) { return; } const { layoutSectionNode, selectedLayoutColumns, startIndex, endIndex } = selectedLayoutColumnsResult; editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.fireAnalyticsEvent({ action: ACTION.OPENED, actionSubject: ACTION_SUBJECT.LAYOUT_COLUMN_MENU, attributes: { columnCount: layoutSectionNode.childCount, endIndex, inputMethod: openedViaKeyboard ? INPUT_METHOD.KEYBOARD : INPUT_METHOD.MOUSE, selectedCount: selectedLayoutColumns.length, startIndex }, eventType: EVENT_TYPE.UI }); }; const reduceLayoutColumnMenuState = (pluginState, action) => { switch (action.type) { case 'toggleLayoutColumnMenu': { const { anchorPos, isOpen, openedViaKeyboard } = action.meta; // `isOpen` provided: use directly (legacy). Omitted: toggle off only when re-clicking // the already-open column; otherwise open for the clicked column. const isSameColumnAsOpenMenu = pluginState.isLayoutColumnMenuOpen && anchorPos !== undefined && anchorPos === pluginState.layoutColumnMenuAnchorPos; const nextIsOpen = isOpen !== null && isOpen !== void 0 ? isOpen : !isSameColumnAsOpenMenu; return { ...pluginState, isLayoutColumnMenuOpen: nextIsOpen, layoutColumnMenuOpenedViaKeyboard: nextIsOpen ? openedViaKeyboard !== null && openedViaKeyboard !== void 0 ? openedViaKeyboard : false : false, layoutColumnMenuAnchorPos: nextIsOpen ? anchorPos : undefined, dangerPreviewLayoutColumnPositions: nextIsOpen ? pluginState.dangerPreviewLayoutColumnPositions : undefined }; } case 'setDangerPreview': return { ...pluginState, dangerPreviewLayoutColumnPositions: action.positions }; case 'clearDangerPreview': return { ...pluginState, dangerPreviewLayoutColumnPositions: undefined }; case 'setResizeState': return { ...pluginState, isResizing: action.isResizing }; case 'syncSelectionState': { const maybeLayoutSection = getMaybeLayoutSection(action.state); return { ...pluginState, pos: maybeLayoutSection ? maybeLayoutSection.pos : null, selectedLayout: getSelectedLayout(maybeLayoutSection && maybeLayoutSection.node, // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion pluginState.selectedLayout) }; } } }; // To prevent a single-column layout, // if a user attempts to delete a layout column and // we will force remove the content instead. // There are some edge cases where user can delete a layout column // see packages/editor/editor-plugin-layout-tests/src/__tests__/unit/delete.ts const handleDeleteLayoutColumn = (state, dispatch) => { const sel = state.selection; if (sel instanceof NodeSelection && sel.node.type.name === 'layoutColumn' && sel.$from.parent.type.name === 'layoutSection' && sel.$from.parent.childCount === 2 && dispatch && editorExperiment('advanced_layouts', true) && !editorExperiment('single_column_layouts', true)) { var _sel$$from$parent$las, _sel$$from$parent$fir; const tr = state.tr; const layoutContentFragment = sel.$from.parentOffset === 0 ? Fragment.from((_sel$$from$parent$las = sel.$from.parent.lastChild) === null || _sel$$from$parent$las === void 0 ? void 0 : _sel$$from$parent$las.content) : Fragment.from((_sel$$from$parent$fir = sel.$from.parent.firstChild) === null || _sel$$from$parent$fir === void 0 ? void 0 : _sel$$from$parent$fir.content); const parent = findParentNodeClosestToPos(sel.$from, node => { return node.type.name === 'layoutSection'; }); if (parent) { const layoutSectionPos = tr.mapping.map(parent.pos); const layoutSectionNodeSize = parent.node.nodeSize; dispatch(state.tr.replaceWith(layoutSectionPos, layoutSectionPos + layoutSectionNodeSize, layoutContentFragment)); return true; } return false; } return false; }; export default ((options, editorAnalyticsAPI) => { // Store a reference to the EditorView so widget decorations can dispatch transactions let editorViewRef; return new SafePlugin({ key: pluginKey, view(view) { editorViewRef = view; return { update(updatedView) { editorViewRef = updatedView; }, destroy() { editorViewRef = undefined; } }; }, state: { init: (_, state) => getInitialPluginState(options, state), apply: (tr, pluginState, oldState, newState) => { var _tr$getMeta, _pluginKey$getState; let nextPluginState = pluginState; const columnMenuMeta = tr.getMeta('toggleLayoutColumnMenu'); const dangerPreviewMeta = tr.getMeta('layoutColumnDangerPreview'); if (columnMenuMeta) { const wasLayoutColumnMenuOpen = nextPluginState.isLayoutColumnMenuOpen; nextPluginState = reduceLayoutColumnMenuState(nextPluginState, { meta: columnMenuMeta, type: 'toggleLayoutColumnMenu' }); if (!wasLayoutColumnMenuOpen && nextPluginState.isLayoutColumnMenuOpen) { fireLayoutColumnMenuOpenedAnalytics(editorAnalyticsAPI, newState, columnMenuMeta.openedViaKeyboard); } } if (tr.getMeta('layoutColumnDangerPreview') !== undefined) { nextPluginState = reduceLayoutColumnMenuState(nextPluginState, { positions: dangerPreviewMeta !== null && dangerPreviewMeta !== void 0 ? dangerPreviewMeta : undefined, type: 'setDangerPreview' }); } if (tr.docChanged) { nextPluginState = reduceLayoutColumnMenuState(nextPluginState, { type: 'clearDangerPreview' }); } const isResizing = editorExperiment('single_column_layouts', true) ? (_tr$getMeta = tr.getMeta('is-resizer-resizing')) !== null && _tr$getMeta !== void 0 ? _tr$getMeta : (_pluginKey$getState = pluginKey.getState(oldState)) === null || _pluginKey$getState === void 0 ? void 0 : _pluginKey$getState.isResizing : false; nextPluginState = reduceLayoutColumnMenuState(nextPluginState, { isResizing, type: 'setResizeState' }); if (tr.docChanged || tr.selectionSet) { return reduceLayoutColumnMenuState(nextPluginState, { state: newState, type: 'syncSelectionState' }); } return nextPluginState; } }, props: { decorations(state) { const layoutState = pluginKey.getState(state); const isLayoutResizingPluginAvailable = layoutResizingPluginKey.get(state) !== undefined; if (editorExperiment('advanced_layouts', true) && editorExperiment('platform_editor_layout_column_resize_handle', true) && isLayoutResizingPluginAvailable) { const dividerDecorations = getColumnDividerDecorations(state, editorViewRef, editorAnalyticsAPI); const selectedDecorations = layoutState.pos !== null ? getNodeDecoration(layoutState.pos, state.doc.nodeAt(layoutState.pos)) : []; const dangerPreviewDecorations = getDangerPreviewDecorations(state, layoutState.dangerPreviewLayoutColumnPositions); const allDecorations = [...selectedDecorations, ...dividerDecorations, ...dangerPreviewDecorations]; if (allDecorations.length > 0) { return DecorationSet.create(state.doc, allDecorations); } return undefined; } const dangerPreviewDecorations = getDangerPreviewDecorations(state, layoutState.dangerPreviewLayoutColumnPositions); if (layoutState.pos !== null || dangerPreviewDecorations.length > 0) { const selectedDecorations = layoutState.pos !== null ? getNodeDecoration(layoutState.pos, state.doc.nodeAt(layoutState.pos)) : []; return DecorationSet.create(state.doc, [...selectedDecorations, ...dangerPreviewDecorations]); } return undefined; }, handleKeyDown: keydownHandler({ Tab: filter(isWholeSelectionInsideLayoutColumn, moveCursorToNextColumn), 'Mod-Backspace': handleDeleteLayoutColumn, 'Mod-Delete': handleDeleteLayoutColumn, Backspace: handleDeleteLayoutColumn, Delete: handleDeleteLayoutColumn }), handleDOMEvents: { // Place the gap cursor on `mousedown` (not `mouseup`) so the caret never flashes // inside a nested editable child first. mousedown(view, event) { const target = event.target; if (target !== null && target !== void 0 && target.hasAttribute('data-layout-section')) { return false; } if (applyBlankSpaceGapCursor(view, event)) { event.preventDefault(); // `preventDefault()` blocks the editor focus that makes the gap cursor blink, // so restore it here. The `handleClick`/`handleClickOn` paths don't need this. if (!view.hasFocus()) { view.focus(); } return true; } return false; } }, handleClickOn: (() => { const selectionClickHandler = createSelectionClickHandler(['layoutColumn'], target => target.hasAttribute('data-layout-section') || target.hasAttribute('data-layout-column'), { useLongPressSelection: options.useLongPressSelection || false, getNodeSelectionPos: (state, nodePos) => state.doc.resolve(nodePos).before() }); return (view, pos, node, nodePos, event, direct) => { // Fallback for clicks on an atomic node view that the mousedown hook missed. const target = event.target; if (!(target !== null && target !== void 0 && target.hasAttribute('data-layout-section')) && applyBlankSpaceGapCursor(view, event)) { return true; } return selectionClickHandler(view, pos, node, nodePos, event, direct); }; })(), handleClick(view, _pos, event) { // Fallback for clicks the mousedown interceptor missed. const target = event.target; if (target !== null && target !== void 0 && target.hasAttribute('data-layout-section')) { return false; } return applyBlankSpaceGapCursor(view, event); } }, appendTransaction: (transactions, _oldState, newState) => { const changes = []; transactions.forEach(prevTr => { // remap change segments across the transaction set changes.forEach(change => { return { from: prevTr.mapping.map(change.from), to: prevTr.mapping.map(change.to), slice: change.slice }; }); // don't consider transactions that don't mutate if (!prevTr.docChanged) { return; } // Skip fixing column sizes for column resize drag transactions if (editorExperiment('platform_editor_layout_column_resize_handle', true) && prevTr.getMeta('layoutColumnResize')) { return; } // Layout column insert actions already recalculate column widths and need their own // selection mapping; avoid a follow-up normalisation transaction that can remap it. if (prevTr.getMeta(LAYOUT_COLUMN_INSERT_META)) { return; } const change = fixColumnSizes(prevTr, newState); if (change) { changes.push(change); } }); if (editorExperiment('advanced_layouts', true) && changes.length === 1) { var _change$slice$content, _change$slice$content2; const change = changes[0]; // When editorExperiment('single_column_layouts', true) is on // delete can create a single column layout // otherwise we replace the single column layout with its content if (!editorExperiment('single_column_layouts', true) && change.slice.content.childCount === 1 && ((_change$slice$content = change.slice.content.firstChild) === null || _change$slice$content === void 0 ? void 0 : _change$slice$content.type.name) === 'layoutColumn' && ((_change$slice$content2 = change.slice.content.firstChild) === null || _change$slice$content2 === void 0 ? void 0 : _change$slice$content2.attrs.width) === EVEN_DISTRIBUTED_COL_WIDTHS[1]) { const tr = newState.tr; const { content } = change.slice.content.firstChild; tr.replaceWith(change.from - 1, change.to, content); return tr; } } if (changes.length) { let tr = newState.tr; const selection = newState.selection.toJSON(); changes.forEach(change => { tr.replaceRange(change.from, change.to, change.slice); }); // selecting and deleting across columns in 3 col layouts can remove // a layoutColumn so we fix the structure here tr = fixColumnStructure(newState) || tr; if (tr.docChanged) { tr.setSelection(Selection.fromJSON(tr.doc, selection)); return tr; } } return; } }); });