11'use client'
22
3- import { useState , useCallback , useMemo } from 'react'
3+ import { useState , useCallback } from 'react'
44import { useTranslations } from 'next-intl'
55import { computeExportDimensions , drawSolidBorder , drawGradientBorder , drawTextureBorder , drawInnerMat , drawShadow } from '@/lib/math/frame'
66import { transferExif } from '@/lib/utils/exif'
7+ import { renderTiledExport , TILED_EXPORT_THRESHOLD } from '@/lib/utils/image-export'
78import {
89 drawRuleOfThirds , drawGoldenRatio , drawGoldenSpiral , drawGoldenDiagonal ,
910 drawDiagonalLines , drawCenterCross , drawSquareGrid , drawTriangles ,
@@ -39,65 +40,36 @@ const GRID_DRAW_MAP: Record<GridType, DrawFn> = {
3940 'triangles' : ( ctx , w , h ) => drawTriangles ( ctx , w , h ) ,
4041}
4142
42- function getLinePositions (
43- gridType : GridType , w : number , h : number , opts : GridOptions ,
44- ) : { v : number [ ] ; h : number [ ] } | null {
43+ function getLinePositions ( gridType : GridType , w : number , h : number , opts : GridOptions ) {
4544 switch ( gridType ) {
46- case 'rule-of-thirds' :
47- return { v : [ 0 , w / 3 , 2 * w / 3 ] , h : [ 0 , h / 3 , 2 * h / 3 ] }
48- case 'golden-ratio' : {
49- const pw = w / GOLDEN_RATIO , ph = h / GOLDEN_RATIO
50- return { v : [ 0 , w - pw , pw ] , h : [ 0 , h - ph , ph ] }
45+ case 'rule-of-thirds' : return { v : [ 0 , w / 3 , 2 * w / 3 ] , h : [ 0 , h / 3 , 2 * h / 3 ] }
46+ case 'golden-ratio' : { const pw = w / GOLDEN_RATIO , ph = h / GOLDEN_RATIO ; return { v : [ 0 , w - pw , pw ] , h : [ 0 , h - ph , ph ] } }
47+ case 'center-cross' : return { v : [ 0 , w / 2 ] , h : [ 0 , h / 2 ] }
48+ case 'square-grid' : return {
49+ v : Array . from ( { length : opts . gridDensity } , ( _ , i ) => ( i * w ) / opts . gridDensity ) ,
50+ h : Array . from ( { length : opts . gridDensity } , ( _ , i ) => ( i * h ) / opts . gridDensity ) ,
5151 }
52- case 'center-cross' :
53- return { v : [ 0 , w / 2 ] , h : [ 0 , h / 2 ] }
54- case 'square-grid' :
55- return {
56- v : Array . from ( { length : opts . gridDensity } , ( _ , i ) => ( i * w ) / opts . gridDensity ) ,
57- h : Array . from ( { length : opts . gridDensity } , ( _ , i ) => ( i * h ) / opts . gridDensity ) ,
58- }
59- default :
60- return null
52+ default : return null
6153 }
6254}
6355
64- function drawExportGrid (
56+ function drawGridOnExport (
6557 ctx : CanvasRenderingContext2D , gridType : GridType , drawFn : DrawFn ,
66- originX : number , originY : number , gw : number , gh : number ,
67- ox : number , oy : number , opts : GridOptions ,
58+ oX : number , oY : number , gw : number , gh : number , ox : number , oy : number , opts : GridOptions ,
6859) {
69- if ( ox === 0 && oy === 0 ) {
70- ctx . save ( )
71- ctx . translate ( originX , originY )
72- ctx . beginPath ( )
73- drawFn ( ctx , gw , gh , opts )
74- ctx . restore ( )
75- return
60+ const positions = ox === 0 && oy === 0 ? null : getLinePositions ( gridType , gw , gh , opts )
61+ if ( ! positions && ( ox === 0 && oy === 0 ) ) {
62+ ctx . save ( ) ; ctx . translate ( oX , oY ) ; ctx . beginPath ( ) ; drawFn ( ctx , gw , gh , opts ) ; ctx . restore ( ) ; return
7663 }
77-
78- const positions = getLinePositions ( gridType , gw , gh , opts )
7964 if ( positions ) {
8065 ctx . beginPath ( )
81- for ( const base of positions . v ) {
82- const x = ( ( base + ox ) % gw + gw ) % gw
83- if ( x > 0.5 ) { ctx . moveTo ( originX + x , originY ) ; ctx . lineTo ( originX + x , originY + gh ) }
84- }
85- for ( const base of positions . h ) {
86- const y = ( ( base + oy ) % gh + gh ) % gh
87- if ( y > 0.5 ) { ctx . moveTo ( originX , originY + y ) ; ctx . lineTo ( originX + gw , originY + y ) }
88- }
66+ for ( const b of positions . v ) { const x = ( ( b + ox ) % gw + gw ) % gw ; if ( x > 0.5 ) { ctx . moveTo ( oX + x , oY ) ; ctx . lineTo ( oX + x , oY + gh ) } }
67+ for ( const b of positions . h ) { const y = ( ( b + oy ) % gh + gh ) % gh ; if ( y > 0.5 ) { ctx . moveTo ( oX , oY + y ) ; ctx . lineTo ( oX + gw , oY + y ) } }
8968 ctx . stroke ( )
9069 } else {
91- const px = ( ( ox % gw ) + gw ) % gw
92- const py = ( ( oy % gh ) + gh ) % gh
93- for ( let dx = - 1 ; dx <= 0 ; dx ++ ) {
94- for ( let dy = - 1 ; dy <= 0 ; dy ++ ) {
95- ctx . save ( )
96- ctx . translate ( originX + px + dx * gw , originY + py + dy * gh )
97- ctx . beginPath ( )
98- drawFn ( ctx , gw , gh , opts )
99- ctx . restore ( )
100- }
70+ const px = ( ( ox % gw ) + gw ) % gw , py = ( ( oy % gh ) + gh ) % gh
71+ for ( let dx = - 1 ; dx <= 0 ; dx ++ ) for ( let dy = - 1 ; dy <= 0 ; dy ++ ) {
72+ ctx . save ( ) ; ctx . translate ( oX + px + dx * gw , oY + py + dy * gh ) ; ctx . beginPath ( ) ; drawFn ( ctx , gw , gh , opts ) ; ctx . restore ( )
10173 }
10274 }
10375}
@@ -111,106 +83,53 @@ export function ExportDialog({
11183 const [ exporting , setExporting ] = useState ( false )
11284 const [ closing , setClosing ] = useState ( false )
11385
114- const MAX_CANVAS_PIXELS = 16_000_000
115- const downscaleInfo = useMemo ( ( ) => {
116- const sw = crop ?. width ?? image . naturalWidth
117- const sh = crop ?. height ?? image . naturalHeight
118- const bw = frameConfig . borderWidth
119- const matW = frameConfig . innerMatEnabled ? frameConfig . innerMatWidth : 0
120- const { width : ew , height : eh } = computeExportDimensions ( sw , sh , bw , matW )
121- if ( ew * eh <= MAX_CANVAS_PIXELS ) return null
122- const scale = Math . sqrt ( MAX_CANVAS_PIXELS / ( ew * eh ) )
123- return { from : `${ sw } ×${ sh } ` , to : `${ Math . floor ( sw * scale ) } ×${ Math . floor ( sh * scale ) } ` }
124- } , [ crop , image , frameConfig ] )
125-
126- const handleClose = useCallback ( ( ) => {
127- setClosing ( true )
128- setTimeout ( onClose , 150 )
129- } , [ onClose ] )
86+ const handleClose = useCallback ( ( ) => { setClosing ( true ) ; setTimeout ( onClose , 150 ) } , [ onClose ] )
13087
13188 const handleExport = useCallback ( async ( ) => {
13289 setExporting ( true )
133-
13490 try {
135- const sx = crop ?. x ?? 0
136- const sy = crop ?. y ?? 0
137- const sw = crop ?. width ?? image . naturalWidth
138- const sh = crop ?. height ?? image . naturalHeight
139-
91+ const sx = crop ?. x ?? 0 , sy = crop ?. y ?? 0
92+ const sw = crop ?. width ?? image . naturalWidth , sh = crop ?. height ?? image . naturalHeight
14093 const bw = frameConfig . borderWidth
14194 const matW = frameConfig . innerMatEnabled ? frameConfig . innerMatWidth : 0
14295 const { width : exportW , height : exportH } = computeExportDimensions ( sw , sh , bw , matW )
14396
144- // iOS Safari limits canvas to ~16M pixels; scale down for large images
145- const MAX_CANVAS_PIXELS = 16_000_000
146- let exportScale = 1
147- if ( exportW * exportH > MAX_CANVAS_PIXELS ) {
148- exportScale = Math . sqrt ( MAX_CANVAS_PIXELS / ( exportW * exportH ) )
149- }
150-
151- const canvas = document . createElement ( 'canvas' )
152- canvas . width = Math . floor ( exportW * exportScale )
153- canvas . height = Math . floor ( exportH * exportScale )
154- const ctx = canvas . getContext ( '2d' ) !
155- if ( exportScale < 1 ) ctx . scale ( exportScale , exportScale )
156-
157- // All drawing below uses original (exportW × exportH) coordinates;
158- // ctx.scale handles the physical pixel reduction automatically.
159-
160- if ( frameConfig . shadowEnabled && bw > 0 ) {
161- drawShadow ( ctx , exportW , exportH , bw , frameConfig . cornerRadius , {
162- color : frameConfig . shadowColor ,
163- blur : frameConfig . shadowBlur ,
164- offsetX : frameConfig . shadowOffsetX ,
165- offsetY : frameConfig . shadowOffsetY ,
166- } )
167- }
168-
169- if ( bw > 0 ) {
170- if ( frameConfig . fillType === 'solid' ) {
171- drawSolidBorder ( ctx , exportW , exportH , frameConfig . solidColor , frameConfig . cornerRadius )
172- } else if ( frameConfig . fillType === 'gradient' ) {
173- drawGradientBorder ( ctx , exportW , exportH , frameConfig . gradientColor1 , frameConfig . gradientColor2 , frameConfig . gradientDirection , frameConfig . cornerRadius )
174- } else {
175- drawTextureBorder ( ctx , exportW , exportH , frameConfig . texture , frameConfig . cornerRadius )
97+ const drawScene = ( ctx : CanvasRenderingContext2D ) => {
98+ if ( frameConfig . shadowEnabled && bw > 0 ) {
99+ drawShadow ( ctx , exportW , exportH , bw , frameConfig . cornerRadius , {
100+ color : frameConfig . shadowColor , blur : frameConfig . shadowBlur ,
101+ offsetX : frameConfig . shadowOffsetX , offsetY : frameConfig . shadowOffsetY ,
102+ } )
176103 }
177- }
178-
179- if ( frameConfig . innerMatEnabled && matW > 0 ) {
180- drawInnerMat ( ctx , exportW , exportH , bw , frameConfig . cornerRadius , matW , frameConfig . innerMatColor )
181- }
182-
183- const imgX = bw + matW
184- const imgY = bw + matW
185- ctx . drawImage ( image , sx , sy , sw , sh , imgX , imgY , sw , sh )
186-
187- if ( includeGrid && activeGrids . length > 0 ) {
188- ctx . save ( )
189- ctx . beginPath ( )
190- ctx . rect ( imgX , imgY , sw , sh )
191- ctx . clip ( )
192- const scaleX = gridDisplaySize && gridDisplaySize . width > 0 ? sw / gridDisplaySize . width : 1
193- const scaleY = gridDisplaySize && gridDisplaySize . height > 0 ? sh / gridDisplaySize . height : 1
194- const ox = ( gridOffset ?. x ?? 0 ) * scaleX
195- const oy = ( gridOffset ?. y ?? 0 ) * scaleY
196- ctx . strokeStyle = gridOptions . color
197- ctx . globalAlpha = gridOptions . opacity
198- ctx . lineWidth = thicknessToPx ( gridOptions . thickness )
199- for ( const gridType of activeGrids ) {
200- const drawFn = GRID_DRAW_MAP [ gridType ]
201- if ( drawFn ) drawExportGrid ( ctx , gridType , drawFn , imgX , imgY , sw , sh , ox , oy , gridOptions )
104+ if ( bw > 0 ) {
105+ if ( frameConfig . fillType === 'solid' ) drawSolidBorder ( ctx , exportW , exportH , frameConfig . solidColor , frameConfig . cornerRadius )
106+ else if ( frameConfig . fillType === 'gradient' ) drawGradientBorder ( ctx , exportW , exportH , frameConfig . gradientColor1 , frameConfig . gradientColor2 , frameConfig . gradientDirection , frameConfig . cornerRadius )
107+ else drawTextureBorder ( ctx , exportW , exportH , frameConfig . texture , frameConfig . cornerRadius )
108+ }
109+ if ( frameConfig . innerMatEnabled && matW > 0 ) drawInnerMat ( ctx , exportW , exportH , bw , frameConfig . cornerRadius , matW , frameConfig . innerMatColor )
110+ const imgX = bw + matW , imgY = bw + matW
111+ ctx . drawImage ( image , sx , sy , sw , sh , imgX , imgY , sw , sh )
112+ if ( includeGrid && activeGrids . length > 0 ) {
113+ ctx . save ( ) ; ctx . beginPath ( ) ; ctx . rect ( imgX , imgY , sw , sh ) ; ctx . clip ( )
114+ const scX = gridDisplaySize && gridDisplaySize . width > 0 ? sw / gridDisplaySize . width : 1
115+ const scY = gridDisplaySize && gridDisplaySize . height > 0 ? sh / gridDisplaySize . height : 1
116+ ctx . strokeStyle = gridOptions . color ; ctx . globalAlpha = gridOptions . opacity ; ctx . lineWidth = thicknessToPx ( gridOptions . thickness )
117+ for ( const gt of activeGrids ) { const fn = GRID_DRAW_MAP [ gt ] ; if ( fn ) drawGridOnExport ( ctx , gt , fn , imgX , imgY , sw , sh , ( gridOffset ?. x ?? 0 ) * scX , ( gridOffset ?. y ?? 0 ) * scY , gridOptions ) }
118+ ctx . restore ( )
202119 }
203- ctx . restore ( )
204120 }
205121
206- const quality = ( originalMimeType === 'image/png' ) ? undefined : 1
207- let blob = await new Promise < Blob | null > ( ( resolve ) => {
208- canvas . toBlob ( resolve , originalMimeType , quality )
209- } )
210- if ( ! blob ) {
211- console . error ( 'Export failed: canvas.toBlob returned null (image may be too large)' )
212- return
122+ let blob : Blob | null
123+ if ( exportW * exportH > TILED_EXPORT_THRESHOLD ) {
124+ blob = await renderTiledExport ( exportW , exportH , drawScene , originalMimeType )
125+ } else {
126+ const canvas = document . createElement ( 'canvas' )
127+ canvas . width = exportW ; canvas . height = exportH
128+ const ctx = canvas . getContext ( '2d' ) !
129+ drawScene ( ctx )
130+ blob = await new Promise < Blob | null > ( ( r ) => canvas . toBlob ( r , originalMimeType , 1 ) )
213131 }
132+ if ( ! blob ) { console . error ( 'Export failed: could not create image blob' ) ; return }
214133
215134 const originalBuffer = await originalFile . arrayBuffer ( )
216135 blob = await transferExif ( originalBuffer , blob , originalMimeType )
@@ -220,62 +139,35 @@ export function ExportDialog({
220139 const fileName = `${ baseName } _edited${ ext } `
221140 const file = new File ( [ blob ] , fileName , { type : originalMimeType } )
222141
223- // Try Web Share API first (iOS Safari doesn't support anchor downloads)
224142 let shared = false
225143 if ( typeof navigator . share === 'function' ) {
226- try {
227- await navigator . share ( { files : [ file ] } )
228- shared = true
229- } catch ( e ) {
230- // AbortError = user cancelled share sheet (still counts as handled)
231- if ( e instanceof DOMException && e . name === 'AbortError' ) shared = true
232- // TypeError = files not supported → fall through to anchor download
233- }
144+ try { await navigator . share ( { files : [ file ] } ) ; shared = true }
145+ catch ( e ) { if ( e instanceof DOMException && e . name === 'AbortError' ) shared = true }
234146 }
235-
236147 if ( ! shared ) {
237148 const url = URL . createObjectURL ( blob )
238- const a = document . createElement ( 'a' )
239- a . href = url
240- a . download = fileName
241- document . body . appendChild ( a )
242- a . click ( )
243- document . body . removeChild ( a )
149+ const a = document . createElement ( 'a' ) ; a . href = url ; a . download = fileName
150+ document . body . appendChild ( a ) ; a . click ( ) ; document . body . removeChild ( a )
244151 setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 30000 )
245152 }
246153 onClose ( )
247- } finally {
248- setExporting ( false )
249- }
154+ } finally { setExporting ( false ) }
250155 } , [ image , crop , frameConfig , includeGrid , activeGrids , gridOptions , gridOffset , gridDisplaySize , originalFile , originalMimeType , onClose ] )
251156
252157 return (
253158 < div className = { `${ styles . overlay } ${ closing ? styles . closing : '' } ` } onClick = { handleClose } >
254159 < div className = { styles . dialog } onClick = { ( e ) => e . stopPropagation ( ) } >
255160 < h3 className = { styles . title } > { t ( 'exportPhoto' ) } </ h3 >
256-
257161 < div className = { styles . info } >
258162 < span > { t ( 'format' ) } { originalMimeType . split ( '/' ) [ 1 ] ?. toUpperCase ( ) ?? 'PNG' } </ span >
259163 < span > { t ( 'quality' ) } { t ( 'qualityMaximum' ) } </ span >
260164 </ div >
261-
262- { downscaleInfo && (
263- < div className = { styles . notice } >
264- { t ( 'downscaleNotice' , { from : downscaleInfo . from , to : downscaleInfo . to } ) }
265- </ div >
266- ) }
267-
268165 { activeGrids . length > 0 && (
269166 < label className = { styles . toggle } >
270- < input
271- type = "checkbox"
272- checked = { includeGrid }
273- onChange = { ( e ) => setIncludeGrid ( e . target . checked ) }
274- />
167+ < input type = "checkbox" checked = { includeGrid } onChange = { ( e ) => setIncludeGrid ( e . target . checked ) } />
275168 < span > { t ( 'includeGridOverlay' ) } </ span >
276169 </ label >
277170 ) }
278-
279171 < div className = { styles . actions } >
280172 < button className = { styles . cancelBtn } onClick = { handleClose } > { t ( 'cancel' ) } </ button >
281173 < button className = { styles . exportBtn } onClick = { handleExport } disabled = { exporting } >
0 commit comments