UNPKG

@atlaskit/editor-core

Version:

A package contains Atlassian editor core functionality

635 lines (564 loc) • 19.9 kB
import { EditorState, EditorView, Plugin, PluginKey, tableEditing, NodeViewDesc, CellSelection, Selection, TableMap, Node, Slice, Decoration, DecorationSet, TextSelection, } from '../../prosemirror'; import keymapHandler from './keymap'; import * as tableBaseCommands from '../../prosemirror/prosemirror-tables'; import { getColumnPos, getRowPos, getTablePos, getSelectedColumn, getSelectedRow, containsTableHeader, } from './utils'; import { analyticsService } from '../../analytics'; export type TableStateSubscriber = (state: TableState) => any; export interface SelectedCell { pos: number; node: Node; } export interface PluginConfig { isHeaderRowRequired?: boolean; } export class TableState { keymapHandler: Function; cellElement?: HTMLElement; tableElement?: HTMLElement; editorFocused: boolean = false; tableNode?: Node; cellSelection?: CellSelection; toolbarFocused: boolean = false; tableHidden: boolean = false; tableDisabled: boolean = false; tableActive: boolean = false; domEvent: boolean = false; hoveredCells: SelectedCell[] = []; private isHeaderRowRequired: boolean = false; private view: EditorView; private changeHandlers: TableStateSubscriber[] = []; constructor(state: EditorState<any>, pluginConfig: PluginConfig = {}) { this.changeHandlers = []; const { table, tableCell, tableRow, tableHeader } = state.schema.nodes; this.tableHidden = !table || !tableCell || !tableRow || !tableHeader; this.isHeaderRowRequired = pluginConfig.isHeaderRowRequired || false; } insertColumn = (column: number): void => { if (this.tableNode) { const map = TableMap.get(this.tableNode); const { dispatch } = this.view; // last column if (column === map.width) { // to add a column we need to move the cursor to an appropriate cell first const prevColPos = map.positionAt(0, column - 1, this.tableNode); this.moveCursorTo(prevColPos); tableBaseCommands.addColumnAfter(this.view.state, dispatch); // then we move the cursor to the newly created cell const nextPos = TableMap.get(this.tableNode).positionAt(0, column, this.tableNode); this.moveCursorTo(nextPos); } else { const pos = map.positionAt(0, column, this.tableNode); this.moveCursorTo(pos); tableBaseCommands.addColumnBefore(this.view.state, dispatch); this.moveCursorTo(pos); } analyticsService.trackEvent('atlassian.editor.format.table.column.button'); } } insertRow = (row: number): void => { if (this.tableNode) { const map = TableMap.get(this.tableNode); const { dispatch } = this.view; // last row if (row === map.height) { const prevRowPos = map.positionAt(row - 1, 0, this.tableNode); this.moveCursorTo(prevRowPos); tableBaseCommands.addRowAfter(this.view.state, dispatch); const nextPos = TableMap.get(this.tableNode).positionAt(row, 0, this.tableNode); this.moveCursorTo(nextPos); } else { const pos = map.positionAt(row, 0, this.tableNode); this.moveCursorTo(pos); tableBaseCommands.addRowBefore(this.view.state, dispatch); this.moveCursorTo(pos); } analyticsService.trackEvent('atlassian.editor.format.table.row.button'); } } remove = (): void => { if (!this.cellSelection) { return; } const { state, dispatch } = this.view; const isRowSelected = this.cellSelection.isRowSelection(); const isColumnSelected = this.cellSelection.isColSelection(); // the whole table if (isRowSelected && isColumnSelected) { tableBaseCommands.deleteTable(state, dispatch); this.focusEditor(); analyticsService.trackEvent('atlassian.editor.format.table.delete.button'); } else if (isColumnSelected) { analyticsService.trackEvent('atlassian.editor.format.table.delete_column.button'); // move the cursor in the column to the left of the deleted column(s) const map = TableMap.get(this.tableNode!); const { anchor, head } = getSelectedColumn(this.view.state, map); const column = Math.min(anchor, head); const nextPos = map.positionAt(0, column > 0 ? column - 1 : 0, this.tableNode!); tableBaseCommands.deleteColumn(state, dispatch); this.moveCursorTo(nextPos); } else if (isRowSelected) { const { tableHeader } = this.view.state.schema.nodes; const cell = this.getCurrentCell(); const event = cell && cell.type === tableHeader ? 'delete_header_row' : 'delete_row'; analyticsService.trackEvent(`atlassian.editor.format.table.${event}.button`); const headerRowSelected = this.isHeaderRowSelected(); // move the cursor to the beginning of the next row, or prev row if deleted row was the last row const { anchor, head } = getSelectedRow(this.view.state); const map = TableMap.get(this.tableNode!); const minRow = Math.min(anchor, head); const maxRow = Math.max(anchor, head); const isRemovingLastRow = maxRow === (map.height - 1); tableBaseCommands.deleteRow(state, dispatch); if (headerRowSelected && this.isHeaderRowRequired) { this.convertFirstRowToHeader(); } const nextPos = map.positionAt(isRemovingLastRow ? minRow - 1 : minRow, 0, this.tableNode!); this.moveCursorTo(nextPos); } else { // replace selected cells with empty cells this.emptySelectedCells(); this.moveCursorInsideTableTo(state.selection.from); analyticsService.trackEvent('atlassian.editor.format.table.delete_content.button'); } } convertFirstRowToHeader = () => { this.selectRow(0); const { state, dispatch } = this.view; tableBaseCommands.toggleHeaderRow(state, dispatch); } subscribe(cb: TableStateSubscriber): void { this.changeHandlers.push(cb); cb(this); } unsubscribe(cb: TableStateSubscriber): void { this.changeHandlers = this.changeHandlers.filter(ch => ch !== cb); } updateEditorFocused(editorFocused: boolean): void { this.editorFocused = editorFocused; } updateToolbarFocused(toolbarFocused: boolean): void { this.toolbarFocused = toolbarFocused; } selectColumn = (column: number): void => { if (this.tableNode) { const {from, to} = getColumnPos(column, this.tableNode); this.createCellSelection(from, to); } } selectRow = (row: number): void => { if (this.tableNode) { const {from, to} = getRowPos(row, this.tableNode); this.createCellSelection(from, to); } } selectTable = (): void => { if (this.tableNode) { const {from, to} = getTablePos(this.tableNode); this.createCellSelection(from, to); } } hoverColumn = (column: number): void => { if (this.tableNode) { const {from, to} = getColumnPos(column, this.tableNode); this.createHoverSelection(from, to); } } hoverRow = (row: number): void => { if (this.tableNode) { const {from, to} = getRowPos(row, this.tableNode); this.createHoverSelection(from, to); } } hoverTable = () => { if (this.tableNode) { const {from, to} = getTablePos(this.tableNode); this.createHoverSelection(from, to); } } resetHoverSelection = () => { this.hoveredCells = []; this.view.dispatch(this.view.state.tr); } isColumnSelected = (column: number): boolean => { if (this.tableNode && this.cellSelection) { const map = TableMap.get(this.tableNode); const start = this.cellSelection.$anchorCell.start(-1); const anchor = map.colCount(this.cellSelection.$anchorCell.pos - start); const head = map.colCount(this.cellSelection.$headCell.pos - start); return ( this.cellSelection.isColSelection() && (column <= Math.max(anchor, head) && column >= Math.min(anchor, head)) ); } return false; } isRowSelected = (row: number): boolean => { if (this.cellSelection) { const anchor = this.cellSelection.$anchorCell.index(-1); const head = this.cellSelection.$headCell.index(-1); return ( this.cellSelection.isRowSelection() && (row <= Math.max(anchor, head) && row >= Math.min(anchor, head)) ); } return false; } isHeaderRowSelected = (): boolean => { if (this.cellSelection && this.cellSelection.isRowSelection()) { const { $from } = this.view.state.selection; const { tableHeader } = this.view.state.schema.nodes; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); if(node.type === tableHeader) { return true; } } } return false; } isTableSelected = (): boolean => { if (this.cellSelection) { return this.cellSelection.isColSelection() && this.cellSelection.isRowSelection(); } return false; } update(docView: NodeViewDesc, domEvent: boolean = false) { let dirty = this.updateSelection(); const { cellSelection } = this; const tableElement = this.getTableElement(docView); if (domEvent && tableElement || tableElement !== this.tableElement) { this.tableElement = tableElement; this.domEvent = domEvent; dirty = true; } const tableNode = this.getTableNode(); if (tableNode !== this.tableNode) { this.tableNode = tableNode; dirty = true; } // show floating toolbar only when the whole row, column or table is selected const toolbarVisible = ( cellSelection && (cellSelection.isColSelection() || cellSelection.isRowSelection()) ? true : false ); const cellElement = toolbarVisible ? this.getFirstSelectedCellElement(docView) : undefined; if (cellElement !== this.cellElement) { this.cellElement = cellElement; dirty = true; } const tableActive = this.editorFocused && !!tableElement; if (tableActive !== this.tableActive) { this.tableActive = tableActive; dirty = true; } const tableDisabled = !this.canInsertTable(); if (tableDisabled !== this.tableDisabled) { this.tableDisabled = tableDisabled; dirty = true; } if (dirty) { this.triggerOnChange(); } } setView(view: EditorView): void { this.view = view; } tableStartPos(): number | undefined { const { $from } = this.view.state.selection; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); if(node.type === this.view.state.schema.nodes.table) { return $from.start(i); } } } closeFloatingToolbar (): void { this.clearSelection(); this.triggerOnChange(); } getCurrentCellStartPos(): number | undefined { const { $from } = this.view.state.selection; const { tableCell, tableHeader } = this.view.state.schema.nodes; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); if(node.type === tableCell || node.type === tableHeader) { return $from.start(i); } } } isRequiredToAddHeader = (): boolean => this.isHeaderRowRequired; addHeaderToTableNodes = (slice: Node, selectionStart: number): void => { const { table } = this.view.state.schema.nodes; slice.content.forEach((node: Node, offset: number) => { if (node.type === table && !containsTableHeader(this.view, node)) { const { state, dispatch } = this.view; const { tr, doc } = state; const $anchor = doc.resolve(selectionStart + offset); dispatch(tr.setSelection(new TextSelection($anchor))); this.convertFirstRowToHeader(); } }); } private getCurrentCell(): Node | undefined { const { $from } = this.view.state.selection; const { tableCell, tableHeader } = this.view.state.schema.nodes; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); if(node.type === tableCell || node.type === tableHeader) { return node; } } } private createHoverSelection (from: number, to: number): void { if (!this.tableNode) { return; } const offset = this.tableStartPos(); if (offset) { const { state } = this.view; const map = TableMap.get(this.tableNode); const cells = map.cellsInRect(map.rectBetween(from, to)); cells.forEach(cellPos => { const pos = cellPos + offset; const node = state.doc.nodeAt(pos); if (node) { this.hoveredCells.push({node, pos}); } }); // trigger state change to be able to pick it up in the decorations handler this.view.dispatch(state.tr); } } private getTableElement(docView: NodeViewDesc): HTMLElement | undefined { const offset = this.tableStartPos(); if (offset) { const { node } = docView.domFromPos(offset); if (node) { return node.parentNode as HTMLElement; } } } private getFirstSelectedCellElement(docView: NodeViewDesc): HTMLElement | undefined { const offset = this.firstSelectedCellStartPos(); if (offset) { const { node } = docView.domFromPos(offset); if (node) { return node as HTMLElement; } } } private firstSelectedCellStartPos(): number | undefined { if (!this.tableNode) { return; } const offset = this.tableStartPos(); if (offset) { const { state } = this.view; const { $anchorCell, $headCell } = state.selection as CellSelection; const { tableCell, tableHeader } = state.schema.nodes; const map = TableMap.get(this.tableNode); const start = $anchorCell.start(-1); // array of selected cells positions const cells = map.cellsInRect(map.rectBetween($anchorCell.pos - start, $headCell.pos - start)); // first selected cell position const firstCellPos = cells[0] + offset + 1; const $from = state.doc.resolve(firstCellPos); for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); if(node.type === tableCell || node.type === tableHeader) { return $from.start(i); } } } } private getTableNode(): Node | undefined { const { $from } = this.view.state.selection; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); if(node.type === this.view.state.schema.nodes.table) { return node; } } } private triggerOnChange(): void { this.changeHandlers.forEach(cb => cb(this)); } private createCellSelection (from: number, to: number): void { const { state } = this.view; // here "from" and "to" params are table-relative positions, therefore we add table offset const offset = this.tableStartPos(); if (offset) { const $anchor = state.doc.resolve(from + offset); const $head = state.doc.resolve(to + offset); this.view.dispatch( this.view.state.tr.setSelection(new CellSelection($anchor, $head)) ); } } // we keep track of selection changes because // 1) we want to mark toolbar buttons as active when the whole row/col is selected // 2) we want to drop selection if editor looses focus private updateSelection (): boolean { const { selection } = this.view.state; let dirty = false; if (selection instanceof CellSelection) { if (selection !== this.cellSelection) { this.cellSelection = selection; dirty = true; } // drop selection if editor looses focus if (!this.editorFocused) { this.clearSelection(); } } else if (this.cellSelection) { this.cellSelection = undefined; dirty = true; } return dirty; } private clearSelection () { const { state } = this.view; this.cellElement = undefined; this.view.dispatch(state.tr.setSelection(Selection.near(state.selection.$from))); } private canInsertTable (): boolean { const { state } = this.view; const { $from, to } = state.selection; const { code } = state.schema.marks; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); // inline code and codeBlock are excluded if(node.type === state.schema.nodes.codeBlock || (code && state.doc.rangeHasMark($from.pos, to, code))) { return false; } } return true; } private emptySelectedCells (): void { if (!this.cellSelection) { return; } const { tr, schema } = this.view.state; const emptyCell = schema.nodes.tableCell.createAndFill().content; this.cellSelection.forEachCell((cell, pos) => { if (!cell.content.eq(emptyCell)) { const slice = new Slice(emptyCell, 0, 0); tr.replace(tr.mapping.map(pos + 1), tr.mapping.map(pos + cell.nodeSize - 1), slice); } }); if (tr.docChanged) { this.view.dispatch(tr); } } private focusEditor (): void { if (!this.view.hasFocus()) { this.view.focus(); } } private moveCursorInsideTableTo (pos: number): void { this.focusEditor(); const { tr } = this.view.state; tr.setSelection(Selection.near(tr.doc.resolve(pos))); this.view.dispatch(tr); } private moveCursorTo (pos: number): void { const offset = this.tableStartPos(); if (offset) { this.moveCursorInsideTableTo(pos + offset); } } } export const stateKey = new PluginKey('tablePlugin'); export const plugin = (pluginConfig?: PluginConfig) => new Plugin({ state: { init(config, state: EditorState<any>) { return new TableState(state, pluginConfig); }, apply(tr, pluginState: TableState, oldState, newState) { const stored = tr.getMeta(stateKey); if (stored) { pluginState.update(stored.docView, stored.domEvent); } return pluginState; } }, key: stateKey, view: (editorView: EditorView) => { const pluginState = stateKey.getState(editorView.state); pluginState.setView(editorView); pluginState.update(editorView.docView); pluginState.keymapHandler = keymapHandler(pluginState); return { update: (view: EditorView, prevState: EditorState<any>) => { pluginState.update(view.docView); } }; }, props: { decorations: (state: EditorState<any>) => { const pluginState = stateKey.getState(state); if (!pluginState.hoveredCells.length) { return; } const cells: Decoration[] = pluginState.hoveredCells.map(cell => { return Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, {class: 'hoveredCell'}); }); return DecorationSet.create(state.doc, cells); }, handleKeyDown(view, event) { return stateKey.getState(view.state).keymapHandler(view, event); }, handleClick(view: EditorView, pos: number, event) { stateKey.getState(view.state).update(view.docView, true); return false; }, onFocus(view: EditorView, event) { const pluginState = stateKey.getState(view.state); pluginState.updateEditorFocused(true); pluginState.update(view.docView, true); }, onBlur(view: EditorView, event) { const pluginState = stateKey.getState(view.state); if (pluginState.toolbarFocused) { pluginState.updateToolbarFocused(false); } else { pluginState.updateEditorFocused(false); pluginState.update(view.docView, true); } pluginState.resetHoverSelection(); }, } }); const plugins = (pluginConfig?: PluginConfig) => { return [plugin(pluginConfig), tableEditing()].filter((plugin) => !!plugin) as Plugin[]; }; export default plugins; // Disable inline table editing and resizing controls in Firefox // https://github.com/ProseMirror/prosemirror/issues/432 setTimeout(() => { document.execCommand('enableObjectResizing', false, 'false'); document.execCommand('enableInlineTableEditing', false, 'false'); });