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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,9 +10,10 @@ import { handleFileUpload } from '../../../utils/handleFileUpload'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { verifyImg } from '../../../utils/verifyImg' import { verifyImg } from '../../../utils/verifyImg'
import { UploadedFile } from '../../../pages/types'
type Props = { type Props = {
onClose: (imgUrl?: string) => void onClose: (image?: UploadedFile) => void
} }
export const UploadModalContent = (props: Props) => { export const UploadModalContent = (props: Props) => {
@ -27,7 +28,7 @@ export const UploadModalContent = (props: Props) => {
try { try {
setIsUploading(true) setIsUploading(true)
const result = await handleFileUpload(file) const result = await handleFileUpload(file)
props.onClose(result.url) props.onClose(result)
setIsUploading(false) setIsUploading(false)
} catch (error) { } catch (error) {
setIsUploading(false) 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' import { mergeAttributes, Node } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
export interface FigureOptions {
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
figure: { Figure: {
/** setFigureFloat: (float: null | 'left' | 'right') => ReturnType
* 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 Figure = Node.create({
export const inputRegex = /!\[(.+|:?)]\((\S+)(?:\s+["'](\S+)["'])?\)/
export const Figure = Node.create<FigureOptions>({
name: 'figure', name: 'figure',
addOptions() { addOptions() {
return { return {
HTMLAttributes: {} HTMLAttributes: {}
} }
}, },
group: 'block', group: 'block',
content: 'block figcaption',
content: 'inline*',
draggable: true, draggable: true,
isolating: true, isolating: true,
addAttributes() { addAttributes() {
return { return {
src: { 'data-float': null
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() { parseHTML() {
return [ return [
{ {
tag: 'figure', tag: `figure[data-type="${this.name}"]`
contentElement: (dom: HTMLElement) =>
dom.querySelector('figcaption') ?? document.createElement('figcaption')
} }
] ]
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return ['figure', mergeAttributes(HTMLAttributes, { 'data-type': this.name }), 0]
},
addProseMirrorPlugins() {
return [ return [
'figure', new Plugin({
this.options.HTMLAttributes, props: {
['img', mergeAttributes(HTMLAttributes, { draggable: false, contenteditable: false })], handleDOMEvents: {
['figcaption', 0] // 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() { addCommands() {
return { return {
setFigure: setFigureFloat:
({ caption, ...attrs }) => (value) =>
({ chain }) => { ({ commands }) => {
return ( return commands.updateAttributes(this.name, { 'data-float': value })
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
}
})
}
)
} }
} }
},
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 { slugify } from '../../utils/slugify'
import { SolidSwiper } from '../_shared/SolidSwiper' import { SolidSwiper } from '../_shared/SolidSwiper'
import { DropArea } from '../_shared/DropArea' import { DropArea } from '../_shared/DropArea'
import { LayoutType, MediaItem } from '../../pages/types' import { LayoutType, MediaItem, UploadedFile } from '../../pages/types'
import { clone } from '../../utils/clone' import { clone } from '../../utils/clone'
import deepEqual from 'fast-deep-equal' import deepEqual from 'fast-deep-equal'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice' import { AutoSaveNotice } from '../Editor/AutoSaveNotice'

View File

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