@atlaskit/editor-plugin-block-type
Version:
BlockType plugin for @atlaskit/editor-core
332 lines (321 loc) • 10.6 kB
JavaScript
import { anyMarkActive } from '@atlaskit/editor-common/mark';
import { createBlockTaskItem } from '@atlaskit/editor-common/transforms';
import { createRule, createWrappingJoinRule } from '@atlaskit/editor-common/utils';
import { hasParentNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { WRAPPER_BLOCK_TYPES, FORMATTING_NODE_TYPES, FORMATTING_MARK_TYPES } from './block-types';
export const isNodeAWrappingBlockNode = node => {
if (!node) {
return false;
}
return WRAPPER_BLOCK_TYPES.some(blockNode => blockNode.name === node.type.name);
};
export const createJoinNodesRule = (match, nodeType) => {
return createWrappingJoinRule({
nodeType,
match,
getAttrs: {},
joinPredicate: (_, node) => node.type === nodeType
});
};
export const createWrappingTextBlockRule = ({
match,
nodeType,
getAttrs
}) => {
const handler = (state, match, start, end) => {
const fixedStart = Math.max(start, 1);
const $start = state.doc.resolve(fixedStart);
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
const nodeBefore = $start.node(-1);
if (nodeBefore && !nodeBefore.canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) {
return null;
}
return state.tr.delete(fixedStart, end).setBlockType(fixedStart, fixedStart, nodeType, attrs);
};
return createRule(match, handler);
};
/**
* Function will create a list of wrapper blocks present in a selection.
*/
function getSelectedWrapperNodes(state) {
const nodes = [];
if (state.selection) {
const {
$from,
$to,
empty
} = state.selection;
const {
blockquote,
panel,
orderedList,
bulletList,
listItem,
caption,
codeBlock,
decisionItem,
decisionList,
taskItem,
taskList
} = state.schema.nodes;
const wrapperNodes = [blockquote, panel, orderedList, bulletList, listItem, codeBlock, decisionItem, decisionList, taskItem, taskList];
wrapperNodes.push(caption);
if (empty && expValEquals('platform_editor_small_font_size', 'isEnabled', true)) {
for (let depth = 0; depth <= $from.depth; depth++) {
const node = $from.node(depth);
if (node.isBlock && wrapperNodes.indexOf(node.type) >= 0) {
nodes.push(node.type);
}
}
return nodes;
}
state.doc.nodesBetween($from.pos, $to.pos, node => {
if (node.isBlock && wrapperNodes.indexOf(node.type) >= 0) {
nodes.push(node.type);
}
});
}
return nodes;
}
/**
* Function will check if changing block types: Paragraph, Heading is enabled.
*/
export function areBlockTypesDisabled(state, allowFontSize = false) {
const nodesTypes = getSelectedWrapperNodes(state);
const {
panel,
blockquote,
bulletList,
orderedList,
listItem,
taskList,
taskItem
} = state.schema.nodes;
const isSmallFontSizeEnabled = allowFontSize && expValEquals('platform_editor_small_font_size', 'isEnabled', true);
const excludedTypes = isSmallFontSizeEnabled ? [panel, bulletList, orderedList, listItem, taskList, taskItem] : [panel];
const disallowedWrapperTypes = nodesTypes.filter(type => !excludedTypes.includes(type));
if (isSmallFontSizeEnabled) {
const selectionInsideList = isSelectionInsideListNode(state);
const selectionInsideQuote = isSelectionInsideBlockquote(state);
// Inside a blockquote (but not a list nested within one): the blockquote itself isn't a
// disallowing wrapper, but anything else is.
if (selectionInsideQuote && !selectionInsideList) {
return disallowedWrapperTypes.some(type => type !== blockquote);
}
return disallowedWrapperTypes.length > 0;
}
if (editorExperiment('platform_editor_blockquote_in_text_formatting_menu', true)) {
let hasQuote = false;
let hasNestedListInQuote = false;
const {
$from,
$to
} = state.selection;
state.doc.nodesBetween($from.pos, $to.pos, node => {
if (node.type === blockquote) {
hasQuote = true;
node.descendants(child => {
if (child.type === bulletList || child.type === orderedList) {
hasNestedListInQuote = true;
return false;
}
return true;
});
}
return !hasNestedListInQuote;
});
return disallowedWrapperTypes.length > 0 && (!hasQuote || hasNestedListInQuote);
}
return disallowedWrapperTypes.length > 0;
}
/**
* Checks if the current selection is inside a list node (bulletList, orderedList, or taskList).
* Used to determine which text styles should be enabled when the small font size experiment is active.
*/
export function isSelectionInsideListNode(state) {
if (!state.selection) {
return false;
}
const {
$from,
$to
} = state.selection;
const {
bulletList,
orderedList,
taskList
} = state.schema.nodes;
const listNodeTypes = [bulletList, orderedList, taskList];
let insideList = false;
state.doc.nodesBetween($from.pos, $to.pos, node => {
if (node.isBlock && listNodeTypes.indexOf(node.type) >= 0) {
insideList = true;
return false;
}
return true;
});
return insideList || listNodeTypes.some(nodeType => hasParentNodeOfType(nodeType)(state.selection));
}
export function isSelectionInsideBlockquote(state) {
if (!state.selection) {
return false;
}
const {
$from,
$to
} = state.selection;
const {
blockquote
} = state.schema.nodes;
// For collapsed selections, check if the cursor is inside a blockquote
if ($from.pos === $to.pos) {
return hasParentNodeOfType(blockquote)(state.selection);
}
// For range selections, check if any node in the range is a blockquote,
// or if the selection starts/ends inside a blockquote
let insideQuote = false;
state.doc.nodesBetween($from.pos, $to.pos, node => {
if (node.type === blockquote) {
insideQuote = true;
return false;
}
return true;
});
return insideQuote || hasParentNodeOfType(blockquote)(state.selection);
}
const blockStylingIsPresent = state => {
const {
from,
to
} = state.selection;
let isBlockStyling = false;
state.doc.nodesBetween(from, to, node => {
if (FORMATTING_NODE_TYPES.indexOf(node.type.name) !== -1) {
isBlockStyling = true;
return false;
}
return true;
});
return isBlockStyling;
};
const marksArePresent = state => {
const activeMarkTypes = FORMATTING_MARK_TYPES.filter(mark => {
if (!!state.schema.marks[mark]) {
const {
$from,
empty
} = state.selection;
const {
marks
} = state.schema;
if (empty) {
return !!marks[mark].isInSet(state.storedMarks || $from.marks());
}
return anyMarkActive(state, marks[mark]);
}
return false;
});
return activeMarkTypes.length > 0;
};
export const checkFormattingIsPresent = state => {
return marksArePresent(state) || blockStylingIsPresent(state);
};
export const hasBlockQuoteInOptions = dropdownOptions => {
return !!dropdownOptions.find(blockType => blockType.name === 'blockquote');
};
/**
* Returns a { from, to } range that extends the selection boundaries outward
* to include the entirety of any list nodes at either end. If the selection
* start is inside a list, `from` is pulled back to the list's start; if the
* selection end is inside a list, `to` is pushed forward to the list's end.
* Non-list content in the middle is included as-is.
*/
export function getSelectionRangeExpandedToLists(tr) {
const {
selection
} = tr;
const {
bulletList,
orderedList,
taskList
} = tr.doc.type.schema.nodes;
const listNodeTypes = [bulletList, orderedList, taskList];
let from = selection.from;
let to = selection.to;
// Walk up from the selection start to find the outermost list node.
// We do NOT break at the first list found because task lists nest differently
// from bullet/ordered lists:
// - bullet/ordered: bulletList > listItem > bulletList (nested inside listItem)
// - task: taskList > taskList (nested as direct children)
// For task lists, breaking at the first list would only capture the innermost
// taskList, missing sibling task items in parent lists. By continuing to walk
// up, we find the outermost list and include all nested content.
for (let depth = selection.$from.depth; depth > 0; depth--) {
const node = selection.$from.node(depth);
if (listNodeTypes.indexOf(node.type) >= 0) {
from = selection.$from.before(depth);
}
}
for (let depth = selection.$to.depth; depth > 0; depth--) {
const node = selection.$to.node(depth);
if (listNodeTypes.indexOf(node.type) >= 0) {
to = selection.$to.after(depth);
}
}
return {
from,
to
};
}
/**
* Converts all taskItem nodes within the given range to blockTaskItem nodes.
*
* taskItem nodes contain inline content directly, which cannot hold block-level
* marks like fontSize. blockTaskItem nodes wrap content in paragraphs, which can
* hold block marks. This conversion is needed when applying small text formatting
* to task lists.
*
* The inline content of each taskItem is wrapped in a paragraph node, and the
* taskItem is replaced with a blockTaskItem that preserves the original attributes
* (localId, state).
*
* Collects taskItem positions in a forward pass over the unmutated document,
* then applies replacements in reverse document order so positions remain valid
* without needing remapping or doc snapshots.
*/
export function convertTaskItemsToBlockTaskItems(tr, from, to) {
const {
nodes: {
taskItem,
blockTaskItem
}
} = tr.doc.type.schema;
if (!blockTaskItem || !taskItem) {
return;
}
// Collect taskItem positions from the current (unmutated) document
const taskItemsToConvert = [];
tr.doc.nodesBetween(from, to, (node, pos) => {
if (node.type === taskItem) {
taskItemsToConvert.push({
pos,
node
});
}
});
// Replace in reverse document order so earlier positions remain valid
for (let i = taskItemsToConvert.length - 1; i >= 0; i--) {
const {
pos,
node
} = taskItemsToConvert[i];
const blockTaskNode = createBlockTaskItem({
attrs: node.attrs,
content: node.content,
schema: tr.doc.type.schema
});
tr.replaceWith(pos, pos + node.nodeSize, blockTaskNode);
}
}