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 (
@@ -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()} > @@ -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()} > @@ -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()} > @@ -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() }} > {t('Add signature')} @@ -67,6 +76,14 @@ export const FigureBubbleMenu = (props: Props) => { )} + + + { + handleUpload(value) + }} + /> +
) } 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() +}