@wyxos/vibe 1.2.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/LICENSE +21 -0
- package/README.md +124 -0
- package/index.js +9 -0
- package/package.json +39 -0
- package/src/App.vue +47 -0
- package/src/Masonry.vue +262 -0
- package/src/archive/App.vue +98 -0
- package/src/archive/InfiniteMansonry.spec.js +11 -0
- package/src/archive/InfiniteMasonry.vue +218 -0
- package/src/assets/vue.svg +1 -0
- package/src/calculateLayout.js +55 -0
- package/src/calculateLayout.test.js +48 -0
- package/src/main.js +5 -0
- package/src/pages.json +32502 -0
- package/src/style.css +2 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import {computed, nextTick, onMounted, onUnmounted, ref, watchEffect} from "vue";
|
|
3
|
+
|
|
4
|
+
const scrollPosition = ref(0);
|
|
5
|
+
const scrollDirection = ref('down');
|
|
6
|
+
|
|
7
|
+
const defaultOptions = {
|
|
8
|
+
sizes: {
|
|
9
|
+
1: {min: 0,},
|
|
10
|
+
2: {min: 401,},
|
|
11
|
+
4: {min: 801,},
|
|
12
|
+
6: {min: 1201,},
|
|
13
|
+
8: {min: 1601,},
|
|
14
|
+
10: {min: 2001,},
|
|
15
|
+
},
|
|
16
|
+
gutterX: 10,
|
|
17
|
+
gutterY: 10,
|
|
18
|
+
cellPadding: {
|
|
19
|
+
top: 0,
|
|
20
|
+
bottom: 0,
|
|
21
|
+
left: 0,
|
|
22
|
+
right: 0
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const mergedOptions = computed(() => ({
|
|
27
|
+
...defaultOptions,
|
|
28
|
+
...props.options,
|
|
29
|
+
sizes: props.options?.sizes ?? defaultOptions.sizes
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
const props = defineProps({
|
|
34
|
+
modelValue: {
|
|
35
|
+
type: Array,
|
|
36
|
+
required: true
|
|
37
|
+
},
|
|
38
|
+
options: {
|
|
39
|
+
type: Object,
|
|
40
|
+
default: () => ({})
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const columnsCount = ref(7);
|
|
45
|
+
|
|
46
|
+
const internalColumnHeights = ref([]);
|
|
47
|
+
|
|
48
|
+
const maximumHeight = computed(() => Math.max(...internalColumnHeights.value));
|
|
49
|
+
|
|
50
|
+
const container = ref(null);
|
|
51
|
+
|
|
52
|
+
const layouts = ref([]); // contains { id, top, left, width, height, src }
|
|
53
|
+
|
|
54
|
+
watchEffect(() => {
|
|
55
|
+
if (!container.value) return;
|
|
56
|
+
|
|
57
|
+
const scrollbarWidth = getScrollbarWidth(); // ← add this
|
|
58
|
+
const containerWidth = container.value.offsetWidth - scrollbarWidth;
|
|
59
|
+
const totalGutterX = (columnsCount.value - 1) * mergedOptions.value.gutterX;
|
|
60
|
+
const colWidth = Math.floor((containerWidth - totalGutterX) / columnsCount.value);
|
|
61
|
+
const colHeights = Array(columnsCount.value).fill(0);
|
|
62
|
+
|
|
63
|
+
const flatItems = props.modelValue.flatMap(p => p.items);
|
|
64
|
+
layouts.value = flatItems.map((item, index) => {
|
|
65
|
+
const columnIndex = index % columnsCount.value;
|
|
66
|
+
const scaledHeight = Math.round((item.height / item.width) * colWidth);
|
|
67
|
+
const top = colHeights[columnIndex];
|
|
68
|
+
const left = columnIndex * (colWidth + mergedOptions.value.gutterX);
|
|
69
|
+
|
|
70
|
+
// update cumulative column height
|
|
71
|
+
colHeights[columnIndex] += scaledHeight + mergedOptions.value.gutterY;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...item,
|
|
75
|
+
width: colWidth,
|
|
76
|
+
height: scaledHeight,
|
|
77
|
+
top,
|
|
78
|
+
left
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
internalColumnHeights.value = colHeights;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const visibleItems = computed(() => {
|
|
86
|
+
const scroll = scrollPosition.value;
|
|
87
|
+
const viewHeight = container.value?.offsetHeight || 0;
|
|
88
|
+
|
|
89
|
+
return layouts.value.filter(item => {
|
|
90
|
+
return (
|
|
91
|
+
item.top + item.height >= scroll - 200 &&
|
|
92
|
+
item.top <= scroll + viewHeight + 200
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const getScrollbarWidth = () => {
|
|
98
|
+
const outer = document.createElement('div');
|
|
99
|
+
outer.style.visibility = 'hidden';
|
|
100
|
+
outer.style.overflow = 'scroll';
|
|
101
|
+
outer.style.msOverflowStyle = 'scrollbar'; // for IE
|
|
102
|
+
outer.style.position = 'absolute';
|
|
103
|
+
outer.style.top = '-9999px';
|
|
104
|
+
outer.style.width = '100px';
|
|
105
|
+
document.body.appendChild(outer);
|
|
106
|
+
|
|
107
|
+
const inner = document.createElement('div');
|
|
108
|
+
inner.style.width = '100%';
|
|
109
|
+
outer.appendChild(inner);
|
|
110
|
+
|
|
111
|
+
const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
|
|
112
|
+
|
|
113
|
+
document.body.removeChild(outer);
|
|
114
|
+
return scrollbarWidth;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const emit = defineEmits(['update:modelValue', 'scroll']);
|
|
118
|
+
|
|
119
|
+
const onScroll = () => {
|
|
120
|
+
const el = container.value;
|
|
121
|
+
if (!el) return;
|
|
122
|
+
|
|
123
|
+
const scroll = el.scrollTop;
|
|
124
|
+
const viewHeight = el.clientHeight;
|
|
125
|
+
const contentHeight = el.scrollHeight;
|
|
126
|
+
|
|
127
|
+
const threshold = 50; // pixels from bottom (you can tweak this)
|
|
128
|
+
|
|
129
|
+
scrollDirection.value = scroll > scrollPosition.value ? 'down' : 'up';
|
|
130
|
+
scrollPosition.value = scroll;
|
|
131
|
+
|
|
132
|
+
const isEnd = scroll + viewHeight >= contentHeight - threshold;
|
|
133
|
+
const isStart = scroll <= threshold;
|
|
134
|
+
|
|
135
|
+
const viewportBottom = scroll + viewHeight;
|
|
136
|
+
const hasShortColumn = internalColumnHeights.value.some(height => height < viewportBottom - 50);
|
|
137
|
+
|
|
138
|
+
emit('scroll', {
|
|
139
|
+
position: scroll,
|
|
140
|
+
direction: scrollDirection.value,
|
|
141
|
+
isEnd,
|
|
142
|
+
isStart,
|
|
143
|
+
hasShortColumn
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const onResize = () => {
|
|
148
|
+
const scrollbarWidth = getScrollbarWidth();
|
|
149
|
+
const containerWidth = container.value.offsetWidth - scrollbarWidth;
|
|
150
|
+
columnsCount.value = 0;
|
|
151
|
+
|
|
152
|
+
const sizes = mergedOptions.value.sizes;
|
|
153
|
+
|
|
154
|
+
const sortedSizes = Object.entries(sizes).sort(
|
|
155
|
+
(a, b) => a[1].min - b[1].min
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
for (const [columns, { min }] of sortedSizes) {
|
|
159
|
+
if (containerWidth >= min) {
|
|
160
|
+
columnsCount.value = Number(columns);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const getCellPosition = (item) => {
|
|
166
|
+
return {
|
|
167
|
+
top: item.top + 'px',
|
|
168
|
+
left: item.left + 'px',
|
|
169
|
+
width: item.width + 'px',
|
|
170
|
+
height: item.height + 'px'
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const remove = () => {
|
|
175
|
+
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const restore = () => {
|
|
179
|
+
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
defineExpose({
|
|
183
|
+
remove,
|
|
184
|
+
restore
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
onMounted(async () => {
|
|
188
|
+
await nextTick(); // wait for DOM to render and size
|
|
189
|
+
onResize(); // now has proper width
|
|
190
|
+
container.value?.addEventListener('scroll', onScroll);
|
|
191
|
+
window.addEventListener('resize', onResize);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
onUnmounted(() => {
|
|
195
|
+
if (container.value) {
|
|
196
|
+
container.value.removeEventListener('scroll', onScroll);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
window.removeEventListener('resize', onResize);
|
|
200
|
+
})
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<template>
|
|
204
|
+
<div ref="container" class="overflow-auto flex-1 h-full w-full">
|
|
205
|
+
<div :style="{ height: `${maximumHeight}px` }" class="relative w-full">
|
|
206
|
+
<template v-for="item in visibleItems" :key="item.id">
|
|
207
|
+
<slot name="cell" :item="item" :get-cell-position="getCellPosition">
|
|
208
|
+
<div
|
|
209
|
+
class="absolute bg-slate-200 rounded-lg shadow-lg"
|
|
210
|
+
:style="getCellPosition(item)"
|
|
211
|
+
>
|
|
212
|
+
<img :src="item.src" class="w-full h-auto"/>
|
|
213
|
+
</div>
|
|
214
|
+
</slot>
|
|
215
|
+
</template>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function getScrollbarWidth() {
|
|
2
|
+
// Create a temporary div
|
|
3
|
+
const div = document.createElement('div')
|
|
4
|
+
div.style.visibility = 'hidden'
|
|
5
|
+
div.style.overflow = 'scroll' // force scrollbar
|
|
6
|
+
div.style.msOverflowStyle = 'scrollbar' // for IE
|
|
7
|
+
div.style.width = '100px'
|
|
8
|
+
div.style.height = '100px'
|
|
9
|
+
|
|
10
|
+
// Append to body
|
|
11
|
+
document.body.appendChild(div)
|
|
12
|
+
|
|
13
|
+
// Create inner div and measure difference
|
|
14
|
+
const inner = document.createElement('div')
|
|
15
|
+
inner.style.width = '100%'
|
|
16
|
+
div.appendChild(inner)
|
|
17
|
+
|
|
18
|
+
const scrollbarWidth = div.offsetWidth - inner.offsetWidth
|
|
19
|
+
|
|
20
|
+
// Clean up
|
|
21
|
+
document.body.removeChild(div)
|
|
22
|
+
|
|
23
|
+
return scrollbarWidth
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function calculateLayout(items, container, columnCount, gutterX = 0, gutterY = 0, header = 0, footer = 0) {
|
|
27
|
+
const measuredScrollbarWidth = container.offsetWidth - container.clientWidth;
|
|
28
|
+
const scrollbarWidth = measuredScrollbarWidth > 0 ? measuredScrollbarWidth + 2 : getScrollbarWidth() + 2;
|
|
29
|
+
const usableWidth = container.offsetWidth - scrollbarWidth;
|
|
30
|
+
const totalGutterX = gutterX * (columnCount - 1);
|
|
31
|
+
const columnWidth = Math.floor((usableWidth - totalGutterX) / columnCount);
|
|
32
|
+
|
|
33
|
+
const columnHeights = new Array(columnCount).fill(0);
|
|
34
|
+
const processedItems = [];
|
|
35
|
+
|
|
36
|
+
for (let index = 0; index < items.length; index++) {
|
|
37
|
+
const item = items[index];
|
|
38
|
+
const newItem = { ...item };
|
|
39
|
+
|
|
40
|
+
const col = index % columnCount;
|
|
41
|
+
const originalWidth = item.width;
|
|
42
|
+
const originalHeight = item.height;
|
|
43
|
+
|
|
44
|
+
newItem.columnWidth = columnWidth;
|
|
45
|
+
newItem.left = col * (columnWidth + gutterX);
|
|
46
|
+
newItem.columnHeight = Math.round((columnWidth * originalHeight) / originalWidth);
|
|
47
|
+
newItem.top = columnHeights[col];
|
|
48
|
+
|
|
49
|
+
columnHeights[col] += newItem.columnHeight + gutterY;
|
|
50
|
+
|
|
51
|
+
processedItems.push(newItem);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return processedItems;
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import calculateLayout from './calculateLayout'
|
|
3
|
+
import fixture from './pages.json'
|
|
4
|
+
|
|
5
|
+
describe('calculateLayout', () => {
|
|
6
|
+
it('should calculate correct layout positions for known inputs', () => {
|
|
7
|
+
const items = fixture[0].items.slice(0, 3) // first 3 items
|
|
8
|
+
const mockContainer = {
|
|
9
|
+
offsetWidth: 1000,
|
|
10
|
+
clientWidth: 980 // scrollbarWidth = 20
|
|
11
|
+
}
|
|
12
|
+
const columnCount = 3
|
|
13
|
+
const gutterX = 10
|
|
14
|
+
const gutterY = 10
|
|
15
|
+
|
|
16
|
+
const scrollbarWidth = mockContainer.offsetWidth - mockContainer.clientWidth
|
|
17
|
+
const usableWidth = mockContainer.offsetWidth - scrollbarWidth
|
|
18
|
+
const totalGutterX = gutterX * (columnCount - 1)
|
|
19
|
+
const expectedColumnWidth = Math.floor((usableWidth - totalGutterX) / columnCount)
|
|
20
|
+
|
|
21
|
+
const result = calculateLayout(items, mockContainer, columnCount, gutterX, gutterY)
|
|
22
|
+
|
|
23
|
+
expect(result.length).toBe(3)
|
|
24
|
+
|
|
25
|
+
result.forEach((item, i) => {
|
|
26
|
+
const original = items[i]
|
|
27
|
+
const expectedColumn = i % columnCount
|
|
28
|
+
const expectedLeft = expectedColumn * (expectedColumnWidth + gutterX)
|
|
29
|
+
const expectedHeight = Math.round((expectedColumnWidth * original.height) / original.width)
|
|
30
|
+
|
|
31
|
+
expect(item.left).toBe(expectedLeft)
|
|
32
|
+
expect(item.columnWidth).toBe(expectedColumnWidth)
|
|
33
|
+
expect(item.columnHeight).toBe(expectedHeight)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Test top stacking logic: second item in same column should be placed below with gutter
|
|
37
|
+
const secondBatch = fixture[0].items.slice(0, 6)
|
|
38
|
+
const stacked = calculateLayout(secondBatch, mockContainer, columnCount, gutterX, gutterY)
|
|
39
|
+
for (let col = 0; col < columnCount; col++) {
|
|
40
|
+
const colItems = stacked.filter((_, i) => i % columnCount === col)
|
|
41
|
+
for (let j = 1; j < colItems.length; j++) {
|
|
42
|
+
expect(colItems[j].top).toBe(
|
|
43
|
+
colItems[j - 1].top + colItems[j - 1].columnHeight + gutterY
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|