fullbubblemenu
All checks were successful
deploy / testbuild (push) Successful in 2m13s
deploy / Update templates on Mailgun (push) Has been skipped

This commit is contained in:
Untone 2024-10-11 02:20:23 +03:00
parent 95db381436
commit 53944193f4
18 changed files with 921 additions and 469 deletions

View File

@ -104,9 +104,9 @@
"prosemirror-view": "^1.34.3",
"sass": "^1.79.4",
"solid-js": "^1.9.2",
"solid-popper": "^0.3.0",
"solid-tiptap": "0.7.0",
"solid-transition-group": "^0.2.3",
"solid-popper": "^0.3.0",
"storybook": "^8.3.5",
"storybook-addon-sass-postcss": "^0.3.2",
"storybook-solidjs": "^1.0.0-beta.2",

View File

@ -0,0 +1,313 @@
.articleEditor {
font-size: 1.6rem;
outline: none;
min-height: 300px;
p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
opacity: 0.3;
}
/* Give a remote user a caret */
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
/* Render the username above the caret */
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
.embed-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #f1f1f1;
margin: 4rem 0;
iframe {
border: none;
overflow: hidden;
}
}
.horizontalRule {
border-top: 2px solid #000;
}
mark.highlight {
box-decoration-break: clone;
padding: 0.2em 0;
}
// custom atibutes fro TipTap Nodes
@include media-breakpoint-up(sm) {
[data-float] {
max-width: 50%;
}
[data-float='left'] {
float: left;
max-width: 35%;
margin: 1rem 2.2em 0 0;
clear: left;
}
[data-float='right'] {
float: right;
margin: 1rem 0 1rem 2.2em;
max-width: 35%;
clear: right;
}
[data-float='half-left'] {
float: left;
margin: 1rem 1rem 0;
clear: left;
@include media-breakpoint-up(md) {
max-width: 50%;
min-width: 30%;
}
}
[data-float='half-right'] {
float: right;
margin: 1rem 0;
clear: right;
@include media-breakpoint-up(md) {
max-width: 50%;
min-width: 30%;
}
}
}
blockquote, .blockquote {
p:last-child {
margin-bottom: 0;
}
&[data-type='quote'] {
font-size: 1.4rem;
border: solid #000;
border-width: 0 0 0 2px;
margin: 1.6rem 0;
padding: 0 0 0 1.5em;
&[data-float='left'] {
padding-left: 0;
padding-right: 0;
margin-right: 1.6rem;
border-width: 0 2px 0 0;
clear: left;
}
&[data-float='right'] {
margin-left: 1.6rem;
border-width: 0 0 0 2px;
clear: right;
}
}
&[data-type='punchline'] {
border: solid #000;
border-width: 2px 0;
font-size: 3.2rem;
font-weight: 700;
line-height: 1.2;
margin: 1em 0;
padding: 2.4rem 0;
&[data-float='left'],
&[data-float='right'] {
font-size: 2.2rem;
line-height: 1.4;
}
@include media-breakpoint-up(sm) {
&[data-float='left'] {
margin-right: 1.5em;
clear: left;
}
&[data-float='right'] {
margin-left: 1.5em;
clear: right;
}
}
}
}
article[data-type='incut'] {
background: #f1f2f3;
font-size: 1.4rem;
margin: 1em -1rem;
padding: 2em 2rem;
transition: background 0.3s ease-in-out;
@include media-breakpoint-up(sm) {
margin-left: -2rem;
margin-right: -2rem;
}
@include media-breakpoint-up(lg) {
margin-right: -6%;
padding-left: 3em;
padding-right: 3em;
}
@include media-breakpoint-up(lg) {
margin-left: -3em;
margin-right: -3em;
}
&[data-float] img {
float: none;
max-width: unset;
width: 100% !important;
margin: 0;
}
&[data-float='left'],
&[data-float='half-left'] {
margin-left: -1rem;
clear: left;
@include media-breakpoint-up(sm) {
margin-left: -2rem;
margin-right: 2rem;
}
@include media-breakpoint-up(lg) {
margin-left: -6%;
}
@include media-breakpoint-up(xl) {
margin-left: -12.5%;
}
}
&[data-float='right'],
&[data-float='half-right'] {
margin-right: -1rem;
clear: right;
@include media-breakpoint-up(sm) {
margin-left: 2rem;
margin-right: -2rem;
}
@include media-breakpoint-up(lg) {
margin-right: -6%;
}
@include media-breakpoint-up(xl) {
margin-right: -12.5%;
}
}
*: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: #eafff2;
}
&[data-bg='white'] {
background: #fff;
box-shadow: 0 0 0 1px #000;
}
}
figure[data-type='figure'] {
width: 100% !important;
.iframe-wrapper {
position: relative;
overflow: hidden;
width: 100%;
height: auto;
iframe {
display: block;
width: 100%;
}
}
}
/* stylelint-disable-next-line selector-type-no-unknown */
footnote, .footnote {
display: inline-flex;
position: relative;
cursor: pointer;
width: 0.8rem;
height: 1em;
&::before {
content: '';
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
top: -2px;
border: unset;
background-size: 10px;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgY2xhc3M9ImJpIGJpLWluZm8tY2lyY2xlIiBmaWxsPSJjdXJyZW50Q29sb3IiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik04IDE1QTcgNyAwIDEgMSA4IDFhNyA3IDAgMCAxIDAgMTR6bTAgMUE4IDggMCAxIDAgOCAwYTggOCAwIDAgMCAwIDE2eiIvPjxwYXRoIGQ9Im04LjkzIDYuNTg4LTIuMjkuMjg3LS4wODIuMzguNDUuMDgzYy4yOTQuMDcuMzUyLjE3Ni4yODguNDY5bC0uNzM4IDMuNDY4Yy0uMTk0Ljg5Ny4xMDUgMS4zMTkuODA4IDEuMzE5LjU0NSAwIDEuMTc4LS4yNTIgMS40NjUtLjU5OGwuMDg4LS40MTZjLS4yLjE3Ni0uNDkyLjI0Ni0uNjg2LjI0Ni0uMjc1IDAtLjM3NS0uMTkzLS4zMDQtLjUzM0w4LjkzIDYuNTg4ek05IDQuNWExIDEgMCAxIDEtMiAwIDEgMSAwIDAgMSAyIDB6Ii8+PC9zdmc+');
}
&:hover {
background-color: unset;
}
}
.highlight-fake-selection {
background: var(--selection-background);
color: var(--selection-color);
border: solid var(--selection-background);
border-width: 0;
}
&.ProseMirror-hideselection figure[data-type='figure'] {
&>figcaption {
--selection-color: rgb(0 0 0 / 60%);
}
}
}

