@atlaskit/editor-plugin-media
Version:
Media plugin for @atlaskit/editor-core
386 lines (381 loc) • 15.7 kB
JavaScript
import memoizeOne from 'memoize-one';
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
import { safeInsert, shouldSplitSelectedNodeOnNodeInsertion } from '@atlaskit/editor-common/insert';
import { DEFAULT_IMAGE_WIDTH, getMaxWidthForNestedNodeNext, getMediaSingleInitialWidth, MEDIA_SINGLE_DEFAULT_MIN_PIXEL_WIDTH, MEDIA_SINGLE_VIDEO_MIN_PIXEL_WIDTH } from '@atlaskit/editor-common/media-single';
import { atTheBeginningOfBlock, selectionIsAtTheBeginningOfBlock } from '@atlaskit/editor-common/selection';
import { checkNodeDown, isEmptyParagraph } from '@atlaskit/editor-common/utils';
import { Fragment, Slice } from '@atlaskit/editor-prosemirror/model';
import { safeInsert as pmSafeInsert, removeSelectedNode } from '@atlaskit/editor-prosemirror/utils';
import { fg } from '@atlaskit/platform-feature-flags';
import { copyOptionalAttrsFromMediaState } from '../utils/media-common';
import { findChangeFromLocation, getChangeMediaAnalytics } from './analytics';
import { isImage } from './is-type';
const getInsertMediaAnalytics = (inputMethod, fileExtension, insertMediaVia) => ({
action: ACTION.INSERTED,
actionSubject: ACTION_SUBJECT.DOCUMENT,
actionSubjectId: ACTION_SUBJECT_ID.MEDIA,
attributes: {
inputMethod,
insertMediaVia,
fileExtension,
type: ACTION_SUBJECT_ID.MEDIA_SINGLE
},
eventType: EVENT_TYPE.TRACK
});
function shouldAddParagraph(state) {
return atTheBeginningOfBlock(state) && !checkNodeDown(state.selection, state.doc, isEmptyParagraph);
}
function insertNodesWithOptionalParagraph({
nodes,
analyticsAttributes = {},
editorAnalyticsAPI,
insertMediaVia
}) {
return function (state, dispatch) {
const {
tr
} = state;
if (fg('platform_editor_introduce_insert_media_command')) {
const updatedTr = insertNodesWithOptionalParagraphCommand({
nodes,
analyticsAttributes,
editorAnalyticsAPI,
insertMediaVia
})({
tr
});
if (updatedTr && dispatch) {
dispatch === null || dispatch === void 0 ? void 0 : dispatch(updatedTr);
return true;
}
return false;
}
const {
inputMethod,
fileExtension,
newType,
previousType
} = analyticsAttributes;
let updatedTr = tr;
const openEnd = 0;
if (state.selection.empty) {
const insertFrom = atTheBeginningOfBlock(state) ? state.selection.$from.before() : state.selection.from;
// the use of pmSafeInsert causes the node selection to media single node.
// It leads to discrepancy between the full-page and comment editor - not sure why :shrug:
// When multiple images are uploaded, the node selection is set to the previous node
// and got overridden by the next node inserted.
// It also causes the images position shifted when the images are uploaded.
// E.g the images are uploaded after a table, the images will be inserted inside the table.
// so we revert to use tr.insert instead. No extra paragraph is added.
updatedTr = updatedTr.insert(insertFrom, nodes);
} else {
updatedTr.replaceSelection(new Slice(Fragment.from(nodes), 0, openEnd));
}
if (inputMethod) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent(getInsertMediaAnalytics(inputMethod, fileExtension, insertMediaVia))(updatedTr);
}
if (newType && previousType) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent(getChangeMediaAnalytics(previousType, newType, findChangeFromLocation(state.selection)))(updatedTr);
}
if (dispatch) {
dispatch(updatedTr);
}
return true;
};
}
function insertNodesWithOptionalParagraphCommand({
nodes,
analyticsAttributes = {},
editorAnalyticsAPI,
insertMediaVia
}) {
return ({
tr
}) => {
const {
inputMethod,
fileExtension,
newType,
previousType
} = analyticsAttributes;
let updatedTr = tr;
const openEnd = 0;
if (tr.selection.empty) {
const insertFrom = selectionIsAtTheBeginningOfBlock(tr.selection) ? tr.selection.$from.before() : tr.selection.from;
// the use of pmSafeInsert causes the node selection to media single node.
// It leads to discrepancy between the full-page and comment editor - not sure why :shrug:
// When multiple images are uploaded, the node selection is set to the previous node
// and got overridden by the next node inserted.
// It also causes the images position shifted when the images are uploaded.
// E.g the images are uploaded after a table, the images will be inserted inside the table.
// so we revert to use tr.insert instead. No extra paragraph is added.
updatedTr = updatedTr.insert(insertFrom, nodes);
} else {
updatedTr.replaceSelection(new Slice(Fragment.from(nodes), 0, openEnd));
}
if (inputMethod) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent(getInsertMediaAnalytics(inputMethod, fileExtension, insertMediaVia))(updatedTr);
}
if (newType && previousType) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent(getChangeMediaAnalytics(previousType, newType, findChangeFromLocation(tr.selection)))(updatedTr);
}
return updatedTr;
};
}
export const isMediaSingle = (schema, fileMimeType) => !!schema.nodes.mediaSingle && isImage(fileMimeType);
export const insertMediaAsMediaSingle = (view, node, inputMethod, editorAnalyticsAPI, insertMediaVia, allowPixelResizing) => {
var _node$attrs$width;
const {
state,
dispatch
} = view;
const {
mediaSingle,
media
} = state.schema.nodes;
if (!mediaSingle) {
return false;
}
// if not an image type media node
if (node.type !== media || !isImage(node.attrs.__fileMimeType) && node.attrs.type !== 'external') {
return false;
}
if (fg('platform_editor_introduce_insert_media_command')) {
const updatedTr = createInsertMediaAsMediaSingleCommand(node.attrs, inputMethod, editorAnalyticsAPI, insertMediaVia, allowPixelResizing)({
tr: state.tr
});
if (updatedTr && dispatch) {
dispatch === null || dispatch === void 0 ? void 0 : dispatch(updatedTr);
return true;
}
return false;
}
const mediaSingleAttrs = allowPixelResizing ? {
widthType: 'pixel',
width: getMediaSingleInitialWidth((_node$attrs$width = node.attrs.width) !== null && _node$attrs$width !== void 0 ? _node$attrs$width : DEFAULT_IMAGE_WIDTH),
layout: 'center'
} : {};
const mediaSingleNode = mediaSingle.create(mediaSingleAttrs, node);
const nodes = [mediaSingleNode];
const analyticsAttributes = {
inputMethod,
fileExtension: node.attrs.__fileMimeType
};
return insertNodesWithOptionalParagraph({
nodes,
analyticsAttributes,
editorAnalyticsAPI,
insertMediaVia
})(state, dispatch);
};
export const createInsertMediaAsMediaSingleCommand = (mediaAttrs, inputMethod, editorAnalyticsAPI, insertMediaVia, allowPixelResizing) => {
return ({
tr
}) => {
var _mediaAttrs$__fileMim, _mediaAttrs$width, _mediaAttrs$__fileMim2;
const {
mediaSingle,
media
} = tr.doc.type.schema.nodes;
if (!mediaSingle || !media) {
return null;
}
if (mediaAttrs.type !== 'external' && !isImage((_mediaAttrs$__fileMim = mediaAttrs.__fileMimeType) !== null && _mediaAttrs$__fileMim !== void 0 ? _mediaAttrs$__fileMim : undefined)) {
return null;
}
const mediaSingleAttrs = allowPixelResizing ? {
widthType: 'pixel',
width: getMediaSingleInitialWidth((_mediaAttrs$width = mediaAttrs.width) !== null && _mediaAttrs$width !== void 0 ? _mediaAttrs$width : DEFAULT_IMAGE_WIDTH),
layout: 'center'
} : {};
const mediaNode = media.create(mediaAttrs);
const mediaSingleNode = mediaSingle.create(mediaSingleAttrs, mediaNode);
const nodes = [mediaSingleNode];
const analyticsAttributes = {
inputMethod,
// External images have no file extension
fileExtension: mediaAttrs.type !== 'external' && mediaAttrs.__fileMimeType ? (_mediaAttrs$__fileMim2 = mediaAttrs.__fileMimeType) !== null && _mediaAttrs$__fileMim2 !== void 0 ? _mediaAttrs$__fileMim2 : undefined : undefined
};
return insertNodesWithOptionalParagraphCommand({
nodes,
analyticsAttributes,
editorAnalyticsAPI,
insertMediaVia
})({
tr
});
};
};
const getFileExtension = fileName => {
if (fileName) {
const extensionIdx = fileName.lastIndexOf('.');
return extensionIdx >= 0 ? fileName.substring(extensionIdx + 1) : undefined;
}
return undefined;
};
export const insertMediaSingleNode = (view, mediaState, inputMethod, collection, alignLeftOnInsert, widthPluginState, editorAnalyticsAPI, onNodeInserted, insertMediaVia, allowPixelResizing) => {
var _state$selection$$fro;
if (collection === undefined) {
return false;
}
const {
state,
dispatch
} = view;
const grandParentNodeType = (_state$selection$$fro = state.selection.$from.node(-1)) === null || _state$selection$$fro === void 0 ? void 0 : _state$selection$$fro.type;
const parentNodeType = state.selection.$from.parent.type;
// add undefined as fallback as we don't want media single width to have upper limit as 0
// if widthPluginState.width is 0, default 760 will be used
const contentWidth = getMaxWidthForNestedNodeNext(view, state.selection.$from.pos, true) || (widthPluginState === null || widthPluginState === void 0 ? void 0 : widthPluginState.lineLength) || (widthPluginState === null || widthPluginState === void 0 ? void 0 : widthPluginState.width) || undefined;
const node = createMediaSingleNode(state.schema, collection, contentWidth, mediaState.status !== 'error' && isVideo(mediaState.fileMimeType) ? MEDIA_SINGLE_VIDEO_MIN_PIXEL_WIDTH : MEDIA_SINGLE_DEFAULT_MIN_PIXEL_WIDTH, alignLeftOnInsert, allowPixelResizing)(mediaState);
let fileExtension;
if (mediaState.fileName) {
const extensionIdx = mediaState.fileName.lastIndexOf('.');
fileExtension = extensionIdx >= 0 ? mediaState.fileName.substring(extensionIdx + 1) : undefined;
}
// should split if media is valid content for the grandparent of the selected node
// and the parent node is a paragraph
if (shouldSplitSelectedNodeOnNodeInsertion({
parentNodeType,
grandParentNodeType,
content: node
})) {
insertNodesWithOptionalParagraph({
nodes: [node],
analyticsAttributes: {
fileExtension,
inputMethod
},
editorAnalyticsAPI,
insertMediaVia
})(state, dispatch);
} else {
let tr = null;
tr = safeInsert(node, state.selection.from)(state.tr);
if (!tr) {
const content = shouldAddParagraph(view.state) ? Fragment.fromArray([node, state.schema.nodes.paragraph.create()]) : node;
tr = pmSafeInsert(content, undefined, true)(state.tr);
}
if (inputMethod) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent(getInsertMediaAnalytics(inputMethod, fileExtension, insertMediaVia))(tr);
}
dispatch(tr);
}
if (onNodeInserted) {
onNodeInserted(mediaState.id, view.state.selection.to);
}
return true;
};
export const changeFromMediaInlineToMediaSingleNode = (view, fromNode, widthPluginState, editorAnalyticsAPI, allowPixelResizing) => {
var _state$selection$$fro2;
const {
state,
dispatch
} = view;
const {
mediaInline
} = state.schema.nodes;
if (fromNode.type !== mediaInline) {
return false;
}
const grandParentNodeType = (_state$selection$$fro2 = state.selection.$from.node(-1)) === null || _state$selection$$fro2 === void 0 ? void 0 : _state$selection$$fro2.type;
const parentNodeType = state.selection.$from.parent.type;
// add undefined as fallback as we don't want media single width to have upper limit as 0
// if widthPluginState.width is 0, default 760 will be used
const contentWidth = getMaxWidthForNestedNodeNext(view, state.selection.$from.pos, true) || (widthPluginState === null || widthPluginState === void 0 ? void 0 : widthPluginState.lineLength) || (widthPluginState === null || widthPluginState === void 0 ? void 0 : widthPluginState.width) || undefined;
const node = replaceWithMediaSingleNode(state.schema, contentWidth, MEDIA_SINGLE_DEFAULT_MIN_PIXEL_WIDTH, allowPixelResizing)(fromNode);
const fileExtension = getFileExtension(fromNode.attrs.__fileName);
// should split if media is valid content for the grandparent of the selected node
// and the parent node is a paragraph
if (shouldSplitSelectedNodeOnNodeInsertion({
parentNodeType,
grandParentNodeType,
content: node
})) {
return insertNodesWithOptionalParagraph({
nodes: [node],
analyticsAttributes: {
fileExtension,
newType: ACTION_SUBJECT_ID.MEDIA_SINGLE,
previousType: ACTION_SUBJECT_ID.MEDIA_INLINE
},
editorAnalyticsAPI
})(state, dispatch);
} else {
const nodePos = state.tr.doc.resolve(state.selection.from).end();
let tr = null;
tr = removeSelectedNode(state.tr);
tr = safeInsert(node, nodePos)(tr);
if (!tr) {
const content = shouldAddParagraph(view.state) ? Fragment.fromArray([node, state.schema.nodes.paragraph.create()]) : node;
tr = pmSafeInsert(content, undefined, true)(state.tr);
}
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent(getChangeMediaAnalytics(ACTION_SUBJECT_ID.MEDIA_INLINE, ACTION_SUBJECT_ID.MEDIA_SINGLE, findChangeFromLocation(state.selection)))(tr);
dispatch(tr);
}
return true;
};
const createMediaSingleNode = (schema, collection, maxWidth, minWidth, alignLeftOnInsert, allowPixelResizing) => mediaState => {
const {
id,
dimensions,
contextId,
scaleFactor = 1,
fileName
} = mediaState;
const {
width,
height
} = dimensions || {
height: undefined,
width: undefined
};
const {
media,
mediaSingle
} = schema.nodes;
const scaledWidth = width && Math.round(width / scaleFactor);
const mediaNode = media.create({
id,
type: 'file',
collection,
contextId,
width: scaledWidth,
height: height && Math.round(height / scaleFactor),
...(fileName && {
alt: fileName
})
});
const mediaSingleAttrs = alignLeftOnInsert ? {
layout: 'align-start'
} : {};
const extendedMediaSingleAttrs = allowPixelResizing ? {
...mediaSingleAttrs,
width: getMediaSingleInitialWidth(scaledWidth, maxWidth, minWidth),
// TODO: ED-26962 - change to use enum
widthType: 'pixel'
} : mediaSingleAttrs;
copyOptionalAttrsFromMediaState(mediaState, mediaNode);
return mediaSingle.createChecked(extendedMediaSingleAttrs, mediaNode);
};
const replaceWithMediaSingleNode = (schema, maxWidth, minWidth, allowPixelResizing) => mediaNode => {
const {
width
} = mediaNode.attrs;
const {
media,
mediaSingle
} = schema.nodes;
const copiedMediaNode = media.create({
...mediaNode.attrs,
type: 'file'
}, mediaNode.content, mediaNode.marks);
const extendedMediaSingleAttrs = allowPixelResizing ? {
width: getMediaSingleInitialWidth(width, maxWidth, minWidth),
widthType: 'pixel'
} : {};
return mediaSingle.createChecked(extendedMediaSingleAttrs, copiedMediaNode);
};
export const isVideo = memoizeOne(fileType => {
return !!fileType && fileType.includes('video');
});