fullbubblemenu
This commit is contained in:
parent
95db381436
commit
53944193f4
|
@ -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",
|
||||
|
|
313
src/components/Editor/Editor.module.scss
Normal file
313
src/components/Editor/Editor.module.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
}
|
104
src/components/Editor/Toolbar/FullBubbleMenu.module.scss
Normal file
104
src/components/Editor/Toolbar/FullBubbleMenu.module.scss
Normal 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;
|
||||
}
|
||||
}
|
280
src/components/Editor/Toolbar/FullBubbleMenu.tsx
Normal file
280
src/components/Editor/Toolbar/FullBubbleMenu.tsx
Normal 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
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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')
|
||||
|
|
35
src/components/Editor/Toolbar/ToolbarControl.module.scss
Normal file
35
src/components/Editor/Toolbar/ToolbarControl.module.scss
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user