Quote variants (#101)

Quote options + alignment
This commit is contained in:
Ilya Y 2023-05-29 13:09:44 +03:00 committed by GitHub
parent 2ef8da6da5
commit 5a1699aa87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 564 additions and 88 deletions

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 2.44444V0H22V2.44444H0ZM0 13H11V4H0V13ZM13 5H22V7H13V5ZM22 15V17H0V15H22ZM22 12H13V10H22V12ZM22 21.9999V19.5554H0V21.9999H22Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M22 2.44444V0H0V2.44444H22ZM22 13H11V4H22V13ZM9 5H0V7H9V5ZM0 15V17H22V15H0ZM0 12H9V10H0V12ZM0 21.9999V19.5554H22V21.9999H0Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1.77779V0H16.0001V1.77779H0ZM10.9092 9.4546H5.09094V2.90911H10.9092V9.4546ZM12.3637 3.63638H16.0001V5.09094H12.3637V3.63638ZM0 10.9092H16.0001V12.3637H0V10.9092ZM3.63638 3.63638H0V5.09094H3.63638V3.63638ZM12.3637 8.72732H16.0001V7.27277H12.3637V8.72732ZM3.63638 8.72732H0V7.27277H3.63638V8.72732ZM0 16H16.0001V14.2222H0V16Z" fill="#898C94"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@ -295,5 +295,7 @@
"number list": "number list",
"delimiter": "delimiter",
"cancel_low_caps": "cancel",
"repeat": "repeat"
"repeat": "repeat",
"Add signature": "Add signature",
"Substrate": "Substrate"
}

View File

@ -300,6 +300,7 @@
"sign up": "зарегистрироваться",
"sign up or sign in": "зарегистрироваться или войти",
"slug is used by another user": "Имя уже занято другим пользователем",
"squib": "Подверстка",
"terms of use": "правилами пользования сайтом",
"topics": "темы",
"user already exist": "пользователь уже существует",
@ -316,5 +317,7 @@
"number list": "нумер. список",
"delimiter": "разделитель",
"cancel_low_caps": "отменить",
"repeat": "повторить"
"repeat": "повторить",
"Add signature": "Добавить подпись",
"Substrate": "Подложка"
}

View File

@ -372,15 +372,3 @@ img {
}
}
}
[data-float] {
max-width: 50%;
}
[data-float='left'] {
float: left;
}
[data-float='right'] {
float: right;
}

View File

@ -0,0 +1,40 @@
import type { Editor } from '@tiptap/core'
import styles from './FigureBubbleMenu.module.scss'
import { clsx } from 'clsx'
import { Icon } from '../../_shared/Icon'
import { useLocalize } from '../../../context/localize'
type Props = {
editor: Editor
ref: (el: HTMLElement) => void
}
export const BlockquoteBubbleMenu = (props: Props) => {
return (
<div ref={props.ref} class={styles.FigureBubbleMenu}>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => {
props.editor.chain().focus().setBlockQuoteFloat('left').run()
}}
>
<Icon name="editor-image-align-left" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => props.editor.chain().focus().setBlockQuoteFloat(null).run()}
>
<Icon name="editor-image-align-center" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => props.editor.chain().focus().setBlockQuoteFloat('right').run()}
>
<Icon name="editor-image-align-right" />
</button>
</div>
)
}

View File

@ -0,0 +1,107 @@
.FigureBubbleMenu {
background: #000;
display: flex;
flex-direction: row;
align-items: center;
.bubbleMenuButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
opacity: 0.5;
padding: 1rem;
.triangle {
margin-left: 4px;
}
img {
display: block;
}
}
.bubbleMenuButtonActive {
opacity: 1;
}
.delimiter {
background: #fff;
opacity: 0.5;
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: #000;
color: #898c94;
& > header {
font-size: 10px;
border-bottom: 1px solid #898c94;
}
.actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 10px;
margin-bottom: 8px;
.color {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #3c3c3c;
background: #ccc;
&.yellow {
background: #f6e3a1;
}
&.white {
background: #fff;
box-shadow: inset 0 0 0 1px #000;
border-color: #fff;
}
&.yellow {
background: #f6e3a1;
}
&.pink {
background: #f1b5bc;
}
&.green {
background: #bfe9cb;
box-shadow: inset 0 0 0 1px #000;
border-color: #fff;
}
&.black {
background: #000;
}
}
&:last-child {
margin-bottom: 0;
}
.bubbleMenuButton {
min-width: 40px;
}
}
}
}
}

View File

