Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions frontend/packages/editor/src/block-selection-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {useState} from 'react'
import {NodeSelection, TextSelection} from 'prosemirror-state'
import {Node as PMNode} from 'prosemirror-model'
import {BlockNoteEditor} from './blocknote/core/BlockNoteEditor'
import {Block} from './blocknote/core/extensions/Blocks/api/blockTypes'
import {getBlockInfoWithManualOffset} from './blocknote/core/extensions/Blocks/helpers/getBlockInfoFromPos'
import {MultipleNodeSelection} from './blocknote/core/extensions/SideMenu/MultipleNodeSelection'
import {useEditorSelectionChange} from './blocknote/react/hooks/useEditorSelectionChange'
import {HMBlockSchema} from './schema'
import {cn} from '@shm/ui/utils'
import './blockSelection.css'

function updateSelection(
editor: BlockNoteEditor<HMBlockSchema>,
block: Block<HMBlockSchema>,
setSelected: (selected: boolean) => void,
) {
const {view} = editor._tiptapEditor
const {selection} = view.state
let isSelected = false

if (selection instanceof NodeSelection) {
const selectedNode = view.state.doc.resolve(selection.from).parent
if (selectedNode && selectedNode.attrs && selectedNode.attrs.id === block.id) {
isSelected = true
}
} else if (selection instanceof MultipleNodeSelection) {
for (const node of selection.nodes) {
if (node.attrs && node.attrs.id === block.id) {
isSelected = true
break
}
}
} else if (selection instanceof TextSelection) {
const {from, to} = selection
view.state.doc.descendants((node: PMNode, pos: number) => {
if (node.type.name === 'blockNode' && node.attrs?.id === block.id) {
try {
const blockInfo = getBlockInfoWithManualOffset(node, pos)
const contentStart = blockInfo.blockContent.beforePos + 1
const contentEnd = blockInfo.blockContent.afterPos - 1
if (from <= contentStart && to >= contentEnd) {
isSelected = true
}
} catch {}
return false
}
return true
})
}

setSelected(isSelected)
}

export function BlockSelectionWrapper({
editor,
block,
children,
className,
}: {
editor: BlockNoteEditor<HMBlockSchema>
block: Block<HMBlockSchema>
children: React.ReactNode
className?: string
}) {
const [selected, setSelected] = useState(false)

useEditorSelectionChange(editor, () => updateSelection(editor, block, setSelected))

return (
<div contentEditable={false} className={cn(className, selected && 'bn-media-selected')}>
{children}
</div>
)
}
25 changes: 25 additions & 0 deletions frontend/packages/editor/src/blockSelection.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.bn-media-selected {
outline: 3px solid #3b82f6;
outline-offset: 2px;
border-radius: 6px;
}

.bn-media-selected .replace-btn,
.bn-media-selected .sel-btn {
opacity: 1;
}

.bn-media-selected .video-iframe {
pointer-events: auto;
}

[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='image']),
[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='video']),
[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='file']),
[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='embed']),
[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='web-embed']),
[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='button']),
[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='query']),
[data-node-type='blockNode'].bn-full-block-selected:has(> [data-content-type='math']) {
background-color: transparent;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {EventEmitter} from '../../shared/EventEmitter'
import {BlockSchema} from '../Blocks/api/blockTypes'
import {getBlockInfoWithManualOffset} from '../Blocks/helpers/getBlockInfoFromPos'
import {MultipleNodeSelection} from '../SideMenu/MultipleNodeSelection'
import {selectableNodeTypes} from '../BlockManipulation/BlockManipulationExtension'

import './full-block-selection.css'

Expand Down Expand Up @@ -129,12 +130,21 @@ function buildDecorations(doc: Node, blockIds: string[]): DecorationSet {

doc.descendants((node, pos) => {
if (node.type.name === 'blockNode' && idSet.has(node.attrs['id'] as string)) {
try {
const blockInfo = getBlockInfoWithManualOffset(node, pos)
if (selectableNodeTypes.includes(blockInfo.blockContentType)) {
return true
}
} catch {
// Not a valid block structure — apply decoration anyway.
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: DECORATION_CLASS,
}),
)
}
return true
})

return DecorationSet.create(doc, decorations)
Expand Down
47 changes: 22 additions & 25 deletions frontend/packages/editor/src/button-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {cn} from '@shm/ui/utils'
import {useEffect, useState} from 'react'
import type {BlockNoteEditor} from './blocknote/core/BlockNoteEditor'
import type {Block} from './blocknote/core/extensions/Blocks/api/blockTypes'
import {useEditorSelectionChange} from './blocknote/react/hooks/useEditorSelectionChange'
import {updateSelection} from './media-render'
import {BlockSelectionWrapper} from './block-selection-wrapper'
import type {HMBlockSchema} from './schema'

