This commit is contained in:
tonyrewin 2022-10-19 20:26:07 +03:00
parent 1d7a71ae3a
commit 642d8b9dd1
37 changed files with 810 additions and 945 deletions

View File

@ -27,6 +27,7 @@ module.exports = {
// 'plugin:@typescript-eslint/recommended-requiring-type-checking' // 'plugin:@typescript-eslint/recommended-requiring-type-checking'
], ],
rules: { rules: {
'no-nested-ternary': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'warn',
{ {

View File

@ -1,5 +1,5 @@
import type { EditorView } from 'prosemirror-view' import { EditorView } from 'prosemirror-view'
import type { EditorState } from 'prosemirror-state' import { EditorState } from 'prosemirror-state'
import { useState } from '../store/context' import { useState } from '../store/context'
import { ProseMirror } from './ProseMirror' import { ProseMirror } from './ProseMirror'
import '../styles/Editor.scss' import '../styles/Editor.scss'
@ -9,17 +9,11 @@ export const Editor = () => {
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 style = () => { // const editorCss = (config) => css``
if (store.error) { const style = () => (store.error ? `display: none;` : store.markdown ? `white-space: pre-wrap;` : '')
return `display: none;`
} else {
return store.markdown ? `white-space: pre-wrap;` : ''
}
}
return ( return (
<ProseMirror <ProseMirror
// eslint-disable-next-line solid/no-react-specific-props className='editor'
className="editor"
style={style()} style={style()}
editorView={store.editorView} editorView={store.editorView}
text={store.text} text={store.text}

View File

@ -1,20 +1,19 @@
import { Switch, Match } from 'solid-js' import { Switch, Match } from 'solid-js'
import { useState } from '../store/context'
import '../styles/Button.scss' import '../styles/Button.scss'
import { ErrorObject, useState } from '../store/context'
import { t } from '../../../utils/intl'
export default () => { export default () => {
const [store] = useState() const [store] = useState()
return ( return (
<Switch fallback={<Other />}> <Switch fallback={<Other />}>
<Match when={store.error.id === 'invalid_state'}> <Match when={store.error.id === 'invalid_state'}>
<InvalidState title="Invalid State" /> <InvalidState title='Invalid State' />
</Match> </Match>
<Match when={store.error.id === 'invalid_config'}> <Match when={store.error.id === 'invalid_config'}>
<InvalidState title="Invalid Config" /> <InvalidState title='Invalid Config' />
</Match> </Match>
<Match when={store.error.id === 'invalid_draft'}> <Match when={store.error.id === 'invalid_file'}>
<InvalidState title="Invalid Draft" /> <InvalidState title='Invalid File' />
</Match> </Match>
</Switch> </Switch>
) )
@ -25,17 +24,19 @@ const InvalidState = (props: { title: string }) => {
const onClick = () => ctrl.clean() const onClick = () => ctrl.clean()
return ( return (
<div class="error"> <div class='error'>
<div class="container"> <div class='container'>
<h1>{props.title}</h1> <h1>{props.title}</h1>
<p> <p>
{t('Editing conflict, please copy your notes and refresh page')} There is an error with the editor state. This is probably due to an old version in which the data
structure has changed. Automatic data migrations may be supported in the future. To fix this now,
you can copy important notes from below, clean the state and paste it again.
</p> </p>
<pre> <pre>
<code>{JSON.stringify(store.error.props)}</code> <code>{JSON.stringify(store.error.props)}</code>
</pre> </pre>
<button class="primary" onClick={onClick}> <button class='primary' onClick={onClick}>
{t('Clean')} Clean
</button> </button>
</div> </div>
</div> </div>
@ -47,18 +48,18 @@ const Other = () => {
const onClick = () => ctrl.discard() const onClick = () => ctrl.discard()
const getMessage = () => { const getMessage = () => {
const err = (store.error.props as ErrorObject['props']).error const err = (store.error.props as any).error
return typeof err === 'string' ? err : err.message return typeof err === 'string' ? err : err.message
} }
return ( return (
<div class="error"> <div class='error'>
<div class="container"> <div class='container'>
<h1>An error occurred.</h1> <h1>An error occurred.</h1>
<pre> <pre>
<code>{getMessage()}</code> <code>{getMessage()}</code>
</pre> </pre>
<button class="primary" onClick={onClick}> <button class='primary' onClick={onClick}>
Close Close
</button> </button>
</div> </div>

View File

@ -1,19 +1,16 @@
import type { JSX } from 'solid-js/jsx-runtime' import { Config } from '../store/context'
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?: () => void onClick?: () => void;
onMouseEnter?: (ev: MouseEvent) => void onMouseEnter?: (e: any) => void;
} }
export const Layout = (props: Styled) => { export const Layout = (props: Styled) => {
return ( return (<div onMouseEnter={props.onMouseEnter} class='layout' data-testid={props['data-testid']}>
<div onMouseEnter={props.onMouseEnter} class="layout" data-testid={props['data-testid']}>
{props.children} {props.children}
</div> </div>)
)
} }

View File

@ -1,19 +1,19 @@
import { createEffect, untrack } from 'solid-js' import { createEffect, untrack } from 'solid-js'
import { Store, unwrap } from 'solid-js/store' import { Store, unwrap } from 'solid-js/store'
import { EditorState, EditorStateConfig, Transaction } from 'prosemirror-state' import { EditorState, Transaction } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view' import { EditorView } from 'prosemirror-view'
import { Schema } from 'prosemirror-model' import { Schema } from 'prosemirror-model'
import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers' import { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
interface Props { interface Props {
style?: string style?: string;
className?: string className?: string;
text?: Store<ProseMirrorState> text?: Store<ProseMirrorState>;
editorView?: Store<EditorView> editorView?: Store<EditorView>;
extensions?: Store<ProseMirrorExtension[]> extensions?: Store<ProseMirrorExtension[]>;
onInit: (s: EditorState, v: EditorView) => void onInit: (s: EditorState, v: EditorView) => void;
onReconfigure: (s: EditorState) => void onReconfigure: (s: EditorState) => void;
onChange: (s: EditorState) => void onChange: (s: EditorState) => void;
} }
export const ProseMirror = (props: Props) => { export const ProseMirror = (props: Props) => {
@ -28,10 +28,9 @@ export const ProseMirror = (props: Props) => {
props.onChange(newState) props.onChange(newState)
} }
createEffect( createEffect((payload: [EditorState, ProseMirrorExtension[]]) => {
(payload: [EditorState, ProseMirrorExtension[]]) => {
const [prevText, prevExtensions] = payload const [prevText, prevExtensions] = payload
const text = unwrap(props.text) const text: EditorState = unwrap(props.text)
const extensions: ProseMirrorExtension[] = unwrap(props.extensions) const extensions: ProseMirrorExtension[] = unwrap(props.extensions)
if (!text || !extensions?.length) { if (!text || !extensions?.length) {
return [text, extensions] return [text, extensions]
@ -60,7 +59,14 @@ export const ProseMirror = (props: Props) => {
[props.text, props.extensions] [props.text, props.extensions]
) )
return <div style={props.style} ref={editorRef} class={props.className} spell-check={false} /> return (
<div
style={props.style}
ref={editorRef}
className={props.className}
spell-check={false}
/>
)
} }
const createEditorState = ( const createEditorState = (
@ -68,8 +74,8 @@ const createEditorState = (
extensions: ProseMirrorExtension[], extensions: ProseMirrorExtension[],
prevText?: EditorState prevText?: EditorState
): { ): {
editorState: EditorState editorState: EditorState;
nodeViews: { [key: string]: NodeViewFn } nodeViews: { [key: string]: NodeViewFn };
} => { } => {
const reconfigure = text instanceof EditorState && prevText?.schema const reconfigure = text instanceof EditorState && prevText?.schema
let schemaSpec = { nodes: {} } let schemaSpec = { nodes: {} }
@ -95,10 +101,10 @@ const createEditorState = (
let editorState: EditorState let editorState: EditorState
if (reconfigure) { if (reconfigure) {
editorState = text.reconfigure({ schema, plugins } as EditorStateConfig) editorState = text.reconfigure({ schema, plugins })
} else if (text instanceof EditorState) { } else if (text instanceof EditorState) {
editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON()) editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON())
} else if (text) { } else if (text){
console.debug(text) console.debug(text)
editorState = EditorState.fromJSON({ schema, plugins }, text) editorState = EditorState.fromJSON({ schema, plugins }, text)
} }

View File

@ -1,23 +1,23 @@
import { For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js' import { For, 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 { File, useState } from '../store/context'
import { mod } from '../env'
import * as remote from '../remote' import * as remote from '../remote'
import { isEmpty /*, isInitialized*/ } from '../prosemirror/helpers' import { isEmpty } from '../prosemirror/helpers'
import type { Styled } from './Layout' import { Styled } from './Layout'
import '../styles/Sidebar.scss' import '../styles/Sidebar.scss'
import { t } from '../../../utils/intl'
const Off = (props) => <div class="sidebar-off">{props.children}</div> const Off = ({ children }: Styled) => <div class='sidebar-off'>{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={`sidebar-link${props.className ? ' ' + props.className : ''}`} class={`sidebar-link${props.className ? ` ${props.className}` : ''}`}
style={{ 'margin-bottom': props.withMargin ? '10px' : '' }} style={{ marginBottom: props.withMargin ? '10px' : '' }}
onClick={props.onClick} onClick={props.onClick}
disabled={props.disabled} disabled={props.disabled}
title={props.title} title={props.title}
@ -27,59 +27,54 @@ const Link = (
</button> </button>
) )
const Keys = (props) => (
<span>
<For each={props.keys}>{(k: Element) => <i>{k}</i>}</For>
</span>
)
export const Sidebar = () => { 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 [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 = () => (store.collab?.started ? 'Stop' : store.collab?.error ? 'Restart 🚨' : 'Start')
if (store.collab?.started) {
return t('Stop collab')
} else {
return store.collab?.error ? t('Restart collab') : t('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 onOpenFile = (file: File) => ctrl.openFile(unwrap(file))
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 = () => const onCopyAllAsMd = () => remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md'))
remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md'))
const onDiscard = () => ctrl.discard() const onDiscard = () => ctrl.discard()
const [isHidden, setIsHidden] = createSignal<boolean | false>() const [isHidden, setIsHidden] = createSignal<boolean | false>()
const toggleSidebar = () => setIsHidden(!isHidden())
toggleSidebar()
// eslint-disable-next-line sonarjs/cognitive-complexity const toggleSidebar = () => {
const DraftLink = (p: { draft: Draft }) => { setIsHidden(!isHidden());
}
toggleSidebar();
const onCollab = () => {
const state = unwrap(store)
store.collab?.started ? ctrl.stopCollab(state) : ctrl.startCollab(state)
}
const FileLink = (p: { file: File }) => {
const length = 100 const length = 100
let content = '' let content = ''
const getContent = (node: any) => { const getContent = (node: any) => {
if (node.text) content += node.text if (node.text) {
content += node.text
}
if (content.length > length) { if (content.length > length) {
content = content.slice(0, Math.max(0, length)) + '...' content = content.substring(0, length) + '...'
return content return content
} }
if (node.content) { if (node.content) {
for (const child of node.content) { for (const child of node.content) {
if (content.length >= length) break if (content.length >= length) {
break
}
content = getContent(child) content = getContent(child)
} }
} }
@ -88,90 +83,89 @@ export const Sidebar = () => {
} }
const text = () => const text = () =>
p.draft.path p.file.path ? p.file.path.substring(p.file.path.length - length) : getContent(p.file.text?.doc)
? p.draft.path.slice(Math.max(0, p.draft.path.length - length))
: getContent(p.draft.text?.doc)
return ( return (
// eslint-disable-next-line solid/no-react-specific-props <Link className='file' onClick={() => onOpenFile(p.file)} data-testid='open'>
<Link className="draft" onClick={() => onOpenDraft(p.draft)} data-testid="open"> {text()} {p.file.path && '📎'}
{text()} {p.draft.path && '📎'}
</Link> </Link>
) )
} }
const onCollab = () => { const Keys = ({ keys }: { keys: string[] }) => (
const state = unwrap(store) <span>
store.collab?.started ? ctrl.stopCollab(state) : ctrl.startCollab(state) {keys.map((k) => (
} <i>{k}</i>
))}
</span>
)
createEffect(() => { createEffect(() => {
if (store.lastModified) setLastAction() setLastAction(undefined)
}) }, store.lastModified)
createEffect(() => { createEffect(() => {
if (!lastAction()) return if (!lastAction()) return
const id = setTimeout(() => { const id = setTimeout(() => {
setLastAction() setLastAction(undefined)
}, 1000) }, 1000)
onCleanup(() => clearTimeout(id)) onCleanup(() => clearTimeout(id))
}) })
return ( return (
<div class={'sidebar-container' + (isHidden() ? ' sidebar-container--hidden' : '')}> <div className={'sidebar-container' + (isHidden() ? ' sidebar-container--hidden' : '')}>
<span class="sidebar-opener" onClick={toggleSidebar}> <span className='sidebar-opener' onClick={toggleSidebar}>Советы и&nbsp;предложения</span>
Советы и&nbsp;предложения
</span>
<Off onClick={() => editorView().focus()}> <Off onClick={() => editorView().focus()}>
<div class="sidebar-closer" onClick={toggleSidebar} /> <div className='sidebar-closer' onClick={toggleSidebar}/>
<Show when={true}> <Show when={true}>
<div> <div>
{store.path && ( {store.path && (
<Label> <Label>
<i>({store.path.slice(Math.max(0, store.path.length - 24))})</i> <i>({store.path.substring(store.path.length - 24)})</i>
</Label> </Label>
)} )}
<Link>Пригласить соавторов</Link> <Link>
<Link>Настройки публикации</Link> Пригласить соавторов
<Link>История правок</Link> </Link>
<Link>
Настройки публикации
</Link>
<Link>
История правок
</Link>
<div class="theme-switcher"> <div class='theme-switcher'>
Ночная тема Ночная тема
<input type="checkbox" name="theme" id="theme" onClick={toggleTheme} /> <input type='checkbox' name='theme' id='theme' onClick={toggleTheme} />
<label for="theme">Ночная тема</label> <label for='theme'>Ночная тема</label>
</div> </div>
<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'
> >
{/* eslint-disable-next-line no-nested-ternary */} {store.path ? 'Close' : store.files.length > 0 && isEmpty(store.text) ? 'Delete ⚠️' : 'Clear'}{' '}
{store.path
? 'Close'
: (store.drafts.length > 0 && isEmpty(store.text)
? 'Delete ⚠️'
: 'Clear')}{' '}
<Keys keys={[mod, 'w']} /> <Keys keys={[mod, 'w']} />
</Link> </Link>
<Link onClick={onUndo}> <Link onClick={onUndo}>
Undo <Keys keys={[mod, 'z']} /> Undo <Keys keys={[mod, 'z']} />
</Link> </Link>
<Link onClick={onRedo}> <Link onClick={onRedo}>
Redo <Keys keys={[mod, ...(isMac() ? ['Shift', 'z'] : ['y'])]} /> Redo <Keys keys={[mod, ...['Shift', 'z']]} />
</Link> </Link>
<Link onClick={onToggleMarkdown} data-testid="markdown"> <Link onClick={onToggleMarkdown} data-testid='markdown'>
Markdown mode {store.markdown && '✅'} <Keys keys={[mod, 'm']} /> Markdown mode {store.markdown && '✅'} <Keys keys={[mod, 'm']} />
</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>Drafts:</h4> <h4>Drafts:</h4>
<p> <p>
<For each={store.drafts}>{(draft: Draft) => <DraftLink draft={draft} />}</For> <For each={store.files}>{(file) => <FileLink file={file} />}</For>
</p> </p>
</Show> </Show>
<Link onClick={onCollab} title={store.collab?.error ? 'Connection error' : ''}> <Link onClick={onCollab} title={store.collab?.error ? 'Connection error' : ''}>
{collabText()} Collab {collabText()}
</Link> </Link>
<Show when={collabUsers() > 0}> <Show when={collabUsers() > 0}>
<span> <span>

View File

@ -0,0 +1,3 @@
export const isDark = () => (window as any).matchMedia('(prefers-color-scheme: dark)').matches
export const mod = 'Ctrl'
export const alt = 'Alt'

View File

@ -1,7 +1,7 @@
import markdownit from 'markdown-it' import markdownit from 'markdown-it'
import { MarkdownSerializer, MarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown' import { MarkdownSerializer, MarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'
import type { Node, Schema } from 'prosemirror-model' import { Node, Schema } from 'prosemirror-model'
import type { EditorState } from 'prosemirror-state' import { EditorState } from 'prosemirror-state'
export const serialize = (state: EditorState) => { export const serialize = (state: EditorState) => {
let text = markdownSerializer.serialize(state.doc) let text = markdownSerializer.serialize(state.doc)
@ -12,24 +12,10 @@ export const serialize = (state: EditorState) => {
return text return text
} }
const findAlignment = (cell: Node): string | null => {
const alignment = cell.attrs.style as string
if (!alignment) {
return null
}
const match = alignment.match(/text-align: ?(left|right|center)/)
if (match && match[1]) {
return match[1]
}
return null
}
export const markdownSerializer = new MarkdownSerializer( export const markdownSerializer = new MarkdownSerializer(
{ {
...defaultMarkdownSerializer.nodes, ...defaultMarkdownSerializer.nodes,
image(state: any, node) { image(state, 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 const title = node.attrs.title ? state.quote(node.attrs.title) : undefined
@ -102,6 +88,20 @@ export const markdownSerializer = new MarkdownSerializer(
return findAlignment(cell) return findAlignment(cell)
} }
function findAlignment(cell: Node): string | null {
const alignment = cell.attrs.style as string
if (!alignment) {
return null
}
const match = alignment.match(/text-align:[ ]?(left|right|center)/)
if (match && match[1]) {
return match[1]
}
return null
}
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)
@ -122,10 +122,9 @@ export const markdownSerializer = new MarkdownSerializer(
} }
) )
function listIsTight(tokens: any[], idx: number) { function listIsTight(tokens: any, i: number) {
let i = idx
while (++i < tokens.length) { while (++i < tokens.length) {
if (tokens[i].type !== 'list_item_open') return tokens[i].hidden if (tokens[i].type != 'list_item_open') return tokens[i].hidden
} }
return false return false
} }

View File

@ -1,13 +1,12 @@
import { schema as markdownSchema } from 'prosemirror-markdown' import { schema as markdownSchema } from 'prosemirror-markdown'
import type OrderedMap from 'orderedmap' import { Schema } from 'prosemirror-model'
import { NodeSpec, Schema } from 'prosemirror-model'
import { baseKeymap } from 'prosemirror-commands' import { baseKeymap } from 'prosemirror-commands'
import { sinkListItem, liftListItem } from 'prosemirror-schema-list' import { sinkListItem, liftListItem } from 'prosemirror-schema-list'
import { history } from 'prosemirror-history' 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 '../helpers' import { ProseMirrorExtension } from '../helpers'
const plainSchema = new Schema({ const plainSchema = new Schema({
nodes: { nodes: {
@ -40,10 +39,7 @@ export default (plain = false): ProseMirrorExtension => ({
marks: plainSchema.spec.marks marks: plainSchema.spec.marks
} }
: { : {
nodes: (markdownSchema.spec.nodes as OrderedMap<NodeSpec>).update( nodes: (markdownSchema.spec.nodes as any).update('blockquote', blockquoteSchema),
'blockquote',
blockquoteSchema as unknown as NodeSpec
),
marks: markdownSchema.spec.marks marks: markdownSchema.spec.marks
}, },
plugins: (prev, schema) => [ plugins: (prev, schema) => [

View File

@ -1,12 +1,12 @@
import { inputRules } from 'prosemirror-inputrules' import { inputRules } from 'prosemirror-inputrules'
import type { Mark, MarkType } from 'prosemirror-model' import { Mark, MarkType } from 'prosemirror-model'
import type { EditorState, Transaction } from 'prosemirror-state' import { EditorState, Transaction } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view' import { 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 '../helpers' import { ProseMirrorExtension } from '../helpers'
const blank = '\u00A0' const blank = '\xa0'
const onArrow = const onArrow =
(dir: 'left' | 'right') => (dir: 'left' | 'right') =>
@ -36,7 +36,7 @@ const codeKeymap = {
ArrowRight: onArrow('right') ArrowRight: onArrow('right')
} }
const codeRule = (nodeType: MarkType) => markInputRule(/`([^`]+)`$/, nodeType, null) const codeRule = (nodeType: MarkType) => markInputRule(/(?:`)([^`]+)(?:`)$/, nodeType)
export default (): ProseMirrorExtension => ({ export default (): ProseMirrorExtension => ({
plugins: (prev, schema) => [ plugins: (prev, schema) => [

View File

@ -1,14 +1,8 @@
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror' import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'
import type { YOptions } from '../../store/context' import { ProseMirrorExtension } from '../helpers'
import type { ProseMirrorExtension } from '../helpers' import { YOptions } from '../../store/context'
export interface EditingProps { export const cursorBuilder = (user: any): HTMLElement => {
name: string
foreground: string
background: string
}
export const cursorBuilder = (user: EditingProps): HTMLElement => {
const cursor = document.createElement('span') const cursor = 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}`)
@ -25,6 +19,7 @@ export default (y: YOptions): ProseMirrorExtension => ({
? [ ? [
...prev, ...prev,
ySyncPlugin(y.type), ySyncPlugin(y.type),
// @ts-ignore
yCursorPlugin(y.provider.awareness, { cursorBuilder }), yCursorPlugin(y.provider.awareness, { cursorBuilder }),
yUndoPlugin() yUndoPlugin()
] ]

View File

@ -1,7 +1,11 @@
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 '../helpers' import { ProseMirrorExtension } from '../helpers'
import handleIcon from '../../../../assets/handle.svg'
const handleIcon = `
<svg viewBox="0 0 10 10" height="14" width="14">
<path d="M3 2a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm4-8a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2z"/>
</svg>`
const createDragHandle = () => { const createDragHandle = () => {
const handle = document.createElement('span') const handle = document.createElement('span')
@ -18,8 +22,8 @@ const handlePlugin = new Plugin({
decorations(state) { decorations(state) {
const decos = [] const decos = []
state.doc.forEach((node, pos) => { state.doc.forEach((node, pos) => {
decos.push(Decoration.widget(pos + 1, createDragHandle))
decos.push( decos.push(
Decoration.widget(pos + 1, createDragHandle),
Decoration.node(pos, pos + node.nodeSize, { Decoration.node(pos, pos + node.nodeSize, {
class: 'draggable' class: 'draggable'
}) })

View File

@ -1,24 +1,23 @@
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
import type { Node, NodeSpec, Schema } from 'prosemirror-model' import { Node, Schema } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view' import { EditorView } from 'prosemirror-view'
import type { NodeViewFn, ProseMirrorExtension } from '../helpers' import { ProseMirrorExtension } from '../helpers'
import type OrderedMap from 'orderedmap'
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
} }
} }
const isBlank = (text: string) => text === ' ' || text === '\u00A0' const isBlank = (text: string) => text === ' ' || text === '\xa0'
const imageInput = (schema: Schema, _path?: string) => const imageInput = (schema: Schema, path?: string) =>
new Plugin({ new Plugin({
props: { props: {
handleTextInput(view, from, to, text) { handleTextInput(view, from, to, text) {
@ -30,7 +29,7 @@ const imageInput = (schema: Schema, _path?: string) =>
Math.max(0, $from.parentOffset - MAX_MATCH), Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset, $from.parentOffset,
null, null,
'\uFFFC' '\ufffc'
) + text ) + text
const match = REGEX.exec(textBefore) const match = REGEX.exec(textBefore)
@ -45,6 +44,7 @@ const imageInput = (schema: Schema, _path?: string) =>
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
return false return false
} }
} }
@ -69,7 +69,7 @@ const imageSchema = {
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 as NodeSpec).dataset.path path: dom.getAttribute('data-path')
}) })
} }
], ],
@ -102,12 +102,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
@ -162,12 +162,12 @@ class ImageView {
export default (path?: string): ProseMirrorExtension => ({ export default (path?: string): ProseMirrorExtension => ({
schema: (prev) => ({ schema: (prev) => ({
...prev, ...prev,
nodes: (prev.nodes as OrderedMap<NodeSpec>).update('image', imageSchema as unknown as NodeSpec) nodes: (prev.nodes as any).update('image', imageSchema)
}), }),
plugins: (prev, schema) => [...prev, imageInput(schema, path)], plugins: (prev, schema) => [...prev, imageInput(schema, path)],
nodeViews: { nodeViews: {
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)
} }
} as unknown as { [key: string]: NodeViewFn } }
}) })

View File

@ -1,9 +1,9 @@
import { Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state' import { Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view' import { EditorView } from 'prosemirror-view'
import type { Mark, Node, Schema } from 'prosemirror-model' import { Mark, Node, Schema } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../helpers' import { ProseMirrorExtension } from '../helpers'
const REGEX = /(^|\s)\[(.+)]\(([^ ]+)(?: "(.+)")?\)/ const REGEX = /(^|\s)\[(.+)\]\(([^ ]+)(?: "(.+)")?\)/
const findMarkPosition = (mark: Mark, doc: Node, from: number, to: number) => { const findMarkPosition = (mark: Mark, doc: Node, from: number, to: number) => {
let markPos = { from: -1, to: -1 } let markPos = { from: -1, to: -1 }
@ -29,8 +29,9 @@ const markdownLinks = (schema: Schema) =>
apply(tr, state) { apply(tr, state) {
const action = tr.getMeta(this) const action = tr.getMeta(this)
if (action?.pos) { if (action?.pos) {
(state as any).pos = action.pos state.pos = action.pos
} }
return state return state
} }
}, },
@ -53,12 +54,11 @@ const markdownLinks = (schema: Schema) =>
const resolvePos = (view: EditorView, pos: number) => { const resolvePos = (view: EditorView, pos: number) => {
try { try {
return view.state.doc.resolve(pos) return view.state.doc.resolve(pos)
} catch { } catch (err) {
// ignore // ignore
} }
} }
// 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
const state = pluginKey.getState(view.state) const state = pluginKey.getState(view.state)

View File

@ -1,8 +1,8 @@
import { InputRule } from 'prosemirror-inputrules' import { InputRule } from 'prosemirror-inputrules'
import type { EditorState } from 'prosemirror-state' import { EditorState } from 'prosemirror-state'
import type { MarkType } from 'prosemirror-model' import { MarkType } from 'prosemirror-model'
export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs) => export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs = undefined) =>
new InputRule(regexp, (state: EditorState, match: string[], start: number, end: number) => { new InputRule(regexp, (state: EditorState, match: string[], start: number, end: 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
@ -13,6 +13,7 @@ export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs) =>
state.doc.nodesBetween(textStart, textEnd, (node) => { state.doc.nodesBetween(textStart, textEnd, (node) => {
if (node.marks.length > 0) { if (node.marks.length > 0) {
hasMarks = true hasMarks = true
return
} }
}) })
@ -22,7 +23,6 @@ export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs) =>
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
} }

View File

@ -6,8 +6,8 @@ import {
emDash, emDash,
ellipsis ellipsis
} from 'prosemirror-inputrules' } from 'prosemirror-inputrules'
import type { NodeType, Schema } from 'prosemirror-model' import { NodeType, Schema } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../helpers' import { ProseMirrorExtension } from '../helpers'
const blockQuoteRule = (nodeType: NodeType) => wrappingInputRule(/^\s*>\s$/, nodeType) const blockQuoteRule = (nodeType: NodeType) => wrappingInputRule(/^\s*>\s$/, nodeType)
@ -16,10 +16,10 @@ const orderedListRule = (nodeType: NodeType) =>
/^(\d+)\.\s$/, /^(\d+)\.\s$/,
nodeType, nodeType,
(match) => ({ order: +match[1] }), (match) => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1] (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)
const headingRule = (nodeType: NodeType, maxLevel: number) => const headingRule = (nodeType: NodeType, maxLevel: number) =>
textblockTypeInputRule(new RegExp('^(#{1,' + maxLevel + '})\\s$'), nodeType, (match) => ({ textblockTypeInputRule(new RegExp('^(#{1,' + maxLevel + '})\\s$'), nodeType, (match) => ({
@ -27,7 +27,7 @@ const headingRule = (nodeType: NodeType, maxLevel: number) =>
})) }))
const markdownRules = (schema: Schema) => { const markdownRules = (schema: Schema) => {
const rules = [...smartQuotes, ellipsis, emDash] const rules = smartQuotes.concat(ellipsis, emDash)
if (schema.nodes.blockquote) rules.push(blockQuoteRule(schema.nodes.blockquote)) if (schema.nodes.blockquote) rules.push(blockQuoteRule(schema.nodes.blockquote))
if (schema.nodes.ordered_list) rules.push(orderedListRule(schema.nodes.ordered_list)) if (schema.nodes.ordered_list) rules.push(orderedListRule(schema.nodes.ordered_list))
if (schema.nodes.bullet_list) rules.push(bulletListRule(schema.nodes.bullet_list)) if (schema.nodes.bullet_list) rules.push(bulletListRule(schema.nodes.bullet_list))

View File

@ -13,40 +13,38 @@ import {
Dropdown Dropdown
} from 'prosemirror-menu' } from 'prosemirror-menu'
import type { MenuItemSpec, MenuElement } from 'prosemirror-menu'
import { wrapInList } from 'prosemirror-schema-list' import { wrapInList } from 'prosemirror-schema-list'
import { Command, EditorState, NodeSelection, Transaction } from 'prosemirror-state' import { NodeSelection } from 'prosemirror-state'
import { TextField, openPrompt } from './prompt' import { TextField, openPrompt } from './prompt'
import type { ProseMirrorExtension } from '../helpers' import { ProseMirrorExtension } from '../helpers'
import type { Attrs, MarkType, NodeType, Schema } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view'
// Helpers to create specific types of items // Helpers to create specific types of items
function canInsert(state: EditorState, nodeType: NodeType) { function canInsert(state, nodeType) {
const $from = state.selection.$from const $from = state.selection.$from
for (let d = $from.depth; d >= 0; d--) { for (let d = $from.depth; d >= 0; d--) {
const index = $from.index(d) const index = $from.index(d)
if ($from.node(d).canReplaceWith(index, index, nodeType)) return true if ($from.node(d).canReplaceWith(index, index, nodeType)) return true
} }
return false return false
} }
function insertImageItem(nodeType: NodeType) { function insertImageItem(nodeType) {
return new MenuItem({ return new MenuItem({
icon: icons.image, icon: icons.image,
label: 'image', label: 'image',
enable(state) { enable(state) {
return canInsert(state, nodeType) return canInsert(state, nodeType)
}, },
run(state: EditorState, _, view: EditorView) { run(state, _, view) {
const { from, to, node } = state.selection as NodeSelection const { from, to } = state.selection
let attrs = null let attrs = null
if (state.selection instanceof NodeSelection && node.type === nodeType) {
attrs = node.attrs if (state.selection instanceof NodeSelection && state.selection.node.type == nodeType) { attrs = state.selection.node.attrs }
}
openPrompt({ openPrompt({
title: 'Insert image', title: 'Insert image',
@ -62,8 +60,7 @@ function insertImageItem(nodeType: NodeType) {
value: attrs ? attrs.alt : state.doc.textBetween(from, to, ' ') value: attrs ? attrs.alt : state.doc.textBetween(from, to, ' ')
}) })
}, },
// eslint-disable-next-line no-shadow callback(attrs) {
callback(attrs: Attrs) {
view.dispatch(view.state.tr.replaceSelectionWith(nodeType.createAndFill(attrs))) view.dispatch(view.state.tr.replaceSelectionWith(nodeType.createAndFill(attrs)))
view.focus() view.focus()
} }
@ -72,31 +69,41 @@ function insertImageItem(nodeType: NodeType) {
}) })
} }
function cmdItem(cmd: Command, options: MenuItemSpec) { function cmdItem(cmd, options) {
const passedOptions = { label: options.title, run: cmd } as MenuItemSpec const passedOptions = {
Object.keys(options).forEach((prop) => (passedOptions[prop] = options[prop])) label: options.title,
// TODO: enable/disable items logix run: cmd
passedOptions.select = (state) => cmd(state) }
return new MenuItem(passedOptions as MenuItemSpec)
for (const prop in options) passedOptions[prop] = options[prop]
if ((!options.enable || options.enable === true) && !options.select) { passedOptions[options.enable ? 'enable' : 'select'] = (state) => cmd(state) }
return new MenuItem(passedOptions)
} }
function markActive(state: EditorState, type: MarkType) { function markActive(state, type) {
const { from, $from, to, empty } = state.selection const { from, $from, to, empty } = state.selection
if (empty) return type.isInSet(state.storedMarks || $from.marks()) if (empty) return type.isInSet(state.storedMarks || $from.marks())
return state.doc.rangeHasMark(from, to, type) return state.doc.rangeHasMark(from, to, type)
} }
function markItem(markType: MarkType, options: MenuItemSpec) { function markItem(markType, options) {
const passedOptions = { const passedOptions = {
active(state) { active(state) {
return markActive(state, markType) return markActive(state, markType)
},
enable: true
} }
} as MenuItemSpec
Object.keys(options).forEach((prop: string) => (passedOptions[prop] = options[prop])) for (const prop in options) passedOptions[prop] = options[prop]
return cmdItem(toggleMark(markType), passedOptions) return cmdItem(toggleMark(markType), passedOptions)
} }
function linkItem(markType: MarkType) { function linkItem(markType) {
return new MenuItem({ return new MenuItem({
title: 'Add or remove link', title: 'Add or remove link',
icon: { icon: {
@ -104,21 +111,27 @@ function linkItem(markType: MarkType) {
height: 18, height: 18,
path: 'M3.27177 14.7277C2.06258 13.5186 2.06258 11.5527 3.27177 10.3435L6.10029 7.51502L4.75675 6.17148L1.92823 9C-0.0234511 10.9517 -0.0234511 14.1196 1.92823 16.0713C3.87991 18.023 7.04785 18.023 8.99952 16.0713L11.828 13.2428L10.4845 11.8992L7.65598 14.7277C6.44679 15.9369 4.48097 15.9369 3.27177 14.7277ZM6.87756 12.536L12.5346 6.87895L11.1203 5.46469L5.4633 11.1217L6.87756 12.536ZM6.17055 4.75768L8.99907 1.92916C10.9507 -0.0225206 14.1187 -0.0225201 16.0704 1.92916C18.022 3.88084 18.022 7.04878 16.0704 9.00046L13.2418 11.829L11.8983 10.4854L14.7268 7.65691C15.936 6.44772 15.936 4.4819 14.7268 3.27271C13.5176 2.06351 11.5518 2.06351 10.3426 3.2727L7.51409 6.10122L6.17055 4.75768Z' path: 'M3.27177 14.7277C2.06258 13.5186 2.06258 11.5527 3.27177 10.3435L6.10029 7.51502L4.75675 6.17148L1.92823 9C-0.0234511 10.9517 -0.0234511 14.1196 1.92823 16.0713C3.87991 18.023 7.04785 18.023 8.99952 16.0713L11.828 13.2428L10.4845 11.8992L7.65598 14.7277C6.44679 15.9369 4.48097 15.9369 3.27177 14.7277ZM6.87756 12.536L12.5346 6.87895L11.1203 5.46469L5.4633 11.1217L6.87756 12.536ZM6.17055 4.75768L8.99907 1.92916C10.9507 -0.0225206 14.1187 -0.0225201 16.0704 1.92916C18.022 3.88084 18.022 7.04878 16.0704 9.00046L13.2418 11.829L11.8983 10.4854L14.7268 7.65691C15.936 6.44772 15.936 4.4819 14.7268 3.27271C13.5176 2.06351 11.5518 2.06351 10.3426 3.2727L7.51409 6.10122L6.17055 4.75768Z'
}, },
active: (state) => Boolean(markActive(state, markType)), active(state) {
enable: (state: EditorState) => !state.selection.empty, return markActive(state, markType)
run(state: EditorState, dispatch: (t: Transaction) => void, view: EditorView) { },
enable(state) {
return !state.selection.empty
},
run(state, dispatch, view) {
if (markActive(state, markType)) { if (markActive(state, markType)) {
toggleMark(markType)(state, dispatch) toggleMark(markType)(state, dispatch)
return true return true
} }
openPrompt({ openPrompt({
fields: { fields: {
href: new TextField({ href: new TextField({
label: 'Link target', label: 'Link target',
required: true required: true
}) }),
}, },
callback(attrs: Attrs) { callback(attrs) {
toggleMark(markType, attrs)(view.state, view.dispatch) toggleMark(markType, attrs)(view.state, view.dispatch)
view.focus() view.focus()
} }
@ -127,8 +140,7 @@ function linkItem(markType: MarkType) {
}) })
} }
function wrapListItem(nodeType: NodeType, options: MenuItemSpec & { attrs: Attrs }) { function wrapListItem(nodeType, options) {
options.run = (_) => true
return cmdItem(wrapInList(nodeType, options.attrs), options) return cmdItem(wrapInList(nodeType, options.attrs), options)
} }
@ -190,24 +202,9 @@ function wrapListItem(nodeType: NodeType, options: MenuItemSpec & { attrs: Attrs
// **`fullMenu`**`: [[MenuElement]]` // **`fullMenu`**`: [[MenuElement]]`
// : An array of arrays of menu elements for use as the full menu // : An array of arrays of menu elements for use as the full menu
// for, for example the [menu bar](https://github.com/prosemirror/prosemirror-menu#user-content-menubar). // for, for example the [menu bar](https://github.com/prosemirror/prosemirror-menu#user-content-menubar).
/* export function buildMenuItems(schema) {
type BuildSchema = {
marks: { strong: any; em: any; code: any; link: any; blockquote: any }
nodes: {
image: any
bullet_list: any
ordered_list: any
blockquote: any
paragraph: any
code_block: any
heading: any
horizontal_rule: any
}
}
*/
export function buildMenuItems(schema: Schema) {
const r: { [key: string]: MenuItem | MenuItem[] } = {} const r: { [key: string]: MenuItem | MenuItem[] } = {}
let type: NodeType | MarkType let type
if ((type = schema.marks.strong)) { if ((type = schema.marks.strong)) {
r.toggleStrong = markItem(type, { r.toggleStrong = markItem(type, {
@ -215,9 +212,9 @@ export function buildMenuItems(schema: Schema) {
icon: { icon: {
width: 13, width: 13,
height: 16, height: 16,
path: 'M9.82857 7.76C10.9371 6.99429 11.7143 5.73714 11.7143 4.57143C11.7143 1.98857 9.71428 0 7.14286 0H0V16H8.04571C10.4343 16 12.2857 14.0571 12.2857 11.6686C12.2857 9.93143 11.3029 8.44571 9.82857 7.76ZM3.42799 2.85708H6.85656C7.80513 2.85708 8.57085 3.6228 8.57085 4.57137C8.57085 5.51994 7.80513 6.28565 6.85656 6.28565H3.42799V2.85708ZM3.42799 13.1429H7.42799C8.37656 13.1429 9.14228 12.3772 9.14228 11.4286C9.14228 10.4801 8.37656 9.71434 7.42799 9.71434H3.42799V13.1429Z' path: "M9.82857 7.76C10.9371 6.99429 11.7143 5.73714 11.7143 4.57143C11.7143 1.98857 9.71428 0 7.14286 0H0V16H8.04571C10.4343 16 12.2857 14.0571 12.2857 11.6686C12.2857 9.93143 11.3029 8.44571 9.82857 7.76ZM3.42799 2.85708H6.85656C7.80513 2.85708 8.57085 3.6228 8.57085 4.57137C8.57085 5.51994 7.80513 6.28565 6.85656 6.28565H3.42799V2.85708ZM3.42799 13.1429H7.42799C8.37656 13.1429 9.14228 12.3772 9.14228 11.4286C9.14228 10.4801 8.37656 9.71434 7.42799 9.71434H3.42799V13.1429Z"
} }
} as MenuItemSpec) })
} }
if ((type = schema.marks.em)) { if ((type = schema.marks.em)) {
@ -226,21 +223,21 @@ export function buildMenuItems(schema: Schema) {
icon: { icon: {
width: 14, width: 14,
height: 16, height: 16,
path: 'M4.39216 0V3.42857H6.81882L3.06353 12.5714H0V16H8.78431V12.5714H6.35765L10.1129 3.42857H13.1765V0H4.39216Z' path: "M4.39216 0V3.42857H6.81882L3.06353 12.5714H0V16H8.78431V12.5714H6.35765L10.1129 3.42857H13.1765V0H4.39216Z"
} }
} as MenuItemSpec) })
} }
if ((type = schema.marks.code)) { if ((type = schema.marks.code)) {
r.toggleCode = markItem(type, { r.toggleCode = markItem(type, {
title: 'Toggle code font', title: 'Toggle code font',
icon: icons.code icon: icons.code
} as MenuItemSpec) })
} }
if ((type = schema.marks.link)) r.toggleLink = linkItem(type) if ((type = schema.marks.link)) r.toggleLink = linkItem(type)
if ((type = schema.marks.blockquote) && (type = schema.nodes.image)) r.insertImage = insertImageItem(type) if ((type = schema.marks.blockquote)) { if ((type = schema.nodes.image)) r.insertImage = insertImageItem(type) }
if ((type = schema.nodes.bullet_list)) { if ((type = schema.nodes.bullet_list)) {
r.wrapBulletList = wrapListItem(type, { r.wrapBulletList = wrapListItem(type, {
@ -250,7 +247,7 @@ export function buildMenuItems(schema: Schema) {
height: 16, height: 16,
path: 'M0.000114441 1.6C0.000114441 0.714665 0.71478 0 1.60011 0C2.48544 0 3.20011 0.714665 3.20011 1.6C3.20011 2.48533 2.48544 3.19999 1.60011 3.19999C0.71478 3.19999 0.000114441 2.48533 0.000114441 1.6ZM0 8.00013C0 7.1148 0.714665 6.40014 1.6 6.40014C2.48533 6.40014 3.19999 7.1148 3.19999 8.00013C3.19999 8.88547 2.48533 9.60013 1.6 9.60013C0.714665 9.60013 0 8.88547 0 8.00013ZM1.6 12.8C0.714665 12.8 0 13.5254 0 14.4C0 15.2747 0.725332 16 1.6 16C2.47466 16 3.19999 15.2747 3.19999 14.4C3.19999 13.5254 2.48533 12.8 1.6 12.8ZM19.7333 15.4662H4.79999V13.3329H19.7333V15.4662ZM4.79999 9.06677H19.7333V6.93344H4.79999V9.06677ZM4.79999 2.66664V0.533307H19.7333V2.66664H4.79999Z' path: 'M0.000114441 1.6C0.000114441 0.714665 0.71478 0 1.60011 0C2.48544 0 3.20011 0.714665 3.20011 1.6C3.20011 2.48533 2.48544 3.19999 1.60011 3.19999C0.71478 3.19999 0.000114441 2.48533 0.000114441 1.6ZM0 8.00013C0 7.1148 0.714665 6.40014 1.6 6.40014C2.48533 6.40014 3.19999 7.1148 3.19999 8.00013C3.19999 8.88547 2.48533 9.60013 1.6 9.60013C0.714665 9.60013 0 8.88547 0 8.00013ZM1.6 12.8C0.714665 12.8 0 13.5254 0 14.4C0 15.2747 0.725332 16 1.6 16C2.47466 16 3.19999 15.2747 3.19999 14.4C3.19999 13.5254 2.48533 12.8 1.6 12.8ZM19.7333 15.4662H4.79999V13.3329H19.7333V15.4662ZM4.79999 9.06677H19.7333V6.93344H4.79999V9.06677ZM4.79999 2.66664V0.533307H19.7333V2.66664H4.79999Z'
} }
} as MenuItemSpec & { attrs: Attrs }) })
} }
if ((type = schema.nodes.ordered_list)) { if ((type = schema.nodes.ordered_list)) {
@ -261,7 +258,7 @@ export function buildMenuItems(schema: Schema) {
height: 16, height: 16,
path: 'M2.00002 4.00003H1.00001V1.00001H0V0H2.00002V4.00003ZM2.00002 13.5V13H0V12H3.00003V16H0V15H2.00002V14.5H1.00001V13.5H2.00002ZM0 6.99998H1.80002L0 9.1V10H3.00003V9H1.20001L3.00003 6.89998V5.99998H0V6.99998ZM4.9987 2.99967V0.999648H18.9988V2.99967H4.9987ZM4.9987 15.0001H18.9988V13.0001H4.9987V15.0001ZM18.9988 8.99987H4.9987V6.99986H18.9988V8.99987Z' path: 'M2.00002 4.00003H1.00001V1.00001H0V0H2.00002V4.00003ZM2.00002 13.5V13H0V12H3.00003V16H0V15H2.00002V14.5H1.00001V13.5H2.00002ZM0 6.99998H1.80002L0 9.1V10H3.00003V9H1.20001L3.00003 6.89998V5.99998H0V6.99998ZM4.9987 2.99967V0.999648H18.9988V2.99967H4.9987ZM4.9987 15.0001H18.9988V13.0001H4.9987V15.0001ZM18.9988 8.99987H4.9987V6.99986H18.9988V8.99987Z'
} }
} as MenuItemSpec & { attrs: Attrs }) })
} }
if ((type = schema.nodes.blockquote)) { if ((type = schema.nodes.blockquote)) {
@ -312,36 +309,28 @@ export function buildMenuItems(schema: Schema) {
r.insertHorizontalRule = new MenuItem({ r.insertHorizontalRule = new MenuItem({
label: '---', label: '---',
icon: icons.horizontal_rule, icon: icons.horizontal_rule,
enable: (state) => canInsert(state, hr), enable(state) {
run(state: EditorState, dispatch: (tr: Transaction) => void) { return canInsert(state, hr)
},
run(state, dispatch) {
dispatch(state.tr.replaceSelectionWith(hr.create())) dispatch(state.tr.replaceSelectionWith(hr.create()))
} }
}) })
} }
const tMenu = new Dropdown( const cut = (arr) => arr.filter((x) => x)
[ r.typeMenu = new Dropdown(
r.makeHead1 as MenuElement, cut([r.makeHead1, r.makeHead2, r.makeHead3, r.typeMenu, r.wrapBlockQuote]),
r.makeHead2 as MenuElement, { label: 'Тт', icon: {
r.makeHead3 as MenuElement,
r.typeMenu as MenuElement,
r.wrapBlockQuote as MenuElement
],
{
label: 'Тт',
// FIXME !!!!!!!!!
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: {
width: 12, width: 12,
height: 12, height: 12,
path: 'M6.39999 3.19998V0H20.2666V3.19998H14.9333V15.9999H11.7333V3.19998H6.39999ZM3.19998 8.5334H0V5.33342H9.59994V8.5334H6.39996V16H3.19998V8.5334Z' path: "M6.39999 3.19998V0H20.2666V3.19998H14.9333V15.9999H11.7333V3.19998H6.39999ZM3.19998 8.5334H0V5.33342H9.59994V8.5334H6.39996V16H3.19998V8.5334Z"
} } })
} // r.blockMenu = []
) r.listMenu = [cut([r.wrapBulletList, r.wrapOrderedList])]
r.listMenu = [r.wrapBulletList as MenuItem, r.wrapOrderedList as MenuItem] r.inlineMenu = [cut([r.toggleStrong, r.toggleEm, r.toggleMark])]
r.inlineMenu = [r.toggleStrong as MenuItem, r.toggleEm as MenuItem, r.toggleMark as MenuItem] r.fullMenu = r.inlineMenu.concat([cut([r.typeMenu])], r.listMenu)
r.fullMenu = [...r.inlineMenu, tMenu as MenuItem, ...r.listMenu].filter(Boolean)
return r return r
} }
@ -349,8 +338,8 @@ export default (): ProseMirrorExtension => ({
plugins: (prev, schema) => [ plugins: (prev, schema) => [
...prev, ...prev,
menuBar({ menuBar({
floating: true, floating: false,
content: buildMenuItems(schema).fullMenu as any // NOTE: MenuItem and MenuElement are compatible content: buildMenuItems(schema).fullMenu
}) })
] ]
}) })

View File

@ -1,16 +1,16 @@
import { Plugin } from 'prosemirror-state' import { Plugin } from 'prosemirror-state'
import { Fragment, Node, Schema, Slice } from 'prosemirror-model' import { Fragment, Node, Schema, Slice } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../helpers' import { 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 const URL_REGEX = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/g
const transform = (schema: Schema, fragment: Fragment) => { const transform = (schema: Schema, fragment: Fragment) => {
const nodes = [] 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: any
while ((match = URL_REGEX.exec(child.text)) !== null) { while ((match = URL_REGEX.exec(child.text)) !== null) {
const start = match.index const start = match.index
@ -64,7 +64,7 @@ const pasteMarkdown = (schema: Schema) => {
event.preventDefault() event.preventDefault()
const paste = parser.parse(text) const paste = parser.parse(text)
const slice = paste as Node & { openStart: number; openEnd: number } const slice = paste.slice(0)
const fragment = shiftKey ? slice.content : transform(schema, slice.content) const fragment = shiftKey ? slice.content : transform(schema, slice.content)
const tr = view.state.tr.replaceSelection(new Slice(fragment, slice.openStart, slice.openEnd)) const tr = view.state.tr.replaceSelection(new Slice(fragment, slice.openStart, slice.openEnd))

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 { isEmpty, ProseMirrorExtension } from '../helpers' import { ProseMirrorExtension, isEmpty } from '../helpers'
const placeholder = (text: string) => const placeholder = (text: string) =>
new Plugin({ new Plugin({

View File

@ -1,19 +1,19 @@
const prefix = 'ProseMirror-prompt' const prefix = 'ProseMirror-prompt'
// eslint-disable-next-line sonarjs/cognitive-complexity export function openPrompt(options: any) {
export function openPrompt(options) {
const wrapper = document.body.appendChild(document.createElement('div')) const wrapper = document.body.appendChild(document.createElement('div'))
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) 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.parentNode.removeChild(wrapper)
} }
const domFields = [] const domFields: any = []
options.fields.forEach((name) => { options.fields.forEach((name) => {
domFields.push(options.fields[name].render()) domFields.push(options.fields[name].render())
}) })
@ -32,7 +32,7 @@ export function openPrompt(options) {
if (options.title) { if (options.title) {
form.appendChild(document.createElement('h5')).textContent = options.title form.appendChild(document.createElement('h5')).textContent = options.title
} }
domFields.forEach((field) => { domFields.forEach((field: any) => {
form.appendChild(document.createElement('div')).appendChild(field) form.appendChild(document.createElement('div')).appendChild(field)
}) })
const buttons = form.appendChild(document.createElement('div')) const buttons = form.appendChild(document.createElement('div'))
@ -59,25 +59,24 @@ export function openPrompt(options) {
}) })
form.addEventListener('keydown', (e) => { form.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.keyCode == 27) {
e.preventDefault() e.preventDefault()
close() close()
// eslint-disable-next-line unicorn/prefer-keyboard-event-key } else if (e.keyCode == 13 && !(e.ctrlKey || e.metaKey || e.shiftKey)) {
} 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.keyCode == 9) {
window.setTimeout(() => { window.setTimeout(() => {
if (!wrapper.contains(document.activeElement)) close() if (!wrapper.contains(document.activeElement)) close()
}, 500) }, 500)
} }
}) })
const inpel = form.elements[0] as HTMLInputElement const input: any = form.elements[0]
if (inpel) inpel.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) => {
@ -94,35 +93,23 @@ function getValues(fields, domFields) {
return result return result
} }
function reportInvalid(dom: HTMLElement, message: string) { 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
// eslint-disable-next-line unicorn/prefer-dom-node-remove
setTimeout(() => parent.removeChild(msg), 1500) setTimeout(() => parent.removeChild(msg), 1500)
} }
interface FieldOptions {
options: { value: string; label: string }[]
required: boolean
label: string
value: string
validateType: (v) => boolean
validate: (v) => boolean
read: (v) => string
clean: (v) => boolean
}
export class Field { export class Field {
options: FieldOptions 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
@ -131,12 +118,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
} }
} }
@ -156,10 +144,10 @@ export class TextField extends Field {
export class SelectField extends Field { export class SelectField extends Field {
render() { render() {
const select = document.createElement('select') const select = document.createElement('select')
this.options.options.forEach((o) => { this.options.options.forEach((o: { value: string; label: string }) => {
const opt = select.appendChild(document.createElement('option')) const opt = select.appendChild(document.createElement('option'))
opt.value = o.value opt.value = o.value
opt.selected = o.value === this.options.value opt.selected = o.value == this.options.value
opt.label = o.label opt.label = o.label
}) })
return select 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 { EditorView } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../helpers' import { 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,57 +1,60 @@
import { renderGrouped } from 'prosemirror-menu' import { renderGrouped } from "prosemirror-menu";
import { EditorState, Plugin } from 'prosemirror-state' import { Plugin } from "prosemirror-state";
import type { EditorView } from 'prosemirror-view' import { ProseMirrorExtension } from "../helpers";
import type { ProseMirrorExtension } from '../helpers' import { buildMenuItems } from "./menu";
import { buildMenuItems } from './menu'
export class SelectionTooltip { export class SelectionTooltip {
tooltip: HTMLElement tooltip: any;
constructor(view: EditorView, 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.appendChild(this.tooltip) view.dom.parentNode.appendChild(this.tooltip);
console.debug('[prosemirror] selection view', view) const { dom } = renderGrouped(view, buildMenuItems(schema).fullMenu);
console.debug('[prosemirror] selection menu', buildMenuItems(schema).fullMenu) this.tooltip.appendChild(dom);
const { dom } = renderGrouped(view, buildMenuItems(schema).fullMenu as any) this.update(view, null);
this.tooltip.appendChild(dom)
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 (
return lastState &&
} lastState.doc.eq(state.doc) &&
lastState.selection.eq(state.selection)
)
{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),
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() {
this.tooltip.remove() this.tooltip.remove();
} }
} }
export function toolTip(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);
} },
}) });
} }
export default (): ProseMirrorExtension => ({ export default (): ProseMirrorExtension => ({
plugins: (prev, schema) => [...prev, toolTip(schema)] plugins: (prev, schema) => [
...prev,
toolTip(schema)
]
}) })

View File

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

View File

@ -1,16 +1,15 @@
import { EditorState, Selection } from 'prosemirror-state' import { EditorState, Selection } from 'prosemirror-state'
import type { Node, Schema, ResolvedPos, NodeSpec } from 'prosemirror-model' import { 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 '../helpers' import { ProseMirrorExtension } from '../helpers'
import type OrderedMap from 'orderedmap'
export const tableInputRule = (schema: Schema) => export const tableInputRule = (schema: Schema) =>
new InputRule( new InputRule(
new RegExp('^\\|{2,}\\s$'), new RegExp('^\\|{2,}\\s$'),
(state: EditorState, match: string[], start: number, end: number) => { (state: EditorState, match: string[], start: number, end: number) => {
const tr = state.tr const tr = state.tr
const columns = [...Array.from({ length: match[0].trim().length - 1 })] const columns = [...Array(match[0].trim().length - 1)]
const headers = columns.map(() => schema.node(schema.nodes.table_header, {})) const headers = columns.map(() => schema.node(schema.nodes.table_header, {}))
const cells = columns.map(() => schema.node(schema.nodes.table_cell, {})) const cells = columns.map(() => schema.node(schema.nodes.table_cell, {}))
const table = schema.node(schema.nodes.table, {}, [ const table = schema.node(schema.nodes.table, {}, [
@ -175,9 +174,8 @@ const getTextSize = (n: Node) => {
export default (): ProseMirrorExtension => ({ export default (): ProseMirrorExtension => ({
schema: (prev) => ({ schema: (prev) => ({
...prev, ...prev,
nodes: (prev.nodes as OrderedMap<NodeSpec>).append(tableSchema as NodeSpec) nodes: (prev.nodes as any).append(tableSchema)
}), }),
// eslint-disable-next-line sonarjs/cognitive-complexity
plugins: (prev, schema) => [ plugins: (prev, schema) => [
keymap({ keymap({
'Ctrl-Enter': (state, dispatch) => { 'Ctrl-Enter': (state, dispatch) => {

View File

@ -1,9 +1,10 @@
import { DOMSerializer, Node as ProsemirrorNode, NodeType, Schema } from 'prosemirror-model' import { DOMSerializer, Node as ProsemirrorNode, NodeType, Schema } from 'prosemirror-model'
import { inputRules, wrappingInputRule } from 'prosemirror-inputrules' import { EditorView } from 'prosemirror-view'
import { 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 { EditorView } from 'prosemirror-view' import { inputRules } from 'prosemirror-inputrules'
import type { ProseMirrorExtension } from '../helpers' import { 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) => ({
@ -59,9 +60,7 @@ 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) ;(this.dom as Element).querySelector('input').onclick = this.handleClick.bind(this)
.querySelector('input')
.addEventListener('click', () => this.handleClick.bind(this))
} }
handleClick(e: MouseEvent) { handleClick(e: MouseEvent) {
@ -88,8 +87,8 @@ export default (): ProseMirrorExtension => ({
inputRules({ rules: [todoListRule(schema.nodes.todo_item)] }) inputRules({ rules: [todoListRule(schema.nodes.todo_item)] })
], ],
nodeViews: { nodeViews: {
todo_item: (node: any, view, getPos) => { todo_item: (node, view, getPos) => {
return new TodoItemView(node, view, getPos) return new TodoItemView(node, view, getPos)
} }
} as any }
}) })

View File

@ -1,11 +1,11 @@
import { Plugin, EditorState } from 'prosemirror-state' import { Plugin, EditorState } from 'prosemirror-state'
import type { Node, Schema, SchemaSpec } from 'prosemirror-model' import { Node, Schema, SchemaSpec } from 'prosemirror-model'
import type { Decoration, EditorView, NodeView } from 'prosemirror-view' import { Decoration, EditorView, NodeView } from 'prosemirror-view'
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[];
nodeViews?: { [key: string]: NodeViewFn } nodeViews?: { [key: string]: NodeViewFn };
} }
export type ProseMirrorState = EditorState | unknown export type ProseMirrorState = EditorState | unknown
@ -21,7 +21,7 @@ export const isInitialized = (state: any) => state !== undefined && state instan
export const isEmpty = (state: any) => export const isEmpty = (state: any) =>
!isInitialized(state) || !isInitialized(state) ||
(state.doc.childCount === 1 && (state.doc.childCount == 1 &&
!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)

View File

@ -1,52 +1,59 @@
// import menu from './extension/menu'
// import scroll from './prosemirror/extension/scroll'
import { keymap } from 'prosemirror-keymap' import { keymap } from 'prosemirror-keymap'
import type { ProseMirrorExtension } from './helpers' import { ProseMirrorExtension } from './helpers'
import { Schema } from 'prosemirror-model' import { Schema } from 'prosemirror-model'
import { t } from '../../../utils/intl'
import base from './extension/base' import base from './extension/base'
import code from './extension/code'
import dragHandle from './extension/drag-handle'
import image from './extension/image'
import link from './extension/link'
import markdown from './extension/markdown' import markdown from './extension/markdown'
import link from './extension/link'
// import scroll from './prosemirror/extension/scroll'
import todoList from './extension/todo-list'
import code from './extension/code'
import strikethrough from './extension/strikethrough'
import placeholder from './extension/placeholder'
// import menu from './extension/menu'
import image from './extension/image'
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, YOptions } from '../store/context' import { Config, YOptions } from '../store/context'
import selectionMenu from './extension/selection' import selectionMenu from './extension/selection'
import type { Command } from 'prosemirror-state'
import placeholder from './extension/placeholder'
import todoList from './extension/todo-list'
import strikethrough from './extension/strikethrough'
import scrollPlugin from './extension/scroll'
interface ExtensionsProps { 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?: YOptions y?: YOptions;
schema?: Schema schema?: Schema;
collab?: boolean
typewriterMode?: boolean
} }
const customKeymap = (props: ExtensionsProps): ProseMirrorExtension => ({ const customKeymap = (props: Props): ProseMirrorExtension => ({
plugins: (prev) => (props.keymap ? [...prev, keymap(props.keymap)] : prev) plugins: (prev) => (props.keymap ? [...prev, keymap(props.keymap)] : prev)
}) })
/*
const codeMirrorKeymap = (props: Props) => {
const keys = []
for (const key in props.keymap) {
keys.push({key: key, run: props.keymap[key]})
}
export const createExtensions = (props: ExtensionsProps): ProseMirrorExtension[] => { return cmKeymap.of(keys)
const eee = [ }
placeholder(t('Just start typing...')), */
export const createExtensions = (props: Props): ProseMirrorExtension[] =>
props.markdown
? [
placeholder('Просто начните...'),
customKeymap(props), customKeymap(props),
base(props.markdown), base(props.markdown),
selectionMenu(), collab(props.y),
scrollPlugin(props.config?.typewriterMode) selectionMenu()
] ]
if (props.markdown) { : [
eee.push( selectionMenu(),
customKeymap(props),
base(props.markdown),
markdown(), markdown(),
todoList(), todoList(),
dragHandle(), dragHandle(),
@ -55,7 +62,9 @@ export const createExtensions = (props: ExtensionsProps): ProseMirrorExtension[]
link(), link(),
table(), table(),
image(props.path), image(props.path),
pasteMarkdown() pasteMarkdown(),
collab(props.y)
// scroll(props.config.typewriterMode),
/* /*
codeBlock({ codeBlock({
theme: codeTheme(props.config), theme: codeTheme(props.config),
@ -65,11 +74,7 @@ export const createExtensions = (props: ExtensionsProps): ProseMirrorExtension[]
extensions: () => [codeMirrorKeymap(props)], extensions: () => [codeMirrorKeymap(props)],
}), }),
*/ */
) ]
}
if (props.collab) eee.push(collab(props.y))
return eee
}
export const createEmptyText = () => ({ export const createEmptyText = () => ({
doc: { doc: {
@ -83,7 +88,7 @@ export const createEmptyText = () => ({
} }
}) })
export const createSchema = (props: ExtensionsProps) => { export const createSchema = (props: Props) => {
const extensions = createExtensions({ const extensions = createExtensions({
config: props.config, config: props.config,
markdown: props.markdown, markdown: props.markdown,

View File

@ -1,4 +1,4 @@
import type { EditorState } from 'prosemirror-state' import { EditorState } from 'prosemirror-state'
import { serialize } from './markdown' import { serialize } from './markdown'
export const copy = async (text: string): Promise<void> => { export const copy = async (text: string): Promise<void> => {

View File

@ -1,29 +1,28 @@
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 { 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 debounce from 'lodash/debounce' import { WebrtcProvider } from 'y-webrtc'
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'
import { debounce } from 'lodash'
import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup' import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup'
import { State, Draft, Config, ServiceError, newState } from './context' import { State, File, Config, ServiceError, newState } from './context'
import { mod } from '../env'
import { serialize, createMarkdownParser } from '../markdown' import { serialize, createMarkdownParser } from '../markdown'
import db from '../db' import db from '../db'
import { isEmpty, isInitialized } from '../prosemirror/helpers' import { isEmpty, isInitialized } from '../prosemirror/helpers'
import { createSignal } from 'solid-js' import { Awareness } from 'y-protocols/awareness'
const isText = (x) => x && x.doc && x.selection const isText = (x: any) => x && x.doc && x.selection
const isDraft = (x): boolean => x && (x.text || x.path) const isState = (x: any) => typeof x.lastModified !== 'string' && Array.isArray(x.files)
const mod = 'Ctrl' const isFile = (x: any): boolean => x && (x.text || x.path)
export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => { export const createCtrl = (initial: State): [Store<State>, any] => {
const [store, setState] = createStore(initial) const [store, setState] = createStore(initial)
const onNew = () => {
newDraft()
return true
}
const onDiscard = () => { const onDiscard = () => {
discard() discard()
return true return true
@ -32,14 +31,19 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
const onToggleMarkdown = () => toggleMarkdown() const onToggleMarkdown = () => toggleMarkdown()
const onUndo = () => { const onUndo = () => {
if (!isInitialized(store.text as EditorState)) return if (!isInitialized(store.text)) return
const text = store.text as EditorState const text = store.text as EditorState
store.collab?.started ? yUndo(text) : undo(text, store.editorView.dispatch) if (store.collab?.started) {
yUndo(text)
} else {
undo(text, store.editorView.dispatch)
}
return true return true
} }
const onRedo = () => { const onRedo = () => {
if (!isInitialized(store.text as EditorState)) return if (!isInitialized(store.text)) return
const text = store.text as EditorState const text = store.text as EditorState
if (store.collab?.started) { if (store.collab?.started) {
yRedo(text) yRedo(text)
@ -51,59 +55,53 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
} }
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`]: onToggleMarkdown [`${mod}-m`]: onToggleMarkdown
} 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 createTextFromFile = async (file: File) => {
const state = unwrap(store)
const extensions = createExtensions({ const extensions = createExtensions({
config: state.config, config: state.config,
markdown: draft.markdown, markdown: file.markdown,
path: draft.path, path: file.path,
keymap keymap
}) })
return { return {
text: draft.text, text: file.text,
extensions, extensions,
lastModified: draft.lastModified ? new Date(draft.lastModified) : undefined, lastModified: file.lastModified ? new Date(file.lastModified) : undefined,
path: draft.path, path: file.path,
markdown: draft.markdown markdown: file.markdown
} }
} }
// eslint-disable-next-line unicorn/consistent-function-scoping const addToFiles = (files: File[], prev: State) => {
const addToDrafts = (drafts: Draft[], prev: Draft) => { const text = prev.path ? undefined : (prev.text as EditorState).toJSON()
const text = prev.path ? undefined : JSON.stringify(prev.text)
return [ return [
...drafts, ...files,
{ {
body: text, text,
lastModified: prev.lastModified, lastModified: prev.lastModified?.toISOString(),
path: prev.path, path: prev.path,
markdown: prev.markdown markdown: prev.markdown
} as Draft }
] ]
} }
const discardText = async () => { const discardText = async () => {
const state = unwrap(store) const state = unwrap(store)
const index = state.drafts.length - 1 const index = state.files.length - 1
const draft = index !== -1 ? state.drafts[index] : undefined const file = index !== -1 ? state.files[index] : undefined
let next let next: Partial<State>
if (draft) { if (file) {
next = await createTextFromDraft(draft) next = await createTextFromFile(file)
} else { } else {
const extensions = createExtensions({ const extensions = createExtensions({
config: state.config ?? store.config, config: state.config ?? store.config,
@ -114,35 +112,46 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
next = { next = {
text: createEmptyText(), text: createEmptyText(),
extensions, extensions,
lastModified: new Date(), lastModified: undefined,
path: undefined, path: undefined,
markdown: state.markdown markdown: state.markdown
} }
} }
const drafts = state.drafts.filter((f: Draft) => f !== draft) const files = state.files.filter((f: File) => f !== file)
setState({ setState({
drafts, files,
...next, ...next,
collab: state.collab, collab: file ? undefined : state.collab,
error: undefined error: undefined
}) })
} }
const readStoredState = async (): Promise<State> => { const fetchData = async (): Promise<State> => {
const state: State = unwrap(store) const state: State = unwrap(store)
const room = window.location.pathname?.slice(1).trim() const room = window.location.pathname?.slice(1).trim()
const args = { draft: room } const args = { room: room ? room : undefined }
const data = await db.get('state') const data = await db.get('state')
let parsed: any
if (data !== undefined) { if (data !== undefined) {
try { try {
const parsed = JSON.parse(data) parsed = JSON.parse(data)
} catch (err) {
throw new ServiceError('invalid_state', data)
}
}
if (!parsed) {
return { ...state, args }
}
let text = state.text let text = state.text
if (parsed.text) { if (parsed.text) {
if (!isText(parsed.text)) { if (!isText(parsed.text)) {
throw new ServiceError('invalid_state', parsed.text) throw new ServiceError('invalid_state', parsed.text)
} }
text = parsed.text text = parsed.text
} }
@ -153,34 +162,39 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
config: undefined config: undefined
}) })
for (const draft of parsed.drafts || []) { const newState = {
if (!isDraft(draft)) {
console.error('[editor] invalid draft', draft)
}
}
return {
...parsed, ...parsed,
text, text,
extensions, extensions,
// config, // config,
args, args
lastModified: new Date(parsed.lastModified)
} }
} catch (error) {
console.error(error) if (newState.lastModified) {
return { ...state, args } newState.lastModified = new Date(newState.lastModified)
} }
for (const file of parsed.files) {
if (!isFile(file)) {
throw new ServiceError('invalid_file', file)
} }
} }
const getTheme = (state: State) => ({ theme: state.config?.theme || '' }) if (!isState(newState)) {
throw new ServiceError('invalid_state', newState)
}
return newState
}
const getTheme = (state: State) => ({ theme: state.config.theme })
const clean = () => { const clean = () => {
setState({ setState({
...newState(), ...newState(),
loading: 'initialized', loading: 'initialized',
drafts: [], files: [],
fullscreen: store.fullscreen,
lastModified: new Date(), lastModified: new Date(),
error: undefined, error: undefined,
text: undefined text: undefined
@ -190,7 +204,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
const discard = async () => { const discard = async () => {
if (store.path) { if (store.path) {
await discardText() await discardText()
} else if (store.drafts.length > 0 && isEmpty(store.text as EditorState)) { } else if (store.files.length > 0 && isEmpty(store.text)) {
await discardText() await discardText()
} else { } else {
selectAll(store.editorView.state, store.editorView.dispatch) selectAll(store.editorView.state, store.editorView.dispatch)
@ -199,128 +213,34 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
} }
const init = async () => { const init = async () => {
let state = await readStoredState() let data = await fetchData()
console.log('[editor] init with state', state)
try { try {
if (state.args?.room) { if (data.args.room) {
state = await doStartCollab(state) data = await doStartCollab(data)
} else if (state.args.text) { } else if (!data.text) {
state = await doOpenDraft(state, {
text: { ...JSON.parse(state.args.text) },
lastModified: new Date()
})
} else if (state.args.draft) {
const draft = await loadDraft(state.config, state.args.draft)
state = await doOpenDraft(state, draft)
} else if (state.path) {
const draft = await loadDraft(state.config, state.path)
state = await doOpenDraft(state, draft)
} else if (!state.text) {
const text = createEmptyText() const text = createEmptyText()
const extensions = createExtensions({ const extensions = createExtensions({
config: state.config ?? store.config, config: data.config ?? store.config,
markdown: state.markdown ?? store.markdown, markdown: data.markdown ?? store.markdown,
keymap: keymap keymap: keymap
}) })
state = { ...state, text, extensions } data = { ...data, text, extensions }
} }
} catch (error) { } catch (error) {
state = { ...state, error: error.errorObject } data = { ...data, error: error.errorObject }
} }
setState({ setState({
...state, ...data,
config: { ...state.config, ...getTheme(state) }, config: { ...data.config, ...getTheme(data) },
loading: 'initialized' loading: 'initialized'
}) })
console.log('[editor] initialized successfully', state)
} }
const loadDraft = async (config: Config, path: string): Promise<Draft> => { const saveState = () => debounce(async (state: State) => {
const [draft, setDraft] = createSignal<Draft>() const data: any = {
const schema = createSchema({
config,
markdown: false,
path,
keymap
})
const parser = createMarkdownParser(schema)
return {
...draft(),
text: {
doc: parser.parse(draft().body).toJSON(),
selection: {
type: 'text',
anchor: 1,
head: 1
}
},
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((d: Draft) => d !== item)
if (!isEmpty(state.text as EditorState) && state.lastModified) {
drafts = addToDrafts(drafts, { lastModified: new Date(), text: state.text } as Draft)
}
draft.lastModified = item.lastModified
const next = await createTextFromDraft(draft)
return {
...state,
...next,
drafts,
collab: undefined,
error: undefined
}
}
const saveState = () =>
debounce(async (state: State) => {
const data: State = {
loading: 'initialized',
lastModified: state.lastModified, lastModified: state.lastModified,
drafts: state.drafts, files: state.files,
config: state.config, config: state.config,
path: state.path, path: state.path,
markdown: state.markdown, markdown: state.markdown,
@ -329,14 +249,8 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
} }
} }
if (isInitialized(state.text as EditorState)) { if (isInitialized(state.text)) {
if (state.path) {
const text = serialize(store.editorView.state)
// TODO: saving draft logix here
// 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 data.text = state.text
} }
@ -344,18 +258,21 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
db.set('state', JSON.stringify(data)) db.set('state', JSON.stringify(data))
}, 200) }, 200)
const startCollab = () => { const setFullscreen = (fullscreen: boolean) => {
setState({ fullscreen })
}
const startCollab = async () => {
const state: State = unwrap(store) const state: State = unwrap(store)
const update = doStartCollab(state) const update = await doStartCollab(state)
setState(update) setState(update)
} }
const doStartCollab = async (state: State): Promise<State> => { const doStartCollab = async (state: State): Promise<State> => {
const restoredRoom = 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()
state.args = { ...state.args, room } window.history.replaceState(null, '', `/${room}`)
let newst = state
try {
const { roomConnect } = await import('../prosemirror/p2p') const { roomConnect } = await import('../prosemirror/p2p')
const [type, provider] = roomConnect(room) const [type, provider] = roomConnect(room)
@ -364,38 +281,30 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
markdown: state.markdown, markdown: state.markdown,
path: state.path, path: state.path,
keymap, keymap,
y: { type, provider }, y: { type, provider }
collab: true
}) })
if ((restoredRoom && !isEmpty(state.text as EditorState)) || state.path) { let newState = state
let drafts = state.drafts if ((backup && !isEmpty(state.text)) || state.path) {
let files = state.files
if (!state.error) { if (!state.error) {
drafts = addToDrafts(drafts, { lastModified: new Date(), text: state.text } as Draft) files = addToFiles(files, state)
} }
newst = { newState = {
...state, ...state,
drafts, files,
lastModified: undefined, lastModified: undefined,
path: undefined, path: undefined,
error: undefined error: undefined
} }
window.history.replaceState(null, '', `/${room}`)
} }
return { return {
...newst, ...newState,
extensions, extensions,
collab: { started: true, room, y: { type, provider } } collab: { started: true, room, y: { type, provider } }
} }
} catch (error) {
console.error(error)
return {
...state,
collab: { error }
}
}
} }
const stopCollab = (state: State) => { const stopCollab = (state: State) => {
@ -404,8 +313,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
config: state.config, config: state.config,
markdown: state.markdown, markdown: state.markdown,
path: state.path, path: state.path,
keymap, keymap
collab: false
}) })
setState({ collab: undefined, extensions }) setState({ collab: undefined, extensions })
@ -417,7 +325,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
const editorState = store.text as EditorState const editorState = store.text as EditorState
const markdown = !state.markdown const markdown = !state.markdown
const selection = { type: 'text', anchor: 1, head: 1 } const selection = { type: 'text', anchor: 1, head: 1 }
let doc let doc: any
if (markdown) { if (markdown) {
const lines = serialize(editorState).split('\n') const lines = serialize(editorState).split('\n')
@ -457,7 +365,6 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
extensions, extensions,
markdown markdown
}) })
return true
} }
const updateConfig = (config: Partial<Config>) => { const updateConfig = (config: Partial<Config>) => {
@ -491,10 +398,8 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
discard, discard,
getTheme, getTheme,
init, init,
loadDraft,
newDraft,
openDraft,
saveState, saveState,
setFullscreen,
setState, setState,
startCollab, startCollab,
stopCollab, stopCollab,

View File

@ -1,84 +1,79 @@
import { createContext, useContext } from 'solid-js' import { createContext, useContext } from 'solid-js'
import type { Store } from 'solid-js/store' import { Store } from 'solid-js/store'
import type { XmlFragment } from 'yjs' import { XmlFragment } from 'yjs'
import type { WebrtcProvider } from 'y-webrtc' import { WebrtcProvider } from 'y-webrtc'
import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers' import { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
import type { EditorView } from 'prosemirror-view'
export interface Args { export interface Args {
cwd?: string cwd?: string;
draft?: string file?: string;
room?: string room?: string;
text?: string text?: any;
} }
export interface PrettierConfig { export interface PrettierConfig {
printWidth: number printWidth: number;
tabWidth: number tabWidth: number;
useTabs: boolean useTabs: boolean;
semi: boolean semi: boolean;
singleQuote: boolean singleQuote: boolean;
} }
export interface Config { export interface Config {
theme: string theme: string;
// codeTheme: string; // codeTheme: string;
// alwaysOnTop: boolean; font: string;
font: string fontSize: number;
fontSize: number contentWidth: number;
contentWidth: number typewriterMode: boolean;
typewriterMode?: boolean; prettier: PrettierConfig;
prettier: PrettierConfig
} }
export interface ErrorObject { export interface ErrorObject {
id: string id: string;
props?: any props?: unknown;
} }
export interface YOptions { export interface YOptions {
type: XmlFragment type: XmlFragment;
provider: WebrtcProvider provider: WebrtcProvider;
} }
export interface Collab { export interface Collab {
started?: boolean started?: boolean;
error?: boolean error?: boolean;
room?: string room?: string;
y?: YOptions y?: YOptions;
} }
export type LoadingType = 'loading' | 'initialized' export type LoadingType = 'loading' | 'initialized'
export interface State { export interface State {
text?: ProseMirrorState text?: ProseMirrorState;
editorView?: EditorView editorView?: any;
extensions?: ProseMirrorExtension[] extensions?: ProseMirrorExtension[];
markdown?: boolean markdown?: boolean;
lastModified?: Date lastModified?: Date;
drafts: Draft[] files: File[];
config: Config config: Config;
error?: ErrorObject error?: ErrorObject;
loading: LoadingType loading: LoadingType;
fullscreen?: boolean fullscreen: boolean;
collab?: Collab collab?: Collab;
path?: string path?: string;
args?: Args args?: Args;
isMac?: boolean
} }
export interface Draft { export interface File {
text?: { [key: string]: any } text?: { [key: string]: any };
body?: string lastModified?: string;
lastModified?: Date path?: string;
path?: string markdown?: boolean;
markdown?: boolean
extensions?: ProseMirrorExtension[]
} }
export class ServiceError extends Error { export class ServiceError extends Error {
public errorObject: ErrorObject public errorObject: ErrorObject
constructor(id: string, props: any) { constructor(id: string, props: unknown) {
super(id) super(id)
this.errorObject = { id, props } this.errorObject = { id, props }
} }
@ -90,7 +85,7 @@ export const useState = () => useContext(StateContext)
export const newState = (props: Partial<State> = {}): State => ({ export const newState = (props: Partial<State> = {}): State => ({
extensions: [], extensions: [],
drafts: [], files: [],
loading: 'loading', loading: 'loading',
fullscreen: false, fullscreen: false,
markdown: false, markdown: false,
@ -100,7 +95,7 @@ export const newState = (props: Partial<State> = {}): State => ({
font: 'muller', font: 'muller',
fontSize: 24, fontSize: 24,
contentWidth: 800, contentWidth: 800,
// typewriterMode: true, typewriterMode: true,
prettier: { prettier: {
printWidth: 80, printWidth: 80,
tabWidth: 2, tabWidth: 2,

View File

@ -10,7 +10,6 @@
.article__title { .article__title {
@include font-size(2.4rem); @include font-size(2.4rem);
line-height: 1.25; line-height: 1.25;
} }
@ -36,7 +35,6 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@include font-size(1.4rem); @include font-size(1.4rem);
padding-top: 2em; padding-top: 2em;
} }

View File

@ -1,5 +1,4 @@
.error { button {
button {
height: 50px; height: 50px;
padding: 0 20px; padding: 0 20px;
font-size: 18px; font-size: 18px;
@ -9,20 +8,18 @@
align-items: center; align-items: center;
outline: none; outline: none;
text-decoration: none; text-decoration: none;
font-family: Muller; font-family: 'JetBrains Mono';
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }
background: none; background: none;
font-family: 'Muller';
color: var(--foreground); color: var(--foreground);
border: 1px solid var(--foreground); border: 1px solid var(--foreground);
} }
button.primary { button.primary {
color: var(--primary-foreground); color: var(--primary-foreground);
border: 0; border: 0;
background: var(--primary-background); background: var(--primary-background);
}
} }

View File

@ -1,73 +1,67 @@
@import './Button';
@import './Sidebar';
.editor { .editor {
margin: 0.5em; padding-top: 1em;
padding: 1em;
min-width: 50%;
min-height: fit-content;
display: inline-block;
border: 1px dashed rgb(0 0 0 / 80%);
} }
.ProseMirror { a {
a { color: rgb(0, 100, 200);
color: rgb(0 100 200);
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
a:visited { a:visited {
color: rgb(0 100 200 / 70%); color: rgb(0, 80, 160);
} }
label { label {
display: block; display: block;
} }
input, input,
button, button,
select, select,
textarea { textarea {
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em; padding: 0.4em;
margin: 0 0 0.5em; margin: 0 0 0.5em 0;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 2px; border-radius: 2px;
} }
input:disabled { input:disabled {
color: #ccc; color: #ccc;
} }
button { button {
color: #333; color: #333;
background-color: #f4f4f4; background-color: #f4f4f4;
outline: none; outline: none;
} }
button:disabled { button:disabled {
color: #999; color: #999;
} }
button:not(:disabled):active { button:not(:disabled):active {
background-color: #ddd; background-color: #ddd;
} }
button:focus { button:focus {
border-color: #666; border-color: #666;
} }
.ProseMirror {
color: var(--foreground); color: var(--foreground);
background-color: var(--background); background-color: var(--background);
position: relative; position: relative;
word-wrap: break-word; word-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none; font-variant-ligatures: none;
outline: none; outline: none;
margin: 1em 1em 1em 2em; margin: 1em 1em 1em 2em;
@ -120,17 +114,15 @@
} }
blockquote { blockquote {
@include font-size(1.6rem);
margin: 1.5em 0;
border-left: 2px solid; border-left: 2px solid;
@include font-size(1.6rem);
margin: 1.5em 0;
padding-left: 1.6em; padding-left: 1.6em;
} }
} }
.ProseMirror-menuitem { .ProseMirror-menuitem {
font-size: small; font-size: small;
display: flex;
&:hover { &:hover {
> * { > * {
@ -160,10 +152,15 @@
} }
.ProseMirror-tooltip .ProseMirror-menu { .ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content; width: fit-content;
white-space: pre; white-space: pre;
} }
.ProseMirror-menuitem {
display: flex;
}
.ProseMirror-menuseparator { .ProseMirror-menuseparator {
border-right: 1px solid #ddd; border-right: 1px solid #ddd;
} }
@ -187,11 +184,11 @@
position: relative; position: relative;
} }
.ProseMirror-menu-dropdown::after { .ProseMirror-menu-dropdown:after {
content: ''; content: '';
border-left: 4px solid transparent; border-left: 4px solid transparent;
border-right: 4px solid transparent; border-right: 4px solid transparent;
border-top: 4px solid draftcurrentcolor; border-top: 4px solid currentColor;
opacity: 0.6; opacity: 0.6;
position: absolute; position: absolute;
right: 4px; right: 4px;
@ -209,7 +206,6 @@
.ProseMirror-menu-dropdown-menu { .ProseMirror-menu-dropdown-menu {
z-index: 15; z-index: 15;
/* min-width: 6em; */ /* min-width: 6em; */
} }
@ -227,11 +223,11 @@
margin-right: -4px; margin-right: -4px;
} }
.ProseMirror-menu-submenu-label::after { .ProseMirror-menu-submenu-label:after {
content: ''; content: '';
border-top: 4px solid transparent; border-top: 4px solid transparent;
border-bottom: 4px solid transparent; border-bottom: 4px solid transparent;
border-left: 4px solid draftcurrentcolor; border-left: 4px solid currentColor;
opacity: 0.6; opacity: 0.6;
position: absolute; position: absolute;
right: 4px; right: 4px;
@ -272,6 +268,7 @@
border-bottom: 1px solid silver; border-bottom: 1px solid silver;
background: white; background: white;
z-index: 10; z-index: 10;
-moz-box-sizing: border-box;
box-sizing: border-box; box-sizing: border-box;
overflow: visible; overflow: visible;
} }
@ -286,7 +283,7 @@
} }
.ProseMirror-icon svg { .ProseMirror-icon svg {
fill: draftcurrentcolor; fill: currentColor;
height: 1em; height: 1em;
} }
@ -306,6 +303,10 @@
background: transparent; background: transparent;
} }
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection { .ProseMirror-hideselection {
caret-color: transparent; caret-color: transparent;
} }
@ -319,7 +320,7 @@ li.ProseMirror-selectednode {
outline: none; outline: none;
} }
li.ProseMirror-selectednode::after { li.ProseMirror-selectednode:after {
content: ''; content: '';
position: absolute; position: absolute;
left: -32px; left: -32px;
@ -349,7 +350,7 @@ li.ProseMirror-selectednode::after {
.ProseMirror-prompt { .ProseMirror-prompt {
background: #fff; background: #fff;
box-shadow: 0 4px 10px rgba(0 0 0 / 25%); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
font-size: 0.7em; font-size: 0.7em;
position: absolute; position: absolute;
} }
@ -394,7 +395,7 @@ li.ProseMirror-selectednode::after {
.tooltip { .tooltip {
background: var(--background); background: var(--background);
box-shadow: 0 4px 10px rgba(0 0 0 / 25%); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
color: #000; color: #000;
display: flex; display: flex;
position: absolute; position: absolute;

View File

@ -5,7 +5,6 @@
display: flex; display: flex;
font-family: 'JetBrains Mono'; font-family: 'JetBrains Mono';
justify-content: center; justify-content: center;
::-webkit-scrollbar { ::-webkit-scrollbar {
display: none; display: none;
} }
@ -18,7 +17,7 @@
} }
.error pre { .error pre {
background: var(--foreground); background: var(--foreground) 19;
border: 1px solid var(--foreground); border: 1px solid var(--foreground);
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;

View File

@ -0,0 +1,3 @@
.index {
width: 350px;
}

View File

@ -1,12 +1,11 @@
.layout--editor { .layout {
display: flex; display: flex;
font-family: Muller; font-family: 'Muller';
font-size: 18px; font-size: 18px;
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
border-color: var(--background); border-color: var(--background);
min-height: 100vh; min-height: 100vh;
margin-top: -2.2rem !important;
&.dark { &.dark {
background: var(--foreground); background: var(--foreground);

View File

@ -1,3 +1,16 @@
.sidebar-container {
color: rgba(255,255,255,0.5);
font-family: 'Muller';
@include font-size(1.6rem);
overflow: hidden;
position: relative;
top: 0;
p {
color: var(--foreground);
}
}
.sidebar-off { .sidebar-off {
background: #1f1f1f; background: #1f1f1f;
height: 100%; height: 100%;
@ -31,7 +44,7 @@
opacity: 0.5; opacity: 0.5;
} }
&::after { &:after {
background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='mask0_1090_23825' style='mask-type:alpha' maskUnits='userSpaceOnUse' x='0' y='14' width='4' height='4'%3E%3Crect y='14.8237' width='3.17647' height='3.17647' fill='%23fff'/%3E%3C/mask%3E%3Cg mask='url(%23mask0_1090_23825)'%3E%3Cpath d='M16.0941 1.05908H0.847027C0.379194 1.05908 0 1.43828 0 1.90611V18.0003L3.38824 14.612H16.0942C16.562 14.612 16.9412 14.2328 16.9412 13.765V1.90614C16.9412 1.43831 16.562 1.05912 16.0942 1.05912L16.0941 1.05908ZM15.2471 12.9179H1.69412V2.7532H15.2471V12.9179Z' fill='black'/%3E%3C/g%3E%3Crect x='1' y='1' width='16' height='12.8235' stroke='black' stroke-width='2'/%3E%3Crect x='4.23535' y='3.17627' width='9.52941' height='2.11765' fill='black'/%3E%3Crect x='4.23535' y='9.5293' width='7.41176' height='2.11765' fill='black'/%3E%3Crect x='4.23535' y='6.35303' width='5.29412' height='2.11765' fill='black'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='mask0_1090_23825' style='mask-type:alpha' maskUnits='userSpaceOnUse' x='0' y='14' width='4' height='4'%3E%3Crect y='14.8237' width='3.17647' height='3.17647' fill='%23fff'/%3E%3C/mask%3E%3Cg mask='url(%23mask0_1090_23825)'%3E%3Cpath d='M16.0941 1.05908H0.847027C0.379194 1.05908 0 1.43828 0 1.90611V18.0003L3.38824 14.612H16.0942C16.562 14.612 16.9412 14.2328 16.9412 13.765V1.90614C16.9412 1.43831 16.562 1.05912 16.0942 1.05912L16.0941 1.05908ZM15.2471 12.9179H1.69412V2.7532H15.2471V12.9179Z' fill='black'/%3E%3C/g%3E%3Crect x='1' y='1' width='16' height='12.8235' stroke='black' stroke-width='2'/%3E%3Crect x='4.23535' y='3.17627' width='9.52941' height='2.11765' fill='black'/%3E%3Crect x='4.23535' y='9.5293' width='7.41176' height='2.11765' fill='black'/%3E%3Crect x='4.23535' y='6.35303' width='5.29412' height='2.11765' fill='black'/%3E%3C/svg%3E");
content: ''; content: '';
height: 18px; height: 18px;
@ -60,8 +73,7 @@
} }
.sidebar-label { .sidebar-label {
color: var(--foreground); color: var(--foreground) 7f;
> i { > i {
text-transform: none; text-transform: none;
} }
@ -71,7 +83,6 @@
margin: 10px 0; margin: 10px 0;
margin-bottom: 30px; margin-bottom: 30px;
} }
.sidebar-container button, .sidebar-container button,
.sidebar-container a, .sidebar-container a,
.sidebar-item { .sidebar-item {
@ -81,7 +92,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
line-height: 24px; line-height: 24px;
font-family: Muller; font-family: 'Muller';
text-align: left;
} }
.sidebar-container a, .sidebar-container a,
@ -92,22 +104,8 @@
} }
.sidebar-container { .sidebar-container {
@include font-size(1.6rem);
color: rgb(255 255 255 / 50%);
font-family: Muller;
display: inline-flex;
overflow: hidden;
position: relative;
top: 0;
p {
color: var(--foreground);
}
h4 { h4 {
@include font-size(120%); @include font-size(120%);
margin-left: 1rem; margin-left: 1rem;
} }
@ -140,12 +138,12 @@
} }
&[disabled] { &[disabled] {
color: var(--foreground); color: var(--foreground) 99;
cursor: not-allowed; cursor: not-allowed;
} }
&.file { &.file {
color: rgb(255 255 255 / 50%); color: rgba(255,255,255,0.5);
line-height: 1.4; line-height: 1.4;
margin: 0 0 1em 1.5em; margin: 0 0 1em 1.5em;
width: calc(100% - 2rem); width: calc(100% - 2rem);
@ -178,22 +176,20 @@
} }
.theme-switcher { .theme-switcher {
border-bottom: 1px solid rgb(255 255 255 / 30%); border-bottom: 1px solid rgba(255,255,255,0.3);
border-top: 1px solid rgb(255 255 255 / 30%); border-top: 1px solid rgba(255,255,255,0.3);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin: 1rem; margin: 1rem;
padding: 1em 0; padding: 1em 0;
input[type='checkbox'] { input[type=checkbox] {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
+ label { + label {
background: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.20869 7.73227C5.22953 7.36499 4.38795 6.70402 3.79906 5.83976C3.2103 4.97565 2.90318 3.95064 2.91979 2.90512C2.93639 1.8597 3.27597 0.844915 3.8919 0C2.82862 0.254038 1.87585 0.844877 1.17594 1.68438C0.475894 2.52388 0.0660276 3.5671 0.00731938 4.6585C-0.0513888 5.74989 0.244296 6.83095 0.850296 7.74073C1.45631 8.65037 2.34006 9.33992 3.36994 9.70637C4.39987 10.073 5.52063 10.0969 6.56523 9.77466C7.60985 9.45247 8.52223 8.80134 9.16667 7.91837C8.1842 8.15404 7.15363 8.08912 6.20869 7.73205V7.73227Z' fill='white'/%3E%3C/svg%3E%0A") background: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.20869 7.73227C5.22953 7.36499 4.38795 6.70402 3.79906 5.83976C3.2103 4.97565 2.90318 3.95064 2.91979 2.90512C2.93639 1.8597 3.27597 0.844915 3.8919 0C2.82862 0.254038 1.87585 0.844877 1.17594 1.68438C0.475894 2.52388 0.0660276 3.5671 0.00731938 4.6585C-0.0513888 5.74989 0.244296 6.83095 0.850296 7.74073C1.45631 8.65037 2.34006 9.33992 3.36994 9.70637C4.39987 10.073 5.52063 10.0969 6.56523 9.77466C7.60985 9.45247 8.52223 8.80134 9.16667 7.91837C8.1842 8.15404 7.15363 8.08912 6.20869 7.73205V7.73227Z' fill='white'/%3E%3C/svg%3E%0A") no-repeat 30px 9px,
no-repeat 30px 9px, url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.41196 0H5.58811V2.43024H6.41196V0ZM5.99988 8.96576C4.36601 8.96576 3.03419 7.63397 3.03419 6.00007C3.04792 4.3662 4.36598 3.04818 5.99988 3.03439C7.63375 3.03439 8.96557 4.3662 8.96557 6.00007C8.96557 7.63395 7.63375 8.96576 5.99988 8.96576ZM5.58811 9.56977H6.41196V12H5.58811V9.56977ZM12.0002 5.58811H9.56996V6.41196H12.0002V5.58811ZM0 5.58811H2.43024V6.41196H0V5.58811ZM8.81339 3.76727L10.5318 2.04891L9.94925 1.46641L8.23089 3.18477L8.81339 3.76727ZM3.7745 8.8129L2.05614 10.5313L1.47364 9.94877L3.192 8.2304L3.7745 8.8129ZM9.95043 10.5269L10.5329 9.94437L8.81456 8.22601L8.23207 8.80851L9.95043 10.5269ZM3.76864 3.18731L3.18614 3.76981L1.46778 2.05145L2.05028 1.46895L3.76864 3.18731Z' fill='%231F1F1F'/%3E%3C/svg%3E%0A") #000 no-repeat 8px 8px;
url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.41196 0H5.58811V2.43024H6.41196V0ZM5.99988 8.96576C4.36601 8.96576 3.03419 7.63397 3.03419 6.00007C3.04792 4.3662 4.36598 3.04818 5.99988 3.03439C7.63375 3.03439 8.96557 4.3662 8.96557 6.00007C8.96557 7.63395 7.63375 8.96576 5.99988 8.96576ZM5.58811 9.56977H6.41196V12H5.58811V9.56977ZM12.0002 5.58811H9.56996V6.41196H12.0002V5.58811ZM0 5.58811H2.43024V6.41196H0V5.58811ZM8.81339 3.76727L10.5318 2.04891L9.94925 1.46641L8.23089 3.18477L8.81339 3.76727ZM3.7745 8.8129L2.05614 10.5313L1.47364 9.94877L3.192 8.2304L3.7745 8.8129ZM9.95043 10.5269L10.5329 9.94437L8.81456 8.22601L8.23207 8.80851L9.95043 10.5269ZM3.76864 3.18731L3.18614 3.76981L1.46778 2.05145L2.05028 1.46895L3.76864 3.18731Z' fill='%231F1F1F'/%3E%3C/svg%3E%0A")
#000 no-repeat 8px 8px;
border-radius: 14px; border-radius: 14px;
cursor: pointer; cursor: pointer;
display: block; display: block;
@ -204,7 +200,7 @@
transition: background-color 0.3s; transition: background-color 0.3s;
width: 46px; width: 46px;
&::before { &:before {
background-color: #fff; background-color: #fff;
border-radius: 100%; border-radius: 100%;
content: ''; content: '';
@ -220,7 +216,7 @@
&:checked + label { &:checked + label {
background-color: #fff; background-color: #fff;
&::before { &:before {
background-color: #1f1f1f; background-color: #1f1f1f;
left: 24px; left: 24px;
} }