@ -1,43 +1,40 @@
import type { Editor } from '@tiptap/core'
import styles from './ImageBubbleMenu.module.scss'
import styles from './FigureBubbleMenu.module.scss'
import { clsx } from 'clsx'
import { Icon } from '../../_shared/Icon'
import { useLocalize } from '../../../context/localize'
type BubbleMenuProps = {
type Props = {
editor: Editor
ref: (el: HTMLDivElement) => void
ref: (el: HTMLElement) => void
}
export const ImageBubbleMenu = (props: BubbleMenuProps) => {
export const FigureBubbleMenu = (props: Props) => {
const { t } = useLocalize()
return (
<div ref={props.ref} class={styles.ImageBubbleMenu}>
<div ref={props.ref} class={styles.FigureBubbleMenu}>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => {
props.editor.chain().focus().setFloat('left').run()
}}
onClick={() => props.editor.chain().focus().setImageFloat('left').run()}
>
<Icon name="editor-image-align-left" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => {
props.editor.chain().focus().setFloat(null).run()
}}
onClick={() => props.editor.chain().focus().setImageFloat(null).run()}
>
<Icon name="editor-image-align-center" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => {
props.editor.chain().focus().setFloat('right').run()
}}
onClick={() => props.editor.chain().focus().setImageFloat('right').run()}
>
<Icon name="editor-image-align-right" />
</button>
<div class={styles.delimiter} />
<button
type="button"
@ -46,7 +43,7 @@ export const ImageBubbleMenu = (props: BubbleMenuProps) => {
props.editor.chain().focus().imageToFigure().run()
}}
>
<span style={{ color: 'white' }}>Добавить подпись</span>
<span style={{ color: 'white' }}>{t('Add signature')}</span>
</button>
<div class={styles.delimiter} />
<button type="button" class={clsx(styles.bubbleMenuButton)}>

View File

@ -0,0 +1,85 @@
import { createSignal, Show, For } from 'solid-js'
import type { Editor } from '@tiptap/core'
import styles from './FigureBubbleMenu.module.scss'
import { clsx } from 'clsx'
import { Icon } from '../../_shared/Icon'
import { useLocalize } from '../../../context/localize'
type Props = {
editor: Editor
ref: (el: HTMLElement) => void
}
const backgrounds = [null, 'white', 'black', 'yellow', 'pink', 'green']
export const IncutBubbleMenu = (props: Props) => {
const { t } = useLocalize()
const [substratBubbleOpen, setSubstratBubbleOpen] = createSignal(false)
return (
<div ref={props.ref} class={styles.FigureBubbleMenu}>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => props.editor.chain().focus().setArticleFloat('left').run()}
>
<Icon name="editor-image-align-left" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => props.editor.chain().focus().setArticleFloat('half-left').run()}
>
<Icon name="editor-image-half-align-left" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => props.editor.chain().focus().setArticleFloat(null).run()}
>
<Icon name="editor-image-align-center" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => props.editor.chain().focus().setArticleFloat('half-right').run()}
>
<Icon name="editor-image-half-align-right" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => props.editor.chain().focus().setArticleFloat('right').run()}
>
<Icon name="editor-image-align-right" />
</button>
<div class={styles.delimiter} />
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => setSubstratBubbleOpen(!substratBubbleOpen())}
>
<span style={{ color: 'white' }}>{t('Substrate')}</span>
<Icon name="down-triangle" class={styles.triangle} />
</button>
<Show when={!substratBubbleOpen()}>
<div class={styles.dropDown}>
<div class={styles.actions}>
<For each={backgrounds}>
{(bg) => (
<div
onClick={() => props.editor.chain().focus().setArticleBg(bg).run()}
class={clsx(styles.color, styles[bg])}
/>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
)
}

View File

@ -0,0 +1,3 @@
export { FigureBubbleMenu } from './FigureBubbleMenu'
export { BlockquoteBubbleMenu } from './BlockquoteBubbleMenu'
export { IncutBubbleMenu } from './IncutBubbleMenu'

View File

