/*
* This file is part of ORY Editor.
*
* ORY Editor is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ORY Editor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with ORY Editor. If not, see .
*
* @license LGPL-3.0
* @copyright 2016-2018 Aeneas Rekkas
* @author Aeneas Rekkas
*
*/
import { v4 } from 'uuid';
import semver, { satisfies } from 'semver';
import {
ContentPlugin,
LayoutPlugin,
Plugin,
NativePlugin,
NativePluginProps,
LayoutPluginProps,
ContentPluginProps,
Plugins,
LayoutPluginConfig,
PluginConfig,
PluginsInternal,
ContentPluginConfig,
} from './classes';
import { ComponetizedCell, EditableType } from '../../types/editable';
import defaultPlugin from './default';
import { layoutMissing, contentMissing } from './missing';
import { EditorState } from '../../types/editor';
const find = (name: string, version: string = '*') => (
plugin: PluginConfig
): boolean => plugin.name === name && satisfies(plugin.version, version);
/**
* Iterate through an editable content tree and generate ids where missing.
*/
export const generateMissingIds = (props: EditorState): EditorState => {
const { rows, cells, id } = props;
if ((rows || []).length > 0) {
props.rows = rows.map(generateMissingIds);
} else if ((cells || []).length > 0) {
props.cells = cells.map(generateMissingIds);
}
return { ...props, id: id || v4() };
};
/**
* PluginService is a registry of all content and layout plugins known to the editor.
*/
export default class PluginService {
plugins: PluginsInternal;
/**
* Instantiate a new PluginService instance. You can provide your own set of content and layout plugins here.
*/
constructor({
content = [],
layout = [],
native,
}: Plugins = {} as Plugins) {
this.plugins = {
content: [defaultPlugin, ...content].map(
// tslint:disable-next-line:no-any
(config: any) => new ContentPlugin(config)
),
// tslint:disable-next-line:no-any
layout: layout.map((config: any) => new LayoutPlugin(config)),
native: native,
};
}
hasNativePlugin = () => Boolean(this.plugins.native);
getNativePlugin = () => this.plugins.native;
createNativePlugin = (
// tslint:disable-next-line:no-any
hover?: any,
// tslint:disable-next-line:no-any
monitor?: any,
// tslint:disable-next-line:no-any
component?: any
): ComponetizedCell => {
const native = this.plugins.native;
if (!native) {
const insert = new NativePlugin({} as NativePluginProps);
// tslint:disable-next-line:no-any
const cell: any = { node: insert, rawNode: () => insert };
return cell;
} else {
const plugin = new NativePlugin(native(hover, monitor, component));
const initialState = plugin.createInitialState();
// tslint:disable-next-line:no-any
let insert: any = { content: { plugin, state: initialState } };
/*if (plugin === 'layout') {
insert = { layout: { plugin, state: initialState } };
}*/
// tslint:disable-next-line:no-any
const cell: any = { node: insert, rawNode: () => insert };
return cell;
}
}
setLayoutPlugins = (plugins: LayoutPluginConfig[] = []) => {
this.plugins.layout = [];
plugins.forEach(plugin => this.addLayoutPlugin(plugin));
}
addLayoutPlugin = (config: LayoutPluginConfig) => {
this.plugins.layout.push(new LayoutPlugin(config));
}
removeLayoutPlugin = (name: string) => {
this.plugins.layout = this.plugins.layout.filter(
(plugin: LayoutPluginConfig) => plugin.name !== name
);
}
setContentPlugins = (plugins: ContentPluginConfig[] = []) => {
this.plugins.content = [];
// semicolon is required to avoid syntax error
[defaultPlugin, ...plugins].forEach(plugin =>
this.addContentPlugin(plugin)
);
}
addContentPlugin = (config: ContentPluginConfig) => {
this.plugins.content.push(new ContentPlugin(config));
}
removeContentPlugin = (name: string) => {
this.plugins.content = this.plugins.content.filter(
(plugin: ContentPlugin) => plugin.name !== name
);
}
/**
* Finds a layout plugin based on its name and version.
*/
findLayoutPlugin = (
name: string,
version: string
): { plugin: LayoutPlugin; pluginWrongVersion?: LayoutPlugin } => {
const plugin = this.plugins.layout.find(find(name, version)) as LayoutPlugin;
let pluginWrongVersion = undefined;
if (!plugin) {
pluginWrongVersion = this.plugins.layout.find(find(name, '*'));
}
return {
plugin:
plugin ||
new LayoutPlugin(layoutMissing({ name, version } as LayoutPluginProps)),
pluginWrongVersion,
};
}
/**
* Finds a content plugin based on its name and version.
*/
findContentPlugin = (
name: string,
version: string
): { plugin: ContentPlugin; pluginWrongVersion?: ContentPlugin } => {
const plugin = this.plugins.content.find(find(name, version));
let pluginWrongVersion = undefined;
if (!plugin) {
pluginWrongVersion = this.plugins.content.find(find(name, '*'));
}
return {
plugin:
plugin ||
new ContentPlugin(
contentMissing({ name, version } as ContentPluginProps)
),
pluginWrongVersion,
};
}
/**
* Returns a list of all known plugin names.
*/
getRegisteredNames = (): Array => [
...this.plugins.content.map(({ name }: Plugin) => name),
...this.plugins.layout.map(({ name }: Plugin) => name),
]
migratePluginState = (
// tslint:disable-next-line:no-any
state: any,
plugin: Plugin,
dataVersion: string
// tslint:disable-next-line:no-any
): any => {
if (!plugin || !dataVersion || semver.valid(dataVersion) === null) {
return state;
}
let currentDataVersion = dataVersion;
let migrations = plugin.migrations ? plugin.migrations : [];
while (true) {
const migration = migrations.find(m =>
semver.satisfies(currentDataVersion, m.fromVersionRange)
);
migrations = migrations.filter(
m => !semver.satisfies(currentDataVersion, m.fromVersionRange)
);
if (!migration) {
// We assume all migrations necessary for the current version of plugin to work are provided
// Therefore if we don't find any, that means we are done and state is up to date
break;
}
currentDataVersion = migration.toVersion;
state = migration.migrate(state);
}
return state;
}
// tslint:disable-next-line:no-any
getNewPluginState = (found: { plugin: Plugin; pluginWrongVersion?: Plugin }, state: any, version: string): {
plugin: Plugin,
// tslint:disable-next-line:no-any
state: any
} => {
if (
!found.pluginWrongVersion ||
semver.lt(found.pluginWrongVersion.version, version)
) {
// Standard case
return {
plugin: found.plugin,
state: found.plugin.unserialize(state),
};
} else {
// Attempt to migrate
const migratedState = this.migratePluginState(
state,
found.pluginWrongVersion,
version
);
if (found.pluginWrongVersion && migratedState) {
return {
plugin: found.pluginWrongVersion,
state: found.pluginWrongVersion.unserialize(migratedState),
};
} else {
// Unable to migrate, fallback to missing plugin
return {
plugin: found.plugin,
state: found.plugin.unserialize(state),
};
}
}
}
// tslint:disable-next-line:no-any
unserialize = (state: any): Object => {
const {
rows = [],
cells = [],
content = {},
layout = {},
inline,
size,
id,
} = state;
const newState: EditorState = { id, inline, size };
const {
plugin: { name: contentName = null, version: contentVersion = '*' } = {},
state: contentState = {},
} = content || {};
const {
plugin: { name: layoutName = null, version: layoutVersion = '*' } = {},
state: layoutState = {},
} = layout || {};
if (contentName) {
const found = this.findContentPlugin(contentName, contentVersion);
const newContentState = this.getNewPluginState(
found,
contentState,
contentVersion
);
newState.content = newContentState;
}
if (layoutName) {
const found = this.findLayoutPlugin(layoutName, layoutVersion);
const newLayoutState = this.getNewPluginState(
found,
layoutState,
layoutVersion
);
newState.layout = newLayoutState;
}
if ((rows || []).length) {
newState.rows = rows.map(this.unserialize);
}
if ((cells || []).length) {
newState.cells = cells.map(this.unserialize);
}
return generateMissingIds(newState);
}
// tslint:disable-next-line:no-any
serialize = (state: any): EditableType => {
const { rows = [], cells = [], content, layout, inline, size, id } = state;
// tslint:disable-next-line:no-any
const newState: any = { id, inline, size };
if (content && content.plugin) {
newState.content = {
plugin: { name: content.plugin.name, version: content.plugin.version },
state: content.plugin.serialize(content.state),
};
}
if (layout && layout.plugin) {
newState.layout = {
plugin: { name: layout.plugin.name, version: layout.plugin.version },
state: layout.plugin.serialize(layout.state),
};
}
if (rows.length) {
newState.rows = rows.map(this.serialize);
}
if (cells.length) {
newState.cells = cells.map(this.serialize);
}
return newState;
}
}