@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
286 lines (281 loc) • 8.55 kB
JavaScript
import { Fragment } from '@atlaskit/editor-prosemirror/model';
import { NodeSelection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE } from '../analytics';
import { withAnalytics } from '../editor-analytics';
import { GapCursorSelection } from '../selection';
import { isEmptyParagraph } from './editor-core-utils';
import { isMediaNode } from './nodes';
export const filter = (predicates, cmd) => {
return function (state, dispatch, view) {
if (!Array.isArray(predicates)) {
predicates = [predicates];
}
if (predicates.some(pred => !pred(state, view))) {
return false;
}
return cmd(state, dispatch, view) || false;
};
};
/**
* Walk forwards from a position until we encounter the (inside) start of
* the next node, or reach the end of the document.
*
* @param $startPos Position to start walking from.
*/
export const walkNextNode = $startPos => {
let $pos = $startPos;
// invariant 1: don't walk past the end of the document
// invariant 2: we are at the beginning or
// we haven't walked to the start of *any* node
// parentOffset includes textOffset.
while ($pos.pos < $pos.doc.nodeSize - 2 && ($pos.pos === $startPos.pos || $pos.parentOffset > 0)) {
$pos = $pos.doc.resolve($pos.pos + 1);
}
return {
$pos: $pos,
foundNode: $pos.pos < $pos.doc.nodeSize - 2
};
};
/**
* Walk backwards from a position until we encounter the (inside) end of
* the previous node, or reach the start of the document.
*
* @param $startPos Position to start walking from.
*/
export const walkPrevNode = $startPos => {
let $pos = $startPos;
while ($pos.pos > 0 && ($pos.pos === $startPos.pos || $pos.parentOffset < $pos.parent.nodeSize - 2)) {
$pos = $pos.doc.resolve($pos.pos - 1);
}
return {
$pos: $pos,
foundNode: $pos.pos > 0
};
};
export function insertNewLine() {
return function (state, dispatch) {
const {
$from
} = state.selection;
const parent = $from.parent;
const {
hardBreak
} = state.schema.nodes;
if (hardBreak) {
const hardBreakNode = hardBreak.createChecked();
if (parent && parent.type.validContent(Fragment.from(hardBreakNode))) {
if (dispatch) {
dispatch(state.tr.replaceSelectionWith(hardBreakNode, false));
}
return true;
}
}
if (state.selection instanceof TextSelection) {
if (dispatch) {
dispatch(state.tr.insertText('\n'));
}
return true;
}
return false;
};
}
export const insertNewLineWithAnalytics = editorAnalyticsAPI => withAnalytics(editorAnalyticsAPI, {
action: ACTION.INSERTED,
actionSubject: ACTION_SUBJECT.TEXT,
actionSubjectId: ACTION_SUBJECT_ID.LINE_BREAK,
eventType: EVENT_TYPE.TRACK
})(insertNewLine());
export const createNewParagraphAbove = (state, dispatch) => {
const append = false;
if (!canMoveUp(state) && canCreateParagraphNear(state)) {
createParagraphNear(append)(state, dispatch);
return true;
}
return false;
};
export const createNewParagraphBelow = (state, dispatch) => {
const append = true;
if (!canMoveDown(state) && canCreateParagraphNear(state)) {
createParagraphNear(append)(state, dispatch);
return true;
}
return false;
};
function canCreateParagraphNear(state) {
const {
selection: {
$from
}
} = state;
const node = $from.node($from.depth);
const insideCodeBlock = !!node && node.type === state.schema.nodes.codeBlock;
const isNodeSelection = state.selection instanceof NodeSelection;
return $from.depth > 1 || isNodeSelection || insideCodeBlock;
}
export function createParagraphNear(append = true) {
return function (state, dispatch) {
const paragraph = state.schema.nodes.paragraph;
if (!paragraph) {
return false;
}
let insertPos;
if (state.selection instanceof TextSelection) {
if (topLevelNodeIsEmptyTextBlock(state)) {
return false;
}
insertPos = getInsertPosFromTextBlock(state, append);
} else {
insertPos = getInsertPosFromNonTextBlock(state, append);
}
const tr = state.tr.insert(insertPos, paragraph.createAndFill());
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
if (dispatch) {
dispatch(tr);
}
return true;
};
}
function getInsertPosFromTextBlock(state, append) {
const {
$from,
$to
} = state.selection;
let pos;
if (!append) {
pos = $from.start(0);
} else {
pos = $to.end(0);
}
return pos;
}
function getInsertPosFromNonTextBlock(state, append) {
const {
$from,
$to
} = state.selection;
const nodeAtSelection = state.selection instanceof NodeSelection && state.doc.nodeAt(state.selection.$anchor.pos);
const isMediaSelection = nodeAtSelection && nodeAtSelection.type.name === 'mediaGroup';
let pos;
if (!append) {
// The start position is different with text block because it starts from 0
pos = $from.start($from.depth);
// The depth is different with text block because it starts from 0
pos = $from.depth > 0 && !isMediaSelection ? pos - 1 : pos;
} else {
pos = $to.end($to.depth);
pos = $to.depth > 0 && !isMediaSelection ? pos + 1 : pos;
}
return pos;
}
function topLevelNodeIsEmptyTextBlock(state) {
const topLevelNode = state.selection.$from.node(1);
return topLevelNode.isTextblock && topLevelNode.type !== state.schema.nodes.codeBlock && topLevelNode.nodeSize === 2;
}
function canMoveUp(state) {
const {
selection
} = state;
/**
* If there's a media element on the selection it will use a gap cursor to move
*/
if (selection instanceof NodeSelection && isMediaNode(selection.node)) {
return true;
}
if (selection instanceof TextSelection) {
if (!selection.empty) {
return true;
}
}
return !atTheBeginningOfDoc(state);
}
function canMoveDown(state) {
const {
selection
} = state;
/**
* If there's a media element on the selection it will use a gap cursor to move
*/
if (selection instanceof NodeSelection && isMediaNode(selection.node)) {
return true;
}
if (selection instanceof TextSelection) {
if (!selection.empty) {
return true;
}
}
return !atTheEndOfDoc(state);
}
export function atTheEndOfDoc(state) {
const {
selection,
doc
} = state;
return doc.nodeSize - selection.$to.pos - 2 === selection.$to.depth;
}
export function atTheBeginningOfDoc(state) {
const {
selection
} = state;
return selection.$from.pos === selection.$from.depth;
}
/**
* If the selection is empty, is inside a paragraph node and `canNextNodeMoveUp` is true then delete current paragraph
* and move the node below it up. The selection will be retained, to be placed in the moved node.
*
* @param canNextNodeMoveUp check if node directly after the selection is able to be brought up to selection
* @returns PM Command
*/
export const deleteEmptyParagraphAndMoveBlockUp = canNextNodeMoveUp => {
return (state, dispatch, view) => {
const {
selection: {
$from: {
pos,
parent
},
$head,
empty
},
tr,
doc
} = state;
const {
$pos
} = walkNextNode($head);
const nextPMNode = doc.nodeAt($pos.pos - 1);
if (empty && nextPMNode && canNextNodeMoveUp(nextPMNode) && isEmptyParagraph(parent) && view !== null && view !== void 0 && view.endOfTextblock('right')) {
tr.deleteRange(pos - 1, pos + 1);
if (dispatch) {
dispatch(tr);
}
return true;
}
return false;
};
};
export const insertContentDeleteRange = (tr, getSelectionResolvedPos, insertions, deletions) => {
insertions.forEach(contentInsert => {
let [content, pos] = contentInsert;
tr.insert(tr.mapping.map(pos), content);
});
deletions.forEach(deleteRange => {
let [firstPos, lastPos] = deleteRange;
tr.delete(tr.mapping.map(firstPos), tr.mapping.map(lastPos));
});
tr.setSelection(new TextSelection(getSelectionResolvedPos(tr)));
};
export const isEmptySelectionAtStart = state => {
const {
empty,
$from
} = state.selection;
return empty && ($from.parentOffset === 0 || state.selection instanceof GapCursorSelection);
};
export const isEmptySelectionAtEnd = state => {
const {
empty,
$from
} = state.selection;
return empty && ($from.end() === $from.pos || state.selection instanceof GapCursorSelection);
};
export { filter as filterCommand };