@ -1,7 +1,6 @@
import { createEffect, createSignal } from 'solid-js'
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import { useLocalize } from '../../context/localize'
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { Dropcursor } from '@tiptap/extension-dropcursor'
@ -23,26 +22,27 @@ import { Link } from '@tiptap/extension-link'
import { Document } from '@tiptap/extension-document'
import { Text } from '@tiptap/extension-text'
import { CustomImage } from './extensions/CustomImage'
import { CustomBlockquote } from './extensions/CustomBlockquote'
import { Figure } from './extensions/Figure'
import { Paragraph } from '@tiptap/extension-paragraph'
import Focus from '@tiptap/extension-focus'
import * as Y from 'yjs'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { Collaboration } from '@tiptap/extension-collaboration'
import { IndexeddbPersistence } from 'y-indexeddb'
import { useSession } from '../../context/session'
import uniqolor from 'uniqolor'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { Embed } from './extensions/Embed'
import { TextBubbleMenu } from './TextBubbleMenu'
import { ImageBubbleMenu } from './ImageBubbleMenu'
import { FigureBubbleMenu, BlockquoteBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import { useEditorContext } from '../../context/editor'
import { isTextSelection } from '@tiptap/core'
import type { Doc } from 'yjs/dist/src/utils/Doc'
import './Prosemirror.scss'
import { TrailingNode } from './extensions/TrailingNode'
import Article from './extensions/Article'
type EditorProps = {
shoutId: number
@ -58,6 +58,7 @@ export const Editor = (props: EditorProps) => {
const { t } = useLocalize()
const { user } = useSession()
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const [floatMenuRef, setFloatMenuRef] = createSignal<'blockquote' | 'image' | 'incut'>()
const docName = `shout-${props.shoutId}`
@ -89,8 +90,18 @@ export const Editor = (props: EditorProps) => {
current: null
}
const imageBubbleMenuRef: {
current: HTMLDivElement
const incutBubbleMenuRef: {
current: HTMLElement
} = {
current: null
}
const figureBubbleMenuRef: {
current: HTMLElement
} = {
current: null
}
const blockquoteBubbleMenuRef: {
current: HTMLElement
} = {
current: null
}
@ -108,7 +119,7 @@ export const Editor = (props: EditorProps) => {
Text,
Paragraph,
Dropcursor,
Blockquote,
CustomBlockquote,
Bold,
Italic,
Strike,
@ -163,16 +174,36 @@ export const Editor = (props: EditorProps) => {
shouldShow: ({ editor: e, view, state, from, to }) => {
const { doc, selection } = state
const { empty } = selection
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
setIsCommonMarkup(e.isActive('figure'))
return view.hasFocus() && !empty && !isEmptyTextBlock && !e.isActive('image')
return (
view.hasFocus() &&
!empty &&
!isEmptyTextBlock &&
!e.isActive('image') &&
!e.isActive('blockquote') &&
!e.isActive('article')
)
}
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef.current,
shouldShow: ({ editor: e, view }) => {
return view.hasFocus() && e.isActive('blockquote')
}
}),
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef.current,
shouldShow: ({ editor: e, view }) => {
return view.hasFocus() && e.isActive('article')
}
}),
BubbleMenu.configure({
pluginKey: 'imageBubbleMenu',
element: imageBubbleMenuRef.current,
element: figureBubbleMenuRef.current,
shouldShow: ({ editor: e, view }) => {
return view.hasFocus() && e.isActive('image')
}
@ -183,7 +214,8 @@ export const Editor = (props: EditorProps) => {
},
element: floatingMenuRef.current
}),
TrailingNode
TrailingNode,
Article
]
}))
@ -213,7 +245,24 @@ export const Editor = (props: EditorProps) => {
editor={editor()}
ref={(el) => (textBubbleMenuRef.current = el)}
/>
<ImageBubbleMenu editor={editor()} ref={(el) => (imageBubbleMenuRef.current = el)} />
<BlockquoteBubbleMenu
ref={(el) => {
blockquoteBubbleMenuRef.current = el
}}
editor={editor()}
/>
<FigureBubbleMenu
editor={editor()}
ref={(el) => {
figureBubbleMenuRef.current = el
}}
/>
<IncutBubbleMenu
editor={editor()}
ref={(el) => {
incutBubbleMenuRef.current = el
}}
/>
<EditorFloatingMenu editor={editor()} ref={(el) => (floatingMenuRef.current = el)} />
</>
)

View File

@ -1,33 +0,0 @@
.ImageBubbleMenu {
background: #000;
display: flex;
flex-direction: row;
align-items: center;
.bubbleMenuButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
opacity: 0.5;
padding: 1rem;
img {
display: block;
}
}
.bubbleMenuButtonActive {
opacity: 1;
}
.delimiter {
background: #fff;
opacity: 0.5;
display: inline-block;
height: 1.4em;
margin: 0 0.2em;
vertical-align: text-bottom;
width: 1px;
}
}

View File

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

View File

