@@ -50,6 +50,9 @@ class Canvas {
5050 // Start a 1-second interval to update time-dependent widgets (like datetime)
5151 if ( this . updateInterval ) clearInterval ( this . updateInterval ) ;
5252 this . updateInterval = setInterval ( ( ) => {
53+ // SKIP auto-render during active interaction to prevent DOM detachment
54+ if ( this . touchState || this . pinchState || this . dragState || this . panState ) return ;
55+
5356 // Only re-render if there is a datetime widget on the current page to avoid unnecessary overhead
5457 const page = AppState . getCurrentPage ( ) ;
5558 if ( page && page . widgets . some ( w => w . type === 'datetime' ) ) {
@@ -967,17 +970,6 @@ class Canvas {
967970 this . canvas . addEventListener ( "touchstart" , ( ev ) => {
968971 const touches = ev . touches ;
969972
970- // Double-tap detection for zoom reset
971- const now = Date . now ( ) ;
972- if ( touches . length === 1 && now - this . lastTapTime < 300 ) {
973- // Double-tap detected
974- this . zoomReset ( ) ;
975- this . lastTapTime = 0 ;
976- ev . preventDefault ( ) ;
977- return ;
978- }
979- this . lastTapTime = now ;
980-
981973 if ( touches . length === 2 ) {
982974 // Two-finger: start pinch/pan mode
983975 ev . preventDefault ( ) ;
@@ -989,7 +981,7 @@ class Canvas {
989981 startCenterX : ( touches [ 0 ] . clientX + touches [ 1 ] . clientX ) / 2 ,
990982 startCenterY : ( touches [ 0 ] . clientY + touches [ 1 ] . clientY ) / 2
991983 } ;
992- this . touchState = null ; // Cancel any widget drag
984+ this . touchState = null ;
993985 return ;
994986 }
995987
@@ -998,16 +990,15 @@ class Canvas {
998990 const widgetEl = touch . target . closest ( ".widget" ) ;
999991
1000992 if ( widgetEl ) {
1001- // Single touch on widget: prepare for drag
993+ // TOUCHING A WIDGET: Prepare for direct manipulation
994+ // We DO NOT call selectWidget here to avoid a re-render that would
995+ // detach the element from the touch stream.
1002996 ev . preventDefault ( ) ;
1003- const widgetId = widgetEl . dataset . id ;
1004- AppState . selectWidget ( widgetId ) ;
1005997
998+ const widgetId = widgetEl . dataset . id ;
1006999 const widget = AppState . getWidgetById ( widgetId ) ;
10071000 if ( ! widget ) return ;
10081001
1009- const rect = this . canvas . getBoundingClientRect ( ) ;
1010- const zoom = AppState . zoomLevel ;
10111002 const isResizeHandle = touch . target . classList . contains ( "widget-resize-handle" ) ;
10121003
10131004 if ( isResizeHandle ) {
@@ -1017,26 +1008,37 @@ class Canvas {
10171008 startX : touch . clientX ,
10181009 startY : touch . clientY ,
10191010 startW : widget . width ,
1020- startH : widget . height
1011+ startH : widget . height ,
1012+ el : widgetEl
10211013 } ;
10221014 } else {
1023- // Calculate offset for drag
10241015 this . touchState = {
10251016 mode : "move" ,
10261017 id : widgetId ,
10271018 startTouchX : touch . clientX ,
10281019 startTouchY : touch . clientY ,
10291020 startWidgetX : widget . x ,
10301021 startWidgetY : widget . y ,
1031- hasMoved : false // Deadzone tracking
1022+ hasMoved : false ,
1023+ el : widgetEl
10321024 } ;
10331025 }
10341026
10351027 window . addEventListener ( "touchmove" , this . _boundTouchMove , { passive : false } ) ;
10361028 window . addEventListener ( "touchend" , this . _boundTouchEnd ) ;
10371029 window . addEventListener ( "touchcancel" , this . _boundTouchEnd ) ;
1030+
10381031 } else {
1039- // Single touch on empty canvas: start panning
1032+ // TOUCHING EMPTY CANVAS: Pan or double-tap zoom reset
1033+ const now = Date . now ( ) ;
1034+ if ( now - this . lastTapTime < 300 ) {
1035+ this . zoomReset ( ) ;
1036+ this . lastTapTime = 0 ;
1037+ ev . preventDefault ( ) ;
1038+ return ;
1039+ }
1040+ this . lastTapTime = now ;
1041+
10401042 ev . preventDefault ( ) ;
10411043 this . touchState = {
10421044 mode : "pan" ,
@@ -1113,12 +1115,12 @@ class Canvas {
11131115 this . panY = this . touchState . startPanY + dy ;
11141116 this . applyZoom ( ) ;
11151117 } else if ( this . touchState . mode === "move" ) {
1116- // Widget move with 10px deadzone to prevent accidental drags
1118+ // Widget move with small deadzone
11171119 const dx = touch . clientX - this . touchState . startTouchX ;
11181120 const dy = touch . clientY - this . touchState . startTouchY ;
11191121
1120- if ( ! this . touchState . hasMoved && Math . hypot ( dx , dy ) < 10 ) {
1121- return ; // Still in deadzone
1122+ if ( ! this . touchState . hasMoved && Math . hypot ( dx , dy ) < 5 ) {
1123+ return ; // Small deadzone
11221124 }
11231125 this . touchState . hasMoved = true ;
11241126
@@ -1128,29 +1130,22 @@ class Canvas {
11281130 const dims = AppState . getCanvasDimensions ( ) ;
11291131 const zoom = AppState . zoomLevel ;
11301132
1131- // Calculate new position
11321133 let x = this . touchState . startWidgetX + dx / zoom ;
11331134 let y = this . touchState . startWidgetY + dy / zoom ;
11341135
11351136 // Clamp to canvas
11361137 x = Math . max ( 0 , Math . min ( dims . width - widget . width , x ) ) ;
11371138 y = Math . max ( 0 , Math . min ( dims . height - widget . height , y ) ) ;
11381139
1139- // Apply grid/widget snapping
1140- const page = AppState . getCurrentPage ( ) ;
1141- if ( page ?. layout ) {
1142- const snapped = this . _snapToGridCell ( x , y , widget . width , widget . height , page . layout , dims ) ;
1143- x = snapped . x ;
1144- y = snapped . y ;
1145- } else {
1146- const snapped = this . applySnapToPosition ( widget , x , y , false , dims ) ;
1147- x = snapped . x ;
1148- y = snapped . y ;
1149- }
1150-
1140+ // Update internal state
11511141 widget . x = x ;
11521142 widget . y = y ;
1153- this . render ( ) ;
1143+
1144+ // Direct DOM update instead of render() to preserve touch stream
1145+ if ( this . touchState . el ) {
1146+ this . touchState . el . style . left = x + "px" ;
1147+ this . touchState . el . style . top = y + "px" ;
1148+ }
11541149 } else if ( this . touchState . mode === "resize" ) {
11551150 // Widget resize
11561151 const widget = AppState . getWidgetById ( this . touchState . id ) ;
@@ -1180,30 +1175,18 @@ class Canvas {
11801175 }
11811176
11821177 // Clamp to canvas bounds
1183- const minSize = 1 ;
1178+ const minSize = 20 ; // Ensure widget doesn't disappear
11841179 w = Math . max ( minSize , Math . min ( dims . width - widget . x , w ) ) ;
11851180 h = Math . max ( minSize , Math . min ( dims . height - widget . y , h ) ) ;
1186- widget . width = Math . round ( w ) ;
1187- widget . height = Math . round ( h ) ;
11881181
1189- // Special handling for icons and circles
1190- if ( wtype === "icon" || wtype === "weather_icon" || wtype === "battery_icon" || wtype === "wifi_signal" ) {
1191- const props = widget . props || { } ;
1192- if ( props . fit_icon_to_frame ) {
1193- const padding = 4 ;
1194- const maxDim = Math . max ( 8 , Math . min ( widget . width - padding * 2 , widget . height - padding * 2 ) ) ;
1195- props . size = Math . round ( maxDim ) ;
1196- } else {
1197- const newSize = Math . max ( 8 , Math . min ( widget . width , widget . height ) ) ;
1198- props . size = Math . round ( newSize ) ;
1199- }
1200- } else if ( wtype === "shape_circle" ) {
1201- const size = Math . max ( widget . width , widget . height ) ;
1202- widget . width = size ;
1203- widget . height = size ;
1204- }
1182+ widget . width = w ;
1183+ widget . height = h ;
12051184
1206- this . render ( ) ;
1185+ // Direct DOM update instead of render() to preserve touch stream
1186+ if ( this . touchState . el ) {
1187+ this . touchState . el . style . width = w + "px" ;
1188+ this . touchState . el . style . height = h + "px" ;
1189+ }
12071190 }
12081191 }
12091192 }
@@ -1214,15 +1197,45 @@ class Canvas {
12141197 _onTouchEnd ( ev ) {
12151198 if ( this . touchState ) {
12161199 const widgetId = this . touchState . id ;
1217- this . touchState = null ;
1218- this . clearSnapGuides ( ) ;
1200+ const mode = this . touchState . mode ;
1201+ const hasMoved = this . touchState . hasMoved ;
12191202
1203+ // Handle final snapping and selection for widgets
12201204 if ( widgetId ) {
1221- this . _updateWidgetGridCell ( widgetId ) ;
1222- AppState . recordHistory ( ) ;
1223- emit ( EVENTS . STATE_CHANGED ) ;
1205+ const widget = AppState . getWidgetById ( widgetId ) ;
1206+ if ( widget ) {
1207+ if ( mode === "move" && hasMoved ) {
1208+ // Apply final snapping on release
1209+ const dims = AppState . getCanvasDimensions ( ) ;
1210+ const page = AppState . getCurrentPage ( ) ;
1211+ if ( page ?. layout ) {
1212+ const snapped = this . _snapToGridCell ( widget . x , widget . y , widget . width , widget . height , page . layout , dims ) ;
1213+ widget . x = snapped . x ;
1214+ widget . y = snapped . y ;
1215+ } else {
1216+ const snapped = this . applySnapToPosition ( widget , widget . x , widget . y , false , dims ) ;
1217+ widget . x = snapped . x ;
1218+ widget . y = snapped . y ;
1219+ }
1220+ } else if ( mode === "resize" ) {
1221+ // Integer rounding for final dimensions
1222+ widget . width = Math . round ( widget . width ) ;
1223+ widget . height = Math . round ( widget . height ) ;
1224+ }
1225+
1226+ // Perform selection at the end to avoid DOM detachment during gesture
1227+ AppState . selectWidget ( widgetId ) ;
1228+ }
1229+
1230+ if ( ( mode === "move" || mode === "resize" ) && hasMoved ) {
1231+ this . _updateWidgetGridCell ( widgetId ) ;
1232+ AppState . recordHistory ( ) ;
1233+ emit ( EVENTS . STATE_CHANGED ) ;
1234+ }
12241235 }
12251236
1237+ this . touchState = null ;
1238+ this . clearSnapGuides ( ) ;
12261239 this . render ( ) ;
12271240 }
12281241
0 commit comments