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

619 lines
14 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'
import type { EditorState } from 'prosemirror-state'
import { undo, redo } from 'prosemirror-history'
import { selectAll, deleteSelection } from 'prosemirror-commands'
import * as Y from 'yjs'
import { undo as yUndo, redo as yRedo } from 'y-prosemirror'
import { WebrtcProvider } from 'y-webrtc'
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'
import { debounce } from 'ts-debounce'
// import * as remote from '../prosemirror/remote'
import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup'
import { State, File, Config, ServiceError, newState } from '.'
// import { isTauri, mod } from '../env'
import { serialize, createMarkdownParser } from '../prosemirror/markdown'
import { isEmpty, isInitialized } from '../prosemirror/state'
import { Awareness } from 'y-protocols/awareness'
import { isServer } from 'solid-js/web'
const mod = 'Ctrl'
const isTauri = false
const isText = (x: any) => x && x.doc && x.selection
const isState = (x: any) => typeof x.lastModified !== 'string' && Array.isArray(x.files)
const isFile = (x: any): boolean => x && (x.text || x.path)
export const createCtrl = (initial: State): [Store<State>, any] => {
const [store, setState] = createStore(initial)
const discardText = async () => {
const state = unwrap(store)
const index = state.files.length - 1
const file = index !== -1 ? state.files[index] : undefined
let next: Partial<State>
if (file) {
next = await createTextFromFile(file)
} else {
const extensions = createExtensions({
config: state.config ?? store.config,
markdown: (state.markdown && store.markdown) as any,
keymap
})
next = {
text: createEmptyText(),
extensions,
lastModified: undefined,
path: undefined,
markdown: state.markdown
}
}
const files = state.files.filter((f: File) => f !== file)
setState({
files,
...next,
collab: file ? undefined : state.collab,
error: undefined
})
}
const addToFiles = (files: File[], prev: State) => {
const text = prev.path ? undefined : (prev.text as EditorState).toJSON()
return [
...files,
{
text,
lastModified: prev.lastModified?.toISOString(),
path: prev.path,
markdown: prev.markdown
}
]
}
const newFile = () => {
if (isEmpty(store.text) && !store.path) {
return
}
const state: State = unwrap(store)
let files = state.files
if (!state.error) {
files = addToFiles(files, state)
}
const extensions: any[] = createExtensions({
config: state.config ?? store.config,
markdown: state.markdown,
keymap
})
setState({
text: createEmptyText(),
extensions,
files,
lastModified: undefined,
path: undefined,
error: undefined,
collab: undefined
})
}
const discard = async () => {
if (store.path) {
await discardText()
} else if (store.files?.length > 0 && isEmpty(store.text)) {
await discardText()
} else {
selectAll(store.editorView.state, store.editorView.dispatch)
deleteSelection(store.editorView.state, store.editorView.dispatch)
}
}
// FIXME
// eslint-disable-next-line unicorn/consistent-function-scoping
const onQuit = () => {
if (!isTauri) {
console.debug('quit')
// return
}
// remote.quit()
}
const onNew = () => {
newFile()
return true
}
const onDiscard = () => {
discard()
return true
}
const onFullscreen = () => {
if (!isTauri) return
ctrl.setFullscreen(!store.fullscreen)
return true
}
const onToggleMarkdown = () => toggleMarkdown()
const onUndo = () => {
if (!isInitialized(store.text)) return
const text = store.text as EditorState
if (store.collab?.started) {
yUndo(text)
} else {
undo(text, store.editorView.dispatch)
}
return true
}
const onRedo = () => {
if (!isInitialized(store.text)) return
const text = store.text as EditorState
if (store.collab?.started) {
yRedo(text)
} else {
redo(text, store.editorView.dispatch)
}
return true
}
const keymap = {
[`${mod}-q`]: onQuit,
[`${mod}-n`]: onNew,
[`${mod}-w`]: onDiscard,
'Cmd-Enter': onFullscreen,
'Alt-Enter': onFullscreen,
[`${mod}-z`]: onUndo,
[`Shift-${mod}-z`]: onRedo,
[`${mod}-y`]: onRedo,
[`${mod}-m`]: onToggleMarkdown
}
const createTextFromFile = async (file: File) => {
const state = unwrap(store)
// if (file.path) file = await loadFile(state.config, file.path)
const extensions = createExtensions({
config: state.config,
markdown: file.markdown,
path: file.path,
keymap
})
return {
text: file.text,
extensions,
lastModified: file.lastModified ? new Date(file.lastModified) : undefined,
path: file.path,
markdown: file.markdown
}
}
// FIXME
// eslint-disable-next-line sonarjs/cognitive-complexity
const fetchData = async (): Promise<State> => {
let args = {} // await remote.getArgs().catch(() => undefined)
const state: State = unwrap(store)
if (!isTauri) {
const room = window.location.pathname?.slice(1).trim()
args = { room: room || undefined }
}
if (!isServer) {
const { default: db } = await import('../db')
const data: string = await db.get('state')
let parsed: any
if (data !== undefined) {
try {
parsed = JSON.parse(data)
} catch {
throw new ServiceError('invalid_state', data)
}
}
if (!parsed) {
return { ...state, args }
}
let text = state.text
if (parsed.text) {
if (!isText(parsed.text)) {
throw new ServiceError('invalid_state', parsed.text)
}
text = parsed.text
}
const extensions = createExtensions({
path: parsed.path,
markdown: parsed.markdown,
keymap,
config: {} as Config
})
const nState = {
...parsed,
text,
extensions,
// config,
args
}
if (nState.lastModified) {
nState.lastModified = new Date(nState.lastModified)
}
for (const file of parsed.files) {
if (!isFile(file)) {
throw new ServiceError('invalid_file', file)
}
}
if (!isState(nState)) {
throw new ServiceError('invalid_state', nState)
}
return nState
} else {
return
}
}
const getTheme = (state: State) => ({ theme: state.config.theme })
const clean = () => {
setState({
...newState(),
loading: 'initialized',
files: [],
fullscreen: store.fullscreen,
lastModified: new Date(),
error: undefined,
text: undefined
})
}
const init = async () => {
let data = await fetchData()
try {
if (data.args?.room) {
data = doStartCollab(data)
} else if (data.args?.text) {
data = await doOpenFile(data, { text: JSON.parse(data.args?.text) })
} /* else if (data.args?.file) {
const file = await loadFile(data.config, data.args?.file)
data = await doOpenFile(data, file)
} else if (data.path) {
const file = await loadFile(data.config, data.path)
data = await doOpenFile(data, file)
} */ else if (!data.text) {
const text = createEmptyText()
const extensions = createExtensions({
config: data.config,
markdown: data.markdown,
keymap
})
data = { ...data, text, extensions }
}
} catch (error: any) {
data = { ...data, error: error.errorObject }
}
setState({
...data,
config: { ...data.config, ...getTheme(data) },
loading: 'initialized'
})
}
/*
const loadFile = async (config: Config, path: string): Promise<File> => {
try {
const fileContent = await remote.readFile(path)
const lastModified = await remote.getFileLastModified(path)
const schema = createSchema({
config,
markdown: false,
path,
keymap
})
const parser = createMarkdownParser(schema)
const doc = parser.parse(fileContent).toJSON()
const text = {
doc,
selection: {
type: 'text',
anchor: 1,
head: 1
}
}
return {
text,
lastModified: lastModified.toISOString(),
path
}
} catch (e) {
throw new ServiceError('file_permission_denied', { error: e })
}
}
*/
const openFile = async (file: File) => {
const state: State = unwrap(store)
const update = await doOpenFile(state, file)
setState(update)
}
const doOpenFile = async (state: State, file: File): Promise<State> => {
const findIndexOfFile = (f: File) => {
for (let i = 0; i < state.files.length; i++) {
if (state.files[i] === f) return i
if (f.path && state.files[i].path === f.path) return i
}
return -1
}
const index = findIndexOfFile(file)
const item = index === -1 ? file : state.files[index]
let files = state.files.filter((f) => f !== item)
if (!isEmpty(state.text) && state.lastModified) {
files = addToFiles(files, state)
}
file.lastModified = item.lastModified
const next = await createTextFromFile(file)
return {
...state,
...next,
files,
collab: undefined,
error: undefined
}
}
// eslint-disable-next-line solid/reactivity
const saveState = debounce(async (state: State) => {
const data: any = {
lastModified: state.lastModified,
files: state.files,
config: state.config,
path: state.path,
markdown: state.markdown,
collab: {
room: state.collab?.room
}
}
if (isInitialized(state.text)) {
//if (state.path) {
// const text = serialize(store.editorView.state)
// await remote.writeFile(state.path, text)
//}
data.text = store.editorView.state.toJSON()
} else if (state.text) {
data.text = state.text
}
if (!isServer) {
const { default: db } = await import('../db')
db.set('state', JSON.stringify(data))
}
}, 200)
const setFullscreen = (fullscreen: boolean) => {
// remote.setFullscreen(fullscreen)
setState({ fullscreen })
}
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()
window.history.replaceState(null, '', `/${room}`)
const ydoc = new Y.Doc()
const type = ydoc.getXmlFragment('prosemirror')
const webrtcOptions = {
awareness: new Awareness(ydoc),
filterBcConns: true,
maxConns: 33,
signaling: [
// 'wss://signaling.discours.io',
// 'wss://stun.l.google.com:19302',
'wss://y-webrtc-signaling-eu.herokuapp.com',
'wss://signaling.yjs.dev'
],
peerOpts: {},
password: ''
}
const provider = new WebrtcProvider(room, ydoc, webrtcOptions)
const username = uniqueNamesGenerator({
dictionaries: [adjectives, animals],
style: 'capital',
separator: ' ',
length: 2
})
provider.awareness.setLocalStateField('user', {
name: username
})
const extensions = createExtensions({
config: state.config,
markdown: state.markdown,
path: state.path,
keymap,
y: { type, provider }
})
let nState = state
if ((backup && !isEmpty(state.text)) || state.path) {
let files = state.files
if (!state.error) {
files = addToFiles(files, state)
}
nState = {
...state,
files,
lastModified: undefined,
path: undefined,
error: undefined
}
}
return {
...nState,
extensions,
collab: { started: true, room, y: { type, provider } }
}
}
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 toggleMarkdown = () => {
const state = unwrap(store)
const editorState = store.text as EditorState
const markdown = !state.markdown
const selection = { type: 'text', anchor: 1, head: 1 }
let doc: any
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,
y: state.collab?.y
})
setState({
text: { selection, doc },
extensions,
markdown
})
}
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,
// loadFile,
newFile,
openFile,
saveState,
setFullscreen,
setState,
startCollab,
stopCollab,
toggleMarkdown,
updateConfig,
updatePath,
updateTheme
}
return [store, ctrl]
}