View File

@ -7,9 +7,9 @@ import { Collaboration } from '@tiptap/extension-collaboration'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { FloatingMenu } from '@tiptap/extension-floating-menu'
import { Placeholder } from '@tiptap/extension-placeholder'
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { isServer } from 'solid-js/web'
import { createTiptapEditor } from 'solid-tiptap'
import { createEditorTransaction, createTiptapEditor } from 'solid-tiptap'
import uniqolor from 'uniqolor'
import { Doc } from 'yjs'
import { useEditorContext } from '~/context/editor'
@ -23,10 +23,10 @@ import { allowedImageTypes, renderUploadedImage } from '../Upload/renderUploaded
import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu'
import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu'
import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu'
import { FullBubbleMenu } from './Toolbar/FullBubbleMenu'
import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu'
import { TextBubbleMenu } from './Toolbar/TextBubbleMenu'
import './Prosemirror.scss'
import styles from './Editor.module.scss'
export type EditorComponentProps = {
shoutId: number
@ -126,11 +126,10 @@ export const EditorComponent = (props: EditorComponentProps) => {
const options: Partial<EditorOptions> = {
element: editorElRef()!,
editorProps: {
attributes: { class: 'articleEditor' },
attributes: { class: styles.articleEditor },
transformPastedHTML: (c: string) => c.replaceAll(/<img.*?>/g, ''),
handlePaste: (_view, _event, _slice) => {
handleClipboardPaste().then((result) => result)
return false
handlePaste: () => {
handleClipboardPaste().then((_) => 0)
}
},
extensions: [
@ -180,6 +179,11 @@ export const EditorComponent = (props: EditorComponentProps) => {
}, 'edit')
})
const isFigcaptionActive = createEditorTransaction(editor as Accessor<Editor | undefined>, (e) =>
e?.isActive('figcaption')
)
createEffect(() => setIsCommonMarkup(!!isFigcaptionActive()))
const initializeMenus = () => {
if (menusInitialized() || !editor()) return
if (blockquoteBubbleMenuRef() && figureBubbleMenuRef() && incutBubbleMenuRef() && floatingMenuRef()) {
@ -188,7 +192,7 @@ export const EditorComponent = (props: EditorComponentProps) => {
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef()!,
shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => {
shouldShow: ({ editor: e, state: { doc, selection }, from, to }) => {
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
@ -197,10 +201,8 @@ export const EditorComponent = (props: EditorComponentProps) => {
const isFootnoteOrFigcaption =
e.isActive('footnote') || (e.isActive('figcaption') && hasSelection)
setIsCommonMarkup(e?.isActive('figcaption'))
const result =
view.hasFocus() &&
e.isFocused &&
hasSelection &&
!e.isActive('image') &&
!e.isActive('figure') &&
@ -355,10 +357,10 @@ export const EditorComponent = (props: EditorComponentProps) => {
</div>
</div>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
<FullBubbleMenu
shouldShow={shouldShowTextBubbleMenu}
isCommonMarkup={isCommonMarkup()}
editor={editor() as Editor}
editor={editor as Accessor<Editor | undefined>}
ref={setTextBubbleMenuRef}
/>
<BlockquoteBubbleMenu editor={editor() as Editor} ref={setBlockquoteBubbleMenuRef} />

View File

@ -49,11 +49,7 @@ export const MicroEditor = (props: MicroEditorProps): JSX.Element => {
[styles.bordered]: props.bordered
})}
>
<MicroBubbleMenu
editor={editor()!}
ref={setBubbleMenuElement}
hidden={!!editor()?.state.selection.empty}
/>
<MicroBubbleMenu editor={editor} ref={setBubbleMenuElement} />
<div id="micro-editor" ref={setEditorElement} style={styles.minimal} />
</div>
)

View File

@ -33,8 +33,8 @@
.blockQuote {
font-weight: 500;
color: var(--black-500);
border-left: 2px solid #696969;
color: var(--black-300);
border-left: 2px solid var(--black-100);
padding: 0 0 0 8px;
margin: 0;
@ -45,10 +45,10 @@
.bubbleMenu {
display: flex;
background-color: white;
background-color: var(--background-color);
padding: 5px;
border-radius: 5px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 5%), 0 10px 20px rgb(0 0 0 / 10%);
box-shadow: 0 0 0 1px var(--shadow-color-light), 0 10px 20px var(--shadow-color-medium);
}
.controls {
@ -82,22 +82,6 @@
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;
}
}
.linkInput {
opacity: 0;
transition: opacity ease-in-out 0.3s;

View File

@ -1,23 +1,23 @@
import { Editor } from '@tiptap/core'
import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder'
import clsx from 'clsx'
import { type JSX, Show, createEffect, createSignal, on } from 'solid-js'
import { createTiptapEditor, useEditorHTML, useEditorIsEmpty } from 'solid-tiptap'
import { Button } from '~/components/_shared/Button'
import { useLocalize } from '~/context/localize'
import { base } from '~/lib/editorExtensions'
import { ToolbarControl as Control } from './Toolbar/ToolbarControl'
import { Editor } from '@tiptap/core'
import { Portal } from 'solid-js/web'
import { createTiptapEditor, useEditorHTML, useEditorIsEmpty } from 'solid-tiptap'
import { UploadModalContent } from '~/components/Upload/UploadModalContent'
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
import { Button } from '~/components/_shared/Button'
import { Icon } from '~/components/_shared/Icon/Icon'
import { Modal } from '~/components/_shared/Modal'
import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui'
import { base } from '~/lib/editorExtensions'
import { UploadedFile } from '~/types/upload'
import styles from './MiniEditor.module.scss'
import { InsertLinkForm } from './Toolbar/InsertLinkForm'
import { ToolbarControl as Control } from './Toolbar/ToolbarControl'
import styles from './MiniEditor.module.scss'
interface MiniEditorProps {
content?: string
@ -96,13 +96,12 @@ export function MiniEditor(props: MiniEditorProps): JSX.Element {
return (
<div class={clsx(styles.MiniEditor, styles.isFocused)}>
<div class={clsx(styles.controls, styles.isFocused)}>
<div class={clsx(styles.actions, styles.active)}>
<div class={clsx(styles.controls)}>
<div class={clsx(styles.actions)}>
<Control
key="bold"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleBold().run()}
title={t('Bold')}
>
<Icon name="editor-bold" />
</Control>
@ -110,32 +109,25 @@ export function MiniEditor(props: MiniEditorProps): JSX.Element {
key="italic"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleItalic().run()}
title={t('Italic')}
>
<Icon name="editor-italic" />
</Control>
<Control
key="link"
editor={editor()}
onChange={handleLinkButtonClick}
title={t('Add url')}
isActive={(e: Editor) => Boolean(e?.isActive('link'))}
>
<Control key="link" editor={editor()} onChange={handleLinkButtonClick} caption={t('Add url')}>
<Icon name="editor-link" />
</Control>
<Control
key="blockquote"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleBlockquote().run()}
title={t('Add blockquote')}
caption={t('Add blockquote')}
>
<Icon name="editor-quote" />
</Control>
<Control
key="image"
editor={editor()}
onChange={() => showModal('simplifiedEditorUploadImage')}
title={t('Add image')}
onChange={() => showModal('editorUploadImage')}
caption={t('Add image')}
>
<Icon name="editor-image-dd-full" />
</Control>
@ -150,7 +142,7 @@ export function MiniEditor(props: MiniEditorProps): JSX.Element {
</div>
<Portal>
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<Modal variant="narrow" name="editorUploadImage">
<UploadModalContent
onClose={(image) => renderUploadedImage(editor() as Editor, image as UploadedFile)}
/>

View File

@ -1,314 +0,0 @@
.ProseMirror {
font-size: 1.6rem;
outline: none;
min-height: 300px;
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
opacity: 0.3;
}
// Keeping the cursor active when moving outside the editable area
/* Give a remote user a caret */
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
/* Render the username above the caret */
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
.embed-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #f1f1f1;
margin: 4rem 0;
iframe {
border: none;
overflow: hidden;
}
}
.horizontalRule {
border-top: 2px solid #000;
}
mark.highlight {
box-decoration-break: clone;
padding: 0.2em 0;
}
// custom atibutes fro TipTap Nodes
@include media-breakpoint-up(sm) {
[data-float] {
max-width: 50%;
}
[data-float='left'] {
float: left;
max-width: 35%;
margin: 1rem 2.2em 0 0;
clear: left;
}
[data-float='right'] {
float: right;
margin: 1rem 0 1rem 2.2em;
max-width: 35%;
clear: right;
}
[data-float='half-left'] {
float: left;
margin: 1rem 1rem 0;
clear: left;
@include media-breakpoint-up(md) {
max-width: 50%;
min-width: 30%;
}
}
[data-float='half-right'] {
float: right;
margin: 1rem 0;
clear: right;
@include media-breakpoint-up(md) {
max-width: 50%;
min-width: 30%;
}
}
}
.ProseMirror blockquote {
p:last-child {
margin-bottom: 0;
}
&[data-type='quote'] {
font-size: 1.4rem;
border: solid #000;
border-width: 0 0 0 2px;
margin: 1.6rem 0;
padding: 0 0 0 1.5em;
&[data-float='left'] {
padding-left: 0;
padding-right: 0;
margin-right: 1.6rem;
border-width: 0 2px 0 0;
clear: left;
}
&[data-float='right'] {
margin-left: 1.6rem;
border-width: 0 0 0 2px;
clear: right;
}
}
&[data-type='punchline'] {
border: solid #000;
border-width: 2px 0;
font-size: 3.2rem;
font-weight: 700;
line-height: 1.2;
margin: 1em 0;
padding: 2.4rem 0;
&[data-float='left'],
&[data-float='right'] {
font-size: 2.2rem;
line-height: 1.4;
}
@include media-breakpoint-up(sm) {
&[data-float='left'] {
margin-right: 1.5em;
clear: left;
}
&[data-float='right'] {
margin-left: 1.5em;
clear: right;
}
}
}
}
.ProseMirror article[data-type='incut'] {
background: #f1f2f3;
font-size: 1.4rem;
margin: 1em -1rem;
padding: 2em 2rem;
transition: background 0.3s ease-in-out;
@include media-breakpoint-up(sm) {
margin-left: -2rem;
margin-right: -2rem;
}
@include media-breakpoint-up(lg) {
margin-right: -6%;
padding-left: 3em;
padding-right: 3em;
}
@include media-breakpoint-up(lg) {
margin-left: -3em;
margin-right: -3em;
}
&[data-float] img {
float: none;
max-width: unset;
width: 100% !important;
margin: 0;
}
&[data-float='left'],
&[data-float='half-left'] {
margin-left: -1rem;
clear: left;
@include media-breakpoint-up(sm) {
margin-left: -2rem;
margin-right: 2rem;
}
@include media-breakpoint-up(lg) {
margin-left: -6%;
}
@include media-breakpoint-up(xl) {
margin-left: -12.5%;
}
}
&[data-float='right'],
&[data-float='half-right'] {
margin-right: -1rem;
clear: right;
@include media-breakpoint-up(sm) {
margin-left: 2rem;
margin-right: -2rem;
}
@include media-breakpoint-up(lg) {
margin-right: -6%;
}
@include media-breakpoint-up(xl) {
margin-right: -12.5%;
}
}
*: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: #eafff2;
}
&[data-bg='white'] {
background: #fff;
box-shadow: 0 0 0 1px #000;
}
}
.ProseMirror-hideselection figure[data-type='figure'] {
& > figcaption {
--selection-color: rgb(0 0 0 / 60%);
}
}
figure[data-type='figure'] {
width: 100% !important;
.iframe-wrapper {
position: relative;
overflow: hidden;
width: 100%;
height: auto;
iframe {
display: block;
width: 100%;
}
}
}
/* stylelint-disable-next-line selector-type-no-unknown */
footnote {
display: inline-flex;
position: relative;
cursor: pointer;
width: 0.8rem;
height: 1em;
&::before {
content: '';
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
top: -2px;
border: unset;
background-size: 10px;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgY2xhc3M9ImJpIGJpLWluZm8tY2lyY2xlIiBmaWxsPSJjdXJyZW50Q29sb3IiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik04IDE1QTcgNyAwIDEgMSA4IDFhNyA3IDAgMCAxIDAgMTR6bTAgMUE4IDggMCAxIDAgOCAwYTggOCAwIDAgMCAwIDE2eiIvPjxwYXRoIGQ9Im04LjkzIDYuNTg4LTIuMjkuMjg3LS4wODIuMzguNDUuMDgzYy4yOTQuMDcuMzUyLjE3Ni4yODguNDY5bC0uNzM4IDMuNDY4Yy0uMTk0Ljg5Ny4xMDUgMS4zMTkuODA4IDEuMzE5LjU0NSAwIDEuMTc4LS4yNTIgMS40NjUtLjU5OGwuMDg4LS40MTZjLS4yLjE3Ni0uNDkyLjI0Ni0uNjg2LjI0Ni0uMjc1IDAtLjM3NS0uMTkzLS4zMDQtLjUzM0w4LjkzIDYuNTg4ek05IDQuNWExIDEgMCAxIDEtMiAwIDEgMSAwIDAgMSAyIDB6Ii8+PC9zdmc+');
}
&:hover {
background-color: unset;
}
}
.highlight-fake-selection {
background: var(--selection-background);
color: var(--selection-color);
border: solid var(--selection-background);
border-width: 0;
}

View File

@ -0,0 +1,104 @@
.FullBubbleMenu {
display: flex;
background-color: var(--background-color);
padding: 5px;
border-radius: 5px;
box-shadow: 0 0 0 1px var(--shadow-color-light), 0 10px 20px var(--shadow-color-medium);
&.growWidth {
min-width: 460px;
}
.actionButton {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
&:hover,
&.buttonActive {
opacity: 1;
}
.triangle {
margin-left: 4px;
}
.toggleHighlight {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f6e3a1;
}
img {
filter: var(--icon-filter);
}
}
.noWrap {
white-space: nowrap;
}
.delimiter {
background: var(--secondary-color);
display: inline-block;
margin: 0 0.2em;
vertical-align: middle;
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: var(--background-color);
color: var(--default-color);
&>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,280 @@
import type { Editor } from '@tiptap/core'
import { clsx } from 'clsx'
import { Accessor, Show, createEffect, createSignal, on } from 'solid-js'
import { createEditorTransaction } from 'solid-tiptap'
import { Icon } from '~/components/_shared/Icon'
import { useLocalize } from '~/context/localize'
import { MiniEditor } from '../MiniEditor'
import { MicroBubbleMenu } from './MicroBubbleMenu'
import { ToolbarControl } from './ToolbarControl'
import { Popover } from '~/components/_shared/Popover/Popover'
import styles from './FullBubbleMenu.module.scss'
type FullBubbleMenuProps = {
editor: () => Editor | undefined
ref: (el: HTMLDivElement) => void
shouldShow: Accessor<boolean>
isCommonMarkup?: boolean
}
export const FullBubbleMenu = (props: FullBubbleMenuProps) => {
const { t } = useLocalize()
// SIGNALS
const [textSizeBubbleOpen, setTextSizeBubbleOpen] = createSignal(false)
const [listBubbleOpen, setListBubbleOpen] = createSignal(false)
const [footnoteEditorOpen, setFootnoteEditorOpen] = createSignal(false)
const [footNote, setFootNote] = createSignal<string>()
// OBSERVERS
const isActive = (name: string, attributes?: Record<string, string | number>) =>
createEditorTransaction(props.editor, (editor) => editor?.isActive(name, attributes))
const isH1 = isActive('heading', { level: 2 })
const isH2 = isActive('heading', { level: 3 })
const isH3 = isActive('heading', { level: 4 })
const isQuote = isActive('blockquote', { 'data-type': 'quote' })
const isPunchLine = isActive('blockquote', { 'data-type': 'punchline' })
const isOrderedList = isActive('isOrderedList')
const isBulletList = isActive('isBulletList')
const isFootnote = isActive('footnote')
const isIncut = isActive('article')
const isFigcaption = isActive('figcaption')
const isHighlight = isActive('highlight')
// toggle open / close on submenus
createEffect(on(props.shouldShow, setFootnoteEditorOpen))
createEffect(on(props.shouldShow, setTextSizeBubbleOpen))
createEffect(on(props.shouldShow, setListBubbleOpen))
const toggleTextSizeMenu = () => setTextSizeBubbleOpen((x) => !x)
const toggleListMenu = () => setListBubbleOpen((x) => !x)
const toggleFootnoteEditor = () => setFootnoteEditorOpen((x) => !x)
// handle footnote
const updateCurrentFootnoteValue = createEditorTransaction(props.editor, (ed) => {
if (!isFootnote()) {
return
}
const value = ed?.getAttributes('footnote').value
setFootNote(value)
})
createEffect(on(isFootnote, updateCurrentFootnoteValue))
const handleAddFootnote = (value: string) => {
if (footNote()) {
props.editor()?.chain().focus().updateFootnote({ value }).run()
} else {
props.editor()?.chain().focus().setFootnote({ value }).run()
}
setFootNote()
setFootnoteEditorOpen(false)
}
// handle blockquote
const handleSetPunchline = () => {
if (isPunchLine()) {
props.editor()?.chain().focus().toggleBlockquote('punchline').run()
}
props.editor()?.chain().focus().toggleBlockquote('quote').run()
toggleTextSizeMenu()
}
const handleSetQuote = () => {
if (isQuote()) {
props.editor()?.chain().focus().toggleBlockquote('quote').run()
}
props.editor()?.chain().focus().toggleBlockquote('punchline').run()
toggleTextSizeMenu()
}
// submenus
const TextSizeDropdown = () => (
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.actionButton, { [styles.buttonActive]: textSizeBubbleOpen() })}
onClick={toggleTextSizeMenu}
>
<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}>
<ToolbarControl
caption={t('Header 1')}
editor={props.editor()}
isActive={isH1}
onChange={() => {
props.editor()?.chain().focus().toggleHeading({ level: 2 }).run()
toggleTextSizeMenu()
}}
>
<Icon name="editor-h1" />
</ToolbarControl>
<ToolbarControl
caption={t('Header 2')}
editor={props.editor()}
isActive={isH2}
onChange={() => {
props.editor()?.chain().focus().toggleHeading({ level: 3 }).run()
toggleTextSizeMenu()
}}
>
<Icon name="editor-h2" />
</ToolbarControl>
<ToolbarControl
caption={t('Header 3')}
editor={props.editor()}
isActive={isH3}
onChange={() => {
props.editor()?.chain().focus().toggleHeading({ level: 4 }).run()
toggleTextSizeMenu()
}}
>
<Icon name="editor-h3" />
</ToolbarControl>
</div>
<header>{t('Quotes')}</header>
<div class={styles.actions}>
<ToolbarControl
caption={t('Quote')}
editor={props.editor()}
isActive={isQuote}
onChange={handleSetPunchline}
>
<Icon name="editor-blockquote" />
</ToolbarControl>
<ToolbarControl
caption={t('Punchline')}
editor={props.editor()}
isActive={isPunchLine}
onChange={handleSetQuote}
>
<Icon name="editor-quote" />
</ToolbarControl>
</div>
<header>{t('squib')}</header>
<div class={styles.actions}>
<ToolbarControl
caption={t('Incut')}
editor={props.editor()}
isActive={isIncut}
onChange={() => {
props.editor()?.chain().focus().toggleArticle().run()
toggleTextSizeMenu()
}}
>
<Icon name="editor-squib" />
</ToolbarControl>
</div>
</div>
</Show>
</div>
)
const ListDropdown = () => (
<div class={styles.dropDownHolder}>
<div>
<button
type="button"
class={clsx(styles.actionButton, { [styles.buttonActive]: listBubbleOpen() })}
onClick={toggleListMenu}
>
<Icon name="editor-ul" />
<Icon name="down-triangle" class={styles.triangle} />
</button>
</div>
<Show when={listBubbleOpen()}>
<div class={styles.dropDown}>
<header>{t('Lists')}</header>
<div class={styles.actions}>
<ToolbarControl
caption={t('Bullet list')}
isActive={isBulletList}
editor={props.editor()}
onChange={() => {
props.editor()?.chain().focus().toggleBulletList().run()
toggleListMenu()
}}
>
<Icon name="editor-ul" />
</ToolbarControl>
<ToolbarControl
caption={t('Ordered list')}
editor={props.editor()}
isActive={isOrderedList}
onChange={() => {
props.editor()?.chain().focus().toggleOrderedList().run()
toggleListMenu()
}}
>
<Icon name="editor-ol" />
</ToolbarControl>
</div>
</div>
</Show>
</div>
)
const MainMenu = () => (
<>
<Show when={!isFigcaption()}>
<TextSizeDropdown />
</Show>
<MicroBubbleMenu editor={props.editor} noBorders={true} />
<Show when={!isFigcaption()}>
<div class={styles.dropDownHolder}>
<Popover content={t('Highlight')}>
{(triggerRef: (el: HTMLButtonElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, {
[styles.buttonActive]: isHighlight()
})}
onClick={() => props.editor()?.chain().focus().toggleHighlight({ color: '#f6e3a1' }).run()}
>
<div class={styles.toggleHighlight} />
</button>
)}
</Popover>
<ToolbarControl
caption={t('Insert footnote')}
editor={props.editor()}
key="footnote"
onChange={toggleFootnoteEditor}
>
<Icon name="editor-footnote" />
</ToolbarControl>
</div>
<div style={{ width: '5px' }} />
<ListDropdown />
</Show>
</>
)
return (
<div ref={props.ref} class={clsx(styles.FullBubbleMenu, { [styles.growWidth]: footnoteEditorOpen() })}>
<Show when={footnoteEditorOpen()} fallback={<MainMenu />}>
<MiniEditor
placeholder={t('Enter footnote text')}
onSubmit={(value) => handleAddFootnote(value)}
content={footNote()}
onCancel={toggleFootnoteEditor}
/>
</Show>
</div>
)
}
export default FullBubbleMenu

View File

@ -1,9 +1,9 @@
.MicroBubbleMenu {
display: flex;
background-color: white;
background-color: var(--background-color);
padding: 5px;
border-radius: 5px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 5%), 0 10px 20px rgb(0 0 0 / 10%);
box-shadow: 0 0 0 1px var(--shadow-color-light), 0 10px 20px var(--shadow-color-medium);
.bubbleMenuButton {
width: 32px;
@ -26,4 +26,10 @@
.noWrap {
white-space: nowrap;
}
&.noBorders {
border: 0 !important;
box-shadow: none !important;
border-radius: 0 !important;
}
}

View File

@ -1,98 +1,76 @@
import type { Editor } from '@tiptap/core'
import { clsx } from 'clsx'
import { Show, createEffect, createSignal } from 'solid-js'
import { createEditorTransaction } from 'solid-tiptap'
import { Accessor, Show, createSignal, onCleanup, onMount } from 'solid-js'
import { Icon } from '~/components/_shared/Icon'
import { Popover } from '~/components/_shared/Popover'
import { useLocalize } from '~/context/localize'
import { InsertLinkForm } from './InsertLinkForm'
import clsx from 'clsx'
import styles from './MicroBubbleMenu.module.scss'
import ToolbarControl from './ToolbarControl'
type MicroBubbleMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
hidden: boolean
editor: Accessor<Editor | undefined>
ref?: (el: HTMLDivElement) => void
noBorders?: boolean
}
export const MicroBubbleMenu = (props: MicroBubbleMenuProps) => {
const { t } = useLocalize()
const isActive = (name: string, attributes?: Record<string, string | number>) =>
createEditorTransaction(
// biome-ignore lint/suspicious/noExplicitAny: tiptap 2.8.0 typing
() => props.editor as any,
(editor) => editor?.isActive(name, attributes)
)
const [linkEditorOpen, setLinkEditorOpen] = createSignal(false)
createEffect(() => props.hidden && setLinkEditorOpen(false))
const isBold = isActive('bold')
const isItalic = isActive('italic')
const isLink = isActive('link')
const handleOpenLinkForm = () => {
const { from, to } = props.editor.state.selection
props.editor?.chain().focus().setTextSelection({ from, to }).run()
const { from, to } = props.editor()!.state.selection
props.editor()?.chain().focus().setTextSelection({ from, to }).run()
setLinkEditorOpen(true)
}
const handleCloseLinkForm = () => {
setLinkEditorOpen(false)
// Снимаем выделение, устанавливая курсор в конец текущего выделения
const { to } = props.editor.state.selection
props.editor?.chain().focus().setTextSelection(to).run()
const { to } = props.editor()!.state.selection
props.editor()?.chain().focus().setTextSelection(to).run()
}
// handle ctrl+k to insert link
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.code === 'KeyK' &&
(event.metaKey || event.ctrlKey) &&
!props.editor()?.state.selection.empty
) {
event.preventDefault()
setLinkEditorOpen((prev) => !prev)
}
}
onMount(() => {
window.addEventListener('keydown', handleKeyDown)
onCleanup(() => {
window.removeEventListener('keydown', handleKeyDown)
})
})
return (
<div ref={props.ref} class={styles.MicroBubbleMenu}>
<div ref={props.ref} class={clsx(styles.MicroBubbleMenu, { [styles.noBorders]: props.noBorders })}>
<Show
when={!linkEditorOpen()}
fallback={<InsertLinkForm editor={props.editor} onClose={handleCloseLinkForm} />}
fallback={<InsertLinkForm editor={props.editor() as Editor} onClose={handleCloseLinkForm} />}
>
<Popover content={t('Bold')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBold()
})}
onClick={() => props.editor?.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
)}
</Popover>
<Popover content={t('Italic')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isItalic()
})}
onClick={() => props.editor?.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
)}
</Popover>
<Popover content={<div class={styles.noWrap}>{t('Add url')}</div>}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
onClick={handleOpenLinkForm}
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink()
})}
>
<Icon name="editor-link" />
</button>
)}
</Popover>
<ToolbarControl
key="bold"
editor={props.editor()}
onChange={() => props.editor()?.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</ToolbarControl>
<ToolbarControl
key="italic"
editor={props.editor()}
onChange={() => props.editor()?.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</ToolbarControl>
<ToolbarControl key="link" editor={props.editor()} onChange={handleOpenLinkForm}>
<Icon name="editor-link" />
</ToolbarControl>
</Show>
</div>
)

View File

@ -1,6 +1,6 @@
.TextBubbleMenu {
background: var(--editor-bubble-menu-background);
box-shadow: 0 4px 10px rgba(var(--primary-color), 0.25);
box-shadow: 0 4px 10px rgba(var(--default-color), 0.25);
&.growWidth {
min-width: 460px;
@ -36,7 +36,7 @@
}
.delimiter {
background: var(--secondary-color);
background: var(--default-color);
display: inline-block;
height: 1.4em;
margin: 0 0.2em;

View File

@ -1,6 +1,6 @@
import type { Editor } from '@tiptap/core'
import { clsx } from 'clsx'
import { Match, Show, Switch, createEffect, createSignal, onCleanup, onMount } from 'solid-js'
import { Accessor, Match, Show, Switch, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import { createEditorTransaction } from 'solid-tiptap'
import { useLocalize } from '../../../context/localize'
import { Icon } from '../../_shared/Icon'
@ -14,7 +14,7 @@ type BubbleMenuProps = {
editor: Editor
isCommonMarkup: boolean
ref: (el: HTMLDivElement) => void
shouldShow: boolean
shouldShow: Accessor<boolean>
}
export const TextBubbleMenu = (props: BubbleMenuProps) => {
@ -32,12 +32,14 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
const [footnoteEditorOpen, setFootnoteEditorOpen] = createSignal(false)
const [footNote, setFootNote] = createSignal<string>()
createEffect(() => {
if (!props.shouldShow) {
setFootNote()
setFootnoteEditorOpen(false)
}
})
createEffect(
on(props.shouldShow, (show?: boolean) => {
if (!show) {
setFootNote()
setFootnoteEditorOpen(false)
}
})
)
const isBold = isActive('bold')
const isItalic = isActive('italic')

View File

@ -0,0 +1,35 @@
.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;
}
.nwrp {
display: inline-block;
white-space: nowrap;
}
.triangle {
margin-left: 4px;
}
.toggleHighlight {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--yellow);
}
img {
filter: var(--icon-filter);
}
}

View File

@ -2,15 +2,17 @@ import { Editor } from '@tiptap/core'
import clsx from 'clsx'
import { JSX } from 'solid-js'
import { Popover } from '~/components/_shared/Popover'
import { capitalize } from '~/utils/capitalize'
import styles from '../MiniEditor.module.scss'
import styles from './ToolbarControl.module.scss'
import { t } from 'i18next'
export interface ControlProps {
editor: Editor | undefined
title: string
key: string
caption?: string
key?: string
onChange: () => void
isActive?: (editor: Editor) => boolean
isActive?: () => boolean | undefined
children: JSX.Element
}
@ -20,14 +22,15 @@ export const ToolbarControl = (props: ControlProps): JSX.Element => {
ev?.stopPropagation()
props.onChange?.()
}
const isActive =
props.isActive || props.key ? () => props.editor?.isActive?.(props.key || '') : () => false
return (
<Popover content={props.title}>
<Popover content={props.caption || t(capitalize(props.key || ''))}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: props.editor?.isActive?.(props.key) })}
class={clsx(styles.actionButton, { [styles.active]: isActive() })}
onClick={handleClick}
>
{props.children}

