@@ -26,6 +26,11 @@ import {
2626
2727@Peekable ( )
2828export class ImageEdgelessBlockComponent extends GfxBlockComponent < ImageBlockModel > {
29+ private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024 ;
30+ private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080 ;
31+ private static readonly LOD_MAX_ZOOM = 0.4 ;
32+ private static readonly LOD_THUMBNAIL_MAX_EDGE = 256 ;
33+
2934 static override styles = css `
3035 affine-edgeless-image {
3136 position: relative;
@@ -63,13 +68,24 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
6368 width: 100%;
6469 height: 100%;
6570 }
71+
72+ affine-edgeless-image .resizable-img {
73+ position: relative;
74+ overflow: hidden;
75+ }
6676 ` ;
6777
6878 resourceController = new ResourceController (
6979 computed ( ( ) => this . model . props . sourceId$ . value ) ,
7080 'Image'
7181 ) ;
7282
83+ private _lodThumbnailUrl : string | null = null ;
84+ private _lodSourceUrl : string | null = null ;
85+ private _lodGeneratingSourceUrl : string | null = null ;
86+ private _lodGenerationToken = 0 ;
87+ private _lastShouldUseLod = false ;
88+
7389 get blobUrl ( ) {
7490 return this . resourceController . blobUrl$ . value ;
7591 }
@@ -96,6 +112,134 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
96112 } ) ;
97113 }
98114
115+ private _isLargeImage ( ) {
116+ const { width = 0 , height = 0 , size = 0 } = this . model . props ;
117+ const pixels = width * height ;
118+ return (
119+ size >= ImageEdgelessBlockComponent . LOD_MIN_IMAGE_BYTES ||
120+ pixels >= ImageEdgelessBlockComponent . LOD_MIN_IMAGE_PIXELS
121+ ) ;
122+ }
123+
124+ private _shouldUseLod ( blobUrl : string | null , zoom = this . gfx . viewport . zoom ) {
125+ return (
126+ Boolean ( blobUrl ) &&
127+ this . _isLargeImage ( ) &&
128+ zoom <= ImageEdgelessBlockComponent . LOD_MAX_ZOOM
129+ ) ;
130+ }
131+
132+ private _revokeLodThumbnail ( ) {
133+ if ( ! this . _lodThumbnailUrl ) {
134+ return ;
135+ }
136+
137+ URL . revokeObjectURL ( this . _lodThumbnailUrl ) ;
138+ this . _lodThumbnailUrl = null ;
139+ }
140+
141+ private _resetLodSource ( blobUrl : string | null ) {
142+ if ( this . _lodSourceUrl === blobUrl ) {
143+ return ;
144+ }
145+
146+ this . _lodGenerationToken += 1 ;
147+ this . _lodGeneratingSourceUrl = null ;
148+ this . _lodSourceUrl = blobUrl ;
149+ this . _revokeLodThumbnail ( ) ;
150+ }
151+
152+ private _createImageElement ( src : string ) {
153+ return new Promise < HTMLImageElement > ( ( resolve , reject ) => {
154+ const image = new Image ( ) ;
155+ image . decoding = 'async' ;
156+ image . onload = ( ) => resolve ( image ) ;
157+ image . onerror = ( ) => reject ( new Error ( 'Failed to load image' ) ) ;
158+ image . src = src ;
159+ } ) ;
160+ }
161+
162+ private _createThumbnailBlob ( image : HTMLImageElement ) {
163+ const maxEdge = ImageEdgelessBlockComponent . LOD_THUMBNAIL_MAX_EDGE ;
164+ const longestEdge = Math . max ( image . naturalWidth , image . naturalHeight ) ;
165+ const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1 ;
166+ const targetWidth = Math . max ( 1 , Math . round ( image . naturalWidth * scale ) ) ;
167+ const targetHeight = Math . max ( 1 , Math . round ( image . naturalHeight * scale ) ) ;
168+
169+ const canvas = document . createElement ( 'canvas' ) ;
170+ canvas . width = targetWidth ;
171+ canvas . height = targetHeight ;
172+ const ctx = canvas . getContext ( '2d' ) ;
173+ if ( ! ctx ) {
174+ return Promise . resolve < Blob | null > ( null ) ;
175+ }
176+ ctx . imageSmoothingEnabled = true ;
177+ ctx . imageSmoothingQuality = 'low' ;
178+ ctx . drawImage ( image , 0 , 0 , targetWidth , targetHeight ) ;
179+
180+ return new Promise < Blob | null > ( resolve => {
181+ canvas . toBlob ( resolve ) ;
182+ } ) ;
183+ }
184+
185+ private _ensureLodThumbnail ( blobUrl : string ) {
186+ if (
187+ this . _lodThumbnailUrl ||
188+ this . _lodGeneratingSourceUrl === blobUrl ||
189+ ! this . _shouldUseLod ( blobUrl )
190+ ) {
191+ return ;
192+ }
193+
194+ const token = ++ this . _lodGenerationToken ;
195+ this . _lodGeneratingSourceUrl = blobUrl ;
196+
197+ void this . _createImageElement ( blobUrl )
198+ . then ( image => this . _createThumbnailBlob ( image ) )
199+ . then ( blob => {
200+ if ( ! blob || token !== this . _lodGenerationToken || ! this . isConnected ) {
201+ return ;
202+ }
203+
204+ const thumbnailUrl = URL . createObjectURL ( blob ) ;
205+ if ( token !== this . _lodGenerationToken || ! this . isConnected ) {
206+ URL . revokeObjectURL ( thumbnailUrl ) ;
207+ return ;
208+ }
209+
210+ this . _revokeLodThumbnail ( ) ;
211+ this . _lodThumbnailUrl = thumbnailUrl ;
212+
213+ if ( this . _shouldUseLod ( this . blobUrl ) ) {
214+ this . requestUpdate ( ) ;
215+ }
216+ } )
217+ . catch ( err => {
218+ if ( token !== this . _lodGenerationToken || ! this . isConnected ) {
219+ return ;
220+ }
221+ console . error ( err ) ;
222+ } )
223+ . finally ( ( ) => {
224+ if ( token === this . _lodGenerationToken ) {
225+ this . _lodGeneratingSourceUrl = null ;
226+ }
227+ } ) ;
228+ }
229+
230+ private _updateLodFromViewport ( zoom : number ) {
231+ const shouldUseLod = this . _shouldUseLod ( this . blobUrl , zoom ) ;
232+ if ( shouldUseLod === this . _lastShouldUseLod ) {
233+ return ;
234+ }
235+
236+ this . _lastShouldUseLod = shouldUseLod ;
237+ if ( shouldUseLod && this . blobUrl ) {
238+ this . _ensureLodThumbnail ( this . blobUrl ) ;
239+ }
240+ this . requestUpdate ( ) ;
241+ }
242+
99243 override connectedCallback ( ) {
100244 super . connectedCallback ( ) ;
101245
@@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
108252
109253 this . disposables . add (
110254 this . model . props . sourceId$ . subscribe ( ( ) => {
255+ this . _resetLodSource ( null ) ;
111256 this . refreshData ( ) ;
112257 } )
113258 ) ;
259+
260+ this . disposables . add (
261+ this . gfx . viewport . viewportUpdated . subscribe ( ( { zoom } ) => {
262+ this . _updateLodFromViewport ( zoom ) ;
263+ } )
264+ ) ;
265+
266+ this . _lastShouldUseLod = this . _shouldUseLod ( this . blobUrl ) ;
267+ }
268+
269+ override disconnectedCallback ( ) {
270+ this . _lodGenerationToken += 1 ;
271+ this . _lodGeneratingSourceUrl = null ;
272+ this . _lodSourceUrl = null ;
273+ this . _revokeLodThumbnail ( ) ;
274+ super . disconnectedCallback ( ) ;
114275 }
115276
116277 override renderGfxBlock ( ) {
117278 const blobUrl = this . blobUrl ;
118279 const { rotate = 0 , size = 0 , caption = 'Image' } = this . model . props ;
280+ this . _resetLodSource ( blobUrl ) ;
119281
120282 const containerStyleMap = styleMap ( {
121283 display : 'flex' ,
@@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
138300 } ) ;
139301
140302 const { loading, icon, description, error, needUpload } = resovledState ;
303+ const shouldUseLod = this . _shouldUseLod ( blobUrl ) ;
304+ if ( shouldUseLod && blobUrl ) {
305+ this . _ensureLodThumbnail ( blobUrl ) ;
306+ }
307+ this . _lastShouldUseLod = shouldUseLod ;
308+ const imageUrl =
309+ shouldUseLod && this . _lodThumbnailUrl ? this . _lodThumbnailUrl : blobUrl ;
141310
142311 return html `
143312 < div class ="affine-image-container " style =${ containerStyleMap } >
@@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
149318 class ="drag-target "
150319 draggable ="false "
151320 loading ="lazy "
152- src =${ blobUrl }
321+ src =${ imageUrl ?? '' }
153322 alt =${ caption }
154323 @error=${ this . _handleError }
155324 />
0 commit comments