-
Notifications
You must be signed in to change notification settings - Fork 33
Core Developer Documentation
The repository is structured as a monorepo containing the core, shared ui elements, plugins and renderer. Theres also a storybook demo, which is live deployed from master to demo.edtr.io
This documentation focuses on the concepts of edtr.io and parts of the core package.
Plugins are the main element to the editor. All editing functionality lies in these plugins, defining a specific content element. E.g. there is an image plugin, which can be used to handle uploads of images, display them on the page and also edit meta informations like an alt-text. Plugins can be nested, so plugins can display other plugins inside (e.g. you could create a slideshow using a list of image plugins - where the slideshow doesnt need to reimplement the uploading process, but just displays them in a slider).
This allows us to create complex layout information as well. A grid plugin could have a two-dimensional list of arbitrary plugins, which it renders in a responsive grid layout. For the basic purpose of just multiple content rows we have the plugin-rows
included, implementing some drag&drop functionality as well as a flow for adding plugins in a new row.
The editor state is a composition of plugins. Since plugins can be nested we can simply use one plugin as root, so the (serialized) state just looks like this:
interface DocumentState {
plugin: string
state?: unknown
}
where the plugin will define what the state looks like. A concrete example (NOTE: these plugins don't exist in this project, only used for illustrating purposes)
{
plugin: 'rows',
state: [ // state is just a list of other plugins
{
plugin: 'simple-text',
state: 'This is the first line' // state is just a string
},
{
plugin: 'slideshow',
state: { // state contains an interval for automatic cycling and a list of nested image plugins
interval: 2000,
images: [
{
plugin: 'image',
state: { /*...*/ }
},
// more images
]
}
}
//... more rows
]
}
During deserialization the tree like structure will be broken up into a Record
of plugins identified by a string id, replacing the nested elements with the id, so the above example during editing is handled something like this:
{
root: {
plugin: 'rows',
state: ['1', '2', /* more rows */] // plugins replaced with id
},
1: {
plugin: 'simple-text',
state: 'This is the first line'
},
2: {
plugin: 'slideshow'
state: { interval: 2000, images: ['3', /* more images */] }
},
3: {
plugin: 'image',
state: { /*...*/ }
}
// ... more documents
}
This way, changes in one plugin are well isolated and don't cause rerendering of other parts.
The editor uses redux for state transformations. To handle multiple editor instances in the same store, the store state of one instance is scoped and the whole store is a record of scoped states. Here is what the type definition looks like:
export type EditorState = Record<string, ScopeState>
export interface ScopeState {
plugins: { // a record of the plugins registered on the editor instance
defaultPlugin: string
plugins: Record<string, Plugin>
}
documents: Record<string, DocumentState> // the deserialized state as detailed above
focus: string | null // currently focused id
root: string | null // id of the root plugin (atm. always 'root')
clipboard: DocumentState[] // a clipboard for copy&paste of documents
history: HistoryState // a history of all actions to reproduce the current state (sideffect free), see below
}
export interface HistoryState {
initialState?: {
documents: ScopeState['documents']
}
undoStack: PureAction[][] // all pure actions, multiple actions may be combined to an array for cleaner undo
redoStack: PureAction[][] // actions pushed on undo, to allow redo.
pendingChanges: number // quick way of checking if there are unsaved changes
}
All side effects are resolved using redux-saga and then dispatched as pure actions, which get stored in the store.state.history
. These actions are sufficient to calculate the current state from the initial state.
The Editor
Component is the entry point for integration of Edtr.io into a webpage. It creates the redux store and handles the deserialization of the initial state by dispatching an initRoot
event to the root saga.
After the deserialization the root <SubDocument>
gets rendered. That Component does connect to the redux store to collect some props for the plugin, creates the StateType Helpers (you can read more on the StateType
s in the docs) and then gets the React Component of the plugin and renders that Component.
When it is a nested plugin, the plugin Component will then use the StateType.child
helper for rendering, another SubDocument
for every child, building up the rendered dom.
When an action is dispatched it will be passed to the saga middleware, which will then just call all of the sagas of a part of the store state (e.g. documents, history, ...). All of these parts use the same pattern:
- In
actions.ts
the module can define all actions affecting the substate. Actions that can be called publicly are exported aspublicSomeActionName
(e.g. document actions) and exposed. This way we can control which actions can be dispatched in the plugins. - If the module does handle impure actions (probably the public ones), it defines a saga in the
saga.ts
file. (E.g. the root saga handles the initialization of the root document and therefor deserialization of the initial state). The saga will then dispatch pure actions usingput
. (e.g. inserting the deserialized documents) - Then finally there is the
reducer.ts
for the substate, resolving the pure actions triggered by sagas. It also defines selectors used for connecting.