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 05:24:09 +00:00
|
|
|
import { State, Config, ServiceError, newState, PeerData } from '../prosemirror/context'
|
2022-09-09 11:53:35 +00:00
|
|
|
import { serialize, createMarkdownParser } from '../prosemirror/markdown'
|
2022-10-07 19:35:53 +00:00
|
|
|
import { isEmpty, isInitialized, ProseMirrorExtension } from '../prosemirror/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
|
|
|
|
|
|
|
const mod = 'Ctrl'
|
|
|
|
|
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 05:24:09 +00:00
|
|
|
if (!isInitialized(store.text)) 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 05:24:09 +00:00
|
|
|
if (!isInitialized(store.text)) 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 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)
|
|
|
|
const room = undefined // window.location.pathname?.slice(1) + uuidv4()
|
|
|
|
// console.debug('[editor-ctrl] got unique room', room)
|
|
|
|
const args = { room }
|
2022-10-07 19:35:53 +00:00
|
|
|
const { default: db } = await import('../db')
|
|
|
|
const data: string = await db.get('state')
|
2022-10-08 05:24:09 +00:00
|
|
|
console.debug('[editor-ctrl] got stored state from idb')
|
2022-10-07 19:35:53 +00:00
|
|
|
let parsed
|
2022-10-08 05:24:09 +00:00
|
|
|
let text = state.text
|
2022-10-07 19:35:53 +00:00
|
|
|
|
|
|
|
if (data !== undefined) {
|
|
|
|
try {
|
|
|
|
parsed = JSON.parse(data)
|
|
|
|
if (!parsed) return { ...state, args }
|
2022-10-08 05:24:09 +00:00
|
|
|
|
|
|
|
console.debug('[editor-ctrl] json state parsed successfully', parsed)
|
|
|
|
if (parsed?.text) {
|
|
|
|
if (!parsed.text || !parsed.text.doc || !parsed.text.selection) {
|
|
|
|
throw new ServiceError('invalid_state', parsed.text)
|
|
|
|
} else {
|
|
|
|
text = parsed.text
|
|
|
|
console.debug('[editor-ctrl] got text from stored json', parsed)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
const getTheme = (state: State) => ({ theme: state.config.theme })
|
|
|
|
|
|
|
|
const clean = () => {
|
|
|
|
setState({
|
|
|
|
...newState(),
|
|
|
|
loading: 'initialized',
|
|
|
|
lastModified: new Date(),
|
|
|
|
error: undefined,
|
|
|
|
text: undefined
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
const init = async () => {
|
2022-10-08 05:24:09 +00:00
|
|
|
let state = await fetchData()
|
|
|
|
console.debug('[editor-ctrl] state initiated', state)
|
2022-09-09 11:53:35 +00:00
|
|
|
try {
|
2022-10-08 05:24:09 +00:00
|
|
|
if (state.args?.room) {
|
|
|
|
state = doStartCollab(state)
|
|
|
|
} else if (!state.text) {
|
2022-09-09 11:53:35 +00:00
|
|
|
const text = createEmptyText()
|
|
|
|
const extensions = createExtensions({
|
2022-10-08 05:24:09 +00:00
|
|
|
config: state.config,
|
|
|
|
markdown: state.markdown,
|
2022-09-09 11:53:35 +00:00
|
|
|
keymap
|
|
|
|
})
|
|
|
|
|
2022-10-08 05:24:09 +00:00
|
|
|
state = { ...state, text, extensions }
|
2022-09-09 11:53:35 +00:00
|
|
|
}
|
2022-10-07 19:35:53 +00:00
|
|
|
} catch (error) {
|
2022-10-08 05:24:09 +00:00
|
|
|
state = { ...state, error }
|
2022-09-09 11:53:35 +00:00
|
|
|
}
|
|
|
|
setState({
|
2022-10-08 05:24:09 +00:00
|
|
|
...state,
|
|
|
|
config: { ...state.config, ...getTheme(state) },
|
2022-09-09 11:53:35 +00:00
|
|
|
loading: 'initialized'
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
const saveState = debounce(async (state: State) => {
|
2022-10-07 19:35:53 +00:00
|
|
|
const data = {
|
2022-09-09 11:53:35 +00:00
|
|
|
lastModified: state.lastModified,
|
|
|
|
config: state.config,
|
|
|
|
path: state.path,
|
|
|
|
markdown: state.markdown,
|
|
|
|
collab: {
|
|
|
|
room: state.collab?.room
|
2022-10-07 19:35:53 +00:00
|
|
|
},
|
|
|
|
text: ''
|
2022-09-09 11:53:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (isInitialized(state.text)) {
|
|
|
|
data.text = store.editorView.state.toJSON()
|
|
|
|
} else if (state.text) {
|
2022-10-07 19:35:53 +00:00
|
|
|
data.text = state.text as string
|
2022-09-09 11:53:35 +00:00
|
|
|
}
|
|
|
|
if (!isServer) {
|
|
|
|
const { default: db } = await import('../db')
|
|
|
|
db.set('state', JSON.stringify(data))
|
|
|
|
}
|
|
|
|
}, 200)
|
|
|
|
|
|
|
|
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)
|
2022-09-09 11:53:35 +00:00
|
|
|
|
2022-10-07 19:35:53 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
if ((backup && !isEmpty(state.text)) || state.path) {
|
|
|
|
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]
|
|
|
|
}
|