diff --git a/src/components/Editor/components/Editor.tsx b/src/components/Editor/components/Editor.tsx index 9b9a5415..584fa0ea 100644 --- a/src/components/Editor/components/Editor.tsx +++ b/src/components/Editor/components/Editor.tsx @@ -1,9 +1,8 @@ import type { EditorView } from 'prosemirror-view' import type { EditorState } from 'prosemirror-state' -import { useState } from '../store' -import { ProseMirror } from '../components/ProseMirror' +import { useState } from '../store/context' +import { ProseMirror } from './ProseMirror' import '../styles/Editor.scss' -import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers' export const Editor = () => { const [store, ctrl] = useState() @@ -23,9 +22,9 @@ export const Editor = () => { // eslint-disable-next-line solid/no-react-specific-props className="editor" style={style()} - editorView={store.editorView as EditorView} - text={store.text as ProseMirrorState} - extensions={store.extensions as ProseMirrorExtension[]} + editorView={store.editorView} + text={store.text} + extensions={store.extensions} onInit={onInit} onReconfigure={onReconfigure} onChange={onChange} diff --git a/src/components/Editor/components/Error.tsx b/src/components/Editor/components/Error.tsx index e53d2abe..106dce79 100644 --- a/src/components/Editor/components/Error.tsx +++ b/src/components/Editor/components/Error.tsx @@ -1,5 +1,5 @@ import { Switch, Match } from 'solid-js' -import { useState } from '../store' +import { useState } from '../store/context' import '../styles/Button.scss' export default () => { @@ -12,8 +12,8 @@ export default () => { - - + + ) @@ -48,8 +48,8 @@ const Other = () => { const onClick = () => ctrl.discard() const getMessage = () => { - const { error } = store.error.props as any - return typeof error === 'string' ? error : error.message + const err = (store.error.props as any).error + return typeof err === 'string' ? err : err.message } return ( diff --git a/src/components/Editor/components/Layout.tsx b/src/components/Editor/components/Layout.tsx index a7059887..f8fe6a3b 100644 --- a/src/components/Editor/components/Layout.tsx +++ b/src/components/Editor/components/Layout.tsx @@ -1,8 +1,9 @@ -import type { Config } from '../store' +import type { JSX } from 'solid-js/jsx-runtime' +import type { Config } from '../store/context' import '../styles/Layout.scss' export type Styled = { - children: any + children: JSX.Element config?: Config 'data-testid'?: string onClick?: () => void diff --git a/src/components/Editor/components/ProseMirror.tsx b/src/components/Editor/components/ProseMirror.tsx index f384a2ed..b85af777 100644 --- a/src/components/Editor/components/ProseMirror.tsx +++ b/src/components/Editor/components/ProseMirror.tsx @@ -1,16 +1,16 @@ -import { EditorState, type Transaction } from 'prosemirror-state' -import { EditorView } from 'prosemirror-view' -import { unwrap } from 'solid-js/store' import { createEffect, untrack } from 'solid-js' -import { createEditorState } from '../prosemirror' -import type { ProseMirrorState, ProseMirrorExtension } from '../prosemirror/helpers' +import { Store, unwrap } from 'solid-js/store' +import { EditorState, EditorStateConfig, Transaction } from 'prosemirror-state' +import { EditorView } from 'prosemirror-view' +import { Schema } from 'prosemirror-model' +import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers' interface Props { style?: string className?: string - text?: ProseMirrorState - editorView?: EditorView - extensions?: ProseMirrorExtension[] + text?: Store + editorView?: Store + extensions?: Store onInit: (s: EditorState, v: EditorView) => void onReconfigure: (s: EditorState) => void onChange: (s: EditorState) => void @@ -19,6 +19,7 @@ interface Props { export const ProseMirror = (props: Props) => { let editorRef: HTMLDivElement const editorView = () => untrack(() => unwrap(props.editorView)) + const dispatchTransaction = (tr: Transaction) => { if (!editorView()) return const newState = editorView().state.apply(tr) @@ -28,10 +29,10 @@ export const ProseMirror = (props: Props) => { } createEffect( - (state: [EditorState, ProseMirrorExtension[]]) => { - const [prevText, prevExtensions] = state + (payload: [EditorState, ProseMirrorExtension[]]) => { + const [prevText, prevExtensions] = payload const text = unwrap(props.text) - const extensions = unwrap(props.extensions) + const extensions: ProseMirrorExtension[] = unwrap(props.extensions) if (!text || !extensions?.length) { return [text, extensions] } @@ -61,3 +62,46 @@ export const ProseMirror = (props: Props) => { return
} + +const createEditorState = ( + text: ProseMirrorState, + extensions: ProseMirrorExtension[], + prevText?: EditorState +): { + editorState: EditorState + nodeViews: { [key: string]: NodeViewFn } +} => { + const reconfigure = text instanceof EditorState && prevText?.schema + let schemaSpec = { nodes: {} } + let nodeViews = {} + let plugins = [] + + for (const extension of extensions) { + if (extension.schema) { + schemaSpec = extension.schema(schemaSpec) + } + + if (extension.nodeViews) { + nodeViews = { ...nodeViews, ...extension.nodeViews } + } + } + + const schema = reconfigure ? prevText.schema : new Schema(schemaSpec) + for (const extension of extensions) { + if (extension.plugins) { + plugins = extension.plugins(plugins, schema) + } + } + + let editorState: EditorState + if (reconfigure) { + editorState = text.reconfigure({ schema, plugins } as EditorStateConfig) + } else if (text instanceof EditorState) { + editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON()) + } else if (text) { + console.debug(text) + editorState = EditorState.fromJSON({ schema, plugins }, text) + } + + return { editorState, nodeViews } +} diff --git a/src/components/Editor/components/Sidebar.tsx b/src/components/Editor/components/Sidebar.tsx index 68082ea0..5c81ba6c 100644 --- a/src/components/Editor/components/Sidebar.tsx +++ b/src/components/Editor/components/Sidebar.tsx @@ -1,14 +1,12 @@ -import { Show, createEffect, createSignal, onCleanup, For } from 'solid-js' +import { For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js' import { unwrap } from 'solid-js/store' import { undo, redo } from 'prosemirror-history' -import { useState } from '../store' +import { Draft, useState /*, Config, PrettierConfig */ } from '../store/context' +import * as remote from '../remote' +import { isEmpty /*, isInitialized*/ } from '../prosemirror/helpers' import type { Styled } from './Layout' - import '../styles/Sidebar.scss' -import { router } from '../../../stores/router' import { t } from '../../../utils/intl' -import { isEmpty } from '../prosemirror/helpers' -import type { EditorState } from 'prosemirror-state' const Off = (props) => @@ -29,23 +27,22 @@ const Link = ( ) -const mod = 'Ctrl' const Keys = (props) => ( - {(k: string) => {k}} + {(k: Element) => {k}} ) -interface SidebarProps { - error?: string -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -export const Sidebar = (_props: SidebarProps) => { +export const Sidebar = () => { + const [isMac, setIsMac] = createSignal(false) + onMount(() => setIsMac(window?.navigator.platform.includes('Mac'))) + // eslint-disable-next-line unicorn/consistent-function-scoping + // const isDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches + const mod = isMac() ? 'Cmd' : 'Ctrl' + // const alt = isMac() ? 'Cmd' : 'Alt' const [store, ctrl] = useState() const [lastAction, setLastAction] = createSignal() const toggleTheme = () => { - // TODO: use dark/light toggle somewhere document.body.classList.toggle('dark') ctrl.updateConfig({ theme: document.body.className }) } @@ -58,20 +55,51 @@ export const Sidebar = (_props: SidebarProps) => { } const editorView = () => unwrap(store.editorView) const onToggleMarkdown = () => ctrl.toggleMarkdown() + const onOpenDraft = (draft: Draft) => ctrl.openDraft(unwrap(draft)) const collabUsers = () => store.collab?.y?.provider.awareness.meta.size ?? 0 const onUndo = () => undo(editorView().state, editorView().dispatch) const onRedo = () => redo(editorView().state, editorView().dispatch) - const onNew = () => ctrl.newFile() + const onCopyAllAsMd = () => + remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md')) const onDiscard = () => ctrl.discard() const [isHidden, setIsHidden] = createSignal() - // eslint-disable-next-line unicorn/consistent-function-scoping - const onHistory = () => { - console.log('[editor.sidebar] implement history handling') - router.open('/create/settings') - } const toggleSidebar = () => setIsHidden(!isHidden()) toggleSidebar() + // eslint-disable-next-line sonarjs/cognitive-complexity + const DraftLink = (p: { draft: Draft }) => { + const length = 100 + let content = '' + const getContent = (node: any) => { + if (node.text) content += node.text + if (content.length > length) { + content = content.slice(0, Math.max(0, length)) + '...' + return content + } + + if (node.content) { + for (const child of node.content) { + if (content.length >= length) break + content = getContent(child) + } + } + + return content + } + + const text = () => + p.draft.path + ? p.draft.path.slice(Math.max(0, p.draft.path.length - length)) + : getContent(p.draft.text?.doc) + + return ( + // eslint-disable-next-line solid/no-react-specific-props + onOpenDraft(p.draft)} data-testid="open"> + {text()} {p.draft.path && '📎'} + + ) + } + const onCollab = () => { const state = unwrap(store) store.collab?.started ? ctrl.stopCollab(state) : ctrl.startCollab(state) @@ -88,19 +116,11 @@ export const Sidebar = (_props: SidebarProps) => { }, 1000) onCleanup(() => clearTimeout(id)) }) - const discardText = () => { - if (store.path) { - return t('Close') - } else if (store.drafts.length > 0 && isEmpty(store.text as EditorState)) { - return t('Delete') - } else { - return t('Clear') - } - } + return (
- {t('Tips and proposals')} + Советы и предложения editorView().focus()}> @@ -112,35 +132,44 @@ export const Sidebar = (_props: SidebarProps) => { ({store.path.slice(Math.max(0, store.path.length - 24))}) )} - {t('Tabula rasa')} - {t('Invite coauthors')} - router.open('/create/settings')}>{t('Publication settings')} - {t('History of changes')} + Пригласить соавторов + Настройки публикации + История правок + +
+ Ночная тема + + +
- {discardText()} + {/* eslint-disable-next-line no-nested-ternary */} + {store.path + ? 'Close' + : (store.drafts.length > 0 && isEmpty(store.text) + ? 'Delete ⚠️' + : 'Clear')}{' '} + - {t('Undo')} + Undo - {t('Redo')} + Redo - Markdown {store.markdown && '✅'} + Markdown mode {store.markdown && '✅'} + Copy all as MD {lastAction() === 'copy-md' && '📋'} 0}> -

{t('Drafts')}:

+

Drafts:

- - {(draft) => router.open(draft.path)}>{draft.path}} - + {(draft: Draft) => }

