Skip to content

Commit 7bcacc7

Browse files
committed
fix(rich-markdown-editor): reliable image selection + resize and broken-image polish
- Reactive editability. The editor runs with shouldRerenderOnTransaction:false, so a node view that read editor.isEditable once at render kept a stale value after setEditable() toggled (e.g. an agent stream settling into the doc), leaving a pasted image showing read-only affordances and code blocks stuck on their read-only label until a full refresh. A shared useEditorEditable hook subscribes to the editor's update/transaction events so both node views track editability reactively. - Deterministic click-to-select. A handleClickOn plugin sets the image's NodeSelection on a plain click so selecting never depends on ProseMirror's click-vs-drag arbitration; grab-anywhere drag-reorder is kept, and modified clicks (Cmd/Ctrl to follow a linked badge) fall through. - Resize commits once. The width previews in local state during the drag and commits to the node once on pointer-up (or pointer-cancel), so a resize is a single undo step and an interrupted drag isn't lost. - Broken-image placeholder. A src that fails to load renders as a visible box with its alt text and stays selectable, instead of collapsing to a bare broken-icon.
1 parent f3582ed commit 7bcacc7

3 files changed

Lines changed: 95 additions & 19 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap
1515
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
1616
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
1717
import { detectLanguage } from './detect-language'
18+
import { useEditorEditable } from './use-editor-editable'
1819

1920
const PLAIN = 'plain'
2021
const MERMAID = 'mermaid'
@@ -59,6 +60,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
5960
const [editingInline, setEditingInline] = useState(false)
6061
const [peekSource, setPeekSource] = useState(false)
6162
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
63+
const editable = useEditorEditable(editor)
6264

6365
const explicitLanguage = node.attrs.language as string | null
6466
const text = node.textContent
@@ -68,7 +70,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
6870
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
6971
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
7072
useEffect(() => {
71-
if (!isMermaid || !editor.isEditable) {
73+
if (!isMermaid || !editable) {
7274
setEditingInline(false)
7375
return
7476
}
@@ -91,9 +93,9 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
9193
editor.off('focus', sync)
9294
editor.off('blur', sync)
9395
}
94-
}, [editor, getPos, isMermaid])
96+
}, [editor, getPos, isMermaid, editable])
9597

96-
const showSource = editor.isEditable ? editingInline : peekSource
98+
const showSource = editable ? editingInline : peekSource
9799
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
98100

99101
// Skip language detection on the mermaid path — the picker/label never render there.
@@ -104,7 +106,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
104106
'Plain text'
105107

106108
const toggleSource = () => {
107-
if (!editor.isEditable) {
109+
if (!editable) {
108110
setPeekSource((value) => !value)
109111
return
110112
}
@@ -144,7 +146,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
144146
</button>
145147
)}
146148
{!isMermaid &&
147-
(editor.isEditable ? (
149+
(editable ? (
148150
// Editable: a language picker. Read-only: a static label — selecting a language calls
149151
// updateAttributes, which would mutate a doc that must not change.
150152
<DropdownMenu onOpenChange={setMenuOpen}>
@@ -179,7 +181,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
179181
{label}
180182
</span>
181183
))}
182-
{!isMermaid && editor.isEditable && (
184+
{!isMermaid && editable && (
183185
<button
184186
type='button'
185187
aria-label='Toggle line wrap'

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { useEffect, useRef, useState } from 'react'
22
import { cn } from '@sim/emcn'
33
import type { JSONContent } from '@tiptap/core'
44
import { Image } from '@tiptap/extension-image'
5+
import { NodeSelection, Plugin } from '@tiptap/pm/state'
56
import type { ReactNodeViewProps } from '@tiptap/react'
67
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
78
import { useFileContentSource } from '@/hooks/use-file-content-source'
89
import { normalizeLinkHref } from './markdown-fidelity'
10+
import { useEditorEditable } from './use-editor-editable'
911

1012
const MIN_WIDTH = 64
1113

@@ -168,7 +170,12 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
168170
const source = useFileContentSource()
169171
const imageRef = useRef<HTMLImageElement>(null)
170172
const dragAbortRef = useRef<AbortController | null>(null)
173+
const dragWidthRef = useRef<number | null>(null)
171174
const [dragging, setDragging] = useState(false)
175+
/** Live width during a resize drag; kept out of the doc so the whole resize is one undo step. */
176+
const [dragWidth, setDragWidth] = useState<number | null>(null)
177+
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
178+
const [failed, setFailed] = useState(false)
172179
const attrs = node.attrs as {
173180
src?: string
174181
alt?: string
@@ -178,6 +185,7 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
178185
}
179186

180187
useEffect(() => () => dragAbortRef.current?.abort(), [])
188+
useEffect(() => setFailed(false), [attrs.src])
181189

182190
const startResize = (event: React.PointerEvent) => {
183191
event.preventDefault()
@@ -195,31 +203,42 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
195203
'pointermove',
196204
(move) => {
197205
const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX)))
198-
updateAttributes({ width: String(next) })
199-
},
200-
{ signal }
201-
)
202-
window.addEventListener(
203-
'pointerup',
204-
() => {
205-
setDragging(false)
206-
controller.abort()
206+
dragWidthRef.current = next
207+
setDragWidth(next)
207208
},
208209
{ signal }
209210
)
211+
const finish = () => {
212+
const finalWidth = dragWidthRef.current
213+
setDragging(false)
214+
setDragWidth(null)
215+
dragWidthRef.current = null
216+
controller.abort()
217+
if (finalWidth !== null) updateAttributes({ width: String(finalWidth) })
218+
}
219+
window.addEventListener('pointerup', finish, { signal })
220+
window.addEventListener('pointercancel', finish, { signal })
210221
}
211222

212-
const widthStyle = attrs.width
213-
? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width }
223+
const committedWidth = attrs.width
224+
? /^\d+$/.test(attrs.width)
225+
? `${attrs.width}px`
226+
: attrs.width
214227
: undefined
228+
const widthStyle =
229+
dragWidth !== null
230+
? { width: `${dragWidth}px` }
231+
: committedWidth
232+
? { width: committedWidth }
233+
: undefined
215234

216235
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
217236
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
218237
const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '')
219238

