Feature/editor figure refactoring (#165)

New Figure (have delete bug)
This commit is contained in:
Ilya Y 2023-08-15 12:38:49 +03:00 committed by GitHub
parent a94b8343b4
commit 3b0c3789df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 189 additions and 217 deletions

View File

@ -3,6 +3,10 @@ import styles from './BubbleMenu.module.scss'
import { Icon } from '../../_shared/Icon'
import { useLocalize } from '../../../context/localize'
import { Popover } from '../../_shared/Popover'
import { UploadModalContent } from '../UploadModalContent'
import { Modal } from '../../Nav/Modal'
import { UploadedFile } from '../../../pages/types'
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
type Props = {
editor: Editor
@ -11,6 +15,11 @@ type Props = {
export const FigureBubbleMenu = (props: Props) => {
const { t } = useLocalize()
const handleUpload = (image: UploadedFile) => {
renderUploadedImage(props.editor, image)
}
return (
<div ref={props.ref} class={styles.BubbleMenu}>
<Popover content={t('Alignment left')}>
@ -19,7 +28,7 @@ export const FigureBubbleMenu = (props: Props) => {
ref={triggerRef}
type="button"
class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setImageFloat('left').run()}
onClick={() => props.editor.chain().focus().setFigcaptionFocus(true).run()}
>
<Icon name="editor-image-align-left" />
</button>
@ -31,7 +40,7 @@ export const FigureBubbleMenu = (props: Props) => {
ref={triggerRef}
type="button"
class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setImageFloat(null).run()}
onClick={() => props.editor.chain().focus().setFigureFloat(null).run()}
>
<Icon name="editor-image-align-center" />
</button>
@ -43,7 +52,7 @@ export const FigureBubbleMenu = (props: Props) => {
ref={triggerRef}
type="button"
class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setImageFloat('right').run()}
onClick={() => props.editor.chain().focus().setFigureFloat('right').run()}
>
<Icon name="editor-image-align-right" />
</button>
@ -54,7 +63,7 @@ export const FigureBubbleMenu = (props: Props) => {
type="button"
class={styles.bubbleMenuButton}
onClick={() => {
props.editor.chain().focus().imageToFigure().run()
props.editor.chain().focus().setFigcaptionFocus(true).run()
}}
>
<span style={{ color: 'white' }}>{t('Add signature')}</span>
@ -67,6 +76,14 @@ export const FigureBubbleMenu = (props: Props) => {
</button>
)}
</Popover>
<Modal variant="narrow" name="uploadImage">
<UploadModalContent
onClose={(value) => {
handleUpload(value)
}}
/>
</Modal>
</div>
)
}

View File

@ -29,18 +29,15 @@ import { Paragraph } from '@tiptap/extension-paragraph'
import Focus from '@tiptap/extension-focus'
import { Collaboration } from '@tiptap/extension-collaboration'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { CustomImage } from './extensions/CustomImage'
import { CustomBlockquote } from './extensions/CustomBlockquote'
import { Figure } from './extensions/Figure'
import { Figcaption } from './extensions/Figcaption'
import { Embed } from './extensions/Embed'
import { useSession } from '../../context/session'
import { useLocalize } from '../../context/localize'
import { useEditorContext } from '../../context/editor'
import { TrailingNode } from './extensions/TrailingNode'
import Article from './extensions/Article'
import { TextBubbleMenu } from './TextBubbleMenu'
import { FigureBubbleMenu, BlockquoteBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
@ -49,6 +46,7 @@ import { TableOfContents } from '../TableOfContents'
import { isDesktop } from '../../utils/media-query'
import './Prosemirror.scss'
import { Image } from '@tiptap/extension-image'
type Props = {
shoutId: number
@ -112,6 +110,12 @@ export const Editor = (props: Props) => {
} = {
current: null
}
const ImageFigure = Figure.extend({
name: 'capturedImage',
content: 'figcaption image'
})
const { initialContent } = props
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
@ -166,12 +170,9 @@ export const Editor = (props: Props) => {
class: 'highlight'
}
}),
CustomImage.configure({
HTMLAttributes: {
class: 'uploadedImage'
}
}),
Figure,
ImageFigure,
Image,
Figcaption,
Embed,
CharacterCount,
BubbleMenu.configure({
@ -181,8 +182,7 @@ export const Editor = (props: Props) => {
const { doc, selection } = state
const { empty } = selection
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
setIsCommonMarkup(e.isActive('figure'))
setIsCommonMarkup(e.isActive('figcaption'))
return view.hasFocus() && !empty && !isEmptyTextBlock && !e.isActive('image')
},
tippyOptions: {

View File

@ -12,6 +12,8 @@ import { hideModal, showModal } from '../../../stores/ui'
import { UploadModalContent } from '../UploadModalContent'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { imageProxy } from '../../../utils/imageProxy'
import { UploadedFile } from '../../../pages/types'
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
type FloatingMenuProps = {
editor: Editor
@ -76,13 +78,8 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
}
})
const renderImage = (src: string) => {
props.editor
.chain()
.focus()
.setImage({ src: imageProxy(src) })
.run()
hideModal()
const handleUpload = (image: UploadedFile) => {
renderUploadedImage(props.editor, image)
}
return (
@ -116,7 +113,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
<Modal variant="narrow" name="uploadImage" onClose={closeUploadModalHandler}>
<UploadModalContent
onClose={(value) => {
renderImage(value)
handleUpload(value)
setSelectedMenuItem()
}}
/>

View File

@ -37,7 +37,6 @@
.articleEditor blockquote,
.articleEditor figure,
.articleEditor .uploadedImage,
.articleEditor article[data-type='incut'] {
@media (width >= 768px) {
margin-left: calc(21.9% + 3px) !important;
@ -124,13 +123,6 @@
}
}
.uploadedImage {
max-height: 80vh;
margin: auto;
display: block;
width: unset !important;
}
.horizontalRule {
border-top: 2px solid #000;
}
@ -330,3 +322,7 @@ mark.highlight {
box-shadow: 0 0 0 1px #000;
}
}
figure[data-type='capturedImage'] {
flex-direction: column-reverse;
}

View File

@ -43,12 +43,6 @@
}
}
.uploadedImage {
max-height: 60vh;
margin: auto;
display: block;
}
.controls {
margin-top: auto;
display: flex;

View File

@ -19,7 +19,6 @@ import { Italic } from '@tiptap/extension-italic'
import { Modal } from '../Nav/Modal'
import { hideModal, showModal } from '../../stores/ui'
import { Blockquote } from '@tiptap/extension-blockquote'
import { CustomImage } from './extensions/CustomImage'
import { UploadModalContent } from './UploadModalContent'
import { imageProxy } from '../../utils/imageProxy'
import { clsx } from 'clsx'
@ -27,6 +26,8 @@ import styles from './SimplifiedEditor.module.scss'
import { Placeholder } from '@tiptap/extension-placeholder'
import { InsertLinkForm } from './InsertLinkForm'
import { Link } from '@tiptap/extension-link'
import { UploadedFile } from '../../pages/types'
import { Figure } from './extensions/Figure'
type Props = {
initialContent?: string
@ -54,6 +55,11 @@ const SimplifiedEditor = (props: Props) => {
actions: { setEditor }
} = useEditorContext()
const ImageFigure = Figure.extend({
name: 'capturedImage',
content: 'figcaption image'
})
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
editorProps: {
@ -75,11 +81,7 @@ const SimplifiedEditor = (props: Props) => {
class: styles.blockQuote
}
}),
CustomImage.configure({
HTMLAttributes: {
class: styles.uploadedImage
}
}),
ImageFigure,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
@ -106,11 +108,30 @@ const SimplifiedEditor = (props: Props) => {
const isLink = isActive('link')
const isBlockquote = isActive('blockquote')
const renderImage = (src: string) => {
const renderImage = (image: UploadedFile) => {
editor()
.chain()
.focus()
.setImage({ src: imageProxy(src) })
.insertContent({
type: 'capturedImage',
content: [
{
type: 'figcaption',
content: [
{
type: 'text',
text: image.originalFilename
}
]
},
{
type: 'image',
attrs: {
src: imageProxy(image.url)
}
}
]
})
.run()
hideModal()
}

View File

@ -10,9 +10,10 @@ import { handleFileUpload } from '../../../utils/handleFileUpload'
import { useLocalize } from '../../../context/localize'
import { Loading } from '../../_shared/Loading'
import { verifyImg } from '../../../utils/verifyImg'
import { UploadedFile } from '../../../pages/types'
type Props = {
onClose: (imgUrl?: string) => void
onClose: (image?: UploadedFile) => void
}
export const UploadModalContent = (props: Props) => {
@ -27,7 +28,7 @@ export const UploadModalContent = (props: Props) => {
try {
setIsUploading(true)
const result = await handleFileUpload(file)
props.onClose(result.url)
props.onClose(result)
setIsUploading(false)
} catch (error) {
setIsUploading(false)

View File

@ -0,0 +1,45 @@
import { mergeAttributes, Node } from '@tiptap/core'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
Figcaption: {
setFigcaptionFocus: (value: boolean) => ReturnType
}
}
}
export const Figcaption = Node.create({
name: 'figcaption',
addOptions() {
return {
HTMLAttributes: {}
}
},
content: 'inline*',
selectable: false,
draggable: false,
parseHTML() {
return [
{
tag: 'figcaption'
}
]
},
renderHTML({ HTMLAttributes }) {
return ['figcaption', mergeAttributes(HTMLAttributes), 0]
},
addCommands() {
return {
setFigcaptionFocus:
(value) =>
({ commands }) => {
return commands.focus(value)
}
}
}
})

View File

@ -1,205 +1,73 @@
import { findChildrenInRange, mergeAttributes, Node, nodeInputRule, Tracker } from '@tiptap/core'
export interface FigureOptions {
HTMLAttributes: Record<string, any>
}
import { mergeAttributes, Node } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
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
Figure: {
setFigureFloat: (float: null | 'left' | 'right') => ReturnType
}
}
}
export const inputRegex = /!\[(.+|:?)]\((\S+)(?:\s+["'](\S+)["'])?\)/
export const Figure = Node.create<FigureOptions>({
export const Figure = Node.create({
name: 'figure',
addOptions() {
return {
HTMLAttributes: {}
}
},
group: 'block',
content: 'inline*',
content: 'block figcaption',
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')
}
'data-float': null
}
},
parseHTML() {
return [
{
tag: 'figure',
contentElement: (dom: HTMLElement) =>
dom.querySelector('figcaption') ?? document.createElement('figcaption')
tag: `figure[data-type="${this.name}"]`
}
]
},
renderHTML({ HTMLAttributes }) {
return ['figure', mergeAttributes(HTMLAttributes, { 'data-type': this.name }), 0]
},
addProseMirrorPlugins() {
return [
'figure',
this.options.HTMLAttributes,
['img', mergeAttributes(HTMLAttributes, { draggable: false, contenteditable: false })],
['figcaption', 0]
new Plugin({
props: {
handleDOMEvents: {
// prevent dragging nodes out of the figure
dragstart: (view, event) => {
if (!event.target) {
return false
}
const pos = view.posAtDOM(event.target as HTMLElement, 0)
const $pos = view.state.doc.resolve(pos)
if ($pos.parent.type === this.type) {
event.preventDefault()
}
return false
}
}
}
})
]
},
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:
() =>
// eslint-disable-next-line unicorn/consistent-function-scoping
({ tr, commands }) => {
const { doc, selection } = tr
const { from, to } = selection
const images = findChildrenInRange(doc, { from, to }, (node) => node.type.name === 'image')
if (images.length === 0) {
return false
}
const tracker = new Tracker(tr)
return commands.forEach(
// eslint-disable-next-line unicorn/no-array-callback-reference
images,
// eslint-disable-next-line unicorn/no-array-method-this-argument
({ node, pos }) => {
// eslint-disable-next-line unicorn/no-array-callback-reference
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
},
content: [{ type: 'text', text: node.attrs.src }]
})
}
)
},
figureToImage:
() =>
// eslint-disable-next-line unicorn/consistent-function-scoping
({ 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 === 0) {
return false
}
const tracker = new Tracker(tr)
return commands.forEach(
// eslint-disable-next-line unicorn/no-array-callback-reference
figures,
// eslint-disable-next-line unicorn/no-array-method-this-argument
({ node, pos }) => {
// eslint-disable-next-line unicorn/no-array-callback-reference
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
}
})
}
)
setFigureFloat:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })
}
}
},
addInputRules() {
return [
nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes: (match) => {
const [, src, alt, title] = match
return { src, alt, title }
}
})
]
}
})