- {collabText()} diff --git a/src/components/Editor/prosemirror/markdown.ts b/src/components/Editor/markdown.ts similarity index 90% rename from src/components/Editor/prosemirror/markdown.ts rename to src/components/Editor/markdown.ts index 311f62bb..5d501f61 100644 --- a/src/components/Editor/prosemirror/markdown.ts +++ b/src/components/Editor/markdown.ts @@ -1,34 +1,38 @@ import markdownit from 'markdown-it' -import { - MarkdownSerializer, - MarkdownParser, - defaultMarkdownSerializer, - MarkdownSerializerState -} from 'prosemirror-markdown' +import { MarkdownSerializer, MarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown' import type { Node, Schema } from 'prosemirror-model' import type { EditorState } from 'prosemirror-state' export const serialize = (state: EditorState) => { let text = markdownSerializer.serialize(state.doc) - if (text.charAt(text.length - 1) !== '\n') text += '\n' + if (text.charAt(text.length - 1) !== '\n') { + text += '\n' + } + return text } -const findAlignment = (cell: Node) => { +const findAlignment = (cell: Node): string | null => { const alignment = cell.attrs.style as string - if (!alignment) return null + if (!alignment) { + return null + } + const match = alignment.match(/text-align: ?(left|right|center)/) - if (match && match[1]) return match[1] + if (match && match[1]) { + return match[1] + } + return null } export const markdownSerializer = new MarkdownSerializer( { ...defaultMarkdownSerializer.nodes, - image(state: MarkdownSerializerState, node) { + image(state, node) { const alt = state.esc(node.attrs.alt || '') const src = node.attrs.path ?? node.attrs.src - const title = node.attrs.title || '' // ? state.quote(node.attrs.title) : undefined + const title = node.attrs.title ? state.quote(node.attrs.title) : undefined state.write(`![${alt}](${src}${title ? ' ' + title : ''})\n`) }, code_block(state, node) { @@ -118,8 +122,8 @@ export const markdownSerializer = new MarkdownSerializer( } ) -function listIsTight(tokens, i: number) { - // eslint-disable-next-line no-param-reassign +function listIsTight(tokens: any, idx: number) { + let i = idx while (++i < tokens.length) { if (tokens[i].type !== 'list_item_open') return tokens[i].hidden } diff --git a/src/components/Editor/prosemirror/extension/paste-markdown.ts b/src/components/Editor/prosemirror/extension/paste-markdown.ts index 465dc82a..c63a3ed7 100644 --- a/src/components/Editor/prosemirror/extension/paste-markdown.ts +++ b/src/components/Editor/prosemirror/extension/paste-markdown.ts @@ -1,7 +1,7 @@ import { Plugin } from 'prosemirror-state' import { Fragment, Node, Schema, Slice } from 'prosemirror-model' import type { ProseMirrorExtension } from '../helpers' -import { createMarkdownParser } from '../markdown' +import { createMarkdownParser } from '../../markdown' const URL_REGEX = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:\d+)?(\/|\/([\w!#%&+./:=?@-]))?/g diff --git a/src/components/Editor/prosemirror/helpers.ts b/src/components/Editor/prosemirror/helpers.ts index 0399727a..46acf00c 100644 --- a/src/components/Editor/prosemirror/helpers.ts +++ b/src/components/Editor/prosemirror/helpers.ts @@ -17,13 +17,11 @@ export type NodeViewFn = ( decorations: Decoration[] ) => NodeView -export const isInitialized = (state: EditorState) => state !== undefined && state instanceof EditorState +export const isInitialized = (state: any) => state !== undefined && state instanceof EditorState -export const isEmpty = (state: EditorState) => +export const isEmpty = (state: any) => !isInitialized(state) || (state.doc.childCount === 1 && !state.doc.firstChild.type.spec.code && state.doc.firstChild.isTextblock && state.doc.firstChild.content.size === 0) - -export const isText = (x) => x && x.doc && x.selection diff --git a/src/components/Editor/prosemirror/index.ts b/src/components/Editor/prosemirror/index.ts deleted file mode 100644 index 3c5fe5fb..00000000 --- a/src/components/Editor/prosemirror/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { EditorState } from 'prosemirror-state' -import { Schema } from 'prosemirror-model' -import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from './helpers' - -export const createEditorState = ( - text: ProseMirrorState, - extensions: ProseMirrorExtension[], - prevText?: EditorState -): { - editorState: EditorState - nodeViews: { [key: string]: NodeViewFn } -} => { - const reconfigure = text instanceof EditorState && prevText?.schema - let schemaSpec = { nodes: {} } - let nodeViews = {} - let plugins = [] - - for (const extension of extensions) { - if (extension.schema) { - schemaSpec = extension.schema(schemaSpec) - } - - if (extension.nodeViews) { - nodeViews = { ...nodeViews, ...extension.nodeViews } - } - } - - const schema = reconfigure ? prevText.schema : new Schema(schemaSpec) - for (const extension of extensions) { - if (extension.plugins) { - plugins = extension.plugins(plugins, schema) - } - } - - let editorState: EditorState - if (reconfigure) { - editorState = text.reconfigure({ schema, plugins } as Partial) - } else if (text instanceof EditorState) { - editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON()) - } else if (text) { - console.debug(text) - editorState = EditorState.fromJSON({ schema, plugins }, text) - } - - return { editorState, nodeViews } -} diff --git a/src/components/Editor/prosemirror/setup.ts b/src/components/Editor/prosemirror/setup.ts index 8b4067d8..8c498363 100644 --- a/src/components/Editor/prosemirror/setup.ts +++ b/src/components/Editor/prosemirror/setup.ts @@ -17,7 +17,7 @@ import selectionMenu from './extension/selection' import strikethrough from './extension/strikethrough' import table from './extension/table' import todoList from './extension/todo-list' -import type { Config, YOptions } from '../store' +import type { Config, YOptions } from '../store/context' import type { ProseMirrorExtension } from './helpers' interface ExtensionsProps { diff --git a/src/components/Editor/remote.ts b/src/components/Editor/remote.ts new file mode 100644 index 00000000..e6f8ceb0 --- /dev/null +++ b/src/components/Editor/remote.ts @@ -0,0 +1,11 @@ +import { EditorState } from 'prosemirror-state' +import { serialize } from './markdown' + +export const copy = async (text: string): Promise => { + navigator.clipboard.writeText(text) +} + +export const copyAllAsMarkdown = async (state: EditorState): Promise => { + const text = serialize(state) + navigator.clipboard.writeText(text) +} diff --git a/src/components/Editor/store/ctrl.ts b/src/components/Editor/store/actions.ts similarity index 98% rename from src/components/Editor/store/ctrl.ts rename to src/components/Editor/store/actions.ts index 9d6e18a8..de661f95 100644 --- a/src/components/Editor/store/ctrl.ts +++ b/src/components/Editor/store/actions.ts @@ -6,8 +6,8 @@ import { selectAll, deleteSelection } from 'prosemirror-commands' import { undo as yUndo, redo as yRedo } from 'y-prosemirror' import debounce from 'lodash/debounce' import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup' -import { State, Draft, Config, ServiceError, newState } from '.' -import { serialize, createMarkdownParser } from '../prosemirror/markdown' +import { State, Draft, Config, ServiceError, newState } from './context' +import { serialize, createMarkdownParser } from '../markdown' import db from '../db' import { isEmpty, isInitialized } from '../prosemirror/helpers' import { drafts as draftsatom } from '../../../stores/editor' @@ -91,7 +91,7 @@ export const createCtrl = (initial): [Store, { [key: string]: any }] => { ...drafts, { body: text, - lastModified: prev.lastModified as Date, + lastModified: prev.lastModified, path: prev.path, markdown: prev.markdown } as Draft @@ -302,7 +302,7 @@ export const createCtrl = (initial): [Store, { [key: string]: any }] => { } const index = findIndexOfDraft(draft) const item = index === -1 ? draft : state.drafts[index] - let drafts = state.drafts.filter((f) => f !== item) + let drafts = state.drafts.filter((d: Draft) => d !== item) if (!isEmpty(state.text as EditorState) && state.lastModified) { drafts = addToDrafts(drafts, { lastModified: new Date(), text: state.text } as Draft) } diff --git a/src/components/Editor/store/index.ts b/src/components/Editor/store/context.ts similarity index 77% rename from src/components/Editor/store/index.ts rename to src/components/Editor/store/context.ts index 284055cf..069ee18e 100644 --- a/src/components/Editor/store/index.ts +++ b/src/components/Editor/store/context.ts @@ -4,14 +4,12 @@ import type { XmlFragment } from 'yjs' import type { WebrtcProvider } from 'y-webrtc' import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers' import type { EditorView } from 'prosemirror-view' -import { createEmptyText } from '../prosemirror/setup' export interface Args { - draft: string // path to draft cwd?: string - file?: string + draft?: string room?: string - text?: string + text?: any } export interface PrettierConfig { @@ -28,7 +26,7 @@ export interface Config { font: string fontSize: number contentWidth: number - alwaysOnTop: boolean + // alwaysOnTop: boolean; // typewriterMode: boolean; prettier: PrettierConfig } @@ -62,18 +60,20 @@ export interface State { config: Config error?: ErrorObject loading: LoadingType + fullscreen?: boolean collab?: Collab path?: string args?: Args + isMac?: boolean } export interface Draft { - extensions?: ProseMirrorExtension[] - lastModified: Date + text?: { [key: string]: any } body?: string - text?: { doc: any; selection: { type: string; anchor: number; head: number } } + lastModified?: Date path?: string markdown?: boolean + extensions?: ProseMirrorExtension[] } export class ServiceError extends Error { @@ -92,6 +92,7 @@ export const newState = (props: Partial = {}): State => ({ extensions: [], drafts: [], loading: 'loading', + fullscreen: false, markdown: false, config: { theme: undefined, @@ -99,7 +100,6 @@ export const newState = (props: Partial = {}): State => ({ font: 'muller', fontSize: 24, contentWidth: 800, - alwaysOnTop: false, // typewriterMode: true, prettier: { printWidth: 80, @@ -111,16 +111,3 @@ export const newState = (props: Partial = {}): State => ({ }, ...props }) - -export const addToDrafts = (drafts: Draft[], state: State): Draft[] => { - drafts.forEach((d) => { - if (!state.drafts.includes(d)) state.drafts.push(d) - }) - return state.drafts -} - -export const createTextFromDraft = async (draft: Draft) => { - const created = createEmptyText() - created.doc.content = Object.values(draft.text) // FIXME - return created -} diff --git a/src/components/Pages/CreatePage.tsx b/src/components/Pages/CreatePage.tsx index a7cb6943..2bfac823 100644 --- a/src/components/Pages/CreatePage.tsx +++ b/src/components/Pages/CreatePage.tsx @@ -1,4 +1,4 @@ -import { newState } from '../Editor/store' +import { newState } from '../Editor/store/context' import { MainLayout } from '../Layouts/MainLayout' import { CreateView } from '../Views/Create' diff --git a/src/components/Views/Create.tsx b/src/components/Views/Create.tsx index 063e5b38..982bfc79 100644 --- a/src/components/Views/Create.tsx +++ b/src/components/Views/Create.tsx @@ -1,7 +1,7 @@ import { Show, onCleanup, createEffect, onError, onMount, untrack } from 'solid-js' import { createMutable, unwrap } from 'solid-js/store' -import { State, StateContext } from '../Editor/store' -import { createCtrl } from '../Editor/store/ctrl' +import { State, StateContext } from '../Editor/store/context' +import { createCtrl } from '../Editor/store/actions' import { Layout } from '../Editor/components/Layout' import { Editor } from '../Editor/components/Editor' import { Sidebar } from '../Editor/components/Sidebar' @@ -10,7 +10,14 @@ import ErrorView from '../Editor/components/Error' const matchDark = () => window.matchMedia('(prefers-color-scheme: dark)') export const CreateView = (props: { state: State }) => { - const [store, ctrl] = createCtrl(props.state) + let isMac = false + onMount(() => { + isMac = window?.navigator.platform.includes('Mac') + matchDark().addEventListener('change', onChangeTheme) + onCleanup(() => matchDark().removeEventListener('change', onChangeTheme)) + }) + + const [store, ctrl] = createCtrl({ ...props.state, isMac }) const mouseEnterCoords = createMutable({ x: 0, y: 0 }) const onMouseEnter = (e: MouseEvent) => { @@ -28,10 +35,6 @@ export const CreateView = (props: { state: State }) => { }) const onChangeTheme = () => ctrl.updateTheme() - onMount(() => { - matchDark().addEventListener('change', onChangeTheme) - onCleanup(() => matchDark().removeEventListener('change', onChangeTheme)) - }) onError((error) => { console.error('[create] error:', error) diff --git a/src/stores/editor.ts b/src/stores/editor.ts index c91302dd..3868646d 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -2,14 +2,7 @@ import { persistentMap } from '@nanostores/persistent' import type { Reaction } from '../graphql/types.gen' import { atom } from 'nanostores' import { createSignal } from 'solid-js' - -interface Draft { - createdAt: Date - topics?: string[] - lastModified: Date - body?: string - title?: string -} +import type { Draft } from '../components/Editor/store/context' interface Collab { authors: string[] // slugs