220239
// Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move,
221240
// mutating a doc that must not change. The image still renders (and follows its link on click).
222-
const editable = editor.isEditable
241+
const editable = useEditorEditable(editor)
223242

224243
const image = (
225244
<img
@@ -233,9 +252,13 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
233252
draggable={editable}
234253
data-drag-handle={editable ? '' : undefined}
235254
style={widthStyle}
255+
onError={() => setFailed(true)}
256+
onLoad={() => setFailed(false)}
236257
className={cn(
237258
'block max-w-full rounded-lg border border-[var(--border)]',
238-
editable && 'cursor-grab'
259+
editable && 'cursor-grab',
260+
failed &&
261+
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
239262
)}
240263
/>
241264
)
@@ -275,4 +298,28 @@ export const ResizableImage = MarkdownImage.extend({
275298
addNodeView() {
276299
return ReactNodeViewRenderer(ResizableImageView)
277300
},
301+
/**
302+
* Guarantee a plain click on the image forms a node selection. The image body is also a native drag
303+
* source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on
304+
* drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes
305+
* with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526).
306+
* Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and
307+
* modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's
308+
* `handleClick` / default behavior.
309+
*/
310+
addProseMirrorPlugins() {
311+
const nodeName = this.name
312+
return [
313+
new Plugin({
314+
props: {
315+
handleClickOn(view, _pos, node, nodePos, event) {
316+
if (!view.editable || node.type.name !== nodeName) return false
317+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
318+
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)))
319+
return true
320+
},
321+
},
322+
}),
323+
]
324+
},
278325
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect, useState } from 'react'
2+
import type { Editor } from '@tiptap/react'
3+
4+
/**
5+
* Reactively tracks `editor.isEditable` for React node views.
6+
*
7+
* The editor runs with `shouldRerenderOnTransaction: false`, and `editor.setEditable()` updates the
8+
* option + re-applies view state but does NOT change any node-view prop — so a node view that reads
9+
* `editor.isEditable` once at render keeps a stale value after the editability toggles (e.g. an agent
10+
* stream settling into the doc). That leaves images showing no drag/resize/selection affordances and
11+
* code blocks stuck on their read-only label until the node happens to re-render. Subscribing to the
12+
* editor's `transaction`/`update` events re-reads the flag so the node view stays in sync.
13+
*/
14+
export function useEditorEditable(editor: Editor): boolean {
15+
const [editable, setEditable] = useState(editor.isEditable)
16+
useEffect(() => {
17+
const sync = () => setEditable(editor.isEditable)
18+
sync()
19+
editor.on('transaction', sync)
20+
editor.on('update', sync)
21+
return () => {
22+
editor.off('transaction', sync)
23+
editor.off('update', sync)
24+
}
25+
}, [editor])
26+
return editable
27+
}

0 commit comments

Comments
 (0)