@atlaskit/editor-core
Version:
A package contains Atlassian editor core functionality
635 lines (564 loc) • 19.9 kB
text/typescript
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');
});