diff --git a/apps/sim/app/_shell/paste-admission-guard.test.tsx b/apps/sim/app/_shell/paste-admission-guard.test.tsx
index 79f9a6298a4..d10ad45b2c0 100644
--- a/apps/sim/app/_shell/paste-admission-guard.test.tsx
+++ b/apps/sim/app/_shell/paste-admission-guard.test.tsx
@@ -17,7 +17,11 @@ import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
let host: HTMLDivElement
let root: Root
-function dispatchPaste(target: Element, text: string, selectionContext?: string): Event {
+function dispatchPaste(
+ target: Element,
+ text: string,
+ options: { selectionContext?: string; html?: string; imageFile?: boolean } = {}
+): Event {
const event = new Event('paste', {
bubbles: true,
cancelable: true,
@@ -27,9 +31,12 @@ function dispatchPaste(target: Element, text: string, selectionContext?: string)
value: {
getData: (type: string) => {
if (type === 'text/plain') return text
- if (type === SIM_SELECTION_MIME) return selectionContext ?? ''
+ if (type === SIM_SELECTION_MIME) return options.selectionContext ?? ''
+ if (type === 'text/html') return options.html ?? ''
return ''
},
+ files: options.imageFile ? [new File(['image'], 'pasted.png', { type: 'image/png' })] : [],
+ items: options.imageFile ? [{ kind: 'file', type: 'image/png' }] : [],
},
})
target.dispatchEvent(event)
@@ -93,7 +100,36 @@ describe('PasteAdmissionGuard', () => {
expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false)
})
- it('lets a compact Sim selection reference bypass its large plain-text representation', () => {
+ it('defers text admission to an editor that projects its exact paste result', () => {
+ const editor = document.createElement('div')
+ editor.setAttribute('contenteditable', 'true')
+ editor.dataset.pasteMaxBytes = '4'
+ editor.dataset.pasteProjectsTextResult = 'true'
+ host.appendChild(editor)
+
+ const targetHandler = vi.fn()
+ editor.addEventListener('paste', targetHandler)
+ expect(dispatchPaste(editor, '12345').defaultPrevented).toBe(false)
+ expect(targetHandler).toHaveBeenCalledOnce()
+ })
+
+ it('lets a prompt consume a compact Sim selection reference before its large plain text', () => {
+ const input = document.createElement('textarea')
+ input.dataset.pasteMaxBytes = '4'
+ input.dataset.pasteSelectionContext = 'reference'
+ host.appendChild(input)
+ const selectionContext = JSON.stringify({
+ kind: 'table_selection',
+ tableId: 'table-1',
+ tableName: 'Large table',
+ rowIds: ['row-1'],
+ label: 'Large table (1 row)',
+ })
+
+ expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(false)
+ })
+
+ it('still bounds a Sim selection plain-text representation outside the prompt', () => {
const input = document.createElement('textarea')
input.dataset.pasteMaxBytes = '4'
host.appendChild(input)
@@ -105,6 +141,37 @@ describe('PasteAdmissionGuard', () => {
label: 'Large table (1 row)',
})
- expect(dispatchPaste(input, '12345', selectionContext).defaultPrevented).toBe(false)
+ expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(true)
+ })
+
+ it('bounds rich HTML separately from its smaller plain-text representation', () => {
+ const editable = document.createElement('div')
+ editable.setAttribute('contenteditable', 'true')
+ editable.dataset.pasteMaxBytes = '100'
+ editable.dataset.pasteMaxHtmlBytes = '10'
+ host.appendChild(editable)
+
+ expect(dispatchPaste(editable, 'abc', { html: 'abc' }).defaultPrevented).toBe(
+ true
+ )
+ })
+
+ it('lets an opted-in rich editor handle clipboard image files before text admission', () => {
+ const editable = document.createElement('div')
+ editable.setAttribute('contenteditable', 'true')
+ editable.dataset.pasteMaxBytes = '4'
+ editable.dataset.pasteMaxHtmlBytes = '4'
+ editable.dataset.pasteHandlesImages = 'true'
+ host.appendChild(editable)
+
+ const targetHandler = vi.fn()
+ editable.addEventListener('paste', targetHandler)
+ const event = dispatchPaste(editable, '12345', {
+ html: '',
+ imageFile: true,
+ })
+
+ expect(event.defaultPrevented).toBe(false)
+ expect(targetHandler).toHaveBeenCalledOnce()
})
})
diff --git a/apps/sim/app/_shell/paste-admission-guard.tsx b/apps/sim/app/_shell/paste-admission-guard.tsx
index 6c8a30e07c6..1a12cb1f3a7 100644
--- a/apps/sim/app/_shell/paste-admission-guard.tsx
+++ b/apps/sim/app/_shell/paste-admission-guard.tsx
@@ -15,11 +15,20 @@ function finitePositiveAttribute(element: Element | null, name: string): number
return Number.isFinite(value) && value > 0 ? value : undefined
}
+function clipboardHasImageFile(data: DataTransfer | null): boolean {
+ if (!data) return false
+ if (Array.from(data.files).some((file) => file.type.startsWith('image/'))) return true
+ return Array.from(data.items).some(
+ (item) => item.kind === 'file' && item.type.startsWith('image/')
+ )
+}
+
/**
* Last-resort admission for every editable workspace surface. Specialized editors publish their
* downstream ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit a
* crash-only fallback. This layer bounds only the clipboard payload, so a small paste into an already
* large field keeps native behavior. Editors with a real result-size contract enforce it themselves.
+ * Targets that explicitly handle clipboard images may claim those file events before the text guards.
* The capture listener runs before React, ProseMirror, Monaco, and xterm parse the clipboard value.
*/
export function PasteAdmissionGuard() {
@@ -33,11 +42,14 @@ export function PasteAdmissionGuard() {
return
}
- if (readSelectionContextFromClipboard(event.clipboardData)) return
+ const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]')
+ if (acceptsSelectionContext && readSelectionContextFromClipboard(event.clipboardData)) return
- const text = event.clipboardData?.getData('text/plain') ?? ''
- if (!text) return
+ const handlesImageFiles = event.target.closest('[data-paste-handles-images="true"]')
+ if (handlesImageFiles && clipboardHasImageFile(event.clipboardData)) return
+ const text = event.clipboardData?.getData('text/plain') ?? ''
+ const projectsTextResult = event.target.closest('[data-paste-projects-text-result="true"]')
const policyElement = event.target.closest('[data-paste-max-bytes]')
const maxPastedBytes =
finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES
@@ -45,19 +57,38 @@ export function PasteAdmissionGuard() {
policyElement,
'data-paste-max-characters'
)
- const admission = assessTextPaste({
- pastedText: text,
- maxPastedBytes,
- maxPastedCharacters,
- })
- if (admission.accepted) return
+ const textAdmission =
+ text && !projectsTextResult
+ ? assessTextPaste({
+ pastedText: text,
+ maxPastedBytes,
+ maxPastedCharacters,
+ })
+ : null
+ const htmlPolicyElement = event.target.closest('[data-paste-max-html-bytes]')
+ const maxPastedHtmlBytes = finitePositiveAttribute(
+ htmlPolicyElement,
+ 'data-paste-max-html-bytes'
+ )
+ const html = maxPastedHtmlBytes ? (event.clipboardData?.getData('text/html') ?? '') : ''
+ const htmlAdmission =
+ html && maxPastedHtmlBytes
+ ? assessTextPaste({ pastedText: html, maxPastedBytes: maxPastedHtmlBytes })
+ : null
+ const rejection =
+ textAdmission && !textAdmission.accepted
+ ? textAdmission
+ : htmlAdmission && !htmlAdmission.accepted
+ ? htmlAdmission
+ : null
+ if (!rejection) return
event.preventDefault()
event.stopImmediatePropagation()
const limit =
- admission.reason === 'pasted-characters'
- ? `${admission.limit.toLocaleString()} characters`
- : formatPasteLimit(admission.limit)
+ rejection.reason === 'pasted-characters'
+ ? `${rejection.limit.toLocaleString()} characters`
+ : formatPasteLimit(rejection.limit)
notifyRef.current.warning('Paste is too large for this editor', {
description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`,
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts
index 68c6875b12d..a7b73083704 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts
@@ -4,8 +4,11 @@
import { Editor } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { createMarkdownContentExtensions } from './extensions'
-import { createRichMarkdownPasteAdmission } from './paste-admission'
+import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
+import {
+ assessRawMarkdownPaste,
+ createRichMarkdownPasteAdmission,
+} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission'
let editor: Editor | null = null
@@ -14,10 +17,16 @@ afterEach(() => {
editor = null
})
-function runPaste(ed: Editor, text: string): { handled: boolean; prevented: boolean } {
+function runPaste(ed: Editor, text: string, html = ''): { handled: boolean; prevented: boolean } {
let prevented = false
const event = {
- clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') },
+ clipboardData: {
+ getData: (type: string) => {
+ if (type === 'text/plain') return text
+ if (type === 'text/html') return html
+ return ''
+ },
+ },
preventDefault: () => {
prevented = true
},
@@ -31,6 +40,20 @@ function runPaste(ed: Editor, text: string): { handled: boolean; prevented: bool
}
describe('rich Markdown paste admission', () => {
+ it('rejects a raw-text append whose projected result exceeds the limit', () => {
+ expect(
+ assessRawMarkdownPaste(
+ {
+ pastedText: '56789',
+ currentText: '123456',
+ selectionStart: 6,
+ selectionEnd: 6,
+ },
+ 10
+ )
+ ).toEqual({ accepted: false, reason: 'result-bytes', actual: 11, limit: 10 })
+ })
+
it('rejects before downstream paste parsing when projected bytes exceed the document limit', () => {
const onRejected = vi.fn()
editor = new Editor({
@@ -88,4 +111,51 @@ describe('rich Markdown paste admission', () => {
expect(runPaste(editor, '1234567890')).toEqual({ handled: false, prevented: false })
})
+
+ it('rejects oversized rich HTML before downstream parsing', () => {
+ const onRejected = vi.fn()
+ editor = new Editor({
+ extensions: [
+ ...createMarkdownContentExtensions(),
+ createRichMarkdownPasteAdmission({
+ maxResultBytes: 10,
+ getCurrentText: () => '',
+ onRejected,
+ }),
+ ],
+ content: '
123456
', + }) + const strong = editor.schema.marks.bold.create() + const transaction = editor.state.tr + .replaceSelectionWith(editor.schema.text('abc', [strong]), false) + .setMeta('uiEvent', 'paste') + + expect(editor.markdown.serialize(transaction.doc.toJSON())).toBe('**abc**123456') + expect(transaction.getMeta('uiEvent')).toBe('paste') + editor.view.dispatch(transaction) + + expect(editor.getText()).toBe('123456') + expect(onRejected).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts index b5d505c6ebc..106dce3a6b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.ts @@ -1,6 +1,12 @@ -import { utf8ByteLength } from '@sim/utils/paste' +import { + assessTextPaste, + PASTE_LIMITS, + type TextPasteAdmission, + utf8ByteLength, +} from '@sim/utils/paste' import { Extension } from '@tiptap/core' import { Plugin } from '@tiptap/pm/state' +import { postProcessSerializedMarkdown } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' export interface RichMarkdownPasteAdmissionOptions { maxResultBytes: number @@ -8,10 +14,26 @@ export interface RichMarkdownPasteAdmissionOptions { onRejected: () => void } +interface RawMarkdownPasteInput { + pastedText: string + currentText: string + selectionStart: number + selectionEnd: number +} + +/** Applies the rich-document boundary to a projected raw-text paste result. */ +export function assessRawMarkdownPaste( + input: RawMarkdownPasteInput, + maxResultBytes = PASTE_LIMITS.RICH_MARKDOWN_BYTES +): TextPasteAdmission { + return assessTextPaste({ ...input, maxResultBytes }) +} + /** - * Rejects a paste before Markdown parsing when its projected document would leave the editor's - * supported collaboration envelope. The selected ProseMirror text is subtracted from the current - * Markdown size, so replacing a large selection is admitted instead of being treated as an append. + * Rejects oversized clipboard representations before parsing, then filters the exact canonical + * Markdown transaction before it can leave the editor's supported collaboration envelope. The early + * plain-text projection subtracts the selected content, so a large replacement remains fast and is + * not treated as an append. */ export function createRichMarkdownPasteAdmission({ maxResultBytes, @@ -23,34 +45,69 @@ export function createRichMarkdownPasteAdmission({ priority: 1_000, addProseMirrorPlugins() { + const { editor } = this + let pasteInProgress = false + return [ new Plugin({ + filterTransaction: (transaction) => { + const isPaste = pasteInProgress || transaction.getMeta('uiEvent') === 'paste' + if (!isPaste || !transaction.docChanged) return true + pasteInProgress = false + + if (!editor.markdown) { + throw new Error('Rich Markdown paste admission requires the Markdown extension') + } + const projectedMarkdown = postProcessSerializedMarkdown( + editor.markdown.serialize(transaction.doc.toJSON()) + ) + if (utf8ByteLength(projectedMarkdown, maxResultBytes) <= maxResultBytes) return true + + onRejected() + return false + }, props: { handleDOMEvents: { paste: (view, event) => { const pastedText = event.clipboardData?.getData('text/plain') ?? '' - if (!pastedText) return false - - const currentText = getCurrentText() - const { from, to } = view.state.selection - const replacedText = view.state.doc.textBetween(from, to, '\n') - const replacesWholeDocument = from <= 1 && to >= view.state.doc.content.size - 1 - const projectedCharacters = replacesWholeDocument - ? pastedText.length - : Math.max(0, currentText.length - replacedText.length) + pastedText.length - if (projectedCharacters <= Math.floor(maxResultBytes / 3)) return false - - const currentBytes = utf8ByteLength(currentText, maxResultBytes) - const pastedBytes = utf8ByteLength(pastedText, maxResultBytes) - const replacedBytes = replacesWholeDocument - ? currentBytes - : utf8ByteLength(replacedText, maxResultBytes) - const projectedBytes = Math.max(0, currentBytes - replacedBytes) + pastedBytes - if (projectedBytes <= maxResultBytes) return false - - event.preventDefault() - onRejected() - return true + const pastedHtml = event.clipboardData?.getData('text/html') ?? '' + if (!pastedText && !pastedHtml) return false + + if (pastedHtml && utf8ByteLength(pastedHtml, maxResultBytes) > maxResultBytes) { + event.preventDefault() + onRejected() + return true + } + + if (pastedText) { + const currentText = getCurrentText() + const { from, to } = view.state.selection + const replacedText = view.state.doc.textBetween(from, to, '\n') + const replacesWholeDocument = from <= 1 && to >= view.state.doc.content.size - 1 + const projectedCharacters = replacesWholeDocument + ? pastedText.length + : Math.max(0, currentText.length - replacedText.length) + pastedText.length + + if (projectedCharacters > Math.floor(maxResultBytes / 3)) { + const currentBytes = utf8ByteLength(currentText, maxResultBytes) + const pastedBytes = utf8ByteLength(pastedText, maxResultBytes) + const replacedBytes = replacesWholeDocument + ? currentBytes + : utf8ByteLength(replacedText, maxResultBytes) + const projectedBytes = Math.max(0, currentBytes - replacedBytes) + pastedBytes + if (projectedBytes > maxResultBytes) { + event.preventDefault() + onRejected() + return true + } + } + } + + pasteInProgress = true + queueMicrotask(() => { + pasteInProgress = false + }) + return false }, }, }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 0fb024f79e4..9684f1bab84 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -556,6 +556,8 @@ export function LoadedRichMarkdownEditor({ class: 'rich-markdown-nodes rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K', 'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-handles-images': 'true', }, handleKeyDown: (_view, event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 595c8f4b3f0..7005203bd0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -5,6 +5,7 @@ import { ChipTextarea, chipFieldSurfaceClass, cn, toast } from '@sim/emcn' import { formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' +import { assessRawMarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission' import { createMarkdownEditorExtensions } from './editor-extensions' import { moveDraggedImageNode } from './image-drag-move' import { extractImageFiles, isInlineRouteSrc, shouldSkipFileUpload } from './image-paste' @@ -248,6 +249,8 @@ function LoadedRichMarkdownField({ // Claim ⌘K so the bubble-menu link editor wins over the global search palette. 'data-owned-shortcuts': 'Mod+K', 'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES), + 'data-paste-handles-images': uploadImage ? 'true' : 'false', }, handlePaste: (view, event) => { const images = uploadImageRef.current ? extractImageFiles(event.clipboardData) : [] @@ -476,7 +479,22 @@ function RawMarkdownField({ const handlePaste = (event: React.ClipboardEvent