Merge branch 'drafts' into 'dev'

separate menu for images in editor

See merge request discoursio/discoursio-webapp!60
This commit is contained in:
Igor 2023-05-04 12:18:09 +00:00
commit 2e3e32aabf
14 changed files with 385 additions and 268 deletions

View File

@ -35,7 +35,8 @@ import { useSession } from '../../context/session'
import uniqolor from 'uniqolor'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { Embed } from './extensions/embed'
import { EditorBubbleMenu } from './EditorBubbleMenu'
import { TextBubbleMenu } from './TextBubbleMenu'
import { ImageBubbleMenu } from './ImageBubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import { useEditorContext } from '../../context/editor'
@ -74,7 +75,13 @@ export const Editor = (props: EditorProps) => {
current: null
}
const bubbleMenuRef: {
const textBubbleMenuRef: {
current: HTMLDivElement
} = {
current: null
}
const imageBubbleMenuRef: {
current: HTMLDivElement
} = {
current: null
@ -135,7 +142,19 @@ export const Editor = (props: EditorProps) => {
TrailingNode,
CharacterCount,
BubbleMenu.configure({
element: bubbleMenuRef.current
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
shouldShow: ({ editor: e, view, state, oldState, from, to }) => {
console.log(view)
return e.isFocused && !e.isActive('image')
}
}),
BubbleMenu.configure({
pluginKey: 'imageBubbleMenu',
element: imageBubbleMenuRef.current,
shouldShow: ({ editor: e, view, state, oldState, from, to }) => {
return e.isFocused && e.isActive('image')
}
}),
FloatingMenu.configure({
tippyOptions: {
@ -165,7 +184,8 @@ export const Editor = (props: EditorProps) => {
return (
<>
<div ref={(el) => (editorElRef.current = el)} />
<EditorBubbleMenu editor={editor()} ref={(el) => (bubbleMenuRef.current = el)} />
<TextBubbleMenu editor={editor()} ref={(el) => (textBubbleMenuRef.current = el)} />
<ImageBubbleMenu editor={editor()} ref={(el) => (imageBubbleMenuRef.current = el)} />
<EditorFloatingMenu editor={editor()} ref={(el) => (floatingMenuRef.current = el)} />
</>
)

View File

@ -1,258 +0,0 @@
import { Switch, Match, createSignal, Show } from 'solid-js'
import type { Editor } from '@tiptap/core'
import styles from './EditorBubbleMenu.module.scss'
import { Icon } from '../../_shared/Icon'
import { clsx } from 'clsx'
import { createEditorTransaction } from 'solid-tiptap'
import { useLocalize } from '../../../context/localize'
import { InlineForm } from '../InlineForm'
import validateImage from '../../../utils/validateUrl'
type BubbleMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
}
export const EditorBubbleMenu = (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(
() => props.editor,
(editor) => {
return editor && editor.isActive(name, attributes)
}
)
const isBold = isActive('bold')
const isItalic = isActive('italic')
const isH1 = isActive('heading', { level: 1 })
const isH2 = isActive('heading', { level: 2 })
const isH3 = isActive('heading', { level: 3 })
const isBlockQuote = isActive('blockquote')
const isOrderedList = isActive('isOrderedList')
const isBulletList = isActive('isBulletList')
const isLink = isActive('link')
const toggleLinkForm = () => {
setLinkEditorOpen(true)
}
const toggleTextSizePopup = () => {
if (listBubbleOpen()) {
setListBubbleOpen(false)
}
setTextSizeBubbleOpen((prev) => !prev)
}
const toggleListPopup = () => {
if (textSizeBubbleOpen()) {
setTextSizeBubbleOpen(false)
}
setListBubbleOpen((prev) => !prev)
}
const handleLinkFormSubmit = (value: string) => {
props.editor.chain().focus().setLink({ href: 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.bubbleMenu}>
<Switch>
<Match when={linkEditorOpen()}>
<InlineForm
placeholder={t('Enter URL address')}
initialValue={currentUrl() ?? ''}
onClear={handleClearLinkForm}
validate={(value) => (validateImage(value) ? '' : t('Invalid url format'))}
onSubmit={handleLinkFormSubmit}
onClose={() => setLinkEditorOpen(false)}
errorMessage={t('Error')}
/>
</Match>
<Match when={!linkEditorOpen()}>
<>
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: textSizeBubbleOpen()
})}
onClick={toggleTextSizePopup}
>
<Icon name="editor-text-size" />
<Icon name="down-triangle" class={styles.triangle} />
</button>
<Show when={textSizeBubbleOpen()}>
<div class={styles.dropDown}>
<header>{t('Headers')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH1()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 1 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h1" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH2()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 2 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h2" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH3()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 3 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h3" />
</button>
</div>
<header>{t('Quotes')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
toggleTextSizePopup()
}}
>
<Icon name="editor-blockquote" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
toggleTextSizePopup()
}}
>
<Icon name="editor-quote" />
</button>
</div>
</div>
</Show>
</div>
<div class={styles.delimiter} />
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBold()
})}
onClick={() => props.editor.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isItalic()
})}
onClick={() => props.editor.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
<div class={styles.delimiter} />
<button
type="button"
onClick={toggleLinkForm}
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink()
})}
>
<Icon name="editor-link" />
</button>
<button type="button" class={styles.bubbleMenuButton}>
<Icon name="editor-footnote" />
</button>
<div class={styles.delimiter} />
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: listBubbleOpen()
})}
onClick={toggleListPopup}
>
<Icon name="editor-ul" />
<Icon name="down-triangle" class={styles.triangle} />
</button>
<Show when={listBubbleOpen()}>
<div class={styles.dropDown}>
<header>{t('Lists')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBulletList()
})}
onClick={() => {
props.editor.chain().focus().toggleBulletList().run()
toggleListPopup()
}}
>
<Icon name="editor-ul" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isOrderedList()
})}
onClick={() => {
props.editor.chain().focus().toggleOrderedList().run()
toggleListPopup()
}}
>
<Icon name="editor-ol" />
</button>
</div>
</div>
</Show>
</div>
</>
</Match>
</Switch>
</div>
</>
)
}

