bubble-conditions-wip
This commit is contained in:
parent
0857ca8775
commit
ffe0ede835
|
@ -1,18 +1,21 @@
|
||||||
.articleEditor {
|
.ProseMirror {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
p.is-editor-empty:first-child::before {
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Give a remote user a caret */
|
// Keeping the cursor active when moving outside the editable area
|
||||||
.collaboration-cursor__caret {
|
|
||||||
|
/* Give a remote user a caret */
|
||||||
|
.collaboration-cursor__caret {
|
||||||
border-left: 1px solid #0d0d0d;
|
border-left: 1px solid #0d0d0d;
|
||||||
border-right: 1px solid #0d0d0d;
|
border-right: 1px solid #0d0d0d;
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
|
@ -20,11 +23,10 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
word-break: normal;
|
word-break: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Render the username above the caret */
|
||||||
/* Render the username above the caret */
|
.collaboration-cursor__label {
|
||||||
.collaboration-cursor__label {
|
|
||||||
border-radius: 3px 3px 3px 0;
|
border-radius: 3px 3px 3px 0;
|
||||||
color: #0d0d0d;
|
color: #0d0d0d;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
@ -37,9 +39,9 @@
|
||||||
top: -1.4em;
|
top: -1.4em;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-wrapper {
|
.embed-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -51,20 +53,20 @@
|
||||||
border: none;
|
border: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontalRule {
|
.horizontalRule {
|
||||||
border-top: 2px solid #000;
|
border-top: 2px solid #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
mark.highlight {
|
mark.highlight {
|
||||||
box-decoration-break: clone;
|
box-decoration-break: clone;
|
||||||
padding: 0.2em 0;
|
padding: 0.2em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// custom atibutes fro TipTap Nodes
|
// custom atibutes fro TipTap Nodes
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
@include media-breakpoint-up(sm) {
|
||||||
[data-float] {
|
[data-float] {
|
||||||
max-width: 50%;
|
max-width: 50%;
|
||||||
}
|
}
|
||||||
|
@ -104,9 +106,9 @@
|
||||||
min-width: 30%;
|
min-width: 30%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blockquote, .blockquote {
|
.ProseMirror blockquote {
|
||||||
p:last-child {
|
p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -160,9 +162,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
article[data-type='incut'] {
|
.ProseMirror article[data-type='incut'] {
|
||||||
background: #f1f2f3;
|
background: #f1f2f3;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
margin: 1em -1rem;
|
margin: 1em -1rem;
|
||||||
|
@ -255,9 +257,15 @@
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 0 0 1px #000;
|
box-shadow: 0 0 0 1px #000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
figure[data-type='figure'] {
|
.ProseMirror-hideselection figure[data-type='figure'] {
|
||||||
|
& > figcaption {
|
||||||
|
--selection-color: rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
figure[data-type='figure'] {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
|
||||||
.iframe-wrapper {
|
.iframe-wrapper {
|
||||||
|
@ -271,10 +279,10 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stylelint-disable-next-line selector-type-no-unknown */
|
/* stylelint-disable-next-line selector-type-no-unknown */
|
||||||
footnote, .footnote {
|
footnote {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -296,18 +304,11 @@
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: unset;
|
background-color: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-fake-selection {
|
.highlight-fake-selection {
|
||||||
background: var(--selection-background);
|
background: var(--selection-background);
|
||||||
color: var(--selection-color);
|
color: var(--selection-color);
|
||||||
border: solid var(--selection-background);
|
border: solid var(--selection-background);
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
}
|
|
||||||
|
|
||||||
&.ProseMirror-hideselection figure[data-type='figure'] {
|
|
||||||
&>figcaption {
|
|
||||||
--selection-color: rgb(0 0 0 / 60%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,134 +1,60 @@
|
||||||
import { HocuspocusProvider } from '@hocuspocus/provider'
|
import { Editor, isTextSelection } from '@tiptap/core'
|
||||||
import { UploadFile } from '@solid-primitives/upload'
|
|
||||||
import { Editor, EditorOptions } from '@tiptap/core'
|
|
||||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||||
import { CharacterCount } from '@tiptap/extension-character-count'
|
import { CharacterCount } from '@tiptap/extension-character-count'
|
||||||
import { Collaboration } from '@tiptap/extension-collaboration'
|
|
||||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
|
|
||||||
import { FloatingMenu } from '@tiptap/extension-floating-menu'
|
import { FloatingMenu } from '@tiptap/extension-floating-menu'
|
||||||
|
import { Link } from '@tiptap/extension-link'
|
||||||
import { Placeholder } from '@tiptap/extension-placeholder'
|
import { Placeholder } from '@tiptap/extension-placeholder'
|
||||||
import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
|
import { createEffect, createSignal, onCleanup } 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'
|
|
||||||
import { useLocalize } from '~/context/localize'
|
|
||||||
import { useSession } from '~/context/session'
|
|
||||||
import { useSnackbar } from '~/context/ui'
|
import { useSnackbar } from '~/context/ui'
|
||||||
import { Author } from '~/graphql/schema/core.gen'
|
|
||||||
import { base, custom, extended } from '~/lib/editorExtensions'
|
import { base, custom, extended } from '~/lib/editorExtensions'
|
||||||
import { handleImageUpload } from '~/lib/handleImageUpload'
|
import { handleClipboardPaste } from '~/lib/handleImageUpload'
|
||||||
import { allowedImageTypes, renderUploadedImage } from '../Upload/renderUploadedImage'
|
import { useEditorContext } from '../../context/editor'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
import { useSession } from '../../context/session'
|
||||||
import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu'
|
import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu'
|
||||||
import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu'
|
import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu'
|
||||||
import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu'
|
import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu'
|
||||||
import { FullBubbleMenu } from './Toolbar/FullBubbleMenu'
|
import { FullBubbleMenu } from './Toolbar/FullBubbleMenu'
|
||||||
import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu'
|
import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu'
|
||||||
|
import { ArticleNode } from './extensions/Article'
|
||||||
|
import { TrailingNode } from './extensions/TrailingNode'
|
||||||
|
|
||||||
import styles from './Editor.module.scss'
|
import './Editor.module.scss'
|
||||||
|
|
||||||
export type EditorComponentProps = {
|
type Props = {
|
||||||
shoutId: number
|
shoutId: number
|
||||||
initialContent?: string
|
initialContent?: string
|
||||||
onChange: (text: string) => void
|
onChange: (text: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const yDocs: Record<string, Doc> = {}
|
export const EditorComponent = (props: Props) => {
|
||||||
const providers: Record<string, HocuspocusProvider> = {}
|
|
||||||
|
|
||||||
export const EditorComponent = (props: EditorComponentProps) => {
|
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { session, requireAuthentication } = useSession()
|
const { session } = useSession()
|
||||||
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
|
||||||
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { countWords, setEditing, isCollabMode } = useEditorContext()
|
const { countWords, setEditing } = useEditorContext()
|
||||||
const [editorOptions, setEditorOptions] = createSignal<Partial<EditorOptions>>({})
|
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
||||||
|
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
||||||
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
|
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
|
||||||
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
|
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [fullBubbleMenuRef, setFullBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
const [textBubbleMenuRef, setFullBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [editor, setEditor] = createSignal<Editor | null>(null)
|
|
||||||
const [menusInitialized, setMenusInitialized] = createSignal(false)
|
|
||||||
const [shouldShowFullBubbleMenu, setShouldShowFullBubbleMenu] = createSignal(false)
|
|
||||||
|
|
||||||
// store tiptap editor in context provider's signal to use it in Panel
|
const editor = createTiptapEditor(() => ({
|
||||||
createEffect(() => setEditing(editor() || undefined))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создает экземпляр редактора с заданными опциями
|
|
||||||
* @param opts Опции редактора
|
|
||||||
*/
|
|
||||||
const createEditorInstance = (opts?: Partial<EditorOptions>) => {
|
|
||||||
if (!opts?.element) {
|
|
||||||
console.error('Editor options or element is missing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.log('stage 2: create editor instance without menus', opts)
|
|
||||||
|
|
||||||
const old = editor() || { options: {} as EditorOptions }
|
|
||||||
const uniqueExtensions = [
|
|
||||||
...new Map(
|
|
||||||
[...(old?.options?.extensions || []), ...(opts?.extensions || [])].map((ext) => [ext.name, ext])
|
|
||||||
).values()
|
|
||||||
]
|
|
||||||
|
|
||||||
const fresh = createTiptapEditor(() => ({
|
|
||||||
...old?.options,
|
|
||||||
...opts,
|
|
||||||
element: opts.element as HTMLElement,
|
|
||||||
extensions: uniqueExtensions
|
|
||||||
}))
|
|
||||||
if (old instanceof Editor) old?.destroy()
|
|
||||||
setEditor(fresh() || null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClipboardPaste = async () => {
|
|
||||||
try {
|
|
||||||
const clipboardItems: ClipboardItems = await navigator.clipboard.read()
|
|
||||||
|
|
||||||
if (clipboardItems.length === 0) return
|
|
||||||
const [clipboardItem] = clipboardItems
|
|
||||||
const { types } = clipboardItem
|
|
||||||
const imageType: string | undefined = types.find((type) => allowedImageTypes.has(type))
|
|
||||||
|
|
||||||
if (!imageType) return
|
|
||||||
const blob = await clipboardItem.getType(imageType)
|
|
||||||
const extension = imageType.split('/')[1]
|
|
||||||
const file = new File([blob], `clipboardImage.${extension}`)
|
|
||||||
|
|
||||||
const uplFile: UploadFile = {
|
|
||||||
source: blob.toString(),
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
file
|
|
||||||
}
|
|
||||||
|
|
||||||
showSnackbar({ body: t('Uploading image') })
|
|
||||||
const image: { url: string; originalFilename?: string } = await handleImageUpload(
|
|
||||||
uplFile,
|
|
||||||
session()?.access_token || ''
|
|
||||||
)
|
|
||||||
renderUploadedImage(editor() as Editor, image)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Paste Image Error]:', error)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// stage 0: update editor options
|
|
||||||
const setupEditor = () => {
|
|
||||||
console.log('stage 0: update editor options')
|
|
||||||
const options: Partial<EditorOptions> = {
|
|
||||||
element: editorElRef()!,
|
element: editorElRef()!,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: { class: styles.articleEditor },
|
attributes: {
|
||||||
transformPastedHTML: (c: string) => c.replaceAll(/<img.*?>/g, ''),
|
class: 'articleEditor'
|
||||||
|
},
|
||||||
|
transformPastedHTML(html) {
|
||||||
|
return html.replaceAll(/<img.*?>/g, '')
|
||||||
|
},
|
||||||
handlePaste: () => {
|
handlePaste: () => {
|
||||||
handleClipboardPaste().then((_) => 0)
|
showSnackbar({ body: t('Uploading image') })
|
||||||
|
handleClipboardPaste(editor(), session()?.access_token || '').then(() => false)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extensions: [
|
extensions: [
|
||||||
|
@ -138,200 +64,104 @@ export const EditorComponent = (props: EditorComponentProps) => {
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: t('Add a link or click plus to embed media')
|
placeholder: t('Add a link or click plus to embed media')
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure()
|
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
||||||
],
|
|
||||||
onTransaction({ transaction, editor }) {
|
|
||||||
if (transaction.docChanged) {
|
|
||||||
const html = editor.getHTML()
|
|
||||||
html && props.onChange(html)
|
|
||||||
const wordCount: number = editor.storage.characterCount.words()
|
|
||||||
const charsCount: number = editor.storage.characterCount.characters()
|
|
||||||
wordCount && countWords({ words: wordCount, characters: charsCount })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
content: props.initialContent ?? null
|
|
||||||
}
|
|
||||||
console.log(options)
|
|
||||||
setEditorOptions(() => options)
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
// stage 1: create editor options when got author profile
|
|
||||||
createEffect(
|
|
||||||
on([editorOptions, author], ([opts, a]: [Partial<EditorOptions> | undefined, Author | undefined]) => {
|
|
||||||
if (isServer) return
|
|
||||||
console.log('stage 1: create editor options when got author profile', { opts, a })
|
|
||||||
const noOptions = !opts || Object.keys(opts).length === 0
|
|
||||||
noOptions && a && setTimeout(setupEditor, 1)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
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() &&
|
|
||||||
fullBubbleMenuRef()
|
|
||||||
) {
|
|
||||||
console.log('stage 3: initialize menus when editor instance is ready')
|
|
||||||
const menus = [
|
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
element: fullBubbleMenuRef()!,
|
pluginKey: 'textBubbleMenu',
|
||||||
pluginKey: 'fullBubbleMenu',
|
element: textBubbleMenuRef()!,
|
||||||
shouldShow: ({ editor: e, state: { selection } }) => {
|
shouldShow: ({ editor: e, view, state: { doc, selection } , from, to }) => {
|
||||||
const { empty, from, to } = selection
|
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
|
||||||
const hasSelection = !empty && from !== to
|
if (isEmptyTextBlock) {
|
||||||
const shouldShow =
|
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
|
||||||
e.view.hasFocus() && hasSelection && !e.isActive('image') && !e.isActive('figure')
|
}
|
||||||
setShouldShowFullBubbleMenu(shouldShow)
|
setIsCommonMarkup(e.isActive('figcaption'))
|
||||||
return shouldShow
|
const result =
|
||||||
|
(view.hasFocus() &&
|
||||||
|
!selection.empty &&
|
||||||
|
!isEmptyTextBlock &&
|
||||||
|
!e.isActive('image') &&
|
||||||
|
!e.isActive('figure')) ||
|
||||||
|
e.isActive('footnote') ||
|
||||||
|
(e.isActive('figcaption') && !selection.empty)
|
||||||
|
setShouldShowTextBubbleMenu(result)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
duration: 200,
|
sticky: true
|
||||||
placement: 'top'
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'blockquoteBubbleMenu',
|
pluginKey: 'blockquoteBubbleMenu',
|
||||||
element: blockquoteBubbleMenuRef()!,
|
element: blockquoteBubbleMenuRef()!,
|
||||||
shouldShow: ({ editor: e, state: { selection } }) =>
|
shouldShow: ({ editor: e, state }) => {
|
||||||
e.isFocused && !selection.empty && e.isActive('blockquote'),
|
const { selection } = state
|
||||||
|
const { empty } = selection
|
||||||
|
return empty && e.isActive('blockquote')
|
||||||
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
offset: [0, 0],
|
offset: [0, 0],
|
||||||
placement: 'top',
|
placement: 'top',
|
||||||
getReferenceClientRect: () => {
|
getReferenceClientRect: (): DOMRect => {
|
||||||
const selectedElement = editor()?.view.dom.querySelector('.has-focus')
|
const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null
|
||||||
return selectedElement?.getBoundingClientRect() || new DOMRect()
|
if (selectedElement) {
|
||||||
|
return selectedElement.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
return new DOMRect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
|
||||||
pluginKey: 'figureBubbleMenu',
|
|
||||||
element: figureBubbleMenuRef()!,
|
|
||||||
shouldShow: ({ editor: e, view }) => view.hasFocus() && e.isActive('figure')
|
|
||||||
}),
|
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'incutBubbleMenu',
|
pluginKey: 'incutBubbleMenu',
|
||||||
element: incutBubbleMenuRef()!,
|
element: incutBubbleMenuRef()!,
|
||||||
shouldShow: ({ editor: e, state: { selection } }) =>
|
shouldShow: ({ editor: e, state }) => {
|
||||||
e.isFocused && !selection.empty && e.isActive('figcaption'),
|
const { selection } = state
|
||||||
|
const { empty } = selection
|
||||||
|
return empty && e.isActive('article')
|
||||||
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
offset: [0, -16],
|
offset: [0, -16],
|
||||||
placement: 'top',
|
placement: 'top',
|
||||||
getReferenceClientRect: () => {
|
getReferenceClientRect: (): DOMRect => {
|
||||||
const selectedElement = editor()?.view.dom.querySelector('.has-focus')
|
const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null
|
||||||
return selectedElement?.getBoundingClientRect() || new DOMRect()
|
if (selectedElement) {
|
||||||
|
return selectedElement.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
return new DOMRect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
BubbleMenu.configure({
|
||||||
|
pluginKey: 'imageBubbleMenu',
|
||||||
|
element: figureBubbleMenuRef()!,
|
||||||
|
shouldShow: ({ editor: e, view }) => {
|
||||||
|
return view.hasFocus() && e.isActive('image')
|
||||||
|
}
|
||||||
|
}),
|
||||||
FloatingMenu.configure({
|
FloatingMenu.configure({
|
||||||
element: floatingMenuRef()!,
|
|
||||||
pluginKey: 'floatingMenu',
|
|
||||||
shouldShow: ({ editor: e, state: { selection } }) => {
|
|
||||||
const { $anchor, empty } = selection
|
|
||||||
const isRootDepth = $anchor.depth === 1
|
|
||||||
if (!(isRootDepth && empty)) return false
|
|
||||||
return !(e.isActive('codeBlock') || e.isActive('heading'))
|
|
||||||
},
|
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
placement: 'left'
|
placement: 'left'
|
||||||
}
|
},
|
||||||
})
|
element: floatingMenuRef()!
|
||||||
]
|
}),
|
||||||
setEditorOptions((prev) => ({ ...prev, extensions: [...(prev.extensions || []), ...menus] }))
|
TrailingNode,
|
||||||
setMenusInitialized(true)
|
ArticleNode
|
||||||
} else {
|
],
|
||||||
console.error('Some menu references are missing')
|
enablePasteRules: [Link],
|
||||||
|
content: props.initialContent || null,
|
||||||
|
onTransaction: ({ editor: e, transaction }) => {
|
||||||
|
if (transaction.docChanged) {
|
||||||
|
const html = e.getHTML()
|
||||||
|
html && props.onChange(html)
|
||||||
|
const wordCount: number = e.storage.characterCount.words()
|
||||||
|
const charsCount: number = e.storage.characterCount.characters()
|
||||||
|
wordCount && countWords({ words: wordCount, characters: charsCount })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
const initializeCollaboration = () => {
|
// store tiptap editor in context provider's signal to use it in Panel
|
||||||
if (!editor()) {
|
createEffect(() => setEditing(editor() || undefined))
|
||||||
console.error('Editor is not initialized')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditorOptions((prev: Partial<EditorOptions>) => {
|
|
||||||
const extensions = [...(prev.extensions || [])]
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isCollabMode()) {
|
|
||||||
// Remove collaboration extensions and return
|
|
||||||
const filteredExtensions = extensions.filter(
|
|
||||||
(ext) => ext.name !== 'collaboration' && ext.name !== 'collaborationCursor'
|
|
||||||
)
|
|
||||||
return { ...prev, extensions: filteredExtensions }
|
|
||||||
}
|
|
||||||
|
|
||||||
const docName = `shout-${props.shoutId}`
|
|
||||||
const token = session()?.access_token || ''
|
|
||||||
const profile = author()
|
|
||||||
|
|
||||||
if (!(token && profile)) {
|
|
||||||
throw new Error('Missing authentication data')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!yDocs[docName]) {
|
|
||||||
yDocs[docName] = new Doc()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!providers[docName]) {
|
|
||||||
providers[docName] = new HocuspocusProvider({
|
|
||||||
url: 'wss://hocuspocus.discours.io',
|
|
||||||
name: docName,
|
|
||||||
document: yDocs[docName],
|
|
||||||
token
|
|
||||||
})
|
|
||||||
console.log(`HocuspocusProvider установлен для ${docName}`)
|
|
||||||
}
|
|
||||||
extensions.push(
|
|
||||||
Collaboration.configure({ document: yDocs[docName] }),
|
|
||||||
CollaborationCursor.configure({
|
|
||||||
provider: providers[docName],
|
|
||||||
user: { name: profile.name, color: uniqolor(profile.slug).color }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing collaboration:', error)
|
|
||||||
showSnackbar({ body: t('Failed to initialize collaboration') })
|
|
||||||
}
|
|
||||||
console.log('collab extensions added:', extensions)
|
|
||||||
return { ...prev, extensions }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFocus = (event: FocusEvent) => {
|
|
||||||
console.log('handling focus event', event)
|
|
||||||
if (editor()?.isActive('figcaption')) {
|
|
||||||
editor()?.commands.focus()
|
|
||||||
console.log('active figcaption detected, focusing editor')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
console.log('Editor component mounted')
|
|
||||||
editorElRef()?.addEventListener('focus', handleFocus)
|
|
||||||
requireAuthentication(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const opts = setupEditor()
|
|
||||||
createEditorInstance(opts)
|
|
||||||
initializeMenus()
|
|
||||||
}, 120)
|
|
||||||
}, 'edit')
|
|
||||||
})
|
|
||||||
|
|
||||||
// collab mode on/off
|
|
||||||
createEffect(on(isCollabMode, (x) => !x && initializeCollaboration(), { defer: true }))
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
editorElRef()?.removeEventListener('focus', handleFocus)
|
|
||||||
editor()?.destroy()
|
editor()?.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -345,9 +175,9 @@ export const EditorComponent = (props: EditorComponentProps) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FullBubbleMenu
|
<FullBubbleMenu
|
||||||
editor={editor as Accessor<Editor | undefined>}
|
editor={editor}
|
||||||
ref={setFullBubbleMenuRef}
|
ref={setFullBubbleMenuRef}
|
||||||
shouldShow={shouldShowFullBubbleMenu}
|
shouldShow={shouldShowTextBubbleMenu}
|
||||||
isCommonMarkup={isCommonMarkup()}
|
isCommonMarkup={isCommonMarkup()}
|
||||||
/>
|
/>
|
||||||
<BlockquoteBubbleMenu editor={editor() as Editor} ref={setBlockquoteBubbleMenuRef} />
|
<BlockquoteBubbleMenu editor={editor() as Editor} ref={setBlockquoteBubbleMenuRef} />
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blockQuote {
|
.blockquote {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--black-300);
|
color: var(--black-300);
|
||||||
border-left: 2px solid var(--black-100);
|
border-left: 2px solid var(--black-100);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Editor } from '@tiptap/core'
|
import { Editor } from '@tiptap/core'
|
||||||
|
|
||||||
export const renderUploadedImage = (editor: Editor, image: { url: string; originalFilename?: string }) => {
|
export const renderUploadedImage = (editor: Editor, image: { url: string; originalFilename?: string }) => {
|
||||||
|
image?.url &&
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
|
@ -20,14 +21,3 @@ export const renderUploadedImage = (editor: Editor, image: { url: string; origin
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allowedImageTypes = new Set([
|
|
||||||
'image/bmp',
|
|
||||||
'image/gif',
|
|
||||||
'image/jpeg',
|
|
||||||
'image/jpg',
|
|
||||||
'image/png',
|
|
||||||
'image/tiff',
|
|
||||||
'image/webp',
|
|
||||||
'image/x-icon'
|
|
||||||
])
|
|
||||||
|
|
|
@ -1,6 +1,18 @@
|
||||||
import { UploadFile } from '@solid-primitives/upload'
|
import { UploadFile } from '@solid-primitives/upload'
|
||||||
|
import { Editor } from '@tiptap/core'
|
||||||
import { thumborUrl } from '../config'
|
import { thumborUrl } from '../config'
|
||||||
|
|
||||||
|
export const allowedImageTypes = new Set([
|
||||||
|
'image/bmp',
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/tiff',
|
||||||
|
'image/webp',
|
||||||
|
'image/x-icon'
|
||||||
|
])
|
||||||
|
|
||||||
export const handleImageUpload = async (uploadFile: UploadFile, token: string) => {
|
export const handleImageUpload = async (uploadFile: UploadFile, token: string) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('media', uploadFile.file, uploadFile.name)
|
formData.append('media', uploadFile.file, uploadFile.name)
|
||||||
|
@ -38,3 +50,49 @@ export const handleImageUpload = async (uploadFile: UploadFile, token: string) =
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const handleClipboardPaste = async (editor?: Editor, token = '') => {
|
||||||
|
try {
|
||||||
|
const clipboardItems: ClipboardItems = await navigator.clipboard.read()
|
||||||
|
|
||||||
|
if (clipboardItems.length === 0) return
|
||||||
|
const [clipboardItem] = clipboardItems
|
||||||
|
const { types } = clipboardItem
|
||||||
|
const imageType = types.find((type) => allowedImageTypes.has(type))
|
||||||
|
|
||||||
|
if (!imageType) return
|
||||||
|
const blob = await clipboardItem.getType(imageType)
|
||||||
|
const extension = imageType.split('/')[1]
|
||||||
|
const file = new File([blob], `clipboardImage.${extension}`)
|
||||||
|
|
||||||
|
const uplFile = {
|
||||||
|
source: blob.toString(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await handleImageUpload(uplFile, token)
|
||||||
|
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: 'figure',
|
||||||
|
attrs: { 'data-type': 'image' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
attrs: { src: result.url }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'figcaption',
|
||||||
|
content: [{ type: 'text', text: result.originalFilename }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Paste Image Error]:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user