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

321 lines
8.7 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-08 05:24:09 +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-07 19:35:53 +00:00
import { createSchema, createExtensions, createEmptyText, InitOpts } from '../prosemirror/setup'
2022-10-08 16:40:58 +00:00
import { State, Config, ServiceError, newState, PeerData } from './context'
2022-09-09 11:53:35 +00:00
import { serialize, createMarkdownParser } from '../prosemirror/markdown'
2022-10-08 16:40:58 +00:00
import { isEmpty, isInitialized, ProseMirrorExtension } from './state'
2022-09-09 11:53:35 +00:00
import { isServer } from 'solid-js/web'
2022-10-07 19:35:53 +00:00
import { roomConnect } from '../prosemirror/p2p'
2022-09-09 11:53:35 +00:00
2022-10-08 05:24:09 +00:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2022-10-07 19:35:53 +00:00
export const createCtrl = (initial: State): [Store<State>, { [key: string]: any }] => {
2022-09-09 11:53:35 +00:00
const [store, setState] = createStore(initial)
const discardText = async () => {
const state = unwrap(store)
2022-10-08 05:24:09 +00:00
const extensions = createExtensions({
config: state.config ?? store.config,
markdown: state.markdown && store.markdown,
keymap
})
2022-09-09 11:53:35 +00:00
setState({
2022-10-08 05:24:09 +00:00
text: createEmptyText(),
extensions,
lastModified: undefined,
path: undefined,
markdown: state.markdown,
collab: state.collab,
2022-09-09 11:53:35 +00:00
error: undefined
})
}
const discard = async () => {
if (store.path) {
await discardText()
} else {
selectAll(store.editorView.state, store.editorView.dispatch)
deleteSelection(store.editorView.state, store.editorView.dispatch)
}
2022-10-08 05:24:09 +00:00
return true
2022-09-09 11:53:35 +00:00
}
const onDiscard = () => {
discard()
return true
}
const onUndo = () => {
2022-10-08 16:40:58 +00:00
if (!isInitialized(store.text as EditorState)) return false
2022-09-09 11:53:35 +00:00
const text = store.text as EditorState
2022-10-08 05:24:09 +00:00
if (store.collab?.started) yUndo(text)
else undo(text, store.editorView.dispatch)
2022-09-09 11:53:35 +00:00
return true
}
const onRedo = () => {
2022-10-08 16:40:58 +00:00
if (!isInitialized(store.text as EditorState)) return false
2022-09-09 11:53:35 +00:00
const text = store.text as EditorState
2022-10-08 05:24:09 +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-08 05:24:09 +00:00
const toggleMarkdown = () => {
2022-09-09 11:53:35 +00:00
const state = unwrap(store)
2022-10-08 05:24:09 +00:00
const editorState = store.text as EditorState
const markdown = !state.markdown
const selection = { type: 'text', anchor: 1, head: 1 }
let doc
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()
}
2022-09-09 11:53:35 +00:00
const extensions = createExtensions({
config: state.config,
2022-10-08 05:24:09 +00:00
markdown,
path: state.path,
keymap,
y: state.collab?.y
2022-09-09 11:53:35 +00:00
})
2022-10-08 05:24:09 +00:00
setState({
text: { selection, doc },
2022-09-09 11:53:35 +00:00
extensions,
2022-10-08 05:24:09 +00:00
markdown
})
2022-09-09 11:53:35 +00:00
}
2022-10-08 16:40:58 +00:00
const mod = 'Ctrl'
2022-10-08 05:24:09 +00:00
const keymap = {
[`${mod}-w`]: onDiscard,
[`${mod}-z`]: onUndo,
[`Shift-${mod}-z`]: onRedo,
[`${mod}-y`]: onRedo,
[`${mod}-m`]: toggleMarkdown
} as unknown as { [key: string]: Command }
2022-09-09 11:53:35 +00:00
const fetchData = async (): Promise<State> => {
2022-10-07 19:35:53 +00:00
if (isServer) return
2022-10-08 05:24:09 +00:00
const state: State = unwrap(store)
2022-10-08 16:40:58 +00:00
console.debug('[editor] init state', state)
2022-10-07 19:35:53 +00:00
const { default: db } = await import('../db')
const data: string = await db.get('state')
if (data !== undefined) {
2022-10-08 16:40:58 +00:00
console.debug('[editor] state stored before', data)
2022-10-07 19:35:53 +00:00
try {
2022-10-08 16:40:58 +00:00
const parsed = JSON.parse(data)
let text = state.text
const room = undefined // window.location.pathname?.slice(1) + uuidv4()
const args = { room }
2022-10-07 19:35:53 +00:00
if (!parsed) return { ...state, args }
2022-10-08 05:24:09 +00:00
if (parsed?.text) {
if (!parsed.text || !parsed.text.doc || !parsed.text.selection) {
throw new ServiceError('invalid_state', parsed.text)
} else {
text = parsed.text
2022-10-08 16:40:58 +00:00
console.debug('[editor] got text parsed')
2022-10-08 05:24:09 +00:00
}
}
2022-10-08 16:40:58 +00:00
console.debug('[editor] json state parsed successfully', parsed)
2022-10-08 05:24:09 +00:00
return {
...parsed,
text,
extensions: createExtensions({
path: parsed.path,
markdown: parsed.markdown,
keymap,
config: {} as Config
}),
args,
lastModified: parsed.lastModified ? new Date(parsed.lastModified) : new Date()
}
2022-10-07 19:35:53 +00:00
} catch {
throw new ServiceError('invalid_state', data)
2022-09-09 11:53:35 +00:00
}
2022-10-07 19:35:53 +00:00
}
2022-09-09 11:53:35 +00:00
}
2022-10-08 16:40:58 +00:00
const getTheme = (state: State) => ({ theme: state.config?.theme || '' })
2022-09-09 11:53:35 +00:00
const clean = () => {
2022-10-08 16:40:58 +00:00
const s: State = {
2022-09-09 11:53:35 +00:00
...newState(),
loading: 'initialized',
lastModified: new Date(),
error: undefined,
2022-10-08 16:40:58 +00:00
text: undefined,
args: {}
}
setState(s)
console.debug('[editor] clean state', s)
2022-09-09 11:53:35 +00:00
}
const init = async () => {
2022-10-08 05:24:09 +00:00
let state = await fetchData()
2022-10-08 16:40:58 +00:00
if (state) {
console.debug('[editor] state initiated', state)
try {
if (state.args?.room) {
state = { ...doStartCollab(state) }
} else if (!state.text) {
const text = createEmptyText()
const extensions = createExtensions({
config: state?.config || ({} as Config),
markdown: state.markdown,
keymap
})
state = { ...state, text, extensions }
}
} catch (error) {
state = { ...state, error }
2022-09-09 11:53:35 +00:00
}
2022-10-08 16:40:58 +00:00
setState({
...state,
config: {
...state.config,
...getTheme(state)
},
loading: 'initialized'
})
2022-09-09 11:53:35 +00:00
}
}
2022-10-08 16:40:58 +00:00
const saveState = () =>
debounce(async (state: State) => {
const data = {
lastModified: state.lastModified,
config: state.config,
path: state.path,
markdown: state.markdown,
collab: {
room: state.collab?.room
},
text: ''
}
if (isInitialized(state.text as EditorState)) {
data.text = store.editorView.state.toJSON()
} else if (state.text) {
data.text = state.text as string
}
if (!isServer) {
const { default: db } = await import('../db')
db.set('state', JSON.stringify(data))
}
}, 200)
2022-09-09 11:53:35 +00:00
const startCollab = () => {
const state: State = unwrap(store)
const update = doStartCollab(state)
setState(update)
}
const doStartCollab = (state: State): State => {
const backup = state.args?.room && state.collab?.room !== state.args.room
const room = state.args?.room ?? uuidv4()
2022-10-04 10:45:10 +00:00
const username = '' // FIXME: use authenticated user name
2022-10-07 19:35:53 +00:00
const [payload, provider] = roomConnect(room, username)
const extensions: ProseMirrorExtension[] = createExtensions({
2022-09-09 11:53:35 +00:00
config: state.config,
markdown: state.markdown,
path: state.path,
keymap,
2022-10-07 19:35:53 +00:00
y: { payload, provider } as PeerData
} as InitOpts)
2022-09-09 11:53:35 +00:00
let nState = state
2022-10-08 16:40:58 +00:00
if ((backup && !isEmpty(state.text as EditorState)) || state.path) {
2022-09-09 11:53:35 +00:00
nState = {
...state,
lastModified: undefined,
path: undefined,
error: undefined
}
}
return {
...nState,
extensions,
2022-10-07 19:35:53 +00:00
collab: { started: true, room, y: { payload, provider } }
2022-09-09 11:53:35 +00:00
}
}
const stopCollab = (state: State) => {
state.collab?.y?.provider.destroy()
const extensions = createExtensions({
config: state.config,
markdown: state.markdown,
path: state.path,
keymap
})
setState({ collab: undefined, extensions })
window.history.replaceState(null, '', '/')
}
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,
saveState,
setState,
startCollab,
stopCollab,
toggleMarkdown,
updateConfig,
updatePath,
updateTheme
}
return [store, ctrl]
}