View File

@ -1 +0,0 @@
export { EditorBubbleMenu } from './EditorBubbleMenu'

View File

@ -0,0 +1,15 @@
import type { Editor } from '@tiptap/core'
import styles from './ImageBubbleMenu.module.scss'
type BubbleMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
}
export const ImageBubbleMenu = (props: BubbleMenuProps) => {
return (
<div ref={props.ref} class={styles.bubbleMenu}>
test
</div>
)
}

View File

@ -0,0 +1 @@
export { ImageBubbleMenu } from './ImageBubbleMenu'

View File

@ -0,0 +1,85 @@
.bubbleMenu {
background: #fff;
box-shadow: 0 4px 10px rgba(#000, 0.25);
.bubbleMenuButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
opacity: 0.5;
padding: 1rem;
.triangle {
margin-left: 4px;
}
.colorWheel {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f6e3a1;
}
}
.bubbleMenuButtonActive {
opacity: 1;
}
.delimiter {
background: #999;
display: inline-block;
height: 1.4em;
margin: 0 0.2em;
vertical-align: text-bottom;
width: 1px;
}
.dropDownHolder {
position: relative;
cursor: pointer;
display: inline-flex;
flex-flow: row nowrap;
align-items: center;
.dropDown {
position: absolute;
padding: 6px;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
box-shadow: 0 4px 10px rgb(0 0 0 / 25%);
background: #fff;
color: #898c94;
& > header {
font-size: 10px;
border-bottom: 1px solid #898c94;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: nowrap;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.bubbleMenuButton {
min-width: 40px;
}
}
}
}
.dropDownEnter,
.dropDownExit {
height: 0;
color: transparent;
}
}

View File

