re-integrated

This commit is contained in:
tonyrewin 2022-10-18 21:43:50 +03:00
parent 05cdf42c05
commit 24d5acabef
16 changed files with 200 additions and 177 deletions

View File

@ -1,9 +1,8 @@
import type { EditorView } from 'prosemirror-view'
import type { EditorState } from 'prosemirror-state'
import { useState } from '../store'
import { ProseMirror } from '../components/ProseMirror'
import { useState } from '../store/context'
import { ProseMirror } from './ProseMirror'
import '../styles/Editor.scss'
import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
export const Editor = () => {
const [store, ctrl] = useState()
@ -23,9 +22,9 @@ export const Editor = () => {
// eslint-disable-next-line solid/no-react-specific-props
className="editor"
style={style()}
editorView={store.editorView as EditorView}
text={store.text as ProseMirrorState}
extensions={store.extensions as ProseMirrorExtension[]}
editorView={store.editorView}
text={store.text}
extensions={store.extensions}
onInit={onInit}
onReconfigure={onReconfigure}
onChange={onChange}

View File

@ -1,5 +1,5 @@
import { Switch, Match } from 'solid-js'
import { useState } from '../store'
import { useState } from '../store/context'
import '../styles/Button.scss'
export default () => {
@ -12,8 +12,8 @@ export default () => {
<Match when={store.error.id === 'invalid_config'}>
<InvalidState title="Invalid Config" />
</Match>
<Match when={store.error.id === 'invalid_file'}>
<InvalidState title="Invalid File" />
<Match when={store.error.id === 'invalid_draft'}>
<InvalidState title="Invalid Draft" />
</Match>
</Switch>
)
@ -48,8 +48,8 @@ const Other = () => {
const onClick = () => ctrl.discard()
const getMessage = () => {
const { error } = store.error.props as any
return typeof error === 'string' ? error : error.message
const err = (store.error.props as any).error
return typeof err === 'string' ? err : err.message
}
return (

View File

@ -1,8 +1,9 @@
import type { Config } from '../store'
import type { JSX } from 'solid-js/jsx-runtime'
import type { Config } from '../store/context'
import '../styles/Layout.scss'
export type Styled = {
children: any
children: JSX.Element
config?: Config
'data-testid'?: string
onClick?: () => void

View File

@ -1,16 +1,16 @@
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'
import { Store, unwrap } from 'solid-js/store'
import { EditorState, EditorStateConfig, Transaction } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { Schema } from 'prosemirror-model'
import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
interface Props {
style?: string
className?: string
text?: ProseMirrorState
editorView?: EditorView
extensions?: ProseMirrorExtension[]
text?: Store<ProseMirrorState>
editorView?: Store<EditorView>
extensions?: Store<ProseMirrorExtension[]>
onInit: (s: EditorState, v: EditorView) => void
onReconfigure: (s: EditorState) => void
onChange: (s: EditorState) => void
@ -19,6 +19,7 @@ interface Props {
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)
@ -28,10 +29,10 @@ export const ProseMirror = (props: Props) => {
}
createEffect(
(state: [EditorState, ProseMirrorExtension[]]) => {
const [prevText, prevExtensions] = state
(payload: [EditorState, ProseMirrorExtension[]]) => {
const [prevText, prevExtensions] = payload
const text = unwrap(props.text)
const extensions = unwrap(props.extensions)
const extensions: ProseMirrorExtension[] = unwrap(props.extensions)
if (!text || !extensions?.length) {
return [text, extensions]
}
@ -61,3 +62,46 @@ export const ProseMirror = (props: Props) => {
return <div style={props.style} ref={editorRef} class={props.className} spell-check={false} />
}
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 EditorStateConfig)
} 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,14 +1,12 @@
import { Show, createEffect, createSignal, onCleanup, For } from 'solid-js'
import { For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'
import { unwrap } from 'solid-js/store'
import { undo, redo } from 'prosemirror-history'
import { useState } from '../store'
import { Draft, useState /*, Config, PrettierConfig */ } from '../store/context'
import * as remote from '../remote'
import { isEmpty /*, isInitialized*/ } from '../prosemirror/helpers'
import type { Styled } from './Layout'
import '../styles/Sidebar.scss'
import { router } from '../../../stores/router'
import { t } from '../../../utils/intl'
import { isEmpty } from '../prosemirror/helpers'
import type { EditorState } from 'prosemirror-state'
const Off = (props) => <div class="sidebar-off">{props.children}</div>
@ -29,23 +27,22 @@ const Link = (
</button>
)
const mod = 'Ctrl'
const Keys = (props) => (
<span>
<For each={props.keys}>{(k: string) => <i>{k}</i>}</For>
<For each={props.keys}>{(k: Element) => <i>{k}</i>}</For>
</span>
)
interface SidebarProps {
error?: string
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export const Sidebar = (_props: SidebarProps) => {
export const Sidebar = () => {
const [isMac, setIsMac] = createSignal(false)
onMount(() => setIsMac(window?.navigator.platform.includes('Mac')))
// eslint-disable-next-line unicorn/consistent-function-scoping
// const isDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches
const mod = isMac() ? 'Cmd' : 'Ctrl'
// const alt = isMac() ? 'Cmd' : 'Alt'
const [store, ctrl] = useState()
const [lastAction, setLastAction] = createSignal<string | undefined>()
const toggleTheme = () => {
// TODO: use dark/light toggle somewhere
document.body.classList.toggle('dark')
ctrl.updateConfig({ theme: document.body.className })
}
@ -58,20 +55,51 @@ export const Sidebar = (_props: SidebarProps) => {
}
const editorView = () => unwrap(store.editorView)
const onToggleMarkdown = () => ctrl.toggleMarkdown()
const onOpenDraft = (draft: Draft) => ctrl.openDraft(unwrap(draft))
const collabUsers = () => store.collab?.y?.provider.awareness.meta.size ?? 0
const onUndo = () => undo(editorView().state, editorView().dispatch)
const onRedo = () => redo(editorView().state, editorView().dispatch)
const onNew = () => ctrl.newFile()
const onCopyAllAsMd = () =>
remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md'))
const onDiscard = () => ctrl.discard()
const [isHidden, setIsHidden] = createSignal<boolean | false>()
// eslint-disable-next-line unicorn/consistent-function-scoping
const onHistory = () => {
console.log('[editor.sidebar] implement history handling')
router.open('/create/settings')
}
const toggleSidebar = () => setIsHidden(!isHidden())
toggleSidebar()
// eslint-disable-next-line sonarjs/cognitive-complexity
const DraftLink = (p: { draft: Draft }) => {
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 = () =>
p.draft.path
? p.draft.path.slice(Math.max(0, p.draft.path.length - length))
: getContent(p.draft.text?.doc)
return (
// eslint-disable-next-line solid/no-react-specific-props
<Link className="draft" onClick={() => onOpenDraft(p.draft)} data-testid="open">
{text()} {p.draft.path && '📎'}
</Link>
)
}
const onCollab = () => {
const state = unwrap(store)
store.collab?.started ? ctrl.stopCollab(state) : ctrl.startCollab(state)
@ -88,19 +116,11 @@ export const Sidebar = (_props: SidebarProps) => {
}, 1000)
onCleanup(() => clearTimeout(id))
})
const discardText = () => {
if (store.path) {
return t('Close')
} else if (store.drafts.length > 0 && isEmpty(store.text as EditorState)) {
return t('Delete')
} else {
return t('Clear')
}
}
return (
<div class={'sidebar-container' + (isHidden() ? ' sidebar-container--hidden' : '')}>
<span class="sidebar-opener" onClick={toggleSidebar}>
{t('Tips and proposals')}
Советы и&nbsp;предложения
</span>
<Off onClick={() => editorView().focus()}>
@ -112,35 +132,44 @@ export const Sidebar = (_props: SidebarProps) => {
<i>({store.path.slice(Math.max(0, store.path.length - 24))})</i>
</Label>
)}
<Link onClick={onNew}>{t('Tabula rasa')}</Link>
<Link onClick={onCollab}>{t('Invite coauthors')}</Link>
<Link onClick={() => router.open('/create/settings')}>{t('Publication settings')}</Link>
<Link onClick={onHistory}>{t('History of changes')}</Link>
<Link>Пригласить соавторов</Link>
<Link>Настройки публикации</Link>
<Link>История правок</Link>
<div class="theme-switcher">
Ночная тема
<input type="checkbox" name="theme" id="theme" onClick={toggleTheme} />
<label for="theme">Ночная тема</label>
</div>
<Link
onClick={onDiscard}
disabled={!store.path && store.drafts.length === 0 && isEmpty(store.text as EditorState)}
disabled={!store.path && store.drafts.length === 0 && isEmpty(store.text)}
data-testid="discard"
>
{discardText()} <Keys keys={[mod, 'w']} />
{/* eslint-disable-next-line no-nested-ternary */}
{store.path
? 'Close'
: (store.drafts.length > 0 && isEmpty(store.text)
? 'Delete ⚠️'
: 'Clear')}{' '}
<Keys keys={[mod, 'w']} />
</Link>
<Link onClick={onUndo}>
{t('Undo')} <Keys keys={[mod, 'z']} />
Undo <Keys keys={[mod, 'z']} />
</Link>
<Link onClick={onRedo}>
{t('Redo')} <Keys keys={[mod, 'Shift+z']} />
Redo <Keys keys={[mod, ...(isMac() ? ['Shift', 'z'] : ['y'])]} />
</Link>
<Link onClick={onToggleMarkdown} data-testid="markdown">
Markdown {store.markdown && '✅'} <Keys keys={[mod, 'm']} />
Markdown mode {store.markdown && '✅'} <Keys keys={[mod, 'm']} />
</Link>
<Link onClick={onCopyAllAsMd}>Copy all as MD {lastAction() === 'copy-md' && '📋'}</Link>
<Show when={store.drafts.length > 0}>
<h4>{t('Drafts')}:</h4>
<h4>Drafts:</h4>
<p>
<For each={store.drafts}>
{(draft) => <Link onClick={() => router.open(draft.path)}>{draft.path}</Link>}
</For>
<For each={store.drafts}>{(draft: Draft) => <DraftLink draft={draft} />}</For>
</p>
</Show>
<Link onClick={onCollab} title={store.collab?.error ? 'Connection error' : ''}>
{collabText()}
</Link>

View File

@ -1,34 +1,38 @@
import markdownit from 'markdown-it'
import {
MarkdownSerializer,
MarkdownParser,
defaultMarkdownSerializer,
MarkdownSerializerState
} from 'prosemirror-markdown'
import { MarkdownSerializer, MarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'
import type { Node, Schema } from 'prosemirror-model'
import type { EditorState } from 'prosemirror-state'
export const serialize = (state: EditorState) => {
let text = markdownSerializer.serialize(state.doc)
if (text.charAt(text.length - 1) !== '\n') text += '\n'
if (text.charAt(text.length - 1) !== '\n') {
text += '\n'
}
return text
}
const findAlignment = (cell: Node) => {
const findAlignment = (cell: Node): string | null => {
const alignment = cell.attrs.style as string
if (!alignment) return null
if (!alignment) {
return null
}
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
}
export const markdownSerializer = new MarkdownSerializer(
{
...defaultMarkdownSerializer.nodes,
image(state: MarkdownSerializerState, node) {
image(state, node) {
const alt = state.esc(node.attrs.alt || '')
const src = node.attrs.path ?? node.attrs.src
const title = node.attrs.title || '' // ? state.quote(node.attrs.title) : undefined
const title = node.attrs.title ? state.quote(node.attrs.title) : undefined
state.write(`![${alt}](${src}${title ? ' ' + title : ''})\n`)
},
code_block(state, node) {
@ -118,8 +122,8 @@ export const markdownSerializer = new MarkdownSerializer(
}
)
function listIsTight(tokens, i: number) {
// eslint-disable-next-line no-param-reassign
function listIsTight(tokens: any, idx: number) {
let i = idx
while (++i < tokens.length) {
if (tokens[i].type !== 'list_item_open') return tokens[i].hidden
}

View File

@ -1,7 +1,7 @@
import { Plugin } from 'prosemirror-state'
import { Fragment, Node, Schema, Slice } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../helpers'
import { createMarkdownParser } from '../markdown'
import { createMarkdownParser } from '../../markdown'
const URL_REGEX = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:\d+)?(\/|\/([\w!#%&+./:=?@-]))?/g

View File

@ -17,13 +17,11 @@ export type NodeViewFn = (
decorations: Decoration[]
) => NodeView
export const isInitialized = (state: EditorState) => state !== undefined && state instanceof EditorState
export const isInitialized = (state: any) => state !== undefined && state instanceof EditorState
export const isEmpty = (state: EditorState) =>
export const isEmpty = (state: any) =>
!isInitialized(state) ||
(state.doc.childCount === 1 &&
!state.doc.firstChild.type.spec.code &&
state.doc.firstChild.isTextblock &&
state.doc.firstChild.content.size === 0)
export const isText = (x) => x && x.doc && x.selection

View File

@ -1,46 +0,0 @@
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

@ -17,7 +17,7 @@ import selectionMenu from './extension/selection'
import strikethrough from './extension/strikethrough'
import table from './extension/table'
import todoList from './extension/todo-list'
import type { Config, YOptions } from '../store'
import type { Config, YOptions } from '../store/context'
import type { ProseMirrorExtension } from './helpers'
interface ExtensionsProps {

View File

@ -0,0 +1,11 @@
import { EditorState } from 'prosemirror-state'
import { serialize } from './markdown'
export const copy = async (text: string): Promise<void> => {
navigator.clipboard.writeText(text)
}
export const copyAllAsMarkdown = async (state: EditorState): Promise<void> => {
const text = serialize(state)
navigator.clipboard.writeText(text)
}

View File

@ -6,8 +6,8 @@ import { selectAll, deleteSelection } from 'prosemirror-commands'
import { undo as yUndo, redo as yRedo } from 'y-prosemirror'
import debounce from 'lodash/debounce'
import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup'
import { State, Draft, Config, ServiceError, newState } from '.'
import { serialize, createMarkdownParser } from '../prosemirror/markdown'
import { State, Draft, Config, ServiceError, newState } from './context'
import { serialize, createMarkdownParser } from '../markdown'
import db from '../db'
import { isEmpty, isInitialized } from '../prosemirror/helpers'
import { drafts as draftsatom } from '../../../stores/editor'
@ -91,7 +91,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
...drafts,
{
body: text,
lastModified: prev.lastModified as Date,
lastModified: prev.lastModified,
path: prev.path,
markdown: prev.markdown
} as Draft
@ -302,7 +302,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
}
const index = findIndexOfDraft(draft)
const item = index === -1 ? draft : state.drafts[index]
let drafts = state.drafts.filter((f) => f !== item)
let drafts = state.drafts.filter((d: Draft) => d !== item)
if (!isEmpty(state.text as EditorState) && state.lastModified) {
drafts = addToDrafts(drafts, { lastModified: new Date(), text: state.text } as Draft)
}

View File

@ -4,14 +4,12 @@ import type { XmlFragment } from 'yjs'
import type { WebrtcProvider } from 'y-webrtc'
import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
import type { EditorView } from 'prosemirror-view'
import { createEmptyText } from '../prosemirror/setup'
export interface Args {
draft: string // path to draft
cwd?: string
file?: string
draft?: string
room?: string
text?: string
text?: any
}
export interface PrettierConfig {
@ -28,7 +26,7 @@ export interface Config {
font: string
fontSize: number
contentWidth: number
alwaysOnTop: boolean
// alwaysOnTop: boolean;
// typewriterMode: boolean;
prettier: PrettierConfig
}
@ -62,18 +60,20 @@ export interface State {
config: Config
error?: ErrorObject
loading: LoadingType
fullscreen?: boolean
collab?: Collab
path?: string
args?: Args
isMac?: boolean
}
export interface Draft {
extensions?: ProseMirrorExtension[]
lastModified: Date
text?: { [key: string]: any }
body?: string
text?: { doc: any; selection: { type: string; anchor: number; head: number } }
lastModified?: Date
path?: string
markdown?: boolean
extensions?: ProseMirrorExtension[]
}
export class ServiceError extends Error {
@ -92,6 +92,7 @@ export const newState = (props: Partial<State> = {}): State => ({
extensions: [],
drafts: [],
loading: 'loading',
fullscreen: false,
markdown: false,
config: {
theme: undefined,
@ -99,7 +100,6 @@ export const newState = (props: Partial<State> = {}): State => ({
font: 'muller',
fontSize: 24,
contentWidth: 800,
alwaysOnTop: false,
// typewriterMode: true,
prettier: {
printWidth: 80,
@ -111,16 +111,3 @@ export const newState = (props: Partial<State> = {}): State => ({
},
...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,4 +1,4 @@
import { newState } from '../Editor/store'
import { newState } from '../Editor/store/context'
import { MainLayout } from '../Layouts/MainLayout'
import { CreateView } from '../Views/Create'

View File

@ -1,7 +1,7 @@
import { Show, onCleanup, createEffect, onError, onMount, untrack } from 'solid-js'
import { createMutable, unwrap } from 'solid-js/store'
import { State, StateContext } from '../Editor/store'
import { createCtrl } from '../Editor/store/ctrl'
import { State, StateContext } from '../Editor/store/context'
import { createCtrl } from '../Editor/store/actions'
import { Layout } from '../Editor/components/Layout'
import { Editor } from '../Editor/components/Editor'
import { Sidebar } from '../Editor/components/Sidebar'
@ -10,7 +10,14 @@ import ErrorView from '../Editor/components/Error'
const matchDark = () => window.matchMedia('(prefers-color-scheme: dark)')
export const CreateView = (props: { state: State }) => {
const [store, ctrl] = createCtrl(props.state)
let isMac = false
onMount(() => {
isMac = window?.navigator.platform.includes('Mac')
matchDark().addEventListener('change', onChangeTheme)
onCleanup(() => matchDark().removeEventListener('change', onChangeTheme))
})
const [store, ctrl] = createCtrl({ ...props.state, isMac })
const mouseEnterCoords = createMutable({ x: 0, y: 0 })
const onMouseEnter = (e: MouseEvent) => {
@ -28,10 +35,6 @@ export const CreateView = (props: { state: State }) => {
})
const onChangeTheme = () => ctrl.updateTheme()
onMount(() => {
matchDark().addEventListener('change', onChangeTheme)
onCleanup(() => matchDark().removeEventListener('change', onChangeTheme))
})
onError((error) => {
console.error('[create] error:', error)

View File

@ -2,14 +2,7 @@ import { persistentMap } from '@nanostores/persistent'
import type { Reaction } from '../graphql/types.gen'
import { atom } from 'nanostores'
import { createSignal } from 'solid-js'
interface Draft {
createdAt: Date
topics?: string[]
lastModified: Date
body?: string
title?: string
}
import type { Draft } from '../components/Editor/store/context'
interface Collab {
authors: string[] // slugs