View File

@ -15,7 +15,7 @@ import { AudioUploader } from '../Editor/AudioUploader'
import { slugify } from '../../utils/slugify'
import { SolidSwiper } from '../_shared/SolidSwiper'
import { DropArea } from '../_shared/DropArea'
import { LayoutType, MediaItem } from '../../pages/types'
import { LayoutType, MediaItem, UploadedFile } from '../../pages/types'
import { clone } from '../../utils/clone'
import deepEqual from 'fast-deep-equal'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice'

View File

@ -18,6 +18,7 @@ import { redirectPage } from '@nanostores/router'
import { router } from '../../../stores/router'
import { GrowingTextarea } from '../../_shared/GrowingTextarea'
import { createStore } from 'solid-js/store'
import { UploadedFile } from '../../../pages/types'
type Props = {
shoutId: number
@ -60,9 +61,9 @@ export const PublishSettings = (props: Props) => {
const [settingsForm, setSettingsForm] = createStore(initialData)
const [topics, setTopics] = createSignal<Topic[]>(null)
const handleUploadModalContentCloseSetCover = (imgUrl: string) => {
const handleUploadModalContentCloseSetCover = (image: UploadedFile) => {
hideModal()
setSettingsForm('coverImageUrl', imgUrl)
setSettingsForm('coverImageUrl', image.url)
}
const handleDeleteCoverImage = () => {
setSettingsForm('coverImageUrl', '')

View File

@ -0,0 +1,32 @@
import { UploadedFile } from '../pages/types'
import { imageProxy } from './imageProxy'
import { hideModal } from '../stores/ui'
import { Editor } from '@tiptap/core'
export const renderUploadedImage = (editor: Editor, image: UploadedFile) => {
editor
.chain()
.focus()
.insertContent({
type: 'capturedImage',
content: [
{
type: 'figcaption',
content: [
{
type: 'text',
text: image.originalFilename
}
]
},
{
type: 'image',
attrs: {
src: imageProxy(image.url)
}
}
]
})
.run()
hideModal()
}