UNPKG

@atlaskit/editor-plugin-media

Version:

Media plugin for @atlaskit/editor-core

386 lines (381 loc) 15.7 kB
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'); });