floating image + figure

This commit is contained in:
bniwredyc 2023-05-07 15:16:03 +02:00
parent 9a8a358a8e
commit 4d31dcf2c3
8 changed files with 226 additions and 36 deletions

View File

@ -41,6 +41,7 @@ import { ImageBubbleMenu } from './ImageBubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu' import { EditorFloatingMenu } from './EditorFloatingMenu'
import { useEditorContext } from '../../context/editor' import { useEditorContext } from '../../context/editor'
import { isTextSelection } from '@tiptap/core' import { isTextSelection } from '@tiptap/core'
import { Figure } from './extensions/Figure'
type EditorProps = { type EditorProps = {
shoutId: number shoutId: number
@ -143,9 +144,9 @@ export const Editor = (props: EditorProps) => {
class: 'uploadedImage' class: 'uploadedImage'
} }
}), }),
Figure,
TrailingNode, TrailingNode,
Embed, Embed,
TrailingNode,
CharacterCount, CharacterCount,
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'textBubbleMenu', pluginKey: 'textBubbleMenu',
@ -156,7 +157,9 @@ export const Editor = (props: EditorProps) => {
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection) const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
return !(!view.hasFocus() || empty || isEmptyTextBlock || e.isActive('image')) return (
view.hasFocus() && !empty && !isEmptyTextBlock && !e.isActive('image') && !e.isActive('figure')
)
} }
}), }),
BubbleMenu.configure({ BubbleMenu.configure({

View File

@ -20,7 +20,13 @@ export const ImageBubbleMenu = (props: BubbleMenuProps) => {
> >
<Icon name="editor-image-align-left" /> <Icon name="editor-image-align-left" />
</button> </button>
<button type="button" class={clsx(styles.bubbleMenuButton)}> <button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => {
props.editor.chain().focus().setFloat(null).run()
}}
>
<Icon name="editor-image-align-center" /> <Icon name="editor-image-align-center" />
</button> </button>
<button <button
@ -33,6 +39,16 @@ export const ImageBubbleMenu = (props: BubbleMenuProps) => {
<Icon name="editor-image-align-right" /> <Icon name="editor-image-align-right" />
</button> </button>
<div class={styles.delimiter} /> <div class={styles.delimiter} />
<button
type="button"
class={clsx(styles.bubbleMenuButton)}
onClick={() => {
props.editor.chain().focus().imageToFigure().run()
}}
>
<span style={{ color: 'white' }}>Добавить подпись</span>
</button>
<div class={styles.delimiter} />
<button type="button" class={clsx(styles.bubbleMenuButton)}> <button type="button" class={clsx(styles.bubbleMenuButton)}>
<Icon name="editor-image-add" /> <Icon name="editor-image-add" />
</button> </button>

View File

@ -29,7 +29,6 @@ export const UploadModalContent = (props: Props) => {
props.editor props.editor
.chain() .chain()
.focus() .focus()
.extendMarkRange('link')
.setImage({ src: imageProxy(src) }) .setImage({ src: imageProxy(src) })
.run() .run()
hideModal() hideModal()

View File

@ -1,24 +1,17 @@
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
import { mergeAttributes } from '@tiptap/core'
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
resizableMedia: { customImage: {
setFloat: (float: 'none' | 'left' | 'right') => ReturnType /**
* Add an image
*/
setImage: (options: { src: string; alt?: string; title?: string }) => ReturnType
setFloat: (float: null | 'left' | 'right') => ReturnType
} }
} }
} }
export const updateAttrs = (attrs, editor, node) => {
const { view } = editor
if (!view.editable) return
const { state } = view
const newAttrs = { ...node.attrs, ...attrs }
const { from } = state.selection
const transaction = state.tr.setNodeMarkup(from, null, newAttrs)
view.dispatch(transaction)
}
export default Image.extend({ export default Image.extend({
addAttributes() { addAttributes() {
return { return {
@ -39,11 +32,16 @@ export default Image.extend({
} }
} }
}, },
renderHTML({ HTMLAttributes }) {
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
},
addCommands() { addCommands() {
return { return {
setImage:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options
})
},
setFloat: setFloat:
(value) => (value) =>
({ commands }) => { ({ commands }) => {

View File

@ -0,0 +1,190 @@
import { findChildrenInRange, mergeAttributes, Node, nodeInputRule, Tracker } from '@tiptap/core'
export interface FigureOptions {
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
figure: {
/**
* Add a figure element
*/
setFigure: (options: { src: string; alt?: string; title?: string; caption?: string }) => ReturnType
/**
* Converts an image to a figure
*/
imageToFigure: () => ReturnType
/**
* Converts a figure to an image
*/
figureToImage: () => ReturnType
}
}
}
export const inputRegex = /!\[(.+|:?)]\((\S+)(?:\s+["'](\S+)["'])?\)/
export const Figure = Node.create<FigureOptions>({
name: 'figure',
addOptions() {
return {
HTMLAttributes: {}
}
},
group: 'block',
content: 'inline*',
draggable: true,
isolating: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: (element) => element.querySelector('img')?.getAttribute('src')
},
alt: {
default: null,
parseHTML: (element) => element.querySelector('img')?.getAttribute('alt')
},
title: {
default: null,
parseHTML: (element) => element.querySelector('img')?.getAttribute('title')
}
}
},
parseHTML() {
return [
{
tag: 'figure',
contentElement: 'figcaption'
}
]
},
renderHTML({ HTMLAttributes }) {
return [
'figure',
this.options.HTMLAttributes,
['img', mergeAttributes(HTMLAttributes, { draggable: false, contenteditable: false })],
['figcaption', 0]
]
},
addCommands() {
return {
setFigure:
({ caption, ...attrs }) =>
({ chain }) => {
return (
chain()
.insertContent({
type: this.name,
attrs,
content: caption ? [{ type: 'text', text: caption }] : []
})
// set cursor at end of caption field
.command(({ tr, commands }) => {
const { doc, selection } = tr
const position = doc.resolve(selection.to - 2).end()
return commands.setTextSelection(position)
})
.run()
)
},
imageToFigure:
() =>
({ tr, commands }) => {
const { doc, selection } = tr
const { from, to } = selection
const images = findChildrenInRange(doc, { from, to }, (node) => node.type.name === 'image')
if (!images.length) {
return false
}
const tracker = new Tracker(tr)
return commands.forEach(images, ({ node, pos }) => {
const mapResult = tracker.map(pos)
if (mapResult.deleted) {
return false
}
const range = {
from: mapResult.position,
to: mapResult.position + node.nodeSize
}
return commands.insertContentAt(range, {
type: this.name,
attrs: {
src: node.attrs.src
}
})
})
},
figureToImage:
() =>
({ tr, commands }) => {
const { doc, selection } = tr
const { from, to } = selection
const figures = findChildrenInRange(doc, { from, to }, (node) => node.type.name === this.name)
if (!figures.length) {
return false
}
const tracker = new Tracker(tr)
return commands.forEach(figures, ({ node, pos }) => {
const mapResult = tracker.map(pos)
if (mapResult.deleted) {
return false
}
const range = {
from: mapResult.position,
to: mapResult.position + node.nodeSize
}
return commands.insertContentAt(range, {
type: 'image',
attrs: {
src: node.attrs.src
}
})
})
}
}
},
addInputRules() {
return [
nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes: (match) => {
const [, src, alt, title] = match
return { src, alt, title }
}
})
]
}
})

View File

@ -1,3 +0,0 @@
.ImageView {
border: 1px solid royalblue;
}

View File

@ -1,12 +0,0 @@
import styles from './ImageView.module.scss'
import { ImageDisplay, updateAttrs } from '../../extensions/CustomImage'
import { Editor } from '@tiptap/core'
import { onMount } from 'solid-js'
type Props = {
editor: Editor
}
export const ImageView = (props: Props) => {
return <div class={styles.ImageView}>asdads</div>
}

View File

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