@@ -3842,6 +3842,45 @@ document.addEventListener("keydown", (e) => {
38423842 return ;
38433843 }
38443844
3845+ // Ctrl/Cmd+C: copy selected annotations
3846+ if ( ( e . ctrlKey || e . metaKey ) && e . key === "c" && ! e . shiftKey ) {
3847+ if (
3848+ document . activeElement instanceof HTMLInputElement ||
3849+ document . activeElement instanceof HTMLTextAreaElement
3850+ ) {
3851+ return ;
3852+ }
3853+ if ( selectedAnnotationIds . size > 0 ) {
3854+ e . preventDefault ( ) ;
3855+ copySelectedAnnotations ( ) ;
3856+ }
3857+ return ;
3858+ }
3859+
3860+ // Ctrl/Cmd+X: cut selected annotations (copy + delete)
3861+ if ( ( e . ctrlKey || e . metaKey ) && e . key === "x" && ! e . shiftKey ) {
3862+ if (
3863+ document . activeElement instanceof HTMLInputElement ||
3864+ document . activeElement instanceof HTMLTextAreaElement
3865+ ) {
3866+ return ;
3867+ }
3868+ if ( selectedAnnotationIds . size > 0 ) {
3869+ e . preventDefault ( ) ;
3870+ copySelectedAnnotations ( ) . then ( ( copied ) => {
3871+ if ( copied ) {
3872+ const ids = [ ...selectedAnnotationIds ] ;
3873+ selectAnnotation ( null ) ;
3874+ for ( const id of ids ) {
3875+ removeAnnotation ( id ) ;
3876+ }
3877+ persistAnnotations ( ) ;
3878+ }
3879+ } ) ;
3880+ }
3881+ return ;
3882+ }
3883+
38453884 // Ctrl/Cmd+S: save (for local files)
38463885 if ( ( e . ctrlKey || e . metaKey ) && e . key === "s" ) {
38473886 e . preventDefault ( ) ;
@@ -4607,6 +4646,89 @@ app.connect().then(() => {
46074646 updateAnnotationsBadge ( ) ;
46084647} ) ;
46094648
4649+ // =============================================================================
4650+ // Image from File (shared by drag-drop and paste)
4651+ // =============================================================================
4652+
4653+ /**
4654+ * Create an image annotation from a File/Blob at the given screen position.
4655+ * If no position is given, places the image at the center of the current page.
4656+ */
4657+ function addImageFromFile (
4658+ file : File | Blob ,
4659+ screenX ?: number ,
4660+ screenY ?: number ,
4661+ ) : void {
4662+ const reader = new FileReader ( ) ;
4663+ reader . onload = ( ) => {
4664+ const dataUrl = reader . result as string ;
4665+ const base64 = dataUrl . split ( "," ) [ 1 ] ;
4666+ const mimeType =
4667+ file . type || ( base64 . startsWith ( "/9j/" ) ? "image/jpeg" : "image/png" ) ;
4668+
4669+ const img = new Image ( ) ;
4670+ img . onload = ( ) => {
4671+ const maxWidth = 200 ; // PDF points
4672+ const aspectRatio = img . naturalHeight / img . naturalWidth ;
4673+ const width = Math . min ( img . naturalWidth , maxWidth ) ;
4674+ const height = width * aspectRatio ;
4675+
4676+ // Convert screen position to PDF internal coords, or default to page center
4677+ let pdfX : number ;
4678+ let pdfInternalY : number ;
4679+ if ( screenX != null && screenY != null ) {
4680+ pdfX = screenX / scale ;
4681+ pdfInternalY = ( containerHtmlEl . clientHeight - screenY ) / scale ;
4682+ } else {
4683+ // Center on the visible page area
4684+ const pageW = containerHtmlEl . clientWidth / scale ;
4685+ const pageH = containerHtmlEl . clientHeight / scale ;
4686+ pdfX = pageW / 2 - width / 2 ;
4687+ pdfInternalY = pageH / 2 + height / 2 ;
4688+ }
4689+
4690+ const id = `img_${ Date . now ( ) } _${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
4691+ const def : ImageAnnotation = {
4692+ type : "image" ,
4693+ id,
4694+ page : currentPage ,
4695+ x : pdfX ,
4696+ y : pdfInternalY ,
4697+ width,
4698+ height,
4699+ imageData : base64 ,
4700+ mimeType,
4701+ } ;
4702+
4703+ // Downscale if base64 data is too large (> ~300KB)
4704+ if ( base64 . length > 400_000 ) {
4705+ const canvas = document . createElement ( "canvas" ) ;
4706+ const maxDim = 800 ;
4707+ let w = img . naturalWidth ;
4708+ let h = img . naturalHeight ;
4709+ if ( w > maxDim || h > maxDim ) {
4710+ const ratio = Math . min ( maxDim / w , maxDim / h ) ;
4711+ w = Math . round ( w * ratio ) ;
4712+ h = Math . round ( h * ratio ) ;
4713+ }
4714+ canvas . width = w ;
4715+ canvas . height = h ;
4716+ const ctx = canvas . getContext ( "2d" ) ! ;
4717+ ctx . drawImage ( img , 0 , 0 , w , h ) ;
4718+ const quality = mimeType === "image/jpeg" ? 0.7 : undefined ;
4719+ const downscaledUrl = canvas . toDataURL ( mimeType , quality ) ;
4720+ def . imageData = downscaledUrl . split ( "," ) [ 1 ] ;
4721+ }
4722+
4723+ addAnnotation ( def ) ;
4724+ selectAnnotation ( def . id ) ;
4725+ persistAnnotations ( ) ;
4726+ } ;
4727+ img . src = dataUrl ;
4728+ } ;
4729+ reader . readAsDataURL ( file ) ;
4730+ }
4731+
46104732// =============================================================================
46114733// Image Drag & Drop
46124734// =============================================================================
@@ -4623,72 +4745,112 @@ containerHtmlEl.addEventListener("drop", async (e: DragEvent) => {
46234745 e . stopPropagation ( ) ;
46244746 if ( ! e . dataTransfer ?. files . length ) return ;
46254747
4748+ const containerRect = containerHtmlEl . getBoundingClientRect ( ) ;
4749+ const dropX = e . clientX - containerRect . left ;
4750+ const dropY = e . clientY - containerRect . top ;
4751+
46264752 for ( const file of e . dataTransfer . files ) {
46274753 if ( ! file . type . startsWith ( "image/" ) ) continue ;
4754+ addImageFromFile ( file , dropX , dropY ) ;
4755+ }
4756+ } ) ;
46284757
4629- const reader = new FileReader ( ) ;
4630- reader . onload = async ( ) => {
4631- const dataUrl = reader . result as string ;
4632- // Strip the data:mime;base64, prefix
4633- const base64 = dataUrl . split ( "," ) [ 1 ] ;
4634- const mimeType = file . type ;
4635-
4636- // Determine drop position in PDF coordinates
4637- const containerRect = containerHtmlEl . getBoundingClientRect ( ) ;
4638- const dropX = e . clientX - containerRect . left ;
4639- const dropY = e . clientY - containerRect . top ;
4640- const pdfX = dropX / scale ;
4641-
4642- // Get natural image dimensions to determine aspect ratio
4643- const img = new Image ( ) ;
4644- img . onload = ( ) => {
4645- const maxWidth = 200 ; // PDF points
4646- const aspectRatio = img . naturalHeight / img . naturalWidth ;
4647- const width = Math . min ( img . naturalWidth , maxWidth ) ;
4648- const height = width * aspectRatio ;
4649-
4650- // Convert screen drop point to PDF internal coords
4651- // The drop Y is in screen space (top-left origin), convert to PDF space (bottom-left)
4652- const pdfInternalY = ( containerHtmlEl . clientHeight - dropY ) / scale ;
4653-
4654- const id = `img_${ Date . now ( ) } _${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
4655- const def : ImageAnnotation = {
4656- type : "image" ,
4657- id,
4658- page : currentPage ,
4659- x : pdfX ,
4660- y : pdfInternalY ,
4661- width,
4662- height,
4663- imageData : base64 ,
4664- mimeType,
4665- } ;
4758+ // =============================================================================
4759+ // Clipboard: Copy / Cut / Paste
4760+ // =============================================================================
46664761
4667- // Downscale if base64 data is too large (> ~300KB)
4668- if ( base64 . length > 400_000 ) {
4669- const canvas = document . createElement ( "canvas" ) ;
4670- const maxDim = 800 ;
4671- let w = img . naturalWidth ;
4672- let h = img . naturalHeight ;
4673- if ( w > maxDim || h > maxDim ) {
4674- const ratio = Math . min ( maxDim / w , maxDim / h ) ;
4675- w = Math . round ( w * ratio ) ;
4676- h = Math . round ( h * ratio ) ;
4677- }
4678- canvas . width = w ;
4679- canvas . height = h ;
4680- const ctx = canvas . getContext ( "2d" ) ! ;
4681- ctx . drawImage ( img , 0 , 0 , w , h ) ;
4682- const quality = mimeType === "image/jpeg" ? 0.7 : undefined ;
4683- const downscaledUrl = canvas . toDataURL ( mimeType , quality ) ;
4684- def . imageData = downscaledUrl . split ( "," ) [ 1 ] ;
4685- }
4762+ /** Clipboard format identifier so we can recognize our own data on paste. */
4763+ const CLIPBOARD_FORMAT = "pdf-annotations/v1" ;
46864764
4687- addAnnotation ( def ) ;
4688- persistAnnotations ( ) ;
4689- } ;
4690- img . src = dataUrl ;
4691- } ;
4692- reader . readAsDataURL ( file ) ;
4765+ /** Copy selected annotations to clipboard as JSON. Returns true if anything was copied. */
4766+ async function copySelectedAnnotations ( ) : Promise < boolean > {
4767+ if ( selectedAnnotationIds . size === 0 ) return false ;
4768+ const defs : PdfAnnotationDef [ ] = [ ] ;
4769+ for ( const id of selectedAnnotationIds ) {
4770+ const tracked = annotationMap . get ( id ) ;
4771+ if ( tracked ) defs . push ( { ...tracked . def } ) ;
46934772 }
4694- } ) ;
4773+ if ( defs . length === 0 ) return false ;
4774+
4775+ const payload = JSON . stringify ( {
4776+ format : CLIPBOARD_FORMAT ,
4777+ annotations : defs ,
4778+ } ) ;
4779+ try {
4780+ await navigator . clipboard . writeText ( payload ) ;
4781+ return true ;
4782+ } catch {
4783+ return false ;
4784+ }
4785+ }
4786+
4787+ /** Try to parse clipboard text as our annotation format. */
4788+ function parseAnnotationClipboard ( text : string ) : PdfAnnotationDef [ ] | null {
4789+ try {
4790+ const parsed = JSON . parse ( text ) ;
4791+ if (
4792+ parsed ?. format === CLIPBOARD_FORMAT &&
4793+ Array . isArray ( parsed . annotations )
4794+ ) {
4795+ return parsed . annotations ;
4796+ }
4797+ } catch {
4798+ // Not our format
4799+ }
4800+ return null ;
4801+ }
4802+
4803+ /** Paste annotations or images from clipboard. */
4804+ function handlePaste ( e : ClipboardEvent ) : void {
4805+ // Don't intercept paste in inputs
4806+ if (
4807+ document . activeElement instanceof HTMLInputElement ||
4808+ document . activeElement instanceof HTMLTextAreaElement ||
4809+ document . activeElement instanceof HTMLSelectElement
4810+ ) {
4811+ return ;
4812+ }
4813+
4814+ const clipboardData = e . clipboardData ;
4815+ if ( ! clipboardData ) return ;
4816+
4817+ // Check for image files first
4818+ for ( const item of clipboardData . items ) {
4819+ if ( item . type . startsWith ( "image/" ) ) {
4820+ e . preventDefault ( ) ;
4821+ const file = item . getAsFile ( ) ;
4822+ if ( file ) addImageFromFile ( file ) ;
4823+ return ;
4824+ }
4825+ }
4826+
4827+ // Check for text that might be our annotation format
4828+ const text = clipboardData . getData ( "text/plain" ) ;
4829+ if ( ! text ) return ;
4830+
4831+ const annotations = parseAnnotationClipboard ( text ) ;
4832+ if ( ! annotations || annotations . length === 0 ) return ;
4833+
4834+ e . preventDefault ( ) ;
4835+
4836+ // Paste with new IDs and a slight offset so they don't overlap originals
4837+ const offset = 10 ; // PDF points
4838+ selectAnnotation ( null ) ;
4839+ for ( const def of annotations ) {
4840+ def . id = `paste_${ Date . now ( ) } _${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
4841+ def . page = currentPage ;
4842+ if ( "x" in def && typeof def . x === "number" ) def . x += offset ;
4843+ if ( "y" in def && typeof def . y === "number" ) def . y += offset ;
4844+ if ( "rects" in def && Array . isArray ( def . rects ) ) {
4845+ for ( const r of def . rects ) {
4846+ r . x += offset ;
4847+ r . y += offset ;
4848+ }
4849+ }
4850+ addAnnotation ( def ) ;
4851+ selectAnnotation ( def . id , true ) ;
4852+ }
4853+ persistAnnotations ( ) ;
4854+ }
4855+
4856+ document . addEventListener ( "paste" , handlePaste ) ;
0 commit comments