custom-isotope.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * CustomIsotope - Custom implementation of filtering and layout
  3. * Replaces Isotope.js functionality - FIXED VERSION
  4. */
  5. class CustomIsotope {
  6. constructor(container, options = {}) {
  7. this.container = typeof container === 'string' ? document.querySelector(container) : container;
  8. this.options = {
  9. itemSelector: '.portfolio-item',
  10. layoutMode: 'masonry',
  11. columnWidth: null,
  12. gutter: 20,
  13. transitionDuration: 600,
  14. hiddenStyle: { opacity: 0, transform: 'scale(0.8)' },
  15. visibleStyle: { opacity: 1, transform: 'scale(1)' },
  16. ...options
  17. };
  18. this.items = [];
  19. this.filteredItems = [];
  20. this.currentFilter = '*';
  21. this.isLayoutComplete = false;
  22. this.init();
  23. }
  24. // Initialize the isotope instance
  25. init() {
  26. if (!this.container) {
  27. console.error('❌ Container not found');
  28. return;
  29. }
  30. // Get all items
  31. this.items = Array.from(this.container.querySelectorAll(this.options.itemSelector));
  32. this.filteredItems = [...this.items];
  33. // Setup initial styles
  34. this.setupContainer();
  35. this.setupItems();
  36. // Execute initial layout
  37. setTimeout(() => {
  38. this.layout();
  39. }, 100);
  40. // Listen for window resize
  41. this.setupResizeListener();
  42. }
  43. // Setup container styles
  44. setupContainer() {
  45. const container = this.container;
  46. container.style.position = 'relative';
  47. container.style.transition = `height ${this.options.transitionDuration}ms ease`;
  48. }
  49. // Setup individual item styles
  50. setupItems() {
  51. this.items.forEach((item, index) => {
  52. item.style.position = 'absolute';
  53. item.style.transition = `all ${this.options.transitionDuration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
  54. item.style.left = '0';
  55. item.style.top = '0';
  56. // Apply default visible styles
  57. Object.assign(item.style, {
  58. opacity: this.options.visibleStyle.opacity,
  59. transform: this.options.visibleStyle.transform
  60. });
  61. });
  62. }
  63. // Setup window resize listener
  64. setupResizeListener() {
  65. let resizeTimeout;
  66. window.addEventListener('resize', () => {
  67. clearTimeout(resizeTimeout);
  68. resizeTimeout = setTimeout(() => {
  69. this.layout();
  70. }, 250);
  71. });
  72. }
  73. /**
  74. * Filter items by category
  75. */
  76. arrange(options = {}) {
  77. const filter = options.filter || '*';
  78. this.currentFilter = filter;
  79. // Filter items
  80. if (filter === '*') {
  81. this.filteredItems = [...this.items];
  82. } else {
  83. const filterClass = filter.replace('.', '');
  84. this.filteredItems = this.items.filter(item =>
  85. item.classList.contains(filterClass)
  86. );
  87. }
  88. // Animate hidden items out
  89. this.hideItems();
  90. // Layout after hide animation
  91. setTimeout(() => {
  92. this.layout();
  93. this.showItems();
  94. }, this.options.transitionDuration / 2);
  95. }
  96. // Hide items that don't match filter
  97. hideItems() {
  98. this.items.forEach(item => {
  99. if (!this.filteredItems.includes(item)) {
  100. Object.assign(item.style, {
  101. opacity: this.options.hiddenStyle.opacity,
  102. transform: this.options.hiddenStyle.transform,
  103. pointerEvents: 'none'
  104. });
  105. }
  106. });
  107. }
  108. // Show filtered items with staggered animation
  109. showItems() {
  110. this.filteredItems.forEach((item, index) => {
  111. setTimeout(() => {
  112. Object.assign(item.style, {
  113. opacity: this.options.visibleStyle.opacity,
  114. transform: this.options.visibleStyle.transform,
  115. pointerEvents: 'auto'
  116. });
  117. }, index * 50); // Staggered animation
  118. });
  119. }
  120. /**
  121. * Calculate and apply layout - FIXED VERSION
  122. */
  123. layout() {
  124. if (this.filteredItems.length === 0) {
  125. this.container.style.height = '0px';
  126. return;
  127. }
  128. const containerWidth = this.container.offsetWidth;
  129. // Calculate columns based on container width and item width
  130. const itemWidth = this.getItemWidth();
  131. const columns = Math.max(1, Math.floor((containerWidth + this.options.gutter) / (itemWidth + this.options.gutter)));
  132. const actualItemWidth = Math.floor((containerWidth - (columns - 1) * this.options.gutter) / columns);
  133. // Arrays to track each column height
  134. const columnHeights = new Array(columns).fill(0);
  135. // Process each filtered item
  136. this.filteredItems.forEach((item, index) => {
  137. // Set width first
  138. item.style.width = `${actualItemWidth}px`;
  139. // Force reflow to get accurate height
  140. item.offsetHeight;
  141. // Find shortest column
  142. const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
  143. // Calculate position
  144. const x = shortestColumnIndex * (actualItemWidth + this.options.gutter);
  145. const y = columnHeights[shortestColumnIndex];
  146. // Apply position
  147. item.style.left = `${x}px`;
  148. item.style.top = `${y}px`;
  149. // Get item height and update column height
  150. const itemHeight = item.offsetHeight;
  151. columnHeights[shortestColumnIndex] += itemHeight + this.options.gutter;
  152. });
  153. // Update container height
  154. const maxHeight = Math.max(...columnHeights) - this.options.gutter;
  155. this.container.style.height = `${maxHeight}px`;
  156. this.isLayoutComplete = true;
  157. }
  158. // Get item width for calculations
  159. getItemWidth() {
  160. if (this.options.columnWidth) {
  161. return this.options.columnWidth;
  162. }
  163. // Use first item width as reference, but make it responsive
  164. const containerWidth = this.container.offsetWidth;
  165. // Responsive breakpoints
  166. if (containerWidth <= 640) { // sm (mobile)
  167. return containerWidth; // 1 column
  168. } else if (containerWidth <= 768) { // md (tablet)
  169. return Math.floor((containerWidth - this.options.gutter) / 2); // 2 columns
  170. } else { // lg and xl (desktop) - ALWAYS 3 COLUMNS
  171. return Math.floor((containerWidth - 2 * this.options.gutter) / 3); // 3 columns
  172. }
  173. }
  174. /**
  175. * Reload items and layout
  176. */
  177. reloadItems() {
  178. this.items = Array.from(this.container.querySelectorAll(this.options.itemSelector));
  179. this.setupItems();
  180. this.arrange({ filter: this.currentFilter });
  181. }
  182. /**
  183. * Destroy instance
  184. */
  185. destroy() {
  186. this.items.forEach(item => {
  187. item.style.position = '';
  188. item.style.left = '';
  189. item.style.top = '';
  190. item.style.width = '';
  191. item.style.transition = '';
  192. item.style.opacity = '';
  193. item.style.transform = '';
  194. item.style.pointerEvents = '';
  195. });
  196. this.container.style.height = '';
  197. this.container.style.position = '';
  198. this.container.style.transition = '';
  199. }
  200. /**
  201. * Get currently filtered items
  202. */
  203. getFilteredItems() {
  204. return [...this.filteredItems];
  205. }
  206. /**
  207. * Get current filter
  208. */
  209. getCurrentFilter() {
  210. return this.currentFilter;
  211. }
  212. }
  213. /**
  214. * Setup filter buttons
  215. */
  216. function setupFilterButtons(isotope, filterButtons) {
  217. filterButtons.forEach((button, index) => {
  218. button.addEventListener('click', function(e) {
  219. e.preventDefault();
  220. const filterValue = this.getAttribute('data-filter');
  221. // Update active button state
  222. filterButtons.forEach(btn => btn.classList.remove('active'));
  223. this.classList.add('active');
  224. // Apply filter
  225. isotope.arrange({ filter: filterValue });
  226. // Refresh AOS after filtering
  227. setTimeout(() => {
  228. if (typeof AOS !== 'undefined') {
  229. AOS.refresh();
  230. }
  231. }, 700);
  232. });
  233. });
  234. }
  235. /**
  236. * Wait for images to load
  237. */
  238. function waitForImages(container) {
  239. return new Promise((resolve, reject) => {
  240. const images = container.querySelectorAll('img');
  241. if (images.length === 0) {
  242. resolve();
  243. return;
  244. }
  245. let loadedCount = 0;
  246. let hasError = false;
  247. const checkComplete = () => {
  248. loadedCount++;
  249. if (loadedCount === images.length) {
  250. if (hasError) {
  251. reject(new Error('Some images failed to load'));
  252. } else {
  253. resolve();
  254. }
  255. }
  256. };
  257. images.forEach(img => {
  258. if (img.complete) {
  259. checkComplete();
  260. } else {
  261. img.addEventListener('load', checkComplete);
  262. img.addEventListener('error', () => {
  263. hasError = true;
  264. checkComplete();
  265. });
  266. }
  267. });
  268. // Safety timeout
  269. setTimeout(() => {
  270. if (loadedCount < images.length) {
  271. resolve();
  272. }
  273. }, 5000);
  274. });
  275. }
  276. // Updated global utilities
  277. window.PortfolioUtils = {
  278. refresh: function() {
  279. if (window.customIsotope) {
  280. window.customIsotope.layout();
  281. }
  282. },
  283. filter: function(category) {
  284. if (window.customIsotope) {
  285. window.customIsotope.arrange({ filter: category });
  286. // Update active button
  287. const filterBtn = document.querySelector(`[data-filter="${category}"]`);
  288. if (filterBtn) {
  289. document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
  290. filterBtn.classList.add('active');
  291. }
  292. }
  293. },
  294. getCurrentFilter: function() {
  295. return window.customIsotope ? window.customIsotope.getCurrentFilter() : '*';
  296. },
  297. getFilteredItems: function() {
  298. return window.customIsotope ? window.customIsotope.getFilteredItems() : [];
  299. },
  300. reload: function() {
  301. if (window.customIsotope) {
  302. window.customIsotope.reloadItems();
  303. }
  304. },
  305. destroy: function() {
  306. if (window.customIsotope) {
  307. window.customIsotope.destroy();
  308. window.customIsotope = null;
  309. window.portfolioIsotope = null;
  310. }
  311. },
  312. reinitialize: function() {
  313. this.destroy();
  314. setTimeout(() => {
  315. initPortfolioIsotope();
  316. }, 100);
  317. }
  318. };