View File

@ -88,7 +88,7 @@ export type ModalType =
| 'confirm'
| 'donate'
| 'uploadImage'
| 'simplifiedEditorUploadImage'
| 'editorUploadImage'
| 'uploadCoverImage'
| 'editorInsertLink'
| 'followers'
@ -108,7 +108,7 @@ export const MODALS: Record<ModalType, ModalType> = {
donate: 'donate',
inviteMembers: 'inviteMembers',
uploadImage: 'uploadImage',
simplifiedEditorUploadImage: 'simplifiedEditorUploadImage',
editorUploadImage: 'editorUploadImage',
uploadCoverImage: 'uploadCoverImage',
editorInsertLink: 'editorInsertLink',
followers: 'followers',

View File

@ -1,4 +1,5 @@
import { EditorOptions } from '@tiptap/core'
import { Editor, Extension, getSchemaByResolvedExtensions } from '@tiptap/core'
import Bold from '@tiptap/extension-bold'
import { Document as DocExt } from '@tiptap/extension-document'
import Dropcursor from '@tiptap/extension-dropcursor'
@ -24,6 +25,70 @@ import { Span } from '~/components/Editor/extensions/Span'
import { ToggleTextWrap } from '~/components/Editor/extensions/ToggleTextWrap'
import { TrailingNode } from '~/components/Editor/extensions/TrailingNode'
/**
* Обновляет расширения редактора, добавляя новые и удаляя указанные, без пересоздания инстанса редактора.
* Сохраняет текущее выделение и фокус.
*
* @param {Editor} currentEditor - Текущий экземпляр редактора.
* @param {Extension[]} [extensionsToAdd=[]] - Массив расширений для добавления.
* @param {string[]} [extensionsToRemove=[]] - Массив имен расширений для удаления.
*
* @description
* Эта функция выполняет следующие действия:
* 1. Сохраняет текущее выделение.
* 2. Удаляет указанные расширения из текущего списка.
* 3. Добавляет новые расширения, избегая дубликатов.
* 4. Обновляет менеджер расширений редактора.
* 5. Пересоздает схему и состояние редактора.
* 6. Обновляет view редактора с новым состоянием.
* 7. Восстанавливает сохраненное выделение.
* 8. Фокусирует редактор для применения изменений.
*
* @example
* const editor = new Editor();
* const newExtension = new CustomExtension();
* updateEditorExtensions(editor, [newExtension], ['oldExtension']);
*/
export const updateEditorExtensions = (
currentEditor: Editor,
extensionsToAdd: Extension[] = [],
extensionsToRemove: string[] = []
) => {
let currentExtensions = currentEditor.extensionManager.extensions
// Сохраняем текущее выделение
const { from, to } = currentEditor.state.selection
// Удаляем указанные расширения
if (extensionsToRemove.length > 0) {
currentExtensions = currentExtensions.filter((ext) => !extensionsToRemove.includes(ext.name))
}
// Добавляем новые расширения, избегая дубликатов
const updatedExtensions = [
...currentExtensions,
...extensionsToAdd.filter(
(newExt) => !currentExtensions.some((currentExt) => currentExt.name === newExt.name)
)
]
// Обновляем расширения
currentEditor.extensionManager.extensions = updatedExtensions
// Пересоздаем схему и состояние
const newSchema = getSchemaByResolvedExtensions(updatedExtensions, currentEditor)
const newState = currentEditor.state.reconfigure({ schema: newSchema } as Editor['state'])
// Обновляем view с новым состоянием
currentEditor.view.updateState(newState)
// Восстанавливаем выделение
currentEditor.commands.setTextSelection({ from, to })
// Принудительно обновляем редактор
currentEditor.commands.focus()
}
export const base: EditorOptions['extensions'] = [
StarterKit.configure({
heading: {

View File

@ -17,7 +17,11 @@
--icon-filter-hover: invert(1);
--editor-bubble-menu-background: #fff;
--editor-bubble-menu-border: #000;
--embed-background: #f7f7f8;
--embed-border: #e9e9ee;
--blue-link: #2638d9;
--shadow-color-light: rgb(0 0 0 / 5%);
--shadow-color-medium: rgb(0 0 0 / 10%);
// names from figma
--black-50: #f7f7f8;
@ -44,6 +48,8 @@
--icon-filter: invert(1);
--icon-filter-hover: invert(0);
--editor-bubble-menu-background: #444;
--shadow-color-light: rgb(255 255 255 / 5%);
--shadow-color-medium: rgb(255 255 255 / 10%);
// names from figma
--black-50: #080807;