webapp/src/components/Editor/store/actions.ts

509 lines
13 KiB
TypeScript
Raw Normal View History

2022-09-09 11:53:35 +00:00
import { Store, createStore, unwrap } from 'solid-js/store'
import { v4 as uuidv4 } from 'uuid'
2022-10-09 08:33:28 +00:00
import type { Command, EditorState } from 'prosemirror-state'
2022-09-09 11:53:35 +00:00
import { undo, redo } from 'prosemirror-history'
import { selectAll, deleteSelection } from 'prosemirror-commands'
import { undo as yUndo, redo as yRedo } from 'y-prosemirror'
2022-10-08 05:24:09 +00:00
import debounce from 'lodash/debounce'
2022-10-09 00:00:13 +00:00
import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup'
2022-10-18 18:43:50 +00:00
import { State, Draft, Config, ServiceError, newState } from './context'
import { serialize, createMarkdownParser } from '../markdown'
2022-10-09 00:00:13 +00:00
import db from '../db'
import { isEmpty, isInitialized } from '../prosemirror/helpers'
2022-10-19 15:56:29 +00:00
import { createSignal } from 'solid-js'
2022-09-09 11:53:35 +00:00
2022-10-09 00:00:13 +00:00
const isText = (x) => x && x.doc && x.selection
const isDraft = (x): boolean => x && (x.text || x.path)
const mod = 'Ctrl'
2022-09-09 11:53:35 +00:00
2022-10-09 08:33:28 +00:00
export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
2022-10-09 00:00:13 +00:00
const [store, setState] = createStore(initial)
2022-09-09 11:53:35 +00:00
2022-10-09 00:00:13 +00:00
const onNew = () => {
newDraft()
2022-10-08 05:24:09 +00:00
return true
2022-09-09 11:53:35 +00:00
}
const onDiscard = () => {
discard()
return true
}
2022-10-09 00:00:13 +00:00
const onToggleMarkdown = () => toggleMarkdown()
2022-09-09 11:53:35 +00:00
const onUndo = () => {
2022-10-09 00:00:13 +00:00
if (!isInitialized(store.text as EditorState)) return
2022-09-09 11:53:35 +00:00
const text = store.text as EditorState
2022-10-09 00:00:13 +00:00
store.collab?.started ? yUndo(text) : undo(text, store.editorView.dispatch)
2022-09-09 11:53:35 +00:00
return true
}
const onRedo = () => {
2022-10-09 00:00:13 +00:00
if (!isInitialized(store.text as EditorState)) return
2022-09-09 11:53:35 +00:00
const text = store.text as EditorState
2022-10-09 00:00:13 +00:00
if (store.collab?.started) {
yRedo(text)
} else {
redo(text, store.editorView.dispatch)
}
2022-09-09 11:53:35 +00:00
return true
}
2022-10-09 00:00:13 +00:00
const keymap = {
[`${mod}-n`]: onNew,
[`${mod}-w`]: onDiscard,
[`${mod}-z`]: onUndo,
[`Shift-${mod}-z`]: onRedo,
[`${mod}-y`]: onRedo,
[`${mod}-m`]: onToggleMarkdown
2022-10-09 08:33:28 +00:00
} as { [key: string]: Command }
2022-10-09 00:00:13 +00:00
const createTextFromDraft = async (d: Draft): Promise<Draft> => {
let draft = d
2022-09-09 11:53:35 +00:00
const state = unwrap(store)
2022-10-09 00:00:13 +00:00
if (draft.path) {
draft = await loadDraft(state.config, draft.path)
2022-10-08 05:24:09 +00:00
}
2022-09-09 11:53:35 +00:00
const extensions = createExtensions({
config: state.config,
2022-10-09 00:00:13 +00:00
markdown: draft.markdown,
path: draft.path,
keymap
2022-09-09 11:53:35 +00:00
})
2022-10-09 00:00:13 +00:00
return {
text: draft.text,
2022-09-09 11:53:35 +00:00
extensions,
2022-10-09 08:33:28 +00:00
lastModified: draft.lastModified ? new Date(draft.lastModified) : undefined,
2022-10-09 00:00:13 +00:00
path: draft.path,
markdown: draft.markdown
}
2022-09-09 11:53:35 +00:00
}
2022-10-09 00:00:13 +00:00
// eslint-disable-next-line unicorn/consistent-function-scoping
const addToDrafts = (drafts: Draft[], prev: Draft) => {
const text = prev.path ? undefined : JSON.stringify(prev.text)
return [
...drafts,
{
body: text,
2022-10-18 18:43:50 +00:00
lastModified: prev.lastModified,
2022-10-09 00:00:13 +00:00
path: prev.path,
markdown: prev.markdown
} as Draft
]
}
const discardText = async () => {
const state = unwrap(store)
const index = state.drafts.length - 1
const draft = index !== -1 ? state.drafts[index] : undefined
let next
if (draft) {
next = await createTextFromDraft(draft)
} else {
const extensions = createExtensions({
config: state.config ?? store.config,
markdown: state.markdown ?? store.markdown,
keymap
})
next = {
text: createEmptyText(),
extensions,
2022-10-09 08:33:28 +00:00
lastModified: new Date(),
2022-10-09 00:00:13 +00:00
path: undefined,
markdown: state.markdown
}
}
const drafts = state.drafts.filter((f: Draft) => f !== draft)
setState({
drafts,
...next,
collab: state.collab,
error: undefined
})
}
2022-10-08 05:24:09 +00:00
2022-10-09 10:29:35 +00:00
const readStoredState = async (): Promise<State> => {
2022-10-08 05:24:09 +00:00
const state: State = unwrap(store)
2022-10-09 00:00:13 +00:00
const room = window.location.pathname?.slice(1).trim()
2022-10-15 10:15:35 +00:00
const args = { draft: room }
2022-10-09 00:00:13 +00:00
const data = await db.get('state')
2022-10-07 19:35:53 +00:00
if (data !== undefined) {
try {
2022-10-15 10:15:35 +00:00
const parsed = JSON.parse(data)
let text = state.text
if (parsed.text) {
if (!isText(parsed.text)) {
throw new ServiceError('invalid_state', parsed.text)
}
text = parsed.text
}
2022-10-09 00:00:13 +00:00
2022-10-15 10:15:35 +00:00
const extensions = createExtensions({
path: parsed.path,
markdown: parsed.markdown,
keymap,
config: undefined
})
2022-10-09 00:00:13 +00:00
2022-10-15 10:15:35 +00:00
for (const draft of parsed.drafts || []) {
if (!isDraft(draft)) {
console.error('[editor] invalid draft', draft)
}
}
2022-10-09 00:00:13 +00:00
2022-10-15 10:15:35 +00:00
return {
...parsed,
text,
extensions,
// config,
args,
lastModified: new Date(parsed.lastModified)
}
} catch (error) {
console.error(error)
return { ...state, args }
2022-10-09 10:29:35 +00:00
}
}
2022-09-09 11:53:35 +00:00
}
2022-10-18 15:50:49 +00:00
const getTheme = (state: State) => ({ theme: state.config?.theme || '' })
2022-09-09 11:53:35 +00:00
const clean = () => {
2022-10-09 00:00:13 +00:00
setState({
2022-09-09 11:53:35 +00:00
...newState(),
loading: 'initialized',
2022-10-09 00:00:13 +00:00
drafts: [],
2022-09-09 11:53:35 +00:00
lastModified: new Date(),
error: undefined,
2022-10-09 00:00:13 +00:00
text: undefined
})
}
const discard = async () => {
if (store.path) {
await discardText()
} else if (store.drafts.length > 0 && isEmpty(store.text as EditorState)) {
await discardText()
} else {
selectAll(store.editorView.state, store.editorView.dispatch)
deleteSelection(store.editorView.state, store.editorView.dispatch)
2022-10-08 16:40:58 +00:00
}
2022-09-09 11:53:35 +00:00
}
const init = async () => {
2022-10-09 10:29:35 +00:00
let state = await readStoredState()
2022-10-15 10:15:35 +00:00
console.log('[editor] init with state', state)
2022-10-09 00:00:13 +00:00
try {
2022-10-18 15:50:49 +00:00
if (state.args?.room) {
2022-10-09 10:29:35 +00:00
state = await doStartCollab(state)
} else if (state.args.text) {
state = await doOpenDraft(state, {
text: { ...JSON.parse(state.args.text) },
2022-10-09 08:33:28 +00:00
lastModified: new Date()
2022-10-09 00:00:13 +00:00
})
2022-10-09 10:29:35 +00:00
} else if (state.args.draft) {
const draft = await loadDraft(state.config, state.args.draft)
state = await doOpenDraft(state, draft)
} else if (state.path) {
const draft = await loadDraft(state.config, state.path)
state = await doOpenDraft(state, draft)
} else if (!state.text) {
2022-10-09 00:00:13 +00:00
const text = createEmptyText()
const extensions = createExtensions({
2022-10-09 10:29:35 +00:00
config: state.config ?? store.config,
markdown: state.markdown ?? store.markdown,
2022-10-09 00:00:13 +00:00
keymap: keymap
})
2022-10-09 10:29:35 +00:00
state = { ...state, text, extensions }
2022-09-09 11:53:35 +00:00
}
2022-10-09 00:00:13 +00:00
} catch (error) {
2022-10-09 10:29:35 +00:00
state = { ...state, error: error.errorObject }
2022-10-09 00:00:13 +00:00
}
setState({
2022-10-09 10:29:35 +00:00
...state,
config: { ...state.config, ...getTheme(state) },
2022-10-09 00:00:13 +00:00
loading: 'initialized'
})
2022-10-15 10:15:35 +00:00
console.log('[editor] initialized successfully', state)
2022-10-09 00:00:13 +00:00
}
const loadDraft = async (config: Config, path: string): Promise<Draft> => {
2022-10-19 15:56:29 +00:00
const [draft, setDraft] = createSignal<Draft>()
2022-10-09 00:00:13 +00:00
const schema = createSchema({
config,
markdown: false,
path,
keymap
})
const parser = createMarkdownParser(schema)
return {
...draft(),
2022-10-09 08:41:33 +00:00
text: {
doc: parser.parse(draft().body).toJSON(),
selection: {
type: 'text',
anchor: 1,
head: 1
}
},
2022-10-09 00:00:13 +00:00
path
}
}
const newDraft = () => {
if (isEmpty(store.text as EditorState) && !store.path) return
const state = unwrap(store)
let drafts = state.drafts
if (!state.error) {
drafts = addToDrafts(drafts, state)
}
const extensions = createExtensions({
config: state.config ?? store.config,
markdown: state.markdown ?? store.markdown,
keymap
})
setState({
text: createEmptyText(),
extensions,
drafts,
lastModified: undefined,
path: undefined,
error: undefined,
collab: undefined
})
}
const openDraft = async (draft: Draft) => {
const state: State = unwrap(store)
const update = await doOpenDraft(state, draft)
setState(update)
}
const doOpenDraft = async (state: State, draft: Draft): Promise<State> => {
const findIndexOfDraft = (f: Draft) => {
for (let i = 0; i < state.drafts.length; i++) {
if (state.drafts[i] === f || (f.path && state.drafts[i].path === f.path)) return i
}
return -1
}
const index = findIndexOfDraft(draft)
const item = index === -1 ? draft : state.drafts[index]
2022-10-18 18:43:50 +00:00
let drafts = state.drafts.filter((d: Draft) => d !== item)
2022-10-09 00:00:13 +00:00
if (!isEmpty(state.text as EditorState) && state.lastModified) {
2022-10-09 08:33:28 +00:00
drafts = addToDrafts(drafts, { lastModified: new Date(), text: state.text } as Draft)
2022-10-09 00:00:13 +00:00
}
2022-10-09 08:33:28 +00:00
draft.lastModified = item.lastModified
2022-10-09 00:00:13 +00:00
const next = await createTextFromDraft(draft)
return {
...state,
...next,
drafts,
collab: undefined,
error: undefined
2022-09-09 11:53:35 +00:00
}
}
2022-10-08 16:40:58 +00:00
const saveState = () =>
debounce(async (state: State) => {
2022-10-09 08:33:28 +00:00
const data: State = {
loading: 'initialized',
2022-10-08 16:40:58 +00:00
lastModified: state.lastModified,
2022-10-09 00:00:13 +00:00
drafts: state.drafts,
2022-10-08 16:40:58 +00:00
config: state.config,
path: state.path,
markdown: state.markdown,
collab: {
room: state.collab?.room
2022-10-09 00:00:13 +00:00
}
2022-10-08 16:40:58 +00:00
}
2022-10-09 00:00:13 +00:00
2022-10-08 16:40:58 +00:00
if (isInitialized(state.text as EditorState)) {
2022-10-09 00:00:13 +00:00
if (state.path) {
2022-10-19 15:56:29 +00:00
const text = serialize(store.editorView.state)
// TODO: saving draft logix here
2022-10-09 08:33:28 +00:00
// await remote.writeDraft(state.path, text)
2022-10-09 00:00:13 +00:00
} else {
data.text = store.editorView.state.toJSON()
}
2022-10-08 16:40:58 +00:00
} else if (state.text) {
2022-10-09 00:00:13 +00:00
data.text = state.text
2022-10-08 16:40:58 +00:00
}
2022-10-09 00:00:13 +00:00
db.set('state', JSON.stringify(data))
2022-10-08 16:40:58 +00:00
}, 200)
2022-09-09 11:53:35 +00:00
const startCollab = () => {
const state: State = unwrap(store)
const update = doStartCollab(state)
setState(update)
}
2022-10-09 09:07:13 +00:00
const doStartCollab = async (state: State): Promise<State> => {
2022-10-18 15:50:49 +00:00
const restoredRoom = state.args?.room && state.collab?.room !== state.args.room
2022-09-09 11:53:35 +00:00
const room = state.args?.room ?? uuidv4()
2022-10-18 15:50:49 +00:00
state.args = { ...state.args, room }
let newst = state
try {
const { roomConnect } = await import('../prosemirror/p2p')
const [type, provider] = roomConnect(room)
2022-10-09 00:00:13 +00:00
2022-10-18 15:50:49 +00:00
const extensions = createExtensions({
config: state.config,
markdown: state.markdown,
path: state.path,
keymap,
y: { type, provider },
collab: true
})
2022-10-09 00:00:13 +00:00
2022-10-18 15:50:49 +00:00
if ((restoredRoom && !isEmpty(state.text as EditorState)) || state.path) {
let drafts = state.drafts
if (!state.error) {
drafts = addToDrafts(drafts, { lastModified: new Date(), text: state.text } as Draft)
}
newst = {
...state,
drafts,
lastModified: undefined,
path: undefined,
error: undefined
}
window.history.replaceState(null, '', `/${room}`)
2022-10-09 00:00:13 +00:00
}
2022-10-18 15:50:49 +00:00
return {
...newst,
extensions,
collab: { started: true, room, y: { type, provider } }
}
} catch (error) {
console.error(error)
return {
2022-09-09 11:53:35 +00:00
...state,
2022-10-18 15:50:49 +00:00
collab: { error }
2022-09-09 11:53:35 +00:00
}
}
}
const stopCollab = (state: State) => {
2022-10-09 00:00:13 +00:00
state.collab.y?.provider.destroy()
2022-09-09 11:53:35 +00:00
const extensions = createExtensions({
config: state.config,
markdown: state.markdown,
path: state.path,
2022-10-09 08:48:59 +00:00
keymap,
collab: false
2022-09-09 11:53:35 +00:00
})
2022-10-09 00:00:13 +00:00
2022-09-09 11:53:35 +00:00
setState({ collab: undefined, extensions })
window.history.replaceState(null, '', '/')
}
2022-10-09 00:00:13 +00:00
const toggleMarkdown = () => {
const state = unwrap(store)
const editorState = store.text as EditorState
const markdown = !state.markdown
const selection = { type: 'text', anchor: 1, head: 1 }
2022-10-09 08:33:28 +00:00
let doc
2022-10-09 00:00:13 +00:00
if (markdown) {
const lines = serialize(editorState).split('\n')
const nodes = lines.map((text) => {
return text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' }
})
doc = { type: 'doc', content: nodes }
} else {
const schema = createSchema({
config: state.config,
path: state.path,
y: state.collab?.y,
markdown,
keymap
})
const parser = createMarkdownParser(schema)
let textContent = ''
editorState.doc.forEach((node) => {
textContent += `${node.textContent}\n`
})
const text = parser.parse(textContent)
doc = text.toJSON()
}
const extensions = createExtensions({
config: state.config,
markdown,
path: state.path,
keymap: keymap,
y: state.collab?.y
})
setState({
text: { selection, doc },
extensions,
markdown
})
2022-10-09 08:33:28 +00:00
return true
2022-10-09 00:00:13 +00:00
}
2022-09-09 11:53:35 +00:00
const updateConfig = (config: Partial<Config>) => {
const state = unwrap(store)
const extensions = createExtensions({
config: { ...state.config, ...config },
markdown: state.markdown,
path: state.path,
keymap,
y: state.collab?.y
})
setState({
config: { ...state.config, ...config },
extensions,
lastModified: new Date()
})
}
const updatePath = (path: string) => {
setState({ path, lastModified: new Date() })
}
const updateTheme = () => {
const { theme } = getTheme(unwrap(store))
setState('config', { theme })
}
const ctrl = {
clean,
discard,
getTheme,
init,
2022-10-09 00:00:13 +00:00
loadDraft,
newDraft,
openDraft,
2022-09-09 11:53:35 +00:00
saveState,
setState,
startCollab,
stopCollab,
toggleMarkdown,
updateConfig,
updatePath,
updateTheme
}
return [store, ctrl]
}