@ -0,0 +1,256 @@
import { Switch, Match, createSignal, Show } from 'solid-js'
import type { Editor } from '@tiptap/core'
import styles from './TextBubbleMenu.module.scss'
import { Icon } from '../../_shared/Icon'
import { clsx } from 'clsx'
import { createEditorTransaction } from 'solid-tiptap'
import { useLocalize } from '../../../context/localize'
import { InlineForm } from '../InlineForm'
import validateImage from '../../../utils/validateUrl'
type BubbleMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
}
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(
() => props.editor,
(editor) => {
return editor && editor.isActive(name, attributes)
}
)
const isBold = isActive('bold')
const isItalic = isActive('italic')
const isH1 = isActive('heading', { level: 1 })
const isH2 = isActive('heading', { level: 2 })
const isH3 = isActive('heading', { level: 3 })
const isBlockQuote = isActive('blockquote')
const isOrderedList = isActive('isOrderedList')
const isBulletList = isActive('isBulletList')
const isLink = isActive('link')
const toggleLinkForm = () => {
setLinkEditorOpen(true)
}
const toggleTextSizePopup = () => {
if (listBubbleOpen()) {
setListBubbleOpen(false)
}
setTextSizeBubbleOpen((prev) => !prev)
}
const toggleListPopup = () => {
if (textSizeBubbleOpen()) {
setTextSizeBubbleOpen(false)
}
setListBubbleOpen((prev) => !prev)
}
const handleLinkFormSubmit = (value: string) => {
props.editor.chain().focus().setLink({ href: 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.bubbleMenu}>
<Switch>
<Match when={linkEditorOpen()}>
<InlineForm
placeholder={t('Enter URL address')}
initialValue={currentUrl() ?? ''}
onClear={handleClearLinkForm}
validate={(value) => (validateImage(value) ? '' : t('Invalid url format'))}
onSubmit={handleLinkFormSubmit}
onClose={() => setLinkEditorOpen(false)}
errorMessage={t('Error')}
/>
</Match>
<Match when={!linkEditorOpen()}>
<>
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: textSizeBubbleOpen()
})}
onClick={toggleTextSizePopup}
>
<Icon name="editor-text-size" />
<Icon name="down-triangle" class={styles.triangle} />
</button>
<Show when={textSizeBubbleOpen()}>
<div class={styles.dropDown}>
<header>{t('Headers')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH1()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 1 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h1" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH2()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 2 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h2" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH3()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 3 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h3" />
</button>
</div>
<header>{t('Quotes')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
toggleTextSizePopup()
}}
>
<Icon name="editor-blockquote" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
toggleTextSizePopup()
}}
>
<Icon name="editor-quote" />
</button>
</div>
</div>
</Show>
</div>
<div class={styles.delimiter} />
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBold()
})}
onClick={() => props.editor.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isItalic()
})}
onClick={() => props.editor.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
<div class={styles.delimiter} />
<button
type="button"
onClick={toggleLinkForm}
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink()
})}
>
<Icon name="editor-link" />
</button>
<button type="button" class={styles.bubbleMenuButton}>
<Icon name="editor-footnote" />
</button>
<div class={styles.delimiter} />
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: listBubbleOpen()
})}
onClick={toggleListPopup}
>
<Icon name="editor-ul" />
<Icon name="down-triangle" class={styles.triangle} />
</button>
<Show when={listBubbleOpen()}>
<div class={styles.dropDown}>
<header>{t('Lists')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBulletList()
})}
onClick={() => {
props.editor.chain().focus().toggleBulletList().run()
toggleListPopup()
}}
>
<Icon name="editor-ul" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isOrderedList()
})}
onClick={() => {
props.editor.chain().focus().toggleOrderedList().run()
toggleListPopup()
}}
>
<Icon name="editor-ol" />
</button>
</div>
</div>
</Show>
</div>
</>
</Match>
</Switch>
</div>
)
}

View File

@ -0,0 +1 @@
export { TextBubbleMenu } from './TextBubbleMenu'

View File

@ -50,6 +50,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
const showNotifications = createMemo(() => isAuthenticated() && !isEditorPage())
const showSaveButton = createMemo(() => isAuthenticated() && isEditorPage())
const showCreatePostButton = createMemo(() => isAuthenticated() && !isEditorPage())
const showAuthenticatedControls = createMemo(() => isAuthenticated() && isEditorPage())
const handleBurgerButtonClick = () => {
toggleEditorPanel()
@ -120,7 +121,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
</Show>
<Show
when={isAuthenticated() && page().route !== 'create'}
when={showAuthenticatedControls()}
fallback={
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?modal=auth&mode=login">

View File

@ -3,7 +3,6 @@ import { gql } from '@urql/core'
export default gql`
query LoadShoutQuery($slug: String!) {
loadShout(slug: $slug) {
_id: slug
id
title
subtitle
@ -15,12 +14,11 @@ export default gql`
# community
mainTopic
topics {
# id
id
title
body
slug
stat {
_id: shouts
shouts
authors
followers
@ -35,7 +33,6 @@ export default gql`
createdAt
publishedAt
stat {
_id: viewed
viewed
reacted
rating