virtual-image-layout 1.0.0
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/README.md +163 -0
- package/css/index.css +118 -0
- package/js/index.js +644 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# 图片瀑布流布局 - 虚拟列表
|
|
2
|
+
|
|
3
|
+
一个基于虚拟列表技术的高性能图片瀑布流布局项目,支持大量图片的流畅展示。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- **虚拟列表渲染**:只渲染可视区域内的图片,大幅提升性能
|
|
8
|
+
- **等高布局算法**:智能计算每行图片的宽度,保持行高一致
|
|
9
|
+
- **响应式设计**:自动适配不同屏幕尺寸
|
|
10
|
+
- **懒加载**:滚动加载更多图片
|
|
11
|
+
- **图片曝光统计**:使用 IntersectionObserver API 监听图片曝光
|
|
12
|
+
- **错误处理**:自动移除加载失败的图片
|
|
13
|
+
|
|
14
|
+
## 技术栈
|
|
15
|
+
|
|
16
|
+
- **原生 JavaScript**:核心布局算法
|
|
17
|
+
- **jQuery**:DOM 操作和事件处理
|
|
18
|
+
- **CSS3**:样式和动画效果
|
|
19
|
+
- **IntersectionObserver API**:图片曝光监听
|
|
20
|
+
|
|
21
|
+
## 项目结构
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
├── css/
|
|
25
|
+
│ └── index.css # 样式文件
|
|
26
|
+
├── js/
|
|
27
|
+
│ ├── index.js # 虚拟列表核心类 (VirtualImageLayout)
|
|
28
|
+
│ ├── list.js # 页面逻辑和数据渲染
|
|
29
|
+
│ └── data.js # 图片数据
|
|
30
|
+
├── image-layout.html # 主页面
|
|
31
|
+
└── README.md # 项目文档
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
### 1. 克隆项目
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone <repository-url>
|
|
40
|
+
cd <project-directory>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. 打开页面
|
|
44
|
+
|
|
45
|
+
直接在浏览器中打开 `image-layout.html` 文件即可。
|
|
46
|
+
|
|
47
|
+
## 使用说明
|
|
48
|
+
|
|
49
|
+
### 基本用法
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
// 初始化虚拟列表
|
|
53
|
+
var layout = new VirtualImageLayout('container', {
|
|
54
|
+
rowGap: 16, // 行间距
|
|
55
|
+
titleGap: 30, // 标题高度(可选)
|
|
56
|
+
|
|
57
|
+
// 自定义渲染函数
|
|
58
|
+
onRenderItem: (item, imgWidth, imgHeight, rowHeight) => {
|
|
59
|
+
return `
|
|
60
|
+
<div class="images-gallery">
|
|
61
|
+
<a class="images" style="width: ${imgWidth}px; height: ${imgHeight}px;">
|
|
62
|
+
<img src="${item.img}" alt="${item.res_name}">
|
|
63
|
+
</a>
|
|
64
|
+
<a class="name">${item.res_name}</a>
|
|
65
|
+
</div>
|
|
66
|
+
`;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// 曝光回调(可选)
|
|
70
|
+
ExposureCallback(burialPoint) {
|
|
71
|
+
console.log('曝光数据:', burialPoint);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 添加图片数据
|
|
76
|
+
layout.addImages(imageData);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 数据格式
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
var jsonData = [
|
|
83
|
+
{
|
|
84
|
+
"res_name": "图片标题",
|
|
85
|
+
"img": "https://example.com/image.jpg",
|
|
86
|
+
"width": 1920,
|
|
87
|
+
"height": 1080
|
|
88
|
+
},
|
|
89
|
+
// 更多图片...
|
|
90
|
+
];
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 核心类说明
|
|
94
|
+
|
|
95
|
+
### VirtualImageLayout
|
|
96
|
+
|
|
97
|
+
虚拟列表核心类,负责图片布局和渲染。
|
|
98
|
+
|
|
99
|
+
#### 构造函数参数
|
|
100
|
+
|
|
101
|
+
| 参数 | 类型 | 说明 |
|
|
102
|
+
|------|------|------|
|
|
103
|
+
| containerId | String | 容器元素 ID |
|
|
104
|
+
| options | Object | 配置选项 |
|
|
105
|
+
|
|
106
|
+
#### 配置选项
|
|
107
|
+
|
|
108
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
109
|
+
|------|------|--------|------|
|
|
110
|
+
| rowGap | Number | 16 | 行间距(像素) |
|
|
111
|
+
| titleGap | Number | - | 标题高度(像素),设置后显示标题 |
|
|
112
|
+
| onRenderItem | Function | - | 自定义渲染函数 |
|
|
113
|
+
| ExposureCallback | Function | - | 图片曝光回调函数 |
|
|
114
|
+
|
|
115
|
+
#### 主要方法
|
|
116
|
+
|
|
117
|
+
- `addImages(images)`: 添加图片数据
|
|
118
|
+
- `updateVirtualRows()`: 更新可视区域的行
|
|
119
|
+
- `handleResize()`: 处理窗口大小变化
|
|
120
|
+
|
|
121
|
+
## 性能优化
|
|
122
|
+
|
|
123
|
+
1. **虚拟滚动**:只渲染可视区域 + 缓冲区的图片
|
|
124
|
+
2. **防抖处理**:滚动和窗口大小变化事件使用防抖
|
|
125
|
+
3. **图片懒加载**:滚动到底部自动加载更多
|
|
126
|
+
4. **智能布局算法**:动态计算目标行高,适配不同屏幕
|
|
127
|
+
|
|
128
|
+
## 浏览器兼容性
|
|
129
|
+
|
|
130
|
+
- Chrome (推荐)
|
|
131
|
+
- Firefox
|
|
132
|
+
- Safari
|
|
133
|
+
- Edge
|
|
134
|
+
|
|
135
|
+
需要支持 ES6+ 和 IntersectionObserver API。
|
|
136
|
+
|
|
137
|
+
## 开发说明
|
|
138
|
+
|
|
139
|
+
### 修改样式
|
|
140
|
+
|
|
141
|
+
编辑 `css/index.css` 文件来自定义样式。
|
|
142
|
+
|
|
143
|
+
### 修改数据
|
|
144
|
+
|
|
145
|
+
编辑 `js/data.js` 文件来修改图片数据。
|
|
146
|
+
|
|
147
|
+
### 自定义渲染
|
|
148
|
+
|
|
149
|
+
在 `js/list.js` 中修改 `onRenderItem` 函数来自定义图片卡片的渲染方式。
|
|
150
|
+
|
|
151
|
+
## 注意事项
|
|
152
|
+
|
|
153
|
+
1. 图片数据必须包含 `width` 和 `height` 字段
|
|
154
|
+
2. 建议图片宽度大于高度,以获得更好的布局效果
|
|
155
|
+
3. 图片链接必须是公开可访问的 URL
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
160
|
+
|
|
161
|
+
## 贡献
|
|
162
|
+
|
|
163
|
+
欢迎提交 Issue 和 Pull Request!
|
package/css/index.css
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
* {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
font-family: Arial, sans-serif;
|
|
9
|
+
background-color: #f5f5f5;
|
|
10
|
+
padding:50px;
|
|
11
|
+
}
|
|
12
|
+
/* .list-page{
|
|
13
|
+
padding-left: 108px;
|
|
14
|
+
} */
|
|
15
|
+
|
|
16
|
+
.justified-gallery {
|
|
17
|
+
padding-bottom: 0;
|
|
18
|
+
margin: 0;
|
|
19
|
+
width: 100%;
|
|
20
|
+
min-height: 200px;
|
|
21
|
+
position: relative;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.justified-gallery .image-row {
|
|
25
|
+
display: flex;
|
|
26
|
+
position: absolute;
|
|
27
|
+
left: 0;
|
|
28
|
+
width: 100%;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.justified-gallery .images-gallery {
|
|
32
|
+
flex-shrink: 0;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
transition: transform 0.3s;
|
|
36
|
+
position: relative;
|
|
37
|
+
border-radius: 8px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.justified-gallery .images-gallery img {
|
|
41
|
+
display: block;
|
|
42
|
+
border-radius: 8px;
|
|
43
|
+
max-width: 100%;
|
|
44
|
+
max-height: 100%;
|
|
45
|
+
width: 100%;
|
|
46
|
+
height: 100%;
|
|
47
|
+
object-fit: cover;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
.justified-gallery .images-gallery .images {
|
|
52
|
+
border-radius: 8px;
|
|
53
|
+
position: relative;
|
|
54
|
+
display: block;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
background-color: #dddddd;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.name{
|
|
60
|
+
position: absolute;
|
|
61
|
+
display: block;
|
|
62
|
+
/* 溢出影藏出现省略号 */
|
|
63
|
+
max-width: 100%;
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
text-overflow: ellipsis;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* // 加载更多动画 */
|
|
70
|
+
.load-more-waterfall {
|
|
71
|
+
padding: 50px 0 60px;
|
|
72
|
+
text-align: center;
|
|
73
|
+
color: #86909C;
|
|
74
|
+
font-size: 14px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.wave-loading {
|
|
78
|
+
text-align: center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.wave-loading span {
|
|
82
|
+
display: inline-block;
|
|
83
|
+
width: 10px;
|
|
84
|
+
height: 10px;
|
|
85
|
+
border-radius: 50%;
|
|
86
|
+
background-color: rgb(197, 197, 197);
|
|
87
|
+
margin: 0 2px;
|
|
88
|
+
animation: wave-loading 1s ease-in-out infinite;
|
|
89
|
+
animation-delay: calc(0.1s * var(--time));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@keyframes wave-loading {
|
|
93
|
+
0% {
|
|
94
|
+
transform: translateY(0px);
|
|
95
|
+
opacity: 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
25% {
|
|
99
|
+
transform: translateY(-10px);
|
|
100
|
+
opacity: 0.1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
50%,
|
|
104
|
+
100% {
|
|
105
|
+
transform: translateY(0px);
|
|
106
|
+
opacity: 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@keyframes globalnav-search-input-intro {
|
|
111
|
+
0% {
|
|
112
|
+
opacity: 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
to {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
package/js/index.js
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
class VirtualImageLayout {
|
|
2
|
+
constructor(containerId, options = {}) {
|
|
3
|
+
this.container = document.getElementById(containerId);
|
|
4
|
+
this.rowGap = options.rowGap || 16;
|
|
5
|
+
this.titleGap = options.titleGap; // 标题高度,如果设置了就显示标题
|
|
6
|
+
this.containerWidth = this.container.offsetWidth;
|
|
7
|
+
this.targetHeight = this.calculateTargetHeight(); // 动态计算目标行高
|
|
8
|
+
|
|
9
|
+
// 回调函数
|
|
10
|
+
this.onRenderItem = options.onRenderItem;
|
|
11
|
+
this.onImageExpose = options.onImageExpose || null; // 图片曝光回调
|
|
12
|
+
|
|
13
|
+
// 虚拟列表相关
|
|
14
|
+
this.allRows = []; // 所有行的数据和位置信息
|
|
15
|
+
this.renderedRows = {}; // 已渲染的行 {rowIndex: element}
|
|
16
|
+
this.viewportHeight = window.innerHeight;
|
|
17
|
+
this.bufferZone = this.viewportHeight * 2; // 缓冲区
|
|
18
|
+
this.scrollTop = 0;
|
|
19
|
+
this.containerOffsetTop = 0;
|
|
20
|
+
|
|
21
|
+
// 图片数据
|
|
22
|
+
this.pendingImages = [];
|
|
23
|
+
this.allImages = [];
|
|
24
|
+
|
|
25
|
+
// 曝光监听相关
|
|
26
|
+
this.data = options;
|
|
27
|
+
this.site_num = [];
|
|
28
|
+
// 埋点参数
|
|
29
|
+
this.BurialPoint = {
|
|
30
|
+
res_detail: []
|
|
31
|
+
};
|
|
32
|
+
this.indexReportedArr = []; // 上报记录
|
|
33
|
+
this.maxNum = 30;
|
|
34
|
+
this._observer = null; // IntersectionObserver 实例
|
|
35
|
+
this._timer = null; // 定时器
|
|
36
|
+
|
|
37
|
+
if (typeof options.ExposureCallback === 'function') {
|
|
38
|
+
//全局只会实例化一次Exposure类,init方法只会执行一次
|
|
39
|
+
this.setupIntersectionObserver();
|
|
40
|
+
}
|
|
41
|
+
this.init();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
init() {
|
|
45
|
+
this.containerOffsetTop = this.getContainerOffsetTop();
|
|
46
|
+
this.setupScrollListener();
|
|
47
|
+
this.setupResizeListener();
|
|
48
|
+
this.handleImageError();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getContainerOffsetTop() {
|
|
52
|
+
const rect = this.container.getBoundingClientRect();
|
|
53
|
+
return rect.top + window.pageYOffset;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 根据容器宽度动态计算目标行高
|
|
57
|
+
calculateTargetHeight() {
|
|
58
|
+
const containerWidth = this.containerWidth;
|
|
59
|
+
if (containerWidth < 600) {
|
|
60
|
+
return 200;
|
|
61
|
+
} else if (containerWidth < 1600) {
|
|
62
|
+
return 220;
|
|
63
|
+
}else if (containerWidth < 1920) {
|
|
64
|
+
return 230;
|
|
65
|
+
} else {
|
|
66
|
+
return 250;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 添加图片数据
|
|
71
|
+
addImages(images) {
|
|
72
|
+
// 如果没有数据,直接返回
|
|
73
|
+
if (!images || images.length === 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ✅ 如果存在临时最后一行,先移除它并恢复 pendingImages
|
|
78
|
+
if (this.allRows.length > 0) {
|
|
79
|
+
const lastRow = this.allRows[this.allRows.length - 1];
|
|
80
|
+
if (lastRow.isTempLastRow) {
|
|
81
|
+
// 移除临时最后一行
|
|
82
|
+
this.allRows.pop();
|
|
83
|
+
// 恢复 pendingImages
|
|
84
|
+
this.pendingImages = [...lastRow.images];
|
|
85
|
+
// 从 allImages 中移除这些图片(因为它们要重新布局)
|
|
86
|
+
this.allImages.splice(this.allImages.length - lastRow.images.length, lastRow.images.length);
|
|
87
|
+
|
|
88
|
+
// 移除已渲染的临时行 DOM
|
|
89
|
+
if (this.renderedRows[lastRow.rowIndex]) {
|
|
90
|
+
this.renderedRows[lastRow.rowIndex].remove();
|
|
91
|
+
delete this.renderedRows[lastRow.rowIndex];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ✅ 先记录当前图片总数(作为新图片的起始索引)
|
|
97
|
+
const startIndex = this.allImages.length;
|
|
98
|
+
|
|
99
|
+
// 保存所有图片
|
|
100
|
+
this.allImages.push(...images);
|
|
101
|
+
|
|
102
|
+
// 布局新图片(传入起始索引)
|
|
103
|
+
const newRows = this.layoutImages(images, startIndex);
|
|
104
|
+
|
|
105
|
+
// 计算新行位置
|
|
106
|
+
let startTop = 0;
|
|
107
|
+
if (this.allRows.length > 0) {
|
|
108
|
+
const lastRow = this.allRows[this.allRows.length - 1];
|
|
109
|
+
const maxAllowedHeight = this.targetHeight + 15;
|
|
110
|
+
const lastRowActualHeight = Math.min(lastRow.height, maxAllowedHeight);
|
|
111
|
+
// 行高需要加上titleGap(如果设置了)
|
|
112
|
+
const lastRowTotalHeight = this.titleGap ? (lastRowActualHeight + this.titleGap) : lastRowActualHeight;
|
|
113
|
+
startTop = lastRow.top + lastRowTotalHeight + this.rowGap;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
newRows.forEach((row) => {
|
|
117
|
+
row.top = startTop;
|
|
118
|
+
row.rowIndex = this.allRows.length;
|
|
119
|
+
this.allRows.push(row);
|
|
120
|
+
// 使用限制后的高度计算下一行位置
|
|
121
|
+
const maxAllowedHeight = this.targetHeight + 15;
|
|
122
|
+
const actualHeight = Math.min(row.height, maxAllowedHeight);
|
|
123
|
+
// 行高需要加上titleGap(如果设置了)
|
|
124
|
+
const rowTotalHeight = this.titleGap ? (actualHeight + this.titleGap) : actualHeight;
|
|
125
|
+
startTop += rowTotalHeight + this.rowGap;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ✅ 如果有剩余图片(pendingImages),创建一个临时最后一行
|
|
129
|
+
if (this.pendingImages.length > 0) {
|
|
130
|
+
const height = this.targetHeight;
|
|
131
|
+
const tempLastRow = {
|
|
132
|
+
images: [...this.pendingImages],
|
|
133
|
+
height: height,
|
|
134
|
+
isTempLastRow: true, // 标记为临时最后一行
|
|
135
|
+
isLastRow: true, // 也需要 isLastRow 标记,用于渲染时不拉伸
|
|
136
|
+
top: startTop,
|
|
137
|
+
rowIndex: this.allRows.length,
|
|
138
|
+
containerWidth: this.containerWidth
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
this.allRows.push(tempLastRow);
|
|
142
|
+
|
|
143
|
+
// 使用限制后的高度计算容器高度
|
|
144
|
+
const maxAllowedHeight = this.targetHeight + 15;
|
|
145
|
+
const actualHeight = Math.min(height, maxAllowedHeight);
|
|
146
|
+
const rowTotalHeight = this.titleGap ? (actualHeight + this.titleGap) : actualHeight;
|
|
147
|
+
startTop += rowTotalHeight + this.rowGap;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 更新容器高度
|
|
151
|
+
this.container.style.height = `${startTop}px`;
|
|
152
|
+
|
|
153
|
+
// 更新虚拟渲染
|
|
154
|
+
this.updateVirtualRows();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
calculateCropSize(originalWidth, originalHeight) {
|
|
158
|
+
const ratio = originalWidth / originalHeight;
|
|
159
|
+
let cropRatio;
|
|
160
|
+
|
|
161
|
+
if (ratio >= 1 && ratio <= 1.65) {
|
|
162
|
+
// 不裁剪,保持原始比例
|
|
163
|
+
cropRatio = ratio;
|
|
164
|
+
} else if (ratio > 1.65) {
|
|
165
|
+
// 居中裁剪为 1.65:1
|
|
166
|
+
cropRatio = 1.65;
|
|
167
|
+
} else {
|
|
168
|
+
// 居中裁剪为 1:1(正方形)
|
|
169
|
+
cropRatio = 1.0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 返回裁剪后的宽高比,用于布局计算
|
|
173
|
+
return {
|
|
174
|
+
width: cropRatio * this.targetHeight, // 仅用于显示,实际渲染时会重新计算
|
|
175
|
+
height: this.targetHeight, // 仅用于显示,实际渲染时会重新计算
|
|
176
|
+
ratio: cropRatio, // ✅ 关键:只保存比例,不保存具体尺寸
|
|
177
|
+
originalRatio: ratio // 保存原始比例,用于判断是否需要裁剪
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
layoutImages(images, startIndex, forceRecalculate = false) {
|
|
182
|
+
const rows = [];
|
|
183
|
+
let currentRow = [];
|
|
184
|
+
let currentRowWidthRatios = 0;
|
|
185
|
+
|
|
186
|
+
if (this.pendingImages.length > 0) {
|
|
187
|
+
currentRow = [...this.pendingImages];
|
|
188
|
+
// ✅ 重新计算 pendingImages 的 cropRatio,确保使用最新的 targetHeight
|
|
189
|
+
currentRow.forEach(img => {
|
|
190
|
+
const width = img.width;
|
|
191
|
+
const height = img.height;
|
|
192
|
+
const originalRatio = width / height;
|
|
193
|
+
img.cropWidth = originalRatio * this.targetHeight;
|
|
194
|
+
img.cropHeight = this.targetHeight;
|
|
195
|
+
img.cropRatio = originalRatio;
|
|
196
|
+
img.originalRatio = originalRatio;
|
|
197
|
+
});
|
|
198
|
+
currentRowWidthRatios = currentRow.reduce((sum, img) => sum + img.cropRatio, 0);
|
|
199
|
+
this.pendingImages = [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
images.forEach((image, index) => {
|
|
203
|
+
if (image.img_url === '' && image.img === '') return;
|
|
204
|
+
|
|
205
|
+
// ✅ 强制重新计算或首次计算时,重新计算 cropSize
|
|
206
|
+
if (forceRecalculate || !image.cropRatio) {
|
|
207
|
+
const width = image.view_data?.width || image.width;
|
|
208
|
+
const height = image.view_data?.height || image.height;
|
|
209
|
+
const originalRatio = width / height;
|
|
210
|
+
image.cropWidth = originalRatio * this.targetHeight;
|
|
211
|
+
image.cropHeight = this.targetHeight;
|
|
212
|
+
image.cropRatio = originalRatio;
|
|
213
|
+
image.originalRatio = originalRatio;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ✅ 只在没有 data_index 时才设置(避免 resize 时重复设置)
|
|
217
|
+
if (!image.data_index) {
|
|
218
|
+
image.data_index = startIndex + index + 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const potentialRatios = currentRowWidthRatios + image.cropRatio;
|
|
222
|
+
const gapCount = currentRow.length;
|
|
223
|
+
const availableWidth = this.containerWidth - gapCount * this.rowGap;
|
|
224
|
+
const potentialHeight = availableWidth / potentialRatios;
|
|
225
|
+
const minHeight = this.targetHeight - 10;
|
|
226
|
+
|
|
227
|
+
if (potentialHeight >= minHeight) {
|
|
228
|
+
currentRow.push(image);
|
|
229
|
+
currentRowWidthRatios = potentialRatios;
|
|
230
|
+
} else {
|
|
231
|
+
if (currentRow.length > 0) {
|
|
232
|
+
rows.push({
|
|
233
|
+
images: currentRow,
|
|
234
|
+
height: this.calculateRowHeight(currentRow),
|
|
235
|
+
containerWidth: this.containerWidth
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
currentRow = [image];
|
|
239
|
+
currentRowWidthRatios = image.cropRatio;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (index === images.length - 1 && currentRow.length > 0) {
|
|
243
|
+
this.pendingImages = currentRow;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return rows;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
calculateRowHeight(images) {
|
|
251
|
+
const totalRatio = images.reduce((sum, img) => sum + img.cropRatio, 0);
|
|
252
|
+
const availableWidth = this.containerWidth - (images.length - 1) * this.rowGap;
|
|
253
|
+
const height = availableWidth / totalRatio;
|
|
254
|
+
|
|
255
|
+
return height;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 虚拟滚动核心:更新可见行
|
|
259
|
+
updateVirtualRows() {
|
|
260
|
+
const scrollTop = this.scrollTop - this.containerOffsetTop;
|
|
261
|
+
const viewportTop = Math.max(0, scrollTop - this.bufferZone);
|
|
262
|
+
const viewportBottom = scrollTop + this.viewportHeight + this.bufferZone;
|
|
263
|
+
|
|
264
|
+
const rowsToRender = new Set();
|
|
265
|
+
const rowsToRemove = new Set(Object.keys(this.renderedRows).map(Number));
|
|
266
|
+
|
|
267
|
+
this.allRows.forEach((row, index) => {
|
|
268
|
+
const rowBottom = row.top + row.height;
|
|
269
|
+
if (rowBottom >= viewportTop && row.top <= viewportBottom) {
|
|
270
|
+
rowsToRender.add(index);
|
|
271
|
+
rowsToRemove.delete(index);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// 移除不可见的行
|
|
276
|
+
rowsToRemove.forEach(rowIndex => {
|
|
277
|
+
if (this.renderedRows[rowIndex]) {
|
|
278
|
+
this.renderedRows[rowIndex].remove();
|
|
279
|
+
delete this.renderedRows[rowIndex];
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// 渲染新行
|
|
284
|
+
rowsToRender.forEach(rowIndex => {
|
|
285
|
+
if (!this.renderedRows[rowIndex]) {
|
|
286
|
+
const row = this.allRows[rowIndex];
|
|
287
|
+
const rowElement = this.renderRow(row);
|
|
288
|
+
this.renderedRows[rowIndex] = rowElement;
|
|
289
|
+
this.container.appendChild(rowElement);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
renderRow(row) {
|
|
295
|
+
const rowDiv = document.createElement('div');
|
|
296
|
+
rowDiv.className = 'image-row';
|
|
297
|
+
if (row.isLastRow) {
|
|
298
|
+
rowDiv.classList.add('last-row');
|
|
299
|
+
}
|
|
300
|
+
rowDiv.style.top = `${row.top}px`;
|
|
301
|
+
|
|
302
|
+
// 限制行高:最多超过目标高度15像素
|
|
303
|
+
const maxAllowedHeight = this.targetHeight + 15;
|
|
304
|
+
const actualHeight = Math.min(row.height, maxAllowedHeight);
|
|
305
|
+
// 行高需要加上titleGap(如果设置了)
|
|
306
|
+
const rowTotalHeight = this.titleGap ? (actualHeight + this.titleGap) : actualHeight;
|
|
307
|
+
rowDiv.style.height = `${rowTotalHeight}px`;
|
|
308
|
+
rowDiv.style.gap = `${this.rowGap}px`;
|
|
309
|
+
rowDiv.dataset.rowIndex = row.rowIndex;
|
|
310
|
+
|
|
311
|
+
if (row.isLastRow) {
|
|
312
|
+
// 最后一行:保持图片原始比例,不拉伸填满
|
|
313
|
+
row.images.forEach(image => {
|
|
314
|
+
const imgWidth = actualHeight * image.cropRatio;
|
|
315
|
+
const imgHeight = actualHeight;
|
|
316
|
+
const itemDiv = this.createImageItem(image, imgWidth, imgHeight, actualHeight);
|
|
317
|
+
rowDiv.appendChild(itemDiv);
|
|
318
|
+
|
|
319
|
+
// 添加曝光观察
|
|
320
|
+
if (this._observer && itemDiv) {
|
|
321
|
+
this.addExposureObserver(itemDiv, image);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
// 普通行:按 justifiedGallery 算法分配宽度
|
|
326
|
+
const containerWidth = row.containerWidth || this.containerWidth;
|
|
327
|
+
let availableWidth = containerWidth - (row.images.length - 1) * this.rowGap;
|
|
328
|
+
|
|
329
|
+
row.images.forEach((image, index) => {
|
|
330
|
+
let imgWidth;
|
|
331
|
+
if (index === row.images.length - 1) {
|
|
332
|
+
// 最后一张图片使用剩余宽度,确保填满整行
|
|
333
|
+
imgWidth = availableWidth;
|
|
334
|
+
} else {
|
|
335
|
+
// 使用原始行高计算宽度,确保填满整行
|
|
336
|
+
imgWidth = Math.round(row.height * image.cropRatio);
|
|
337
|
+
availableWidth -= imgWidth;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 使用限制后的高度显示
|
|
341
|
+
const imgHeight = actualHeight;
|
|
342
|
+
const itemDiv = this.createImageItem(image, imgWidth, imgHeight, actualHeight);
|
|
343
|
+
|
|
344
|
+
rowDiv.appendChild(itemDiv);
|
|
345
|
+
|
|
346
|
+
// 添加曝光观察
|
|
347
|
+
if (this._observer && itemDiv) {
|
|
348
|
+
this.addExposureObserver(itemDiv, image);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return rowDiv;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
createImageItem(image, width, height, rowHeight) {
|
|
357
|
+
const result = this.onRenderItem(image, width, height, rowHeight);
|
|
358
|
+
|
|
359
|
+
let itemElement;
|
|
360
|
+
if (result && typeof result === 'object' && result.jquery) {
|
|
361
|
+
// 兼容 jQuery 对象(不强依赖 jQuery)
|
|
362
|
+
itemElement = result[0];
|
|
363
|
+
} else if (typeof result === 'string') {
|
|
364
|
+
const temp = document.createElement('div');
|
|
365
|
+
temp.innerHTML = result;
|
|
366
|
+
itemElement = temp.firstElementChild;
|
|
367
|
+
} else {
|
|
368
|
+
itemElement = result;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return itemElement;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 添加曝光观察
|
|
375
|
+
addExposureObserver(itemElement, image) {
|
|
376
|
+
if (!this._observer || !itemElement) return;
|
|
377
|
+
|
|
378
|
+
// 查找 .images-gallery 元素
|
|
379
|
+
let targetElement = itemElement;
|
|
380
|
+
if (!itemElement.classList.contains('images-gallery')) {
|
|
381
|
+
targetElement = itemElement.querySelector('.images-gallery');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (targetElement) {
|
|
385
|
+
// 开始观察该元素
|
|
386
|
+
this._observer.observe(targetElement);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
setupScrollListener() {
|
|
391
|
+
let scrollTimer;
|
|
392
|
+
window.addEventListener('scroll', () => {
|
|
393
|
+
clearTimeout(scrollTimer);
|
|
394
|
+
scrollTimer = setTimeout(() => {
|
|
395
|
+
this.scrollTop = window.pageYOffset;
|
|
396
|
+
this.updateVirtualRows();
|
|
397
|
+
}, 50);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
setupResizeListener() {
|
|
402
|
+
let resizeTimer;
|
|
403
|
+
window.addEventListener('resize', () => {
|
|
404
|
+
clearTimeout(resizeTimer);
|
|
405
|
+
resizeTimer = setTimeout(() => {
|
|
406
|
+
this.handleResize();
|
|
407
|
+
}, 300);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
handleResize() {
|
|
412
|
+
const newWidth = this.container.offsetWidth;
|
|
413
|
+
if (newWidth === this.containerWidth) return;
|
|
414
|
+
|
|
415
|
+
// ✅ 先更新容器宽度和目标行高
|
|
416
|
+
this.containerWidth = newWidth;
|
|
417
|
+
this.targetHeight = this.calculateTargetHeight();
|
|
418
|
+
this.containerOffsetTop = this.getContainerOffsetTop();
|
|
419
|
+
this.viewportHeight = window.innerHeight;
|
|
420
|
+
|
|
421
|
+
// 清空渲染
|
|
422
|
+
this.container.innerHTML = '';
|
|
423
|
+
this.renderedRows = {};
|
|
424
|
+
|
|
425
|
+
// 重新计算所有行
|
|
426
|
+
this.recalculateAllRows();
|
|
427
|
+
this.updateVirtualRows();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
recalculateAllRows() {
|
|
431
|
+
// ✅ 从 this.allImages 获取所有图片,而不是从 this.allRows
|
|
432
|
+
// 因为 this.allImages 包含了所有已加载的图片(包括第一页、第二页等)
|
|
433
|
+
const allImages = [...this.allImages];
|
|
434
|
+
|
|
435
|
+
this.allRows = [];
|
|
436
|
+
this.pendingImages = [];
|
|
437
|
+
|
|
438
|
+
if (allImages.length > 0) {
|
|
439
|
+
// ✅ 不传 forceRecalculate,使用已有的 cropRatio
|
|
440
|
+
// 这样可以确保 resize 后的布局和自然加载的布局完全一致
|
|
441
|
+
const newRows = this.layoutImages(allImages, 0);
|
|
442
|
+
|
|
443
|
+
let startTop = 0;
|
|
444
|
+
newRows.forEach((row, index) => {
|
|
445
|
+
row.top = startTop;
|
|
446
|
+
row.rowIndex = index;
|
|
447
|
+
this.allRows.push(row);
|
|
448
|
+
// 使用限制后的高度计算下一行位置
|
|
449
|
+
const maxAllowedHeight = this.targetHeight + 15;
|
|
450
|
+
const actualHeight = Math.min(row.height, maxAllowedHeight);
|
|
451
|
+
// 行高需要加上titleGap(如果设置了)
|
|
452
|
+
const rowTotalHeight = this.titleGap ? (actualHeight + this.titleGap) : actualHeight;
|
|
453
|
+
startTop += rowTotalHeight + this.rowGap;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ✅ 只有在没有更多数据时才创建最后一行
|
|
457
|
+
// 如果还有更多数据(this.hasMore !== false),保留 pendingImages 等待下次加载
|
|
458
|
+
if (this.pendingImages.length > 0 && this.hasMore === false) {
|
|
459
|
+
const height = this.targetHeight;
|
|
460
|
+
const lastRow = {
|
|
461
|
+
images: [...this.pendingImages],
|
|
462
|
+
height: height,
|
|
463
|
+
isLastRow: true,
|
|
464
|
+
top: startTop,
|
|
465
|
+
rowIndex: this.allRows.length,
|
|
466
|
+
containerWidth: this.containerWidth
|
|
467
|
+
};
|
|
468
|
+
this.allRows.push(lastRow);
|
|
469
|
+
this.pendingImages = [];
|
|
470
|
+
const maxAllowedHeight = this.targetHeight + 15;
|
|
471
|
+
const actualHeight = Math.min(height, maxAllowedHeight);
|
|
472
|
+
// 行高需要加上titleGap(如果设置了)
|
|
473
|
+
const rowTotalHeight = this.titleGap ? (actualHeight + this.titleGap) : actualHeight;
|
|
474
|
+
startTop += rowTotalHeight + this.rowGap;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
this.container.style.height = `${startTop}px`;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 设置 IntersectionObserver 监听图片曝光
|
|
482
|
+
setupIntersectionObserver() {
|
|
483
|
+
const self = this;
|
|
484
|
+
this._observer = new IntersectionObserver((function (entries, observer) {
|
|
485
|
+
entries.forEach(entry => {
|
|
486
|
+
//每个元素进入视窗都会触发
|
|
487
|
+
if (entry.isIntersecting) {
|
|
488
|
+
//获取元素信息+
|
|
489
|
+
const { index } = entry.target.dataset;
|
|
490
|
+
const { cardType } = entry.target.dataset;
|
|
491
|
+
if (self.indexReportedArr.indexOf(index) == -1 && !cardType) {
|
|
492
|
+
//清除当前定时器
|
|
493
|
+
clearTimeout(self._timer);
|
|
494
|
+
//收集的数据加到上报数组中
|
|
495
|
+
self.site_num.push(index);
|
|
496
|
+
let attributeNames = Array.from(entry.target.attributes).map(function (attribute) {
|
|
497
|
+
return attribute.name;
|
|
498
|
+
});
|
|
499
|
+
let customAttributes = {}
|
|
500
|
+
let SingleAttributes = {};
|
|
501
|
+
// let customAttributes={};
|
|
502
|
+
// 判断单个上报唯一标识
|
|
503
|
+
if (self.data.single_class && (entry.target.getAttribute('class') || '').indexOf(self.data.single_class) != -1 && typeof self.data.buriledSinglePoint === 'function') {
|
|
504
|
+
for (var i = 0; i < attributeNames.length; i++) {
|
|
505
|
+
if (attributeNames[i] !== 'style') {
|
|
506
|
+
let new_key = attributeNames[i].indexOf('data-') != -1 ? attributeNames[i].replace('data-', '') : attributeNames[i];
|
|
507
|
+
SingleAttributes[new_key] = entry.target.getAttribute(attributeNames[i]);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
self.data.buriledSinglePoint(SingleAttributes);
|
|
511
|
+
//收集的数据加到上报数组中
|
|
512
|
+
self.indexReportedArr.push(index);
|
|
513
|
+
// //收集到该元素后,取消对该元素dom的观察
|
|
514
|
+
self._observer.unobserve(entry.target);
|
|
515
|
+
return
|
|
516
|
+
} else {
|
|
517
|
+
for (var i = 0; i < attributeNames.length; i++) {
|
|
518
|
+
if (attributeNames[i] !== 'style') {
|
|
519
|
+
let new_key = attributeNames[i].indexOf('data-') != -1 ? attributeNames[i].replace('data-', '') : attributeNames[i];
|
|
520
|
+
customAttributes[new_key] = entry.target.getAttribute(attributeNames[i]);
|
|
521
|
+
if (!self.BurialPoint[new_key]) {
|
|
522
|
+
self.BurialPoint[new_key] = [entry.target.getAttribute(attributeNames[i])];
|
|
523
|
+
} else {
|
|
524
|
+
self.BurialPoint[new_key].push(entry.target.getAttribute(attributeNames[i]));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
self.BurialPoint.res_detail.push(customAttributes);
|
|
530
|
+
//收集的数据加到上报数组中
|
|
531
|
+
self.indexReportedArr.push(index);
|
|
532
|
+
// //收集到该元素后,取消对该元素dom的观察
|
|
533
|
+
self._observer.unobserve(entry.target);
|
|
534
|
+
//超过一定量打点,打点会删除这一批
|
|
535
|
+
if (self.site_num.length >= self.maxNum) {
|
|
536
|
+
self.data.ExposureCallback(self.BurialPoint)
|
|
537
|
+
self.site_num.splice(0, self.maxNum);
|
|
538
|
+
self.BurialPoint.res_detail.splice(0, self.maxNum);
|
|
539
|
+
for (var i = 0; i < attributeNames.length; i++) {
|
|
540
|
+
if (attributeNames[i] !== 'style') {
|
|
541
|
+
let new_key = attributeNames[i].indexOf('data-') != -1 ? attributeNames[i].replace('data-', '') : attributeNames[i];
|
|
542
|
+
self.BurialPoint[new_key].splice(0, self.maxNum);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
if (self.site_num.length > 0) {
|
|
547
|
+
//只要有新的alias添加进来,接下来如果没有增加,自动1s后打
|
|
548
|
+
self._timer = window.setTimeout(function () {
|
|
549
|
+
if (self.site_num.length > 0) { // 防止同页面清除数据后,定时器还没执行,就执行了打点
|
|
550
|
+
self.data.ExposureCallback(self.BurialPoint);
|
|
551
|
+
self.site_num.splice(0, self.maxNum);
|
|
552
|
+
self.BurialPoint.res_detail.splice(0, self.maxNum);
|
|
553
|
+
for (var i = 0; i < attributeNames.length; i++) {
|
|
554
|
+
if (attributeNames[i] !== 'style') {
|
|
555
|
+
let new_key = attributeNames[i].indexOf('data-') != -1 ? attributeNames[i].replace('data-', '') : attributeNames[i];
|
|
556
|
+
self.BurialPoint[new_key].splice(0, self.maxNum);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}, 1000)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} else if (self.indexReportedArr.indexOf(index) == -1 && cardType == '5') {
|
|
564
|
+
self.indexReportedArr.push(index);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}, {
|
|
568
|
+
root: null,
|
|
569
|
+
rootMargin: '0px',
|
|
570
|
+
threshold: 0.1 // 不一定非得全部露出来 这个阈值可以小一点点
|
|
571
|
+
})
|
|
572
|
+
}), { threshold: 0.1 })
|
|
573
|
+
}
|
|
574
|
+
add(entry) {
|
|
575
|
+
this._observer && this._observer.observe(entry.el)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 清理曝光数据
|
|
579
|
+
waterfallRender() {
|
|
580
|
+
// 清除记录数据时检查下 是否包含已曝光数据,如有曝光数据,先曝光后清理记录数据
|
|
581
|
+
if (typeof this.data.ExposureCallback === 'function' && this.site_num.length > 0) {
|
|
582
|
+
clearTimeout(this._timer);
|
|
583
|
+
this.data.ExposureCallback(this.BurialPoint);
|
|
584
|
+
}
|
|
585
|
+
// 二次加载后清除之前记录数据
|
|
586
|
+
this.indexReportedArr = [];
|
|
587
|
+
this.site_num.splice(0, this.maxNum);
|
|
588
|
+
// 埋点参数
|
|
589
|
+
this.BurialPoint = {
|
|
590
|
+
res_detail: []
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
handleImageError() {
|
|
594
|
+
// 使用 MutationObserver 监听新增的图片元素
|
|
595
|
+
const observer = new MutationObserver((mutations) => {
|
|
596
|
+
mutations.forEach((mutation) => {
|
|
597
|
+
mutation.addedNodes.forEach((node) => {
|
|
598
|
+
if (node.nodeType === 1) { // 元素节点
|
|
599
|
+
// 查找新增节点中的所有图片
|
|
600
|
+
const images = node.querySelectorAll ? node.querySelectorAll('img') : [];
|
|
601
|
+
images.forEach((img) => {
|
|
602
|
+
img.addEventListener('error', function() {
|
|
603
|
+
this.remove(); // 删除加载失败的图片
|
|
604
|
+
}, { once: true }); // 只执行一次
|
|
605
|
+
});
|
|
606
|
+
// 如果新增节点本身就是图片
|
|
607
|
+
if (node.tagName === 'IMG') {
|
|
608
|
+
node.addEventListener('error', function() {
|
|
609
|
+
this.remove();
|
|
610
|
+
}, { once: true });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// 监听容器的子节点变化
|
|
618
|
+
if (this.container) {
|
|
619
|
+
observer.observe(this.container, {
|
|
620
|
+
childList: true,
|
|
621
|
+
subtree: true
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// 为已存在的图片绑定错误处理
|
|
626
|
+
const existingImages = this.container.querySelectorAll('img');
|
|
627
|
+
existingImages.forEach(function(img) {
|
|
628
|
+
img.addEventListener('error', function() {
|
|
629
|
+
this.remove();
|
|
630
|
+
}, { once: true });
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 支持 ESM / CommonJS / 浏览器全局变量
|
|
637
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
638
|
+
module.exports = VirtualImageLayout;
|
|
639
|
+
} else if (typeof define === 'function' && define.amd) {
|
|
640
|
+
define(function () { return VirtualImageLayout; });
|
|
641
|
+
} else if (typeof window !== 'undefined') {
|
|
642
|
+
window.VirtualImageLayout = VirtualImageLayout;
|
|
643
|
+
}
|
|
644
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "virtual-image-layout",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A virtual scrolling justified image gallery layout library, framework-agnostic",
|
|
5
|
+
"main": "js/index.js",
|
|
6
|
+
"module": "js/index.js",
|
|
7
|
+
"author": "lukuihao <351973031@qq.com>",
|
|
8
|
+
"keywords": ["virtual-scroll", "image-layout", "justified-gallery", "waterfall"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": [
|
|
11
|
+
"js/index.js",
|
|
12
|
+
"css/index.css"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "echo 'No build step required'"
|
|
16
|
+
}
|
|
17
|
+
}
|