floating image + figure
This commit is contained in:
parent
9a8a358a8e
commit
4d31dcf2c3
|
@ -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({
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
190
src/components/Editor/extensions/Figure.ts
Normal file
190
src/components/Editor/extensions/Figure.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
|
@ -1,3 +0,0 @@
|
||||||
.ImageView {
|
|
||||||
border: 1px solid royalblue;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export { ImageView } from './ImageView'
|
|
Loading…
Reference in New Issue
Block a user