Skip to content

Commit fc2dd87

Browse files
committed
fix(file-viewer): make all editor controls respect read-only permissions
Every interactive control that calls updateAttributes/dispatches a command mutates the doc even when read-only (ProseMirror commands run regardless of editable), so gate them on editor.isEditable: - bubble menu: the Cmd/Ctrl+K shortcut and shouldShow now bail when not editable, so a read-only doc can't open the link bar and setLink into it (Cursor finding). - code block: the language picker renders as a static label when read-only (its onSelect mutates); copy + view-only wrap stay. - image: no drag-to-reorder (draggable=false, no drag handle) and no resize handle when read-only; the image still renders and follows its link. - links: a plain click now follows the link in read-only (reader) mode, while edit mode still requires a modifier so a plain click can place the cursor (Cursor finding). Verified with new read-only permission e2e tests.
1 parent 535798b commit fc2dd87

4 files changed

Lines changed: 55 additions & 37 deletions

File tree

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

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const LANGUAGE_OPTIONS = [
4343
const CONTROL_CLASS =
4444
'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-none transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]'
4545

46-
function CodeBlockView({ node, updateAttributes }: ReactNodeViewProps) {
46+
function CodeBlockView({ node, updateAttributes, editor }: ReactNodeViewProps) {
4747
const [wrap, setWrap] = useState(false)
4848
const [menuOpen, setMenuOpen] = useState(false)
4949
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
@@ -63,33 +63,41 @@ function CodeBlockView({ node, updateAttributes }: ReactNodeViewProps) {
6363
)}
6464
contentEditable={false}
6565
>
66-
<DropdownMenu onOpenChange={setMenuOpen}>
67-
<DropdownMenuTrigger asChild>
68-
<button
69-
type='button'
70-
aria-label='Code language'
71-
className={cn(
72-
chipVariants({ variant: 'default', flush: true }),
73-
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
74-
)}
75-
>
76-
{label}
77-
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
78-
</button>
79-
</DropdownMenuTrigger>
80-
<DropdownMenuContent align='end'>
81-
{LANGUAGE_OPTIONS.map((option) => (
82-
<DropdownMenuItem
83-
key={option.value}
84-
onSelect={() =>
85-
updateAttributes({ language: option.value === PLAIN ? null : option.value })
86-
}
66+
{editor.isEditable ? (
67+
// Editable: a language picker. Read-only: a static label — selecting a language calls
68+
// updateAttributes, which would mutate a doc that must not change.
69+
<DropdownMenu onOpenChange={setMenuOpen}>
70+
<DropdownMenuTrigger asChild>
71+
<button
72+
type='button'
73+
aria-label='Code language'
74+
className={cn(
75+
chipVariants({ variant: 'default', flush: true }),
76+
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
77+
)}
8778
>
88-
{option.label}
89-
</DropdownMenuItem>
90-
))}
91-
</DropdownMenuContent>
92-
</DropdownMenu>
79+
{label}
80+
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
81+
</button>
82+
</DropdownMenuTrigger>
83+
<DropdownMenuContent align='end'>
84+
{LANGUAGE_OPTIONS.map((option) => (
85+
<DropdownMenuItem
86+
key={option.value}
87+
onSelect={() =>
88+
updateAttributes({ language: option.value === PLAIN ? null : option.value })
89+
}
90+
>
91+
{option.label}
92+
</DropdownMenuItem>
93+
))}
94+
</DropdownMenuContent>
95+
</DropdownMenu>
96+
) : (
97+
<span className='flex h-[24px] items-center px-1.5 text-[var(--text-muted)] text-caption'>
98+
{label}
99+
</span>
100+
)}
93101
<button
94102
type='button'
95103
aria-label='Toggle line wrap'

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export const MarkdownImage = Image.extend({
152152
* Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
153153
* commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
154154
*/
155-
function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewProps) {
155+
function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) {
156156
const imageRef = useRef<HTMLImageElement>(null)
157157
const dragAbortRef = useRef<AbortController | null>(null)
158158
const [dragging, setDragging] = useState(false)
@@ -204,19 +204,23 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP
204204
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
205205
const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '')
206206

207+
// Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move,
208+
// mutating a doc that must not change. The image still renders (and follows its link on click).
209+
const editable = editor.isEditable
210+
207211
const image = (
208212
<img
209213
ref={imageRef}
210214
src={attrs.src}
211215
alt={attrs.alt ?? ''}
212216
title={attrs.title ?? undefined}
213-
// The image itself is the drag handle — grab anywhere on it to reorder. (The node view's
214-
// wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
217+
// When editable, the image itself is the drag handle — grab anywhere on it to reorder. (The node
218+
// view's wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
215219
// the resize button sits outside this element, so it keeps its own pointer behavior.)
216-
draggable
217-
data-drag-handle
220+
draggable={editable}
221+
data-drag-handle={editable ? '' : undefined}
218222
style={widthStyle}
219-
className='block max-w-full cursor-grab rounded-lg border border-[var(--border)]'
223+
className={`block max-w-full rounded-lg border border-[var(--border)]${editable ? ' cursor-grab' : ''}`}
220224
/>
221225
)
222226

@@ -229,7 +233,7 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP
229233
) : (
230234
image
231235
)}
232-
{(selected || dragging) && (
236+
{editable && (selected || dragging) && (
233237
<button
234238
type='button'
235239
aria-label='Resize image'

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
119119
useEffect(() => {
120120
const dom = editor.view.dom
121121
const openLinkOnShortcut = (event: KeyboardEvent) => {
122+
if (!editor.isEditable) return
122123
if (!(event.metaKey || event.ctrlKey) || event.isComposing) return
123124
if (event.key?.toLowerCase() !== 'k') return
124125
const { from, to } = editor.state.selection
@@ -164,8 +165,11 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
164165
aria-label='Text formatting'
165166
updateDelay={0}
166167
shouldShow={({ editor: e, from, to }) => {
168+
// Read-only never shows the menu — even mid-link-edit (e.g. a stream starting) — so a link
169+
// can't be applied to a doc that must not mutate.
170+
if (!e.isEditable) return false
167171
if (isEditingLink) return true
168-
if (!e.isEditable || e.isActive('codeBlock')) return false
172+
if (e.isActive('codeBlock')) return false
169173
return e.state.doc.textBetween(from, to, ' ').trim().length > 0
170174
}}
171175
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-100 motion-reduce:animate-none'

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,12 @@ export function LoadedRichMarkdownEditor({
235235
void onSaveShortcutRef.current()
236236
return true
237237
},
238-
handleClick: (_view, _pos, event) => {
239-
if (!(event.metaKey || event.ctrlKey)) return false
238+
handleClick: (view, _pos, event) => {
240239
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href')
241240
if (!href) return false
241+
// Editing: require a modifier so a plain click can place the cursor. Read-only (a reader, e.g.
242+
// the public share page): a plain click follows the link.
243+
if (view.editable && !(event.metaKey || event.ctrlKey)) return false
242244
const normalized = normalizeLinkHref(href)
243245
if (!normalized) return false
244246
window.open(normalized, '_blank', 'noopener,noreferrer')

0 commit comments

Comments
 (0)