type ButtonAlignment = 'flex-start' | 'center' | 'flex-end'
Expand Down Expand Up @@ -47,16 +46,13 @@ export function ButtonBlockView({
const [alignment, setAlignment] = useState<ButtonAlignment>(
(block.props.alignment as ButtonAlignment) || 'flex-start',
)
const [, setSelected] = useState(false)
const openUrl = useOpenUrl()
const {canEdit, isEditing} = useEditorGate()

// Navigate when the document is being viewed (read-only). In edit mode the
// click should select/focus the block instead, mirroring `embed-block.tsx`.
const navigateOnClick = !canEdit || !isEditing

useEditorSelectionChange(editor, () => updateSelection(editor, block, setSelected))

useEffect(() => {
setAlignment(block.props.alignment as ButtonAlignment)
}, [block.props.alignment])
Expand All @@ -65,26 +61,27 @@ export function ButtonBlockView({
const handleClick = navigateOnClick && url ? () => openUrl(url) : undefined

return (
<div
className="flex w-full max-w-full flex-col select-none"
style={{
justifyContent: alignment || 'flex-start',
}}
contentEditable={false}
>
<Button
variant="brand"
size="lg"
className={cn(
'w-auto max-w-full justify-center border-none border-transparent text-center select-none',
alignment == 'center' ? 'self-center' : alignment == 'flex-end' ? 'self-end' : 'self-start',
)}
onClick={handleClick}
<BlockSelectionWrapper editor={editor} block={block}>
<div
className="flex w-full max-w-full flex-col select-none"
style={{
justifyContent: alignment || 'flex-start',
}}
>
<SizableText size="lg" className="truncate text-center font-sans font-bold text-white">
{block.props.name || 'Button Text'}
</SizableText>
</Button>
</div>
<Button
variant="brand"
size="lg"
className={cn(
'w-auto max-w-full justify-center border-none border-transparent text-center select-none',
alignment == 'center' ? 'self-center' : alignment == 'flex-end' ? 'self-end' : 'self-start',
)}
onClick={handleClick}
>
<SizableText size="lg" className="truncate text-center font-sans font-bold text-white">
{block.props.name || 'Button Text'}
</SizableText>
</Button>
</div>
</BlockSelectionWrapper>
)
}
13 changes: 8 additions & 5 deletions frontend/packages/editor/src/embed-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {useDraftActions} from './draft-actions-context'
import {EmbedEditorView} from './embed-editor'
import {MediaContainer} from './media-container'
import {DisplayComponentProps, MediaRender, MediaType} from './media-render'
import {BlockSelectionWrapper} from './block-selection-wrapper'
import {HMBlockSchema} from './schema'

export const EmbedBlock = createReactBlockSpec({
Expand Down Expand Up @@ -214,7 +215,11 @@ const Render = (block: Block<HMBlockSchema>, editor: BlockNoteEditor<HMBlockSche
// When the embed points at an unpublished child draft,
// render a placeholder card instead of the URL input form.
if (block.props.draftId) {
return <DraftEmbedPlaceholder draftId={block.props.draftId} editor={editor} blockId={block.id} />
return (
<BlockSelectionWrapper editor={editor} block={block}>
<DraftEmbedPlaceholder draftId={block.props.draftId} editor={editor} blockId={block.id} />
</BlockSelectionWrapper>
)
}
const gwUrl = useGatewayUrlStream()
const submitEmbed = async (url: string, assign: any, setFileName: any, setLoading: any) => {
Expand Down Expand Up @@ -277,15 +282,13 @@ const Render = (block: Block<HMBlockSchema>, editor: BlockNoteEditor<HMBlockSche
)
}

const EmbedDisplay = ({editor, block, assign, selected, setSelected}: DisplayComponentProps) => {
const EmbedDisplay = ({editor, block, assign}: DisplayComponentProps) => {
const {canEdit, isEditing} = useEditorGate()
return (
<MediaContainer
editor={editor}
block={block}
mediaType="embed"
selected={selected}
setSelected={setSelected}
assign={assign}
// styleProps={{
// pointerEvents: activeId && activeId !== block.id ? 'none' : '',
Expand Down Expand Up @@ -528,7 +531,7 @@ export const EmbedLauncherInput = ({
setFocusedIndex((prev) => (prev - 1 + activeItems.length) % activeItems.length)
}
}}
className="border-muted-foreground/30 focus-visible:border-ring text-foreground w-full"
className="border-muted-foreground/30 focus-visible:border-ring text-foreground placeholder:text-foreground/50 w-full"
/>

{content}
Expand Down
14 changes: 3 additions & 11 deletions frontend/packages/editor/src/file.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const Render = (block: Block<HMBlockSchema>, editor: BlockNoteEditor<HMBlockSche
)
}

const FileDisplay = ({editor, block, selected, setSelected, assign}: DisplayComponentProps) => {
const FileDisplay = ({editor, block, assign}: DisplayComponentProps) => {
const {saveCidAsFile} = useUniversalAppContext()
const {isEditing} = useEditorGate()
const url: string = block.props.url || ''
Expand All @@ -68,14 +68,7 @@ const FileDisplay = ({editor, block, selected, setSelected, assign}: DisplayComp
const showDownload = !!fileCid && !isEditing

return (
<MediaContainer
editor={editor}
block={block}
mediaType="file"
selected={selected}
setSelected={setSelected}
assign={assign}
>
<MediaContainer editor={editor} block={block} mediaType="file" assign={assign}>
<div className="group relative w-full">
<Button className="w-full justify-start px-4 py-3 select-none" disabled>
<File className="size-4 shrink-0" />
Expand All @@ -92,8 +85,7 @@ const FileDisplay = ({editor, block, selected, setSelected, assign}: DisplayComp
variant="accent"
size="xs"
className={cn(
'absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100',
selected && 'opacity-100',
'sel-btn absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100',
)}
asChild
>
Expand Down
4 changes: 1 addition & 3 deletions frontend/packages/editor/src/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ const Render = (block: Block<HMBlockSchema>, editor: BlockNoteEditor<HMBlockSche
)
}

const ImageDisplay = ({editor, block, selected, setSelected, assign}: DisplayComponentProps) => {
const ImageDisplay = ({editor, block, assign}: DisplayComponentProps) => {
const getImageUrl = useImageUrl()
const {canEdit} = useEditorGate()

Expand Down Expand Up @@ -401,8 +401,6 @@ const ImageDisplay = ({editor, block, selected, setSelected, assign}: DisplayCom
editor={editor}
block={block}
mediaType="image"
selected={selected}
setSelected={setSelected}
assign={assign}
onHoverIn={() => {
// Suppress resize handles in viewer render type (discussion panel)
Expand Down
9 changes: 1 addition & 8 deletions frontend/packages/editor/src/media-container.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,7 @@ afterEach(() => {
function renderImageContainer(editor: ReturnType<typeof makeEditor>) {
act(() => {
root.render(
<MediaContainer
editor={editor}
block={makeBlock()}
mediaType="image"
selected={false}
setSelected={() => {}}
assign={() => {}}
>
<MediaContainer editor={editor} block={makeBlock()} mediaType="image" assign={() => {}}>
<div data-testid="media-child" />
</MediaContainer>,
)
Expand Down
20 changes: 3 additions & 17 deletions frontend/packages/editor/src/media-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ interface ContainerProps {
block: Block<HMBlockSchema>
mediaType: string
styleProps?: Object
selected: boolean
setSelected: any
assign: any
children: any
onHoverIn?: () => void
Expand All @@ -33,8 +31,6 @@ export const MediaContainer = ({
block,
mediaType,
styleProps,
selected,
setSelected,
assign,
children,
onHoverIn,
Expand Down Expand Up @@ -121,7 +117,6 @@ export const MediaContainer = ({
e.preventDefault()
e.stopPropagation()
setDrag(false)
if (selected) setSelected(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = Array.from(e.dataTransfer.files)[0]
// @ts-ignore
Expand All @@ -147,23 +142,15 @@ export const MediaContainer = ({
},
onDragEnter: (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer && e.dataTransfer.types && Array.from(e.dataTransfer.types).includes('Files')) {
const relatedTarget = e.relatedTarget as HTMLElement
e.preventDefault()
e.stopPropagation()
setDrag(true)
if ((!relatedTarget || !e.currentTarget.contains(relatedTarget)) && e.dataTransfer.effectAllowed !== 'move') {
setSelected(true)
}
}
},
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement
e.preventDefault()
e.stopPropagation()
setDrag(false)
if ((!relatedTarget || !e.currentTarget.contains(relatedTarget)) && e.dataTransfer.effectAllowed !== 'move') {
setSelected(false)
}
},
}

Expand Down Expand Up @@ -226,9 +213,9 @@ export const MediaContainer = ({
className={cn(
'group relative flex max-w-full flex-col rounded-md border-2 transition-colors',
mediaType === 'file' ? 'w-full' : 'w-full',
drag || selected ? 'border-foreground/20 dark:border-foreground/30' : 'border-border',
drag ? 'border-foreground/20 dark:border-foreground/30' : 'border-border',
drag && 'border-dashed',
editor.commentEditor && !drag && !selected ? 'bg-black/5 dark:bg-white/10' : 'bg-muted',
editor.commentEditor && !drag ? 'bg-black/5 dark:bg-white/10' : 'bg-muted',
className ?? block.type,
)}
style={{width}}
Expand All @@ -254,8 +241,7 @@ export const MediaContainer = ({
variant="accent"
size="xs"
className={cn(
'absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100',
selected && 'opacity-100',
'replace-btn absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100',
)}
onClick={() => fileInputRef.current?.click()}
>
Expand Down
Loading
Loading