diff --git a/src/components/Editor/BubbleMenu/FigureBubbleMenu.tsx b/src/components/Editor/BubbleMenu/FigureBubbleMenu.tsx
index 9ba4f6ec..6cc0dbb0 100644
--- a/src/components/Editor/BubbleMenu/FigureBubbleMenu.tsx
+++ b/src/components/Editor/BubbleMenu/FigureBubbleMenu.tsx
@@ -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 (
)
}
diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx
index 32c57f5d..adddf04b 100644
--- a/src/components/Editor/Editor.tsx
+++ b/src/components/Editor/Editor.tsx
@@ -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: {
diff --git a/src/components/Editor/EditorFloatingMenu/EditorFloatingMenu.tsx b/src/components/Editor/EditorFloatingMenu/EditorFloatingMenu.tsx
index ff92180d..c8150f04 100644
--- a/src/components/Editor/EditorFloatingMenu/EditorFloatingMenu.tsx
+++ b/src/components/Editor/EditorFloatingMenu/EditorFloatingMenu.tsx
@@ -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) => {
{
- renderImage(value)
+ handleUpload(value)
setSelectedMenuItem()
}}
/>
diff --git a/src/components/Editor/Prosemirror.scss b/src/components/Editor/Prosemirror.scss
index 8851f4be..6c36272a 100644
--- a/src/components/Editor/Prosemirror.scss
+++ b/src/components/Editor/Prosemirror.scss
@@ -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;
+}
diff --git a/src/components/Editor/SimplifiedEditor.module.scss b/src/components/Editor/SimplifiedEditor.module.scss
index a48b531d..7f15041b 100644
--- a/src/components/Editor/SimplifiedEditor.module.scss
+++ b/src/components/Editor/SimplifiedEditor.module.scss
@@ -43,12 +43,6 @@
}
}
- .uploadedImage {
- max-height: 60vh;
- margin: auto;
- display: block;
- }
-
.controls {
margin-top: auto;
display: flex;
diff --git a/src/components/Editor/SimplifiedEditor.tsx b/src/components/Editor/SimplifiedEditor.tsx
index b73beb77..8114cdf3 100644
--- a/src/components/Editor/SimplifiedEditor.tsx
+++ b/src/components/Editor/SimplifiedEditor.tsx
@@ -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()
}
diff --git a/src/components/Editor/UploadModalContent/UploadModalContent.tsx b/src/components/Editor/UploadModalContent/UploadModalContent.tsx
index c5c91890..78ac6127 100644
--- a/src/components/Editor/UploadModalContent/UploadModalContent.tsx
+++ b/src/components/Editor/UploadModalContent/UploadModalContent.tsx
@@ -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)
diff --git a/src/components/Editor/extensions/Figcaption.ts b/src/components/Editor/extensions/Figcaption.ts
new file mode 100644
index 00000000..592f2430
--- /dev/null
+++ b/src/components/Editor/extensions/Figcaption.ts
@@ -0,0 +1,45 @@
+import { mergeAttributes, Node } from '@tiptap/core'
+
+declare module '@tiptap/core' {
+ interface Commands {
+ 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)
+ }
+ }
+ }
+})
diff --git a/src/components/Editor/extensions/Figure.ts b/src/components/Editor/extensions/Figure.ts
index c4fd4565..d90d0997 100644
--- a/src/components/Editor/extensions/Figure.ts
+++ b/src/components/Editor/extensions/Figure.ts
@@ -1,205 +1,73 @@
-import { findChildrenInRange, mergeAttributes, Node, nodeInputRule, Tracker } from '@tiptap/core'
-
-export interface FigureOptions {
- HTMLAttributes: Record
-}
+import { mergeAttributes, Node } from '@tiptap/core'
+import { Plugin } from '@tiptap/pm/state'
declare module '@tiptap/core' {
interface Commands {
- 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({
+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 }
- }
- })
- ]
}
})
diff --git a/src/components/Views/Edit.tsx b/src/components/Views/Edit.tsx
index d6045d6b..ca96985d 100644
--- a/src/components/Views/Edit.tsx
+++ b/src/components/Views/Edit.tsx
@@ -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'
diff --git a/src/components/Views/PublishSettings/PublishSettings.tsx b/src/components/Views/PublishSettings/PublishSettings.tsx
index 4dce0a32..db448aa5 100644
--- a/src/components/Views/PublishSettings/PublishSettings.tsx
+++ b/src/components/Views/PublishSettings/PublishSettings.tsx
@@ -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(null)
- const handleUploadModalContentCloseSetCover = (imgUrl: string) => {
+ const handleUploadModalContentCloseSetCover = (image: UploadedFile) => {
hideModal()
- setSettingsForm('coverImageUrl', imgUrl)
+ setSettingsForm('coverImageUrl', image.url)
}
const handleDeleteCoverImage = () => {
setSettingsForm('coverImageUrl', '')
diff --git a/src/utils/renderUploadedImage.ts b/src/utils/renderUploadedImage.ts
new file mode 100644
index 00000000..30aa4749
--- /dev/null
+++ b/src/utils/renderUploadedImage.ts
@@ -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()
+}