diff --git a/packages/client/components/ReflectionCard/ReflectionCard.tsx b/packages/client/components/ReflectionCard/ReflectionCard.tsx index 9c622059c0e..ea36b9639f3 100644 --- a/packages/client/components/ReflectionCard/ReflectionCard.tsx +++ b/packages/client/components/ReflectionCard/ReflectionCard.tsx @@ -201,14 +201,16 @@ const ReflectionCard = (props: Props) => { } = useTooltip(MenuPosition.UPPER_CENTER) const handleEditorFocus = () => { if (isTempId(reflectionId)) return + if (reflection.isEditing) { + return + } + updateIsEditing(true) EditReflectionMutation(atmosphere, {isEditing: true, meetingId, promptId}) } const updateIsEditing = (isEditing: boolean) => { commitLocalUpdate(atmosphere, (store) => { - const reflection = store.get(reflectionId) - if (!reflection) return - reflection.setValue(isEditing, 'isEditing') + store.get(reflectionId)?.setValue(isEditing, 'isEditing') }) } @@ -244,16 +246,22 @@ const ReflectionCard = (props: Props) => { {content: contentStr, reflectionId}, {onError, onCompleted} ) - commitLocalUpdate(atmosphere, (store) => { - const reflection = store.get(reflectionId) - if (!reflection) return - reflection.setValue(false, 'isEditing') - }) } - const handleEditorBlur = () => { + const handleEditorBlur = (e: React.FocusEvent) => { if (isTempId(reflectionId)) return + const newFocusedElement = e.relatedTarget as Node + // don't trigger a blur if a button inside the element is clicked + if (e.currentTarget.contains(newFocusedElement)) return + const isClickInModal = !( + newFocusedElement === null || document.getElementById('root')?.contains(newFocusedElement) + ) + // If they clicked in a modal, then ignore the blur event, we'll refocus the editor after the modal closes + if (isClickInModal) { + return + } handleContentUpdate() + updateIsEditing(false) EditReflectionMutation(atmosphere, {isEditing: false, meetingId, promptId}) } diff --git a/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx b/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx index efdbbc69810..f845732767d 100644 --- a/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx +++ b/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx @@ -1,6 +1,5 @@ import styled from '@emotion/styled' import {useEventCallback} from '@mui/material' -import {generateHTML} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' import * as React from 'react' import {MutableRefObject, RefObject, useEffect, useRef, useState} from 'react' @@ -12,7 +11,6 @@ import usePortal from '../../hooks/usePortal' import {useTipTapReflectionEditor} from '../../hooks/useTipTapReflectionEditor' import CreateReflectionMutation from '../../mutations/CreateReflectionMutation' import EditReflectionMutation from '../../mutations/EditReflectionMutation' -import {serverTipTapExtensions} from '../../shared/tiptap/serverTipTapExtensions' import {Elevation} from '../../styles/elevation' import {BezierCurve, ZIndex} from '../../types/constEnums' import {cn} from '../../ui/cn' @@ -101,7 +99,7 @@ const PhaseItemEditor = (props: Props) => { const {top, left} = getBBox(phaseEditorRef.current)! const cardInFlight = { transform: `translate(${left}px,${top}px)`, - html: generateHTML(contentJSON, serverTipTapExtensions), + html: editor.getHTML(), key: content, isStart: true } diff --git a/packages/client/components/RetroReflectPhase/ReflectionStack.tsx b/packages/client/components/RetroReflectPhase/ReflectionStack.tsx index b631f68fcb1..2c90aa902c8 100644 --- a/packages/client/components/RetroReflectPhase/ReflectionStack.tsx +++ b/packages/client/components/RetroReflectPhase/ReflectionStack.tsx @@ -6,12 +6,7 @@ import {useFragment} from 'react-relay' import {ReflectionStack_meeting$key} from '~/__generated__/ReflectionStack_meeting.graphql' import {PhaseItemColumn_meeting$data} from '../../__generated__/PhaseItemColumn_meeting.graphql' import useExpandedReflections from '../../hooks/useExpandedReflections' -import { - Breakpoint, - ElementHeight, - ElementWidth, - ReflectionStackPerspective -} from '../../types/constEnums' +import {ElementWidth, ReflectionStackPerspective} from '../../types/constEnums' import ReflectionCard from '../ReflectionCard/ReflectionCard' import ExpandedReflectionStack from './ExpandedReflectionStack' import ReflectionStackPlaceholder from './ReflectionStackPlaceholder' @@ -26,22 +21,6 @@ interface Props { stackTopRef: RefObject } -const CardStack = styled('div')({ - alignItems: 'flex-start', - display: 'flex', - flex: 1, - margin: '0 0 24px', // stacked cards + row gutter = 6 + 6 + 12 = 24 - position: 'relative', - justifyContent: 'center', - [`@media screen and (min-width: ${Breakpoint.SINGLE_REFLECTION_COLUMN}px)`]: { - minHeight: ElementHeight.REFLECTION_CARD_MAX - } -}) - -const CenteredCardStack = styled('div')({ - position: 'relative' -}) - const ReflectionWrapper = styled('div')<{idx: number}>(({idx}): any => { const multiple = Math.min(idx, 2) const scaleX = @@ -94,9 +73,15 @@ const ReflectionStack = (props: Props) => { closePortal={collapse} /> )} +
- - +
+
{reflectionStack.map((reflection, idx) => { return ( { ) })} - - +
+
) diff --git a/packages/client/hooks/useBlockResizer.tsx b/packages/client/hooks/useBlockResizer.tsx new file mode 100644 index 00000000000..d5383b98dcc --- /dev/null +++ b/packages/client/hooks/useBlockResizer.tsx @@ -0,0 +1,69 @@ +import {useRef, type RefObject} from 'react' +import getIsDrag from '../utils/retroGroup/getIsDrag' +import useEventCallback from './useEventCallback' + +const makeDrag = () => ({ + isDrag: false, + startX: 0, + lastX: 0, + side: 'left' +}) +export const useBlockResizer = ( + width: number, + setWidth: (width: number) => void, + updateAttributes: (attrs: Record) => void, + aspectRatioRef: RefObject +) => { + const dragRef = useRef(makeDrag()) + const onMouseUp = useEventCallback((e: MouseEvent | TouchEvent) => { + if (e.type === 'touchend') { + document.removeEventListener('touchmove', onMouseMove) + } else { + document.removeEventListener('mousemove', onMouseMove) + } + const aspectRatio = aspectRatioRef.current! + updateAttributes({width, height: Math.round(width / aspectRatio)}) + dragRef.current = makeDrag() + }) + + const onMouseMove = useEventCallback((e: MouseEvent | TouchEvent) => { + // required to prevent address bar scrolling & other strange browser things on mobile view + e.preventDefault() + const isTouchMove = e.type === 'touchmove' + const {clientX} = isTouchMove ? (e as TouchEvent).touches[0]! : (e as MouseEvent) + const {current: drag} = dragRef + const wasDrag = drag.isDrag + if (!wasDrag) { + const isDrag = getIsDrag(clientX, 0, drag.startX, 0) + drag.isDrag = isDrag + if (!drag.isDrag) return + } + const sideCoefficient = drag.side === 'left' ? 1 : -1 + const delta = (drag.lastX - clientX) * sideCoefficient + drag.lastX = clientX + const nextWidth = Math.max(48, width + delta) + setWidth(nextWidth) + }) + + const onMouseDown = useEventCallback( + (side: 'left' | 'right') => + (e: React.MouseEvent | React.TouchEvent) => { + const isTouchStart = e.type === 'touchstart' + if (isTouchStart) { + document.addEventListener('touchmove', onMouseMove) + document.addEventListener('touchend', onMouseUp, {once: true}) + } else { + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp, {once: true}) + } + const {clientX} = isTouchStart + ? (e as React.TouchEvent).touches[0]! + : (e as React.MouseEvent) + dragRef.current.side = side + dragRef.current.startX = clientX + dragRef.current.lastX = clientX + dragRef.current.isDrag = false + } + ) + return {onMouseDown, onMouseMove, onMouseUp, width} +} diff --git a/packages/client/hooks/useTipTapReflectionEditor.ts b/packages/client/hooks/useTipTapReflectionEditor.ts index cb5ba7dbeac..9a493006083 100644 --- a/packages/client/hooks/useTipTapReflectionEditor.ts +++ b/packages/client/hooks/useTipTapReflectionEditor.ts @@ -70,7 +70,7 @@ export const useTipTapReflectionEditor = ( 'To-do list': false }), Focus, - ImageUpload.configure({editorWidth: ElementWidth.REFLECTION_CARD}), + ImageUpload.configure({editorWidth: ElementWidth.REFLECTION_CARD, editorHeight: 88}), ImageBlock, LoomExtension, Placeholder.configure({ diff --git a/packages/client/shared/tiptap/serverTipTapExtensions.ts b/packages/client/shared/tiptap/serverTipTapExtensions.ts index e3654722929..663cb8a7b25 100644 --- a/packages/client/shared/tiptap/serverTipTapExtensions.ts +++ b/packages/client/shared/tiptap/serverTipTapExtensions.ts @@ -5,7 +5,7 @@ import {TaskItem} from '@tiptap/extension-task-item' import {TaskList} from '@tiptap/extension-task-list' import StarterKit from '@tiptap/starter-kit' import {LoomExtension} from '../../components/promptResponse/loomExtension' -import ImageBlock from '../../tiptap/extensions/imageBlock/ImageBlock' +import {ImageBlockBase} from '../../tiptap/extensions/imageBlock/ImageBlockBase' import {tiptapTagConfig} from '../../utils/tiptapTagConfig' import {ImageUploadBase} from './extensions/ImageUploadBase' @@ -24,7 +24,7 @@ export const serverTipTapExtensions = [ nested: true }), ImageUploadBase, - ImageBlock, + ImageBlockBase, LoomExtension, Mention.configure(mentionConfig), Mention.extend({name: 'taskTag'}).configure(tiptapTagConfig), diff --git a/packages/client/styles/theme/global.css b/packages/client/styles/theme/global.css index b327dbc5a9e..1b6670252a1 100644 --- a/packages/client/styles/theme/global.css +++ b/packages/client/styles/theme/global.css @@ -235,17 +235,17 @@ } &.ProseMirror-selectednode::after { content: ''; - @apply absolute inset-0 h-full w-full rounded bg-[#2383e247]; + @apply pointer-events-none absolute inset-0 h-full w-full select-none rounded bg-[#2383e247]; } } .node-imageBlock { @apply relative; &.has-focus > div::after { content: ''; - @apply absolute inset-0 h-full w-full bg-[#2383e247]; + @apply pointer-events-none absolute inset-0 h-full w-full select-none bg-[#2383e247]; } & img { - @apply overflow-hidden rounded-md border-2 border-transparent; + @apply overflow-hidden rounded-md; } } } diff --git a/packages/client/tiptap/extensions/imageBlock/BlockResizer.tsx b/packages/client/tiptap/extensions/imageBlock/BlockResizer.tsx new file mode 100644 index 00000000000..07a44abf074 --- /dev/null +++ b/packages/client/tiptap/extensions/imageBlock/BlockResizer.tsx @@ -0,0 +1,21 @@ +import {cn} from '../../../ui/cn' + +interface Props { + className?: string + onMouseDown: (e: React.MouseEvent | React.TouchEvent) => void +} +export const BlockResizer = (props: Props) => { + const {className, onMouseDown} = props + return ( +
+
+
+ ) +} diff --git a/packages/client/tiptap/extensions/imageBlock/ImageBlock.ts b/packages/client/tiptap/extensions/imageBlock/ImageBlock.ts index 7a13c4c7d96..e574f6ee54d 100644 --- a/packages/client/tiptap/extensions/imageBlock/ImageBlock.ts +++ b/packages/client/tiptap/extensions/imageBlock/ImageBlock.ts @@ -1,28 +1,8 @@ -import {mergeAttributes, Range} from '@tiptap/core' -import {Image} from '@tiptap/extension-image' import {ReactNodeViewRenderer} from '@tiptap/react' +import {ImageBlockBase} from './ImageBlockBase' import {ImageBlockView} from './ImageBlockView' -declare module '@tiptap/core' { - interface Commands { - imageBlock: { - setImageBlock: (attributes: {src: string}) => ReturnType - setImageBlockAt: (attributes: {src: string; pos: number | Range}) => ReturnType - setImageBlockAlign: (align: 'left' | 'center' | 'right') => ReturnType - setImageBlockWidth: (width: number) => ReturnType - } - } -} - -export const ImageBlock = Image.extend({ - name: 'imageBlock', - - group: 'block', - - defining: true, - - isolating: true, - +export const ImageBlock = ImageBlockBase.extend({ addAttributes() { return { src: { @@ -32,11 +12,18 @@ export const ImageBlock = Image.extend({ src: attributes.src }) }, + height: { + default: '100%', + parseHTML: (element) => element.getAttribute('height'), + renderHTML: (attributes) => ({ + height: attributes.height + }) + }, width: { default: '100%', - parseHTML: (element) => element.getAttribute('data-width'), + parseHTML: (element) => element.getAttribute('width'), renderHTML: (attributes) => ({ - 'data-width': attributes.width + width: attributes.width }) }, align: { @@ -55,19 +42,6 @@ export const ImageBlock = Image.extend({ } } }, - - parseHTML() { - return [ - { - tag: 'img[src]:not([src^="data:"])' - } - ] - }, - - renderHTML({HTMLAttributes}) { - return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)] - }, - addCommands() { return { setImageBlock: @@ -90,7 +64,7 @@ export const ImageBlock = Image.extend({ setImageBlockWidth: (width) => ({commands}) => - commands.updateAttributes('imageBlock', {width: `${Math.max(0, Math.min(100, width))}%`}) + commands.updateAttributes('imageBlock', {width}) } }, diff --git a/packages/client/tiptap/extensions/imageBlock/ImageBlockBase.ts b/packages/client/tiptap/extensions/imageBlock/ImageBlockBase.ts new file mode 100644 index 00000000000..6339c2275dc --- /dev/null +++ b/packages/client/tiptap/extensions/imageBlock/ImageBlockBase.ts @@ -0,0 +1,41 @@ +import {mergeAttributes, Range} from '@tiptap/core' +import {Image} from '@tiptap/extension-image' + +declare module '@tiptap/core' { + interface Commands { + imageBlock: { + setImageBlock: (attributes: {src: string}) => ReturnType + setImageBlockAt: (attributes: {src: string; pos: number | Range}) => ReturnType + setImageBlockAlign: (align: 'left' | 'center' | 'right') => ReturnType + setImageBlockWidth: (width: number) => ReturnType + } + } +} + +export const ImageBlockBase = Image.extend({ + name: 'imageBlock', + + group: 'block', + + defining: true, + + isolating: true, + + parseHTML() { + return [ + { + tag: 'img[src]:not([src^="data:"])' + } + ] + }, + + renderHTML({HTMLAttributes}) { + const align = HTMLAttributes['data-align'] + const justify = align === 'left' ? 'start' : align === 'right' ? 'end' : 'center' + return [ + 'div', + {style: `width: 100%; display: flex; justify-content: ${justify};`}, + ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)] + ] + } +}) diff --git a/packages/client/tiptap/extensions/imageBlock/ImageBlockBubbleMenu.tsx b/packages/client/tiptap/extensions/imageBlock/ImageBlockBubbleMenu.tsx new file mode 100644 index 00000000000..4d7a2090aad --- /dev/null +++ b/packages/client/tiptap/extensions/imageBlock/ImageBlockBubbleMenu.tsx @@ -0,0 +1,44 @@ +import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter' +import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft' +import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight' + +interface Props { + updateAttributes: (attributes: Record) => void + align: 'left' | 'right' | 'center' + width: number +} + +const buttons = [ + {name: 'left', Icon: FormatAlignLeftIcon}, + {name: 'center', Icon: FormatAlignCenterIcon}, + {name: 'right', Icon: FormatAlignRightIcon} +] +export const ImageBlockBubbleMenu = (props: Props) => { + const {align, width, updateAttributes} = props + const scaleFactor = width > 100 ? 1 : width / 100 + return ( +
+ {buttons.map(({name, Icon}) => { + return ( + + ) + })} +
+ ) +} diff --git a/packages/client/tiptap/extensions/imageBlock/ImageBlockView.tsx b/packages/client/tiptap/extensions/imageBlock/ImageBlockView.tsx index 7efce8427ae..19295a9068c 100644 --- a/packages/client/tiptap/extensions/imageBlock/ImageBlockView.tsx +++ b/packages/client/tiptap/extensions/imageBlock/ImageBlockView.tsx @@ -1,41 +1,67 @@ -import {Node} from '@tiptap/pm/model' -import {Editor, NodeViewWrapper} from '@tiptap/react' -import {useCallback, useRef} from 'react' +import {NodeViewWrapper, type NodeViewProps} from '@tiptap/react' +import {useCallback, useEffect, useRef, useState} from 'react' +import {useBlockResizer} from '../../../hooks/useBlockResizer' import {cn} from '../../../ui/cn' - -interface ImageBlockViewProps { - editor: Editor - getPos: () => number - node: Node - updateAttributes: (attrs: Record) => void -} - -export const ImageBlockView = (props: ImageBlockViewProps) => { - const {editor, getPos, node} = props as ImageBlockViewProps & { - node: Node & { - attrs: { - src: string - } - } - } +import {BlockResizer} from './BlockResizer' +import {ImageBlockBubbleMenu} from './ImageBlockBubbleMenu' +export const ImageBlockView = (props: NodeViewProps) => { + const {editor, getPos, node, updateAttributes} = props const imageWrapperRef = useRef(null) - const {src} = node.attrs + const {src, align} = node.attrs - const wrapperClassName = cn( - node.attrs.align === 'left' ? 'ml-0' : 'ml-auto', - node.attrs.align === 'right' ? 'mr-0' : 'mr-auto', - node.attrs.align === 'center' && 'mx-auto' - ) + const alignClass = + align === 'left' ? 'justify-start' : align === 'right' ? 'justify-end' : 'justify-center' const onClick = useCallback(() => { editor.commands.setNodeSelection(getPos()) }, [getPos, editor.commands]) + const maxHeightRef = useRef(editor.storage.imageUpload.editorHeight) + const {current: maxHeight} = maxHeightRef + const aspectRatioRef = useRef(0) + const ref = useRef(null) + const [width, setWidth] = useState(node.attrs.width || 0) + const {onMouseDown} = useBlockResizer(width, setWidth, updateAttributes, aspectRatioRef) + const onMouseDownLeft = onMouseDown('left') + const onMouseDownRight = onMouseDown('right') + useEffect(() => { + if (width === node.attrs.width) return + // the attributes will change if another instance (e.g. a reflection in an expanded stack) edits them + setWidth(node.attrs.width) + }, [node.attrs.width]) return ( -
-
- +
+
+ { + const img = e.target as HTMLImageElement + console.log('loaded', img.width, img.height, maxHeightRef.current) + maxHeightRef.current = undefined + aspectRatioRef.current = img.width / img.height + if (img.width !== node.attrs.width) { + setWidth(img.width) + updateAttributes({width: img.width, height: img.height}) + } + }} + /> + {editor.isEditable && ( + <> + + + + + )}
diff --git a/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts b/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts index 6e77724823e..960ce5bc40d 100644 --- a/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts +++ b/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts @@ -3,16 +3,18 @@ import {EventEmitter} from 'eventemitter3' import {ImageUploadBase} from '../../../shared/tiptap/extensions/ImageUploadBase' import {ImageUploadView} from './ImageUploadView' -export const ImageUpload = ImageUploadBase.extend<{editorWidth: number}>({ +export const ImageUpload = ImageUploadBase.extend<{editorWidth: number; editorHeight: number}>({ addOptions() { return { - editorWidth: 300 + editorWidth: 300, + editorHeight: 112 } }, addStorage(this) { return { emitter: new EventEmitter(), - editorWidth: this.options.editorWidth + editorWidth: this.options.editorWidth, + editorHeight: this.options.editorHeight } },