1- import { utf8ByteLength } from '@sim/utils/paste'
1+ import {
2+ assessTextPaste ,
3+ PASTE_LIMITS ,
4+ type TextPasteAdmission ,
5+ utf8ByteLength ,
6+ } from '@sim/utils/paste'
27import { Extension } from '@tiptap/core'
38import { Plugin } from '@tiptap/pm/state'
9+ import { postProcessSerializedMarkdown } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
410
511export interface RichMarkdownPasteAdmissionOptions {
612 maxResultBytes : number
713 getCurrentText : ( ) => string
814 onRejected : ( ) => void
915}
1016
17+ interface RawMarkdownPasteInput {
18+ pastedText : string
19+ currentText : string
20+ selectionStart : number
21+ selectionEnd : number
22+ }
23+
24+ /** Applies the rich-document boundary to a projected raw-text paste result. */
25+ export function assessRawMarkdownPaste (
26+ input : RawMarkdownPasteInput ,
27+ maxResultBytes = PASTE_LIMITS . RICH_MARKDOWN_BYTES
28+ ) : TextPasteAdmission {
29+ return assessTextPaste ( { ...input , maxResultBytes } )
30+ }
31+
1132/**
12- * Rejects a paste before Markdown parsing when its projected document would leave the editor's
13- * supported collaboration envelope. The selected ProseMirror text is subtracted from the current
14- * Markdown size, so replacing a large selection is admitted instead of being treated as an append.
33+ * Rejects oversized clipboard representations before parsing, then filters the exact canonical
34+ * Markdown transaction before it can leave the editor's supported collaboration envelope. The early
35+ * plain-text projection subtracts the selected content, so a large replacement remains fast and is
36+ * not treated as an append.
1537 */
1638export function createRichMarkdownPasteAdmission ( {
1739 maxResultBytes,
@@ -23,34 +45,69 @@ export function createRichMarkdownPasteAdmission({
2345 priority : 1_000 ,
2446
2547 addProseMirrorPlugins ( ) {
48+ const { editor } = this
49+ let pasteInProgress = false
50+
2651 return [
2752 new Plugin ( {
53+ filterTransaction : ( transaction ) => {
54+ const isPaste = pasteInProgress || transaction . getMeta ( 'uiEvent' ) === 'paste'
55+ if ( ! isPaste || ! transaction . docChanged ) return true
56+ pasteInProgress = false
57+
58+ if ( ! editor . markdown ) {
59+ throw new Error ( 'Rich Markdown paste admission requires the Markdown extension' )
60+ }
61+ const projectedMarkdown = postProcessSerializedMarkdown (
62+ editor . markdown . serialize ( transaction . doc . toJSON ( ) )
63+ )
64+ if ( utf8ByteLength ( projectedMarkdown , maxResultBytes ) <= maxResultBytes ) return true
65+
66+ onRejected ( )
67+ return false
68+ } ,
2869 props : {
2970 handleDOMEvents : {
3071 paste : ( view , event ) => {
3172 const pastedText = event . clipboardData ?. getData ( 'text/plain' ) ?? ''
32- if ( ! pastedText ) return false
33-
34- const currentText = getCurrentText ( )
35- const { from, to } = view . state . selection
36- const replacedText = view . state . doc . textBetween ( from , to , '\n' )
37- const replacesWholeDocument = from <= 1 && to >= view . state . doc . content . size - 1
38- const projectedCharacters = replacesWholeDocument
39- ? pastedText . length
40- : Math . max ( 0 , currentText . length - replacedText . length ) + pastedText . length
41- if ( projectedCharacters <= Math . floor ( maxResultBytes / 3 ) ) return false
42-
43- const currentBytes = utf8ByteLength ( currentText , maxResultBytes )
44- const pastedBytes = utf8ByteLength ( pastedText , maxResultBytes )
45- const replacedBytes = replacesWholeDocument
46- ? currentBytes
47- : utf8ByteLength ( replacedText , maxResultBytes )
48- const projectedBytes = Math . max ( 0 , currentBytes - replacedBytes ) + pastedBytes
49- if ( projectedBytes <= maxResultBytes ) return false
50-
51- event . preventDefault ( )
52- onRejected ( )
53- return true
73+ const pastedHtml = event . clipboardData ?. getData ( 'text/html' ) ?? ''
74+ if ( ! pastedText && ! pastedHtml ) return false
75+
76+ if ( pastedHtml && utf8ByteLength ( pastedHtml , maxResultBytes ) > maxResultBytes ) {
77+ event . preventDefault ( )
78+ onRejected ( )
79+ return true
80+ }
81+
82+ if ( pastedText ) {
83+ const currentText = getCurrentText ( )
84+ const { from, to } = view . state . selection
85+ const replacedText = view . state . doc . textBetween ( from , to , '\n' )
86+ const replacesWholeDocument = from <= 1 && to >= view . state . doc . content . size - 1
87+ const projectedCharacters = replacesWholeDocument
88+ ? pastedText . length
89+ : Math . max ( 0 , currentText . length - replacedText . length ) + pastedText . length
90+
91+ if ( projectedCharacters > Math . floor ( maxResultBytes / 3 ) ) {
92+ const currentBytes = utf8ByteLength ( currentText , maxResultBytes )
93+ const pastedBytes = utf8ByteLength ( pastedText , maxResultBytes )
94+ const replacedBytes = replacesWholeDocument
95+ ? currentBytes
96+ : utf8ByteLength ( replacedText , maxResultBytes )
97+ const projectedBytes = Math . max ( 0 , currentBytes - replacedBytes ) + pastedBytes
98+ if ( projectedBytes > maxResultBytes ) {
99+ event . preventDefault ( )
100+ onRejected ( )
101+ return true
102+ }
103+ }
104+ }
105+
106+ pasteInProgress = true
107+ queueMicrotask ( ( ) => {
108+ pasteInProgress = false
109+ } )
110+ return false
54111 } ,
55112 } ,
56113 } ,
0 commit comments