@aivue/360-spin 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +345 -0
- package/dist/360-spin.css +1 -0
- package/dist/components/Ai360Spin.vue.d.ts +32 -0
- package/dist/composables/use360Spin.d.ts +16 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +249 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types/index.d.ts +131 -0
- package/dist/vue-shims.d.ts +6 -0
- package/package.json +71 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.1] - 2024-12-06
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Fixed demo to use real 360° product photography sequences instead of static image swaps
|
|
12
|
+
- Removed CORS crossOrigin setting that was causing image loading errors
|
|
13
|
+
- Updated demo with Scaleflex CDN 360° product images (car: 36 frames, Nike: 35 frames)
|
|
14
|
+
- Changed demo mode from "gif" to "frames" for proper frame sequence animation
|
|
15
|
+
- Added frame-rate configuration for smooth 24 FPS animation
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Demo now showcases true 360° rotation with professional product photography
|
|
19
|
+
- Updated demo products to Luxury Car and Nike Sneakers with real frame sequences
|
|
20
|
+
- Improved demo descriptions to clarify real 360° spin functionality
|
|
21
|
+
|
|
22
|
+
## [1.0.0] - 2024-12-06
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Initial release of @aivue/360-spin
|
|
26
|
+
- Core `Ai360Spin` component with static/animated image switching
|
|
27
|
+
- Support for GIF animations
|
|
28
|
+
- Support for frame sequence animations
|
|
29
|
+
- Hover, click, and auto-play triggers
|
|
30
|
+
- Mobile touch and drag-to-spin functionality
|
|
31
|
+
- Preloading of images for better performance
|
|
32
|
+
- Loading indicator with customizable text
|
|
33
|
+
- Configurable frame rate and animation direction
|
|
34
|
+
- Event emissions for animation lifecycle
|
|
35
|
+
- TypeScript support with full type definitions
|
|
36
|
+
- Comprehensive documentation and examples
|
|
37
|
+
- Pre-built CSS classes for common use cases
|
|
38
|
+
- Accessibility features (keyboard navigation, ARIA labels)
|
|
39
|
+
- Responsive design for mobile and desktop
|
|
40
|
+
- Vue 2 and Vue 3 compatibility
|
|
41
|
+
|
|
42
|
+
### Features
|
|
43
|
+
- 🖼️ Static to animated image switching
|
|
44
|
+
- 🎬 GIF and frame sequence support
|
|
45
|
+
- 📱 Mobile drag-to-spin
|
|
46
|
+
- 🎯 Multiple trigger modes (hover, click, auto)
|
|
47
|
+
- ⚡ Image preloading
|
|
48
|
+
- 🎨 Customizable styling
|
|
49
|
+
- ♿ Accessible
|
|
50
|
+
- 📦 E-commerce ready
|
|
51
|
+
|
|
52
|
+
[1.0.0]: https://github.com/reachbrt/vueai/releases/tag/@aivue/360-spin@1.0.0
|
|
53
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# @aivue/360-spin
|
|
2
|
+
|
|
3
|
+
> Interactive 360-degree product image spin component for Vue.js
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@aivue/360-spin)
|
|
6
|
+
[](https://www.npmjs.com/package/@aivue/360-spin)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
## ✨ Features
|
|
10
|
+
|
|
11
|
+
- 🖼️ **Static to Animated**: Show static product image by default, animate on hover/tap
|
|
12
|
+
- 🎬 **Multiple Modes**: Support for GIF animations or frame sequences
|
|
13
|
+
- 📱 **Mobile Optimized**: Touch and drag to spin on mobile devices
|
|
14
|
+
- 🎯 **Flexible Triggers**: Hover, click, or auto-play animations
|
|
15
|
+
- 🛍️ **E-commerce Ready**: Perfect for product cards, carousels, and search results
|
|
16
|
+
- ⚡ **Performance**: Preloading and optimized frame rendering
|
|
17
|
+
- 🎨 **Customizable**: Full control over styling and behavior
|
|
18
|
+
- ♿ **Accessible**: Keyboard navigation and screen reader support
|
|
19
|
+
|
|
20
|
+
## 📦 Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @aivue/360-spin
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 🚀 Quick Start
|
|
27
|
+
|
|
28
|
+
### Basic Usage (GIF Mode)
|
|
29
|
+
|
|
30
|
+
```vue
|
|
31
|
+
<template>
|
|
32
|
+
<Ai360Spin
|
|
33
|
+
static-image="/images/product-static.jpg"
|
|
34
|
+
animated-image="/images/product-360.gif"
|
|
35
|
+
alt="Product 360 view"
|
|
36
|
+
width="400px"
|
|
37
|
+
height="400px"
|
|
38
|
+
/>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<script setup>
|
|
42
|
+
import { Ai360Spin } from '@aivue/360-spin';
|
|
43
|
+
import '@aivue/360-spin/360-spin.css';
|
|
44
|
+
</script>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Frame Sequence Mode
|
|
48
|
+
|
|
49
|
+
```vue
|
|
50
|
+
<template>
|
|
51
|
+
<Ai360Spin
|
|
52
|
+
static-image="/images/product-static.jpg"
|
|
53
|
+
:animated-image="frameUrls"
|
|
54
|
+
mode="frames"
|
|
55
|
+
:frame-rate="30"
|
|
56
|
+
enable-drag-spin
|
|
57
|
+
alt="Product 360 view"
|
|
58
|
+
/>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<script setup>
|
|
62
|
+
import { Ai360Spin } from '@aivue/360-spin';
|
|
63
|
+
import '@aivue/360-spin/360-spin.css';
|
|
64
|
+
|
|
65
|
+
const frameUrls = [
|
|
66
|
+
'/images/frame-001.jpg',
|
|
67
|
+
'/images/frame-002.jpg',
|
|
68
|
+
'/images/frame-003.jpg',
|
|
69
|
+
// ... more frames
|
|
70
|
+
];
|
|
71
|
+
</script>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Product Card Integration
|
|
75
|
+
|
|
76
|
+
```vue
|
|
77
|
+
<template>
|
|
78
|
+
<div class="product-card">
|
|
79
|
+
<Ai360Spin
|
|
80
|
+
:static-image="product.image"
|
|
81
|
+
:animated-image="product.spin360"
|
|
82
|
+
container-class="ai-360-spin--card"
|
|
83
|
+
:alt="product.name"
|
|
84
|
+
trigger="hover"
|
|
85
|
+
/>
|
|
86
|
+
<h3>{{ product.name }}</h3>
|
|
87
|
+
<p>{{ product.price }}</p>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 📖 API Reference
|
|
93
|
+
|
|
94
|
+
### Props
|
|
95
|
+
|
|
96
|
+
| Prop | Type | Default | Description |
|
|
97
|
+
|------|------|---------|-------------|
|
|
98
|
+
| `staticImage` | `string` | **required** | URL of the static product image |
|
|
99
|
+
| `animatedImage` | `string \| string[]` | **required** | GIF URL or array of frame URLs |
|
|
100
|
+
| `mode` | `'gif' \| 'frames' \| 'auto'` | `'auto'` | Animation mode |
|
|
101
|
+
| `trigger` | `'hover' \| 'click' \| 'auto'` | `'hover'` | How to start animation |
|
|
102
|
+
| `width` | `string \| number` | `'100%'` | Container width |
|
|
103
|
+
| `height` | `string \| number` | `'auto'` | Container height |
|
|
104
|
+
| `alt` | `string` | `'Product 360 view'` | Alt text for images |
|
|
105
|
+
| `frameRate` | `number` | `30` | FPS for frame sequence |
|
|
106
|
+
| `loop` | `boolean` | `true` | Whether to loop animation |
|
|
107
|
+
| `direction` | `'clockwise' \| 'counterclockwise'` | `'clockwise'` | Spin direction |
|
|
108
|
+
| `preload` | `boolean` | `true` | Preload images on mount |
|
|
109
|
+
| `showLoading` | `boolean` | `true` | Show loading indicator |
|
|
110
|
+
| `loadingText` | `string` | `'Loading...'` | Loading text |
|
|
111
|
+
| `enableDragSpin` | `boolean` | `true` | Enable drag to spin on mobile |
|
|
112
|
+
| `dragSensitivity` | `number` | `10` | Pixels per frame for drag |
|
|
113
|
+
| `containerClass` | `string` | `''` | CSS class for container |
|
|
114
|
+
| `imageClass` | `string` | `''` | CSS class for images |
|
|
115
|
+
|
|
116
|
+
### Events
|
|
117
|
+
|
|
118
|
+
| Event | Payload | Description |
|
|
119
|
+
|-------|---------|-------------|
|
|
120
|
+
| `animation-start` | `void` | Fired when animation starts |
|
|
121
|
+
| `animation-end` | `void` | Fired when animation ends |
|
|
122
|
+
| `loaded` | `void` | Fired when images are loaded |
|
|
123
|
+
| `error` | `Error` | Fired on loading error |
|
|
124
|
+
| `frame-change` | `number` | Fired when frame changes (frames mode) |
|
|
125
|
+
|
|
126
|
+
## 🎨 Styling
|
|
127
|
+
|
|
128
|
+
The component comes with default styles, but you can customize them:
|
|
129
|
+
|
|
130
|
+
```css
|
|
131
|
+
/* Custom container styles */
|
|
132
|
+
.ai-360-spin {
|
|
133
|
+
border-radius: 12px;
|
|
134
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Custom loading spinner */
|
|
138
|
+
.ai-360-spin__spinner {
|
|
139
|
+
border-top-color: #your-brand-color;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Custom hint badge */
|
|
143
|
+
.ai-360-spin__hint {
|
|
144
|
+
background: rgba(your-color, 0.9);
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Pre-built Classes
|
|
149
|
+
|
|
150
|
+
- `.ai-360-spin--card` - Optimized for product cards (1:1 aspect ratio)
|
|
151
|
+
- `.ai-360-spin--grid` - Optimized for grid layouts (4:3 aspect ratio)
|
|
152
|
+
- `.ai-360-spin--carousel` - Optimized for carousels (full width/height)
|
|
153
|
+
|
|
154
|
+
## 💡 Use Cases
|
|
155
|
+
|
|
156
|
+
### E-commerce Product Listings
|
|
157
|
+
|
|
158
|
+
```vue
|
|
159
|
+
<div class="product-grid">
|
|
160
|
+
<div v-for="product in products" :key="product.id" class="product-item">
|
|
161
|
+
<Ai360Spin
|
|
162
|
+
:static-image="product.thumbnail"
|
|
163
|
+
:animated-image="product.spin360Gif"
|
|
164
|
+
container-class="ai-360-spin--grid"
|
|
165
|
+
:alt="product.name"
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Product Detail Page
|
|
172
|
+
|
|
173
|
+
```vue
|
|
174
|
+
<Ai360Spin
|
|
175
|
+
:static-image="product.mainImage"
|
|
176
|
+
:animated-image="product.frames"
|
|
177
|
+
mode="frames"
|
|
178
|
+
:frame-rate="60"
|
|
179
|
+
enable-drag-spin
|
|
180
|
+
width="600px"
|
|
181
|
+
height="600px"
|
|
182
|
+
trigger="click"
|
|
183
|
+
/>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Carousel Integration
|
|
187
|
+
|
|
188
|
+
```vue
|
|
189
|
+
<div class="carousel">
|
|
190
|
+
<div v-for="item in carouselItems" :key="item.id" class="carousel-slide">
|
|
191
|
+
<Ai360Spin
|
|
192
|
+
:static-image="item.image"
|
|
193
|
+
:animated-image="item.spin"
|
|
194
|
+
container-class="ai-360-spin--carousel"
|
|
195
|
+
trigger="hover"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## 🔧 Advanced Usage
|
|
202
|
+
|
|
203
|
+
### Using the Composable
|
|
204
|
+
|
|
205
|
+
```vue
|
|
206
|
+
<script setup>
|
|
207
|
+
import { use360Spin } from '@aivue/360-spin';
|
|
208
|
+
|
|
209
|
+
const config = {
|
|
210
|
+
staticImage: '/product.jpg',
|
|
211
|
+
animatedImage: frameUrls,
|
|
212
|
+
mode: 'frames',
|
|
213
|
+
frameRate: 30
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const {
|
|
217
|
+
isAnimating,
|
|
218
|
+
isLoading,
|
|
219
|
+
currentFrameIndex,
|
|
220
|
+
startAnimation,
|
|
221
|
+
stopAnimation,
|
|
222
|
+
preloadImages
|
|
223
|
+
} = use360Spin(config, emit);
|
|
224
|
+
|
|
225
|
+
// Manually control animation
|
|
226
|
+
function handleCustomTrigger() {
|
|
227
|
+
if (isAnimating.value) {
|
|
228
|
+
stopAnimation();
|
|
229
|
+
} else {
|
|
230
|
+
startAnimation();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
</script>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Programmatic Control
|
|
237
|
+
|
|
238
|
+
```vue
|
|
239
|
+
<template>
|
|
240
|
+
<div>
|
|
241
|
+
<Ai360Spin
|
|
242
|
+
ref="spinRef"
|
|
243
|
+
:static-image="staticImg"
|
|
244
|
+
:animated-image="frames"
|
|
245
|
+
trigger="click"
|
|
246
|
+
@animation-start="onStart"
|
|
247
|
+
@animation-end="onEnd"
|
|
248
|
+
@frame-change="onFrameChange"
|
|
249
|
+
/>
|
|
250
|
+
<button @click="toggleSpin">Toggle Spin</button>
|
|
251
|
+
</div>
|
|
252
|
+
</template>
|
|
253
|
+
|
|
254
|
+
<script setup>
|
|
255
|
+
import { ref } from 'vue';
|
|
256
|
+
import { Ai360Spin } from '@aivue/360-spin';
|
|
257
|
+
|
|
258
|
+
const spinRef = ref(null);
|
|
259
|
+
|
|
260
|
+
function onStart() {
|
|
261
|
+
console.log('Animation started');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function onEnd() {
|
|
265
|
+
console.log('Animation ended');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function onFrameChange(frameIndex) {
|
|
269
|
+
console.log('Current frame:', frameIndex);
|
|
270
|
+
}
|
|
271
|
+
</script>
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## 📱 Mobile Optimization
|
|
275
|
+
|
|
276
|
+
The component automatically optimizes for mobile devices:
|
|
277
|
+
|
|
278
|
+
- **Touch Events**: Drag to spin through frames
|
|
279
|
+
- **Performance**: Optimized rendering for mobile browsers
|
|
280
|
+
- **Responsive**: Adapts to different screen sizes
|
|
281
|
+
- **Touch Feedback**: Visual feedback for touch interactions
|
|
282
|
+
|
|
283
|
+
## 🎯 Best Practices
|
|
284
|
+
|
|
285
|
+
### Frame Sequence Tips
|
|
286
|
+
|
|
287
|
+
1. **Frame Count**: Use 24-36 frames for smooth rotation
|
|
288
|
+
2. **Image Size**: Optimize images (WebP format recommended)
|
|
289
|
+
3. **Naming**: Use sequential naming (frame-001.jpg, frame-002.jpg, etc.)
|
|
290
|
+
4. **Preloading**: Enable preloading for better UX
|
|
291
|
+
|
|
292
|
+
### Performance Optimization
|
|
293
|
+
|
|
294
|
+
```vue
|
|
295
|
+
<Ai360Spin
|
|
296
|
+
:static-image="product.thumbnail"
|
|
297
|
+
:animated-image="product.frames"
|
|
298
|
+
:preload="true"
|
|
299
|
+
:frame-rate="24"
|
|
300
|
+
loading-image="/placeholder.jpg"
|
|
301
|
+
/>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Accessibility
|
|
305
|
+
|
|
306
|
+
```vue
|
|
307
|
+
<Ai360Spin
|
|
308
|
+
:static-image="product.image"
|
|
309
|
+
:animated-image="product.spin"
|
|
310
|
+
:alt="`360-degree view of ${product.name}`"
|
|
311
|
+
role="img"
|
|
312
|
+
aria-label="Interactive product view"
|
|
313
|
+
/>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## 🌐 Browser Support
|
|
317
|
+
|
|
318
|
+
- Chrome/Edge (latest)
|
|
319
|
+
- Firefox (latest)
|
|
320
|
+
- Safari (latest)
|
|
321
|
+
- Mobile browsers (iOS Safari, Chrome Mobile)
|
|
322
|
+
|
|
323
|
+
## 📄 License
|
|
324
|
+
|
|
325
|
+
MIT © [reachbrt](https://github.com/reachbrt)
|
|
326
|
+
|
|
327
|
+
## 🤝 Contributing
|
|
328
|
+
|
|
329
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
330
|
+
|
|
331
|
+
## 🔗 Links
|
|
332
|
+
|
|
333
|
+
- [GitHub Repository](https://github.com/reachbrt/vueai)
|
|
334
|
+
- [Demo](https://aivue.netlify.app)
|
|
335
|
+
- [Report Issues](https://github.com/reachbrt/vueai/issues)
|
|
336
|
+
|
|
337
|
+
## 📝 Changelog
|
|
338
|
+
|
|
339
|
+
See [CHANGELOG.md](./CHANGELOG.md) for details.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
Made with ❤️ by [reachbrt](https://github.com/reachbrt)
|
|
344
|
+
|
|
345
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ai-360-spin[data-v-915b2a52]{position:relative;display:inline-block;overflow:hidden;cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.ai-360-spin__image[data-v-915b2a52]{width:100%;height:100%;-o-object-fit:contain;object-fit:contain;display:block;transition:opacity .3s ease}.ai-360-spin__image--static[data-v-915b2a52]{opacity:1}.ai-360-spin__image--animated[data-v-915b2a52],.ai-360-spin__image--frame[data-v-915b2a52]{position:absolute;top:0;left:0;opacity:1}.ai-360-spin--animating .ai-360-spin__image--static[data-v-915b2a52]{opacity:0}.ai-360-spin__loading[data-v-915b2a52]{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#ffffffe6;z-index:10}.ai-360-spin__spinner[data-v-915b2a52]{width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #3498db;border-radius:50%;animation:ai-360-spin-rotate-915b2a52 1s linear infinite}@keyframes ai-360-spin-rotate-915b2a52{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.ai-360-spin__loading-text[data-v-915b2a52]{margin-top:12px;font-size:14px;color:#666}.ai-360-spin__hint[data-v-915b2a52]{position:absolute;bottom:12px;left:50%;transform:translate(-50%);display:flex;align-items:center;gap:6px;padding:8px 16px;background:#000000b3;color:#fff;border-radius:20px;font-size:12px;pointer-events:none;opacity:0;transition:opacity .3s ease}.ai-360-spin:hover .ai-360-spin__hint[data-v-915b2a52]{opacity:1}.ai-360-spin__hint-icon[data-v-915b2a52]{width:16px;height:16px;stroke-width:2}@media(max-width:768px){.ai-360-spin[data-v-915b2a52]{touch-action:none}.ai-360-spin__hint[data-v-915b2a52]{font-size:11px;padding:6px 12px}}.ai-360-spin{position:relative;display:inline-block;overflow:hidden;cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;background:#f5f5f5}.ai-360-spin__image{width:100%;height:100%;-o-object-fit:contain;object-fit:contain;display:block;transition:opacity .3s ease}.ai-360-spin__image--static{opacity:1}.ai-360-spin__image--animated,.ai-360-spin__image--frame{position:absolute;top:0;left:0;opacity:1}.ai-360-spin--animating .ai-360-spin__image--static{opacity:0}.ai-360-spin__loading{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#fffffff2;z-index:10}.ai-360-spin__spinner{width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #3498db;border-radius:50%;animation:ai-360-spin-rotate 1s linear infinite}@keyframes ai-360-spin-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.ai-360-spin__loading-text{margin-top:12px;font-size:14px;color:#666;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,sans-serif}.ai-360-spin__hint{position:absolute;bottom:12px;left:50%;transform:translate(-50%);display:flex;align-items:center;gap:6px;padding:8px 16px;background:#000000b3;color:#fff;border-radius:20px;font-size:12px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;pointer-events:none;opacity:0;transition:opacity .3s ease;z-index:5}.ai-360-spin:hover .ai-360-spin__hint{opacity:1}.ai-360-spin__hint-icon{width:16px;height:16px;stroke-width:2}.ai-360-spin--card{width:100%;aspect-ratio:1 / 1}.ai-360-spin--card .ai-360-spin__image{-o-object-fit:cover;object-fit:cover}.ai-360-spin--carousel{width:100%;height:100%}.ai-360-spin--grid{width:100%;aspect-ratio:4 / 3}@media(max-width:768px){.ai-360-spin{touch-action:none}.ai-360-spin__hint{font-size:11px;padding:6px 12px;bottom:8px}.ai-360-spin__hint-icon{width:14px;height:14px}.ai-360-spin__loading-text{font-size:12px}.ai-360-spin__spinner{width:32px;height:32px;border-width:3px}}.ai-360-spin:focus{outline:2px solid #3498db;outline-offset:2px}.ai-360-spin:focus:not(:focus-visible){outline:none}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Spin360Config } from '../types';
|
|
2
|
+
declare const _default: import('vue').DefineComponent<Spin360Config, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
3
|
+
loaded: () => any;
|
|
4
|
+
error: (error: Error) => any;
|
|
5
|
+
"animation-start": () => any;
|
|
6
|
+
"animation-end": () => any;
|
|
7
|
+
"frame-change": (frame: number) => any;
|
|
8
|
+
}, string, import('vue').PublicProps, Readonly<Spin360Config> & Readonly<{
|
|
9
|
+
onLoaded?: (() => any) | undefined;
|
|
10
|
+
onError?: ((error: Error) => any) | undefined;
|
|
11
|
+
"onAnimation-start"?: (() => any) | undefined;
|
|
12
|
+
"onAnimation-end"?: (() => any) | undefined;
|
|
13
|
+
"onFrame-change"?: ((frame: number) => any) | undefined;
|
|
14
|
+
}>, {
|
|
15
|
+
mode: import('..').SpinMode;
|
|
16
|
+
trigger: import('..').SpinTrigger;
|
|
17
|
+
width: string | number;
|
|
18
|
+
height: string | number;
|
|
19
|
+
alt: string;
|
|
20
|
+
frameRate: number;
|
|
21
|
+
loop: boolean;
|
|
22
|
+
reverseOnSecondHover: boolean;
|
|
23
|
+
direction: import('..').SpinDirection;
|
|
24
|
+
preload: boolean;
|
|
25
|
+
showLoading: boolean;
|
|
26
|
+
loadingText: string;
|
|
27
|
+
enableDragSpin: boolean;
|
|
28
|
+
dragSensitivity: number;
|
|
29
|
+
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {
|
|
30
|
+
containerRef: HTMLDivElement;
|
|
31
|
+
}, HTMLDivElement>;
|
|
32
|
+
export default _default;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Spin360Config, SpinMode } from '../types';
|
|
2
|
+
export declare function use360Spin(props: Spin360Config, emit: any): {
|
|
3
|
+
isAnimating: import('vue').Ref<boolean, boolean>;
|
|
4
|
+
isLoading: import('vue').Ref<boolean, boolean>;
|
|
5
|
+
currentFrameIndex: import('vue').Ref<number, number>;
|
|
6
|
+
currentMode: import('vue').ComputedRef<SpinMode>;
|
|
7
|
+
animatedImageUrl: import('vue').ComputedRef<string>;
|
|
8
|
+
currentFrameUrl: import('vue').ComputedRef<string>;
|
|
9
|
+
totalFrames: import('vue').ComputedRef<number>;
|
|
10
|
+
startAnimation: () => void;
|
|
11
|
+
stopAnimation: () => void;
|
|
12
|
+
preloadImages: () => Promise<void>;
|
|
13
|
+
handleDragStart: (event: TouchEvent | MouseEvent) => void;
|
|
14
|
+
handleDragMove: (event: TouchEvent | MouseEvent) => void;
|
|
15
|
+
handleDragEnd: (_event: TouchEvent | MouseEvent) => void;
|
|
16
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { App } from 'vue';
|
|
2
|
+
import { default as Ai360Spin } from './components/Ai360Spin.vue';
|
|
3
|
+
import { use360Spin } from './composables/use360Spin';
|
|
4
|
+
import { Spin360Config, SpinMode, SpinTrigger, SpinDirection, Spin360Events } from './types';
|
|
5
|
+
export { Ai360Spin };
|
|
6
|
+
export { use360Spin };
|
|
7
|
+
export type { Spin360Config, SpinMode, SpinTrigger, SpinDirection, Spin360Events };
|
|
8
|
+
declare const _default: {
|
|
9
|
+
install(app: App): void;
|
|
10
|
+
};
|
|
11
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const e=require("vue");function F(i,d){const n=e.ref(!1),c=e.ref(!0),o=e.ref(0),r=e.ref(null),l=e.ref([]),v=e.ref(!1),_=e.ref(0),A=e.ref(0),u=e.computed(()=>i.mode&&i.mode!=="auto"?i.mode:Array.isArray(i.animatedImage)?"frames":"gif"),y=e.computed(()=>u.value==="gif"&&typeof i.animatedImage=="string"?i.animatedImage:""),k=e.computed(()=>{if(u.value==="frames"&&Array.isArray(i.animatedImage)){const t=i.animatedImage,s=o.value%t.length;return t[s]}return""}),I=e.computed(()=>Array.isArray(i.animatedImage)?i.animatedImage:[]),m=e.computed(()=>I.value.length);async function D(){c.value=!0;try{if(u.value==="gif")await S(y.value);else if(u.value==="frames"){const t=I.value.map(f=>S(f)),s=await Promise.all(t);l.value=s}await S(i.staticImage),c.value=!1,d("loaded")}catch(t){c.value=!1,d("error",t)}}function S(t,s=5e3){return new Promise((f,h)=>{const g=new Image,a=setTimeout(()=>{h(new Error(`Image load timeout: ${t}`))},s);g.onload=()=>{clearTimeout(a),f(g)},g.onerror=()=>{clearTimeout(a),h(new Error(`Failed to load image: ${t}`))},g.src=t})}function C(){n.value||c.value||(n.value=!0,d("animation-start"),u.value==="frames"&&M())}function w(){n.value&&(n.value=!1,d("animation-end"),r.value!==null&&(cancelAnimationFrame(r.value),r.value=null),u.value==="frames"&&(o.value=0))}function M(){if(m.value===0)return;const t=1e3/(i.frameRate||30);let s=Date.now();function f(){const h=Date.now();if(h-s>=t&&(i.direction==="clockwise"?o.value=(o.value+1)%m.value:o.value=o.value===0?m.value-1:o.value-1,d("frame-change",o.value),s=h,!i.loop&&o.value===0)){w();return}n.value&&(r.value=requestAnimationFrame(f))}r.value=requestAnimationFrame(f)}function B(t){u.value!=="frames"||m.value===0||(v.value=!0,A.value=o.value,t instanceof TouchEvent?_.value=t.touches[0].clientX:_.value=t.clientX,n.value&&w())}function E(t){if(!v.value||u.value!=="frames")return;t.preventDefault();let s;t instanceof TouchEvent?s=t.touches[0].clientX:s=t.clientX;const f=s-_.value,h=i.dragSensitivity||10,g=Math.floor(f/h);let a=A.value+g;for(;a<0;)a+=m.value;for(;a>=m.value;)a-=m.value;a!==o.value&&(o.value=a,d("frame-change",o.value))}function T(t){v.value=!1}return e.onUnmounted(()=>{r.value!==null&&cancelAnimationFrame(r.value)}),{isAnimating:n,isLoading:c,currentFrameIndex:o,currentMode:u,animatedImageUrl:y,currentFrameUrl:k,totalFrames:m,startAnimation:C,stopAnimation:w,preloadImages:D,handleDragStart:B,handleDragMove:E,handleDragEnd:T}}const N={key:0,class:"ai-360-spin__loading"},V={class:"ai-360-spin__loading-text"},b=["src","alt"],P=["src","alt"],X=["src","alt"],z={key:3,class:"ai-360-spin__hint"},U=e.defineComponent({__name:"Ai360Spin",props:{staticImage:{},animatedImage:{},mode:{default:"auto"},trigger:{default:"hover"},width:{default:"100%"},height:{default:"auto"},alt:{default:"Product 360 view"},frameRate:{default:30},loop:{type:Boolean,default:!0},reverseOnSecondHover:{type:Boolean,default:!1},direction:{default:"clockwise"},preload:{type:Boolean,default:!0},loadingImage:{},containerClass:{},imageClass:{},showLoading:{type:Boolean,default:!0},loadingText:{default:"Loading..."},enableDragSpin:{type:Boolean,default:!0},dragSensitivity:{default:10}},emits:["animation-start","animation-end","loaded","error","frame-change"],setup(i,{emit:d}){const n=i,c=d,o=e.ref(null),{isAnimating:r,isLoading:l,currentMode:v,animatedImageUrl:_,currentFrameUrl:A,startAnimation:u,stopAnimation:y,preloadImages:k,handleDragStart:I,handleDragMove:m,handleDragEnd:D}=F(n,c),S=e.computed(()=>({width:typeof n.width=="number"?`${n.width}px`:n.width,height:typeof n.height=="number"?`${n.height}px`:n.height})),C=e.computed(()=>n.trigger==="hover"&&!r.value),w=e.computed(()=>"Hover to spin");function M(){n.trigger==="hover"&&u()}function B(){n.trigger==="hover"&&y()}function E(){n.trigger==="click"&&(r.value?y():u())}function T(a){n.enableDragSpin&&v.value==="frames"?I(a):n.trigger==="click"&&E()}function t(a){n.enableDragSpin&&v.value==="frames"&&m(a)}function s(a){n.enableDragSpin&&v.value==="frames"&&D(a)}function f(){console.log("[Ai360Spin] Static image loaded, clearing loading state"),l.value&&(l.value=!1),console.log("[Ai360Spin] isLoading after static load:",l.value)}function h(){c("loaded")}function g(a){var p;console.error("Image failed to load:",(p=a.target)==null?void 0:p.src),l.value&&(l.value=!1),c("error",new Error("Failed to load image"))}return e.onMounted(async()=>{if(console.log("[Ai360Spin] onMounted - preload:",n.preload,"isLoading:",l.value),n.preload)try{await k()}catch(a){console.error("[Ai360Spin] Preload failed:",a),l.value=!1}else console.log("[Ai360Spin] Preload disabled, clearing loading state"),l.value=!1,console.log("[Ai360Spin] isLoading after clear:",l.value);n.trigger==="auto"&&u()}),(a,p)=>(e.openBlock(),e.createElementBlock("div",{ref_key:"containerRef",ref:o,class:e.normalizeClass(["ai-360-spin",a.containerClass,{"ai-360-spin--animating":e.unref(r),"ai-360-spin--loading":e.unref(l)}]),style:e.normalizeStyle(S.value),onMouseenter:M,onMouseleave:B,onClick:E,onTouchstart:T,onTouchmove:t,onTouchend:s},[e.unref(l)&&a.showLoading?(e.openBlock(),e.createElementBlock("div",N,[p[0]||(p[0]=e.createElementVNode("div",{class:"ai-360-spin__spinner"},null,-1)),e.createElementVNode("p",V,e.toDisplayString(a.loadingText),1)])):e.createCommentVNode("",!0),e.withDirectives(e.createElementVNode("img",{src:a.staticImage,alt:a.alt,class:e.normalizeClass(["ai-360-spin__image","ai-360-spin__image--static",a.imageClass]),onLoad:f,onError:g},null,42,b),[[e.vShow,!e.unref(r)&&!e.unref(l)]]),e.unref(v)==="gif"&&!e.unref(l)?e.withDirectives((e.openBlock(),e.createElementBlock("img",{key:1,src:e.unref(_),alt:a.alt,class:e.normalizeClass(["ai-360-spin__image","ai-360-spin__image--animated",a.imageClass]),onLoad:h,onError:g},null,42,P)),[[e.vShow,e.unref(r)]]):e.createCommentVNode("",!0),e.unref(v)==="frames"&&!e.unref(l)?e.withDirectives((e.openBlock(),e.createElementBlock("img",{key:2,src:e.unref(A),alt:a.alt,class:e.normalizeClass(["ai-360-spin__image","ai-360-spin__image--frame",a.imageClass])},null,10,X)),[[e.vShow,e.unref(r)]]):e.createCommentVNode("",!0),!e.unref(r)&&!e.unref(l)&&C.value?(e.openBlock(),e.createElementBlock("div",z,[p[1]||(p[1]=e.createElementVNode("svg",{class:"ai-360-spin__hint-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor"},[e.createElementVNode("path",{d:"M12 2L2 7l10 5 10-5-10-5z"}),e.createElementVNode("path",{d:"M2 17l10 5 10-5"}),e.createElementVNode("path",{d:"M2 12l10 5 10-5"})],-1)),e.createElementVNode("span",null,e.toDisplayString(w.value),1)])):e.createCommentVNode("",!0)],38))}}),R=(i,d)=>{const n=i.__vccOpts||i;for(const[c,o]of d)n[c]=o;return n},L=R(U,[["__scopeId","data-v-915b2a52"]]),$={install(i){i.component("Ai360Spin",L)}};exports.Ai360Spin=L;exports.default=$;exports.use360Spin=F;
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/composables/use360Spin.ts","../src/components/Ai360Spin.vue","../src/index.ts"],"sourcesContent":["import { ref, computed, onUnmounted } from 'vue';\nimport type { Spin360Config, SpinMode } from '../types';\n\nexport function use360Spin(\n props: Spin360Config,\n emit: any\n) {\n const isAnimating = ref(false);\n const isLoading = ref(true);\n const currentFrameIndex = ref(0);\n const animationFrameId = ref<number | null>(null);\n const preloadedImages = ref<HTMLImageElement[]>([]);\n \n // Touch/drag state\n const isDragging = ref(false);\n const dragStartX = ref(0);\n const dragStartFrame = ref(0);\n\n // Determine animation mode\n const currentMode = computed<SpinMode>(() => {\n if (props.mode && props.mode !== 'auto') {\n return props.mode;\n }\n // Auto-detect based on animatedImage type\n return Array.isArray(props.animatedImage) ? 'frames' : 'gif';\n });\n\n // Get animated image URL (for GIF mode)\n const animatedImageUrl = computed(() => {\n if (currentMode.value === 'gif' && typeof props.animatedImage === 'string') {\n return props.animatedImage;\n }\n return '';\n });\n\n // Get current frame URL (for frames mode)\n const currentFrameUrl = computed(() => {\n if (currentMode.value === 'frames' && Array.isArray(props.animatedImage)) {\n const frames = props.animatedImage;\n const index = currentFrameIndex.value % frames.length;\n return frames[index];\n }\n return '';\n });\n\n // Get frame array\n const frameArray = computed(() => {\n if (Array.isArray(props.animatedImage)) {\n return props.animatedImage;\n }\n return [];\n });\n\n // Total number of frames\n const totalFrames = computed(() => frameArray.value.length);\n\n // Preload all images\n async function preloadImages() {\n isLoading.value = true;\n\n try {\n if (currentMode.value === 'gif') {\n // Preload GIF\n await loadImage(animatedImageUrl.value);\n } else if (currentMode.value === 'frames') {\n // Preload all frames\n const loadPromises = frameArray.value.map(url => loadImage(url));\n const images = await Promise.all(loadPromises);\n preloadedImages.value = images;\n }\n\n // Also preload static image\n await loadImage(props.staticImage);\n\n isLoading.value = false;\n emit('loaded');\n } catch (error) {\n isLoading.value = false;\n emit('error', error);\n }\n }\n\n // Load a single image with timeout\n function loadImage(url: string, timeout = 5000): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const img = new Image();\n\n // Don't set crossOrigin - it causes CORS issues with many image services\n // If you need CORS support, the image server must explicitly allow it\n\n const timeoutId = setTimeout(() => {\n reject(new Error(`Image load timeout: ${url}`));\n }, timeout);\n\n img.onload = () => {\n clearTimeout(timeoutId);\n resolve(img);\n };\n\n img.onerror = () => {\n clearTimeout(timeoutId);\n reject(new Error(`Failed to load image: ${url}`));\n };\n\n img.src = url;\n });\n }\n\n // Start animation\n function startAnimation() {\n if (isAnimating.value || isLoading.value) return;\n\n isAnimating.value = true;\n emit('animation-start');\n\n if (currentMode.value === 'frames') {\n startFrameAnimation();\n }\n }\n\n // Stop animation\n function stopAnimation() {\n if (!isAnimating.value) return;\n\n isAnimating.value = false;\n emit('animation-end');\n\n if (animationFrameId.value !== null) {\n cancelAnimationFrame(animationFrameId.value);\n animationFrameId.value = null;\n }\n\n // Reset to first frame\n if (currentMode.value === 'frames') {\n currentFrameIndex.value = 0;\n }\n }\n\n // Start frame-by-frame animation\n function startFrameAnimation() {\n if (totalFrames.value === 0) return;\n\n const frameDelay = 1000 / (props.frameRate || 30);\n let lastFrameTime = Date.now();\n\n function animate() {\n const now = Date.now();\n const elapsed = now - lastFrameTime;\n\n if (elapsed >= frameDelay) {\n // Update frame\n if (props.direction === 'clockwise') {\n currentFrameIndex.value = (currentFrameIndex.value + 1) % totalFrames.value;\n } else {\n currentFrameIndex.value = currentFrameIndex.value === 0 \n ? totalFrames.value - 1 \n : currentFrameIndex.value - 1;\n }\n\n emit('frame-change', currentFrameIndex.value);\n lastFrameTime = now;\n\n // Check if we should loop\n if (!props.loop && currentFrameIndex.value === 0) {\n stopAnimation();\n return;\n }\n }\n\n if (isAnimating.value) {\n animationFrameId.value = requestAnimationFrame(animate);\n }\n }\n\n animationFrameId.value = requestAnimationFrame(animate);\n }\n\n // Handle drag start\n function handleDragStart(event: TouchEvent | MouseEvent) {\n if (currentMode.value !== 'frames' || totalFrames.value === 0) return;\n\n isDragging.value = true;\n dragStartFrame.value = currentFrameIndex.value;\n\n if (event instanceof TouchEvent) {\n dragStartX.value = event.touches[0].clientX;\n } else {\n dragStartX.value = event.clientX;\n }\n\n // Stop auto animation if running\n if (isAnimating.value) {\n stopAnimation();\n }\n }\n\n // Handle drag move\n function handleDragMove(event: TouchEvent | MouseEvent) {\n if (!isDragging.value || currentMode.value !== 'frames') return;\n\n event.preventDefault();\n\n let currentX: number;\n if (event instanceof TouchEvent) {\n currentX = event.touches[0].clientX;\n } else {\n currentX = event.clientX;\n }\n\n const deltaX = currentX - dragStartX.value;\n const sensitivity = props.dragSensitivity || 10;\n const frameDelta = Math.floor(deltaX / sensitivity);\n\n let newFrame = dragStartFrame.value + frameDelta;\n \n // Wrap around\n while (newFrame < 0) newFrame += totalFrames.value;\n while (newFrame >= totalFrames.value) newFrame -= totalFrames.value;\n\n if (newFrame !== currentFrameIndex.value) {\n currentFrameIndex.value = newFrame;\n emit('frame-change', currentFrameIndex.value);\n }\n }\n\n // Handle drag end\n function handleDragEnd(_event: TouchEvent | MouseEvent) {\n isDragging.value = false;\n }\n\n // Cleanup on unmount\n onUnmounted(() => {\n if (animationFrameId.value !== null) {\n cancelAnimationFrame(animationFrameId.value);\n }\n });\n\n return {\n isAnimating,\n isLoading,\n currentFrameIndex,\n currentMode,\n animatedImageUrl,\n currentFrameUrl,\n totalFrames,\n startAnimation,\n stopAnimation,\n preloadImages,\n handleDragStart,\n handleDragMove,\n handleDragEnd\n };\n}\n\n","<template>\n <div\n ref=\"containerRef\"\n :class=\"['ai-360-spin', containerClass, { 'ai-360-spin--animating': isAnimating, 'ai-360-spin--loading': isLoading }]\"\n :style=\"containerStyle\"\n @mouseenter=\"handleMouseEnter\"\n @mouseleave=\"handleMouseLeave\"\n @click=\"handleClick\"\n @touchstart=\"handleTouchStart\"\n @touchmove=\"handleTouchMove\"\n @touchend=\"handleTouchEnd\"\n >\n <!-- Loading State -->\n <div v-if=\"isLoading && showLoading\" class=\"ai-360-spin__loading\">\n <div class=\"ai-360-spin__spinner\"></div>\n <p class=\"ai-360-spin__loading-text\">{{ loadingText }}</p>\n </div>\n\n <!-- Static Image -->\n <img\n v-show=\"!isAnimating && !isLoading\"\n :src=\"staticImage\"\n :alt=\"alt\"\n :class=\"['ai-360-spin__image', 'ai-360-spin__image--static', imageClass]\"\n @load=\"handleStaticImageLoad\"\n @error=\"handleImageError\"\n />\n\n <!-- Animated Image (GIF mode) -->\n <img\n v-if=\"currentMode === 'gif' && !isLoading\"\n v-show=\"isAnimating\"\n :src=\"animatedImageUrl\"\n :alt=\"alt\"\n :class=\"['ai-360-spin__image', 'ai-360-spin__image--animated', imageClass]\"\n @load=\"handleAnimatedImageLoad\"\n @error=\"handleImageError\"\n />\n\n <!-- Frame Sequence Mode -->\n <img\n v-if=\"currentMode === 'frames' && !isLoading\"\n v-show=\"isAnimating\"\n :src=\"currentFrameUrl\"\n :alt=\"alt\"\n :class=\"['ai-360-spin__image', 'ai-360-spin__image--frame', imageClass]\"\n />\n\n <!-- Hover Hint (optional) -->\n <div v-if=\"!isAnimating && !isLoading && showHint\" class=\"ai-360-spin__hint\">\n <svg class=\"ai-360-spin__hint-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\">\n <path d=\"M12 2L2 7l10 5 10-5-10-5z\"/>\n <path d=\"M2 17l10 5 10-5\"/>\n <path d=\"M2 12l10 5 10-5\"/>\n </svg>\n <span>{{ hintText }}</span>\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue';\nimport type { Spin360Config } from '../types';\nimport { use360Spin } from '../composables/use360Spin';\n\nconst props = withDefaults(defineProps<Spin360Config>(), {\n mode: 'auto',\n trigger: 'hover',\n width: '100%',\n height: 'auto',\n alt: 'Product 360 view',\n frameRate: 30,\n loop: true,\n reverseOnSecondHover: false,\n direction: 'clockwise',\n preload: true,\n showLoading: true,\n loadingText: 'Loading...',\n enableDragSpin: true,\n dragSensitivity: 10\n});\n\nconst emit = defineEmits<{\n 'animation-start': [];\n 'animation-end': [];\n 'loaded': [];\n 'error': [error: Error];\n 'frame-change': [frame: number];\n}>();\n\nconst containerRef = ref<HTMLElement | null>(null);\n\nconst {\n isAnimating,\n isLoading,\n currentMode,\n animatedImageUrl,\n currentFrameUrl,\n startAnimation,\n stopAnimation,\n preloadImages,\n handleDragStart,\n handleDragMove,\n handleDragEnd\n} = use360Spin(props, emit);\n\n// Computed styles\nconst containerStyle = computed(() => ({\n width: typeof props.width === 'number' ? `${props.width}px` : props.width,\n height: typeof props.height === 'number' ? `${props.height}px` : props.height\n}));\n\n// Show hint on hover trigger\nconst showHint = computed(() => props.trigger === 'hover' && !isAnimating.value);\nconst hintText = computed(() => 'Hover to spin');\n\n// Event handlers\nfunction handleMouseEnter() {\n if (props.trigger === 'hover') {\n startAnimation();\n }\n}\n\nfunction handleMouseLeave() {\n if (props.trigger === 'hover') {\n stopAnimation();\n }\n}\n\nfunction handleClick() {\n if (props.trigger === 'click') {\n if (isAnimating.value) {\n stopAnimation();\n } else {\n startAnimation();\n }\n }\n}\n\nfunction handleTouchStart(event: TouchEvent) {\n if (props.enableDragSpin && currentMode.value === 'frames') {\n handleDragStart(event);\n } else if (props.trigger === 'click') {\n handleClick();\n }\n}\n\nfunction handleTouchMove(event: TouchEvent) {\n if (props.enableDragSpin && currentMode.value === 'frames') {\n handleDragMove(event);\n }\n}\n\nfunction handleTouchEnd(event: TouchEvent) {\n if (props.enableDragSpin && currentMode.value === 'frames') {\n handleDragEnd(event);\n }\n}\n\nfunction handleStaticImageLoad() {\n console.log('[Ai360Spin] Static image loaded, clearing loading state');\n // Static image loaded - clear loading state\n if (isLoading.value) {\n isLoading.value = false;\n }\n console.log('[Ai360Spin] isLoading after static load:', isLoading.value);\n}\n\nfunction handleAnimatedImageLoad() {\n emit('loaded');\n}\n\nfunction handleImageError(event: Event) {\n console.error('Image failed to load:', (event.target as HTMLImageElement)?.src);\n // Clear loading state even on error so component doesn't stay stuck\n if (isLoading.value) {\n isLoading.value = false;\n }\n emit('error', new Error('Failed to load image'));\n}\n\n// Preload images on mount\nonMounted(async () => {\n console.log('[Ai360Spin] onMounted - preload:', props.preload, 'isLoading:', isLoading.value);\n\n if (props.preload) {\n try {\n await preloadImages();\n } catch (error) {\n console.error('[Ai360Spin] Preload failed:', error);\n // Clear loading state even if preload fails\n isLoading.value = false;\n }\n } else {\n // If not preloading, clear loading state immediately\n console.log('[Ai360Spin] Preload disabled, clearing loading state');\n isLoading.value = false;\n console.log('[Ai360Spin] isLoading after clear:', isLoading.value);\n }\n\n // Auto-start animation if trigger is 'auto'\n if (props.trigger === 'auto') {\n startAnimation();\n }\n});\n</script>\n\n<style scoped>\n.ai-360-spin {\n position: relative;\n display: inline-block;\n overflow: hidden;\n cursor: pointer;\n user-select: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n}\n\n.ai-360-spin__image {\n width: 100%;\n height: 100%;\n object-fit: contain;\n display: block;\n transition: opacity 0.3s ease;\n}\n\n.ai-360-spin__image--static {\n opacity: 1;\n}\n\n.ai-360-spin__image--animated,\n.ai-360-spin__image--frame {\n position: absolute;\n top: 0;\n left: 0;\n opacity: 1;\n}\n\n.ai-360-spin--animating .ai-360-spin__image--static {\n opacity: 0;\n}\n\n.ai-360-spin__loading {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: rgba(255, 255, 255, 0.9);\n z-index: 10;\n}\n\n.ai-360-spin__spinner {\n width: 40px;\n height: 40px;\n border: 4px solid #f3f3f3;\n border-top: 4px solid #3498db;\n border-radius: 50%;\n animation: ai-360-spin-rotate 1s linear infinite;\n}\n\n@keyframes ai-360-spin-rotate {\n 0% { transform: rotate(0deg); }\n 100% { transform: rotate(360deg); }\n}\n\n.ai-360-spin__loading-text {\n margin-top: 12px;\n font-size: 14px;\n color: #666;\n}\n\n.ai-360-spin__hint {\n position: absolute;\n bottom: 12px;\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px 16px;\n background: rgba(0, 0, 0, 0.7);\n color: white;\n border-radius: 20px;\n font-size: 12px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.3s ease;\n}\n\n.ai-360-spin:hover .ai-360-spin__hint {\n opacity: 1;\n}\n\n.ai-360-spin__hint-icon {\n width: 16px;\n height: 16px;\n stroke-width: 2;\n}\n\n/* Mobile optimizations */\n@media (max-width: 768px) {\n .ai-360-spin {\n touch-action: none;\n }\n\n .ai-360-spin__hint {\n font-size: 11px;\n padding: 6px 12px;\n }\n}\n</style>\n\n\n","import type { App } from 'vue';\nimport Ai360Spin from './components/Ai360Spin.vue';\nimport { use360Spin } from './composables/use360Spin';\nimport type { Spin360Config, SpinMode, SpinTrigger, SpinDirection, Spin360Events } from './types';\n\n// Import styles\nimport './styles/360-spin.css';\n\n// Export components\nexport { Ai360Spin };\n\n// Export composables\nexport { use360Spin };\n\n// Export types\nexport type {\n Spin360Config,\n SpinMode,\n SpinTrigger,\n SpinDirection,\n Spin360Events\n};\n\n// Vue plugin\nexport default {\n install(app: App) {\n app.component('Ai360Spin', Ai360Spin);\n }\n};\n\n"],"names":["use360Spin","props","emit","isAnimating","ref","isLoading","currentFrameIndex","animationFrameId","preloadedImages","isDragging","dragStartX","dragStartFrame","currentMode","computed","animatedImageUrl","currentFrameUrl","frames","index","frameArray","totalFrames","preloadImages","loadImage","loadPromises","url","images","error","timeout","resolve","reject","img","timeoutId","startAnimation","startFrameAnimation","stopAnimation","frameDelay","lastFrameTime","animate","now","handleDragStart","event","handleDragMove","currentX","deltaX","sensitivity","frameDelta","newFrame","handleDragEnd","_event","onUnmounted","__props","__emit","containerRef","containerStyle","showHint","hintText","handleMouseEnter","handleMouseLeave","handleClick","handleTouchStart","handleTouchMove","handleTouchEnd","handleStaticImageLoad","handleAnimatedImageLoad","handleImageError","_a","onMounted","app","Ai360Spin"],"mappings":"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"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { ref as p, computed as y, onUnmounted as H, defineComponent as q, onMounted as N, createElementBlock as k, openBlock as F, normalizeStyle as O, normalizeClass as L, unref as r, createCommentVNode as C, withDirectives as $, createElementVNode as _, toDisplayString as z, vShow as x } from "vue";
|
|
2
|
+
function V(t, d) {
|
|
3
|
+
const a = p(!1), c = p(!0), i = p(0), o = p(null), l = p([]), v = p(!1), A = p(0), M = p(0), u = y(() => t.mode && t.mode !== "auto" ? t.mode : Array.isArray(t.animatedImage) ? "frames" : "gif"), I = y(() => u.value === "gif" && typeof t.animatedImage == "string" ? t.animatedImage : ""), b = y(() => {
|
|
4
|
+
if (u.value === "frames" && Array.isArray(t.animatedImage)) {
|
|
5
|
+
const n = t.animatedImage, s = i.value % n.length;
|
|
6
|
+
return n[s];
|
|
7
|
+
}
|
|
8
|
+
return "";
|
|
9
|
+
}), T = y(() => Array.isArray(t.animatedImage) ? t.animatedImage : []), m = y(() => T.value.length);
|
|
10
|
+
async function B() {
|
|
11
|
+
c.value = !0;
|
|
12
|
+
try {
|
|
13
|
+
if (u.value === "gif")
|
|
14
|
+
await S(I.value);
|
|
15
|
+
else if (u.value === "frames") {
|
|
16
|
+
const n = T.value.map((f) => S(f)), s = await Promise.all(n);
|
|
17
|
+
l.value = s;
|
|
18
|
+
}
|
|
19
|
+
await S(t.staticImage), c.value = !1, d("loaded");
|
|
20
|
+
} catch (n) {
|
|
21
|
+
c.value = !1, d("error", n);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function S(n, s = 5e3) {
|
|
25
|
+
return new Promise((f, h) => {
|
|
26
|
+
const g = new Image(), e = setTimeout(() => {
|
|
27
|
+
h(new Error(`Image load timeout: ${n}`));
|
|
28
|
+
}, s);
|
|
29
|
+
g.onload = () => {
|
|
30
|
+
clearTimeout(e), f(g);
|
|
31
|
+
}, g.onerror = () => {
|
|
32
|
+
clearTimeout(e), h(new Error(`Failed to load image: ${n}`));
|
|
33
|
+
}, g.src = n;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function X() {
|
|
37
|
+
a.value || c.value || (a.value = !0, d("animation-start"), u.value === "frames" && P());
|
|
38
|
+
}
|
|
39
|
+
function D() {
|
|
40
|
+
a.value && (a.value = !1, d("animation-end"), o.value !== null && (cancelAnimationFrame(o.value), o.value = null), u.value === "frames" && (i.value = 0));
|
|
41
|
+
}
|
|
42
|
+
function P() {
|
|
43
|
+
if (m.value === 0) return;
|
|
44
|
+
const n = 1e3 / (t.frameRate || 30);
|
|
45
|
+
let s = Date.now();
|
|
46
|
+
function f() {
|
|
47
|
+
const h = Date.now();
|
|
48
|
+
if (h - s >= n && (t.direction === "clockwise" ? i.value = (i.value + 1) % m.value : i.value = i.value === 0 ? m.value - 1 : i.value - 1, d("frame-change", i.value), s = h, !t.loop && i.value === 0)) {
|
|
49
|
+
D();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
a.value && (o.value = requestAnimationFrame(f));
|
|
53
|
+
}
|
|
54
|
+
o.value = requestAnimationFrame(f);
|
|
55
|
+
}
|
|
56
|
+
function U(n) {
|
|
57
|
+
u.value !== "frames" || m.value === 0 || (v.value = !0, M.value = i.value, n instanceof TouchEvent ? A.value = n.touches[0].clientX : A.value = n.clientX, a.value && D());
|
|
58
|
+
}
|
|
59
|
+
function E(n) {
|
|
60
|
+
if (!v.value || u.value !== "frames") return;
|
|
61
|
+
n.preventDefault();
|
|
62
|
+
let s;
|
|
63
|
+
n instanceof TouchEvent ? s = n.touches[0].clientX : s = n.clientX;
|
|
64
|
+
const f = s - A.value, h = t.dragSensitivity || 10, g = Math.floor(f / h);
|
|
65
|
+
let e = M.value + g;
|
|
66
|
+
for (; e < 0; ) e += m.value;
|
|
67
|
+
for (; e >= m.value; ) e -= m.value;
|
|
68
|
+
e !== i.value && (i.value = e, d("frame-change", i.value));
|
|
69
|
+
}
|
|
70
|
+
function R(n) {
|
|
71
|
+
v.value = !1;
|
|
72
|
+
}
|
|
73
|
+
return H(() => {
|
|
74
|
+
o.value !== null && cancelAnimationFrame(o.value);
|
|
75
|
+
}), {
|
|
76
|
+
isAnimating: a,
|
|
77
|
+
isLoading: c,
|
|
78
|
+
currentFrameIndex: i,
|
|
79
|
+
currentMode: u,
|
|
80
|
+
animatedImageUrl: I,
|
|
81
|
+
currentFrameUrl: b,
|
|
82
|
+
totalFrames: m,
|
|
83
|
+
startAnimation: X,
|
|
84
|
+
stopAnimation: D,
|
|
85
|
+
preloadImages: B,
|
|
86
|
+
handleDragStart: U,
|
|
87
|
+
handleDragMove: E,
|
|
88
|
+
handleDragEnd: R
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const G = {
|
|
92
|
+
key: 0,
|
|
93
|
+
class: "ai-360-spin__loading"
|
|
94
|
+
}, J = { class: "ai-360-spin__loading-text" }, K = ["src", "alt"], Q = ["src", "alt"], W = ["src", "alt"], Y = {
|
|
95
|
+
key: 3,
|
|
96
|
+
class: "ai-360-spin__hint"
|
|
97
|
+
}, Z = /* @__PURE__ */ q({
|
|
98
|
+
__name: "Ai360Spin",
|
|
99
|
+
props: {
|
|
100
|
+
staticImage: {},
|
|
101
|
+
animatedImage: {},
|
|
102
|
+
mode: { default: "auto" },
|
|
103
|
+
trigger: { default: "hover" },
|
|
104
|
+
width: { default: "100%" },
|
|
105
|
+
height: { default: "auto" },
|
|
106
|
+
alt: { default: "Product 360 view" },
|
|
107
|
+
frameRate: { default: 30 },
|
|
108
|
+
loop: { type: Boolean, default: !0 },
|
|
109
|
+
reverseOnSecondHover: { type: Boolean, default: !1 },
|
|
110
|
+
direction: { default: "clockwise" },
|
|
111
|
+
preload: { type: Boolean, default: !0 },
|
|
112
|
+
loadingImage: {},
|
|
113
|
+
containerClass: {},
|
|
114
|
+
imageClass: {},
|
|
115
|
+
showLoading: { type: Boolean, default: !0 },
|
|
116
|
+
loadingText: { default: "Loading..." },
|
|
117
|
+
enableDragSpin: { type: Boolean, default: !0 },
|
|
118
|
+
dragSensitivity: { default: 10 }
|
|
119
|
+
},
|
|
120
|
+
emits: ["animation-start", "animation-end", "loaded", "error", "frame-change"],
|
|
121
|
+
setup(t, { emit: d }) {
|
|
122
|
+
const a = t, c = d, i = p(null), {
|
|
123
|
+
isAnimating: o,
|
|
124
|
+
isLoading: l,
|
|
125
|
+
currentMode: v,
|
|
126
|
+
animatedImageUrl: A,
|
|
127
|
+
currentFrameUrl: M,
|
|
128
|
+
startAnimation: u,
|
|
129
|
+
stopAnimation: I,
|
|
130
|
+
preloadImages: b,
|
|
131
|
+
handleDragStart: T,
|
|
132
|
+
handleDragMove: m,
|
|
133
|
+
handleDragEnd: B
|
|
134
|
+
} = V(a, c), S = y(() => ({
|
|
135
|
+
width: typeof a.width == "number" ? `${a.width}px` : a.width,
|
|
136
|
+
height: typeof a.height == "number" ? `${a.height}px` : a.height
|
|
137
|
+
})), X = y(() => a.trigger === "hover" && !o.value), D = y(() => "Hover to spin");
|
|
138
|
+
function P() {
|
|
139
|
+
a.trigger === "hover" && u();
|
|
140
|
+
}
|
|
141
|
+
function U() {
|
|
142
|
+
a.trigger === "hover" && I();
|
|
143
|
+
}
|
|
144
|
+
function E() {
|
|
145
|
+
a.trigger === "click" && (o.value ? I() : u());
|
|
146
|
+
}
|
|
147
|
+
function R(e) {
|
|
148
|
+
a.enableDragSpin && v.value === "frames" ? T(e) : a.trigger === "click" && E();
|
|
149
|
+
}
|
|
150
|
+
function n(e) {
|
|
151
|
+
a.enableDragSpin && v.value === "frames" && m(e);
|
|
152
|
+
}
|
|
153
|
+
function s(e) {
|
|
154
|
+
a.enableDragSpin && v.value === "frames" && B(e);
|
|
155
|
+
}
|
|
156
|
+
function f() {
|
|
157
|
+
console.log("[Ai360Spin] Static image loaded, clearing loading state"), l.value && (l.value = !1), console.log("[Ai360Spin] isLoading after static load:", l.value);
|
|
158
|
+
}
|
|
159
|
+
function h() {
|
|
160
|
+
c("loaded");
|
|
161
|
+
}
|
|
162
|
+
function g(e) {
|
|
163
|
+
var w;
|
|
164
|
+
console.error("Image failed to load:", (w = e.target) == null ? void 0 : w.src), l.value && (l.value = !1), c("error", new Error("Failed to load image"));
|
|
165
|
+
}
|
|
166
|
+
return N(async () => {
|
|
167
|
+
if (console.log("[Ai360Spin] onMounted - preload:", a.preload, "isLoading:", l.value), a.preload)
|
|
168
|
+
try {
|
|
169
|
+
await b();
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.error("[Ai360Spin] Preload failed:", e), l.value = !1;
|
|
172
|
+
}
|
|
173
|
+
else
|
|
174
|
+
console.log("[Ai360Spin] Preload disabled, clearing loading state"), l.value = !1, console.log("[Ai360Spin] isLoading after clear:", l.value);
|
|
175
|
+
a.trigger === "auto" && u();
|
|
176
|
+
}), (e, w) => (F(), k("div", {
|
|
177
|
+
ref_key: "containerRef",
|
|
178
|
+
ref: i,
|
|
179
|
+
class: L(["ai-360-spin", e.containerClass, { "ai-360-spin--animating": r(o), "ai-360-spin--loading": r(l) }]),
|
|
180
|
+
style: O(S.value),
|
|
181
|
+
onMouseenter: P,
|
|
182
|
+
onMouseleave: U,
|
|
183
|
+
onClick: E,
|
|
184
|
+
onTouchstart: R,
|
|
185
|
+
onTouchmove: n,
|
|
186
|
+
onTouchend: s
|
|
187
|
+
}, [
|
|
188
|
+
r(l) && e.showLoading ? (F(), k("div", G, [
|
|
189
|
+
w[0] || (w[0] = _("div", { class: "ai-360-spin__spinner" }, null, -1)),
|
|
190
|
+
_("p", J, z(e.loadingText), 1)
|
|
191
|
+
])) : C("", !0),
|
|
192
|
+
$(_("img", {
|
|
193
|
+
src: e.staticImage,
|
|
194
|
+
alt: e.alt,
|
|
195
|
+
class: L(["ai-360-spin__image", "ai-360-spin__image--static", e.imageClass]),
|
|
196
|
+
onLoad: f,
|
|
197
|
+
onError: g
|
|
198
|
+
}, null, 42, K), [
|
|
199
|
+
[x, !r(o) && !r(l)]
|
|
200
|
+
]),
|
|
201
|
+
r(v) === "gif" && !r(l) ? $((F(), k("img", {
|
|
202
|
+
key: 1,
|
|
203
|
+
src: r(A),
|
|
204
|
+
alt: e.alt,
|
|
205
|
+
class: L(["ai-360-spin__image", "ai-360-spin__image--animated", e.imageClass]),
|
|
206
|
+
onLoad: h,
|
|
207
|
+
onError: g
|
|
208
|
+
}, null, 42, Q)), [
|
|
209
|
+
[x, r(o)]
|
|
210
|
+
]) : C("", !0),
|
|
211
|
+
r(v) === "frames" && !r(l) ? $((F(), k("img", {
|
|
212
|
+
key: 2,
|
|
213
|
+
src: r(M),
|
|
214
|
+
alt: e.alt,
|
|
215
|
+
class: L(["ai-360-spin__image", "ai-360-spin__image--frame", e.imageClass])
|
|
216
|
+
}, null, 10, W)), [
|
|
217
|
+
[x, r(o)]
|
|
218
|
+
]) : C("", !0),
|
|
219
|
+
!r(o) && !r(l) && X.value ? (F(), k("div", Y, [
|
|
220
|
+
w[1] || (w[1] = _("svg", {
|
|
221
|
+
class: "ai-360-spin__hint-icon",
|
|
222
|
+
viewBox: "0 0 24 24",
|
|
223
|
+
fill: "none",
|
|
224
|
+
stroke: "currentColor"
|
|
225
|
+
}, [
|
|
226
|
+
_("path", { d: "M12 2L2 7l10 5 10-5-10-5z" }),
|
|
227
|
+
_("path", { d: "M2 17l10 5 10-5" }),
|
|
228
|
+
_("path", { d: "M2 12l10 5 10-5" })
|
|
229
|
+
], -1)),
|
|
230
|
+
_("span", null, z(D.value), 1)
|
|
231
|
+
])) : C("", !0)
|
|
232
|
+
], 38));
|
|
233
|
+
}
|
|
234
|
+
}), j = (t, d) => {
|
|
235
|
+
const a = t.__vccOpts || t;
|
|
236
|
+
for (const [c, i] of d)
|
|
237
|
+
a[c] = i;
|
|
238
|
+
return a;
|
|
239
|
+
}, ee = /* @__PURE__ */ j(Z, [["__scopeId", "data-v-915b2a52"]]), ne = {
|
|
240
|
+
install(t) {
|
|
241
|
+
t.component("Ai360Spin", ee);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
export {
|
|
245
|
+
ee as Ai360Spin,
|
|
246
|
+
ne as default,
|
|
247
|
+
V as use360Spin
|
|
248
|
+
};
|
|
249
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","sources":["../src/composables/use360Spin.ts","../src/components/Ai360Spin.vue","../src/index.ts"],"sourcesContent":["import { ref, computed, onUnmounted } from 'vue';\nimport type { Spin360Config, SpinMode } from '../types';\n\nexport function use360Spin(\n props: Spin360Config,\n emit: any\n) {\n const isAnimating = ref(false);\n const isLoading = ref(true);\n const currentFrameIndex = ref(0);\n const animationFrameId = ref<number | null>(null);\n const preloadedImages = ref<HTMLImageElement[]>([]);\n \n // Touch/drag state\n const isDragging = ref(false);\n const dragStartX = ref(0);\n const dragStartFrame = ref(0);\n\n // Determine animation mode\n const currentMode = computed<SpinMode>(() => {\n if (props.mode && props.mode !== 'auto') {\n return props.mode;\n }\n // Auto-detect based on animatedImage type\n return Array.isArray(props.animatedImage) ? 'frames' : 'gif';\n });\n\n // Get animated image URL (for GIF mode)\n const animatedImageUrl = computed(() => {\n if (currentMode.value === 'gif' && typeof props.animatedImage === 'string') {\n return props.animatedImage;\n }\n return '';\n });\n\n // Get current frame URL (for frames mode)\n const currentFrameUrl = computed(() => {\n if (currentMode.value === 'frames' && Array.isArray(props.animatedImage)) {\n const frames = props.animatedImage;\n const index = currentFrameIndex.value % frames.length;\n return frames[index];\n }\n return '';\n });\n\n // Get frame array\n const frameArray = computed(() => {\n if (Array.isArray(props.animatedImage)) {\n return props.animatedImage;\n }\n return [];\n });\n\n // Total number of frames\n const totalFrames = computed(() => frameArray.value.length);\n\n // Preload all images\n async function preloadImages() {\n isLoading.value = true;\n\n try {\n if (currentMode.value === 'gif') {\n // Preload GIF\n await loadImage(animatedImageUrl.value);\n } else if (currentMode.value === 'frames') {\n // Preload all frames\n const loadPromises = frameArray.value.map(url => loadImage(url));\n const images = await Promise.all(loadPromises);\n preloadedImages.value = images;\n }\n\n // Also preload static image\n await loadImage(props.staticImage);\n\n isLoading.value = false;\n emit('loaded');\n } catch (error) {\n isLoading.value = false;\n emit('error', error);\n }\n }\n\n // Load a single image with timeout\n function loadImage(url: string, timeout = 5000): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const img = new Image();\n\n // Don't set crossOrigin - it causes CORS issues with many image services\n // If you need CORS support, the image server must explicitly allow it\n\n const timeoutId = setTimeout(() => {\n reject(new Error(`Image load timeout: ${url}`));\n }, timeout);\n\n img.onload = () => {\n clearTimeout(timeoutId);\n resolve(img);\n };\n\n img.onerror = () => {\n clearTimeout(timeoutId);\n reject(new Error(`Failed to load image: ${url}`));\n };\n\n img.src = url;\n });\n }\n\n // Start animation\n function startAnimation() {\n if (isAnimating.value || isLoading.value) return;\n\n isAnimating.value = true;\n emit('animation-start');\n\n if (currentMode.value === 'frames') {\n startFrameAnimation();\n }\n }\n\n // Stop animation\n function stopAnimation() {\n if (!isAnimating.value) return;\n\n isAnimating.value = false;\n emit('animation-end');\n\n if (animationFrameId.value !== null) {\n cancelAnimationFrame(animationFrameId.value);\n animationFrameId.value = null;\n }\n\n // Reset to first frame\n if (currentMode.value === 'frames') {\n currentFrameIndex.value = 0;\n }\n }\n\n // Start frame-by-frame animation\n function startFrameAnimation() {\n if (totalFrames.value === 0) return;\n\n const frameDelay = 1000 / (props.frameRate || 30);\n let lastFrameTime = Date.now();\n\n function animate() {\n const now = Date.now();\n const elapsed = now - lastFrameTime;\n\n if (elapsed >= frameDelay) {\n // Update frame\n if (props.direction === 'clockwise') {\n currentFrameIndex.value = (currentFrameIndex.value + 1) % totalFrames.value;\n } else {\n currentFrameIndex.value = currentFrameIndex.value === 0 \n ? totalFrames.value - 1 \n : currentFrameIndex.value - 1;\n }\n\n emit('frame-change', currentFrameIndex.value);\n lastFrameTime = now;\n\n // Check if we should loop\n if (!props.loop && currentFrameIndex.value === 0) {\n stopAnimation();\n return;\n }\n }\n\n if (isAnimating.value) {\n animationFrameId.value = requestAnimationFrame(animate);\n }\n }\n\n animationFrameId.value = requestAnimationFrame(animate);\n }\n\n // Handle drag start\n function handleDragStart(event: TouchEvent | MouseEvent) {\n if (currentMode.value !== 'frames' || totalFrames.value === 0) return;\n\n isDragging.value = true;\n dragStartFrame.value = currentFrameIndex.value;\n\n if (event instanceof TouchEvent) {\n dragStartX.value = event.touches[0].clientX;\n } else {\n dragStartX.value = event.clientX;\n }\n\n // Stop auto animation if running\n if (isAnimating.value) {\n stopAnimation();\n }\n }\n\n // Handle drag move\n function handleDragMove(event: TouchEvent | MouseEvent) {\n if (!isDragging.value || currentMode.value !== 'frames') return;\n\n event.preventDefault();\n\n let currentX: number;\n if (event instanceof TouchEvent) {\n currentX = event.touches[0].clientX;\n } else {\n currentX = event.clientX;\n }\n\n const deltaX = currentX - dragStartX.value;\n const sensitivity = props.dragSensitivity || 10;\n const frameDelta = Math.floor(deltaX / sensitivity);\n\n let newFrame = dragStartFrame.value + frameDelta;\n \n // Wrap around\n while (newFrame < 0) newFrame += totalFrames.value;\n while (newFrame >= totalFrames.value) newFrame -= totalFrames.value;\n\n if (newFrame !== currentFrameIndex.value) {\n currentFrameIndex.value = newFrame;\n emit('frame-change', currentFrameIndex.value);\n }\n }\n\n // Handle drag end\n function handleDragEnd(_event: TouchEvent | MouseEvent) {\n isDragging.value = false;\n }\n\n // Cleanup on unmount\n onUnmounted(() => {\n if (animationFrameId.value !== null) {\n cancelAnimationFrame(animationFrameId.value);\n }\n });\n\n return {\n isAnimating,\n isLoading,\n currentFrameIndex,\n currentMode,\n animatedImageUrl,\n currentFrameUrl,\n totalFrames,\n startAnimation,\n stopAnimation,\n preloadImages,\n handleDragStart,\n handleDragMove,\n handleDragEnd\n };\n}\n\n","<template>\n <div\n ref=\"containerRef\"\n :class=\"['ai-360-spin', containerClass, { 'ai-360-spin--animating': isAnimating, 'ai-360-spin--loading': isLoading }]\"\n :style=\"containerStyle\"\n @mouseenter=\"handleMouseEnter\"\n @mouseleave=\"handleMouseLeave\"\n @click=\"handleClick\"\n @touchstart=\"handleTouchStart\"\n @touchmove=\"handleTouchMove\"\n @touchend=\"handleTouchEnd\"\n >\n <!-- Loading State -->\n <div v-if=\"isLoading && showLoading\" class=\"ai-360-spin__loading\">\n <div class=\"ai-360-spin__spinner\"></div>\n <p class=\"ai-360-spin__loading-text\">{{ loadingText }}</p>\n </div>\n\n <!-- Static Image -->\n <img\n v-show=\"!isAnimating && !isLoading\"\n :src=\"staticImage\"\n :alt=\"alt\"\n :class=\"['ai-360-spin__image', 'ai-360-spin__image--static', imageClass]\"\n @load=\"handleStaticImageLoad\"\n @error=\"handleImageError\"\n />\n\n <!-- Animated Image (GIF mode) -->\n <img\n v-if=\"currentMode === 'gif' && !isLoading\"\n v-show=\"isAnimating\"\n :src=\"animatedImageUrl\"\n :alt=\"alt\"\n :class=\"['ai-360-spin__image', 'ai-360-spin__image--animated', imageClass]\"\n @load=\"handleAnimatedImageLoad\"\n @error=\"handleImageError\"\n />\n\n <!-- Frame Sequence Mode -->\n <img\n v-if=\"currentMode === 'frames' && !isLoading\"\n v-show=\"isAnimating\"\n :src=\"currentFrameUrl\"\n :alt=\"alt\"\n :class=\"['ai-360-spin__image', 'ai-360-spin__image--frame', imageClass]\"\n />\n\n <!-- Hover Hint (optional) -->\n <div v-if=\"!isAnimating && !isLoading && showHint\" class=\"ai-360-spin__hint\">\n <svg class=\"ai-360-spin__hint-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\">\n <path d=\"M12 2L2 7l10 5 10-5-10-5z\"/>\n <path d=\"M2 17l10 5 10-5\"/>\n <path d=\"M2 12l10 5 10-5\"/>\n </svg>\n <span>{{ hintText }}</span>\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue';\nimport type { Spin360Config } from '../types';\nimport { use360Spin } from '../composables/use360Spin';\n\nconst props = withDefaults(defineProps<Spin360Config>(), {\n mode: 'auto',\n trigger: 'hover',\n width: '100%',\n height: 'auto',\n alt: 'Product 360 view',\n frameRate: 30,\n loop: true,\n reverseOnSecondHover: false,\n direction: 'clockwise',\n preload: true,\n showLoading: true,\n loadingText: 'Loading...',\n enableDragSpin: true,\n dragSensitivity: 10\n});\n\nconst emit = defineEmits<{\n 'animation-start': [];\n 'animation-end': [];\n 'loaded': [];\n 'error': [error: Error];\n 'frame-change': [frame: number];\n}>();\n\nconst containerRef = ref<HTMLElement | null>(null);\n\nconst {\n isAnimating,\n isLoading,\n currentMode,\n animatedImageUrl,\n currentFrameUrl,\n startAnimation,\n stopAnimation,\n preloadImages,\n handleDragStart,\n handleDragMove,\n handleDragEnd\n} = use360Spin(props, emit);\n\n// Computed styles\nconst containerStyle = computed(() => ({\n width: typeof props.width === 'number' ? `${props.width}px` : props.width,\n height: typeof props.height === 'number' ? `${props.height}px` : props.height\n}));\n\n// Show hint on hover trigger\nconst showHint = computed(() => props.trigger === 'hover' && !isAnimating.value);\nconst hintText = computed(() => 'Hover to spin');\n\n// Event handlers\nfunction handleMouseEnter() {\n if (props.trigger === 'hover') {\n startAnimation();\n }\n}\n\nfunction handleMouseLeave() {\n if (props.trigger === 'hover') {\n stopAnimation();\n }\n}\n\nfunction handleClick() {\n if (props.trigger === 'click') {\n if (isAnimating.value) {\n stopAnimation();\n } else {\n startAnimation();\n }\n }\n}\n\nfunction handleTouchStart(event: TouchEvent) {\n if (props.enableDragSpin && currentMode.value === 'frames') {\n handleDragStart(event);\n } else if (props.trigger === 'click') {\n handleClick();\n }\n}\n\nfunction handleTouchMove(event: TouchEvent) {\n if (props.enableDragSpin && currentMode.value === 'frames') {\n handleDragMove(event);\n }\n}\n\nfunction handleTouchEnd(event: TouchEvent) {\n if (props.enableDragSpin && currentMode.value === 'frames') {\n handleDragEnd(event);\n }\n}\n\nfunction handleStaticImageLoad() {\n console.log('[Ai360Spin] Static image loaded, clearing loading state');\n // Static image loaded - clear loading state\n if (isLoading.value) {\n isLoading.value = false;\n }\n console.log('[Ai360Spin] isLoading after static load:', isLoading.value);\n}\n\nfunction handleAnimatedImageLoad() {\n emit('loaded');\n}\n\nfunction handleImageError(event: Event) {\n console.error('Image failed to load:', (event.target as HTMLImageElement)?.src);\n // Clear loading state even on error so component doesn't stay stuck\n if (isLoading.value) {\n isLoading.value = false;\n }\n emit('error', new Error('Failed to load image'));\n}\n\n// Preload images on mount\nonMounted(async () => {\n console.log('[Ai360Spin] onMounted - preload:', props.preload, 'isLoading:', isLoading.value);\n\n if (props.preload) {\n try {\n await preloadImages();\n } catch (error) {\n console.error('[Ai360Spin] Preload failed:', error);\n // Clear loading state even if preload fails\n isLoading.value = false;\n }\n } else {\n // If not preloading, clear loading state immediately\n console.log('[Ai360Spin] Preload disabled, clearing loading state');\n isLoading.value = false;\n console.log('[Ai360Spin] isLoading after clear:', isLoading.value);\n }\n\n // Auto-start animation if trigger is 'auto'\n if (props.trigger === 'auto') {\n startAnimation();\n }\n});\n</script>\n\n<style scoped>\n.ai-360-spin {\n position: relative;\n display: inline-block;\n overflow: hidden;\n cursor: pointer;\n user-select: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n}\n\n.ai-360-spin__image {\n width: 100%;\n height: 100%;\n object-fit: contain;\n display: block;\n transition: opacity 0.3s ease;\n}\n\n.ai-360-spin__image--static {\n opacity: 1;\n}\n\n.ai-360-spin__image--animated,\n.ai-360-spin__image--frame {\n position: absolute;\n top: 0;\n left: 0;\n opacity: 1;\n}\n\n.ai-360-spin--animating .ai-360-spin__image--static {\n opacity: 0;\n}\n\n.ai-360-spin__loading {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: rgba(255, 255, 255, 0.9);\n z-index: 10;\n}\n\n.ai-360-spin__spinner {\n width: 40px;\n height: 40px;\n border: 4px solid #f3f3f3;\n border-top: 4px solid #3498db;\n border-radius: 50%;\n animation: ai-360-spin-rotate 1s linear infinite;\n}\n\n@keyframes ai-360-spin-rotate {\n 0% { transform: rotate(0deg); }\n 100% { transform: rotate(360deg); }\n}\n\n.ai-360-spin__loading-text {\n margin-top: 12px;\n font-size: 14px;\n color: #666;\n}\n\n.ai-360-spin__hint {\n position: absolute;\n bottom: 12px;\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px 16px;\n background: rgba(0, 0, 0, 0.7);\n color: white;\n border-radius: 20px;\n font-size: 12px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.3s ease;\n}\n\n.ai-360-spin:hover .ai-360-spin__hint {\n opacity: 1;\n}\n\n.ai-360-spin__hint-icon {\n width: 16px;\n height: 16px;\n stroke-width: 2;\n}\n\n/* Mobile optimizations */\n@media (max-width: 768px) {\n .ai-360-spin {\n touch-action: none;\n }\n\n .ai-360-spin__hint {\n font-size: 11px;\n padding: 6px 12px;\n }\n}\n</style>\n\n\n","import type { App } from 'vue';\nimport Ai360Spin from './components/Ai360Spin.vue';\nimport { use360Spin } from './composables/use360Spin';\nimport type { Spin360Config, SpinMode, SpinTrigger, SpinDirection, Spin360Events } from './types';\n\n// Import styles\nimport './styles/360-spin.css';\n\n// Export components\nexport { Ai360Spin };\n\n// Export composables\nexport { use360Spin };\n\n// Export types\nexport type {\n Spin360Config,\n SpinMode,\n SpinTrigger,\n SpinDirection,\n Spin360Events\n};\n\n// Vue plugin\nexport default {\n install(app: App) {\n app.component('Ai360Spin', Ai360Spin);\n }\n};\n\n"],"names":["use360Spin","props","emit","isAnimating","ref","isLoading","currentFrameIndex","animationFrameId","preloadedImages","isDragging","dragStartX","dragStartFrame","currentMode","computed","animatedImageUrl","currentFrameUrl","frames","index","frameArray","totalFrames","preloadImages","loadImage","loadPromises","url","images","error","timeout","resolve","reject","img","timeoutId","startAnimation","startFrameAnimation","stopAnimation","frameDelay","lastFrameTime","animate","now","handleDragStart","event","handleDragMove","currentX","deltaX","sensitivity","frameDelta","newFrame","handleDragEnd","_event","onUnmounted","__props","__emit","containerRef","containerStyle","showHint","hintText","handleMouseEnter","handleMouseLeave","handleClick","handleTouchStart","handleTouchMove","handleTouchEnd","handleStaticImageLoad","handleAnimatedImageLoad","handleImageError","_a","onMounted","app","Ai360Spin"],"mappings":"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"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation mode for 360-degree spin
|
|
3
|
+
*/
|
|
4
|
+
export type SpinMode = 'gif' | 'frames' | 'auto';
|
|
5
|
+
/**
|
|
6
|
+
* Trigger type for starting the spin animation
|
|
7
|
+
*/
|
|
8
|
+
export type SpinTrigger = 'hover' | 'click' | 'auto';
|
|
9
|
+
/**
|
|
10
|
+
* Direction of the spin animation
|
|
11
|
+
*/
|
|
12
|
+
export type SpinDirection = 'clockwise' | 'counterclockwise';
|
|
13
|
+
/**
|
|
14
|
+
* Configuration options for 360-degree spin
|
|
15
|
+
*/
|
|
16
|
+
export interface Spin360Config {
|
|
17
|
+
/**
|
|
18
|
+
* Static image URL (shown by default)
|
|
19
|
+
*/
|
|
20
|
+
staticImage: string;
|
|
21
|
+
/**
|
|
22
|
+
* Animated image URL (GIF) or array of frame URLs
|
|
23
|
+
*/
|
|
24
|
+
animatedImage: string | string[];
|
|
25
|
+
/**
|
|
26
|
+
* Animation mode
|
|
27
|
+
* @default 'auto' - automatically detects based on animatedImage type
|
|
28
|
+
*/
|
|
29
|
+
mode?: SpinMode;
|
|
30
|
+
/**
|
|
31
|
+
* Trigger for starting animation
|
|
32
|
+
* @default 'hover'
|
|
33
|
+
*/
|
|
34
|
+
trigger?: SpinTrigger;
|
|
35
|
+
/**
|
|
36
|
+
* Width of the image container
|
|
37
|
+
* @default '100%'
|
|
38
|
+
*/
|
|
39
|
+
width?: string | number;
|
|
40
|
+
/**
|
|
41
|
+
* Height of the image container
|
|
42
|
+
* @default 'auto'
|
|
43
|
+
*/
|
|
44
|
+
height?: string | number;
|
|
45
|
+
/**
|
|
46
|
+
* Alt text for the image
|
|
47
|
+
*/
|
|
48
|
+
alt?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Frame rate for frame sequence animation (frames per second)
|
|
51
|
+
* @default 30
|
|
52
|
+
*/
|
|
53
|
+
frameRate?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Whether to loop the animation
|
|
56
|
+
* @default true
|
|
57
|
+
*/
|
|
58
|
+
loop?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Whether to reverse the animation on second hover
|
|
61
|
+
* @default false
|
|
62
|
+
*/
|
|
63
|
+
reverseOnSecondHover?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Spin direction
|
|
66
|
+
* @default 'clockwise'
|
|
67
|
+
*/
|
|
68
|
+
direction?: SpinDirection;
|
|
69
|
+
/**
|
|
70
|
+
* Whether to preload animated images
|
|
71
|
+
* @default true
|
|
72
|
+
*/
|
|
73
|
+
preload?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Loading placeholder image
|
|
76
|
+
*/
|
|
77
|
+
loadingImage?: string;
|
|
78
|
+
/**
|
|
79
|
+
* CSS class for the container
|
|
80
|
+
*/
|
|
81
|
+
containerClass?: string;
|
|
82
|
+
/**
|
|
83
|
+
* CSS class for the image
|
|
84
|
+
*/
|
|
85
|
+
imageClass?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Whether to show loading indicator
|
|
88
|
+
* @default true
|
|
89
|
+
*/
|
|
90
|
+
showLoading?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Custom loading text
|
|
93
|
+
* @default 'Loading...'
|
|
94
|
+
*/
|
|
95
|
+
loadingText?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Whether to enable touch/drag to spin on mobile
|
|
98
|
+
* @default true
|
|
99
|
+
*/
|
|
100
|
+
enableDragSpin?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Sensitivity for drag spin (pixels per frame)
|
|
103
|
+
* @default 10
|
|
104
|
+
*/
|
|
105
|
+
dragSensitivity?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Events emitted by the 360-spin component
|
|
109
|
+
*/
|
|
110
|
+
export interface Spin360Events {
|
|
111
|
+
/**
|
|
112
|
+
* Emitted when animation starts
|
|
113
|
+
*/
|
|
114
|
+
'animation-start': void;
|
|
115
|
+
/**
|
|
116
|
+
* Emitted when animation ends
|
|
117
|
+
*/
|
|
118
|
+
'animation-end': void;
|
|
119
|
+
/**
|
|
120
|
+
* Emitted when images are loaded
|
|
121
|
+
*/
|
|
122
|
+
'loaded': void;
|
|
123
|
+
/**
|
|
124
|
+
* Emitted when loading fails
|
|
125
|
+
*/
|
|
126
|
+
'error': Error;
|
|
127
|
+
/**
|
|
128
|
+
* Emitted when current frame changes (for frame sequence mode)
|
|
129
|
+
*/
|
|
130
|
+
'frame-change': number;
|
|
131
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aivue/360-spin",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Interactive 360-degree product image spin component for Vue.js",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./360-spin.css": "./dist/360-spin.css",
|
|
15
|
+
"./dist/360-spin.css": "./dist/360-spin.css"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "npm run clean && vite build",
|
|
24
|
+
"dev": "vite build --watch",
|
|
25
|
+
"clean": "rm -rf dist",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"vue",
|
|
30
|
+
"360",
|
|
31
|
+
"spin",
|
|
32
|
+
"product",
|
|
33
|
+
"image",
|
|
34
|
+
"rotation",
|
|
35
|
+
"3d",
|
|
36
|
+
"viewer",
|
|
37
|
+
"ecommerce",
|
|
38
|
+
"product-view",
|
|
39
|
+
"interactive",
|
|
40
|
+
"ui",
|
|
41
|
+
"components"
|
|
42
|
+
],
|
|
43
|
+
"author": "reachbrt",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/reachbrt/vueai.git",
|
|
48
|
+
"directory": "packages/360-spin"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/reachbrt/vueai#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/reachbrt/vueai/issues"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"vue": "^2.6.0 || ^3.0.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/node": "^20.16.0",
|
|
62
|
+
"@vitejs/plugin-vue": "^5.0.0",
|
|
63
|
+
"@vue/compiler-sfc": "^3.5.13",
|
|
64
|
+
"typescript": "^5.3.0",
|
|
65
|
+
"vite": "^6.3.5",
|
|
66
|
+
"vite-plugin-dts": "^4.5.3",
|
|
67
|
+
"vue": "^3.5.0",
|
|
68
|
+
"vue-tsc": "^2.2.10"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|