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'
],
rules: {
'no-nested-ternary': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{

View File

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

View File

@ -1,20 +1,19 @@
import { Switch, Match } from 'solid-js'
import { useState } from '../store/context'
import '../styles/Button.scss'
import { ErrorObject, useState } from '../store/context'
import { t } from '../../../utils/intl'
export default () => {
const [store] = useState()
return (
<Switch fallback={<Other />}>
<Match when={store.error.id === 'invalid_state'}>
<InvalidState title="Invalid State" />
<InvalidState title='Invalid State' />
</Match>
<Match when={store.error.id === 'invalid_config'}>
<InvalidState title="Invalid Config" />
<InvalidState title='Invalid Config' />
</Match>
<Match when={store.error.id === 'invalid_draft'}>
<InvalidState title="Invalid Draft" />
<Match when={store.error.id === 'invalid_file'}>
<InvalidState title='Invalid File' />
</Match>
</Switch>
)
@ -25,17 +24,19 @@ const InvalidState = (props: { title: string }) => {
const onClick = () => ctrl.clean()
return (
<div class="error">
<div class="container">
<div class='error'>
<div class='container'>
<h1>{props.title}</h1>
<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>
<pre>
<code>{JSON.stringify(store.error.props)}</code>
</pre>
<button class="primary" onClick={onClick}>
{t('Clean')}
<button class='primary' onClick={onClick}>
Clean
</button>
</div>
</div>
@ -47,18 +48,18 @@ const Other = () => {
const onClick = () => ctrl.discard()
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 (
<div class="error">
<div class="container">
<div class='error'>
<div class='container'>
<h1>An error occurred.</h1>
<pre>
<code>{getMessage()}</code>
</pre>
<button class="primary" onClick={onClick}>
<button class='primary' onClick={onClick}>
Close
</button>
</div>

View File

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

View File

@ -1,19 +1,19 @@
import { createEffect, untrack } from 'solid-js'
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 { Schema } from 'prosemirror-model'
import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
import { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
interface Props {
style?: string
className?: string
text?: Store<ProseMirrorState>
editorView?: Store<EditorView>
extensions?: Store<ProseMirrorExtension[]>
onInit: (s: EditorState, v: EditorView) => void
onReconfigure: (s: EditorState) => void
onChange: (s: EditorState) => void
style?: string;
className?: string;
text?: Store<ProseMirrorState>;
editorView?: Store<EditorView>;
extensions?: Store<ProseMirrorExtension[]>;
onInit: (s: EditorState, v: EditorView) => void;
onReconfigure: (s: EditorState) => void;
onChange: (s: EditorState) => void;
}
export const ProseMirror = (props: Props) => {
@ -28,39 +28,45 @@ export const ProseMirror = (props: Props) => {
props.onChange(newState)
}
createEffect(
(payload: [EditorState, ProseMirrorExtension[]]) => {
const [prevText, prevExtensions] = payload
const text = unwrap(props.text)
const extensions: ProseMirrorExtension[] = unwrap(props.extensions)
if (!text || !extensions?.length) {
return [text, extensions]
}
if (!props.editorView) {
const { editorState, nodeViews } = createEditorState(text, extensions)
const view = new EditorView(editorRef, { state: editorState, nodeViews, dispatchTransaction })
view.focus()
props.onInit(editorState, view)
return [editorState, extensions]
}
if (extensions !== prevExtensions || (!(text instanceof EditorState) && text !== prevText)) {
const { editorState, nodeViews } = createEditorState(text, extensions, prevText)
if (!editorState) return
editorView().updateState(editorState)
editorView().setProps({ nodeViews, dispatchTransaction })
props.onReconfigure(editorState)
editorView().focus()
return [editorState, extensions]
}
createEffect((payload: [EditorState, ProseMirrorExtension[]]) => {
const [prevText, prevExtensions] = payload
const text: EditorState = unwrap(props.text)
const extensions: ProseMirrorExtension[] = unwrap(props.extensions)
if (!text || !extensions?.length) {
return [text, extensions]
},
[props.text, props.extensions]
}
if (!props.editorView) {
const { editorState, nodeViews } = createEditorState(text, extensions)
const view = new EditorView(editorRef, { state: editorState, nodeViews, dispatchTransaction })
view.focus()
props.onInit(editorState, view)
return [editorState, extensions]
}
if (extensions !== prevExtensions || (!(text instanceof EditorState) && text !== prevText)) {
const { editorState, nodeViews } = createEditorState(text, extensions, prevText)
if (!editorState) return
editorView().updateState(editorState)
editorView().setProps({ nodeViews, dispatchTransaction })
props.onReconfigure(editorState)
editorView().focus()
return [editorState, extensions]
}
return [text, extensions]
},
[props.text, props.extensions]
)
return <div style={props.style} ref={editorRef} class={props.className} spell-check={false} />
return (
<div
style={props.style}
ref={editorRef}
className={props.className}
spell-check={false}
/>
)
}
const createEditorState = (
@ -68,8 +74,8 @@ const createEditorState = (
extensions: ProseMirrorExtension[],
prevText?: EditorState
): {
editorState: EditorState
nodeViews: { [key: string]: NodeViewFn }
editorState: EditorState;
nodeViews: { [key: string]: NodeViewFn };
} => {
const reconfigure = text instanceof EditorState && prevText?.schema
let schemaSpec = { nodes: {} }
@ -95,10 +101,10 @@ const createEditorState = (
let editorState: EditorState
if (reconfigure) {
editorState = text.reconfigure({ schema, plugins } as EditorStateConfig)
editorState = text.reconfigure({ schema, plugins })
} else if (text instanceof EditorState) {
editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON())
} else if (text) {
} else if (text){
console.debug(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 { 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 { isEmpty /*, isInitialized*/ } from '../prosemirror/helpers'
import type { Styled } from './Layout'
import { isEmpty } from '../prosemirror/helpers'
import { Styled } from './Layout'
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 = (
props: Styled & { withMargin?: boolean; disabled?: boolean; title?: string; className?: string }
) => (
<button
class={`sidebar-link${props.className ? ' ' + props.className : ''}`}
style={{ 'margin-bottom': props.withMargin ? '10px' : '' }}
class={`sidebar-link${props.className ? ` ${props.className}` : ''}`}
style={{ marginBottom: props.withMargin ? '10px' : '' }}
onClick={props.onClick}
disabled={props.disabled}
title={props.title}
@ -27,59 +27,54 @@ const Link = (
</button>
)
const Keys = (props) => (
<span>
<For each={props.keys}>{(k: Element) => <i>{k}</i>}</For>
</span>
)
export const Sidebar = () => {
const [isMac, setIsMac] = createSignal(false)
onMount(() => setIsMac(window?.navigator.platform.includes('Mac')))
// eslint-disable-next-line unicorn/consistent-function-scoping
// const isDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches
const mod = isMac() ? 'Cmd' : 'Ctrl'
// const alt = isMac() ? 'Cmd' : 'Alt'
const [store, ctrl] = useState()
const [lastAction, setLastAction] = createSignal<string | undefined>()
const toggleTheme = () => {
document.body.classList.toggle('dark')
ctrl.updateConfig({ theme: document.body.className })
}
const collabText = () => {
if (store.collab?.started) {
return t('Stop collab')
} else {
return store.collab?.error ? t('Restart collab') : t('Start collab')
}
}
const collabText = () => (store.collab?.started ? 'Stop' : store.collab?.error ? 'Restart 🚨' : 'Start')
const editorView = () => unwrap(store.editorView)
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 onUndo = () => undo(editorView().state, editorView().dispatch)
const onRedo = () => redo(editorView().state, editorView().dispatch)
const onCopyAllAsMd = () =>
remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md'))
const onCopyAllAsMd = () => remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md'))
const onDiscard = () => ctrl.discard()
const [isHidden, setIsHidden] = createSignal<boolean | false>()
const toggleSidebar = () => setIsHidden(!isHidden())
toggleSidebar()
// eslint-disable-next-line sonarjs/cognitive-complexity
const DraftLink = (p: { draft: Draft }) => {
const toggleSidebar = () => {
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
let content = ''
const getContent = (node: any) => {
if (node.text) content += node.text
if (node.text) {
content += node.text
}
if (content.length > length) {
content = content.slice(0, Math.max(0, length)) + '...'
content = content.substring(0, length) + '...'
return content
}
if (node.content) {
for (const child of node.content) {
if (content.length >= length) break
if (content.length >= length) {
break
}
content = getContent(child)
}
}
@ -88,90 +83,89 @@ export const Sidebar = () => {
}
const text = () =>
p.draft.path
? p.draft.path.slice(Math.max(0, p.draft.path.length - length))
: getContent(p.draft.text?.doc)
p.file.path ? p.file.path.substring(p.file.path.length - length) : getContent(p.file.text?.doc)
return (
// eslint-disable-next-line solid/no-react-specific-props
<Link className="draft" onClick={() => onOpenDraft(p.draft)} data-testid="open">
{text()} {p.draft.path && '📎'}
<Link className='file' onClick={() => onOpenFile(p.file)} data-testid='open'>
{text()} {p.file.path && '📎'}
</Link>
)
}
const onCollab = () => {
const state = unwrap(store)
store.collab?.started ? ctrl.stopCollab(state) : ctrl.startCollab(state)
}
const Keys = ({ keys }: { keys: string[] }) => (
<span>
{keys.map((k) => (
<i>{k}</i>
))}
</span>
)
createEffect(() => {
if (store.lastModified) setLastAction()
})
setLastAction(undefined)
}, store.lastModified)
createEffect(() => {
if (!lastAction()) return
const id = setTimeout(() => {
setLastAction()
setLastAction(undefined)
}, 1000)
onCleanup(() => clearTimeout(id))
})
return (
<div class={'sidebar-container' + (isHidden() ? ' sidebar-container--hidden' : '')}>
<span class="sidebar-opener" onClick={toggleSidebar}>
Советы и&nbsp;предложения
</span>
<div className={'sidebar-container' + (isHidden() ? ' sidebar-container--hidden' : '')}>
<span className='sidebar-opener' onClick={toggleSidebar}>Советы и&nbsp;предложения</span>
<Off onClick={() => editorView().focus()}>
<div class="sidebar-closer" onClick={toggleSidebar} />
<div className='sidebar-closer' onClick={toggleSidebar}/>
<Show when={true}>
<div>
{store.path && (
<Label>
<i>({store.path.slice(Math.max(0, store.path.length - 24))})</i>
<i>({store.path.substring(store.path.length - 24)})</i>
</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} />
<label for="theme">Ночная тема</label>
<input type='checkbox' name='theme' id='theme' onClick={toggleTheme} />
<label for='theme'>Ночная тема</label>
</div>
<Link
onClick={onDiscard}
disabled={!store.path && store.drafts.length === 0 && isEmpty(store.text)}
data-testid="discard"
disabled={!store.path && store.files.length === 0 && isEmpty(store.text)}
data-testid='discard'
>
{/* eslint-disable-next-line no-nested-ternary */}
{store.path
? 'Close'
: (store.drafts.length > 0 && isEmpty(store.text)
? 'Delete ⚠️'
: 'Clear')}{' '}
{store.path ? 'Close' : store.files.length > 0 && isEmpty(store.text) ? 'Delete ⚠️' : 'Clear'}{' '}
<Keys keys={[mod, 'w']} />
</Link>
<Link onClick={onUndo}>
Undo <Keys keys={[mod, 'z']} />
</Link>
<Link onClick={onRedo}>
Redo <Keys keys={[mod, ...(isMac() ? ['Shift', 'z'] : ['y'])]} />
Redo <Keys keys={[mod, ...['Shift', 'z']]} />
</Link>
<Link onClick={onToggleMarkdown} data-testid="markdown">
<Link onClick={onToggleMarkdown} data-testid='markdown'>
Markdown mode {store.markdown && '✅'} <Keys keys={[mod, 'm']} />
</Link>
<Link onClick={onCopyAllAsMd}>Copy all as MD {lastAction() === 'copy-md' && '📋'}</Link>
<Show when={store.drafts.length > 0}>
<Show when={store.files.length > 0}>
<h4>Drafts:</h4>
<p>
<For each={store.drafts}>{(draft: Draft) => <DraftLink draft={draft} />}</For>
<For each={store.files}>{(file) => <FileLink file={file} />}</For>
</p>
</Show>
<Link onClick={onCollab} title={store.collab?.error ? 'Connection error' : ''}>
{collabText()}
Collab {collabText()}
</Link>
<Show when={collabUsers() > 0}>
<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 { MarkdownSerializer, MarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'
import type { Node, Schema } from 'prosemirror-model'
import type { EditorState } from 'prosemirror-state'
import { Node, Schema } from 'prosemirror-model'
import { EditorState } from 'prosemirror-state'
export const serialize = (state: EditorState) => {
let text = markdownSerializer.serialize(state.doc)
@ -12,24 +12,10 @@ export const serialize = (state: EditorState) => {
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(
{
...defaultMarkdownSerializer.nodes,
image(state: any, node) {
image(state, node) {
const alt = state.esc(node.attrs.alt || '')
const src = node.attrs.path ?? node.attrs.src
const title = node.attrs.title ? state.quote(node.attrs.title) : undefined
@ -102,6 +88,20 @@ export const markdownSerializer = new MarkdownSerializer(
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) => {
if (table_child.type.name === 'table_head') serializeTableHead(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) {
let i = idx
function listIsTight(tokens: any, i: number) {
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
}

View File

@ -1,13 +1,12 @@
import { schema as markdownSchema } from 'prosemirror-markdown'
import type OrderedMap from 'orderedmap'
import { NodeSpec, Schema } from 'prosemirror-model'
import { Schema } from 'prosemirror-model'
import { baseKeymap } from 'prosemirror-commands'
import { sinkListItem, liftListItem } from 'prosemirror-schema-list'
import { history } from 'prosemirror-history'
import { dropCursor } from 'prosemirror-dropcursor'
import { buildKeymap } from 'prosemirror-example-setup'
import { keymap } from 'prosemirror-keymap'
import type { ProseMirrorExtension } from '../helpers'
import { ProseMirrorExtension } from '../helpers'
const plainSchema = new Schema({
nodes: {
@ -36,16 +35,13 @@ export default (plain = false): ProseMirrorExtension => ({
schema: () =>
plain
? {
nodes: plainSchema.spec.nodes,
marks: plainSchema.spec.marks
}
nodes: plainSchema.spec.nodes,
marks: plainSchema.spec.marks
}
: {
nodes: (markdownSchema.spec.nodes as OrderedMap<NodeSpec>).update(
'blockquote',
blockquoteSchema as unknown as NodeSpec
),
marks: markdownSchema.spec.marks
},
nodes: (markdownSchema.spec.nodes as any).update('blockquote', blockquoteSchema),
marks: markdownSchema.spec.marks
},
plugins: (prev, schema) => [
...prev,
keymap({

View File

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

View File

@ -1,14 +1,8 @@
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'
import type { YOptions } from '../../store/context'
import type { ProseMirrorExtension } from '../helpers'
import { ProseMirrorExtension } from '../helpers'
import { YOptions } from '../../store/context'
export interface EditingProps {
name: string
foreground: string
background: string
}
export const cursorBuilder = (user: EditingProps): HTMLElement => {
export const cursorBuilder = (user: any): HTMLElement => {
const cursor = document.createElement('span')
cursor.classList.add('ProseMirror-yjs-cursor')
cursor.setAttribute('style', `border-color: ${user.background}`)
@ -23,10 +17,11 @@ export default (y: YOptions): ProseMirrorExtension => ({
plugins: (prev) =>
y
? [
...prev,
ySyncPlugin(y.type),
yCursorPlugin(y.provider.awareness, { cursorBuilder }),
yUndoPlugin()
]
...prev,
ySyncPlugin(y.type),
// @ts-ignore
yCursorPlugin(y.provider.awareness, { cursorBuilder }),
yUndoPlugin()
]
: prev
})

View File

@ -1,7 +1,11 @@
import { Plugin, NodeSelection } from 'prosemirror-state'
import { DecorationSet, Decoration } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../helpers'
import handleIcon from '../../../../assets/handle.svg'
import { ProseMirrorExtension } from '../helpers'
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 handle = document.createElement('span')
@ -18,8 +22,8 @@ const handlePlugin = new Plugin({
decorations(state) {
const decos = []
state.doc.forEach((node, pos) => {
decos.push(Decoration.widget(pos + 1, createDragHandle))
decos.push(
Decoration.widget(pos + 1, createDragHandle),
Decoration.node(pos, pos + node.nodeSize, {
class: 'draggable'
})

View File

@ -1,24 +1,23 @@
import { Plugin } from 'prosemirror-state'
import type { Node, NodeSpec, Schema } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view'
import type { NodeViewFn, ProseMirrorExtension } from '../helpers'
import type OrderedMap from 'orderedmap'
import { Node, Schema } from 'prosemirror-model'
import { EditorView } from 'prosemirror-view'
import { ProseMirrorExtension } from '../helpers'
const REGEX = /^!\[([^[\]]*?)]\((.+?)\)\s+/
const REGEX = /^!\[([^[\]]*?)\]\((.+?)\)\s+/
const MAX_MATCH = 500
const isUrl = (str: string) => {
try {
const url = new URL(str)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
} catch (_) {
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({
props: {
handleTextInput(view, from, to, text) {
@ -30,7 +29,7 @@ const imageInput = (schema: Schema, _path?: string) =>
Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset,
null,
'\uFFFC'
'\ufffc'
) + text
const match = REGEX.exec(textBefore)
@ -45,6 +44,7 @@ const imageInput = (schema: Schema, _path?: string) =>
view.dispatch(tr)
return true
}
return false
}
}
@ -69,7 +69,7 @@ const imageSchema = {
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt'),
path: (dom as NodeSpec).dataset.path
path: dom.getAttribute('data-path')
})
}
],
@ -102,12 +102,12 @@ class ImageView {
contentDOM: Element
container: HTMLElement
handle: HTMLElement
onResizeFn: (e: Event) => void
onResizeEndFn: (e: Event) => void
onResizeFn: any
onResizeEndFn: any
width: 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.view = view
this.getPos = getPos
@ -162,12 +162,12 @@ class ImageView {
export default (path?: string): ProseMirrorExtension => ({
schema: (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)],
nodeViews: {
image: (node, view, getPos) => {
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 type { EditorView } from 'prosemirror-view'
import type { Mark, Node, Schema } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../helpers'
import { EditorView } from 'prosemirror-view'
import { Mark, Node, Schema } from 'prosemirror-model'
import { ProseMirrorExtension } from '../helpers'
const REGEX = /(^|\s)\[(.+)]\(([^ ]+)(?: "(.+)")?\)/
const REGEX = /(^|\s)\[(.+)\]\(([^ ]+)(?: "(.+)")?\)/
const findMarkPosition = (mark: Mark, doc: Node, from: number, to: number) => {
let markPos = { from: -1, to: -1 }
@ -29,8 +29,9 @@ const markdownLinks = (schema: Schema) =>
apply(tr, state) {
const action = tr.getMeta(this)
if (action?.pos) {
(state as any).pos = action.pos
state.pos = action.pos
}
return state
}
},
@ -53,12 +54,11 @@ const markdownLinks = (schema: Schema) =>
const resolvePos = (view: EditorView, pos: number) => {
try {
return view.state.doc.resolve(pos)
} catch {
} catch (err) {
// ignore
}
}
// eslint-disable-next-line sonarjs/cognitive-complexity
const toLink = (view: EditorView, tr: Transaction) => {
const sel = view.state.selection
const state = pluginKey.getState(view.state)

View File

@ -1,8 +1,8 @@
import { InputRule } from 'prosemirror-inputrules'
import type { EditorState } from 'prosemirror-state'
import type { MarkType } from 'prosemirror-model'
import { EditorState } from 'prosemirror-state'
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) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs
const tr = state.tr
@ -13,6 +13,7 @@ export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs) =>
state.doc.nodesBetween(textStart, textEnd, (node) => {
if (node.marks.length > 0) {
hasMarks = true
return
}
})
@ -22,7 +23,6 @@ export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs) =>
if (textEnd < end) tr.delete(textEnd, end)
if (textStart > start) tr.delete(start, textStart)
// eslint-disable-next-line no-param-reassign
end = start + match[1].length
}

View File

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

View File

@ -13,40 +13,38 @@ import {
Dropdown
} from 'prosemirror-menu'
import type { MenuItemSpec, MenuElement } from 'prosemirror-menu'
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 type { ProseMirrorExtension } from '../helpers'
import type { Attrs, MarkType, NodeType, Schema } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view'
import { ProseMirrorExtension } from '../helpers'
// Helpers to create specific types of items
function canInsert(state: EditorState, nodeType: NodeType) {
function canInsert(state, nodeType) {
const $from = state.selection.$from
for (let d = $from.depth; d >= 0; d--) {
const index = $from.index(d)
if ($from.node(d).canReplaceWith(index, index, nodeType)) return true
}
return false
}
function insertImageItem(nodeType: NodeType) {
function insertImageItem(nodeType) {
return new MenuItem({
icon: icons.image,
label: 'image',
enable(state) {
return canInsert(state, nodeType)
},
run(state: EditorState, _, view: EditorView) {
const { from, to, node } = state.selection as NodeSelection
run(state, _, view) {
const { from, to } = state.selection
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({
title: 'Insert image',
@ -62,8 +60,7 @@ function insertImageItem(nodeType: NodeType) {
value: attrs ? attrs.alt : state.doc.textBetween(from, to, ' ')
})
},
// eslint-disable-next-line no-shadow
callback(attrs: Attrs) {
callback(attrs) {
view.dispatch(view.state.tr.replaceSelectionWith(nodeType.createAndFill(attrs)))
view.focus()
}
@ -72,31 +69,41 @@ function insertImageItem(nodeType: NodeType) {
})
}
function cmdItem(cmd: Command, options: MenuItemSpec) {
const passedOptions = { label: options.title, run: cmd } as MenuItemSpec
Object.keys(options).forEach((prop) => (passedOptions[prop] = options[prop]))
// TODO: enable/disable items logix
passedOptions.select = (state) => cmd(state)
return new MenuItem(passedOptions as MenuItemSpec)
function cmdItem(cmd, options) {
const passedOptions = {
label: options.title,
run: cmd
}
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
if (empty) return type.isInSet(state.storedMarks || $from.marks())
return state.doc.rangeHasMark(from, to, type)
}
function markItem(markType: MarkType, options: MenuItemSpec) {
function markItem(markType, options) {
const passedOptions = {
active(state) {
return markActive(state, markType)
}
} as MenuItemSpec
Object.keys(options).forEach((prop: string) => (passedOptions[prop] = options[prop]))
},
enable: true
}
for (const prop in options) passedOptions[prop] = options[prop]
return cmdItem(toggleMark(markType), passedOptions)
}
function linkItem(markType: MarkType) {
function linkItem(markType) {
return new MenuItem({
title: 'Add or remove link',
icon: {
@ -104,21 +111,27 @@ function linkItem(markType: MarkType) {
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'
},
active: (state) => Boolean(markActive(state, markType)),
enable: (state: EditorState) => !state.selection.empty,
run(state: EditorState, dispatch: (t: Transaction) => void, view: EditorView) {
active(state) {
return markActive(state, markType)
},
enable(state) {
return !state.selection.empty
},
run(state, dispatch, view) {
if (markActive(state, markType)) {
toggleMark(markType)(state, dispatch)
return true
}
openPrompt({
fields: {
href: new TextField({
label: 'Link target',
required: true
})
}),
},
callback(attrs: Attrs) {
callback(attrs) {
toggleMark(markType, attrs)(view.state, view.dispatch)
view.focus()
}
@ -127,8 +140,7 @@ function linkItem(markType: MarkType) {
})
}
function wrapListItem(nodeType: NodeType, options: MenuItemSpec & { attrs: Attrs }) {
options.run = (_) => true
function wrapListItem(nodeType, options) {
return cmdItem(wrapInList(nodeType, options.attrs), options)
}
@ -190,24 +202,9 @@ function wrapListItem(nodeType: NodeType, options: MenuItemSpec & { attrs: Attrs
// **`fullMenu`**`: [[MenuElement]]`
// : 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).
/*
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) {
export function buildMenuItems(schema) {
const r: { [key: string]: MenuItem | MenuItem[] } = {}
let type: NodeType | MarkType
let type
if ((type = schema.marks.strong)) {
r.toggleStrong = markItem(type, {
@ -215,9 +212,9 @@ export function buildMenuItems(schema: Schema) {
icon: {
width: 13,
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)) {
@ -226,21 +223,21 @@ export function buildMenuItems(schema: Schema) {
icon: {
width: 14,
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)) {
r.toggleCode = markItem(type, {
title: 'Toggle code font',
icon: icons.code
} as MenuItemSpec)
})
}
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)) {
r.wrapBulletList = wrapListItem(type, {
@ -250,7 +247,7 @@ export function buildMenuItems(schema: Schema) {
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'
}
} as MenuItemSpec & { attrs: Attrs })
})
}
if ((type = schema.nodes.ordered_list)) {
@ -261,7 +258,7 @@ export function buildMenuItems(schema: Schema) {
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'
}
} as MenuItemSpec & { attrs: Attrs })
})
}
if ((type = schema.nodes.blockquote)) {
@ -312,36 +309,28 @@ export function buildMenuItems(schema: Schema) {
r.insertHorizontalRule = new MenuItem({
label: '---',
icon: icons.horizontal_rule,
enable: (state) => canInsert(state, hr),
run(state: EditorState, dispatch: (tr: Transaction) => void) {
enable(state) {
return canInsert(state, hr)
},
run(state, dispatch) {
dispatch(state.tr.replaceSelectionWith(hr.create()))
}
})
}
const tMenu = new Dropdown(
[
r.makeHead1 as MenuElement,
r.makeHead2 as MenuElement,
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,
height: 12,
path: 'M6.39999 3.19998V0H20.2666V3.19998H14.9333V15.9999H11.7333V3.19998H6.39999ZM3.19998 8.5334H0V5.33342H9.59994V8.5334H6.39996V16H3.19998V8.5334Z'
}
}
)
r.listMenu = [r.wrapBulletList as MenuItem, r.wrapOrderedList as MenuItem]
r.inlineMenu = [r.toggleStrong as MenuItem, r.toggleEm as MenuItem, r.toggleMark as MenuItem]
r.fullMenu = [...r.inlineMenu, tMenu as MenuItem, ...r.listMenu].filter(Boolean)
const cut = (arr) => arr.filter((x) => x)
r.typeMenu = new Dropdown(
cut([r.makeHead1, r.makeHead2, r.makeHead3, r.typeMenu, r.wrapBlockQuote]),
{ label: 'Тт', icon: {
width: 12,
height: 12,
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.inlineMenu = [cut([r.toggleStrong, r.toggleEm, r.toggleMark])]
r.fullMenu = r.inlineMenu.concat([cut([r.typeMenu])], r.listMenu)
return r
}
@ -349,8 +338,8 @@ export default (): ProseMirrorExtension => ({
plugins: (prev, schema) => [
...prev,
menuBar({
floating: true,
content: buildMenuItems(schema).fullMenu as any // NOTE: MenuItem and MenuElement are compatible
floating: false,
content: buildMenuItems(schema).fullMenu
})
]
})

View File

@ -1,16 +1,16 @@
import { Plugin } from 'prosemirror-state'
import { Fragment, Node, Schema, Slice } from 'prosemirror-model'
import type { ProseMirrorExtension } from '../helpers'
import { ProseMirrorExtension } from '../helpers'
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 nodes = []
fragment.forEach((child: Node) => {
if (child.isText) {
let pos = 0
let match
let match: any
while ((match = URL_REGEX.exec(child.text)) !== null) {
const start = match.index
@ -64,7 +64,7 @@ const pasteMarkdown = (schema: Schema) => {
event.preventDefault()
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 tr = view.state.tr.replaceSelection(new Slice(fragment, slice.openStart, slice.openEnd))

View File

@ -1,6 +1,6 @@
import { Plugin } from 'prosemirror-state'
import { DecorationSet, Decoration } from 'prosemirror-view'
import { isEmpty, ProseMirrorExtension } from '../helpers'
import { ProseMirrorExtension, isEmpty } from '../helpers'
const placeholder = (text: string) =>
new Plugin({

View File

@ -1,19 +1,19 @@
const prefix = 'ProseMirror-prompt'
// eslint-disable-next-line sonarjs/cognitive-complexity
export function openPrompt(options) {
export function openPrompt(options: any) {
const wrapper = document.body.appendChild(document.createElement('div'))
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)
const close = () => {
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) => {
domFields.push(options.fields[name].render())
})
@ -32,7 +32,7 @@ export function openPrompt(options) {
if (options.title) {
form.appendChild(document.createElement('h5')).textContent = options.title
}
domFields.forEach((field) => {
domFields.forEach((field: any) => {
form.appendChild(document.createElement('div')).appendChild(field)
})
const buttons = form.appendChild(document.createElement('div'))
@ -59,25 +59,24 @@ export function openPrompt(options) {
})
form.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (e.keyCode == 27) {
e.preventDefault()
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()
submit()
} else if (e.key === 'Tab') {
} else if (e.keyCode == 9) {
window.setTimeout(() => {
if (!wrapper.contains(document.activeElement)) close()
}, 500)
}
})
const inpel = form.elements[0] as HTMLInputElement
if (inpel) inpel.focus()
const input: any = form.elements[0]
if (input) input.focus()
}
function getValues(fields, domFields) {
function getValues(fields: any, domFields: any) {
const result = Object.create(null)
let i = 0
fields.forEarch((name) => {
@ -94,35 +93,23 @@ function getValues(fields, domFields) {
return result
}
function reportInvalid(dom: HTMLElement, message: string) {
function reportInvalid(dom: any, message: any) {
const parent = dom.parentNode
const msg = parent.appendChild(document.createElement('div'))
msg.style.left = dom.offsetLeft + dom.offsetWidth + 2 + 'px'
msg.style.top = dom.offsetTop - 5 + 'px'
msg.className = 'ProseMirror-invalid'
msg.textContent = message
// eslint-disable-next-line unicorn/prefer-dom-node-remove
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 {
options: FieldOptions
constructor(options) {
options: any
constructor(options: any) {
this.options = options
}
read(dom) {
read(dom: any) {
return dom.value
}
// :: (any) → ?string
@ -131,12 +118,13 @@ export class Field {
return typeof _value === typeof ''
}
validate(value) {
validate(value: any) {
if (!value && this.options.required) return 'Required field'
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
}
}
@ -156,10 +144,10 @@ export class TextField extends Field {
export class SelectField extends Field {
render() {
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'))
opt.value = o.value
opt.selected = o.value === this.options.value
opt.selected = o.value == this.options.value
opt.label = o.label
})
return select

View File

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

View File

@ -1,57 +1,60 @@
import { renderGrouped } from 'prosemirror-menu'
import { EditorState, Plugin } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../helpers'
import { buildMenuItems } from './menu'
import { renderGrouped } from "prosemirror-menu";
import { Plugin } from "prosemirror-state";
import { ProseMirrorExtension } from "../helpers";
import { buildMenuItems } from "./menu";
export class SelectionTooltip {
tooltip: HTMLElement
tooltip: any;
constructor(view: EditorView, schema) {
this.tooltip = document.createElement('div')
this.tooltip.className = 'tooltip'
view.dom.parentNode.appendChild(this.tooltip)
console.debug('[prosemirror] selection view', view)
console.debug('[prosemirror] selection menu', buildMenuItems(schema).fullMenu)
const { dom } = renderGrouped(view, buildMenuItems(schema).fullMenu as any)
this.tooltip.appendChild(dom)
this.update(view, null)
constructor(view: any, schema: any) {
this.tooltip = document.createElement("div");
this.tooltip.className = "tooltip";
view.dom.parentNode.appendChild(this.tooltip);
const { dom } = renderGrouped(view, buildMenuItems(schema).fullMenu);
this.tooltip.appendChild(dom);
this.update(view, null);
}
update(view: EditorView, lastState: EditorState) {
const state = view.state
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return
}
update(view: any, lastState: any) {
const state = view.state;
if (
lastState &&
lastState.doc.eq(state.doc) &&
lastState.selection.eq(state.selection)
)
{return;}
if (state.selection.empty) {
this.tooltip.style.display = 'none'
return
this.tooltip.style.display = "none";
return;
}
this.tooltip.style.display = ''
const { from, to } = state.selection
this.tooltip.style.display = "";
const { from, to } = state.selection;
const start = view.coordsAtPos(from),
end = view.coordsAtPos(to)
const box = this.tooltip.offsetParent.getBoundingClientRect()
const left = Math.max((start.left + end.left) / 2, start.left + 3)
this.tooltip.style.left = left - box.left + 'px'
this.tooltip.style.bottom = box.bottom - (start.top + 15) + 'px'
end = view.coordsAtPos(to);
const box = this.tooltip.offsetParent.getBoundingClientRect();
const left = Math.max((start.left + end.left) / 2, start.left + 3);
this.tooltip.style.left = left - box.left + "px";
this.tooltip.style.bottom = box.bottom - (start.top + 15) + "px";
}
destroy() {
this.tooltip.remove()
this.tooltip.remove();
}
}
export function toolTip(schema) {
export function toolTip(schema: any) {
return new Plugin({
view(editorView: EditorView) {
return new SelectionTooltip(editorView, schema)
}
})
view(editorView: any) {
return new SelectionTooltip(editorView, schema);
},
});
}
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 type { MarkType } from 'prosemirror-model'
import { MarkType } from 'prosemirror-model'
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 = {
strikethrough: {

View File

@ -1,16 +1,15 @@
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 { keymap } from 'prosemirror-keymap'
import type { ProseMirrorExtension } from '../helpers'
import type OrderedMap from 'orderedmap'
import { ProseMirrorExtension } from '../helpers'
export const tableInputRule = (schema: Schema) =>
new InputRule(
new RegExp('^\\|{2,}\\s$'),
(state: EditorState, match: string[], start: number, end: number) => {
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 cells = columns.map(() => schema.node(schema.nodes.table_cell, {}))
const table = schema.node(schema.nodes.table, {}, [
@ -175,9 +174,8 @@ const getTextSize = (n: Node) => {
export default (): ProseMirrorExtension => ({
schema: (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) => [
keymap({
'Ctrl-Enter': (state, dispatch) => {

View File

@ -1,9 +1,10 @@
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 { keymap } from 'prosemirror-keymap'
import type { EditorView } from 'prosemirror-view'
import type { ProseMirrorExtension } from '../helpers'
import { inputRules } from 'prosemirror-inputrules'
import { ProseMirrorExtension } from '../helpers'
const todoListRule = (nodeType: NodeType) =>
wrappingInputRule(new RegExp('^\\[( |x)]\\s$'), nodeType, (match) => ({
@ -59,9 +60,7 @@ class TodoItemView {
this.contentDOM = res.contentDOM
this.view = view
this.getPos = getPos
;(this.dom as Element)
.querySelector('input')
.addEventListener('click', () => this.handleClick.bind(this))
;(this.dom as Element).querySelector('input').onclick = this.handleClick.bind(this)
}
handleClick(e: MouseEvent) {
@ -88,8 +87,8 @@ export default (): ProseMirrorExtension => ({
inputRules({ rules: [todoListRule(schema.nodes.todo_item)] })
],
nodeViews: {
todo_item: (node: any, view, getPos) => {
todo_item: (node, view, getPos) => {
return new TodoItemView(node, view, getPos)
}
} as any
}
})

View File

@ -1,11 +1,11 @@
import { Plugin, EditorState } from 'prosemirror-state'
import type { Node, Schema, SchemaSpec } from 'prosemirror-model'
import type { Decoration, EditorView, NodeView } from 'prosemirror-view'
import { Node, Schema, SchemaSpec } from 'prosemirror-model'
import { Decoration, EditorView, NodeView } from 'prosemirror-view'
export interface ProseMirrorExtension {
schema?: (prev: SchemaSpec) => SchemaSpec
plugins?: (prev: Plugin[], schema: Schema) => Plugin[]
nodeViews?: { [key: string]: NodeViewFn }
schema?: (prev: SchemaSpec) => SchemaSpec;
plugins?: (prev: Plugin[], schema: Schema) => Plugin[];
nodeViews?: { [key: string]: NodeViewFn };
}
export type ProseMirrorState = EditorState | unknown
@ -21,7 +21,7 @@ export const isInitialized = (state: any) => state !== undefined && state instan
export const isEmpty = (state: any) =>
!isInitialized(state) ||
(state.doc.childCount === 1 &&
(state.doc.childCount == 1 &&
!state.doc.firstChild.type.spec.code &&
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 type { ProseMirrorExtension } from './helpers'
import { ProseMirrorExtension } from './helpers'
import { Schema } from 'prosemirror-model'
import { t } from '../../../utils/intl'
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 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 table from './extension/table'
import collab from './extension/collab'
import type { Config, YOptions } from '../store/context'
import { Config, YOptions } from '../store/context'
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 {
data?: unknown
keymap?: { [key: string]: Command }
config: Config
markdown: boolean
path?: string
y?: YOptions
schema?: Schema
collab?: boolean
typewriterMode?: boolean
interface Props {
data?: unknown;
keymap?: any;
config: Config;
markdown: boolean;
path?: string;
y?: YOptions;
schema?: Schema;
}
const customKeymap = (props: ExtensionsProps): ProseMirrorExtension => ({
const customKeymap = (props: Props): ProseMirrorExtension => ({
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[] => {
const eee = [
placeholder(t('Just start typing...')),
customKeymap(props),
base(props.markdown),
selectionMenu(),
scrollPlugin(props.config?.typewriterMode)
]
if (props.markdown) {
eee.push(
return cmKeymap.of(keys)
}
*/
export const createExtensions = (props: Props): ProseMirrorExtension[] =>
props.markdown
? [
placeholder('Просто начните...'),
customKeymap(props),
base(props.markdown),
collab(props.y),
selectionMenu()
]
: [
selectionMenu(),
customKeymap(props),
base(props.markdown),
markdown(),
todoList(),
dragHandle(),
@ -55,21 +62,19 @@ export const createExtensions = (props: ExtensionsProps): ProseMirrorExtension[]
link(),
table(),
image(props.path),
pasteMarkdown()
pasteMarkdown(),
collab(props.y)
// scroll(props.config.typewriterMode),
/*
codeBlock({
theme: codeTheme(props.config),
typewriterMode: props.config.typewriterMode,
fontSize: props.config.fontSize,
prettier: props.config.prettier,
extensions: () => [codeMirrorKeymap(props)],
}),
*/
)
}
if (props.collab) eee.push(collab(props.y))
return eee
}
codeBlock({
theme: codeTheme(props.config),
typewriterMode: props.config.typewriterMode,
fontSize: props.config.fontSize,
prettier: props.config.prettier,
extensions: () => [codeMirrorKeymap(props)],
}),
*/
]
export const createEmptyText = () => ({
doc: {
@ -83,7 +88,7 @@ export const createEmptyText = () => ({
}
})
export const createSchema = (props: ExtensionsProps) => {
export const createSchema = (props: Props) => {
const extensions = createExtensions({
config: props.config,
markdown: props.markdown,

View File

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

View File

@ -1,29 +1,28 @@
import { Store, createStore, unwrap } from 'solid-js/store'
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 { selectAll, deleteSelection } from 'prosemirror-commands'
import * as Y from 'yjs'
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 { 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 db from '../db'
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 isDraft = (x): boolean => x && (x.text || x.path)
const mod = 'Ctrl'
const isText = (x: any) => x && x.doc && x.selection
const isState = (x: any) => typeof x.lastModified !== 'string' && Array.isArray(x.files)
const isFile = (x: any): boolean => x && (x.text || x.path)
export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
export const createCtrl = (initial: State): [Store<State>, any] => {
const [store, setState] = createStore(initial)
const onNew = () => {
newDraft()
return true
}
const onDiscard = () => {
discard()
return true
@ -32,14 +31,19 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
const onToggleMarkdown = () => toggleMarkdown()
const onUndo = () => {
if (!isInitialized(store.text as EditorState)) return
if (!isInitialized(store.text)) return
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
}
const onRedo = () => {
if (!isInitialized(store.text as EditorState)) return
if (!isInitialized(store.text)) return
const text = store.text as EditorState
if (store.collab?.started) {
yRedo(text)
@ -51,59 +55,53 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
}
const keymap = {
[`${mod}-n`]: onNew,
[`${mod}-w`]: onDiscard,
[`${mod}-z`]: onUndo,
[`Shift-${mod}-z`]: onRedo,
[`${mod}-y`]: onRedo,
[`${mod}-m`]: onToggleMarkdown
} as { [key: string]: Command }
}
const createTextFromDraft = async (d: Draft): Promise<Draft> => {
let draft = d
const createTextFromFile = async (file: File) => {
const state = unwrap(store)
if (draft.path) {
draft = await loadDraft(state.config, draft.path)
}
const extensions = createExtensions({
config: state.config,
markdown: draft.markdown,
path: draft.path,
markdown: file.markdown,
path: file.path,
keymap
})
return {
text: draft.text,
text: file.text,
extensions,
lastModified: draft.lastModified ? new Date(draft.lastModified) : undefined,
path: draft.path,
markdown: draft.markdown
lastModified: file.lastModified ? new Date(file.lastModified) : undefined,
path: file.path,
markdown: file.markdown
}
}
// eslint-disable-next-line unicorn/consistent-function-scoping
const addToDrafts = (drafts: Draft[], prev: Draft) => {
const text = prev.path ? undefined : JSON.stringify(prev.text)
const addToFiles = (files: File[], prev: State) => {
const text = prev.path ? undefined : (prev.text as EditorState).toJSON()
return [
...drafts,
...files,
{
body: text,
lastModified: prev.lastModified,
text,
lastModified: prev.lastModified?.toISOString(),
path: prev.path,
markdown: prev.markdown
} as Draft
}
]
}
const discardText = async () => {
const state = unwrap(store)
const index = state.drafts.length - 1
const draft = index !== -1 ? state.drafts[index] : undefined
const index = state.files.length - 1
const file = index !== -1 ? state.files[index] : undefined
let next
if (draft) {
next = await createTextFromDraft(draft)
let next: Partial<State>
if (file) {
next = await createTextFromFile(file)
} else {
const extensions = createExtensions({
config: state.config ?? store.config,
@ -114,73 +112,89 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
next = {
text: createEmptyText(),
extensions,
lastModified: new Date(),
lastModified: undefined,
path: undefined,
markdown: state.markdown
}
}
const drafts = state.drafts.filter((f: Draft) => f !== draft)
const files = state.files.filter((f: File) => f !== file)
setState({
drafts,
files,
...next,
collab: state.collab,
collab: file ? undefined : state.collab,
error: undefined
})
}
const readStoredState = async (): Promise<State> => {
const fetchData = async (): Promise<State> => {
const state: State = unwrap(store)
const room = window.location.pathname?.slice(1).trim()
const args = { draft: room }
const args = { room: room ? room : undefined }
const data = await db.get('state')
let parsed: any
if (data !== undefined) {
try {
const parsed = JSON.parse(data)
let text = state.text
if (parsed.text) {
if (!isText(parsed.text)) {
throw new ServiceError('invalid_state', parsed.text)
}
text = parsed.text
}
const extensions = createExtensions({
path: parsed.path,
markdown: parsed.markdown,
keymap,
config: undefined
})
for (const draft of parsed.drafts || []) {
if (!isDraft(draft)) {
console.error('[editor] invalid draft', draft)
}
}
return {
...parsed,
text,
extensions,
// config,
args,
lastModified: new Date(parsed.lastModified)
}
} catch (error) {
console.error(error)
return { ...state, args }
parsed = JSON.parse(data)
} catch (err) {
throw new ServiceError('invalid_state', data)
}
}
if (!parsed) {
return { ...state, args }
}
let text = state.text
if (parsed.text) {
if (!isText(parsed.text)) {
throw new ServiceError('invalid_state', parsed.text)
}
text = parsed.text
}
const extensions = createExtensions({
path: parsed.path,
markdown: parsed.markdown,
keymap,
config: undefined
})
const newState = {
...parsed,
text,
extensions,
// config,
args
}
if (newState.lastModified) {
newState.lastModified = new Date(newState.lastModified)
}
for (const file of parsed.files) {
if (!isFile(file)) {
throw new ServiceError('invalid_file', file)
}
}
if (!isState(newState)) {
throw new ServiceError('invalid_state', newState)
}
return newState
}
const getTheme = (state: State) => ({ theme: state.config?.theme || '' })
const getTheme = (state: State) => ({ theme: state.config.theme })
const clean = () => {
setState({
...newState(),
loading: 'initialized',
drafts: [],
files: [],
fullscreen: store.fullscreen,
lastModified: new Date(),
error: undefined,
text: undefined
@ -190,7 +204,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
const discard = async () => {
if (store.path) {
await discardText()
} else if (store.drafts.length > 0 && isEmpty(store.text as EditorState)) {
} else if (store.files.length > 0 && isEmpty(store.text)) {
await discardText()
} else {
selectAll(store.editorView.state, store.editorView.dispatch)
@ -199,203 +213,98 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
}
const init = async () => {
let state = await readStoredState()
console.log('[editor] init with state', state)
let data = await fetchData()
try {
if (state.args?.room) {
state = await doStartCollab(state)
} else if (state.args.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) {
if (data.args.room) {
data = await doStartCollab(data)
} else if (!data.text) {
const text = createEmptyText()
const extensions = createExtensions({
config: state.config ?? store.config,
markdown: state.markdown ?? store.markdown,
config: data.config ?? store.config,
markdown: data.markdown ?? store.markdown,
keymap: keymap
})
state = { ...state, text, extensions }
data = { ...data, text, extensions }
}
} catch (error) {
state = { ...state, error: error.errorObject }
data = { ...data, error: error.errorObject }
}
setState({
...state,
config: { ...state.config, ...getTheme(state) },
...data,
config: { ...data.config, ...getTheme(data) },
loading: 'initialized'
})
console.log('[editor] initialized successfully', state)
}
const loadDraft = async (config: Config, path: string): Promise<Draft> => {
const [draft, setDraft] = createSignal<Draft>()
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 saveState = () => debounce(async (state: State) => {
const data: any = {
lastModified: state.lastModified,
files: state.files,
config: state.config,
path: state.path,
markdown: state.markdown,
collab: {
room: state.collab?.room
}
}
const extensions = createExtensions({
config: state.config ?? store.config,
markdown: state.markdown ?? store.markdown,
keymap
})
if (isInitialized(state.text)) {
data.text = store.editorView.state.toJSON()
} else if (state.text) {
data.text = state.text
}
setState({
text: createEmptyText(),
extensions,
drafts,
lastModified: undefined,
path: undefined,
error: undefined,
collab: undefined
})
db.set('state', JSON.stringify(data))
}, 200)
const setFullscreen = (fullscreen: boolean) => {
setState({ fullscreen })
}
const openDraft = async (draft: Draft) => {
const startCollab = async () => {
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,
drafts: state.drafts,
config: state.config,
path: state.path,
markdown: state.markdown,
collab: {
room: state.collab?.room
}
}
if (isInitialized(state.text as EditorState)) {
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()
}
} else if (state.text) {
data.text = state.text
}
db.set('state', JSON.stringify(data))
}, 200)
const startCollab = () => {
const state: State = unwrap(store)
const update = doStartCollab(state)
const update = await doStartCollab(state)
setState(update)
}
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()
state.args = { ...state.args, room }
let newst = state
try {
const { roomConnect } = await import('../prosemirror/p2p')
const [type, provider] = roomConnect(room)
window.history.replaceState(null, '', `/${room}`)
const extensions = createExtensions({
config: state.config,
markdown: state.markdown,
path: state.path,
keymap,
y: { type, provider },
collab: true
})
const { roomConnect } = await import('../prosemirror/p2p')
const [type, provider] = roomConnect(room)
if ((restoredRoom && !isEmpty(state.text as EditorState)) || state.path) {
let drafts = state.drafts
if (!state.error) {
drafts = addToDrafts(drafts, { lastModified: new Date(), text: state.text } as Draft)
}
const extensions = createExtensions({
config: state.config,
markdown: state.markdown,
path: state.path,
keymap,
y: { type, provider }
})
newst = {
...state,
drafts,
lastModified: undefined,
path: undefined,
error: undefined
}
window.history.replaceState(null, '', `/${room}`)
let newState = state
if ((backup && !isEmpty(state.text)) || state.path) {
let files = state.files
if (!state.error) {
files = addToFiles(files, state)
}
return {
...newst,
extensions,
collab: { started: true, room, y: { type, provider } }
}
} catch (error) {
console.error(error)
return {
newState = {
...state,
collab: { error }
files,
lastModified: undefined,
path: undefined,
error: undefined
}
}
return {
...newState,
extensions,
collab: { started: true, room, y: { type, provider } }
}
}
const stopCollab = (state: State) => {
@ -404,8 +313,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
config: state.config,
markdown: state.markdown,
path: state.path,
keymap,
collab: false
keymap
})
setState({ collab: undefined, extensions })
@ -417,7 +325,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
const editorState = store.text as EditorState
const markdown = !state.markdown
const selection = { type: 'text', anchor: 1, head: 1 }
let doc
let doc: any
if (markdown) {
const lines = serialize(editorState).split('\n')
@ -457,7 +365,6 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
extensions,
markdown
})
return true
}
const updateConfig = (config: Partial<Config>) => {
@ -482,7 +389,7 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
}
const updateTheme = () => {
const { theme } = getTheme(unwrap(store))
const { theme } = getTheme(unwrap(store))
setState('config', { theme })
}
@ -491,10 +398,8 @@ export const createCtrl = (initial): [Store<State>, { [key: string]: any }] => {
discard,
getTheme,
init,
loadDraft,
newDraft,
openDraft,
saveState,
setFullscreen,
setState,
startCollab,
stopCollab,

View File

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

View File

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

View File

@ -1,28 +1,25 @@
.error {
button {
height: 50px;
padding: 0 20px;
font-size: 18px;
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center;
outline: none;
text-decoration: none;
font-family: Muller;
&:hover {
opacity: 0.8;
}
background: none;
color: var(--foreground);
border: 1px solid var(--foreground);
}
button.primary {
color: var(--primary-foreground);
border: 0;
background: var(--primary-background);
button {
height: 50px;
padding: 0 20px;
font-size: 18px;
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center;
outline: none;
text-decoration: none;
font-family: 'JetBrains Mono';
&:hover {
opacity: 0.8;
}
background: none;
font-family: 'Muller';
color: var(--foreground);
border: 1px solid var(--foreground);
}
button.primary {
color: var(--primary-foreground);
border: 0;
background: var(--primary-background);
}

View File

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

View File

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

View File

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

View File

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

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 {
background: #1f1f1f;
height: 100%;
@ -31,7 +44,7 @@
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");
content: '';
height: 18px;
@ -60,8 +73,7 @@
}
.sidebar-label {
color: var(--foreground);
color: var(--foreground) 7f;
> i {
text-transform: none;
}
@ -71,7 +83,6 @@
margin: 10px 0;
margin-bottom: 30px;
}
.sidebar-container button,
.sidebar-container a,
.sidebar-item {
@ -81,7 +92,8 @@
display: flex;
align-items: center;
line-height: 24px;
font-family: Muller;
font-family: 'Muller';
text-align: left;
}
.sidebar-container a,
@ -92,22 +104,8 @@
}
.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 {
@include font-size(120%);
margin-left: 1rem;
}
@ -140,12 +138,12 @@
}
&[disabled] {
color: var(--foreground);
color: var(--foreground) 99;
cursor: not-allowed;
}
&.file {
color: rgb(255 255 255 / 50%);
color: rgba(255,255,255,0.5);
line-height: 1.4;
margin: 0 0 1em 1.5em;
width: calc(100% - 2rem);
@ -178,22 +176,20 @@
}
.theme-switcher {
border-bottom: 1px solid rgb(255 255 255 / 30%);
border-top: 1px solid rgb(255 255 255 / 30%);
border-bottom: 1px solid rgba(255,255,255,0.3);
border-top: 1px solid rgba(255,255,255,0.3);
display: flex;
justify-content: space-between;
margin: 1rem;
padding: 1em 0;
input[type='checkbox'] {
input[type=checkbox] {
opacity: 0;
position: absolute;
+ 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")
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;
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,
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;
cursor: pointer;
display: block;
@ -204,7 +200,7 @@
transition: background-color 0.3s;
width: 46px;
&::before {
&:before {
background-color: #fff;
border-radius: 100%;
content: '';
@ -220,7 +216,7 @@
&:checked + label {
background-color: #fff;
&::before {
&:before {
background-color: #1f1f1f;
left: 24px;
}