wip-refactor

This commit is contained in:
tonyrewin 2022-10-09 03:00:13 +03:00
parent 43fbe729f9
commit 2801ab0ef9
36 changed files with 997 additions and 904 deletions

View File

@ -1,23 +0,0 @@
import '../styles/ArticlesList.scss'
export default () => {
return (
<div class="articles-list">
<div class="articles-list__item article row">
<div class="col-md-6">
<div class="article__status article__status--draft">Черновик</div>
<div class="article__title">
<strong>Поствыживание. Комплекс вины и&nbsp;кризис самооценки в&nbsp;дивном новом мире.</strong>{' '}
В&nbsp;летописи российского музыкального подполья остаётся множество лакун.
</div>
<time class="article__date">21 марта 2022</time>
</div>
<div class="article__controls col-md-5 offset-md-1">
<div class="article-control">Редактировать</div>
<div class="article-control">Опубликовать</div>
<div class="article-control article-control--remove">Удалить</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
import type { EditorView } from 'prosemirror-view'
import type { EditorState } from 'prosemirror-state'
import { useState } from '../store'
import { ProseMirror } from '../components/ProseMirror'
import '../styles/Editor.scss'
import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
export default () => {
const [store, ctrl] = useState()
const onInit = (text: EditorState, editorView: EditorView) => ctrl.setState({ editorView, text })
const onReconfigure = (text: EditorState) => ctrl.setState({ text })
const onChange = (text: EditorState) => ctrl.setState({ text, lastModified: new Date() })
// const editorCss = (config) => css``
const style = () => {
if (store.error) {
return `display: none;`
} else {
return store.markdown ? `white-space: pre-wrap;` : ''
}
}
return (
<ProseMirror
class={'editor'}
style={style()}
editorView={store.editorView as EditorView}
text={store.text as ProseMirrorState}
extensions={store.extensions as ProseMirrorExtension[]}
onInit={onInit}
onReconfigure={onReconfigure}
onChange={onChange}
/>
)
}

View File

@ -1,5 +1,23 @@
import { Switch, Match, createMemo } from 'solid-js' import { Switch, Match } from 'solid-js'
import { ErrorObject, useState } from '../store/context' import { useState } from '../store'
import '../styles/Button.scss'
export default () => {
const [store] = useState()
return (
<Switch fallback={<Other />}>
<Match when={store.error.id === 'invalid_state'}>
<InvalidState title="Invalid State" />
</Match>
<Match when={store.error.id === 'invalid_config'}>
<InvalidState title="Invalid Config" />
</Match>
<Match when={store.error.id === 'invalid_file'}>
<InvalidState title="Invalid File" />
</Match>
</Switch>
)
}
const InvalidState = (props: { title: string }) => { const InvalidState = (props: { title: string }) => {
const [store, ctrl] = useState() const [store, ctrl] = useState()
@ -15,7 +33,7 @@ const InvalidState = (props: { title: string }) => {
you can copy important notes from below, clean the state and paste it again. you can copy important notes from below, clean the state and paste it again.
</p> </p>
<pre> <pre>
<code>{JSON.stringify(store.error)}</code> <code>{JSON.stringify(store.error.props)}</code>
</pre> </pre>
<button class="primary" onClick={onClick}> <button class="primary" onClick={onClick}>
Clean Clean
@ -28,7 +46,11 @@ const InvalidState = (props: { title: string }) => {
const Other = () => { const Other = () => {
const [store, ctrl] = useState() const [store, ctrl] = useState()
const onClick = () => ctrl.discard() const onClick = () => ctrl.discard()
const getMessage = createMemo<ErrorObject['message']>(() => store.error.message)
const getMessage = () => {
const err = (store.error.props as any).error
return typeof err === 'string' ? err : err.message
}
return ( return (
<div class="error" data-tauri-drag-region="true"> <div class="error" data-tauri-drag-region="true">
@ -44,21 +66,3 @@ const Other = () => {
</div> </div>
) )
} }
export default () => {
const [store] = useState()
return (
<Switch fallback={<Other />}>
<Match when={store.error?.id === 'invalid_state'}>
<InvalidState title="Invalid State" />
</Match>
<Match when={store.error?.id === 'invalid_config'}>
<InvalidState title="Invalid Config" />
</Match>
<Match when={store.error?.id === 'invalid_file'}>
<InvalidState title="Invalid File" />
</Match>
</Switch>
)
}

View File

@ -1,19 +1,17 @@
import type { JSX } from 'solid-js/jsx-runtime' import type { Config } from '../store'
import type { Config } from '../store/context'
import '../styles/Layout.scss' import '../styles/Layout.scss'
export type Styled = { export type Styled = {
children: JSX.Element children: any
config?: Config config?: Config
'data-testid'?: string 'data-testid'?: string
onClick?: (e: MouseEvent) => void onClick?: () => void
onMouseEnter?: (e: MouseEvent) => void onMouseEnter?: (e: any) => void
} }
export const Layout = (props: Styled) => { export const Layout = (props: Styled) => {
return ( return (
// eslint-disable-next-line solid/reactivity <div onMouseEnter={props.onMouseEnter} class="layout" data-testid={props['data-testid']}>
<div onMouseEnter={props.onMouseEnter} class="layout layout--editor" data-testid={props['data-testid']}>
{props.children} {props.children}
</div> </div>
) )

View File

@ -0,0 +1,71 @@
import { EditorState, type Transaction } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { unwrap } from 'solid-js/store'
import { createEffect, untrack } from 'solid-js'
import { createEditorState } from '../prosemirror'
import type { ProseMirrorState, ProseMirrorExtension } from '../prosemirror/helpers'
interface Props {
style?: string
className?: string
text?: ProseMirrorState
editorView?: EditorView
extensions?: ProseMirrorExtension[]
onInit: (s: EditorState, v: EditorView) => void
onReconfigure: (s: EditorState) => void
onChange: (s: EditorState) => void
}
export const ProseMirror = (props: Props) => {
let editorRef: HTMLDivElement
const editorView = () => untrack(() => unwrap(props.editorView))
const dispatchTransaction = (tr: Transaction) => {
if (!editorView()) return
const newState = editorView().state.apply(tr)
editorView().updateState(newState)
if (!tr.docChanged) return
props.onChange(newState)
}
createEffect(
(state: [EditorState, ProseMirrorExtension[]]) => {
const [prevText, prevExtensions] = state
const text = unwrap(props.text)
const extensions = unwrap(props.extensions)
if (!text || !extensions?.length) {
return [text, extensions]
}
if (!props.editorView) {
const { editorState, nodeViews } = createEditorState(text, extensions)
const view = new EditorView(editorRef, { state: editorState, nodeViews, dispatchTransaction })
view.focus()
props.onInit(editorState, view)
return [editorState, extensions]
}
if (extensions !== prevExtensions || (!(text instanceof EditorState) && text !== prevText)) {
const { editorState, nodeViews } = createEditorState(text, extensions, prevText)
if (!editorState) return
editorView().updateState(editorState)
editorView().setProps({ nodeViews, dispatchTransaction })
props.onReconfigure(editorState)
editorView().focus()
return [editorState, extensions]
}
return [text, extensions]
},
[props.text, props.extensions]
)
return (
<div
style={props.style}
ref={editorRef}
class={props.className}
spell-check={false}
data-tauri-drag-region="true"
/>
)
}

View File

@ -1,27 +1,19 @@
import { Show, createEffect, createSignal, onCleanup, For } from 'solid-js' import { Show, createEffect, createSignal, onCleanup } from 'solid-js'
import { unwrap } from 'solid-js/store' import { unwrap } from 'solid-js/store'
import { undo, redo } from 'prosemirror-history' import { undo, redo } from 'prosemirror-history'
import { Draft, useState } from '../store/context' import { useState } from '../store'
import { clsx } from 'clsx'
import type { Styled } from './Layout' import type { Styled } from './Layout'
import { t } from '../../../utils/intl' import '../styles/Sidebar.scss'
// import type { EditorState } from 'prosemirror-state'
// import { serialize } from './prosemirror/markdown'
// import { baseUrl } from '../../graphql/client'
// import { isServer } from 'solid-js/web'
// const copy = async (text: string): Promise<void> => navigator.clipboard.writeText(text)
// const copyAllAsMarkdown = async (state: EditorState): Promise<void> =>
// navigator.clipboard.writeText(serialize(state)) && !isServer
const Off = (props) => <div class="sidebar-off">{props.children}</div> const Off = (props) => <div class="sidebar-off">{props.children}</div>
const Label = (props: Styled) => <h3 class="sidebar-label">{props.children}</h3> const Label = (props: Styled) => <h3 class="sidebar-label">{props.children}</h3>
const Link = ( const Link = (
props: Styled & { withMargin?: boolean; disabled?: boolean; title?: string; className?: string } props: Styled & { withMargin?: boolean; disabled?: boolean; title?: string; className?: string }
) => ( ) => (
<button <button
class={clsx('sidebar-link', props.className)} class={`sidebar-link${props.className ? ' ' + props.className : ''}`}
style={{ 'margin-bottom': props.withMargin ? '10px' : '' }} style={{ 'margin-bottom': props.withMargin ? '10px' : '' }}
onClick={props.onClick} onClick={props.onClick}
disabled={props.disabled} disabled={props.disabled}
@ -32,157 +24,65 @@ const Link = (
</button> </button>
) )
type DraftLinkProps = { // eslint-disable-next-line sonarjs/cognitive-complexity
draft: Draft export const Sidebar = (props) => {
onOpenDraft: (draft: Draft) => void
}
const DraftLink = (props: DraftLinkProps) => {
const length = 100
let content = ''
const getContent = (node: any) => {
if (node.text) {
content += node.text
}
if (content.length > length) {
content = `${content.slice(0, Math.max(0, length))}...`
return content
}
if (node.content) {
for (const child of node.content) {
if (content.length >= length) {
break
}
content = getContent(child)
}
}
return content
}
const text = () =>
props.draft.path
? props.draft.path.slice(Math.max(0, props.draft.path.length - length))
: getContent(props.draft.text?.doc)
return (
// eslint-disable-next-line solid/no-react-specific-props
<Link className="draft" onClick={() => props.onOpenDraft(props.draft)} data-testid="open">
{text()} {props.draft.path && '📎'}
</Link>
)
}
export const Sidebar = () => {
const [store, ctrl] = useState() const [store, ctrl] = useState()
const [lastAction, setLastAction] = createSignal<string | undefined>() const [lastAction, setLastAction] = createSignal<string | undefined>()
const toggleTheme = () => { const toggleTheme = () => {
document.body.classList.toggle('dark') document.body.classList.toggle('dark')
ctrl.updateConfig({ theme: document.body.className }) ctrl.updateConfig({ theme: document.body.className })
} }
const collabText = () => { const collabText = () => {
if (store.collab?.started) { if (store.collab?.started) {
return 'Stop' return 'Stop collab'
} else { } else {
return store.collab?.error ? 'Restart 🚨' : 'Start' return store.collab?.error ? 'Error collab' : 'Start collab'
} }
} }
const editorView = () => unwrap(store.editorView) const editorView = () => unwrap(store.editorView)
const onToggleMarkdown = () => ctrl.toggleMarkdown() const onToggleMarkdown = () => ctrl.toggleMarkdown()
const onOpenDraft = (draft: Draft) => ctrl.openDraft(unwrap(draft))
const collabUsers = () => store.collab?.y?.provider.awareness.meta.size ?? 0 const collabUsers = () => store.collab?.y?.provider.awareness.meta.size ?? 0
const onUndo = () => undo(editorView().state, editorView().dispatch) const onUndo = () => undo(editorView().state, editorView().dispatch)
const onRedo = () => redo(editorView().state, editorView().dispatch) const onRedo = () => redo(editorView().state, editorView().dispatch)
// const onCopyAllAsMd = () => copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md')) const onNew = () => ctrl.newFile()
// const onToggleAlwaysOnTop = () => ctrl.updateConfig({ alwaysOnTop: !store.config.alwaysOnTop }) const onDiscard = () => ctrl.discard()
// const onNew = () => ctrl.newDraft()
// const onDiscard = () => ctrl.discard()
const [isHidden, setIsHidden] = createSignal<boolean | false>() const [isHidden, setIsHidden] = createSignal<boolean | false>()
const toggleSidebar = () => { const toggleSidebar = () => setIsHidden(!isHidden())
setIsHidden(!isHidden())
}
toggleSidebar() toggleSidebar()
// const onSaveAs = async () => {
// const path = 'test' // TODO: save draftname await remote.save(editorView().state)
//
// if (path) ctrl.updatePath(path)
// }
//
const onCollab = () => { const onCollab = () => {
const state = unwrap(store) const state = unwrap(store)
store.collab?.started ? ctrl.stopCollab(state) : ctrl.startCollab(state)
store.collab?.started ? ctrl.stopCollab(state) : console.log(state)
} }
//
// const onOpenInApp = () => {
// // if (isTauri) return
//
// if (store.collab?.started) {
// window.open(`discoursio://main?room=${store.collab?.room}`, '_self')
// } else {
// const text = window.btoa(JSON.stringify(editorView().state.toJSON()))
//
// window.open(`discoursio://main?text=${text}`, '_self')
// }
// }
//
// const onCopyCollabLink = () => {
// copy(`${baseUrl}/collab/${store.collab?.room}`).then(() => {
// editorView().focus()
// setLastAction('copy-collab-link')
// })
// }
//
// const onCopyCollabAppLink = () => {
// copy(`discoursio://${store.collab?.room}`).then(() => {
// editorView().focus()
// setLastAction('copy-collab-app-link')
// })
// }
const Keys = (props: { keys: string[] }) => (
<span>
<For each={props.keys}>{(k: string) => <i>{k}</i>}</For>
</span>
)
createEffect(() => { createEffect(() => {
setLastAction() if (store.lastModified) setLastAction()
}, store.lastModified) })
createEffect(() => { createEffect(() => {
if (!lastAction()) return if (!lastAction()) return
const id = setTimeout(() => { const id = setTimeout(() => {
setLastAction() setLastAction()
}, 1000) }, 1000)
onCleanup(() => clearTimeout(id)) onCleanup(() => clearTimeout(id))
}) })
return ( return (
<div class={`sidebar-container${isHidden() ? ' sidebar-container--hidden' : ''}`}> <div class={'sidebar-container' + (isHidden() ? ' sidebar-container--hidden' : '')}>
<span class="sidebar-opener" onClick={toggleSidebar}> <span class="sidebar-opener" onClick={toggleSidebar}>
Советы и предложения Советы и&nbsp;предложения
</span> </span>
<Off onClick={() => editorView().focus()} data-tauri-drag-region="true"> <Off onClick={() => editorView().focus()} data-tauri-drag-region="true">
<div class="sidebar-closer" onClick={toggleSidebar} /> <div class="sidebar-closer" onClick={toggleSidebar} />
<Show when={true}> <Show when={true}>
<div> <div>
<Show when={store.path}> {store.path && (
<Label> <Label>
<i>({store.path?.slice(Math.max(0, store.path?.length - 24))})</i> <i>({store.path.slice(Math.max(0, store.path.length - 24))})</i>
</Label> </Label>
</Show> )}
<Link>Пригласить соавторов</Link> <Link>Пригласить соавторов</Link>
<Link>Настройки публикации</Link> <Link>Настройки публикации</Link>
<Link>История правок</Link> <Link>История правок</Link>
@ -194,17 +94,27 @@ export const Sidebar = () => {
</div> </div>
{/* {/*
<Show when={isTauri && !store.path}>
<Link onClick={onSaveAs}>
Save to file <Keys keys={[mod, 's']} />
</Link>
</Show>
<Link onClick={onNew} data-testid='new'> <Link onClick={onNew} data-testid='new'>
New <Keys keys={[mod, 'n']} /> New <Keys keys={[mod, 'n']} />
</Link> </Link>
<Link <Link
onClick={onDiscard} onClick={onDiscard}
disabled={!store.path && store.drafts?.length === 0 && isEmpty(store.text)} disabled={!store.path && store.files.length === 0 && isEmpty(store.text)}
data-testid='discard' data-testid='discard'
> >
{store.path ? 'Close' : store.drafts?.length > 0 && isEmpty(store.text) ? 'Delete ⚠️' : 'Clear'}{' '} {store.path ? 'Close' : store.files.length > 0 && isEmpty(store.text) ? 'Delete ⚠️' : 'Clear'}{' '}
<Keys keys={[mod, 'w']} /> <Keys keys={[mod, 'w']} />
</Link> </Link>
<Show when={isTauri}>
<Link onClick={onToggleFullscreen}>
Fullscreen {store.fullscreen && '✅'} <Keys keys={[alt, 'Enter']} />
</Link>
</Show>
<Link onClick={onUndo}> <Link onClick={onUndo}>
Undo <Keys keys={[mod, 'z']} /> Undo <Keys keys={[mod, 'z']} />
</Link> </Link>
@ -222,10 +132,10 @@ export const Sidebar = () => {
</Link> </Link>
<Link onClick={onCopyAllAsMd}>Copy all as MD {lastAction() === 'copy-md' && '📋'}</Link> <Link onClick={onCopyAllAsMd}>Copy all as MD {lastAction() === 'copy-md' && '📋'}</Link>
<Show when={store.drafts?.length > 0}> <Show when={store.files.length > 0}>
<h4>t('Drafts'):</h4> <h4>Files:</h4>
<p> <p>
<For each={store.drafts}>{(draft) => <DraftLink draft={draft} onOpenDraft={onOpenDraft} />}</For> <For each={store.files}>{(file) => <FileLink file={file} />}</For>
</p> </p>
</Show> </Show>
@ -246,12 +156,6 @@ export const Sidebar = () => {
{collabUsers()} {collabUsers() === 1 ? 'user' : 'users'} connected {collabUsers()} {collabUsers() === 1 ? 'user' : 'users'} connected
</span> </span>
</Show> </Show>
<Show when={isTauri}>
<Link onClick={() => remote.quit()}>
Quit <Keys keys={[mod, 'q']} />
</Link>
</Show>
*/} */}
</div> </div>
</Show> </Show>

View File

@ -1,30 +1,31 @@
import { openDB } from 'idb' const dbPromise = async () => {
const { openDB } = await import('idb')
const dbPromise = openDB('discours.io', 2, { return openDB('discours.io', 2, {
upgrade(db) { upgrade(db) {
db.createObjectStore('keyval') db.createObjectStore('keyval')
} }
}) })
}
export default { export default {
async get(key: string) { async get(key: string) {
const result = await dbPromise const result = await dbPromise()
return result.get('keyval', key) return result.get('keyval', key)
}, },
async set(key: string, val: string) { async set(key: string, val: string) {
const result = await dbPromise const result = await dbPromise()
return result.put('keyval', val, key) return result.put('keyval', val, key)
}, },
async delete(key: string) { async delete(key: string) {
const result = await dbPromise const result = await dbPromise()
return result.delete('keyval', key) return result.delete('keyval', key)
}, },
async clear() { async clear() {
const result = await dbPromise const result = await dbPromise()
return result.clear('keyval') return result.clear('keyval')
}, },
async keys() { async keys() {
const result = await dbPromise const result = await dbPromise()
return result.getAllKeys('keyval') return result.getAllKeys('keyval')
} }
} }

View File

@ -1,31 +1,18 @@
import './styles/Editor.scss' import './styles/Editor.scss'
import type { EditorView } from 'prosemirror-view' import type { EditorView } from 'prosemirror-view'
import type { EditorState } from 'prosemirror-state' import type { EditorState } from 'prosemirror-state'
import { useState } from './store/context' import { useState } from './store'
import { ProseMirror } from './prosemirror' import { ProseMirror } from './components/ProseMirror'
export default () => { export const Editor = () => {
const [store, ctrl] = useState() const [store, ctrl] = useState()
const onInit = (text: EditorState, editorView: EditorView) => ctrl.setState({ editorView, text }) const onInit = (text: EditorState, editorView: EditorView) => ctrl.setState({ editorView, text })
const onReconfigure = (text: EditorState) => ctrl.setState({ text }) const onReconfigure = (text: EditorState) => ctrl.setState({ text })
const onChange = (text: EditorState) => ctrl.setState({ text, lastModified: new Date() }) const onChange = (text: EditorState) => ctrl.setState({ text, lastModified: new Date() })
// const editorCss = (config) => css``
const style = () => {
if (store.error) {
return `display: none;`
}
if (store.markdown) {
return `white-space: pre-wrap;`
}
return ''
}
return ( return (
<ProseMirror <ProseMirror
class="editor" class="editor"
style={style()} style={store.markdown && `white-space: pre-wrap;`}
editorView={store.editorView} editorView={store.editorView}
text={store.text} text={store.text}
extensions={store.extensions} extensions={store.extensions}

View File

@ -6,7 +6,7 @@ import { history } from 'prosemirror-history'
import { dropCursor } from 'prosemirror-dropcursor' import { dropCursor } from 'prosemirror-dropcursor'
import { buildKeymap } from 'prosemirror-example-setup' import { buildKeymap } from 'prosemirror-example-setup'
import { keymap } from 'prosemirror-keymap' import { keymap } from 'prosemirror-keymap'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const plainSchema = new Schema({ const plainSchema = new Schema({
nodes: { nodes: {

View File

@ -4,7 +4,7 @@ import type { EditorState, Transaction } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view' import type { EditorView } from 'prosemirror-view'
import { keymap } from 'prosemirror-keymap' import { keymap } from 'prosemirror-keymap'
import { markInputRule } from './mark-input-rule' import { markInputRule } from './mark-input-rule'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const blank = '\u00A0' const blank = '\u00A0'
@ -12,21 +12,18 @@ const onArrow =
(dir: 'left' | 'right') => (dir: 'left' | 'right') =>
(state: EditorState, dispatch: (tr: Transaction) => void, editorView: EditorView) => { (state: EditorState, dispatch: (tr: Transaction) => void, editorView: EditorView) => {
if (!state.selection.empty) return false if (!state.selection.empty) return false
const $pos = state.selection.$head const $pos = state.selection.$head
const isCode = $pos.marks().find((m: Mark) => m.type.name === 'code') const isCode = $pos.marks().find((m: Mark) => m.type.name === 'code')
const tr = state.tr const tr = state.tr
if (dir === 'left') { if (dir === 'left') {
const up = editorView.endOfTextblock('up') const up = editorView.endOfTextblock('up')
if (!$pos.nodeBefore && up && isCode) { if (!$pos.nodeBefore && up && isCode) {
tr.insertText(blank, $pos.pos - 1, $pos.pos) tr.insertText(blank, $pos.pos - 1, $pos.pos)
dispatch(tr) dispatch(tr)
} }
} else { } else {
const down = editorView.endOfTextblock('down') const down = editorView.endOfTextblock('down')
if (!$pos.nodeAfter && down && isCode) { if (!$pos.nodeAfter && down && isCode) {
tr.insertText(blank, $pos.pos, $pos.pos + 1) tr.insertText(blank, $pos.pos, $pos.pos + 1)
dispatch(tr) dispatch(tr)
@ -39,7 +36,7 @@ const codeKeymap = {
ArrowRight: onArrow('right') ArrowRight: onArrow('right')
} }
const codeRule = (nodeType: MarkType) => markInputRule(/`([^`]+)`$/, nodeType) const codeRule = (nodeType: MarkType) => markInputRule(/`([^`]+)`$/, nodeType, null)
export default (): ProseMirrorExtension => ({ export default (): ProseMirrorExtension => ({
plugins: (prev, schema) => [ plugins: (prev, schema) => [

View File

@ -1,28 +1,24 @@
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror' import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'
import type { ProseMirrorExtension } from '../../store/state' import type { YOptions } from '../../store'
import type { PeerData } from '../../store/context' import type { ProseMirrorExtension } from '../helpers'
export const cursorBuilder = (user: { export const cursorBuilder = (user: any): HTMLElement => {
name: string
foreground: string
background: string
}): HTMLElement => {
const cursor = document.createElement('span') const cursor = document.createElement('span')
const userDiv = document.createElement('span')
cursor.classList.add('ProseMirror-yjs-cursor') cursor.classList.add('ProseMirror-yjs-cursor')
cursor.setAttribute('style', `border-color: ${user.background}`) cursor.setAttribute('style', `border-color: ${user.background}`)
const userDiv = document.createElement('span')
userDiv.setAttribute('style', `background-color: ${user.background}; color: ${user.foreground}`) userDiv.setAttribute('style', `background-color: ${user.background}; color: ${user.foreground}`)
userDiv.textContent = user.name userDiv.textContent = user.name
cursor.append(userDiv) cursor.append(userDiv)
return cursor return cursor
} }
export default (y: PeerData): ProseMirrorExtension => ({ export default (y: YOptions): ProseMirrorExtension => ({
plugins: (prev) => plugins: (prev) =>
y y
? [ ? [
...prev, ...prev,
ySyncPlugin(y.payload), ySyncPlugin(y.type),
yCursorPlugin(y.provider.awareness, { cursorBuilder }), yCursorPlugin(y.provider.awareness, { cursorBuilder }),
yUndoPlugin() yUndoPlugin()
] ]

View File

@ -1,6 +1,6 @@
import { Plugin, NodeSelection } from 'prosemirror-state' import { Plugin, NodeSelection } from 'prosemirror-state'
import { DecorationSet, Decoration } from 'prosemirror-view' import { DecorationSet, Decoration } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const handleIcon = ` const handleIcon = `
<svg viewBox="0 0 10 10" height="14" width="14"> <svg viewBox="0 0 10 10" height="14" width="14">
@ -9,14 +9,11 @@ const handleIcon = `
const createDragHandle = () => { const createDragHandle = () => {
const handle = document.createElement('span') const handle = document.createElement('span')
handle.setAttribute('contenteditable', 'false') handle.setAttribute('contenteditable', 'false')
const icon = document.createElement('span') const icon = document.createElement('span')
icon.innerHTML = handleIcon icon.innerHTML = handleIcon
handle.append(icon) handle.appendChild(icon)
handle.classList.add('handle') handle.classList.add('handle')
return handle return handle
} }
@ -24,7 +21,6 @@ const handlePlugin = new Plugin({
props: { props: {
decorations(state) { decorations(state) {
const decos = [] const decos = []
state.doc.forEach((node, pos) => { state.doc.forEach((node, pos) => {
decos.push( decos.push(
Decoration.widget(pos + 1, createDragHandle), Decoration.widget(pos + 1, createDragHandle),
@ -39,15 +35,12 @@ const handlePlugin = new Plugin({
handleDOMEvents: { handleDOMEvents: {
mousedown: (editorView, event) => { mousedown: (editorView, event) => {
const target = event.target as Element const target = event.target as Element
if (target.classList.contains('handle')) { if (target.classList.contains('handle')) {
const pos = editorView.posAtCoords({ left: event.x, top: event.y }) const pos = editorView.posAtCoords({ left: event.x, top: event.y })
const resolved = editorView.state.doc.resolve(pos.pos) const resolved = editorView.state.doc.resolve(pos.pos)
const tr = editorView.state.tr const tr = editorView.state.tr
tr.setSelection(NodeSelection.create(editorView.state.doc, resolved.before())) tr.setSelection(NodeSelection.create(editorView.state.doc, resolved.before()))
editorView.dispatch(tr) editorView.dispatch(tr)
return false return false
} }
} }

View File

@ -1,15 +1,14 @@
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
import type { Node, Schema } from 'prosemirror-model' import type { Node, Schema } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view' import type { EditorView } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const REGEX = /^!\[([^[\]]*)]\((.+?)\)\s+/ const REGEX = /^!\[([^[\]]*?)]\((.+?)\)\s+/
const MAX_MATCH = 500 const MAX_MATCH = 500
const isUrl = (str: string) => { const isUrl = (str: string) => {
try { try {
const url = new URL(str) const url = new URL(str)
return url.protocol === 'http:' || url.protocol === 'https:' return url.protocol === 'http:' || url.protocol === 'https:'
} catch { } catch {
return false return false
@ -17,64 +16,34 @@ const isUrl = (str: string) => {
} }
const isBlank = (text: string) => text === ' ' || text === '\u00A0' const isBlank = (text: string) => text === ' ' || text === '\u00A0'
/*
export const getImagePath = async (src: string, path?: string) => {
let paths = [src]
if (path) paths = [await dirname(path), src] const imageInput = (schema: Schema, path?: string) =>
const absolutePath = await resolvePath(paths)
return convertFileSrc(absolutePath)
}
*/
const imageInput = (schema: Schema, _path?: string) =>
new Plugin({ new Plugin({
props: { props: {
handleTextInput(view, from, to, text) { handleTextInput(view, from, to, text) {
if (view.composing || !isBlank(text)) return false if (view.composing || !isBlank(text)) return false
const $from = view.state.doc.resolve(from) const $from = view.state.doc.resolve(from)
if ($from.parent.type.spec.code) return false if ($from.parent.type.spec.code) return false
const textBefore = const textBefore =
$from.parent.textBetween( $from.parent.textBetween(
Math.max(0, $from.parentOffset - MAX_MATCH), Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset, $from.parentOffset,
undefined, null,
'\uFFFC' '\uFFFC'
) + text ) + text
const match = REGEX.exec(textBefore) const match = REGEX.exec(textBefore)
if (match) { if (match) {
const [, title, src] = match const [, title, src] = match
if (isUrl(src)) { if (isUrl(src)) {
const node = schema.node('image', { src, title }) const node = schema.node('image', { src, title })
const start = from - (match[0].length - text.length) const start = from - (match[0].length - text.length)
const tr = view.state.tr const tr = view.state.tr
tr.delete(start, to) tr.delete(start, to)
tr.insert(start, node) tr.insert(start, node)
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
// if (!isTauri) return false
/*
getImagePath(src, path).then((p) => {
const node = schema.node('image', { src: p, title, path: src })
const start = from - (match[0].length - text.length)
const tr = view.state.tr
tr.delete(start, to)
tr.insert(start, node)
view.dispatch(tr)
})
*/
return false return false
} }
} }
@ -95,11 +64,11 @@ const imageSchema = {
parseDOM: [ parseDOM: [
{ {
tag: 'img[src]', tag: 'img[src]',
getAttrs: (dom: HTMLElement) => ({ getAttrs: (dom: Element) => ({
src: dom.getAttribute('src'), src: dom.getAttribute('src'),
title: dom.getAttribute('title'), title: dom.getAttribute('title'),
alt: dom.getAttribute('alt'), alt: dom.getAttribute('alt'),
path: dom.dataset.path path: (dom as any).dataset.path
}) })
} }
], ],
@ -118,14 +87,10 @@ export const insertImage = (view: EditorView, src: string, left: number, top: nu
const state = view.state const state = view.state
const tr = state.tr const tr = state.tr
const node = state.schema.nodes.image.create({ src }) const node = state.schema.nodes.image.create({ src })
if (view) {
const pos = view.posAtCoords({ left, top }).pos const pos = view.posAtCoords({ left, top }).pos
tr.insert(pos, node) tr.insert(pos, node)
view.dispatch(tr) view.dispatch(tr)
} }
}
class ImageView { class ImageView {
node: Node node: Node
@ -136,12 +101,12 @@ class ImageView {
contentDOM: Element contentDOM: Element
container: HTMLElement container: HTMLElement
handle: HTMLElement handle: HTMLElement
onResizeFn: (e: Event) => void onResizeFn: any
onResizeEndFn: (e: Event) => void onResizeEndFn: any
width: number width: number
updating: number updating: number
constructor(node: Node, view: EditorView, getPos: () => number, schema: Schema, _path: string) { constructor(node: Node, view: EditorView, getPos: () => number, schema: Schema, path: string) {
this.node = node this.node = node
this.view = view this.view = view
this.getPos = getPos this.getPos = getPos
@ -151,23 +116,11 @@ class ImageView {
this.container = document.createElement('span') this.container = document.createElement('span')
this.container.className = 'image-container' this.container.className = 'image-container'
if (node.attrs.width) this.setWidth(node.attrs.width) if (node.attrs.width) this.setWidth(node.attrs.width)
const image = document.createElement('img') const image = document.createElement('img')
image.setAttribute('title', node.attrs.title ?? '') image.setAttribute('title', node.attrs.title ?? '')
if (
// isTauri &&
!node.attrs.src.startsWith('asset:') &&
!node.attrs.src.startsWith('data:') &&
!isUrl(node.attrs.src)
) {
// getImagePath(node.attrs.src, path).then((p) => image.setAttribute('src', p))
} else {
image.setAttribute('src', node.attrs.src) image.setAttribute('src', node.attrs.src)
}
this.handle = document.createElement('span') this.handle = document.createElement('span')
this.handle.className = 'resize-handle' this.handle.className = 'resize-handle'
@ -177,8 +130,8 @@ class ImageView {
window.addEventListener('mouseup', this.onResizeEndFn) window.addEventListener('mouseup', this.onResizeEndFn)
}) })
this.container.append(image) this.container.appendChild(image)
this.container.append(this.handle) this.container.appendChild(this.handle)
this.dom = this.container this.dom = this.container
} }
@ -189,12 +142,9 @@ class ImageView {
onResizeEnd() { onResizeEnd() {
window.removeEventListener('mousemove', this.onResizeFn) window.removeEventListener('mousemove', this.onResizeFn)
if (this.updating === this.width) return if (this.updating === this.width) return
this.updating = this.width this.updating = this.width
const tr = this.view.state.tr const tr = this.view.state.tr
tr.setNodeMarkup(this.getPos(), undefined, { tr.setNodeMarkup(this.getPos(), undefined, {
...this.node.attrs, ...this.node.attrs,
width: this.width width: this.width
@ -204,7 +154,7 @@ class ImageView {
} }
setWidth(width: number) { setWidth(width: number) {
this.container.style.width = `${width}px` this.container.style.width = width + 'px'
} }
} }
@ -215,9 +165,6 @@ export default (path?: string): ProseMirrorExtension => ({
}), }),
plugins: (prev, schema) => [...prev, imageInput(schema, path)], plugins: (prev, schema) => [...prev, imageInput(schema, path)],
nodeViews: { nodeViews: {
// FIXME something is not right
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
image: (node, view, getPos) => { image: (node, view, getPos) => {
return new ImageView(node, view, getPos, view.state.schema, path) return new ImageView(node, view, getPos, view.state.schema, path)
} }

View File

@ -1 +0,0 @@
declare module 'prosemirror-example-setup'

View File

@ -1,7 +1,7 @@
import { Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state' import { Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view' import type { EditorView } from 'prosemirror-view'
import type { Mark, Node, ResolvedPos, Schema } from 'prosemirror-model' import type { Mark, Node, Schema } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const REGEX = /(^|\s)\[(.+)]\(([^ ]+)(?: "(.+)")?\)/ const REGEX = /(^|\s)\[(.+)]\(([^ ]+)(?: "(.+)")?\)/
@ -13,14 +13,52 @@ const findMarkPosition = (mark: Mark, doc: Node, from: number, to: number) => {
markPos = { from: pos, to: pos + Math.max(node.textContent.length, 1) } markPos = { from: pos, to: pos + Math.max(node.textContent.length, 1) }
} }
}) })
return markPos return markPos
} }
const pluginKey = new PluginKey('markdown-links') const pluginKey = new PluginKey('markdown-links')
const resolvePos = (view: EditorView, pos: number) => view.state?.doc?.resolve(pos) const markdownLinks = (schema: Schema) =>
new Plugin({
key: pluginKey,
state: {
init() {
return { schema }
},
apply(tr, state) {
const action = tr.getMeta(this)
if (action?.pos) {
(state as any).pos = action.pos
}
return state
}
},
props: {
handleDOMEvents: {
keyup: (view) => {
return handleMove(view)
},
click: (view, e) => {
if (handleMove(view)) {
e.preventDefault()
}
return true
}
}
}
})
const resolvePos = (view: EditorView, pos: number) => {
try {
return view.state.doc.resolve(pos)
} catch {
// ignore
}
}
// FIXME
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
const toLink = (view: EditorView, tr: Transaction) => { const toLink = (view: EditorView, tr: Transaction) => {
const sel = view.state.selection const sel = view.state.selection
@ -32,32 +70,42 @@ const toLink = (view: EditorView, tr: Transaction) => {
if (!$from || $from.depth === 0 || $from.parent.type.spec.code) { if (!$from || $from.depth === 0 || $from.parent.type.spec.code) {
return false return false
} }
const lineFrom = $from.before() const lineFrom = $from.before()
const lineTo = $from.after() const lineTo = $from.after()
const line = view.state.doc.textBetween(lineFrom, lineTo, '\0', '\0') const line = view.state.doc.textBetween(lineFrom, lineTo, '\0', '\0')
const match = REGEX.exec(line) const match = REGEX.exec(line)
if (match) { if (match) {
const [full, , text, href] = match const [full, , text, href] = match
const spaceLeft = full.indexOf(text) - 1 const spaceLeft = full.indexOf(text) - 1
const spaceRight = full.length - text.length - href.length - spaceLeft - 4 const spaceRight = full.length - text.length - href.length - spaceLeft - 4
const start = match.index + $from.start() + spaceLeft const start = match.index + $from.start() + spaceLeft
const end = start + full.length - spaceLeft - spaceRight const end = start + full.length - spaceLeft - spaceRight
if (sel.$from.pos >= start && sel.$from.pos <= end) { if (sel.$from.pos >= start && sel.$from.pos <= end) {
return false return false
} }
// Do not convert md links if content has marks // Do not convert md links if content has marks
const $startPos = resolvePos(view, start) const $startPos = resolvePos(view, start)
if (($startPos as ResolvedPos).marks().length > 0) { if ($startPos.marks().length > 0) {
return false return false
} }
const textStart = start + 1 const textStart = start + 1
const textEnd = textStart + text.length const textEnd = textStart + text.length
if (textEnd < end) tr.delete(textEnd, end) if (textEnd < end) tr.delete(textEnd, end)
if (textStart > start) tr.delete(start, textStart) if (textStart > start) tr.delete(start, textStart)
const to = start + text.length const to = start + text.length
tr.addMark(start, to, state.schema.marks.link.create({ href })) tr.addMark(start, to, state.schema.marks.link.create({ href }))
const sub = end - textEnd + textStart - start const sub = end - textEnd + textStart - start
tr.setMeta(pluginKey, { pos: sel.$head.pos - sub }) tr.setMeta(pluginKey, { pos: sel.$head.pos - sub })
return true return true
} }
} }
@ -68,7 +116,10 @@ const toLink = (view: EditorView, tr: Transaction) => {
const toMarkdown = (view: EditorView, tr: Transaction) => { const toMarkdown = (view: EditorView, tr: Transaction) => {
const { schema } = pluginKey.getState(view.state) const { schema } = pluginKey.getState(view.state)
const sel = view.state.selection const sel = view.state.selection
if (sel.$head.depth === 0 || sel.$head.parent.type.spec.code) return false if (sel.$head.depth === 0 || sel.$head.parent.type.spec.code) {
return false
}
const mark = schema.marks.link.isInSet(sel.$head.marks()) const mark = schema.marks.link.isInSet(sel.$head.marks())
const textFrom = sel.$head.pos - sel.$head.textOffset const textFrom = sel.$head.pos - sel.$head.textOffset
const textTo = sel.$head.after() const textTo = sel.$head.after()
@ -91,52 +142,22 @@ const handleMove = (view: EditorView) => {
if (!sel.empty || !sel.$head) return false if (!sel.empty || !sel.$head) return false
const pos = sel.$head.pos const pos = sel.$head.pos
const tr = view.state.tr const tr = view.state.tr
if (toLink(view, tr)) { if (toLink(view, tr)) {
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
if (toMarkdown(view, tr)) { if (toMarkdown(view, tr)) {
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
tr.setMeta(pluginKey, { pos }) tr.setMeta(pluginKey, { pos })
view.dispatch(tr) view.dispatch(tr)
return false return false
} }
const markdownLinks = (schema: Schema) =>
new Plugin({
key: pluginKey,
state: {
init() {
return { schema }
},
apply(tr, state) {
const action = tr.getMeta(this)
if (action?.pos) {
// FIXME
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
state.pos = action.pos
}
return state
}
},
props: {
handleDOMEvents: {
keyup: (view) => {
return handleMove(view)
},
click: (view, e) => {
if (handleMove(view)) {
e.preventDefault()
}
return true
}
}
}
})
export default (): ProseMirrorExtension => ({ export default (): ProseMirrorExtension => ({
plugins: (prev, schema) => [...prev, markdownLinks(schema)] plugins: (prev, schema) => [...prev, markdownLinks(schema)]
}) })

View File

@ -2,24 +2,30 @@ import { InputRule } from 'prosemirror-inputrules'
import type { EditorState } from 'prosemirror-state' import type { EditorState } from 'prosemirror-state'
import type { MarkType } from 'prosemirror-model' import type { MarkType } from 'prosemirror-model'
export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs?) => export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs) =>
// FIXME ? new InputRule(regexp, (state: EditorState, match: string[], start: number, end: number) => {
new InputRule(regexp, (state: EditorState, match: string[], start: number, endArg: number) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs
const tr = state.tr const tr = state.tr
let end = endArg
if (match[1]) { if (match[1]) {
const textStart = start + match[0].indexOf(match[1]) const textStart = start + match[0].indexOf(match[1])
const textEnd = textStart + match[1].length const textEnd = textStart + match[1].length
let hasMarks = false let hasMarks = false
state.doc.nodesBetween(textStart, textEnd, (node) => { state.doc.nodesBetween(textStart, textEnd, (node) => {
hasMarks = node.marks.length > 0 if (node.marks.length > 0) {
hasMarks = true
}
}) })
if (hasMarks) return
if (hasMarks) {
return
}
if (textEnd < end) tr.delete(textEnd, end) if (textEnd < end) tr.delete(textEnd, end)
if (textStart > start) tr.delete(start, textStart) if (textStart > start) tr.delete(start, textStart)
// eslint-disable-next-line no-param-reassign
end = start + match[1].length end = start + match[1].length
} }
tr.addMark(start, end, nodeType.create(attrs)) tr.addMark(start, end, nodeType.create(attrs))
tr.removeStoredMark(nodeType) tr.removeStoredMark(nodeType)
return tr return tr

View File

@ -7,7 +7,7 @@ import {
ellipsis ellipsis
} from 'prosemirror-inputrules' } from 'prosemirror-inputrules'
import type { NodeType, Schema } from 'prosemirror-model' import type { NodeType, Schema } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const blockQuoteRule = (nodeType: NodeType) => wrappingInputRule(/^\s*>\s$/, nodeType) const blockQuoteRule = (nodeType: NodeType) => wrappingInputRule(/^\s*>\s$/, nodeType)
@ -16,9 +16,7 @@ const orderedListRule = (nodeType: NodeType) =>
/^(\d+)\.\s$/, /^(\d+)\.\s$/,
nodeType, nodeType,
(match) => ({ order: +match[1] }), (match) => ({ order: +match[1] }),
// FIXME (match, node) => node.childCount + node.attrs.order === +match[1]
// eslint-disable-next-line eqeqeq
(match, node) => node.childCount + node.attrs.order == +match[1]
) )
const bulletListRule = (nodeType: NodeType) => wrappingInputRule(/^\s*([*+-])\s$/, nodeType) const bulletListRule = (nodeType: NodeType) => wrappingInputRule(/^\s*([*+-])\s$/, nodeType)

View File

@ -19,7 +19,7 @@ import { wrapInList } from 'prosemirror-schema-list'
import { NodeSelection } from 'prosemirror-state' import { NodeSelection } from 'prosemirror-state'
import { TextField, openPrompt } from './prompt' import { TextField, openPrompt } from './prompt'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
import type { Schema } from 'prosemirror-model' import type { Schema } from 'prosemirror-model'
// Helpers to create specific types of items // Helpers to create specific types of items

View File

@ -1,86 +1,74 @@
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
// import { Fragment, Node, Schema } from 'prosemirror-model' import { Fragment, Node, Schema, Slice } from 'prosemirror-model'
import type { Schema } from 'prosemirror-model' import type { ProseMirrorExtension } from '../helpers'
import type { ProseMirrorExtension } from '../../store/state' import { createMarkdownParser } from '../markdown'
// import { createMarkdownParser } from '../markdown'
// const URL_REGEX = /(ftp|http|https):\/\/(\w+(?::\w*)?@)?(\S+)(:\d+)?(\/|\/([\w!#%&+./:=?@-]))?/g const URL_REGEX = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:\d+)?(\/|\/([\w!#%&+./:=?@-]))?/g
// const transform = (schema: Schema, fragment: Fragment) => { const transform = (schema: Schema, fragment: Fragment) => {
// const nodes: Node[] = [] const nodes = []
// fragment.forEach((child: Node) => {
// fragment.forEach((child: Node) => { if (child.isText) {
// if (child.isText) { let pos = 0
// let pos = 0 let match
// let match: RegExpMatchArray | null
//
// while ((match = URL_REGEX.exec(child.text as string)) !== null) {
// const start = match.index as number
// const end = start + match[0].length
// const attrs = { href: match[0] }
//
// if (start > 0) {
// nodes.push(child.cut(pos, start))
// }
//
// const node = child.cut(start, end).mark(schema.marks.link.create(attrs).addToSet(child.marks))
//
// nodes.push(node)
// pos = end
// }
//
// if (pos < (child.text as string).length) {
// nodes.push(child.cut(pos))
// }
// } else {
// nodes.push(child.copy(transform(schema, child.content)))
// }
// })
//
// return Fragment.fromArray(nodes)
// }
// let shiftKey = false while ((match = URL_REGEX.exec(child.text)) !== null) {
const start = match.index
const end = start + match[0].length
const attrs = { href: match[0] }
const pasteMarkdown = (_schema: Schema) => { if (start > 0) {
// const parser = createMarkdownParser(schema) nodes.push(child.cut(pos, start))
}
const node = child.cut(start, end).mark(schema.marks.link.create(attrs).addToSet(child.marks))
nodes.push(node)
pos = end
}
if (pos < child.text.length) {
nodes.push(child.cut(pos))
}
} else {
nodes.push(child.copy(transform(schema, child.content)))
}
})
return Fragment.fromArray(nodes)
}
let shiftKey = false
const pasteMarkdown = (schema: Schema) => {
const parser = createMarkdownParser(schema)
return new Plugin({ return new Plugin({
props: { props: {
handleDOMEvents: { handleDOMEvents: {
keydown: (_, _event) => { keydown: (_, event) => {
// shiftKey = event.shiftKey shiftKey = event.shiftKey
return false return false
}, },
keyup: () => { keyup: () => {
// shiftKey = false shiftKey = false
return false return false
} }
}, },
handlePaste: (view, event) => { handlePaste: (view, event) => {
if (!event.clipboardData) return false if (!event.clipboardData) return false
const text = event.clipboardData.getData('text/plain') const text = event.clipboardData.getData('text/plain')
const html = event.clipboardData.getData('text/html') const html = event.clipboardData.getData('text/html')
// otherwise, if we have html then fallback to the default HTML // otherwise, if we have html then fallback to the default HTML
// parser behavior that comes with Prosemirror. // parser behavior that comes with Prosemirror.
if (text.length === 0 || html) return false if (text.length === 0 || html) return false
event.preventDefault() event.preventDefault()
// const paste = parser.parse(text) const paste = parser.parse(text)
const slice = paste as any
// FIXME !!!!!!!!!!!!!!!!!!!!!!!!!!!!!! const fragment = shiftKey ? slice.content : transform(schema, slice.content)
// paste is Node why ...paste? const tr = view.state.tr.replaceSelection(new Slice(fragment, slice.openStart, slice.openEnd))
// const slice = [...paste]
// const fragment = shiftKey ? slice.content : transform(schema, slice.content)
// const tr = view.state.tr.replaceSelection(new Slice(fragment, slice.openStart, slice.openEnd))
//
// view.dispatch(tr)
view.dispatch(tr)
return true return true
} }
} }

View File

@ -1,6 +1,6 @@
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
import { DecorationSet, Decoration } from 'prosemirror-view' import { DecorationSet, Decoration } from 'prosemirror-view'
import { ProseMirrorExtension, isEmpty } from '../../store/state' import { isEmpty, ProseMirrorExtension } from '../helpers'
const placeholder = (text: string) => const placeholder = (text: string) =>
new Plugin({ new Plugin({
@ -8,7 +8,6 @@ const placeholder = (text: string) =>
decorations(state) { decorations(state) {
if (isEmpty(state)) { if (isEmpty(state)) {
const div = document.createElement('div') const div = document.createElement('div')
div.setAttribute('contenteditable', 'false') div.setAttribute('contenteditable', 'false')
div.classList.add('placeholder') div.classList.add('placeholder')
div.textContent = text div.textContent = text

View File

@ -1,43 +1,51 @@
const prefix = 'ProseMirror-prompt' const prefix = 'ProseMirror-prompt'
// FIXME !!!
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
export function openPrompt(options) { export function openPrompt(options: any) {
const domFields = []
const submitButton = document.createElement('button')
const cancelButton = document.createElement('button')
const wrapper = document.body.appendChild(document.createElement('div')) const wrapper = document.body.appendChild(document.createElement('div'))
const form = wrapper.appendChild(document.createElement('form'))
const buttons = form.appendChild(document.createElement('div'))
const box = wrapper.getBoundingClientRect()
wrapper.className = prefix wrapper.className = prefix
const mouseOutside = (e: MouseEvent) => {
if (!wrapper.contains(e.target as Node)) close() const mouseOutside = (e: any) => {
if (!wrapper.contains(e.target)) close()
} }
setTimeout(() => window.addEventListener('mousedown', mouseOutside), 50) // FIXME setTimeout(() => window.addEventListener('mousedown', mouseOutside), 50)
const close = () => { const close = () => {
window.removeEventListener('mousedown', mouseOutside) window.removeEventListener('mousedown', mouseOutside)
if (wrapper.parentNode) wrapper.remove() if (wrapper.parentNode) wrapper.remove()
} }
options.fields.forEach((name) => domFields.push(options.fields[name].render()))
const domFields: any = []
options.fields.forEach((name) => {
domFields.push(options.fields[name].render())
})
const submitButton = document.createElement('button')
submitButton.type = 'submit' submitButton.type = 'submit'
submitButton.className = prefix + '-submit' submitButton.className = prefix + '-submit'
submitButton.textContent = 'OK' submitButton.textContent = 'OK'
const cancelButton = document.createElement('button')
cancelButton.type = 'button' cancelButton.type = 'button'
cancelButton.className = prefix + '-cancel' cancelButton.className = prefix + '-cancel'
cancelButton.textContent = 'Cancel' cancelButton.textContent = 'Cancel'
cancelButton.addEventListener('click', close) cancelButton.addEventListener('click', close)
const form = wrapper.appendChild(document.createElement('form'))
if (options.title) { if (options.title) {
const headel = form.appendChild(document.createElement('h5')) form.appendChild(document.createElement('h5')).textContent = options.title
headel.textContent = options.title
} }
domFields.forEach((fld) => form.appendChild(document.createElement('div')).append(fld)) domFields.forEach((field: any) => {
form.appendChild(document.createElement('div')).appendChild(field)
})
const buttons = form.appendChild(document.createElement('div'))
buttons.className = prefix + '-buttons' buttons.className = prefix + '-buttons'
buttons.append(submitButton) buttons.appendChild(submitButton)
buttons.append(document.createTextNode(' ')) buttons.appendChild(document.createTextNode(' '))
buttons.append(cancelButton) buttons.appendChild(cancelButton)
const box = wrapper.getBoundingClientRect()
wrapper.style.top = (window.innerHeight - box.height) / 2 + 'px' wrapper.style.top = (window.innerHeight - box.height) / 2 + 'px'
wrapper.style.left = (window.innerWidth - box.width) / 2 + 'px' wrapper.style.left = (window.innerWidth - box.width) / 2 + 'px'
const submit = () => { const submit = () => {
const params = getValues(options.fields, domFields) const params = getValues(options.fields, domFields)
if (params) { if (params) {
@ -45,15 +53,18 @@ export function openPrompt(options) {
options.callback(params) options.callback(params)
} }
} }
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
e.preventDefault() e.preventDefault()
submit() submit()
}) })
form.addEventListener('keydown', (e) => { form.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
close() close()
} else if (e.key === 'Enter' && !(e.ctrlKey || e.metaKey || e.shiftKey)) { // eslint-disable-next-line unicorn/prefer-keyboard-event-key
} else if (e.keyCode === 13 && !(e.ctrlKey || e.metaKey || e.shiftKey)) {
e.preventDefault() e.preventDefault()
submit() submit()
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
@ -62,11 +73,12 @@ export function openPrompt(options) {
}, 500) }, 500)
} }
}) })
const input = form.elements[0] as HTMLInputElement
const input: any = form.elements[0]
if (input) input.focus() if (input) input.focus()
} }
function getValues(fields, domFields) { function getValues(fields: any, domFields: any) {
const result = Object.create(null) const result = Object.create(null)
let i = 0 let i = 0
fields.forEarch((name) => { fields.forEarch((name) => {
@ -83,23 +95,24 @@ function getValues(fields, domFields) {
return result return result
} }
function reportInvalid(dom, message) { function reportInvalid(dom: any, message: any) {
const parent = dom.parentNode const parent = dom.parentNode
const msg = parent.appendChild(document.createElement('div')) const msg = parent.appendChild(document.createElement('div'))
msg.style.left = dom.offsetLeft + dom.offsetWidth + 2 + 'px' msg.style.left = dom.offsetLeft + dom.offsetWidth + 2 + 'px'
msg.style.top = dom.offsetTop - 5 + 'px' msg.style.top = dom.offsetTop - 5 + 'px'
msg.className = 'ProseMirror-invalid' msg.className = 'ProseMirror-invalid'
msg.textContent = message msg.textContent = message
setTimeout(() => msg.remove(), 1500) // eslint-disable-next-line unicorn/prefer-dom-node-remove
setTimeout(() => parent.removeChild(msg), 1500)
} }
export class Field { export class Field {
options: { required: boolean; validate; clean; label: string; value: string } options: any
constructor(options) { constructor(options: any) {
this.options = options this.options = options
} }
read(dom) { read(dom: any) {
return dom.value return dom.value
} }
// :: (any) → ?string // :: (any) → ?string
@ -108,12 +121,13 @@ export class Field {
return typeof _value === typeof '' return typeof _value === typeof ''
} }
validate(value) { validate(value: any) {
if (!value && this.options.required) return 'Required field' if (!value && this.options.required) return 'Required field'
return this.validateType(value) || (this.options.validate && this.options.validate(value)) return this.validateType(value) || (this.options.validate && this.options.validate(value))
} }
clean(value) { clean(value: any) {
return this.options.clean ? this.options.clean(value) : value return this.options.clean ? this.options.clean(value) : value
} }
} }
@ -121,6 +135,7 @@ export class Field {
export class TextField extends Field { export class TextField extends Field {
render() { render() {
const input: HTMLInputElement = document.createElement('input') const input: HTMLInputElement = document.createElement('input')
input.type = 'text' input.type = 'text'
input.placeholder = this.options.label input.placeholder = this.options.label
input.value = this.options.value || '' input.value = this.options.value || ''
@ -128,3 +143,16 @@ export class TextField extends Field {
return input return input
} }
} }
export class SelectField extends Field {
render() {
const select = document.createElement('select')
this.options.options.forEach((o: { value: string; label: string }) => {
const opt = select.appendChild(document.createElement('option'))
opt.value = o.value
opt.selected = o.value === this.options.value
opt.label = o.label
})
return select
}
}

View File

@ -1,6 +1,6 @@
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view' import type { EditorView } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const scroll = (view: EditorView) => { const scroll = (view: EditorView) => {
if (!view.state.selection.empty) return false if (!view.state.selection.empty) return false

View File

@ -1,51 +1,39 @@
import { /*MenuItem,*/ MenuItem, renderGrouped } from 'prosemirror-menu' import { renderGrouped } from 'prosemirror-menu'
import type { Schema } from 'prosemirror-model' import { Plugin } from 'prosemirror-state'
import { EditorState, Plugin } from 'prosemirror-state' import type { ProseMirrorExtension } from '../helpers'
import type { EditorView } from 'prosemirror-view'
// import { EditorView } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../../store/state'
import { buildMenuItems } from './menu' import { buildMenuItems } from './menu'
const cut = (arr) => arr.filter((a) => !!a)
export class SelectionTooltip { export class SelectionTooltip {
tooltip: HTMLElement tooltip: any
constructor(view: EditorView, schema: Schema) { constructor(view: any, schema: any) {
this.tooltip = document.createElement('div') this.tooltip = document.createElement('div')
this.tooltip.className = 'tooltip' this.tooltip.className = 'tooltip'
view.dom.parentNode.append(this.tooltip) view.dom.parentNode.appendChild(this.tooltip)
const content = cut((buildMenuItems(schema) as { [key: string]: MenuItem })?.fullMenu) const { dom } = renderGrouped(view, (buildMenuItems(schema) as any).fullMenu)
this.tooltip.appendChild(dom)
console.debug(content)
const { dom } = renderGrouped(view, content)
this.tooltip.append(dom)
this.update(view, null) this.update(view, null)
} }
update(view: EditorView, lastState: EditorState) { update(view: any, lastState: any) {
const state = view.state const state = view.state
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) { if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return return
} }
if (state.selection.empty) { if (state.selection.empty) {
this.tooltip.style.display = 'none' this.tooltip.style.display = 'none'
return return
} }
this.tooltip.style.display = '' this.tooltip.style.display = ''
const { from, to } = state.selection const { from, to } = state.selection
const start = view.coordsAtPos(from) const start = view.coordsAtPos(from),
const end = view.coordsAtPos(to) end = view.coordsAtPos(to)
const box = this.tooltip.offsetParent.getBoundingClientRect() const box = this.tooltip.offsetParent.getBoundingClientRect()
const left = Math.max((start.left + end.left) / 2, start.left + 3) const left = Math.max((start.left + end.left) / 2, start.left + 3)
this.tooltip.style.left = left - box.left + 'px'
this.tooltip.style.left = `${left - box.left}px` this.tooltip.style.bottom = box.bottom - (start.top + 15) + 'px'
this.tooltip.style.bottom = `${box.bottom - (start.top + 15)}px`
} }
destroy() { destroy() {
@ -53,9 +41,9 @@ export class SelectionTooltip {
} }
} }
export function toolTip(schema: Schema) { export function toolTip(schema: any) {
return new Plugin({ return new Plugin({
view(editorView: EditorView) { view(editorView: any) {
return new SelectionTooltip(editorView, schema) return new SelectionTooltip(editorView, schema)
} }
}) })

View File

@ -1,9 +1,9 @@
import { inputRules } from 'prosemirror-inputrules' import { inputRules } from 'prosemirror-inputrules'
import type { MarkType } from 'prosemirror-model' import type { MarkType } from 'prosemirror-model'
import { markInputRule } from './mark-input-rule' import { markInputRule } from './mark-input-rule'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const strikethroughRule = (nodeType: MarkType) => markInputRule(/~{2}(.+)~{2}$/, nodeType) const strikethroughRule = (nodeType: MarkType) => markInputRule(/~{2}(.+)~{2}$/, nodeType, null)
const strikethroughSchema = { const strikethroughSchema = {
strikethrough: { strikethrough: {

View File

@ -2,7 +2,7 @@ import { EditorState, Selection } from 'prosemirror-state'
import type { Node, Schema, ResolvedPos } from 'prosemirror-model' import type { Node, Schema, ResolvedPos } from 'prosemirror-model'
import { InputRule, inputRules } from 'prosemirror-inputrules' import { InputRule, inputRules } from 'prosemirror-inputrules'
import { keymap } from 'prosemirror-keymap' import { keymap } from 'prosemirror-keymap'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
export const tableInputRule = (schema: Schema) => export const tableInputRule = (schema: Schema) =>
new InputRule( new InputRule(
@ -176,7 +176,6 @@ export default (): ProseMirrorExtension => ({
...prev, ...prev,
nodes: (prev.nodes as any).append(tableSchema) nodes: (prev.nodes as any).append(tableSchema)
}), }),
// FIXME (extract functions)
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
plugins: (prev, schema) => [ plugins: (prev, schema) => [
keymap({ keymap({

View File

@ -1,9 +1,9 @@
import { DOMSerializer, Node as ProsemirrorNode, NodeType, Schema } from 'prosemirror-model' import { DOMSerializer, Node as ProsemirrorNode, NodeType, Schema } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view' import type { EditorView } from 'prosemirror-view'
import { wrappingInputRule, inputRules } from 'prosemirror-inputrules' import { inputRules, wrappingInputRule } from 'prosemirror-inputrules'
import { splitListItem } from 'prosemirror-schema-list' import { splitListItem } from 'prosemirror-schema-list'
import { keymap } from 'prosemirror-keymap' import { keymap } from 'prosemirror-keymap'
import type { ProseMirrorExtension } from '../../store/state' import type { ProseMirrorExtension } from '../helpers'
const todoListRule = (nodeType: NodeType) => const todoListRule = (nodeType: NodeType) =>
wrappingInputRule(new RegExp('^\\[( |x)]\\s$'), nodeType, (match) => ({ wrappingInputRule(new RegExp('^\\[( |x)]\\s$'), nodeType, (match) => ({
@ -47,7 +47,7 @@ const todoListSchema = {
} }
class TodoItemView { class TodoItemView {
contentDOM: HTMLElement contentDOM: Node
dom: Node dom: Node
view: EditorView view: EditorView
getPos: () => number getPos: () => number
@ -59,7 +59,9 @@ class TodoItemView {
this.contentDOM = res.contentDOM this.contentDOM = res.contentDOM
this.view = view this.view = view
this.getPos = getPos this.getPos = getPos
;(this.dom as Element).querySelector('input').addEventListener('click', this.handleClick.bind(this)) ;(this.dom as Element)
.querySelector('input')
.addEventListener('click', () => this.handleClick.bind(this))
} }
handleClick(e: MouseEvent) { handleClick(e: MouseEvent) {
@ -86,7 +88,7 @@ export default (): ProseMirrorExtension => ({
inputRules({ rules: [todoListRule(schema.nodes.todo_item)] }) inputRules({ rules: [todoListRule(schema.nodes.todo_item)] })
], ],
nodeViews: { nodeViews: {
todo_item: (node, view, getPos) => { todo_item: (node: any, view, getPos) => {
return new TodoItemView(node, view, getPos) return new TodoItemView(node, view, getPos)
} }
} }

View File

@ -2,13 +2,6 @@ import { Plugin, EditorState } from 'prosemirror-state'
import type { Node, Schema, SchemaSpec } from 'prosemirror-model' import type { Node, Schema, SchemaSpec } from 'prosemirror-model'
import type { Decoration, EditorView, NodeView } from 'prosemirror-view' import type { Decoration, EditorView, NodeView } from 'prosemirror-view'
export type NodeViewFn = (
node: Node,
view: EditorView,
getPos: () => number,
decorations: Decoration[]
) => NodeView
export interface ProseMirrorExtension { export interface ProseMirrorExtension {
schema?: (prev: SchemaSpec) => SchemaSpec schema?: (prev: SchemaSpec) => SchemaSpec
plugins?: (prev: Plugin[], schema: Schema) => Plugin[] plugins?: (prev: Plugin[], schema: Schema) => Plugin[]
@ -17,6 +10,13 @@ export interface ProseMirrorExtension {
export type ProseMirrorState = EditorState | unknown export type ProseMirrorState = EditorState | unknown
export type NodeViewFn = (
node: Node,
view: EditorView,
getPos: () => number,
decorations: Decoration[]
) => NodeView
export const isInitialized = (state: EditorState) => state !== undefined && state instanceof EditorState export const isInitialized = (state: EditorState) => state !== undefined && state instanceof EditorState
export const isEmpty = (state: EditorState) => export const isEmpty = (state: EditorState) =>
@ -25,3 +25,5 @@ export const isEmpty = (state: EditorState) =>
!state.doc.firstChild.type.spec.code && !state.doc.firstChild.type.spec.code &&
state.doc.firstChild.isTextblock && state.doc.firstChild.isTextblock &&
state.doc.firstChild.content.size === 0) state.doc.firstChild.content.size === 0)
export const isText = (x) => x && x.doc && x.selection

View File

@ -0,0 +1,46 @@
import { EditorState } from 'prosemirror-state'
import { Schema } from 'prosemirror-model'
import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from './helpers'
export const createEditorState = (
text: ProseMirrorState,
extensions: ProseMirrorExtension[],
prevText?: EditorState
): {
editorState: EditorState
nodeViews: { [key: string]: NodeViewFn }
} => {
const reconfigure = text instanceof EditorState && prevText?.schema
let schemaSpec = { nodes: {} }
let nodeViews = {}
let plugins = []
for (const extension of extensions) {
if (extension.schema) {
schemaSpec = extension.schema(schemaSpec)
}
if (extension.nodeViews) {
nodeViews = { ...nodeViews, ...extension.nodeViews }
}
}
const schema = reconfigure ? prevText.schema : new Schema(schemaSpec)
for (const extension of extensions) {
if (extension.plugins) {
plugins = extension.plugins(plugins, schema)
}
}
let editorState: EditorState
if (reconfigure) {
editorState = text.reconfigure({ schema, plugins } as Partial<EditorState>)
} else if (text instanceof EditorState) {
editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON())
} else if (text) {
console.debug(text)
editorState = EditorState.fromJSON({ schema, plugins }, text)
}
return { editorState, nodeViews }
}

View File

@ -1,102 +0,0 @@
import { createEffect, untrack } from 'solid-js'
import { Store, unwrap } from 'solid-js/store'
import { EditorState, Plugin, Transaction } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { Schema } from 'prosemirror-model'
import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../store/state'
interface ProseMirrorProps {
style?: string
class?: string
text?: Store<ProseMirrorState>
editorView?: Store<EditorView>
extensions?: Store<ProseMirrorExtension[]>
onInit: (s: EditorState, v: EditorView) => void
onReconfigure: (s: EditorState) => void
onChange: (s: EditorState) => void
}
const createEditorState = (
text: ProseMirrorState,
extensions: ProseMirrorExtension[],
prevText?: EditorState
): {
editorState: EditorState
nodeViews: { [key: string]: NodeViewFn }
} => {
const reconfigure = text instanceof EditorState && prevText?.schema
let schemaSpec = { nodes: {} }
let nodeViews = {}
let plugins: Plugin<any>[] = []
for (const extension of extensions) {
if (extension.schema) {
schemaSpec = extension.schema(schemaSpec)
}
if (extension.nodeViews) {
nodeViews = { ...nodeViews, ...extension.nodeViews }
}
}
console.debug('[editor] create state with extensions', extensions)
const schema = reconfigure ? prevText.schema : new Schema(schemaSpec)
for (const extension of extensions) {
if (extension.plugins) {
plugins = extension.plugins(plugins, schema)
}
}
const editorState: EditorState = reconfigure
? text.reconfigure({ plugins })
: EditorState.fromJSON({ schema, plugins }, text as { [key: string]: any })
return { editorState, nodeViews }
}
export const ProseMirror = (props: ProseMirrorProps) => {
let editorRef: HTMLDivElement
const editorView = () => untrack(() => unwrap(props.editorView))
const dispatchTransaction = (tr: Transaction) => {
if (!editorView()) return
const newState = editorView().state.apply(tr)
editorView().updateState(newState)
if (!tr.docChanged) return
props.onChange(newState)
}
createEffect(
(state: [EditorState, ProseMirrorExtension[]]) => {
console.debug('[prosemirror] init editor with extensions', state)
const [prevText, prevExtensions] = state
const text = unwrap(props.text) as EditorState
const extensions: ProseMirrorExtension[] = unwrap(props.extensions)
if (!text || !extensions?.length) return [text, extensions]
if (!props.editorView) {
const { editorState, nodeViews } = createEditorState(text, extensions)
const view = new EditorView(editorRef, {
state: editorState,
nodeViews,
dispatchTransaction
})
view.focus()
props.onInit(editorState, view)
return [editorState, extensions]
}
if (extensions !== prevExtensions || (!(text instanceof EditorState) && text !== prevText)) {
const { editorState, nodeViews } = createEditorState(text, extensions, prevText)
if (!editorState) return
editorView().updateState(editorState)
editorView().setProps({ nodeViews, dispatchTransaction })
props.onReconfigure(editorState)
editorView().focus()
return [editorState, extensions]
}
return [text, extensions]
},
[props.text, props.extensions]
)
return <div style={props.style} ref={editorRef} class={props.class} spell-check={false} />
}

View File

@ -1,63 +1,57 @@
import markdownit from 'markdown-it' import markdownit from 'markdown-it'
import type Token from 'markdown-it/lib/token' import {
import { MarkdownSerializer, MarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown' MarkdownSerializer,
MarkdownParser,
defaultMarkdownSerializer,
MarkdownSerializerState
} from 'prosemirror-markdown'
import type { Node, Schema } from 'prosemirror-model' import type { Node, Schema } from 'prosemirror-model'
import type { EditorState } from 'prosemirror-state' import type { EditorState } from 'prosemirror-state'
function findAlignment(cell: Node): string | null { export const serialize = (state: EditorState) => {
let text = markdownSerializer.serialize(state.doc)
if (text.charAt(text.length - 1) !== '\n') text += '\n'
return text
}
const findAlignment = (cell: Node) => {
const alignment = cell.attrs.style as string const alignment = cell.attrs.style as string
if (!alignment) return null
if (!alignment) {
return null
}
const match = alignment.match(/text-align: ?(left|right|center)/) const match = alignment.match(/text-align: ?(left|right|center)/)
if (match && match[1]) return match[1]
if (match && match[1]) {
return match[1]
}
return null return null
} }
export const markdownSerializer = new MarkdownSerializer( export const markdownSerializer = new MarkdownSerializer(
{ {
...defaultMarkdownSerializer.nodes, ...defaultMarkdownSerializer.nodes,
image(state, node) { image(state: MarkdownSerializerState, node) {
const alt = state.esc(node.attrs.alt || '') const alt = state.esc(node.attrs.alt || '')
const src = node.attrs.path ?? node.attrs.src const src = node.attrs.path ?? node.attrs.src
const title = node.attrs.title || '' // ? state.quote(node.attrs.title) : undefined
// FIXME !!!!!!!!! state.write(`![${alt}](${src}${title ? ' ' + title : ''})\n`)
// const title = node.attrs.title ? state.quote(node.attrs.title) : undefined
const title = node.attrs.title
state.write(`![${alt}](${src}${title || ''})\n`)
}, },
code_block(state, node) { code_block(state, node) {
const src = node.attrs.params.src const src = node.attrs.params.src
if (src) { if (src) {
const title = state.esc(node.attrs.params.title || '') const title = state.esc(node.attrs.params.title || '')
state.write(`![${title}](${src})\n`) state.write(`![${title}](${src})\n`)
return return
} }
state.write(`\`\`\`${node.attrs.params.lang || ''}\n`) state.write('```' + (node.attrs.params.lang || '') + '\n')
state.text(node.textContent, false) state.text(node.textContent, false)
state.ensureNewLine() state.ensureNewLine()
state.write('```') state.write('```')
state.closeBlock(node) state.closeBlock(node)
}, },
todo_item(state, node) { todo_item(state, node) {
state.write(`${node.attrs.done ? '[x]' : '[ ]'} `) state.write((node.attrs.done ? '[x]' : '[ ]') + ' ')
state.renderContent(node) state.renderContent(node)
}, },
table(state, node) { table(state, node) {
function serializeTableHead(head: Node) { function serializeTableHead(head: Node) {
let columnAlignments: string[] = [] let columnAlignments: string[] = []
head.forEach((headRow) => { head.forEach((headRow) => {
if (headRow.type.name === 'table_row') { if (headRow.type.name === 'table_row') {
columnAlignments = serializeTableRow(headRow) columnAlignments = serializeTableRow(headRow)
@ -71,7 +65,6 @@ export const markdownSerializer = new MarkdownSerializer(
state.write('---') state.write('---')
state.write(alignment === 'right' || alignment === 'center' ? ':' : ' ') state.write(alignment === 'right' || alignment === 'center' ? ':' : ' ')
} }
state.write('|') state.write('|')
state.ensureNewLine() state.ensureNewLine()
} }
@ -87,17 +80,14 @@ export const markdownSerializer = new MarkdownSerializer(
function serializeTableRow(row: Node): string[] { function serializeTableRow(row: Node): string[] {
const columnAlignment: string[] = [] const columnAlignment: string[] = []
row.forEach((cell) => { row.forEach((cell) => {
if (cell.type.name === 'table_header' || cell.type.name === 'table_cell') { if (cell.type.name === 'table_header' || cell.type.name === 'table_cell') {
const alignment = serializeTableCell(cell) const alignment = serializeTableCell(cell)
columnAlignment.push(alignment) columnAlignment.push(alignment)
} }
}) })
state.write('|') state.write('|')
state.ensureNewLine() state.ensureNewLine()
return columnAlignment return columnAlignment
} }
@ -105,13 +95,11 @@ export const markdownSerializer = new MarkdownSerializer(
state.write('| ') state.write('| ')
state.renderInline(cell) state.renderInline(cell)
state.write(' ') state.write(' ')
return findAlignment(cell) return findAlignment(cell)
} }
node.forEach((table_child) => { node.forEach((table_child) => {
if (table_child.type.name === 'table_head') serializeTableHead(table_child) if (table_child.type.name === 'table_head') serializeTableHead(table_child)
if (table_child.type.name === 'table_body') serializeTableBody(table_child) if (table_child.type.name === 'table_body') serializeTableBody(table_child)
}) })
@ -130,24 +118,11 @@ export const markdownSerializer = new MarkdownSerializer(
} }
) )
export const serialize = (state: EditorState) => { function listIsTight(tokens, i: number) {
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-param-reassign
let text = markdownSerializer.serialize(state.doc) while (++i < tokens.length) {
if (tokens[i].type !== 'list_item_open') return tokens[i].hidden
if (text.charAt(text.length - 1) !== '\n') {
text += '\n'
} }
return text
}
function listIsTight(tokens: any[], i: number) {
for (let index = i + 1; i < tokens.length; index++) {
if (tokens[index].type !== 'list_item_open') {
return tokens[i].hidden
}
}
return false return false
} }
@ -176,20 +151,18 @@ export const createMarkdownParser = (schema: Schema) =>
list_item: { block: 'list_item' }, list_item: { block: 'list_item' },
bullet_list: { bullet_list: {
block: 'bullet_list', block: 'bullet_list',
getAttrs: (_: Token, tokens: Token[], i: number): Record<string, any> => ({ getAttrs: (_, tokens, i) => ({ tight: listIsTight(tokens, i) })
tight: listIsTight(tokens, i)
})
}, },
ordered_list: { ordered_list: {
block: 'ordered_list', block: 'ordered_list',
getAttrs: (tok: Token, tokens: Token[], i: number): Record<string, any> => ({ getAttrs: (tok, tokens, i) => ({
order: Number(tok.attrGet('start')) || 1, order: +tok.attrGet('start') || 1,
tight: listIsTight(tokens, i) tight: listIsTight(tokens, i)
}) })
}, },
heading: { heading: {
block: 'heading', block: 'heading',
getAttrs: (tok) => ({ level: Number(tok.tag.slice(1)) }) getAttrs: (tok) => ({ level: +tok.tag.slice(1) })
}, },
code_block: { code_block: {
block: 'code_block', block: 'code_block',
@ -203,7 +176,7 @@ export const createMarkdownParser = (schema: Schema) =>
hr: { node: 'horizontal_rule' }, hr: { node: 'horizontal_rule' },
image: { image: {
node: 'image', node: 'image',
getAttrs: (tok: any) => ({ getAttrs: (tok) => ({
src: tok.attrGet('src'), src: tok.attrGet('src'),
title: tok.attrGet('title') || null, title: tok.attrGet('title') || null,
alt: (tok.children[0] && tok.children[0].content) || null alt: (tok.children[0] && tok.children[0].content) || null

View File

@ -1,5 +1,5 @@
import { keymap } from 'prosemirror-keymap' import { keymap } from 'prosemirror-keymap'
import type { ProseMirrorExtension } from '../store/state' import { ProseMirrorExtension } from './helpers'
import { Schema } from 'prosemirror-model' import { Schema } from 'prosemirror-model'
import base from './extension/base' import base from './extension/base'
import markdown from './extension/markdown' import markdown from './extension/markdown'
@ -10,29 +10,27 @@ import code from './extension/code'
import strikethrough from './extension/strikethrough' import strikethrough from './extension/strikethrough'
import placeholder from './extension/placeholder' import placeholder from './extension/placeholder'
// import menu from './extension/menu' // import menu from './extension/menu'
// import image from './extension/image' import image from './extension/image'
import dragHandle from './extension/drag-handle' import dragHandle from './extension/drag-handle'
import pasteMarkdown from './extension/paste-markdown' import pasteMarkdown from './extension/paste-markdown'
import table from './extension/table' import table from './extension/table'
import collab from './extension/collab' import collab from './extension/collab'
import type { Config, PeerData } from '../store/context' import { Config, YOptions } from '../store'
import selectionMenu from './extension/selection' import selectionMenu from './extension/selection'
import type { Command } from 'prosemirror-state'
export interface InitOpts { interface Props {
data?: unknown data?: unknown
keymap?: { [key: string]: Command } keymap?: any
config: Config config: Config
markdown: boolean markdown: boolean
path?: string path?: string
y?: PeerData y?: YOptions
schema?: Schema schema?: Schema
} }
const customKeymap = (opts: InitOpts): ProseMirrorExtension => ({ const customKeymap = (props: Props): ProseMirrorExtension => ({
plugins: (prev) => (opts.keymap ? [...prev, keymap(opts.keymap)] : prev) plugins: (prev) => (props.keymap ? [...prev, keymap(props.keymap)] : prev)
}) })
/* /*
const codeMirrorKeymap = (props: Props) => { const codeMirrorKeymap = (props: Props) => {
const keys = [] const keys = []
@ -43,22 +41,19 @@ const codeMirrorKeymap = (props: Props) => {
return cmKeymap.of(keys) return cmKeymap.of(keys)
} }
*/ */
export const createExtensions = (props: Props): ProseMirrorExtension[] =>
export const createExtensions = (opts: InitOpts): ProseMirrorExtension[] => { props.markdown
return opts.markdown
? [ ? [
placeholder('Просто начните...'), placeholder('Просто начните...'),
customKeymap(opts), customKeymap(props),
base(opts.markdown), base(props.markdown),
// scroll(props.config.typewriterMode), collab(props.y),
collab(opts.y), selectionMenu()
dragHandle()
] ]
: [ : [
selectionMenu(), selectionMenu(),
customKeymap(opts), customKeymap(props),
base(opts.markdown), base(props.markdown),
collab(opts.y),
markdown(), markdown(),
todoList(), todoList(),
dragHandle(), dragHandle(),
@ -66,8 +61,10 @@ export const createExtensions = (opts: InitOpts): ProseMirrorExtension[] => {
strikethrough(), strikethrough(),
link(), link(),
table(), table(),
// image(props.path), // TODO: image extension image(props.path),
pasteMarkdown() pasteMarkdown(),
collab(props.y)
// scroll(props.config.typewriterMode),
/* /*
codeBlock({ codeBlock({
theme: codeTheme(props.config), theme: codeTheme(props.config),
@ -78,7 +75,6 @@ export const createExtensions = (opts: InitOpts): ProseMirrorExtension[] => {
}), }),
*/ */
] ]
}
export const createEmptyText = () => ({ export const createEmptyText = () => ({
doc: { doc: {
@ -92,11 +88,16 @@ export const createEmptyText = () => ({
} }
}) })
export const createSchema = (opts: InitOpts) => { export const createSchema = (props: Props) => {
const extensions = createExtensions(opts) const extensions = createExtensions({
config: props.config,
markdown: props.markdown,
path: props.path,
keymap: props.keymap,
y: props.y
})
let schemaSpec = { nodes: {} } let schemaSpec = { nodes: {} }
for (const extension of extensions) { for (const extension of extensions) {
if (extension.schema) { if (extension.schema) {
schemaSpec = extension.schema(schemaSpec) schemaSpec = extension.schema(schemaSpec)

View File

@ -1,48 +1,33 @@
import { Store, createStore, unwrap } from 'solid-js/store' import { Store, createStore, unwrap } from 'solid-js/store'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import type { Command, EditorState } from 'prosemirror-state' import type { EditorState } from 'prosemirror-state'
import { undo, redo } from 'prosemirror-history' import { undo, redo } from 'prosemirror-history'
import { selectAll, deleteSelection } from 'prosemirror-commands' import { selectAll, deleteSelection } from 'prosemirror-commands'
import * as Y from 'yjs'
import { undo as yUndo, redo as yRedo } from 'y-prosemirror' 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 'lodash/debounce' import debounce from 'lodash/debounce'
import { createSchema, createExtensions, createEmptyText, InitOpts } from '../prosemirror/setup' import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup'
import { State, Config, ServiceError, newState, PeerData } from './context' import { State, Draft, Config, ServiceError, newState } from '.'
import { serialize, createMarkdownParser } from '../prosemirror/markdown' import { serialize, createMarkdownParser } from '../prosemirror/markdown'
import { isEmpty, isInitialized, ProseMirrorExtension } from './state' import db from '../db'
import { isServer } from 'solid-js/web' import { isEmpty, isInitialized } from '../prosemirror/helpers'
import { roomConnect } from '../prosemirror/p2p' import { Awareness } from 'y-protocols/awareness'
import { drafts as draftsatom } from '../../../stores/editor'
import { useStore } from '@nanostores/solid'
import { createMemo } from 'solid-js'
// eslint-disable-next-line @typescript-eslint/no-explicit-any const isText = (x) => x && x.doc && x.selection
export const createCtrl = (initial: State): [Store<State>, { [key: string]: any }] => { const isState = (x) => typeof x.lastModified !== 'string' && Array.isArray(x.drafts)
const isDraft = (x): boolean => x && (x.text || x.path)
const mod = 'Ctrl'
export const createCtrl = (initial): [Store<State>, any] => {
const [store, setState] = createStore(initial) const [store, setState] = createStore(initial)
const discardText = async () => { const onNew = () => {
const state = unwrap(store) newDraft()
const extensions = createExtensions({
config: state.config ?? store.config,
markdown: state.markdown && store.markdown,
keymap
})
setState({
text: createEmptyText(),
extensions,
lastModified: undefined,
path: undefined,
markdown: state.markdown,
collab: state.collab,
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)
}
return true return true
} }
@ -51,182 +36,336 @@ export const createCtrl = (initial: State): [Store<State>, { [key: string]: any
return true return true
} }
const onToggleMarkdown = () => toggleMarkdown()
const onUndo = () => { const onUndo = () => {
if (!isInitialized(store.text as EditorState)) return false if (!isInitialized(store.text as EditorState)) return
const text = store.text as EditorState const text = store.text as EditorState
if (store.collab?.started) yUndo(text) store.collab?.started ? yUndo(text) : undo(text, store.editorView.dispatch)
else undo(text, store.editorView.dispatch)
return true return true
} }
const onRedo = () => { const onRedo = () => {
if (!isInitialized(store.text as EditorState)) return false if (!isInitialized(store.text as EditorState)) return
const text = store.text as EditorState const text = store.text as EditorState
if (store.collab?.started) yRedo(text) if (store.collab?.started) {
else redo(text, store.editorView.dispatch) yRedo(text)
} else {
redo(text, store.editorView.dispatch)
}
return true return true
} }
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
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 mod = 'Ctrl'
const keymap = { const keymap = {
[`${mod}-n`]: onNew,
[`${mod}-w`]: onDiscard, [`${mod}-w`]: onDiscard,
[`${mod}-z`]: onUndo, [`${mod}-z`]: onUndo,
[`Shift-${mod}-z`]: onRedo, [`Shift-${mod}-z`]: onRedo,
[`${mod}-y`]: onRedo, [`${mod}-y`]: onRedo,
[`${mod}-m`]: toggleMarkdown [`${mod}-m`]: onToggleMarkdown
} as unknown as { [key: string]: Command } }
const createTextFromDraft = async (d: Draft): Promise<Draft> => {
let draft = d
const state = unwrap(store)
if (draft.path) {
draft = await loadDraft(state.config, draft.path)
}
const extensions = createExtensions({
config: state.config,
markdown: draft.markdown,
path: draft.path,
keymap
})
return {
text: draft.text,
extensions,
updatedAt: draft.updatedAt ? new Date(draft.updatedAt) : undefined,
path: draft.path,
markdown: draft.markdown
}
}
// 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,
updatedAt: prev.updatedAt as Date,
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,
updatedAt: new Date(),
path: undefined,
markdown: state.markdown
}
}
const drafts = state.drafts.filter((f: Draft) => f !== draft)
setState({
drafts,
...next,
collab: state.collab,
error: undefined
})
}
const fetchData = async (): Promise<State> => { const fetchData = async (): Promise<State> => {
if (isServer) return
const state: State = unwrap(store) const state: State = unwrap(store)
console.debug('[editor] init state', state) const room = window.location.pathname?.slice(1).trim()
const { default: db } = await import('../db') const args = { room, draft: room }
const data: string = await db.get('state') const data = await db.get('state')
let parsed
if (data !== undefined) { if (data !== undefined) {
console.debug('[editor] state stored before', data)
try { try {
const parsed = JSON.parse(data) parsed = JSON.parse(data)
let text = state.text } catch (error) {
const room = undefined // window.location.pathname?.slice(1) + uuidv4() console.error(error)
const args = { room }
if (!parsed) return { ...state, args }
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] got text parsed')
}
}
console.debug('[editor] json state parsed successfully', 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()
}
} catch {
throw new ServiceError('invalid_state', data) throw new ServiceError('invalid_state', data)
} }
} }
if (!parsed) {
return { ...state, args }
} }
const getTheme = (state: State) => ({ theme: state.config?.theme || '' }) 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: undefined
})
const newst = {
...parsed,
text,
extensions,
// config,
args,
lastModified: new Date(parsed.lastModified)
}
for (const draft of parsed.drafts) {
if (!isDraft(draft)) {
throw new ServiceError('invalid_draft', draft)
}
}
if (!isState(newst)) {
throw new ServiceError('invalid_state', newst)
}
return newst
}
const getTheme = (state: State) => ({ theme: state.config.theme })
const clean = () => { const clean = () => {
const s: State = { setState({
...newState(), ...newState(),
loading: 'initialized', loading: 'initialized',
drafts: [],
fullscreen: store.fullscreen,
lastModified: new Date(), lastModified: new Date(),
error: undefined, error: undefined,
text: undefined, text: undefined
args: {} })
}
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)
} }
setState(s)
console.debug('[editor] clean state', s)
} }
const init = async () => { const init = async () => {
let state = await fetchData() let data = await fetchData()
if (state) {
console.debug('[editor] state initiated', state)
try { try {
if (state.args?.room) { if (data.args.room) {
state = { ...doStartCollab(state) } data = doStartCollab(data)
} else if (!state.text) { } else if (data.args.text) {
data = await doOpenDraft(data, {
text: { ...JSON.parse(data.args.text) },
updatedAt: new Date()
})
} else if (data.args.draft) {
const draft = await loadDraft(data.config, data.args.draft)
data = await doOpenDraft(data, draft)
} else if (data.path) {
const draft = await loadDraft(data.config, data.path)
data = await doOpenDraft(data, draft)
} else if (!data.text) {
const text = createEmptyText() const text = createEmptyText()
const extensions = createExtensions({ const extensions = createExtensions({
config: state?.config || ({} as Config), config: data.config ?? store.config,
markdown: state.markdown, markdown: data.markdown ?? store.markdown,
keymap keymap: keymap
}) })
state = { ...state, text, extensions } data = { ...data, text, extensions }
} }
} catch (error) { } catch (error) {
state = { ...state, error } data = { ...data, error: error.errorObject }
} }
setState({ setState({
...state, ...data,
config: { config: { ...data.config, ...getTheme(data) },
...state.config,
...getTheme(state)
},
loading: 'initialized' loading: 'initialized'
}) })
} }
const loadDraft = async (config: Config, path: string): Promise<Draft> => {
const draftstore = useStore(draftsatom)
const draft = createMemo(() => draftstore()[path])
const lastModified = draft().updatedAt
const draftContent = draft().body
const schema = createSchema({
config,
markdown: false,
path,
keymap
})
const parser = createMarkdownParser(schema)
const doc = parser.parse(draftContent).toJSON()
const text = {
doc,
selection: {
type: 'text',
anchor: 1,
head: 1
}
}
return {
...draft(),
body: doc,
text,
updatedAt: lastModified.toISOString(),
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]
let drafts = state.drafts.filter((f) => f !== item)
if (!isEmpty(state.text as EditorState) && state.lastModified) {
drafts = addToDrafts(drafts, { updatedAt: new Date(), text: state.text } as Draft)
}
draft.updatedAt = item.updatedAt
const next = await createTextFromDraft(draft)
return {
...state,
...next,
drafts,
collab: undefined,
error: undefined
}
} }
const saveState = () => const saveState = () =>
debounce(async (state: State) => { debounce(async (state: State) => {
const data = { const data: any = {
lastModified: state.lastModified, lastModified: state.lastModified,
drafts: state.drafts,
config: state.config, config: state.config,
path: state.path, path: state.path,
markdown: state.markdown, markdown: state.markdown,
collab: { collab: {
room: state.collab?.room room: state.collab?.room
},
text: ''
} }
}
if (isInitialized(state.text as EditorState)) { if (isInitialized(state.text as EditorState)) {
if (state.path) {
const text = serialize(store.editorView.state)
// TODO: await remote.writeDraft(state.path, text)
} else {
data.text = store.editorView.state.toJSON() data.text = store.editorView.state.toJSON()
}
} else if (state.text) { } else if (state.text) {
data.text = state.text as string data.text = state.text
} }
if (!isServer) {
const { default: db } = await import('../db')
db.set('state', JSON.stringify(data)) db.set('state', JSON.stringify(data))
}
}, 200) }, 200)
const startCollab = () => { const startCollab = () => {
@ -238,43 +377,126 @@ export const createCtrl = (initial: State): [Store<State>, { [key: string]: any
const doStartCollab = (state: State): State => { const doStartCollab = (state: State): State => {
const backup = state.args?.room && state.collab?.room !== state.args.room const backup = state.args?.room && state.collab?.room !== state.args.room
const room = state.args?.room ?? uuidv4() const room = state.args?.room ?? uuidv4()
const username = '' // FIXME: use authenticated user name window.history.replaceState(null, '', `/${room}`)
const [payload, provider] = roomConnect(room, username)
const extensions: ProseMirrorExtension[] = createExtensions({ 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, config: state.config,
markdown: state.markdown, markdown: state.markdown,
path: state.path, path: state.path,
keymap, keymap,
y: { payload, provider } as PeerData y: { type, provider }
} as InitOpts) })
let nState = state
let newst = state
if ((backup && !isEmpty(state.text as EditorState)) || state.path) { if ((backup && !isEmpty(state.text as EditorState)) || state.path) {
nState = { let drafts = state.drafts
if (!state.error) {
drafts = addToDrafts(drafts, { updatedAt: new Date(), text: state.text } as Draft)
}
newst = {
...state, ...state,
drafts,
lastModified: undefined, lastModified: undefined,
path: undefined, path: undefined,
error: undefined error: undefined
} }
} }
return { return {
...nState, ...newst,
extensions, extensions,
collab: { started: true, room, y: { payload, provider } } collab: { started: true, room, y: { type, provider } }
} }
} }
const stopCollab = (state: State) => { const stopCollab = (state: State) => {
state.collab?.y?.provider.destroy() state.collab.y?.provider.destroy()
const extensions = createExtensions({ const extensions = createExtensions({
config: state.config, config: state.config,
markdown: state.markdown, markdown: state.markdown,
path: state.path, path: state.path,
keymap keymap
}) })
setState({ collab: undefined, extensions }) setState({ collab: undefined, extensions })
window.history.replaceState(null, '', '/') 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: keymap,
y: state.collab?.y
})
setState({
text: { selection, doc },
extensions,
markdown
})
}
const updateConfig = (config: Partial<Config>) => { const updateConfig = (config: Partial<Config>) => {
const state = unwrap(store) const state = unwrap(store)
const extensions = createExtensions({ const extensions = createExtensions({
@ -306,6 +528,9 @@ export const createCtrl = (initial: State): [Store<State>, { [key: string]: any
discard, discard,
getTheme, getTheme,
init, init,
loadDraft,
newDraft,
openDraft,
saveState, saveState,
setState, setState,
startCollab, startCollab,

View File

@ -1,15 +1,14 @@
import { createContext, useContext } from 'solid-js' import { createContext, useContext } from 'solid-js'
import type { Store } from 'solid-js/store' import type { Store } from 'solid-js/store'
import type { XmlFragment } from 'yjs'
import type { WebrtcProvider } from 'y-webrtc' import type { WebrtcProvider } from 'y-webrtc'
import type { ProseMirrorExtension, ProseMirrorState } from './state' import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
import type { EditorView } from 'prosemirror-view' import type { EditorView } from 'prosemirror-view'
import type { YXmlFragment } from 'yjs/dist/src/internals' import { createEmptyText } from '../prosemirror/setup'
import type { Shout } from '../../../graphql/types.gen'
export const isMac = true // FIXME
export const mod = isMac ? 'Cmd' : 'Ctrl'
export const alt = isMac ? 'Cmd' : 'Alt'
export interface Args { export interface Args {
draft: string // path to draft
cwd?: string cwd?: string
file?: string file?: string
room?: string room?: string
@ -36,13 +35,12 @@ export interface Config {
} }
export interface ErrorObject { export interface ErrorObject {
message: string
id: string id: string
props: unknown props?: unknown
} }
export interface PeerData { export interface YOptions {
payload: YXmlFragment type: XmlFragment
provider: WebrtcProvider provider: WebrtcProvider
} }
@ -50,25 +48,18 @@ export interface Collab {
started?: boolean started?: boolean
error?: boolean error?: boolean
room?: string room?: string
y?: PeerData y?: YOptions
} }
export type LoadingType = 'loading' | 'initialized' export type LoadingType = 'loading' | 'initialized'
// TODO: use this interface in prosemirror's context
export interface Draft {
path?: string // used by state
text?: { [key: string]: string }
lastModified?: string
markdown?: boolean
}
export interface State { export interface State {
text?: ProseMirrorState text?: ProseMirrorState
editorView?: EditorView editorView?: EditorView
extensions?: ProseMirrorExtension[] extensions?: ProseMirrorExtension[]
markdown?: boolean markdown?: boolean
lastModified?: Date lastModified?: Date
drafts: Draft[]
config: Config config: Config
error?: ErrorObject error?: ErrorObject
loading: LoadingType loading: LoadingType
@ -77,21 +68,39 @@ export interface State {
args?: Args args?: Args
} }
export interface Draft {
extensions?: ProseMirrorExtension[]
updatedAt: Date
body?: string
text?: { doc: any; selection: { type: string; anchor: number; head: number } }
path?: string
markdown?: boolean
}
export class ServiceError extends Error { export class ServiceError extends Error {
public errorObject: ErrorObject public errorObject: ErrorObject
constructor(id: string, props: unknown) { constructor(id: string, props: unknown) {
super(id) super(id)
this.errorObject = { id, props, message: '' } this.errorObject = { id, props }
} }
} }
const DEFAULT_CONFIG = { export const StateContext = createContext<[Store<State>, any]>([undefined, undefined])
theme: '',
export const useState = () => useContext(StateContext)
export const newState = (props: Partial<State> = {}): State => ({
extensions: [],
drafts: [],
loading: 'loading',
markdown: false,
config: {
theme: undefined,
// codeTheme: 'material-light', // codeTheme: 'material-light',
font: 'muller', font: 'muller',
fontSize: 24, fontSize: 24,
contentWidth: 800, contentWidth: 800,
alwaysOnTop: isMac, alwaysOnTop: false,
// typewriterMode: true, // typewriterMode: true,
prettier: { prettier: {
printWidth: 80, printWidth: 80,
@ -100,17 +109,19 @@ const DEFAULT_CONFIG = {
semi: false, semi: false,
singleQuote: true singleQuote: true
} }
} },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const StateContext = createContext<[Store<State>, any]>([{} as Store<State>, undefined])
export const useState = () => useContext(StateContext)
export const newState = (props: Partial<State> = {}): State => ({
extensions: [],
loading: 'loading',
markdown: false,
config: DEFAULT_CONFIG,
...props ...props
}) })
export const addToDrafts = (drafts: Draft[], state: State): Draft[] => {
drafts.forEach((d) => {
if (!state.drafts.includes(d)) state.drafts.push(d)
})
return state.drafts
}
export const createTextFromDraft = async (draft: Draft) => {
const created = createEmptyText()
created.doc.content = Object.values(draft.text) // FIXME
return created
}

View File

@ -1,9 +1,9 @@
import { Show, onCleanup, createEffect, onError, onMount, untrack } from 'solid-js' import { Show, onCleanup, createEffect, onError, onMount, untrack } from 'solid-js'
import { createMutable, unwrap } from 'solid-js/store' import { createMutable, unwrap } from 'solid-js/store'
import { State, StateContext, newState } from '../Editor/store/context' import { State, StateContext, newState } from '../Editor/store'
import { createCtrl } from '../Editor/store/ctrl' import { createCtrl } from '../Editor/store/ctrl'
import { Layout } from '../Editor/components/Layout' import { Layout } from '../Editor/components/Layout'
import Editor from '../Editor' import { Editor } from '../Editor'
import { Sidebar } from '../Editor/components/Sidebar' import { Sidebar } from '../Editor/components/Sidebar'
import ErrorView from '../Editor/components/Error' import ErrorView from '../Editor/components/Error'
@ -17,17 +17,16 @@ export const CreateView = () => {
} }
onMount(async () => { onMount(async () => {
if (store.error) return console.debug('[create] view mounted')
if (store.error) {
console.error(store.error)
return
}
await ctrl.init() await ctrl.init()
}) })
onMount(() => { onMount(() => {
if (typeof window === 'undefined') {
return
}
const mediaQuery = '(prefers-color-scheme: dark)' const mediaQuery = '(prefers-color-scheme: dark)'
window.matchMedia(mediaQuery).addEventListener('change', ctrl.updateTheme) window.matchMedia(mediaQuery).addEventListener('change', ctrl.updateTheme)
onCleanup(() => window.matchMedia(mediaQuery).removeEventListener('change', ctrl.updateTheme)) onCleanup(() => window.matchMedia(mediaQuery).removeEventListener('change', ctrl.updateTheme))
}) })
@ -44,6 +43,7 @@ export const CreateView = () => {
} }
const state: State = untrack(() => unwrap(store)) const state: State = untrack(() => unwrap(store))
ctrl.saveState(state) ctrl.saveState(state)
console.debug('[create] status update')
return store.loading return store.loading
}, store.loading) }, store.loading)
@ -54,13 +54,8 @@ export const CreateView = () => {
data-testid={store.error ? 'error' : store.loading} data-testid={store.error ? 'error' : store.loading}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
> >
<Show when={store.error}> <Show when={!store.error} fallback={<ErrorView />}>
<ErrorView />
</Show>
<Show when={store.loading === 'initialized'}>
<Show when={!store.error}>
<Editor /> <Editor />
</Show>
<Sidebar /> <Sidebar />
</Show> </Show>
</Layout> </Layout>

View File

@ -148,5 +148,6 @@
"Help discours to grow": "Поддержка дискурса", "Help discours to grow": "Поддержка дискурса",
"One time": "Единоразово", "One time": "Единоразово",
"Every month": "Ежемесячно", "Every month": "Ежемесячно",
"Another amount": "Другая сумма" "Another amount": "Другая сумма",
"Just start typing...": "Просто начните..."
} }

View File

@ -4,6 +4,7 @@ import { atom } from 'nanostores'
import { createSignal } from 'solid-js' import { createSignal } from 'solid-js'
interface Draft { interface Draft {
[x: string]: any
createdAt: Date createdAt: Date
body?: string body?: string
title?: string title?: string
@ -17,10 +18,14 @@ interface Collab {
title?: string title?: string
} }
export const drafts = persistentAtom<Draft[]>('drafts', [], { export const drafts = persistentAtom<{ [key: string]: Draft }>(
'drafts',
{},
{
encode: JSON.stringify, encode: JSON.stringify,
decode: JSON.parse decode: JSON.parse
}) // save drafts on device }
) // save drafts on device
export const collabs = atom<Collab[]>([]) // save collabs in backend or in p2p network export const collabs = atom<Collab[]>([]) // save collabs in backend or in p2p network
export const [editorReactions, setReactions] = createSignal<Reaction[]>([]) export const [editorReactions, setReactions] = createSignal<Reaction[]>([])