diff --git a/package.json b/package.json index b5bb2ea6..2988cc2f 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "prosemirror-markdown": "^1.9.4", "prosemirror-menu": "^1.2.1", "prosemirror-model": "^1.16.0", - "prosemirror-schema-basic": "^1.2.0", "prosemirror-schema-list": "^1.2.2", "prosemirror-state": "^1.4.1", "prosemirror-view": "^1.28.1", diff --git a/public/icons/expand.svg b/public/icons/expand.svg deleted file mode 100644 index fcbcd21c..00000000 --- a/public/icons/expand.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/components/Editor/components/Editor.module.scss b/src/components/EditorExample/components/Editor.module.scss similarity index 100% rename from src/components/Editor/components/Editor.module.scss rename to src/components/EditorExample/components/Editor.module.scss diff --git a/src/components/Editor/components/Editor.tsx b/src/components/EditorExample/components/Editor.tsx similarity index 100% rename from src/components/Editor/components/Editor.tsx rename to src/components/EditorExample/components/Editor.tsx diff --git a/src/components/Editor/components/Error.module.scss b/src/components/EditorExample/components/Error.module.scss similarity index 100% rename from src/components/Editor/components/Error.module.scss rename to src/components/EditorExample/components/Error.module.scss diff --git a/src/components/Editor/components/Error.tsx b/src/components/EditorExample/components/Error.tsx similarity index 100% rename from src/components/Editor/components/Error.tsx rename to src/components/EditorExample/components/Error.tsx diff --git a/src/components/Editor/components/Layout.module.scss b/src/components/EditorExample/components/Layout.module.scss similarity index 100% rename from src/components/Editor/components/Layout.module.scss rename to src/components/EditorExample/components/Layout.module.scss diff --git a/src/components/Editor/components/Layout.tsx b/src/components/EditorExample/components/Layout.tsx similarity index 100% rename from src/components/Editor/components/Layout.tsx rename to src/components/EditorExample/components/Layout.tsx diff --git a/src/components/Editor/components/ProseMirror.tsx b/src/components/EditorExample/components/ProseMirror.tsx similarity index 100% rename from src/components/Editor/components/ProseMirror.tsx rename to src/components/EditorExample/components/ProseMirror.tsx diff --git a/src/components/Editor/components/Sidebar.module.scss b/src/components/EditorExample/components/Sidebar.module.scss similarity index 100% rename from src/components/Editor/components/Sidebar.module.scss rename to src/components/EditorExample/components/Sidebar.module.scss diff --git a/src/components/Editor/components/Sidebar.tsx b/src/components/EditorExample/components/Sidebar.tsx similarity index 100% rename from src/components/Editor/components/Sidebar.tsx rename to src/components/EditorExample/components/Sidebar.tsx diff --git a/src/components/Editor/db.ts b/src/components/EditorExample/db.ts similarity index 100% rename from src/components/Editor/db.ts rename to src/components/EditorExample/db.ts diff --git a/src/components/Editor/env.ts b/src/components/EditorExample/env.ts similarity index 100% rename from src/components/Editor/env.ts rename to src/components/EditorExample/env.ts diff --git a/src/components/Editor/markdown.ts b/src/components/EditorExample/markdown.ts similarity index 100% rename from src/components/Editor/markdown.ts rename to src/components/EditorExample/markdown.ts diff --git a/src/components/Editor/prosemirror/extension/base.ts b/src/components/EditorExample/prosemirror/extension/base.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/base.ts rename to src/components/EditorExample/prosemirror/extension/base.ts diff --git a/src/components/Editor/prosemirror/extension/code.ts b/src/components/EditorExample/prosemirror/extension/code.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/code.ts rename to src/components/EditorExample/prosemirror/extension/code.ts diff --git a/src/components/Editor/prosemirror/extension/collab.ts b/src/components/EditorExample/prosemirror/extension/collab.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/collab.ts rename to src/components/EditorExample/prosemirror/extension/collab.ts diff --git a/src/components/Editor/prosemirror/extension/drag-handle.ts b/src/components/EditorExample/prosemirror/extension/drag-handle.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/drag-handle.ts rename to src/components/EditorExample/prosemirror/extension/drag-handle.ts diff --git a/src/components/Editor/prosemirror/extension/image.ts b/src/components/EditorExample/prosemirror/extension/image.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/image.ts rename to src/components/EditorExample/prosemirror/extension/image.ts diff --git a/src/components/Editor/prosemirror/extension/link.ts b/src/components/EditorExample/prosemirror/extension/link.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/link.ts rename to src/components/EditorExample/prosemirror/extension/link.ts diff --git a/src/components/Editor/prosemirror/extension/mark-input-rule.ts b/src/components/EditorExample/prosemirror/extension/mark-input-rule.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/mark-input-rule.ts rename to src/components/EditorExample/prosemirror/extension/mark-input-rule.ts diff --git a/src/components/Editor/prosemirror/extension/markdown.ts b/src/components/EditorExample/prosemirror/extension/markdown.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/markdown.ts rename to src/components/EditorExample/prosemirror/extension/markdown.ts diff --git a/src/components/Editor/prosemirror/extension/menu.ts b/src/components/EditorExample/prosemirror/extension/menu.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/menu.ts rename to src/components/EditorExample/prosemirror/extension/menu.ts diff --git a/src/components/Editor/prosemirror/extension/paste-markdown.ts b/src/components/EditorExample/prosemirror/extension/paste-markdown.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/paste-markdown.ts rename to src/components/EditorExample/prosemirror/extension/paste-markdown.ts diff --git a/src/components/Editor/prosemirror/extension/placeholder.ts b/src/components/EditorExample/prosemirror/extension/placeholder.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/placeholder.ts rename to src/components/EditorExample/prosemirror/extension/placeholder.ts diff --git a/src/components/Editor/prosemirror/extension/prompt.ts b/src/components/EditorExample/prosemirror/extension/prompt.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/prompt.ts rename to src/components/EditorExample/prosemirror/extension/prompt.ts diff --git a/src/components/Editor/prosemirror/extension/scroll.ts b/src/components/EditorExample/prosemirror/extension/scroll.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/scroll.ts rename to src/components/EditorExample/prosemirror/extension/scroll.ts diff --git a/src/components/Editor/prosemirror/extension/selection.ts b/src/components/EditorExample/prosemirror/extension/selection.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/selection.ts rename to src/components/EditorExample/prosemirror/extension/selection.ts diff --git a/src/components/Editor/prosemirror/extension/strikethrough.ts b/src/components/EditorExample/prosemirror/extension/strikethrough.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/strikethrough.ts rename to src/components/EditorExample/prosemirror/extension/strikethrough.ts diff --git a/src/components/Editor/prosemirror/extension/table.ts b/src/components/EditorExample/prosemirror/extension/table.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/table.ts rename to src/components/EditorExample/prosemirror/extension/table.ts diff --git a/src/components/Editor/prosemirror/extension/todo-list.ts b/src/components/EditorExample/prosemirror/extension/todo-list.ts similarity index 100% rename from src/components/Editor/prosemirror/extension/todo-list.ts rename to src/components/EditorExample/prosemirror/extension/todo-list.ts diff --git a/src/components/Editor/prosemirror/helpers.ts b/src/components/EditorExample/prosemirror/helpers.ts similarity index 100% rename from src/components/Editor/prosemirror/helpers.ts rename to src/components/EditorExample/prosemirror/helpers.ts diff --git a/src/components/Editor/prosemirror/p2p.ts b/src/components/EditorExample/prosemirror/p2p.ts similarity index 100% rename from src/components/Editor/prosemirror/p2p.ts rename to src/components/EditorExample/prosemirror/p2p.ts diff --git a/src/components/Editor/prosemirror/setup.ts b/src/components/EditorExample/prosemirror/setup.ts similarity index 100% rename from src/components/Editor/prosemirror/setup.ts rename to src/components/EditorExample/prosemirror/setup.ts diff --git a/src/components/Editor/remote.ts b/src/components/EditorExample/remote.ts similarity index 100% rename from src/components/Editor/remote.ts rename to src/components/EditorExample/remote.ts diff --git a/src/components/Editor/store/actions.ts b/src/components/EditorExample/store/actions.ts similarity index 100% rename from src/components/Editor/store/actions.ts rename to src/components/EditorExample/store/actions.ts diff --git a/src/components/Editor/store/context.ts b/src/components/EditorExample/store/context.ts similarity index 100% rename from src/components/Editor/store/context.ts rename to src/components/EditorExample/store/context.ts diff --git a/src/components/Editor/styles/ArticlesList.scss.bak b/src/components/EditorExample/styles/ArticlesList.scss.bak similarity index 100% rename from src/components/Editor/styles/ArticlesList.scss.bak rename to src/components/EditorExample/styles/ArticlesList.scss.bak diff --git a/src/components/Editor/styles/ProseMirror.scss b/src/components/EditorExample/styles/ProseMirror.scss similarity index 100% rename from src/components/Editor/styles/ProseMirror.scss rename to src/components/EditorExample/styles/ProseMirror.scss diff --git a/src/components/EditorNew/Editor.tsx b/src/components/EditorNew/Editor.tsx new file mode 100644 index 00000000..b9696fff --- /dev/null +++ b/src/components/EditorNew/Editor.tsx @@ -0,0 +1,102 @@ +import { createSignal, onMount } from 'solid-js' +import { EditorState, Transaction } from 'prosemirror-state' +import { EditorView, MarkViewConstructor, NodeViewConstructor } from 'prosemirror-view' +import './prosemirror/styles/ProseMirror.scss' +import type { Nodes, Marks } from './prosemirror/schema' +import { createImageView } from './prosemirror/views/image' +import { MarkdownSerializer } from 'prosemirror-markdown' +import { schema } from './prosemirror/schema' +import { createPlugins } from './prosemirror/plugins' + +import debounce from 'lodash/debounce' +import { DOMSerializer } from 'prosemirror-model' +import { clsx } from 'clsx' +import styles from '../Nav/AuthModal/AuthModal.module.scss' +import { t } from '../../utils/intl' +import { apiClient } from '../../utils/apiClient' +import { createArticle } from '../../stores/zine/articles' +import type { InputMaybe, Scalars, ShoutInput } from '../../graphql/types.gen' +import { Sidebar } from './Sidebar' + +const htmlContainer = typeof document === 'undefined' ? null : document.createElement('div') + +const getHtml = (state: EditorState) => { + const fragment = DOMSerializer.fromSchema(schema).serializeFragment(state.doc.content) + htmlContainer.replaceChildren(fragment) + return htmlContainer.innerHTML +} + +export const Editor = () => { + const [markdown, setMarkdown] = createSignal('') + const [html, setHtml] = createSignal('') + + const editorElRef: { + current: HTMLDivElement + } = { + current: null + } + + const editorViewRef: { current: EditorView } = { current: null } + + const update = (state: EditorState) => { + const newHtml = getHtml(state) + setHtml(newHtml) + // setMarkdown(state.toJSON()) + // const el = document.createElement('div') + } + + const debouncedUpdate = debounce(update, 500) + + const dispatchTransaction = (tr: Transaction) => { + const newState = editorViewRef.current.state.apply(tr) + editorViewRef.current.updateState(newState) + debouncedUpdate(newState) + } + + onMount(async () => { + const plugins = createPlugins({ schema }) + + const nodeViews: Partial> = { + image: createImageView + } + + const markViews: Partial> = {} + + editorViewRef.current = new EditorView(editorElRef.current, { + state: EditorState.create({ + schema, + plugins + }), + nodeViews, + markViews, + dispatchTransaction + }) + + editorViewRef.current.focus() + }) + + const handleSaveButtonClick = () => { + const article: ShoutInput = { + body: getHtml(editorViewRef.current.state), + community: 'discours', // ? + slug: 'new-' + Math.floor(Math.random() * 1000000) + } + createArticle({ article }) + } + + return ( +
+
+
(editorElRef.current = el)} /> +
Markdown:
+
{markdown()}
+
HTML:
+
{html()}
+ +
+ +
+ ) +} diff --git a/src/components/EditorNew/Sidebar.module.scss b/src/components/EditorNew/Sidebar.module.scss new file mode 100644 index 00000000..187cd08b --- /dev/null +++ b/src/components/EditorNew/Sidebar.module.scss @@ -0,0 +1,221 @@ +.sidebarContainer { + color: rgb(255 255 255 / 50%); + @include font-size(1.6rem); + + overflow: hidden; + position: relative; + top: 0; + + p { + color: var(--foreground); + } + + h4 { + @include font-size(120%); + + margin-left: 1rem; + } + + button { + height: auto; + min-height: 50px; + padding: 0 1rem; + width: 100%; + } +} + +.sidebarOff { + background: #1f1f1f; + height: 100%; + min-height: 100vh; + padding: 40px 20px 20px; + top: 0; + transform: translateX(0); + transition: transform 0.3s; + overflow-y: auto; + scrollbar-width: none; + width: 350px; + + .sidebarContainerHidden & { + transform: translateX(100%); + } + + ::-webkit-scrollbar { + display: none; + } +} + +.sidebarOpener { + color: #000; + cursor: pointer; + opacity: 1; + position: absolute; + top: 1em; + transition: opacity 0.3s; + + &:hover { + opacity: 0.5; + } + + &::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; + left: 100%; + margin-left: 0.3em; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 18px; + } +} + +.sidebarCloser { + background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13.1517 0.423857L0.42375 13.1518L2.84812 15.5761L15.576 2.84822L13.1517 0.423857Z M15.576 13.1518L2.84812 0.423855L0.423751 2.84822L13.1517 15.5761L15.576 13.1518Z' fill='white'/%3E%3C/svg%3E%0A"); + cursor: pointer; + height: 16px; + opacity: 1; + position: absolute; + transition: opacity 0.3s; + top: 20px; + width: 16px; + + &:hover { + opacity: 0.5; + } +} + +.sidebarLabel { + color: var(--foreground); + + > i { + text-transform: none; + } +} + +.sidebarContainer button, +.sidebarContainer a, +.sidebarItem { + margin: 0; + outline: none; + display: flex; + align-items: center; + line-height: 24px; + text-align: left; +} + +.sidebarContainer a, +.sidebarItem { + font-size: 18px; + padding: 2px 0; + width: 100%; +} + +.sidebarLink { + background: none; + border: 0; + color: inherit; + cursor: pointer; + font-size: inherit; + justify-content: flex-start; + + &:hover { + color: #fff !important; + } + + &:active { + > span i { + position: relative; + box-shadow: none; + top: 1px; + } + } + + &[disabled] { + color: var(--foreground); + cursor: not-allowed; + } + + &.draft { + color: rgb(255 255 255 / 50%); + line-height: 1.4; + margin: 0 0 1em 1.5em; + width: calc(100% - 2rem); + + &:hover { + background: none; + } + } + + > span { + justify-self: flex-end; + margin-left: auto; + + > i { + border: 1px solid; + border-bottom-width: 2px; + border-radius: 0.2rem; + display: inline-block; + color: inherit; + font-size: 13px; + line-height: 1.4; + margin: 0 0.5em 0 0; + padding: 1px 4px; + + &:last-child { + text-transform: uppercase; + } + } + } +} + +.themeSwitcher { + border-bottom: 1px solid rgb(255 255 255 / 30%); + border-top: 1px solid rgb(255 255 255 / 30%); + display: flex; + justify-content: space-between; + margin: 1rem; + padding: 1em 0; + + 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; + border-radius: 14px; + cursor: pointer; + display: block; + height: 28px; + line-height: 10em; + overflow: hidden; + position: relative; + transition: background-color 0.3s; + width: 46px; + + &::before { + background-color: #fff; + border-radius: 100%; + content: ''; + height: 16px; + left: 6px; + position: absolute; + top: 6px; + transition: left 0.3s, color 0.3s; + width: 16px; + } + } + + &:checked + label { + background-color: #fff; + + &::before { + background-color: #1f1f1f; + left: 24px; + } + } + } +} diff --git a/src/components/EditorNew/Sidebar.tsx b/src/components/EditorNew/Sidebar.tsx new file mode 100644 index 00000000..f45f2172 --- /dev/null +++ b/src/components/EditorNew/Sidebar.tsx @@ -0,0 +1,113 @@ +import { For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js' +import type { JSX } from 'solid-js' +import { undo, redo } from 'prosemirror-history' +import { clsx } from 'clsx' +import styles from './Sidebar.module.scss' +import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler' +import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler' +import type { EditorView } from 'prosemirror-view' + +const Off = (props) =>
{props.children}
+ +const Link = (props: { + withMargin?: boolean + disabled?: boolean + title?: string + className?: string + children: JSX.Element + onClick?: () => void +}) => ( + +) + +const Keys = (props: { keys: string[] }) => ( + + {(k) => {k}} + +) + +type SidebarProps = { + editorViewRef: { + current: EditorView + } +} + +export const Sidebar = (props: SidebarProps) => { + const [lastAction, setLastAction] = createSignal() + + const { editorViewRef } = props + + const onUndo = () => undo(editorViewRef.current.state, editorViewRef.current.dispatch) + const onRedo = () => redo(editorViewRef.current.state, editorViewRef.current.dispatch) + + const [isHidden, setIsHidden] = createSignal(true) + + const toggleSidebar = () => { + setIsHidden((oldIsHidden) => !oldIsHidden) + } + + createEffect(() => { + setLastAction() + }) + + createEffect(() => { + if (!lastAction()) return + const id = setTimeout(() => { + setLastAction() + }, 1000) + onCleanup(() => clearTimeout(id)) + }) + + const [mod, setMod] = createSignal<'Ctrl' | 'Cmd'>('Ctrl') + + onMount(() => { + setMod(navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl') + }) + + const containerRef: { current: HTMLElement } = { + current: null + } + + useEscKeyDownHandler(() => setIsHidden(true)) + useOutsideClickHandler({ + containerRef, + predicate: () => !isHidden(), + handler: () => setIsHidden(true) + }) + + return ( +
(containerRef.current = el)} + > + + Советы и предложения + + + editorViewRef.current.focus()}> +
+ +
+ + Undo + + + Redo + +
+ +
+ ) +} diff --git a/src/components/EditorNew/prosemirror/helpers/menu.ts b/src/components/EditorNew/prosemirror/helpers/menu.ts new file mode 100644 index 00000000..6013fe85 --- /dev/null +++ b/src/components/EditorNew/prosemirror/helpers/menu.ts @@ -0,0 +1,215 @@ +import { toggleMark } from 'prosemirror-commands' +import { wrapInList } from 'prosemirror-schema-list' +import { blockTypeItem, icons, MenuItem, wrapItem, Dropdown } from 'prosemirror-menu' + +import type { NodeSelection } from 'prosemirror-state' + +import { TextField, openPrompt } from './prompt' + +import type { DiscoursSchema } from '../schema' + +function wrapListItem(nodeType, options) { + return cmdItem(wrapInList(nodeType, options.attrs), options) +} + +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) { + return new MenuItem({ + icon: icons.image, + label: 'image', + enable(state) { + return canInsert(state, nodeType) + }, + run(state, _, view) { + const { + from, + to, + node: { attrs } + } = state.selection as NodeSelection + + openPrompt({ + title: 'Insert image', + fields: { + src: new TextField({ + label: 'Location', + required: true, + value: attrs && attrs.src + }), + title: new TextField({ label: 'Title', value: attrs && attrs.title }), + alt: new TextField({ + label: 'Description', + value: attrs ? attrs.alt : state.doc.textBetween(from, to, ' ') + }) + }, + onSubmit(newAttrs) { + view.dispatch(view.state.tr.replaceSelectionWith(nodeType.createAndFill(newAttrs))) + view.focus() + } + }) + } + }) +} + +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, 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, options) { + const passedOptions = { + active(state) { + return markActive(state, markType) + }, + enable: true + } + + for (const prop in options) passedOptions[prop] = options[prop] + + return cmdItem(toggleMark(markType), passedOptions) +} + +function linkItem(markType) { + return new MenuItem({ + title: 'Add or remove link', + icon: { + width: 18, + height: 18, + path: 'M3.27177 14.7277C2.06258 13.5186 2.06258 11.5527 3.27177 10.3435L6.10029 7.51502L4.75675 6.17148L1.92823 9C-0.0234511 10.9517 -0.0234511 14.1196 1.92823 16.0713C3.87991 18.023 7.04785 18.023 8.99952 16.0713L11.828 13.2428L10.4845 11.8992L7.65598 14.7277C6.44679 15.9369 4.48097 15.9369 3.27177 14.7277ZM6.87756 12.536L12.5346 6.87895L11.1203 5.46469L5.4633 11.1217L6.87756 12.536ZM6.17055 4.75768L8.99907 1.92916C10.9507 -0.0225206 14.1187 -0.0225201 16.0704 1.92916C18.022 3.88084 18.022 7.04878 16.0704 9.00046L13.2418 11.829L11.8983 10.4854L14.7268 7.65691C15.936 6.44772 15.936 4.4819 14.7268 3.27271C13.5176 2.06351 11.5518 2.06351 10.3426 3.2727L7.51409 6.10122L6.17055 4.75768Z' + }, + 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 + }) + }, + onSubmit(attrs) { + toggleMark(markType, attrs)(view.state, view.dispatch) + view.focus() + } + }) + } + }) +} + +export const buildMenuItems = (schema: DiscoursSchema) => { + const toggleStrong = markItem(schema.marks.strong, { + title: 'Toggle strong style', + 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' + } + }) + + const toggleEm = markItem(schema.marks.em, { + title: 'Toggle emphasis', + icon: { + width: 14, + height: 16, + path: 'M4.39216 0V3.42857H6.81882L3.06353 12.5714H0V16H8.78431V12.5714H6.35765L10.1129 3.42857H13.1765V0H4.39216Z' + } + }) + + const toggleLink = linkItem(schema.marks.link) + + const insertImage = insertImageItem(schema.nodes.image) + + const wrapBlockQuote = wrapItem(schema.nodes.blockquote, { + title: 'Wrap in block quote', + icon: icons.blockquote + }) + + const headingIcons = [ + 'M0 12H2.57143V7.16571H7.95429V12H10.5257V0H7.95429V4.83429H2.57143V0H0V12Z M12.6801 12H19.3315V9.78857H17.3944V0.342858H15.5087L12.6801 1.42286V3.75429L14.8744 2.93143V9.78857H12.6801V12Z', + 'M0 12H2.57143V7.16571H7.95429V12H10.5257V0H7.95429V4.83429H2.57143V0H0V12Z M12.4915 12H21.2515V9.78857H15.4229C15.4229 9.05143 16.6229 8.43429 17.9944 7.59429C19.5372 6.68571 21.1658 5.52 21.1658 3.54857C21.1658 1.16571 19.2458 0.102858 16.8972 0.102858C15.4744 0.102858 14.0858 0.48 12.8858 1.33714V3.73714C14.1201 2.79429 15.4915 2.36571 16.6744 2.36571C17.8229 2.36571 18.5772 2.79429 18.5772 3.65143C18.5772 4.76571 17.5487 5.22857 16.3315 5.93143C14.6172 6.94286 12.4915 8.02286 12.4915 10.8514V12Z', + 'M0 11.7647H2.52101V7.02521H7.79832V11.7647H10.3193V0H7.79832V4.7395H2.52101V0H0V11.7647Z M16.3474 12C18.7004 12 20.9189 11.042 20.9189 8.63866C20.9189 6.95798 19.8936 6.06723 18.7172 5.71429C19.7928 5.34454 20.4483 4.43697 20.4483 3.2605C20.4483 1.17647 18.6836 0.100841 16.3138 0.100841C14.9189 0.100841 13.6079 0.436975 12.5827 0.991597V3.34454C13.7088 2.63865 14.9357 2.31933 15.9609 2.31933C17.339 2.31933 18.0617 2.78992 18.0617 3.61345C18.0617 4.40336 17.3558 4.82353 16.2466 4.80672L14.6668 4.78992L14.6499 6.97479H16.5323C17.6752 6.97479 18.5155 7.31092 18.5155 8.28571C18.5155 9.36134 17.4399 9.7647 16.1457 9.78151C14.8348 9.79832 13.692 9.59664 12.381 8.87395V11.2269C13.692 11.7647 14.8852 12 16.3474 12Z' + ] + + // 3 is the max heading level mb move to constant + const headings: MenuItem[] = [] + + for (let i = 0; i < 3; i++) { + headings.push( + blockTypeItem(schema.nodes.heading, { + label: `H${i + 1}`, + attrs: { level: i + 1 }, + icon: { + width: 22, + height: 12, + path: headingIcons[i] + } + }) + ) + } + + const typeMenu = new Dropdown([...headings, wrapBlockQuote], { + label: 'Тт', + class: 'editor-dropdown' + }) + + const wrapBulletList = wrapListItem(schema.nodes.bullet_list, { + title: 'Wrap in bullet list', + icon: { + width: 20, + 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' + } + }) + + const wrapOrderedList = wrapListItem(schema.nodes.ordered_list, { + title: 'Wrap in ordered list', + icon: { + width: 19, + 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' + } + }) + + const listMenu = [wrapBulletList, wrapOrderedList] + const inlineMenu = [toggleStrong, toggleEm] + + return [[typeMenu, ...inlineMenu, toggleLink, insertImage, ...listMenu]] +} diff --git a/src/components/EditorNew/prosemirror/helpers/prompt.ts b/src/components/EditorNew/prosemirror/helpers/prompt.ts new file mode 100644 index 00000000..96a9528d --- /dev/null +++ b/src/components/EditorNew/prosemirror/helpers/prompt.ts @@ -0,0 +1,197 @@ +const prefix = 'ProseMirror-prompt' + +const createButton = ({ + textContent, + type = 'button', + className, + onClick +}: { + textContent: string + type?: 'button' | 'submit' + className: string + onClick?: () => void +}) => { + const button = document.createElement('button') + button.type = type + button.className = className + button.textContent = textContent + + if (onClick) { + button.addEventListener('click', onClick) + } + + return button +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function openPrompt(options: { + title?: string + fields: Record + onSubmit: (values: Record) => void +}) { + const wrapper = document.body.appendChild(document.createElement('div')) + wrapper.className = prefix + + const mouseOutside = (ev: MouseEvent & { target: Node }) => { + if (!wrapper.contains(ev.target)) { + close() + } + } + + setTimeout(() => window.addEventListener('mousedown', mouseOutside), 50) + + const close = () => { + window.removeEventListener('mousedown', mouseOutside) + if (wrapper.parentNode) wrapper.remove() + } + + const domFields: HTMLElement[] = [] + + Object.keys(options.fields).forEach((name) => { + domFields.push(options.fields[name].render()) + }) + + const submitButton = createButton({ textContent: 'OK', type: 'submit', className: prefix + '-submit' }) + const cancelButton = createButton({ + className: prefix + '-cancel', + textContent: 'Cancel', + onClick: close + }) + + const form = wrapper.appendChild(document.createElement('form')) + + if (options.title) { + form.appendChild(document.createElement('h5')).textContent = options.title + } + + domFields.forEach((fieldEl: HTMLElement) => { + form.appendChild(document.createElement('div')).appendChild(fieldEl) + }) + + const buttons = form.appendChild(document.createElement('div')) + buttons.className = prefix + '-buttons' + buttons.appendChild(submitButton) + buttons.appendChild(document.createTextNode(' ')) + buttons.appendChild(cancelButton) + + const box = wrapper.getBoundingClientRect() + wrapper.style.top = (window.innerHeight - box.height) / 2 + 'px' + wrapper.style.left = (window.innerWidth - box.width) / 2 + 'px' + + const submit = () => { + const values = getValues(options.fields, domFields) + if (values) { + close() + options.onSubmit(values) + } + } + + form.addEventListener('submit', (e) => { + e.preventDefault() + submit() + }) + + form.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault() + close() + } else if (e.key === 'Enter' && !(e.ctrlKey || e.metaKey || e.shiftKey)) { + e.preventDefault() + submit() + } else if (e.key === 'Tab') { + window.setTimeout(() => { + if (!wrapper.contains(document.activeElement)) close() + }, 500) + } + }) + + form.querySelector('input')?.focus() +} + +function getValues(fields: Record, domFields: HTMLElement[]) { + const result = {} + + // TODO: make field read its own value, maybe move to SolidJS + const fieldNames = Object.keys(fields) + + for (const [i, fieldName] of fieldNames.entries()) { + const field = fields[fieldName] + + const dom = domFields[i] + const value = field.read(dom) + const bad = field.validate(value) + + if (bad) { + reportInvalid(dom, bad) + return null + } + + result[fieldName] = field.clean(value) + } + + return result +} + +function reportInvalid(dom: HTMLElement, message: string) { + const msg: HTMLElement = dom.parentNode.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 + setTimeout(msg.remove, 1500) +} + +export abstract class Field { + options: any + + constructor(options: any) { + this.options = options + } + + read(dom: any) { + return dom.value + } + + // :: (any) → ?string + // A field-type-specific validation function. + validateType(_value) { + return typeof _value === typeof '' + } + + validate(value: any) { + if (!value && this.options.required) return 'Required field' + + return this.validateType(value) || (this.options.validate && this.options.validate(value)) + } + + clean(value: any) { + return this.options.clean ? this.options.clean(value) : value + } + + abstract render(): HTMLElement +} + +export class TextField extends Field { + render() { + const input: HTMLInputElement = document.createElement('input') + + input.type = 'text' + input.placeholder = this.options.label + input.value = this.options.value || '' + input.autocomplete = 'off' + return input + } +} + +export class SelectField extends Field { + render() { + const select = document.createElement('select') + this.options.options.forEach((o: { value: string; label: string }) => { + const opt = select.appendChild(document.createElement('option')) + opt.value = o.value + opt.selected = o.value === this.options.value + opt.label = o.label + }) + return select + } +} diff --git a/src/components/EditorNew/prosemirror/plugins/customKeymap.ts b/src/components/EditorNew/prosemirror/plugins/customKeymap.ts new file mode 100644 index 00000000..0f06d3cd --- /dev/null +++ b/src/components/EditorNew/prosemirror/plugins/customKeymap.ts @@ -0,0 +1,18 @@ +import { baseKeymap } from 'prosemirror-commands' +import type { Command } from 'prosemirror-state' +import { redo, undo } from 'prosemirror-history' +import { keymap } from 'prosemirror-keymap' + +export const customKeymap = () => { + const bindings: { + [key: string]: Command + } = { + ...baseKeymap, + Tab: () => true, + // TODO: collab + [`Mod-z`]: undo, + [`Shift-Mod-z`]: redo + } + + return keymap(bindings) +} diff --git a/src/components/EditorNew/prosemirror/plugins/dragHandle.ts b/src/components/EditorNew/prosemirror/plugins/dragHandle.ts new file mode 100644 index 00000000..33d31f5d --- /dev/null +++ b/src/components/EditorNew/prosemirror/plugins/dragHandle.ts @@ -0,0 +1,48 @@ +import { Plugin, NodeSelection } from 'prosemirror-state' +import { DecorationSet, Decoration } from 'prosemirror-view' + +const handleIcon = ` + + + ` + +const createDragHandle = () => { + const handle = document.createElement('span') + handle.setAttribute('contenteditable', 'false') + const icon = document.createElement('span') + icon.innerHTML = handleIcon + handle.appendChild(icon) + handle.classList.add('handle') + return handle +} + +export const dragHandle = () => + new Plugin({ + props: { + decorations(state) { + const decos = [] + state.doc.forEach((node, pos) => { + decos.push( + Decoration.widget(pos + 1, createDragHandle), + Decoration.node(pos, pos + node.nodeSize, { class: 'draggable' }) + ) + }) + + return DecorationSet.create(state.doc, decos) + }, + handleDOMEvents: { + mousedown: (editorView, event: MouseEvent & { target: Element }) => { + const target = event.target + + if (target.classList.contains('handle')) { + const pos = editorView.posAtCoords({ left: event.x, top: event.y }) + const resolved = editorView.state.doc.resolve(pos.pos) + const tr = editorView.state.tr + tr.setSelection(NodeSelection.create(editorView.state.doc, resolved.before())) + editorView.dispatch(tr) + return false + } + } + } + } + }) diff --git a/src/components/EditorNew/prosemirror/plugins/image.ts b/src/components/EditorNew/prosemirror/plugins/image.ts new file mode 100644 index 00000000..a07c64f6 --- /dev/null +++ b/src/components/EditorNew/prosemirror/plugins/image.ts @@ -0,0 +1,50 @@ +import { Plugin } from 'prosemirror-state' +import type { DiscoursSchema } from '../schema' + +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 { + return false + } +} + +const isBlank = (text: string) => text === ' ' || text === '\u00A0' + +export const imageInput = (schema: DiscoursSchema) => + new Plugin({ + props: { + handleTextInput(view, from, to, text) { + if (view.composing || !isBlank(text)) return false + const $from = view.state.doc.resolve(from) + if ($from.parent.type.spec.code) return false + const textBefore = + $from.parent.textBetween( + Math.max(0, $from.parentOffset - MAX_MATCH), + $from.parentOffset, + null, + '\uFFFC' + ) + text + + const match = REGEX.exec(textBefore) + if (match) { + const [, title, src] = match + if (isUrl(src)) { + const node = schema.node('image', { src, title }) + const start = from - (match[0].length - text.length) + const tr = view.state.tr + tr.delete(start, to) + tr.insert(start, node) + view.dispatch(tr) + return true + } + + return false + } + } + } + }) diff --git a/src/components/EditorNew/prosemirror/plugins/index.ts b/src/components/EditorNew/prosemirror/plugins/index.ts new file mode 100644 index 00000000..7a4f6439 --- /dev/null +++ b/src/components/EditorNew/prosemirror/plugins/index.ts @@ -0,0 +1,26 @@ +import { keymap } from 'prosemirror-keymap' +import { baseKeymap } from 'prosemirror-commands' +import { history } from 'prosemirror-history' +import { dropCursor } from 'prosemirror-dropcursor' +import { placeholder } from './placeholder' +import { t } from '../../../../utils/intl' +import styles from '../styles/ProseMirror.module.scss' +import type { DiscoursSchema } from '../schema' +import { dragHandle } from './dragHandle' +import { selectionMenu } from './selectionMenu' +import { imageInput } from './image' +import { customKeymap } from './customKeymap' + +export const createPlugins = ({ schema }: { schema: DiscoursSchema }) => { + return [ + placeholder(t('Just start typing...')), + customKeymap(), + history(), + dropCursor({ class: styles.dropCursor }), + selectionMenu(schema), + dragHandle(), + imageInput(schema) + // TODO + // link(), + ] +} diff --git a/src/components/EditorNew/prosemirror/plugins/placeholder.ts b/src/components/EditorNew/prosemirror/plugins/placeholder.ts new file mode 100644 index 00000000..25a4142e --- /dev/null +++ b/src/components/EditorNew/prosemirror/plugins/placeholder.ts @@ -0,0 +1,23 @@ +import { Plugin } from 'prosemirror-state' +import { DecorationSet, Decoration } from 'prosemirror-view' +import styles from '../styles/ProseMirror.module.scss' + +export const placeholder = (text: string): Plugin => + new Plugin({ + props: { + decorations(state) { + const { doc } = state + + if (doc.childCount > 1 || !doc.firstChild.isTextblock || doc.firstChild.content.size > 0) { + return + } + + const div = document.createElement('div') + div.setAttribute('contenteditable', 'false') + div.classList.add(styles.placeholder) + div.textContent = text + + return DecorationSet.create(doc, [Decoration.widget(1, div)]) + } + } + }) diff --git a/src/components/EditorNew/prosemirror/plugins/selectionMenu.ts b/src/components/EditorNew/prosemirror/plugins/selectionMenu.ts new file mode 100644 index 00000000..2de17ac7 --- /dev/null +++ b/src/components/EditorNew/prosemirror/plugins/selectionMenu.ts @@ -0,0 +1,53 @@ +import { renderGrouped } from 'prosemirror-menu' +import { EditorState, Plugin } from 'prosemirror-state' +import styles from '../styles/ProseMirror.module.scss' +import type { EditorView } from 'prosemirror-view' +import type { DiscoursSchema } from '../schema' +import { buildMenuItems } from '../helpers/menu' + +export class SelectionMenuView { + tooltip: HTMLDivElement + + constructor(view: EditorView, schema: DiscoursSchema) { + this.tooltip = document.createElement('div') + this.tooltip.className = styles.selectionMenu + view.dom.parentNode.appendChild(this.tooltip) + const { dom } = renderGrouped(view, buildMenuItems(schema)) + 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 + } + + if (state.selection.empty) { + this.tooltip.style.display = 'none' + return + } + + this.tooltip.style.display = '' + const { from, to } = state.selection + const start = view.coordsAtPos(from) + const end = view.coordsAtPos(to) + const box = this.tooltip.offsetParent.getBoundingClientRect() + const width = this.tooltip.getBoundingClientRect().width + const left = (start.left + end.left - width) / 2 + this.tooltip.style.left = `${left - box.left}px` + this.tooltip.style.bottom = `${box.bottom - start.top + 8}px` + } + + destroy() { + this.tooltip.remove() + } +} + +export const selectionMenu = (schema: DiscoursSchema) => + new Plugin({ + view(editorView: EditorView) { + return new SelectionMenuView(editorView, schema) + } + }) diff --git a/src/components/EditorNew/prosemirror/schema.ts b/src/components/EditorNew/prosemirror/schema.ts new file mode 100644 index 00000000..47b39188 --- /dev/null +++ b/src/components/EditorNew/prosemirror/schema.ts @@ -0,0 +1,172 @@ +import { Node, Schema, SchemaSpec } from 'prosemirror-model' + +export type Nodes = + | 'doc' + | 'paragraph' + | 'text' + | 'heading' + | 'ordered_list' + | 'bullet_list' + | 'list_item' + | 'blockquote' + | 'image' + | 'embed' + +export type Marks = 'strong' | 'em' | 'strikethrough' | 'note' | 'link' | 'highlight' + +export type DiscoursSchema = Schema + +export const schemaSpec: SchemaSpec = { + nodes: { + doc: { + content: 'block+' + }, + paragraph: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0] + }, + text: { + group: 'inline' + }, + heading: { + attrs: { level: { default: 1 } }, + content: 'inline*', + group: 'block', + defining: true, + parseDOM: [ + { tag: 'h1', attrs: { level: 1 } }, + { tag: 'h2', attrs: { level: 2 } }, + { tag: 'h3', attrs: { level: 3 } } + ], + toDOM(node) { + return ['h' + node.attrs.level, 0] + } + }, + ordered_list: { + group: 'block', + content: 'list_item+', + attrs: { order: { default: 1 } }, + parseDOM: [ + { + tag: 'ol', + getAttrs(dom: HTMLElement) { + return { order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1 } + } + } + ], + toDOM(node) { + return node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0] + } + }, + bullet_list: { + group: 'block', + content: 'list_item+', + parseDOM: [{ tag: 'ul' }], + toDOM() { + return ['ul', 0] + } + }, + list_item: { + content: 'paragraph block*', + parseDOM: [{ tag: 'li' }], + toDOM() { + return ['li', 0] + }, + defining: true + }, + blockquote: { + content: 'block+', + group: 'block', + defining: true, + parseDOM: [{ tag: 'blockquote' }], + toDOM() { + return ['blockquote', 0] + } + }, + embed: {}, + /// + image: { + inline: true, + attrs: { + src: {}, + alt: { default: null }, + title: { default: null }, + path: { default: null }, + width: { default: null } + }, + group: 'inline', + draggable: true, + parseDOM: [ + { + tag: 'img[src]', + getAttrs: (dom: HTMLElement) => ({ + src: dom.getAttribute('src'), + title: dom.getAttribute('title'), + alt: dom.getAttribute('alt'), + path: dom.dataset.path + }) + } + ], + toDOM: (node: Node) => [ + 'img', + { + src: node.attrs.src, + title: node.attrs.title, + alt: node.attrs.alt, + 'data-path': node.attrs.path + } + ] + } + }, + marks: { + strong: { + parseDOM: [ + { tag: 'strong' }, + // This works around a Google Docs misbehavior where + // pasted content will be inexplicably wrapped in `` + // tags with a font-weight normal. + { tag: 'b', getAttrs: (node: HTMLElement) => node.style.fontWeight !== 'normal' && null }, + { + style: 'font-weight', + getAttrs: (value: string) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null + } + ], + toDOM() { + return ['strong', 0] + } + }, + em: { + parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], + toDOM() { + return ['em', 0] + } + }, + link: { + attrs: { + href: {}, + title: { default: null } + }, + inclusive: false, + parseDOM: [ + { + tag: 'a[href]', + getAttrs(dom: HTMLElement) { + return { href: dom.getAttribute('href'), title: dom.getAttribute('title') } + } + } + ], + toDOM(node) { + const { href, title } = node.attrs + return ['a', { href, title }, 0] + } + }, + // TODO: + highlight: {}, + strikethrough: {}, + note: {} + } +} + +export const schema = new Schema(schemaSpec) diff --git a/src/components/EditorNew/prosemirror/styles/ProseMirror.module.scss b/src/components/EditorNew/prosemirror/styles/ProseMirror.module.scss new file mode 100644 index 00000000..24cde362 --- /dev/null +++ b/src/components/EditorNew/prosemirror/styles/ProseMirror.module.scss @@ -0,0 +1,21 @@ +.dropCursor { + // TODO check why important + height: 2px !important; + opacity: 0.5; +} + +.selectionMenu { + background: #fff; + box-shadow: 0 4px 10px rgb(0 0 0 / 25%); + color: #000; + display: flex; + position: absolute; + z-index: 100; +} + +.placeholder { + opacity: 0.3; + position: absolute; + pointer-events: none; + user-select: none; +} diff --git a/src/components/EditorNew/prosemirror/styles/ProseMirror.scss b/src/components/EditorNew/prosemirror/styles/ProseMirror.scss new file mode 100644 index 00000000..07f1982e --- /dev/null +++ b/src/components/EditorNew/prosemirror/styles/ProseMirror.scss @@ -0,0 +1,329 @@ +.ProseMirror { + color: var(--foreground); + background-color: var(--background); + position: relative; + word-wrap: break-word; + white-space: pre-wrap; + font-variant-ligatures: none; + outline: none; + margin: 1em 1em 1em 0; + + .dark & { + color: var(--background); + background-color: var(--foreground); + } + + .draggable { + position: relative; + margin-left: -30px; + padding-left: 30px; + } + + .handle { + position: absolute; + left: 0; + top: 0; + height: calc(var(--font-fize) * 1.6px); + opacity: 0; + cursor: move; + transition: opacity 0.3s; + display: inline-flex; + align-items: center; + justify-content: center; + + > span { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + padding: 6px; + fill: var(--foreground); + pointer-events: none; + user-select: none; + } + + &:hover > span { + background: var(--foreground); + } + } + + h1 .handle { + height: calc(var(--font-size) * 2.3px); + } + + .draggable:hover .handle { + opacity: 1; + } + + blockquote { + border-left: 2px solid; + @include font-size(1.6rem); + + margin: 1.5em 0; + padding-left: 1.6em; + } +} + +.ProseMirror-menuitem { + display: flex; + font-size: small; + + &:hover { + > * { + background: #eee; + } + + .ProseMirror-menu-disabled { + background: inherit; + } + } + + > * { + cursor: pointer; + align-items: center; + display: flex; + padding: 0.8rem 1em; + } +} + +.ProseMirror-textblock-dropdown { + min-width: 3em; +} + +.ProseMirror-menu { + margin: 0 -4px; + line-height: 1; +} + +.ProseMirror-tooltip .ProseMirror-menu { + width: fit-content; + white-space: pre; +} + +.ProseMirror-menuseparator { + border-right: 1px solid #ddd; +} + +.ProseMirror-menu-dropdown, +.ProseMirror-menu-dropdown-menu { + padding: 4px; + white-space: nowrap; +} + +.ProseMirror-menu-dropdown { + vertical-align: 1px; + cursor: pointer; + position: relative; + padding-right: 15px; +} + +.ProseMirror-menu-dropdown-wrap { + padding: 1px 0 1px 4px; + display: inline-block; + position: relative; +} + +.ProseMirror-menu-dropdown::after { + content: ''; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentcolor; + opacity: 0.6; + position: absolute; + right: 4px; + top: calc(50% - 2px); +} + +.ProseMirror-menu-dropdown-menu, +.ProseMirror-menu-submenu { + position: absolute; + background: white; + color: #666; + border: 1px solid #aaa; + padding: 2px; +} + +.ProseMirror-menu-dropdown-menu { + z-index: 15; + + /* min-width: 6em; */ +} + +.ProseMirror-menu-dropdown-item { + cursor: pointer; + padding: 2px 8px 2px 4px; +} + +.ProseMirror-menu-dropdown-item:hover { + background: #f2f2f2; +} + +.ProseMirror-menu-submenu-wrap { + position: relative; + margin-right: -4px; +} + +.ProseMirror-menu-submenu-label::after { + content: ''; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 4px solid currentcolor; + opacity: 0.6; + position: absolute; + right: 4px; + top: calc(50% - 4px); +} + +.ProseMirror-menu-submenu { + display: none; + left: 100%; + top: -3px; +} + +.ProseMirror-menu-active { + background: #eee; +} + +.ProseMirror-menu-disabled { + cursor: default; + opacity: 0.3; +} + +.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, +.ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { + display: block; +} + +.ProseMirror-menubar { + border-top-left-radius: inherit; + border-top-right-radius: inherit; + display: flex; + position: relative; + min-height: 1em; + color: #666; + padding: 0 1.5em; + top: 0; + left: 0; + right: 0; + border-bottom: 1px solid silver; + background: white; + z-index: 10; + box-sizing: border-box; + overflow: visible; +} + +.ProseMirror-icon { + cursor: pointer; + line-height: 0.8; +} + +.ProseMirror-menu-disabled.ProseMirror-icon { + cursor: default; +} + +.ProseMirror-icon svg { + fill: currentcolor; + height: 1em; +} + +.ProseMirror-icon span { + vertical-align: text-top; +} + +.ProseMirror pre { + white-space: pre-wrap; +} + +.ProseMirror li { + position: relative; +} + +.ProseMirror-hideselection *::selection { + background: transparent; +} + +.ProseMirror-hideselection { + caret-color: transparent; +} + +.ProseMirror-selectednode { + outline: 2px solid #8cf; +} + +/* Make sure li selections wrap around markers */ +li.ProseMirror-selectednode { + outline: none; +} + +li.ProseMirror-selectednode::after { + content: ''; + position: absolute; + left: -32px; + right: -2px; + top: -2px; + bottom: -2px; + border: 2px solid #8cf; + pointer-events: none; +} + +.ProseMirror .empty-node::before { + position: absolute; + color: #aaa; + cursor: text; +} + +.ProseMirror .empty-node:hover::before { + color: #777; +} + +.ProseMirror.editor_empty::before { + position: absolute; + content: attr(data-placeholder); + pointer-events: none; + color: var(--ui-color-placeholder); +} + +.ProseMirror-prompt { + background: #fff; + box-shadow: 0 4px 10px rgb(0 0 0 / 25%); + font-size: 0.7em; + position: absolute; +} + +.ProseMirror-prompt input[type='text'] { + border: none; + font-size: 100%; + margin-bottom: 0; + padding: 0.5em 7.5em 0.5em 0.5em; +} + +.ProseMirror-prompt-buttons { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + height: 100%; +} + +.ProseMirror-prompt-buttons button { + border: none; + cursor: pointer; + display: inline-block; + font-size: 90%; + height: 100%; + line-height: 10em; + margin-bottom: 0; + overflow: hidden; + vertical-align: top; + width: 2.5em; +} + +.ProseMirror-prompt-submit { + background: url("data:image/svg+xml,%3Csvg width='19' height='15' viewBox='0 0 19 15' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M19 2.73787L16.2621 0L6.78964 9.47248L2.73787 5.42071L0 8.15858L6.78964 14.9482L19 2.73787Z' fill='%23393840'/%3E%3C/svg%3E") + center no-repeat; +} + +.ProseMirror-prompt-cancel { + background: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13.1512 0.423856L0.423263 13.1518L2.84763 15.5761L15.5756 2.84822L13.1512 0.423856Z M15.5755 13.1518L2.84763 0.423855L0.423263 2.84822L13.1512 15.5761L15.5755 13.1518Z' fill='%23393840'/%3E%3C/svg%3E%0A") + center no-repeat; +} diff --git a/src/components/EditorNew/prosemirror/views/image.ts b/src/components/EditorNew/prosemirror/views/image.ts new file mode 100644 index 00000000..d53bf0dd --- /dev/null +++ b/src/components/EditorNew/prosemirror/views/image.ts @@ -0,0 +1,68 @@ +import type { EditorView, NodeView, NodeViewConstructor } from 'prosemirror-view' +import type { Node } from 'prosemirror-model' + +class ImageView implements NodeView { + node: Node + view: EditorView + getPos: () => number + dom: Element + container: HTMLElement + handle: HTMLElement + onResizeFn: any + onResizeEndFn: any + width: number + updating: number + + constructor(node: Node, view: EditorView, getPos: () => number) { + this.node = node + this.view = view + this.getPos = getPos + this.onResizeFn = this.onResize.bind(this) + this.onResizeEndFn = this.onResizeEnd.bind(this) + + this.container = document.createElement('span') + this.container.className = 'image-container' + if (node.attrs.width) this.setWidth(node.attrs.width) + + const image = document.createElement('img') + image.setAttribute('title', node.attrs.title ?? '') + image.setAttribute('src', node.attrs.src) + + this.handle = document.createElement('span') + this.handle.className = 'resize-handle' + this.handle.addEventListener('mousedown', (e) => { + e.preventDefault() + window.addEventListener('mousemove', this.onResizeFn) + window.addEventListener('mouseup', this.onResizeEndFn) + }) + + this.container.appendChild(image) + this.container.appendChild(this.handle) + this.dom = this.container + } + + onResize(e: MouseEvent) { + this.width = e.pageX - this.container.getBoundingClientRect().left + this.setWidth(this.width) + } + + onResizeEnd() { + window.removeEventListener('mousemove', this.onResizeFn) + if (this.updating === this.width) return + this.updating = this.width + const tr = this.view.state.tr + tr.setNodeMarkup(this.getPos(), undefined, { + ...this.node.attrs, + width: this.width + }) + + this.view.dispatch(tr) + } + + setWidth(width: number) { + this.container.style.width = width + 'px' + } +} + +export const createImageView: NodeViewConstructor = (node: Node, view: EditorView, getPos: () => number) => + new ImageView(node, view, getPos) diff --git a/src/components/Feed/Beside.module.scss b/src/components/Feed/Beside.module.scss index fb3d7b66..1e5486ab 100644 --- a/src/components/Feed/Beside.module.scss +++ b/src/components/Feed/Beside.module.scss @@ -12,8 +12,6 @@ } li { - margin-bottom: 1em; - &.top { border-bottom: 1px solid #e1e1e1; display: flex; diff --git a/src/components/Nav/Modal.scss b/src/components/Nav/Modal.scss index 8c7bc2f0..6be20367 100644 --- a/src/components/Nav/Modal.scss +++ b/src/components/Nav/Modal.scss @@ -1,12 +1,11 @@ .modalwrap { + pointer-events: all; align-items: center; background: rgb(20 20 20 / 70%); display: flex; justify-content: center; height: 100%; left: 0; - overflow: auto; - pointer-events: all; position: fixed; top: 0; width: 100%; diff --git a/src/components/Topic/Full.module.scss b/src/components/Topic/Full.scss similarity index 66% rename from src/components/Topic/Full.module.scss rename to src/components/Topic/Full.scss index f0c39156..ef8c4baf 100644 --- a/src/components/Topic/Full.module.scss +++ b/src/components/Topic/Full.scss @@ -1,19 +1,11 @@ -.topicHeader { +.topic__header { @include font-size(1.7rem); padding-top: 5.8rem; text-align: center; - - h1 { - color: #2638d9; - font-weight: 500; - text-transform: uppercase; - - @include font-size(2rem); - } } -.topicActions { +.topic__actions { margin-top: 2.8rem; button, diff --git a/src/components/Topic/Full.tsx b/src/components/Topic/Full.tsx index c6bb57fa..5923ead9 100644 --- a/src/components/Topic/Full.tsx +++ b/src/components/Topic/Full.tsx @@ -1,11 +1,10 @@ import { createMemo, Show } from 'solid-js' import type { Topic } from '../../graphql/types.gen' import { FollowingEntity } from '../../graphql/types.gen' -import styles from './Full.module.scss' +import './Full.scss' import { useAuthStore } from '../../stores/auth' import { follow, unfollow } from '../../stores/zine/common' import { t } from '../../utils/intl' -import { clsx } from 'clsx' type Props = { topic: Topic @@ -16,35 +15,37 @@ export const FullTopic = (props: Props) => { const subscribed = createMemo(() => session()?.news?.topics?.includes(props.topic?.slug)) return ( -
- -
-

#{props.topic.title}

-

{props.topic.body}

-
- - +
+
+ +
+

#{props.topic.title}

+

{props.topic.body}

+
+ + + + + + + {t('Write about the topic')} +
+ + {props.topic.title} - - - - {t('Write about the topic')}
- - {props.topic.title} - -
- + +
) } diff --git a/src/components/Views/Author.tsx b/src/components/Views/Author.tsx index 59278d34..52cd189f 100644 --- a/src/components/Views/Author.tsx +++ b/src/components/Views/Author.tsx @@ -7,6 +7,7 @@ import { t } from '../../utils/intl' import { useAuthorsStore } from '../../stores/zine/authors' import { loadAuthorArticles, useArticlesStore } from '../../stores/zine/articles' +import '../../styles/Topic.scss' import { useTopicsStore } from '../../stores/zine/topics' import { useRouter } from '../../stores/router' import { Beside } from '../Feed/Beside' diff --git a/src/components/Views/Create.tsx b/src/components/Views/Create.tsx index 757ea2b2..2281ddf5 100644 --- a/src/components/Views/Create.tsx +++ b/src/components/Views/Create.tsx @@ -1,72 +1,16 @@ -import { Show, onCleanup, createEffect, onError, onMount, untrack, createSignal } from 'solid-js' -import { createMutable, unwrap } from 'solid-js/store' -import { State, StateContext, newState } 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' -import ErrorView from '../Editor/components/Error' - -const matchDark = () => window.matchMedia('(prefers-color-scheme: dark)') +import { Show, onMount, createSignal } from 'solid-js' +import { Editor } from '../EditorNew/Editor' export const CreateView = () => { + // don't render anything on server + // usage of isServer causing hydration errors const [isMounted, setIsMounted] = createSignal(false) onMount(() => setIsMounted(true)) - const onChangeTheme = () => ctrl.updateTheme() - onMount(() => { - matchDark().addEventListener('change', onChangeTheme) - onCleanup(() => matchDark().removeEventListener('change', onChangeTheme)) - }) - - const [store, ctrl] = createCtrl(newState()) - const mouseEnterCoords = createMutable({ x: 0, y: 0 }) - - const onMouseEnter = (e: MouseEvent) => { - mouseEnterCoords.x = e.pageX - mouseEnterCoords.y = e.pageY - } - - onMount(async () => { - console.debug('[create] view mounted') - if (store.error) { - console.error(store.error) - return - } - await ctrl.init() - }) - - onError((error) => { - console.error('[create] error:', error) - ctrl.setState({ error: { id: 'exception', props: { error } } }) - }) - - createEffect((prev) => { - const lastModified = store.lastModified - if (!lastModified || (store.loading === 'initialized' && prev === 'loading')) { - return store.loading - } - const state: State = untrack(() => unwrap(store)) - ctrl.saveState(state) - console.debug('[create] status update') - return store.loading - }, store.loading) - return ( - - - }> - - - - - + ) } diff --git a/src/graphql/mutation/article-create.ts b/src/graphql/mutation/article-create.ts index 62523e87..0f2ef0ba 100644 --- a/src/graphql/mutation/article-create.ts +++ b/src/graphql/mutation/article-create.ts @@ -9,13 +9,11 @@ export default gql` slug title subtitle - image body topics { _id: slug title slug - image } authors { _id: slug diff --git a/src/graphql/types.gen.ts b/src/graphql/types.gen.ts index 5b233587..cde59629 100644 --- a/src/graphql/types.gen.ts +++ b/src/graphql/types.gen.ts @@ -31,29 +31,29 @@ export type Author = { } export type Chat = { - createdAt: Scalars['DateTime'] + createdAt: Scalars['Int'] createdBy: User description?: Maybe - id: Scalars['Int'] - messages?: Maybe>> + id: Scalars['String'] + messages: Array> title?: Maybe - updatedAt: Scalars['DateTime'] + unread?: Maybe + updatedAt: Scalars['Int'] users: Array> } -export type ChatResult = { - createdAt: Scalars['DateTime'] - createdBy?: Maybe - error?: Maybe - members: Array> - messages?: Maybe>> - title?: Maybe +export type ChatInput = { + description?: InputMaybe + id: Scalars['String'] + title?: InputMaybe } -export type ChatUpdatedResult = { - error?: Maybe - message?: Maybe - status?: Maybe +export type ChatMember = { + invitedAt?: Maybe + invitedBy?: Maybe + name: Scalars['String'] + pic?: Maybe + slug: Scalars['String'] } export type Collab = { @@ -95,17 +95,6 @@ export type CommunityInput = { title: Scalars['String'] } -export type CreateChatResult = { - chatId?: Maybe - error?: Maybe -} - -export type EnterChatResult = { - chat?: Maybe - error?: Maybe - messages?: Maybe>> -} - export enum FollowingEntity { Author = 'AUTHOR', Community = 'COMMUNITY', @@ -116,17 +105,11 @@ export enum FollowingEntity { export type Message = { author: Scalars['String'] body: Scalars['String'] - chatRoom: Scalars['Int'] - createdAt: Scalars['DateTime'] + chatId: Scalars['String'] + createdAt: Scalars['Int'] id: Scalars['Int'] - replyTo?: Maybe - updatedAt: Scalars['DateTime'] - visibleForUsers: Array> -} - -export type MessageResult = { - error?: Maybe - message?: Maybe + replyTo?: Maybe + updatedAt?: Maybe } export enum MessageStatus { @@ -137,10 +120,10 @@ export enum MessageStatus { export type Mutation = { confirmEmail: AuthResult - createChat: CreateChatResult + createChat: Result createCollection: Result createCommunity: Result - createMessage: MessageResult + createMessage: Result createReaction: Result createShout: Result createTopic: Result @@ -150,6 +133,7 @@ export type Mutation = { deleteReaction: Result deleteShout: Result destroyTopic: Result + enterChat: Result follow: Result incrementView: Result inviteAuthor: Result @@ -161,9 +145,10 @@ export type Mutation = { removeAuthor: Result sendLink: Result unfollow: Result + updateChat: Result updateCollection: Result updateCommunity: Result - updateMessage: MessageResult + updateMessage: Result updateProfile: Result updateReaction: Result updateShout: Result @@ -171,11 +156,12 @@ export type Mutation = { } export type MutationConfirmEmailArgs = { - code: Scalars['String'] + token: Scalars['String'] } export type MutationCreateChatArgs = { - description?: InputMaybe + members: Array> + title?: InputMaybe } export type MutationCreateCollectionArgs = { @@ -189,7 +175,7 @@ export type MutationCreateCommunityArgs = { export type MutationCreateMessageArgs = { body: Scalars['String'] chatId: Scalars['String'] - replyTo?: InputMaybe + replyTo?: InputMaybe } export type MutationCreateReactionArgs = { @@ -229,6 +215,10 @@ export type MutationDestroyTopicArgs = { slug: Scalars['String'] } +export type MutationEnterChatArgs = { + chatId: Scalars['String'] +} + export type MutationFollowArgs = { slug: Scalars['String'] what: FollowingEntity @@ -279,6 +269,10 @@ export type MutationUnfollowArgs = { what: FollowingEntity } +export type MutationUpdateChatArgs = { + chat: ChatInput +} + export type MutationUpdateCollectionArgs = { collection: CollectionInput } @@ -335,7 +329,6 @@ export type ProfileInput = { export type Query = { authorsAll: Array> collectionsAll: Array> - enterChat: ChatResult getCollabs: Array> getCommunities: Array> getCommunity: Community @@ -344,9 +337,9 @@ export type Query = { getUserRoles: Array> getUsersBySlugs: Array> isEmailUsed: Scalars['Boolean'] - loadChat: Array> + loadChat: Result markdownBody: Scalars['String'] - myChats: Array> + myChats: Result reactionsByAuthor: Array> reactionsForShouts: Array> recentAll: Array> @@ -378,10 +371,6 @@ export type Query = { userReactedShouts: Array> } -export type QueryEnterChatArgs = { - chatId: Scalars['String'] -} - export type QueryGetCommunityArgs = { slug?: InputMaybe } @@ -407,9 +396,9 @@ export type QueryIsEmailUsedArgs = { } export type QueryLoadChatArgs = { + amount?: InputMaybe chatId: Scalars['String'] - page: Scalars['Int'] - size: Scalars['Int'] + offset?: InputMaybe } export type QueryMarkdownBodyArgs = { @@ -619,9 +608,14 @@ export type Resource = { export type Result = { author?: Maybe authors?: Maybe>> + chat?: Maybe + chats?: Maybe>> communities?: Maybe>> community?: Maybe error?: Maybe + members?: Maybe>> + message?: Maybe + messages?: Maybe>> reaction?: Maybe reactions?: Maybe>> shout?: Maybe @@ -674,7 +668,7 @@ export type ShoutInput = { topic_slugs?: InputMaybe>> versionOf?: InputMaybe visibleForRoles?: InputMaybe>> - visibleForUsers?: InputMaybe>> + visibleForUsers?: InputMaybe>> } export type Stat = { @@ -686,15 +680,15 @@ export type Stat = { } export type Subscription = { - chatUpdated: ChatUpdatedResult + newMessage: Message onlineUpdated: Array reactionUpdated: ReactionUpdating shoutUpdated: Shout userUpdated: User } -export type SubscriptionChatUpdatedArgs = { - chatId: Scalars['String'] +export type SubscriptionNewMessageArgs = { + chats?: InputMaybe> } export type SubscriptionReactionUpdatedArgs = { diff --git a/src/locales/ru.json b/src/locales/ru.json index bf90bb84..e8b1a94d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -168,5 +168,6 @@ "We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.", "Send link again": "Прислать ссылку ещё раз", "Link sent, check your email": "Ссылка отправлена, проверьте почту", - "Create post": "Создать публикацию" + "Create post": "Создать публикацию", + "Just start typing...": "Просто начните печатать..." } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 3868646d..8acb0c42 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -2,7 +2,8 @@ import { persistentMap } from '@nanostores/persistent' import type { Reaction } from '../graphql/types.gen' import { atom } from 'nanostores' import { createSignal } from 'solid-js' -import type { Draft } from '../components/Editor/store/context' + +// import type { Draft } from '../components/EditorExample/store/context' interface Collab { authors: string[] // slugs @@ -12,7 +13,7 @@ interface Collab { title?: string } -export const drafts = persistentMap<{ [key: string]: Draft }>( +export const drafts = persistentMap<{ [key: string]: string }>( 'drafts', {}, { diff --git a/src/stores/inbox.ts b/src/stores/inbox.ts index 7223b188..bbf7c471 100644 --- a/src/stores/inbox.ts +++ b/src/stores/inbox.ts @@ -1,4 +1,4 @@ import { atom } from 'nanostores' -import type { ChatResult } from '../graphql/types.gen' +import type { Chat } from '../graphql/types.gen' -export const chats = atom([]) +export const chats = atom([]) diff --git a/src/stores/zine/articles.ts b/src/stores/zine/articles.ts index b53790ba..a7ac1eee 100644 --- a/src/stores/zine/articles.ts +++ b/src/stores/zine/articles.ts @@ -1,4 +1,4 @@ -import type { Author, Shout, Topic } from '../../graphql/types.gen' +import type { Author, Shout, ShoutInput, Topic } from '../../graphql/types.gen' import { apiClient } from '../../utils/apiClient' import { addAuthorsByTopic } from './authors' import { addTopicsByAuthor } from './topics' @@ -272,6 +272,14 @@ export const loadArticle = async ({ slug }: { slug: string }): Promise => addArticles([article]) } +export const createArticle = async ({ article }: { article: ShoutInput }) => { + try { + await apiClient.createArticle({ article }) + } catch (error) { + console.error(error) + } +} + type InitialState = { sortedArticles?: Shout[] topRatedArticles?: Shout[] diff --git a/src/styles/Topic.module.scss b/src/styles/Topic.scss similarity index 82% rename from src/styles/Topic.module.scss rename to src/styles/Topic.scss index fa18beab..20b16c28 100644 --- a/src/styles/Topic.module.scss +++ b/src/styles/Topic.scss @@ -1,11 +1,11 @@ -.topicPage { - .groupControls { +.topic-page { + .group__controls { align-items: baseline; margin-bottom: 4rem; margin-top: 7rem; } - .floorImportant { + .floor--important { a:hover { background: #fff; color: #000 !important; diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts index 670d7b8d..d798488e 100644 --- a/src/utils/apiClient.ts +++ b/src/utils/apiClient.ts @@ -1,4 +1,4 @@ -import type { Reaction, Shout, FollowingEntity, AuthResult } from '../graphql/types.gen' +import type { Reaction, Shout, FollowingEntity, AuthResult, ShoutInput } from '../graphql/types.gen' import { publicGraphQLClient } from '../graphql/publicGraphQLClient' import { privateGraphQLClient } from '../graphql/privateGraphQLClient' import articleBySlug from '../graphql/query/article-by-slug' @@ -27,6 +27,7 @@ import reactionDestroy from '../graphql/mutation/reaction-destroy' import reactionUpdate from '../graphql/mutation/reaction-update' import authorsBySlugs from '../graphql/query/authors-by-slugs' import incrementView from '../graphql/mutation/increment-view' +import createArticle from '../graphql/mutation/article-create' import myChats from '../graphql/query/my-chats' const FEED_SIZE = 50 @@ -307,6 +308,11 @@ export const apiClient = { const response = await publicGraphQLClient.query(authorsBySlugs, { slugs }).toPromise() return response.data.getUsersBySlugs }, + createArticle: async ({ article }: { article: ShoutInput }) => { + const response = await privateGraphQLClient.mutation(createArticle, { shout: article }).toPromise() + console.debug('createArticle response:', response) + return response.data.createShout + }, createReaction: async ({ reaction }) => { const response = await privateGraphQLClient.mutation(reactionCreate, { reaction }).toPromise() console.debug('[api-client] [api] create reaction mutation called') diff --git a/src/utils/config.ts b/src/utils/config.ts index 75f03f39..ab0ab70b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,4 +1,4 @@ export const isDev = import.meta.env.MODE === 'development' export const apiBaseUrl = 'https://newapi.discours.io' -// export const apiBaseUrl = 'http://localhost:8000' +// export const apiBaseUrl = 'http://localhost:8080' diff --git a/yarn.lock b/yarn.lock index 764e2393..786a141a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7928,20 +7928,13 @@ prosemirror-menu@^1.0.0, prosemirror-menu@^1.2.1: prosemirror-history "^1.0.0" prosemirror-state "^1.0.0" -prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.2.0: +prosemirror-model@^1.0.0, prosemirror-model@^1.16.0: version "1.18.1" resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.18.1.tgz#1d5d6b6de7b983ee67a479dc607165fdef3935bd" integrity sha512-IxSVBKAEMjD7s3n8cgtwMlxAXZrC7Mlag7zYsAKDndAqnDScvSmp/UdnRTV/B33lTCVU3CCm7dyAn/rVVD0mcw== dependencies: orderedmap "^2.0.0" -prosemirror-schema-basic@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.0.tgz#c33ad74426efae1d41e2260371866f623e8eb10e" - integrity sha512-JMN/ammP94ObOUS6cpIy121r0MEDN9V95mAxFVALwC4bbmhpWXGjBGHTA5LHPPdbqZKyR6Jar1Akv4Z5k9CNLw== - dependencies: - prosemirror-model "^1.2.0" - prosemirror-schema-list@^1.0.0, prosemirror-schema-list@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.2.2.tgz#bafda37b72367d39accdcaf6ddf8fb654a16e8e5"