@ -1,14 +1,6 @@
.ProseMirror {
outline: none;
min-height: 300px;
blockquote {
@include font-size(1.6rem);
border-left: 2px solid;
margin: 1.5em 0;
padding-left: 1.6em;
}
}
.ProseMirror p.is-editor-empty:first-child::before {
@ -78,3 +70,110 @@ mark.highlight {
box-decoration-break: clone;
padding: 0.125em 0;
}
// custom atibutes fro TipTap Nodes
[data-float] {
max-width: 50%;
}
[data-float='left'] {
max-width: 30%;
float: left;
margin: 1rem 1rem 0 0;
}
[data-float='right'] {
max-width: 30%;
float: right;
margin: 1rem 0 1rem 1rem;
}
[data-float='half-left'] {
max-width: 50%;
min-width: 30%;
float: left;
margin: 1rem 1rem 0;
}
[data-float='half-right'] {
max-width: 50%;
min-width: 30%;
float: right;
margin: 1rem 0 1rem;
}
.ProseMirror blockquote {
p:last-child {
margin-bottom: 0;
}
&[data-type='quote'] {
@include font-size(1.6rem);
border: solid #000;
border-width: 0 0 0 2px;
margin: 1.6rem 0;
padding: 1rem;
&[data-float='left'] {
padding-left: 0;
padding-right: 0;
margin-right: 1.6rem;
border-width: 0 2px 0 0;
}
&[data-float='right'] {
margin-left: 1.6rem;
border-width: 0 0 0 2px;
}
}
&[data-type='punchline'] {
padding: 1.6rem 0 0;
border: solid #000;
border-width: 2px 0;
font-size: 3.2rem;
font-weight: 700;
margin: 0;
&[data-float='left'],
&[data-float='right'] {
font-size: 2.2rem;
}
}
}
.ProseMirror article[data-type='incut'] {
background: #f1f2f3;
padding: 1.6rem;
font-size: 1.4rem;
transition: background 0.3s ease-in-out;
&[data-float] img {
float: none;
max-width: unset;
width: 100% !important;
margin: 0;
}
*:last-child {
margin-bottom: 0;
}
&[data-bg='black'] {
background: #000;
color: #fff;
}
&[data-bg='yellow'] {
background: #f6e3a1;
}
&[data-bg='pink'] {
background: #f1b5bc;
}
&[data-bg='green'] {
background: #bfe9cb;
box-shadow: 0 0 0 1px #000;
}
&[data-bg='white'] {
background: #fff;
box-shadow: 0 0 0 1px #000;
}
}

View File

@ -54,7 +54,6 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
if (textSizeBubbleOpen()) {
setTextSizeBubbleOpen(false)
}
setListBubbleOpen((prev) => !prev)
}
@ -153,7 +152,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
props.editor.chain().focus().toggleBlockquote('quote').run()
toggleTextSizePopup()
}}
>
@ -165,13 +164,28 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
props.editor.chain().focus().toggleBlockquote('punchline').run()
toggleTextSizePopup()
}}
>
<Icon name="editor-quote" />
</button>
</div>
<header>{t('squib')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleArticle().run()
toggleTextSizePopup()
}}
>
<Icon name="editor-squib" />
</button>
</div>
</div>
</Show>
</div>

View File

@ -0,0 +1,66 @@
import { Node, mergeAttributes } from '@tiptap/core'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
Article: {
toggleArticle: () => ReturnType
setArticleFloat: (float: null | 'left' | 'half-left' | 'right' | 'half-right') => ReturnType
setArticleBg: (bg: null | string) => ReturnType
}
}
}
export default Node.create({
name: 'article',
defaultOptions: {
HTMLAttributes: {
'data-type': 'incut'
}
},
group: 'block',
content: 'block+',
parseHTML() {
return [
{
tag: 'article'
}
]
},
renderHTML({ HTMLAttributes }) {
return ['article', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addAttributes() {
return {
'data-float': {
default: null
},
'data-bg': {
default: null
}
}
},
addCommands() {
return {
toggleArticle:
() =>
({ commands }) => {
return commands.toggleWrap('article')
},
setArticleFloat:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })
},
setArticleBg:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-bg': value })
}
}
}
})

View File

@ -0,0 +1,48 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Command } from '@tiptap/core'
export type QuoteTypes = 'quote' | 'punchline'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
CustomBlockquote: {
toggleBlockquote: (type: QuoteTypes) => ReturnType
setBlockQuoteFloat: (float: null | 'left' | 'right') => ReturnType
}
}
}
export const CustomBlockquote = Blockquote.extend({
name: 'blockquote',
defaultOptions: {
HTMLAttributes: {},
group: 'block',
content: 'block+'
},
addAttributes() {
return {
'data-float': {
default: null
},
'data-type': {
default: null
}
}
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
addCommands() {
return {
toggleBlockquote:
(type) =>
({ commands }) => {
return commands.toggleWrap(this.name, { 'data-type': type })
},
setBlockQuoteFloat:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })
}
}
}
})

View File

@ -7,7 +7,7 @@ declare module '@tiptap/core' {
* Add an image
*/
setImage: (options: { src: string; alt?: string; title?: string }) => ReturnType
setFloat: (float: null | 'left' | 'right') => ReturnType
setImageFloat: (float: null | 'left' | 'right') => ReturnType
}
}
}
@ -42,7 +42,7 @@ export const CustomImage = Image.extend({
attrs: options
})
},
setFloat:
setImageFloat:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })