Feature/simplified editor (#140)
* Simplified editor * Remove ProseMirror
This commit is contained in:
parent
a035f22c91
commit
48948f1141
3
public/icons/editor-image-dd-full.svg
Normal file
3
public/icons/editor-image-dd-full.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M23.2 21.5998V10.3998C23.2 9.5198 22.48 8.7998 21.6 8.7998H10.4C9.52005 8.7998 8.80005 9.5198 8.80005 10.3998V21.5998C8.80005 22.4798 9.52005 23.1998 10.4 23.1998H21.6C22.48 23.1998 23.2 22.4798 23.2 21.5998ZM13.2 17.1998L15.2 19.6078L18 15.9998L21.6 20.7998H10.4L13.2 17.1998Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 415 B |
|
@ -8,6 +8,7 @@ import styles from './AudioPlayer.module.scss'
|
|||
import { GrowingTextarea } from '../../_shared/GrowingTextarea'
|
||||
import MD from '../MD'
|
||||
import { MediaItem } from '../../../pages/types'
|
||||
import SimplifiedEditor from '../../Editor/SimplifiedEditor'
|
||||
|
||||
type Props = {
|
||||
media: MediaItem[]
|
||||
|
@ -145,7 +146,6 @@ export const PlayerPlaylist = (props: Props) => {
|
|||
<div class={styles.descriptionBlock}>
|
||||
<Show when={mi.body}>
|
||||
<div class={styles.description}>
|
||||
{/*FIXME*/}
|
||||
<MD body={mi.body} />
|
||||
</div>
|
||||
</Show>
|
||||
|
@ -158,12 +158,11 @@ export const PlayerPlaylist = (props: Props) => {
|
|||
}
|
||||
>
|
||||
<div class={styles.descriptionBlock}>
|
||||
<GrowingTextarea
|
||||
allowEnterKey={true}
|
||||
class={styles.description}
|
||||
<SimplifiedEditor
|
||||
initialContent={mi.body}
|
||||
onSubmit={(value) => handleMediaItemFieldChange('body', value)}
|
||||
placeholder={t('Description')}
|
||||
value={(value) => handleMediaItemFieldChange('body', value)}
|
||||
initialValue={mi.body || ''}
|
||||
smallHeight={true}
|
||||
/>
|
||||
<GrowingTextarea
|
||||
allowEnterKey={true}
|
||||
|
|
|
@ -17,7 +17,7 @@ import { getPagePath } from '@nanostores/router'
|
|||
import { router } from '../../stores/router'
|
||||
import { CommentDate } from './CommentDate'
|
||||
|
||||
const CommentEditor = lazy(() => import('../_shared/CommentEditor'))
|
||||
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
|
||||
|
||||
type Props = {
|
||||
comment: Reaction
|
||||
|
@ -34,7 +34,6 @@ export const Comment = (props: Props) => {
|
|||
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
|
||||
const [loading, setLoading] = createSignal<boolean>(false)
|
||||
const [editMode, setEditMode] = createSignal<boolean>(false)
|
||||
const [submitted, setSubmitted] = createSignal<boolean>(false)
|
||||
const { session } = useSession()
|
||||
|
||||
const {
|
||||
|
@ -71,7 +70,6 @@ export const Comment = (props: Props) => {
|
|||
})
|
||||
setIsReplyVisible(false)
|
||||
setLoading(false)
|
||||
setSubmitted(true)
|
||||
} catch (error) {
|
||||
console.error('[handleCreate reaction]:', error)
|
||||
}
|
||||
|
@ -160,7 +158,13 @@ export const Comment = (props: Props) => {
|
|||
<div class={styles.commentBody} id={'comment-' + (comment().id || '')}>
|
||||
<Show when={editMode()} fallback={<MD body={body()} />}>
|
||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||
<CommentEditor initialContent={body()} onSubmit={(value) => handleUpdate(value)} />
|
||||
<SimplifiedEditor
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
placeholder={t('Write a comment...')}
|
||||
onSubmit={(value) => handleUpdate(value)}
|
||||
submitByShiftEnter={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
|
@ -216,11 +220,12 @@ export const Comment = (props: Props) => {
|
|||
|
||||
<Show when={isReplyVisible()}>
|
||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||
<CommentEditor
|
||||
placeholder={''}
|
||||
clear={submitted()}
|
||||
<SimplifiedEditor
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
placeholder={t('Write a comment...')}
|
||||
onSubmit={(value) => handleCreate(value)}
|
||||
cancel={() => setIsReplyVisible(false)}
|
||||
submitByShiftEnter={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
|
|
@ -4,12 +4,12 @@ import styles from './Article.module.scss'
|
|||
import { clsx } from 'clsx'
|
||||
import { Author, Reaction, ReactionKind } from '../../graphql/types.gen'
|
||||
import { useSession } from '../../context/session'
|
||||
import CommentEditor from '../_shared/CommentEditor'
|
||||
import { Button } from '../_shared/Button'
|
||||
import { useReactions } from '../../context/reactions'
|
||||
import { byCreated } from '../../utils/sortby'
|
||||
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import SimplifiedEditor from '../Editor/SimplifiedEditor'
|
||||
|
||||
type CommentsOrder = 'createdAt' | 'rating' | 'newOnly'
|
||||
|
||||
|
@ -172,10 +172,12 @@ export const CommentsTree = (props: Props) => {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<CommentEditor
|
||||
<SimplifiedEditor
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
placeholder={t('Write a comment...')}
|
||||
clear={submitted()}
|
||||
onSubmit={(value) => handleSubmitComment(value)}
|
||||
submitByShiftEnter={true}
|
||||
/>
|
||||
</ShowIfAuthenticated>
|
||||
</>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createSignal } from 'solid-js'
|
||||
import { createEffect, createSignal, onMount } from 'solid-js'
|
||||
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import { Bold } from '@tiptap/extension-bold'
|
||||
|
@ -43,6 +43,7 @@ import type { Doc } from 'yjs/dist/src/utils/Doc'
|
|||
import './Prosemirror.scss'
|
||||
import { TrailingNode } from './extensions/TrailingNode'
|
||||
import Article from './extensions/Article'
|
||||
import styles from './SimplifiedEditor.module.scss'
|
||||
|
||||
type EditorProps = {
|
||||
shoutId: number
|
||||
|
@ -218,6 +219,10 @@ export const Editor = (props: EditorProps) => {
|
|||
]
|
||||
}))
|
||||
|
||||
onMount(() => {
|
||||
editor().view.dom.classList.add('articleEditor')
|
||||
})
|
||||
|
||||
const {
|
||||
actions: { countWords, setEditor }
|
||||
} = useEditorContext()
|
||||
|
|
55
src/components/Editor/InsertLinkForm/InsertLinkForm.tsx
Normal file
55
src/components/Editor/InsertLinkForm/InsertLinkForm.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { validateUrl } from '../../../utils/validateUrl'
|
||||
import { hideModal } from '../../../stores/ui'
|
||||
import { InlineForm } from '../InlineForm'
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { createEditorTransaction } from 'solid-tiptap'
|
||||
|
||||
type Props = {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
export const checkUrl = (url) => {
|
||||
try {
|
||||
new URL(url)
|
||||
return url
|
||||
} catch {
|
||||
return `https://${url}`
|
||||
}
|
||||
}
|
||||
|
||||
export const InsertLinkForm = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const currentUrl = createEditorTransaction(
|
||||
() => props.editor,
|
||||
(ed) => {
|
||||
return (ed && ed.getAttributes('link').href) || ''
|
||||
}
|
||||
)
|
||||
const handleClearLinkForm = () => {
|
||||
if (currentUrl()) {
|
||||
props.editor.chain().focus().unsetLink().run()
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinkFormSubmit = (value: string) => {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setLink({ href: checkUrl(value) })
|
||||
.run()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InlineForm
|
||||
placeholder={t('Enter URL address')}
|
||||
initialValue={currentUrl() ?? ''}
|
||||
onClear={handleClearLinkForm}
|
||||
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
|
||||
onSubmit={handleLinkFormSubmit}
|
||||
onClose={() => hideModal()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
1
src/components/Editor/InsertLinkForm/index.ts
Normal file
1
src/components/Editor/InsertLinkForm/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { InsertLinkForm } from './InsertLinkForm'
|
|
@ -17,10 +17,10 @@
|
|||
|
||||
// Keeping the cursor active when moving outside the editable area
|
||||
|
||||
.ProseMirror p,
|
||||
.ProseMirror ul,
|
||||
.ProseMirror h4,
|
||||
.ProseMirror ol {
|
||||
.articleEditor p,
|
||||
.articleEditor ul,
|
||||
.articleEditor h4,
|
||||
.articleEditor ol {
|
||||
box-sizing: content-box;
|
||||
flex: 0 0 auto;
|
||||
|
||||
|
@ -34,8 +34,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ProseMirror blockquote,
|
||||
.ProseMirror article[data-type='incut'] {
|
||||
.articleEditor blockquote,
|
||||
.articleEditor article[data-type='incut'] {
|
||||
@media (min-width: 768px) {
|
||||
margin-left: calc(21.9% + 3px) !important;
|
||||
max-width: 73.6%;
|
||||
|
@ -46,7 +46,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
.articleEditor h2 {
|
||||
@media (min-width: 768px) {
|
||||
padding-left: calc(21.9% + 2px);
|
||||
max-width: 72.7%;
|
||||
|
@ -57,7 +57,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
.articleEditor h3 {
|
||||
@media (min-width: 768px) {
|
||||
padding-left: calc(21.9% + 2px);
|
||||
}
|
||||
|
@ -67,10 +67,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.ProseMirror * p,
|
||||
.ProseMirror * h2,
|
||||
.ProseMirror * h3,
|
||||
.ProseMirror * h4 {
|
||||
.articleEditor * p,
|
||||
.articleEditor * h2,
|
||||
.articleEditor * h3,
|
||||
.articleEditor * h4 {
|
||||
@media (min-width: 768px) {
|
||||
padding-left: unset;
|
||||
max-width: unset;
|
||||
|
|
100
src/components/Editor/SimplifiedEditor.module.scss
Normal file
100
src/components/Editor/SimplifiedEditor.module.scss
Normal file
|
@ -0,0 +1,100 @@
|
|||
.SimplifiedEditor {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--black-50);
|
||||
border-radius: 16px;
|
||||
padding: 16px 16px 8px;
|
||||
|
||||
.simplifiedEditorField {
|
||||
@include font-size(1.4rem);
|
||||
min-height: 100px;
|
||||
|
||||
.emptyNode:first-child::before {
|
||||
@include font-size(1.4rem);
|
||||
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
margin-top: -4px;
|
||||
color: var(--black-400);
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
&.smallHeight .simplifiedEditorField {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
& * :focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.blockQuote {
|
||||
font-weight: 500;
|
||||
color: var(--black-500);
|
||||
border-left: 2px solid #696969;
|
||||
padding: 0 0 0 8px;
|
||||
margin: 0;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadedImage {
|
||||
max-height: 60vh;
|
||||
margin: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
opacity: 0;
|
||||
bottom: -1rem;
|
||||
transition: 0.3s ease-in-out;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity ease-in-out 0.3s;
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.isFocused {
|
||||
//background: red;
|
||||
.controls {
|
||||
opacity: 1;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
246
src/components/Editor/SimplifiedEditor.tsx
Normal file
246
src/components/Editor/SimplifiedEditor.tsx
Normal file
|
@ -0,0 +1,246 @@
|
|||
import { createEffect, onCleanup, onMount, Show } from 'solid-js'
|
||||
import {
|
||||
createEditorTransaction,
|
||||
createTiptapEditor,
|
||||
useEditorHTML,
|
||||
useEditorIsEmpty,
|
||||
useEditorIsFocused
|
||||
} from 'solid-tiptap'
|
||||
import { useEditorContext } from '../../context/editor'
|
||||
import { Document } from '@tiptap/extension-document'
|
||||
import { Text } from '@tiptap/extension-text'
|
||||
import { Paragraph } from '@tiptap/extension-paragraph'
|
||||
import { Bold } from '@tiptap/extension-bold'
|
||||
import { Button } from '../_shared/Button'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import { Icon } from '../_shared/Icon'
|
||||
import { Popover } from '../_shared/Popover'
|
||||
import { Italic } from '@tiptap/extension-italic'
|
||||
import { Modal } from '../Nav/Modal'
|
||||
import { hideModal, showModal } from '../../stores/ui'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Blockquote } from '@tiptap/extension-blockquote'
|
||||
import { CustomImage } from './extensions/CustomImage'
|
||||
import { UploadModalContent } from './UploadModalContent'
|
||||
import { imageProxy } from '../../utils/imageProxy'
|
||||
import { clsx } from 'clsx'
|
||||
import styles from './SimplifiedEditor.module.scss'
|
||||
import { Placeholder } from '@tiptap/extension-placeholder'
|
||||
import { InsertLinkForm } from './InsertLinkForm'
|
||||
|
||||
type Props = {
|
||||
initialContent?: string
|
||||
onSubmit: (text: string) => void
|
||||
placeholder: string
|
||||
quoteEnabled?: boolean
|
||||
imageEnabled?: boolean
|
||||
setClear?: boolean
|
||||
smallHeight?: boolean
|
||||
submitByEnter?: boolean
|
||||
submitByShiftEnter?: boolean
|
||||
}
|
||||
|
||||
const SimplifiedEditor = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
|
||||
const editorElRef: {
|
||||
current: HTMLDivElement
|
||||
} = {
|
||||
current: null
|
||||
}
|
||||
const {
|
||||
actions: { setEditor }
|
||||
} = useEditorContext()
|
||||
|
||||
const editor = createTiptapEditor(() => ({
|
||||
element: editorElRef.current,
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Bold,
|
||||
Italic,
|
||||
Link.configure({
|
||||
openOnClick: false
|
||||
}),
|
||||
Blockquote.configure({
|
||||
HTMLAttributes: {
|
||||
class: styles.blockQuote
|
||||
}
|
||||
}),
|
||||
CustomImage.configure({
|
||||
HTMLAttributes: {
|
||||
class: styles.uploadedImage
|
||||
}
|
||||
}),
|
||||
Placeholder.configure({
|
||||
emptyNodeClass: styles.emptyNode,
|
||||
placeholder: props.placeholder
|
||||
})
|
||||
],
|
||||
content: props.initialContent ?? null
|
||||
}))
|
||||
|
||||
setEditor(editor)
|
||||
const isEmpty = useEditorIsEmpty(() => editor())
|
||||
const isFocused = useEditorIsFocused(() => editor())
|
||||
|
||||
const isActive = (name: string) =>
|
||||
createEditorTransaction(
|
||||
() => editor(),
|
||||
(ed) => {
|
||||
return ed && ed.isActive(name)
|
||||
}
|
||||
)
|
||||
|
||||
const html = useEditorHTML(() => editor())
|
||||
const isBold = isActive('bold')
|
||||
const isItalic = isActive('italic')
|
||||
const isLink = isActive('link')
|
||||
const isBlockquote = isActive('blockquote')
|
||||
|
||||
const renderImage = (src: string) => {
|
||||
editor()
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: imageProxy(src) })
|
||||
.run()
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
editor().commands.clearContent(true)
|
||||
}
|
||||
createEffect(() => {
|
||||
if (props.setClear) {
|
||||
editor().commands.clearContent(true)
|
||||
}
|
||||
})
|
||||
|
||||
const handleKeyDown = async (event) => {
|
||||
if (props.submitByEnter && event.keyCode === 13 && !event.shiftKey && !isEmpty()) {
|
||||
event.preventDefault()
|
||||
props.onSubmit(html())
|
||||
handleClear()
|
||||
}
|
||||
|
||||
if (props.submitByShiftEnter && event.keyCode === 13 && event.shiftKey && !isEmpty()) {
|
||||
event.preventDefault()
|
||||
props.onSubmit(html())
|
||||
handleClear()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor().view.dom.classList.add(styles.simplifiedEditorField)
|
||||
if (props.submitByShiftEnter || props.submitByEnter) {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={clsx(styles.SimplifiedEditor, {
|
||||
[styles.smallHeight]: props.smallHeight,
|
||||
[styles.isFocused]: isFocused() || !isEmpty()
|
||||
})}
|
||||
>
|
||||
<div ref={(el) => (editorElRef.current = el)} />
|
||||
<div class={styles.controls}>
|
||||
<div class={styles.actions}>
|
||||
<Popover content={t('Bold')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
|
||||
onClick={() => editor().chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Icon name="editor-bold" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
<Popover content={t('Italic')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
|
||||
onClick={() => editor().chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Icon name="editor-italic" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
<Popover content={t('Add url')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => showModal('editorInsertLink')}
|
||||
class={clsx(styles.actionButton, { [styles.active]: isLink() })}
|
||||
>
|
||||
<Icon name="editor-link" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
<Show when={props.quoteEnabled}>
|
||||
<Popover content={t('Add blockquote')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => editor().chain().focus().toggleBlockquote().run()}
|
||||
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
||||
>
|
||||
<Icon name="editor-quote" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
</Show>
|
||||
<Show when={props.imageEnabled}>
|
||||
<Popover content={t('Add image')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => showModal('uploadImage')}
|
||||
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
||||
>
|
||||
<Icon name="editor-image-dd-full" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
</Show>
|
||||
</div>
|
||||
<div class={styles.buttons}>
|
||||
<Button value={t('cancel')} variant="secondary" disabled={isEmpty()} onClick={handleClear} />
|
||||
<Button
|
||||
value={t('Send')}
|
||||
variant="primary"
|
||||
disabled={isEmpty()}
|
||||
onClick={() => props.onSubmit(html())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal variant="narrow" name="editorInsertLink">
|
||||
<InsertLinkForm editor={editor()} />
|
||||
</Modal>
|
||||
<Show when={props.imageEnabled}>
|
||||
<Modal variant="narrow" name="uploadImage">
|
||||
<UploadModalContent
|
||||
onClose={(value) => {
|
||||
renderImage(value)
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SimplifiedEditor // "export default" need to use for asynchronous (lazy) imports in the comments tree
|
|
@ -5,9 +5,8 @@ import { Icon } from '../../_shared/Icon'
|
|||
import { clsx } from 'clsx'
|
||||
import { createEditorTransaction } from 'solid-tiptap'
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { InlineForm } from '../InlineForm'
|
||||
import { validateUrl } from '../../../utils/validateUrl'
|
||||
import { Popover } from '../../_shared/Popover'
|
||||
import { InsertLinkForm } from '../InsertLinkForm'
|
||||
|
||||
type BubbleMenuProps = {
|
||||
editor: Editor
|
||||
|
@ -15,20 +14,8 @@ type BubbleMenuProps = {
|
|||
ref: (el: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
const checkUrl = (url) => {
|
||||
try {
|
||||
new URL(url)
|
||||
return url
|
||||
} catch {
|
||||
return `https://${url}`
|
||||
}
|
||||
}
|
||||
|
||||
export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||
const { t } = useLocalize()
|
||||
const [textSizeBubbleOpen, setTextSizeBubbleOpen] = createSignal<boolean>(false)
|
||||
const [listBubbleOpen, setListBubbleOpen] = createSignal<boolean>(false)
|
||||
const [linkEditorOpen, setLinkEditorOpen] = createSignal<boolean>(false)
|
||||
|
||||
const isActive = (name: string, attributes?: unknown) =>
|
||||
createEditorTransaction(
|
||||
|
@ -37,6 +24,9 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
|||
return editor && editor.isActive(name, attributes)
|
||||
}
|
||||
)
|
||||
const [textSizeBubbleOpen, setTextSizeBubbleOpen] = createSignal(false)
|
||||
const [listBubbleOpen, setListBubbleOpen] = createSignal(false)
|
||||
const [linkEditorOpen, setLinkEditorOpen] = createSignal(false)
|
||||
|
||||
const isBold = isActive('bold')
|
||||
const isItalic = isActive('italic')
|
||||
|
@ -49,15 +39,10 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
|||
const isLink = isActive('link')
|
||||
const isHighlight = isActive('highlight')
|
||||
|
||||
const toggleLinkForm = () => {
|
||||
setLinkEditorOpen(true)
|
||||
}
|
||||
|
||||
const toggleTextSizePopup = () => {
|
||||
if (listBubbleOpen()) {
|
||||
setListBubbleOpen(false)
|
||||
}
|
||||
|
||||
setTextSizeBubbleOpen((prev) => !prev)
|
||||
}
|
||||
const toggleListPopup = () => {
|
||||
|
@ -67,40 +52,11 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
|||
setListBubbleOpen((prev) => !prev)
|
||||
}
|
||||
|
||||
const handleLinkFormSubmit = (value: string) => {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setLink({ href: checkUrl(value) })
|
||||
.run()
|
||||
}
|
||||
|
||||
const currentUrl = createEditorTransaction(
|
||||
() => props.editor,
|
||||
(editor) => {
|
||||
return (editor && editor.getAttributes('link').href) || ''
|
||||
}
|
||||
)
|
||||
|
||||
const handleClearLinkForm = () => {
|
||||
if (currentUrl()) {
|
||||
props.editor.chain().focus().unsetLink().run()
|
||||
}
|
||||
setLinkEditorOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={props.ref} class={styles.TextBubbleMenu}>
|
||||
<Switch>
|
||||
<Match when={linkEditorOpen()}>
|
||||
<InlineForm
|
||||
placeholder={t('Enter URL address')}
|
||||
initialValue={currentUrl() ?? ''}
|
||||
onClear={handleClearLinkForm}
|
||||
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
|
||||
onSubmit={handleLinkFormSubmit}
|
||||
onClose={() => setLinkEditorOpen(false)}
|
||||
/>
|
||||
<InsertLinkForm editor={props.editor} />
|
||||
</Match>
|
||||
<Match when={!linkEditorOpen()}>
|
||||
<>
|
||||
|
@ -287,7 +243,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggleLinkForm}
|
||||
onClick={() => setLinkEditorOpen(true)}
|
||||
class={clsx(styles.bubbleMenuButton, {
|
||||
[styles.bubbleMenuButtonActive]: isLink()
|
||||
})}
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
import { 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 { schema } from './prosemirror/schema'
|
||||
import { createPlugins } from './prosemirror/plugins'
|
||||
import { DOMParser as ProseDOMParser, DOMSerializer } from 'prosemirror-model'
|
||||
import { clsx } from 'clsx'
|
||||
import { createArticle } from '../../stores/zine/articles'
|
||||
import type { ShoutInput } from '../../graphql/types.gen'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import styles from './Sidebar.module.scss'
|
||||
import { Button } from '../_shared/Button'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
|
||||
type Props = {
|
||||
initialContent?: string
|
||||
}
|
||||
|
||||
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 = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const editorElRef: {
|
||||
current: HTMLDivElement
|
||||
} = {
|
||||
current: null
|
||||
}
|
||||
|
||||
const editorViewRef: { current: EditorView } = { current: null }
|
||||
|
||||
const dispatchTransaction = (tr: Transaction) => {
|
||||
const newState = editorViewRef.current.state.apply(tr)
|
||||
editorViewRef.current.updateState(newState)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const plugins = createPlugins({ schema })
|
||||
|
||||
const nodeViews: Partial<Record<Nodes, NodeViewConstructor>> = {
|
||||
image: createImageView
|
||||
}
|
||||
|
||||
const markViews: Partial<Record<Marks, MarkViewConstructor>> = {}
|
||||
|
||||
const domNew = new DOMParser().parseFromString(`<div>${props.initialContent}</div>`, 'text/xml')
|
||||
const doc = ProseDOMParser.fromSchema(schema).parse(domNew)
|
||||
|
||||
editorViewRef.current = new EditorView(editorElRef.current, {
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
schema,
|
||||
plugins
|
||||
}),
|
||||
nodeViews,
|
||||
markViews,
|
||||
dispatchTransaction
|
||||
})
|
||||
|
||||
editorViewRef.current.dom.classList.add('createArticle')
|
||||
editorViewRef.current.focus()
|
||||
})
|
||||
|
||||
const handleSaveButtonClick = () => {
|
||||
const article: ShoutInput = {
|
||||
body: getHtml(editorViewRef.current.state),
|
||||
community: 1, // 'discours' ?
|
||||
slug: 'new-' + Math.floor(Math.random() * 1000000)
|
||||
}
|
||||
createArticle({ article })
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={clsx('container')} style={{ width: '100%', 'max-width': '670px' }}>
|
||||
<div class={styles.editor} ref={(el) => (editorElRef.current = el)} />
|
||||
<Button value={t('Publish')} onClick={handleSaveButtonClick} />
|
||||
<Sidebar editorViewRef={editorViewRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Editor
|
|
@ -1,228 +0,0 @@
|
|||
.editor {
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
max-width: 670px;
|
||||
}
|
||||
|
||||
.sidebarContainer {
|
||||
@include font-size(1.6rem);
|
||||
|
||||
display: none; // режим отладки
|
||||
color: rgb(255 255 255 / 50%);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
import { For, 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) => <div class={styles.sidebarOff}>{props.children}</div>
|
||||
|
||||
const Link = (props: {
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
className?: string
|
||||
children: JSX.Element
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button
|
||||
class={clsx(styles.sidebarLink, props.className)}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
title={props.title}
|
||||
data-testid={props['data-testid']}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
|
||||
const Keys = (props: { keys: string[] }) => (
|
||||
<span>
|
||||
<For each={props.keys}>{(k) => <i>{k}</i>}</For>
|
||||
</span>
|
||||
)
|
||||
|
||||
type SidebarProps = {
|
||||
editorViewRef: {
|
||||
current: EditorView
|
||||
}
|
||||
}
|
||||
|
||||
export const Sidebar = (props: SidebarProps) => {
|
||||
const [lastAction, setLastAction] = createSignal<string | undefined>()
|
||||
|
||||
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 (
|
||||
<div
|
||||
class={clsx(styles.sidebarContainer, {
|
||||
[styles.sidebarContainerHidden]: isHidden()
|
||||
})}
|
||||
ref={(el) => (containerRef.current = el)}
|
||||
>
|
||||
<span class={styles.sidebarOpener} onClick={toggleSidebar}>
|
||||
Советы и предложения
|
||||
</span>
|
||||
|
||||
<Off onClick={() => editorViewRef.current.focus()}>
|
||||
<div class={styles.sidebarCloser} onClick={toggleSidebar} />
|
||||
|
||||
<div>
|
||||
<Link onClick={onUndo}>
|
||||
Undo <Keys keys={[mod(), 'z']} />
|
||||
</Link>
|
||||
<Link onClick={onRedo}>
|
||||
Redo <Keys keys={[mod(), 'Shift', 'z']} />
|
||||
</Link>
|
||||
</div>
|
||||
</Off>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default } from './Editor'
|
|
@ -1,215 +0,0 @@
|
|||
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: 14,
|
||||
height: 16,
|
||||
path: 'M 10.1573,7.43667 C 11.2197,6.70286 11.9645,5.49809 11.9645,4.38095 11.9645,1.90571 10.0478,0 7.58352,0 H 0.738281 V 15.3333 H 8.44876 c 2.28904,0 4.06334,-1.8619 4.06334,-4.1509 0,-1.66478 -0.9419,-3.08859 -2.3548,-3.74573 z M 4.02344,2.73828 h 3.28571 c 0.90905,0 1.64286,0.73381 1.64286,1.64286 0,0.90905 -0.73381,1.64286 -1.64286,1.64286 H 4.02344 Z M 4.01629,9.3405869 h 3.87946 c 0.9090501,0 1.6428601,0.7338101 1.6428601,1.6428601 0,0.90905 -0.73381,1.64286 -1.6428601,1.64286 H 4.01629 Z'
|
||||
}
|
||||
})
|
||||
|
||||
const toggleEm = markItem(schema.marks.em, {
|
||||
title: 'Toggle emphasis',
|
||||
icon: {
|
||||
width: 11,
|
||||
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]]
|
||||
}
|
|
@ -1,197 +0,0 @@
|
|||
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<string, Field>
|
||||
onSubmit: (values: Record<string, string>) => 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<string, Field>, 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
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
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)
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import { Plugin, NodeSelection } from 'prosemirror-state'
|
||||
import { DecorationSet, Decoration } from 'prosemirror-view'
|
||||
|
||||
const handleIcon = `
|
||||
<svg viewBox="0 0 10 10" height="14" width="14">
|
||||
<path d="M3 2a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm4-8a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2z"/>
|
||||
</svg>`
|
||||
|
||||
const createDragHandle = () => {
|
||||
const handle = document.createElement('span')
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,50 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,25 +0,0 @@
|
|||
import { history } from 'prosemirror-history'
|
||||
import { dropCursor } from 'prosemirror-dropcursor'
|
||||
import { placeholder } from './placeholder'
|
||||
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'
|
||||
import { useLocalize } from '../../../../context/localize'
|
||||
|
||||
export const createPlugins = ({ schema }: { schema: DiscoursSchema }) => {
|
||||
const { t } = useLocalize()
|
||||
return [
|
||||
placeholder(t('Just start typing...')),
|
||||
customKeymap(),
|
||||
history(),
|
||||
dropCursor({ class: styles.dropCursor }),
|
||||
selectionMenu(schema),
|
||||
dragHandle(),
|
||||
imageInput(schema)
|
||||
// TODO
|
||||
// link(),
|
||||
]
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
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)])
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,55 +0,0 @@
|
|||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
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)
|
||||
}
|
||||
})
|
|
@ -1,172 +0,0 @@
|
|||
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<Nodes, Marks>
|
||||
|
||||
export const schemaSpec: SchemaSpec<Nodes, Marks> = {
|
||||
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 `<b>`
|
||||
// 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)
|
|
@ -1,21 +0,0 @@
|
|||
.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;
|
||||
}
|
|
@ -1,355 +0,0 @@
|
|||
.ProseMirror.createArticle {
|
||||
color: var(--foreground);
|
||||
background-color: var(--background);
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
font-variant-ligatures: none;
|
||||
outline: none;
|
||||
|
||||
// font styles
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 700;
|
||||
font-size: 44px;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 400;
|
||||
font-size: 44px;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 400;
|
||||
font-size: 34px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 400;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@include font-size(1.6rem);
|
||||
|
||||
border-left: 2px solid;
|
||||
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;
|
||||
inset: -2px -2px -2px -32px;
|
||||
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;
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
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)
|
|
@ -6,6 +6,7 @@ import formattedTime from '../../utils/formatDateTime'
|
|||
import { clsx } from 'clsx'
|
||||
import styles from './DialogCard.module.scss'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import MD from '../Article/MD'
|
||||
|
||||
type DialogProps = {
|
||||
online?: boolean
|
||||
|
@ -53,7 +54,9 @@ const DialogCard = (props: DialogProps) => {
|
|||
</div>
|
||||
<div class={styles.message}>
|
||||
<Switch>
|
||||
<Match when={props.message && !props.isChatHeader}>{props.message}</Match>
|
||||
<Match when={props.message && !props.isChatHeader}>
|
||||
<MD body={props.message} />
|
||||
</Match>
|
||||
<Match when={props.isChatHeader && companions().length > 1}>{names()}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
|
|
@ -8,6 +8,7 @@ import formattedTime from '../../utils/formatDateTime'
|
|||
import { Icon } from '../_shared/Icon'
|
||||
import { MessageActionsPopup } from './MessageActionsPopup'
|
||||
import QuotedMessage from './QuotedMessage'
|
||||
import MD from '../Article/MD'
|
||||
|
||||
type Props = {
|
||||
content: MessageType
|
||||
|
@ -50,7 +51,7 @@ export const Message = (props: Props) => {
|
|||
<Show when={props.replyBody}>
|
||||
<QuotedMessage body={props.replyBody} variant="inline" isOwn={isOwn} />
|
||||
</Show>
|
||||
<div innerHTML={md.render(props.content.body)} />
|
||||
<MD body={props.content.body} />
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.time}>{formattedTime(props.content.createdAt * 1000)()}</div>
|
||||
|
|
|
@ -18,6 +18,7 @@ import { useRouter } from '../../stores/router'
|
|||
import { clsx } from 'clsx'
|
||||
import styles from '../../styles/Inbox.module.scss'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import SimplifiedEditor from '../Editor/SimplifiedEditor'
|
||||
|
||||
type InboxSearchParams = {
|
||||
initChat: string
|
||||
|
@ -41,14 +42,14 @@ export const InboxView = () => {
|
|||
} = useInbox()
|
||||
|
||||
const [recipients, setRecipients] = createSignal<Author[]>([])
|
||||
const [postMessageText, setPostMessageText] = createSignal<string>('')
|
||||
const [sortByGroup, setSortByGroup] = createSignal<boolean>(false)
|
||||
const [sortByPerToPer, setSortByPerToPer] = createSignal<boolean>(false)
|
||||
const [sortByGroup, setSortByGroup] = createSignal(false)
|
||||
const [sortByPerToPer, setSortByPerToPer] = createSignal(false)
|
||||
const [currentDialog, setCurrentDialog] = createSignal<Chat>()
|
||||
const [messageToReply, setMessageToReply] = createSignal<MessageType | null>(null)
|
||||
const [isClear, setClear] = createSignal(false)
|
||||
const { session } = useSession()
|
||||
const currentUserId = createMemo(() => session()?.user.id)
|
||||
|
||||
const { changeSearchParam, searchParams } = useRouter<InboxSearchParams>()
|
||||
// Поиск по диалогам
|
||||
const getQuery = (query) => {
|
||||
if (query().length >= 2) {
|
||||
|
@ -97,28 +98,19 @@ export const InboxView = () => {
|
|||
await loadChats()
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (message: string) => {
|
||||
await sendMessage({
|
||||
body: postMessageText().toString(),
|
||||
body: message,
|
||||
chat: currentDialog().id.toString(),
|
||||
replyTo: messageToReply()?.id
|
||||
})
|
||||
setPostMessageText('')
|
||||
setClear(true)
|
||||
setMessageToReply(null)
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight
|
||||
setClear(false)
|
||||
}
|
||||
|
||||
let textareaParent // textarea autoresize ghost element
|
||||
const handleChangeMessage = (event) => {
|
||||
setPostMessageText(event.target.value)
|
||||
}
|
||||
|
||||
const { changeSearchParam, searchParams } = useRouter<InboxSearchParams>()
|
||||
|
||||
createEffect(async () => {
|
||||
if (textareaParent) {
|
||||
textareaParent.dataset.replicatedValue = postMessageText()
|
||||
}
|
||||
if (searchParams().chat) {
|
||||
const chatToOpen = chats()?.find((chat) => chat.id === searchParams().chat)
|
||||
if (!chatToOpen) return
|
||||
|
@ -156,17 +148,6 @@ export const InboxView = () => {
|
|||
return messages().find((message) => message.id === messageId)
|
||||
}
|
||||
|
||||
const handleKeyDown = async (event) => {
|
||||
if (event.keyCode === 13 && event.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.keyCode === 13 && !event.shiftKey && postMessageText()?.trim().length > 0) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={clsx('container', styles.Inbox)}>
|
||||
<Modal variant="narrow" name="inviteToChat">
|
||||
|
@ -278,19 +259,14 @@ export const InboxView = () => {
|
|||
/>
|
||||
</Show>
|
||||
<div class={styles.wrapper}>
|
||||
<div class={styles.growWrap} ref={textareaParent}>
|
||||
<textarea
|
||||
class={styles.textInput}
|
||||
value={postMessageText()}
|
||||
rows={1}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={(event) => handleChangeMessage(event)}
|
||||
placeholder={t('Write message')}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={postMessageText().length === 0} onClick={handleSubmit}>
|
||||
<Icon name="send-message" />
|
||||
</button>
|
||||
<SimplifiedEditor
|
||||
smallHeight={true}
|
||||
imageEnabled={true}
|
||||
placeholder={t('Write message')}
|
||||
setClear={isClear()}
|
||||
onSubmit={(message) => handleSubmit(message)}
|
||||
submitByEnter={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
import styles from './styles/CommentEditor.module.scss'
|
||||
import './styles/ProseMirrorOverrides.scss'
|
||||
import { clsx } from 'clsx'
|
||||
import { Button } from '../Button'
|
||||
import { createEffect, onMount } from 'solid-js'
|
||||
// ProseMirror deps
|
||||
import { schema } from './schema'
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { DOMParser as ProseDOMParser, DOMSerializer } from 'prosemirror-model'
|
||||
import { renderGrouped } from 'prosemirror-menu'
|
||||
import { buildMenuItems } from './menu'
|
||||
import { keymap } from 'prosemirror-keymap'
|
||||
import { baseKeymap } from 'prosemirror-commands'
|
||||
import { customKeymap } from '../../EditorNew/prosemirror/plugins/customKeymap'
|
||||
import { placeholder } from '../../EditorNew/prosemirror/plugins/placeholder'
|
||||
import { undo, redo, history } from 'prosemirror-history'
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
|
||||
type Props = {
|
||||
placeholder?: string
|
||||
onSubmit: (value: string) => void
|
||||
clear?: boolean
|
||||
cancel?: () => void
|
||||
initialContent?: string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const CommentEditor = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const editorElRef: { current: HTMLDivElement } = { current: null }
|
||||
const menuElRef: { current: HTMLDivElement } = { current: null }
|
||||
const editorViewRef: { current: EditorView } = { current: null }
|
||||
|
||||
const domNew = new DOMParser().parseFromString(`<div>${props.initialContent}</div>`, 'text/xml')
|
||||
const doc = ProseDOMParser.fromSchema(schema).parse(domNew)
|
||||
|
||||
const initEditor = () => {
|
||||
editorViewRef.current = new EditorView(editorElRef.current, {
|
||||
state: EditorState.create({
|
||||
schema,
|
||||
doc: props.initialContent ? doc : null,
|
||||
plugins: [
|
||||
history(),
|
||||
customKeymap(),
|
||||
placeholder(props.placeholder),
|
||||
keymap({ 'Mod-z': undo, 'Mod-Shift-z': redo, 'Mod-y': redo }),
|
||||
keymap(baseKeymap)
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initEditor()
|
||||
const { dom } = renderGrouped(editorViewRef.current, buildMenuItems(schema))
|
||||
menuElRef.current.appendChild(dom)
|
||||
})
|
||||
|
||||
const handleSubmitButtonClick = () => {
|
||||
props.onSubmit(getHtml(editorViewRef.current.state))
|
||||
}
|
||||
|
||||
const clearEditor = () => {
|
||||
editorViewRef.current.destroy()
|
||||
initEditor()
|
||||
if (props.cancel) {
|
||||
props.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.clear) {
|
||||
clearEditor()
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div class={styles.commentEditor}>
|
||||
<div class={clsx('ProseMirrorOverrides', styles.textarea)} ref={(el) => (editorElRef.current = el)} />
|
||||
<div class={styles.actions}>
|
||||
<div class={styles.menu} ref={(el) => (menuElRef.current = el)} />
|
||||
<div class={styles.buttons}>
|
||||
<Button value={t('Send')} variant="primary" onClick={handleSubmitButtonClick} />
|
||||
<Button value={t('cancel')} variant="secondary" onClick={clearEditor} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommentEditor
|
|
@ -1 +0,0 @@
|
|||
export { default } from './CommentEditor'
|
|
@ -1,72 +0,0 @@
|
|||
import { icons, MenuItem, wrapItem } from 'prosemirror-menu'
|
||||
import { toggleMark } from 'prosemirror-commands'
|
||||
|
||||
const 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)
|
||||
}
|
||||
|
||||
const 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)
|
||||
}
|
||||
|
||||
const 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)
|
||||
}
|
||||
|
||||
//TODO: вывести тип для схемы
|
||||
export const buildMenuItems = (schema) => {
|
||||
const toggleStrong = markItem(schema.marks.strong, {
|
||||
title: 'Toggle strong style',
|
||||
icon: {
|
||||
width: 14,
|
||||
height: 16,
|
||||
path: 'M 10.1573,7.43667 C 11.2197,6.70286 11.9645,5.49809 11.9645,4.38095 11.9645,1.90571 10.0478,0 7.58352,0 H 0.738281 V 15.3333 H 8.44876 c 2.28904,0 4.06334,-1.8619 4.06334,-4.1509 0,-1.66478 -0.9419,-3.08859 -2.3548,-3.74573 z M 4.02344,2.73828 h 3.28571 c 0.90905,0 1.64286,0.73381 1.64286,1.64286 0,0.90905 -0.73381,1.64286 -1.64286,1.64286 H 4.02344 Z M 4.01629,9.3405869 h 3.87946 c 0.9090501,0 1.6428601,0.7338101 1.6428601,1.6428601 0,0.90905 -0.73381,1.64286 -1.6428601,1.64286 H 4.01629 Z'
|
||||
}
|
||||
})
|
||||
|
||||
const toggleEm = markItem(schema.marks.em, {
|
||||
title: 'Toggle emphasis',
|
||||
icon: {
|
||||
width: 13,
|
||||
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 inlineMenu = [toggleStrong, toggleEm, wrapBlockQuote]
|
||||
|
||||
return [inlineMenu]
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
import { Plugin } from 'prosemirror-state'
|
||||
import { DecorationSet, Decoration } from 'prosemirror-view'
|
||||
|
||||
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.textContent = text
|
||||
|
||||
return DecorationSet.create(doc, [Decoration.widget(1, div)])
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,43 +0,0 @@
|
|||
import { Schema } from 'prosemirror-model'
|
||||
|
||||
export const schema = new Schema({
|
||||
nodes: {
|
||||
doc: {
|
||||
content: 'block+'
|
||||
},
|
||||
text: {
|
||||
group: 'inline',
|
||||
inline: true
|
||||
},
|
||||
paragraph: {
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
toDOM: function toDOM() {
|
||||
return ['p', { class: 'paragraph' }, 0]
|
||||
}
|
||||
},
|
||||
blockquote: {
|
||||
content: 'block+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'blockquote' }],
|
||||
toDOM() {
|
||||
return ['blockquote', 0]
|
||||
}
|
||||
}
|
||||
},
|
||||
marks: {
|
||||
strong: {
|
||||
toDOM() {
|
||||
return ['strong', 0]
|
||||
},
|
||||
parseDOM: [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }]
|
||||
},
|
||||
em: {
|
||||
toDOM() {
|
||||
return ['em', 0]
|
||||
},
|
||||
parseDOM: [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }]
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,34 +0,0 @@
|
|||
.commentEditor {
|
||||
background: #f7f7f8;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
|
||||
.textarea {
|
||||
min-height: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@include media-breakpoint-up(sm) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.menu,
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.menu {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
.ProseMirrorOverrides > .ProseMirror {
|
||||
min-height: 5em;
|
||||
|
||||
&-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
font-size: 15px;
|
||||
line-height: 1.1em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 10px;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: #9fa1a7;
|
||||
border-left: 2px solid #696969;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
align-items: center;
|
||||
border-radius: 0.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
vertical-align: middle;
|
||||
width: 2em;
|
||||
|
||||
&:hover {
|
||||
background: #e8e8e8;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.4em;
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ import { Popover } from '../Popover'
|
|||
import { useLocalize } from '../../../context/localize'
|
||||
import { register } from 'swiper/element/bundle'
|
||||
import { DropArea } from '../DropArea'
|
||||
import { GrowingTextarea } from '../GrowingTextarea'
|
||||
import MD from '../../Article/MD'
|
||||
import { createFileUploader } from '@solid-primitives/upload'
|
||||
import SwiperCore, { Manipulation, Navigation, Pagination } from 'swiper'
|
||||
|
@ -18,6 +17,7 @@ import { imageProxy } from '../../../utils/imageProxy'
|
|||
import { clsx } from 'clsx'
|
||||
import styles from './Swiper.module.scss'
|
||||
import { composeMediaItems } from '../../../utils/composeMediaItems'
|
||||
import SimplifiedEditor from '../../Editor/SimplifiedEditor'
|
||||
|
||||
type Props = {
|
||||
images: MediaItem[]
|
||||
|
@ -200,12 +200,11 @@ export const SolidSwiper = (props: Props) => {
|
|||
handleSlideDescriptionChange(index(), 'source', event.target.value)
|
||||
}
|
||||
/>
|
||||
<GrowingTextarea
|
||||
allowEnterKey={true}
|
||||
class={styles.descriptionText}
|
||||
<SimplifiedEditor
|
||||
initialContent={slide.body}
|
||||
smallHeight={true}
|
||||
placeholder={t('Enter image description')}
|
||||
initialValue={slide.body}
|
||||
value={(value) => handleSlideDescriptionChange(index(), 'body', value)}
|
||||
onSubmit={(value) => handleSlideDescriptionChange(index(), 'body', value)}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
|
|
|
@ -18,7 +18,10 @@ $navigation-reserve: 32px;
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
a {
|
||||
color: var(--default-color-invert);
|
||||
border-color: var(--default-color-invert);
|
||||
}
|
||||
.container {
|
||||
margin: auto;
|
||||
//max-width: 800px;
|
||||
|
@ -294,12 +297,6 @@ $navigation-reserve: 32px;
|
|||
gap: 0.5em;
|
||||
margin: 1em 0;
|
||||
|
||||
.descriptionText {
|
||||
@include font-size(1.4rem);
|
||||
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.input {
|
||||
@include font-size(1.4rem);
|
||||
|
||||
|
|
|
@ -74,8 +74,8 @@ export const InboxProvider = (props: { children: JSX.Element }) => {
|
|||
pipe(
|
||||
subclient().subscription(newMessage, {}),
|
||||
subscribe((result) => {
|
||||
console.info('[subscription]')
|
||||
console.debug(result)
|
||||
// console.info('[subscription]')
|
||||
// console.debug(result)
|
||||
// TODO: handle data result
|
||||
})
|
||||
)
|
||||
|
|
|
@ -16,6 +16,7 @@ export type ModalType =
|
|||
| 'inviteToChat'
|
||||
| 'uploadImage'
|
||||
| 'uploadCoverImage'
|
||||
| 'editorInsertLink'
|
||||
|
||||
type WarnKind = 'error' | 'warn' | 'info'
|
||||
|
||||
|
@ -33,7 +34,8 @@ export const MODALS: Record<ModalType, ModalType> = {
|
|||
donate: 'donate',
|
||||
inviteToChat: 'inviteToChat',
|
||||
uploadImage: 'uploadImage',
|
||||
uploadCoverImage: 'uploadCoverImage'
|
||||
uploadCoverImage: 'uploadCoverImage',
|
||||
editorInsertLink: 'editorInsertLink'
|
||||
}
|
||||
|
||||
const [modal, setModal] = createSignal<ModalType | null>(null)
|
||||
|
|
|
@ -106,68 +106,6 @@ main {
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.growWrap {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
||||
&::after {
|
||||
content: attr(data-replicated-value);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
visibility: hidden;
|
||||
transition: height 1.3s ease-in-out;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
margin-bottom: 0;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:active {
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::after,
|
||||
& textarea {
|
||||
/* Identical styling required!! */
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: auto 8px 8px 0;
|
||||
|
||||
&:hover {
|
||||
.icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,10 @@
|
|||
--icon-filter-hover: invert(1);
|
||||
--editor-bubble-menu-background: #fff;
|
||||
--blue-link: #2638d9;
|
||||
// names from figma
|
||||
--black-50: #f7f7f8;
|
||||
--black-500: #141414;
|
||||
--black-400: #696969;
|
||||
}
|
||||
|
||||
[data-editor-dark-mode='true'] {
|
||||
|
@ -43,6 +47,10 @@
|
|||
--icon-filter: invert(1);
|
||||
--icon-filter-hover: invert(0);
|
||||
--editor-bubble-menu-background: #444;
|
||||
// names from figma
|
||||
--black-50: #080807;
|
||||
--black-500: #ebebeb;
|
||||
--black-400: #969696;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
Loading…
Reference in New Issue
Block a user