@atlaskit/editor-core
Version:
A package contains Atlassian editor core functionality
583 lines (473 loc) • 17.7 kB
text/typescript
import analyticsService from '../../analytics/service';
import * as assert from 'assert';
import {
Context,
DefaultMediaStateManager,
MediaProvider,
MediaState,
MediaStateManager,
UploadParams,
ContextConfig,
ContextFactory
} from '@atlaskit/media-core';
import { copyOptionalAttrs, MediaType } from './../../schema/nodes/media';
import {
EditorState,
EditorView,
Plugin,
PluginKey,
Node as PMNode,
Schema,
Transaction,
NodeSelection,
Mark,
} from '../../prosemirror';
import PickerFacade from './picker-facade';
import { ErrorReporter } from '../../utils';
import { MediaPluginOptions } from './media-plugin-options';
import { ProsemirrorGetPosHandler } from '../../nodeviews';
import { nodeViewFactory } from '../../nodeviews';
import { ReactMediaGroupNode, ReactMediaNode, ReactSingleImageNode } from '../../nodeviews';
import keymapPlugin from './keymap';
import { insertLinks, URLInfo, detectLinkRangesInSteps } from './media-links';
import { insertFile } from './media-files';
import { removeMediaNode, splitMediaGroup } from './media-common';
import { Alignment, Display } from './single-image';
const MEDIA_RESOLVE_STATES = ['ready', 'error', 'cancelled'];
export type PluginStateChangeSubscriber = (state: MediaPluginState) => any;
export interface MediaNodeWithPosHandler {
node: PMNode;
getPos: ProsemirrorGetPosHandler;
}
export class MediaPluginState {
public allowsMedia: boolean = false;
public allowsUploads: boolean = false;
public allowsLinks: boolean = false;
public stateManager: MediaStateManager;
public pickers: PickerFacade[] = [];
public binaryPicker?: PickerFacade;
public ignoreLinks: boolean = false;
public waitForMediaUpload: boolean = true;
private mediaNodes: MediaNodeWithPosHandler[] = [];
private options: MediaPluginOptions;
private view: EditorView;
private pluginStateChangeSubscribers: PluginStateChangeSubscriber[] = [];
private useDefaultStateManager = true;
private destroyed = false;
private mediaProvider: MediaProvider;
private errorReporter: ErrorReporter;
private popupPicker?: PickerFacade;
private clipboardPicker?: PickerFacade;
private dropzonePicker?: PickerFacade;
private linkRanges: Array<URLInfo>;
constructor(state: EditorState<any>, options: MediaPluginOptions) {
this.options = options;
this.waitForMediaUpload = options.waitForMediaUpload === undefined ? true : options.waitForMediaUpload;
const { nodes } = state.schema;
assert(nodes.media && nodes.mediaGroup, 'Editor: unable to init media plugin - media or mediaGroup node absent in schema');
this.stateManager = new DefaultMediaStateManager();
options.providerFactory.subscribe('mediaProvider', (name, provider: Promise<MediaProvider>) => this.setMediaProvider(provider));
this.errorReporter = options.errorReporter || new ErrorReporter();
}
subscribe(cb: PluginStateChangeSubscriber) {
this.pluginStateChangeSubscribers.push(cb);
cb(this);
}
unsubscribe(cb: PluginStateChangeSubscriber) {
const { pluginStateChangeSubscribers } = this;
const pos = pluginStateChangeSubscribers.indexOf(cb);
if (pos > -1) {
pluginStateChangeSubscribers.splice(pos, 1);
}
}
setMediaProvider = async (mediaProvider?: Promise<MediaProvider>) => {
if (!mediaProvider) {
this.destroyPickers();
this.allowsLinks = false;
this.allowsUploads = false;
this.allowsMedia = false;
this.notifyPluginStateSubscribers();
return;
}
// TODO disable (not destroy!) pickers until mediaProvider is resolved
let resolvedMediaProvider: MediaProvider;
try {
resolvedMediaProvider = await mediaProvider;
assert(
resolvedMediaProvider && resolvedMediaProvider.viewContext,
`MediaProvider promise did not resolve to a valid instance of MediaProvider - ${resolvedMediaProvider}`
);
} catch (err) {
const wrappedError = new Error(`Media functionality disabled due to rejected provider: ${err.message}`);
this.errorReporter.captureException(wrappedError);
this.destroyPickers();
this.allowsLinks = false;
this.allowsUploads = false;
this.allowsMedia = false;
this.notifyPluginStateSubscribers();
return;
}
this.mediaProvider = resolvedMediaProvider;
this.allowsMedia = true;
// release all listeners for default state manager
const { stateManager } = resolvedMediaProvider;
if (stateManager && this.useDefaultStateManager) {
(stateManager as DefaultMediaStateManager).destroy();
this.useDefaultStateManager = false;
}
if (stateManager) {
this.stateManager = stateManager;
}
this.allowsLinks = !!resolvedMediaProvider.linkCreateContext;
this.allowsUploads = !!resolvedMediaProvider.uploadContext;
if (this.allowsUploads) {
const uploadContext = await resolvedMediaProvider.uploadContext;
if (resolvedMediaProvider.uploadParams && uploadContext) {
this.initPickers(resolvedMediaProvider.uploadParams, uploadContext);
} else {
this.destroyPickers();
}
} else {
this.destroyPickers();
}
this.notifyPluginStateSubscribers();
}
insertFile = (mediaState: MediaState): void => {
const collection = this.collectionFromProvider();
if (!collection) {
return;
}
this.stateManager.subscribe(mediaState.id, this.handleMediaState);
insertFile(this.view, mediaState, collection);
const { view } = this;
if (!view.hasFocus()) {
view.focus();
}
}
insertLinks = async () => {
const { mediaProvider } = this;
if (!mediaProvider) {
return;
}
const { linkCreateContext } = this.mediaProvider;
if (!linkCreateContext) {
return;
}
let linkCreateContextInstance = await linkCreateContext;
if (!linkCreateContextInstance) {
return;
}
if (!(linkCreateContextInstance as Context).addLinkItem) {
linkCreateContextInstance = ContextFactory.create(linkCreateContextInstance as ContextConfig);
}
return insertLinks(
this.view,
this.stateManager,
this.handleMediaState,
this.linkRanges,
linkCreateContextInstance as Context,
this.collectionFromProvider()
);
}
splitMediaGroup = (): boolean => {
return splitMediaGroup(this.view);
}
insertFileFromDataUrl = (url: string, fileName: string) => {
const { binaryPicker } = this;
assert(binaryPicker, 'Unable to insert file because media pickers have not been initialized yet');
binaryPicker!.upload(url, fileName);
}
showMediaPicker = () => {
if (!this.popupPicker) {
return;
}
this.popupPicker.show();
}
/**
* Returns a promise that is resolved after all pending operations have been finished.
* An optional timeout will cause the promise to reject if the operation takes too long
*
* NOTE: The promise will resolve even if some of the media have failed to process.
*
*/
waitForPendingTasks = (timeout?: Number) => {
const { mediaNodes, stateManager } = this;
return new Promise<void>((resolve, reject) => {
if (timeout) {
setTimeout(() => reject(new Error(`Media operations did not finish in ${timeout} ms`)), timeout);
}
let outstandingNodes = mediaNodes.length;
if (!outstandingNodes) {
return resolve();
}
function onNodeStateChanged(state: MediaState) {
const { status } = state;
if (MEDIA_RESOLVE_STATES.indexOf(status || '') !== -1) {
onNodeStateReady(state.id);
}
}
function onNodeStateReady(id: string) {
outstandingNodes--;
stateManager.unsubscribe(id, onNodeStateChanged);
if (outstandingNodes <= 0) {
resolve();
}
}
mediaNodes.forEach(({ node }) => {
const mediaNodeId = node.attrs.id;
const nodeCurrentStatus = this.getMediaNodeStateStatus(mediaNodeId);
if (MEDIA_RESOLVE_STATES.indexOf(nodeCurrentStatus) !== -1) {
onNodeStateReady(mediaNodeId);
} else {
stateManager.subscribe(mediaNodeId, onNodeStateChanged);
}
});
});
}
setView(view: EditorView) {
this.view = view;
}
/**
* Called from React UI Component when user clicks on "Delete" icon
* inside of it
*/
handleMediaNodeRemoval = (node: PMNode, getPos: ProsemirrorGetPosHandler) => {
removeMediaNode(this.view, node, getPos);
}
/**
* This is called when media node is removed from media group node view
*/
cancelInFlightUpload(id: string) {
const mediaNodeWithPos = this.findMediaNode(id);
if (!mediaNodeWithPos) {
return;
}
const status = this.getMediaNodeStateStatus(id);
switch (status) {
case 'uploading':
case 'processing':
this.pickers.forEach(picker => picker.cancel(id));
}
}
/**
* Called from React UI Component on componentDidMount
*/
handleMediaNodeMount = (node: PMNode, getPos: ProsemirrorGetPosHandler) => {
this.mediaNodes.push({ node, getPos });
}
/**
* Called from React UI Component on componentWillUnmount and componentWillReceiveProps
* when React component's underlying node property is replaced with a new node
*/
handleMediaNodeUnmount = (oldNode: PMNode) => {
this.mediaNodes = this.mediaNodes.filter(({ node }) => oldNode !== node);
}
align = (alignment: Alignment, display: Display = 'block'): boolean => {
if (!this.isMediaNodeSelection()) {
return false;
}
const { selection: { from }, schema, tr } = this.view.state;
this.view.dispatch(tr.setNodeType(from - 1, schema.nodes.singleImage, { alignment, display }));
return true;
}
destroy() {
if (this.destroyed) {
return;
}
this.destroyed = true;
const { mediaNodes } = this;
mediaNodes.splice(0, mediaNodes.length);
this.destroyPickers();
}
findMediaNode = (id: string): MediaNodeWithPosHandler | null => {
const { mediaNodes } = this;
// Array#find... no IE support
return mediaNodes.reduce((memo: MediaNodeWithPosHandler | null, nodeWithPos: MediaNodeWithPosHandler) => {
if (memo) {
return memo;
}
const { node } = nodeWithPos;
if (node.attrs.id === id) {
return nodeWithPos;
}
return memo;
}, null);
}
detectLinkRangesInSteps = (tr: Transaction, oldState: EditorState<any>) => {
const { link } = this.view.state.schema.marks;
this.linkRanges = [];
if (this.ignoreLinks) {
this.ignoreLinks = false;
return this.linkRanges;
}
if (!link || !this.allowsLinks) {
return this.linkRanges;
}
this.linkRanges = detectLinkRangesInSteps(tr, link, oldState.selection.$anchor.pos);
}
private destroyPickers = () => {
const { pickers } = this;
pickers.forEach(picker => picker.destroy());
pickers.splice(0, pickers.length);
this.popupPicker = undefined;
this.binaryPicker = undefined;
}
private initPickers(uploadParams: UploadParams, context: ContextConfig) {
if (this.destroyed) {
return;
}
const {
errorReporter,
pickers,
stateManager,
} = this;
// create pickers if they don't exist, re-use otherwise
if (!pickers.length) {
pickers.push(this.binaryPicker = new PickerFacade('binary', uploadParams, context, stateManager, errorReporter));
pickers.push(this.popupPicker = new PickerFacade('popup', uploadParams, context, stateManager, errorReporter));
pickers.push(this.clipboardPicker = new PickerFacade('clipboard', uploadParams, context, stateManager, errorReporter));
pickers.push(this.dropzonePicker = new PickerFacade('dropzone', uploadParams, context, stateManager, errorReporter));
pickers.forEach(picker => picker.onNewMedia(this.insertFile));
this.binaryPicker.onNewMedia(e => analyticsService.trackEvent('atlassian.editor.media.file.binary', e.fileMimeType ? { fileMimeType: e.fileMimeType } : {}));
this.popupPicker.onNewMedia(e => analyticsService.trackEvent('atlassian.editor.media.file.popup', e.fileMimeType ? { fileMimeType: e.fileMimeType } : {}));
this.clipboardPicker.onNewMedia(e => analyticsService.trackEvent('atlassian.editor.media.file.paste', e.fileMimeType ? { fileMimeType: e.fileMimeType } : {}));
this.dropzonePicker.onNewMedia(e => analyticsService.trackEvent('atlassian.editor.media.file.drop', e.fileMimeType ? { fileMimeType: e.fileMimeType } : {}));
}
// set new upload params for the pickers
pickers.forEach(picker => picker.setUploadParams(uploadParams));
}
private collectionFromProvider(): string | undefined {
return this.mediaProvider && this.mediaProvider.uploadParams && this.mediaProvider.uploadParams.collection;
}
private handleMediaState = (state: MediaState) => {
switch (state.status) {
case 'error':
this.removeNodeById(state.id);
const { uploadErrorHandler } = this.options;
if (uploadErrorHandler) {
uploadErrorHandler(state);
}
break;
case 'ready':
this.stateManager.unsubscribe(state.id, this.handleMediaState);
this.replaceNodeWithPublicId(state.id, state.publicId!);
break;
}
}
private notifyPluginStateSubscribers = () => {
this.pluginStateChangeSubscribers.forEach(cb => cb.call(cb, this));
}
private removeNodeById = (id: string) => {
// TODO: we would like better error handling and retry support here.
const mediaNodeWithPos = this.findMediaNode(id);
if (mediaNodeWithPos) {
removeMediaNode(this.view, mediaNodeWithPos.node, mediaNodeWithPos.getPos);
}
}
private replaceNodeWithPublicId = (temporaryId: string, publicId: string) => {
const { view } = this;
if (!view) {
return;
}
const mediaNodeWithPos = this.findMediaNode(temporaryId);
if (!mediaNodeWithPos) {
return;
}
const {
getPos,
node: mediaNode,
} = mediaNodeWithPos;
const newNode = view.state.schema.nodes.media!.create({
...mediaNode.attrs,
id: publicId,
});
// Copy all optional attributes from old node
copyOptionalAttrs(mediaNode.attrs, newNode.attrs);
// replace the old node with a new one
const nodePos = getPos();
const tr = view.state.tr.replaceWith(nodePos, nodePos + mediaNode.nodeSize, newNode);
view.dispatch(tr.setMeta('addToHistory', false));
}
removeSelectedMediaNode = (): boolean => {
const { view } = this;
if (this.isMediaNodeSelection()) {
const { from, node } = view.state.selection as NodeSelection;
removeMediaNode(view, node, () => from);
return true;
}
return false;
}
private isMediaNodeSelection() {
const { selection, schema } = this.view.state;
return selection instanceof NodeSelection && selection.node.type === schema.nodes.media;
}
/**
* Since we replace nodes with public id when node is finalized
* stateManager contains no information for public ids
*/
private getMediaNodeStateStatus = (id: string) => {
const { stateManager } = this;
const state = stateManager.getState(id);
return (state && state.status) || 'ready';
}
}
export const stateKey = new PluginKey('mediaPlugin');
export const createPlugin = (schema: Schema<any, any>, options: MediaPluginOptions) => {
return new Plugin({
state: {
init(config, state) {
return new MediaPluginState(state, options);
},
apply(tr, pluginState: MediaPluginState, oldState, newState) {
pluginState.detectLinkRangesInSteps(tr, oldState);
// Ignore creating link cards during link editing
const { link } = oldState.schema.marks as { link: Mark };
const { nodeAfter, nodeBefore } = newState.selection.$from;
if ((nodeAfter && link.isInSet(nodeAfter.marks)) ||
(nodeBefore && link.isInSet(nodeBefore.marks))
) {
pluginState.ignoreLinks = true;
}
// NOTE: We're not calling passing new state to the Editor, because we depend on the view.state reference
// throughout the lifetime of view. We injected the view into the plugin state, because we dispatch()
// transformations from within the plugin state (i.e. when adding a new file).
return pluginState;
}
},
key: stateKey,
view: (view: EditorView) => {
const pluginState: MediaPluginState = stateKey.getState(view.state);
pluginState.setView(view);
return {
update: (view: EditorView, prevState: EditorState<any>) => {
pluginState.insertLinks();
}
};
},
props: {
nodeViews: {
mediaGroup: nodeViewFactory(options.providerFactory, {
mediaGroup: ReactMediaGroupNode,
media: ReactMediaNode,
}, true),
singleImage: nodeViewFactory(options.providerFactory, {
singleImage: ReactSingleImageNode,
media: ReactMediaNode,
}, true),
},
handleTextInput(view: EditorView, from: number, to: number, text: string): boolean {
const pluginState: MediaPluginState = stateKey.getState(view.state);
pluginState.splitMediaGroup();
return false;
}
}
});
};
const plugins = (schema: Schema<any, any>, options: MediaPluginOptions) => {
return [createPlugin(schema, options), keymapPlugin(schema)].filter((plugin) => !!plugin) as Plugin[];
};
export default plugins;
export interface MediaData {
id: string;
type?: MediaType;
}