@squeditor/squeditor-framework 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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/package.json +36 -0
  4. package/php/functions.php +92 -0
  5. package/project-template/package.json +29 -0
  6. package/project-template/postcss.config.js +6 -0
  7. package/project-template/squeditor.config.js +81 -0
  8. package/project-template/src/404.php +21 -0
  9. package/project-template/src/assets/css/squeditor-icons.css +4719 -0
  10. package/project-template/src/assets/css/tailwind.css +3 -0
  11. package/project-template/src/assets/css/uikit-components.css +14586 -0
  12. package/project-template/src/assets/js/gsap-advanced.js +26 -0
  13. package/project-template/src/assets/js/gsap-init.js +672 -0
  14. package/project-template/src/assets/js/gsap-modules/cursor-preview.js +132 -0
  15. package/project-template/src/assets/js/gsap-modules/cursor.js +456 -0
  16. package/project-template/src/assets/js/gsap-modules/loop-panels.js +78 -0
  17. package/project-template/src/assets/js/gsap-modules/marquee.js +106 -0
  18. package/project-template/src/assets/js/gsap-modules/pinned-panels.js +105 -0
  19. package/project-template/src/assets/js/gsap-modules/scroll-to.js +54 -0
  20. package/project-template/src/assets/js/gsap-modules/swipe-slider.js +121 -0
  21. package/project-template/src/assets/js/gsap-modules/text-mask.js +93 -0
  22. package/project-template/src/assets/js/gsap-modules/tilt.js +70 -0
  23. package/project-template/src/assets/js/main.js +302 -0
  24. package/project-template/src/assets/js/uikit-components.js +18171 -0
  25. package/project-template/src/assets/scss/_base.scss +140 -0
  26. package/project-template/src/assets/scss/_components.scss +165 -0
  27. package/project-template/src/assets/scss/_config.scss +13 -0
  28. package/project-template/src/assets/scss/_functions.scss +81 -0
  29. package/project-template/src/assets/scss/_tokens.scss +229 -0
  30. package/project-template/src/assets/scss/_transitions.scss +36 -0
  31. package/project-template/src/assets/scss/_uikit-overrides.scss +187 -0
  32. package/project-template/src/assets/scss/_uikit_dynamic.scss +43 -0
  33. package/project-template/src/assets/scss/_utilities.scss +31 -0
  34. package/project-template/src/assets/scss/custom.scss +10 -0
  35. package/project-template/src/assets/scss/main.scss +11 -0
  36. package/project-template/src/assets/static/fonts/squeditor-icons/squeditor-icons.eot +0 -0
  37. package/project-template/src/assets/static/fonts/squeditor-icons/squeditor-icons.svg +1183 -0
  38. package/project-template/src/assets/static/fonts/squeditor-icons/squeditor-icons.ttf +0 -0
  39. package/project-template/src/assets/static/fonts/squeditor-icons/squeditor-icons.woff +0 -0
  40. package/project-template/src/config/site-config.php +34 -0
  41. package/project-template/src/data/blog.php +21 -0
  42. package/project-template/src/data/portfolio.php +23 -0
  43. package/project-template/src/data/team.php +23 -0
  44. package/project-template/src/index.php +57 -0
  45. package/project-template/src/init.php +19 -0
  46. package/project-template/src/page-templates/base.php +39 -0
  47. package/project-template/src/page-templates/body-scripts.php +26 -0
  48. package/project-template/src/page-templates/head.php +47 -0
  49. package/project-template/src/page-templates/transition.php +45 -0
  50. package/project-template/src/sections/cards/cards-grid.php +34 -0
  51. package/project-template/src/sections/cards/cards-horizontal.php +28 -0
  52. package/project-template/src/sections/cta/cta-banner.php +34 -0
  53. package/project-template/src/sections/cta/cta-newsletter.php +19 -0
  54. package/project-template/src/sections/footer/layout-01.php +35 -0
  55. package/project-template/src/sections/header/layout-01.php +36 -0
  56. package/project-template/src/sections/hero/hero-centered.php +44 -0
  57. package/project-template/src/sections/hero/hero-split.php +132 -0
  58. package/project-template/src/sections/hero/hero-video.php +22 -0
  59. package/project-template/src/sections/sidebar/sidebar-right.php +11 -0
  60. package/project-template/src/template-parts/breadcrumbs.php +17 -0
  61. package/project-template/src/template-parts/footer.php +74 -0
  62. package/project-template/src/template-parts/header.php +120 -0
  63. package/project-template/src/template-parts/mega-menu.php +7 -0
  64. package/project-template/src/template-parts/nav.php +16 -0
  65. package/project-template/src/template-parts/page-title-bar.php +14 -0
  66. package/project-template/tailwind.config.js +26 -0
  67. package/project-template/vite.config.js +67 -0
  68. package/scripts/build-components.js +109 -0
  69. package/scripts/copy-static.js +150 -0
  70. package/scripts/dev-router.php +23 -0
  71. package/scripts/dev.js +55 -0
  72. package/scripts/get-port.js +27 -0
  73. package/scripts/package-customer.js +278 -0
  74. package/scripts/package-dist.js +54 -0
  75. package/scripts/scaffold.js +72 -0
  76. package/scripts/snapshot.js +74 -0
  77. package/uikit-manifest.json +248 -0
@@ -0,0 +1,106 @@
1
+ // src/assets/js/gsap-modules/marquee.js
2
+ import { gsap } from 'gsap';
3
+ import { ScrollTrigger } from 'gsap/ScrollTrigger';
4
+
5
+ gsap.registerPlugin(ScrollTrigger);
6
+
7
+ export function init() {
8
+ const marquees = document.querySelectorAll('[data-sq-marquee]');
9
+ if (!marquees.length) return;
10
+
11
+ marquees.forEach(wrapper => {
12
+ const attr = wrapper.getAttribute('data-sq-marquee') || '';
13
+ const config = parseModuleAttr(attr);
14
+ const speed = config.speed ?? 50; // px per second
15
+ const gap = config.gap ?? 40;
16
+ const direction = config.direction === 'right' ? 1 : -1;
17
+
18
+ // Clone items to fill width for seamless loop
19
+ const items = Array.from(wrapper.children);
20
+ const cloneGroup = document.createElement('div');
21
+ cloneGroup.className = 'sq-marquee__inner';
22
+ const origGroup = document.createElement('div');
23
+ origGroup.className = 'sq-marquee__inner';
24
+
25
+ items.forEach(item => {
26
+ origGroup.appendChild(item.cloneNode(true));
27
+ cloneGroup.appendChild(item.cloneNode(true));
28
+ });
29
+
30
+ wrapper.innerHTML = '';
31
+ wrapper.style.overflow = 'hidden';
32
+ wrapper.style.display = 'flex';
33
+
34
+ injectStyles(`
35
+ .sq-marquee__inner {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: ${gap}px;
39
+ white-space: nowrap;
40
+ flex-shrink: 0;
41
+ padding-right: ${gap}px;
42
+ }
43
+ `);
44
+
45
+ wrapper.appendChild(origGroup);
46
+ wrapper.appendChild(cloneGroup);
47
+
48
+ // Calculate total width for one group
49
+ const totalWidth = origGroup.scrollWidth;
50
+
51
+ let currentSpeed = speed * direction * -1;
52
+
53
+ // Infinite scroll animation
54
+ const tween = gsap.to([origGroup, cloneGroup], {
55
+ x: `+=${totalWidth * (direction < 0 ? -1 : 1)}`,
56
+ modifiers: {
57
+ x: gsap.utils.unitize(x => parseFloat(x) % totalWidth),
58
+ },
59
+ duration: totalWidth / speed,
60
+ ease: 'none',
61
+ repeat: -1,
62
+ });
63
+
64
+ // Reverse / slow on scroll direction change
65
+ ScrollTrigger.create({
66
+ onUpdate: (self) => {
67
+ const velocity = self.getVelocity();
68
+ if (Math.abs(velocity) > 10) {
69
+ const factor = velocity < 0 ? -1 : 1;
70
+ gsap.to(tween, {
71
+ timeScale: factor * Math.min(Math.abs(velocity) / 500, 3),
72
+ duration: 0.5,
73
+ ease: 'power2.out',
74
+ overwrite: true,
75
+ });
76
+ } else {
77
+ gsap.to(tween, {
78
+ timeScale: direction < 0 ? -1 : 1,
79
+ duration: 0.8,
80
+ ease: 'power2.out',
81
+ overwrite: true,
82
+ });
83
+ }
84
+ },
85
+ });
86
+ });
87
+ }
88
+
89
+ function parseModuleAttr(str) {
90
+ const config = {};
91
+ if (!str) return config;
92
+ str.split(';').forEach(part => {
93
+ const idx = part.indexOf(':');
94
+ if (idx === -1) return;
95
+ const key = part.slice(0, idx).trim();
96
+ const val = part.slice(idx + 1).trim().replace(/['"]/g, '');
97
+ config[key] = isNaN(val) ? val : parseFloat(val);
98
+ });
99
+ return config;
100
+ }
101
+
102
+ function injectStyles(css) {
103
+ const style = document.createElement('style');
104
+ style.textContent = css;
105
+ document.head.appendChild(style);
106
+ }
@@ -0,0 +1,105 @@
1
+ // src/assets/js/gsap-modules/pinned-panels.js
2
+ import { gsap } from 'gsap';
3
+ import { ScrollTrigger } from 'gsap/ScrollTrigger';
4
+
5
+ gsap.registerPlugin(ScrollTrigger);
6
+
7
+ export function init() {
8
+ const wrappers = document.querySelectorAll('[data-sq-panels]');
9
+ if (!wrappers.length) return;
10
+
11
+ wrappers.forEach(wrapper => {
12
+ const attr = wrapper.getAttribute('data-sq-panels') || '';
13
+ const config = parseModuleAttr(attr);
14
+ const rounded = config.rounded ?? true;
15
+ const panels = Array.from(wrapper.children);
16
+
17
+ injectStyles(`
18
+ [data-sq-panels] {
19
+ position: relative;
20
+ z-index: 1;
21
+ }
22
+ .sq-panel {
23
+ width: 100%;
24
+ height: 100vh;
25
+ overflow: hidden;
26
+ position: relative;
27
+ box-sizing: border-box;
28
+ ${rounded ? 'border-radius: 0 0 24px 24px;' : ''}
29
+ will-change: transform;
30
+ }
31
+ .sq-panel-inner {
32
+ width: 100%;
33
+ height: auto;
34
+ min-height: 100vh;
35
+ }
36
+ `);
37
+
38
+ // All panels except the last one pin and scale away
39
+ const pinningPanels = panels.slice(0, -1);
40
+
41
+ pinningPanels.forEach((panel) => {
42
+ let innerpanel = panel.querySelector(".sq-panel-inner") || panel;
43
+
44
+ // Calculate how much inner content exceeds the viewport height
45
+ let panelHeight = innerpanel.offsetHeight;
46
+ let windowHeight = window.innerHeight;
47
+ let difference = panelHeight - windowHeight;
48
+
49
+ // Calculate the ratio of the scroll distance that applies to fake-scrolling
50
+ let fakeScrollRatio = difference > 0 ? (difference / (difference + windowHeight)) : 0;
51
+
52
+ // Add margin to push the start of the next panel down, giving us scroll distance
53
+ if (fakeScrollRatio) {
54
+ panel.style.marginBottom = panelHeight * fakeScrollRatio + "px";
55
+ }
56
+
57
+ let tl = gsap.timeline({
58
+ scrollTrigger: {
59
+ trigger: panel,
60
+ start: "top top",
61
+ end: () => fakeScrollRatio ? `+=${innerpanel.offsetHeight}` : "bottom top",
62
+ pinSpacing: false,
63
+ pin: true,
64
+ scrub: true
65
+ }
66
+ });
67
+
68
+ // If there's overflow, translate the inner content up to simulate scrolling
69
+ if (fakeScrollRatio) {
70
+ tl.to(innerpanel, {
71
+ yPercent: -100,
72
+ y: windowHeight,
73
+ duration: 1 / (1 - fakeScrollRatio) - 1,
74
+ ease: "none"
75
+ });
76
+ }
77
+
78
+ // Standard scale and fade away transition for the panel itself
79
+ tl.fromTo(panel,
80
+ { scale: 1, opacity: 1 },
81
+ { scale: 0.85, opacity: 0.5, duration: 0.9, ease: "none" }
82
+ )
83
+ .to(panel, { opacity: 0, duration: 0.1, ease: "none" });
84
+ });
85
+ });
86
+ }
87
+
88
+ function parseModuleAttr(str) {
89
+ const config = {};
90
+ if (!str) return config;
91
+ str.split(';').forEach(part => {
92
+ const idx = part.indexOf(':');
93
+ if (idx === -1) return;
94
+ const key = part.slice(0, idx).trim();
95
+ const val = part.slice(idx + 1).trim().replace(/['"]/g, '');
96
+ config[key] = isNaN(val) ? (val === 'true' ? true : (val === 'false' ? false : val)) : parseFloat(val);
97
+ });
98
+ return config;
99
+ }
100
+
101
+ function injectStyles(css) {
102
+ const style = document.createElement('style');
103
+ style.textContent = css;
104
+ document.head.appendChild(style);
105
+ }
@@ -0,0 +1,54 @@
1
+ // src/assets/js/gsap-modules/scroll-to.js
2
+ import { gsap } from 'gsap';
3
+ import { ScrollToPlugin } from 'gsap/ScrollToPlugin';
4
+
5
+ gsap.registerPlugin(ScrollToPlugin);
6
+
7
+ export function init() {
8
+ // Select all elements that have the [data-sq-scrollto] attribute
9
+ const triggers = document.querySelectorAll('[data-sq-scrollto]');
10
+
11
+ triggers.forEach(trigger => {
12
+ // Read configuration from the attribute, fallback to empty string
13
+ const configStr = trigger.getAttribute('data-sq-scrollto') || '';
14
+
15
+ // Parse simple config (e.g., target: "#section2", duration: 1, offsetY: 120)
16
+ // If the attribute contains just a string, we assume it's the target selector.
17
+ let config = {};
18
+
19
+ if (configStr.startsWith('{')) {
20
+ try {
21
+ // To allow unquoted keys like {target: "#id", offsetY: 50} we use a small regex replacer or Function constructor
22
+ // A safe enough approach for typical declarative attributes:
23
+ const sanitizedStr = configStr
24
+ .replace(/([{,]\s*)([a-zA-Z0-9_]+)\s*:/g, '$1"$2":') // Quote keys
25
+ .replace(/:\s*'([^']*)'/g, ':"$1"'); // Convert single quotes to double quotes for values
26
+
27
+ config = JSON.parse(sanitizedStr);
28
+ } catch (e) {
29
+ console.error('GSAP ScrollTo: Invalid JSON config in attribute', configStr, e);
30
+ }
31
+ } else if (configStr) {
32
+ config.target = configStr;
33
+ }
34
+
35
+ // If target exists in config OR the trigger is an anchor tag, find the destination
36
+ const destination = config.target || trigger.getAttribute('href');
37
+
38
+ if (!destination || destination === '#') return;
39
+
40
+ trigger.addEventListener('click', (e) => {
41
+ e.preventDefault();
42
+
43
+ gsap.to(window, {
44
+ duration: config.duration || 1,
45
+ scrollTo: {
46
+ y: destination,
47
+ offsetY: config.offsetY !== undefined ? config.offsetY : 0,
48
+ autoKill: true
49
+ },
50
+ ease: config.ease || "power2.out"
51
+ });
52
+ });
53
+ });
54
+ }
@@ -0,0 +1,121 @@
1
+ // src/assets/js/gsap-modules/swipe-slider.js
2
+ import { gsap } from 'gsap';
3
+ import { Observer } from 'gsap/observer';
4
+ import { SplitText } from 'gsap/SplitText';
5
+
6
+ gsap.registerPlugin(Observer, SplitText);
7
+
8
+ export function init() {
9
+ const sliders = document.querySelectorAll('[data-sq-swipe]');
10
+ if (!sliders.length) return;
11
+
12
+ sliders.forEach(wrapper => {
13
+ const attr = wrapper.getAttribute('data-sq-swipe') || '';
14
+ const config = parseModuleAttr(attr);
15
+ const duration = config.duration ?? 1.25;
16
+ const ease = config.ease ?? 'power1.inOut';
17
+
18
+ injectStyles(`
19
+ .sq-swipe-section { visibility: hidden; }
20
+ .sq-swipe-bg .clip-text { overflow: hidden; }
21
+ `);
22
+
23
+ const sections = Array.from(wrapper.querySelectorAll('.sq-swipe-section'));
24
+ const outerWrappers = Array.from(wrapper.querySelectorAll('.sq-swipe-outer'));
25
+ const innerWrappers = Array.from(wrapper.querySelectorAll('.sq-swipe-inner'));
26
+ const images = Array.from(wrapper.querySelectorAll('.sq-swipe-bg'));
27
+ const headings = Array.from(wrapper.querySelectorAll('.section-heading'));
28
+
29
+ let currentIndex = -1;
30
+ let animating = false;
31
+ const wrap = gsap.utils.wrap(0, sections.length);
32
+
33
+ // Prepare text splits if heading class is present
34
+ let splitHeadings = headings.map(heading => {
35
+ return new SplitText(heading, { type: "chars,words,lines", linesClass: "clip-text" });
36
+ });
37
+
38
+ // Reset layout for outer/inner
39
+ gsap.set(outerWrappers, { yPercent: 100 });
40
+ gsap.set(innerWrappers, { yPercent: -100 });
41
+
42
+ function goTo(index, direction) {
43
+ if (animating) return;
44
+ index = wrap(index);
45
+ animating = true;
46
+ let fromTop = direction === -1,
47
+ dFactor = fromTop ? -1 : 1,
48
+ tl = gsap.timeline({
49
+ defaults: { duration: duration, ease: ease },
50
+ onComplete: () => animating = false
51
+ });
52
+
53
+ if (currentIndex >= 0) {
54
+ gsap.set(sections[currentIndex], { zIndex: 0 });
55
+ tl.to(images[currentIndex], { yPercent: -15 * dFactor })
56
+ .set(sections[currentIndex], { autoAlpha: 0 });
57
+ }
58
+
59
+ gsap.set(sections[index], { autoAlpha: 1, zIndex: 1 });
60
+
61
+ tl.fromTo([outerWrappers[index], innerWrappers[index]], {
62
+ yPercent: i => i ? -100 * dFactor : 100 * dFactor
63
+ }, {
64
+ yPercent: 0
65
+ }, 0)
66
+ .fromTo(images[index], { yPercent: 15 * dFactor }, { yPercent: 0 }, 0);
67
+
68
+ if (splitHeadings[index]) {
69
+ tl.fromTo(splitHeadings[index].chars, {
70
+ autoAlpha: 0,
71
+ yPercent: 150 * dFactor
72
+ }, {
73
+ autoAlpha: 1,
74
+ yPercent: 0,
75
+ duration: 1,
76
+ ease: "power2",
77
+ stagger: {
78
+ each: 0.02,
79
+ from: "random"
80
+ }
81
+ }, 0.2);
82
+ }
83
+
84
+ currentIndex = index;
85
+ }
86
+
87
+ Observer.create({
88
+ target: wrapper,
89
+ type: "wheel,touch,pointer",
90
+ wheelSpeed: -1,
91
+ tolerance: 10,
92
+ preventDefault: true,
93
+ onDown: () => !animating && goTo(currentIndex - 1, -1),
94
+ onUp: () => !animating && goTo(currentIndex + 1, 1)
95
+ });
96
+
97
+ goTo(0, 1);
98
+ });
99
+ }
100
+
101
+ function parseModuleAttr(str) {
102
+ const config = {};
103
+ if (!str) return config;
104
+ str.split(';').forEach(part => {
105
+ const idx = part.indexOf(':');
106
+ if (idx === -1) return;
107
+ const key = part.slice(0, idx).trim();
108
+ const val = part.slice(idx + 1).trim().replace(/['"]/g, '');
109
+ if (val === 'true') config[key] = true;
110
+ else if (val === 'false') config[key] = false;
111
+ else if (!isNaN(val)) config[key] = parseFloat(val);
112
+ else config[key] = val;
113
+ });
114
+ return config;
115
+ }
116
+
117
+ function injectStyles(css) {
118
+ const style = document.createElement('style');
119
+ style.textContent = css;
120
+ document.head.appendChild(style);
121
+ }
@@ -0,0 +1,93 @@
1
+ // src/assets/js/gsap-modules/text-mask.js
2
+ import { gsap } from 'gsap';
3
+ import { SplitText } from 'gsap/SplitText';
4
+ import { ScrollTrigger } from 'gsap/ScrollTrigger';
5
+
6
+ gsap.registerPlugin(SplitText, ScrollTrigger);
7
+
8
+ export function init() {
9
+ const elements = document.querySelectorAll('[data-sq-mask]');
10
+ if (!elements.length) return;
11
+
12
+ injectStyles(`
13
+ .sq-mask-wrapper {
14
+ overflow: hidden;
15
+ display: inline-block; /* Support nested or inline layouts */
16
+ vertical-align: top;
17
+ width: 100%;
18
+ }
19
+ `);
20
+
21
+ elements.forEach(el => {
22
+ const attr = el.getAttribute('data-sq-mask') || '';
23
+ const config = parseModuleAttr(attr);
24
+ const type = config.type ?? 'lines';
25
+ const duration = config.duration ?? 1.2;
26
+ const stagger = config.stagger ?? 0.15;
27
+ const ease = config.ease ?? 'expo.out';
28
+ const useScroll = config.scroll ?? true;
29
+
30
+ // Ensure opacity is visible before we split (if it was hidden initially via CSS)
31
+ gsap.set(el, { opacity: 1 });
32
+
33
+ // In order for masking to work, we need an outer container with overflow:hidden.
34
+ // If we want to mask lines, we split by lines, then wrap each line in a div.
35
+ const splitFormat = type === 'words' ? 'words' : 'lines';
36
+
37
+ const split = new SplitText(el, {
38
+ type: `${splitFormat},words,chars`, // Force deep split to prevent reflow bugs
39
+ linesClass: splitFormat === 'lines' ? 'sq-split-target' : '',
40
+ wordsClass: splitFormat === 'words' ? 'sq-split-target' : '',
41
+ });
42
+
43
+ const targets = el.querySelectorAll('.sq-split-target');
44
+
45
+ // Wrap each target in an overflow:hidden mask
46
+ targets.forEach(target => {
47
+ const wrapper = document.createElement('div');
48
+ wrapper.className = 'sq-mask-wrapper';
49
+ target.parentNode.insertBefore(wrapper, target);
50
+ wrapper.appendChild(target);
51
+ });
52
+
53
+ const vars = {
54
+ yPercent: 100,
55
+ opacity: 0,
56
+ duration,
57
+ stagger,
58
+ ease,
59
+ };
60
+
61
+ if (useScroll) {
62
+ vars.scrollTrigger = {
63
+ trigger: el,
64
+ start: config.start ?? 'top 85%',
65
+ toggleActions: 'play none none none',
66
+ };
67
+ }
68
+
69
+ gsap.from(targets, vars);
70
+ });
71
+ }
72
+
73
+ function parseModuleAttr(str) {
74
+ const config = {};
75
+ if (!str) return config;
76
+ str.split(';').forEach(part => {
77
+ const idx = part.indexOf(':');
78
+ if (idx === -1) return;
79
+ const key = part.slice(0, idx).trim();
80
+ const val = part.slice(idx + 1).trim().replace(/['"]/g, '');
81
+ if (val === 'true') config[key] = true;
82
+ else if (val === 'false') config[key] = false;
83
+ else if (!isNaN(val)) config[key] = parseFloat(val);
84
+ else config[key] = val;
85
+ });
86
+ return config;
87
+ }
88
+
89
+ function injectStyles(css) {
90
+ const style = document.createElement('style');
91
+ style.textContent = css;
92
+ document.head.appendChild(style);
93
+ }
@@ -0,0 +1,70 @@
1
+ // src/assets/js/gsap-modules/tilt.js
2
+ import { gsap } from 'gsap';
3
+
4
+ export function init() {
5
+ const elements = document.querySelectorAll('[data-sq-tilt]');
6
+ if (!elements.length) return;
7
+
8
+ elements.forEach(el => {
9
+ const attr = el.getAttribute('data-sq-tilt') || '';
10
+ const config = parseModuleAttr(attr);
11
+
12
+ const max = config.max ?? 15;
13
+ const scale = config.scale ?? 1.05;
14
+ const speed = (config.speed ?? 400) / 1000; // convert to seconds for quickTo
15
+
16
+ // Wrap content for inner tilt layer
17
+ el.style.transformStyle = 'preserve-3d';
18
+ el.style.perspective = '1000px';
19
+
20
+ // Setup quickTo mutators for high performance
21
+ const rotateX = gsap.quickTo(el, "rotationX", { ease: "power3", duration: speed });
22
+ const rotateY = gsap.quickTo(el, "rotationY", { ease: "power3", duration: speed });
23
+ const scaleTo = gsap.quickTo(el, "scale", { ease: "power3", duration: speed });
24
+
25
+ // Inner parallax tracking
26
+ const innerLayers = el.querySelectorAll('[data-sq-tilt-inner]');
27
+ const innerX = innerLayers.length ? gsap.quickTo(innerLayers, "x", { ease: "power3", duration: speed }) : null;
28
+ const innerY = innerLayers.length ? gsap.quickTo(innerLayers, "y", { ease: "power3", duration: speed }) : null;
29
+
30
+ el.addEventListener('pointermove', (e) => {
31
+ const rect = el.getBoundingClientRect();
32
+ const centerX = rect.left + rect.width / 2;
33
+ const centerY = rect.top + rect.height / 2;
34
+
35
+ // Calculate interpolation relative to center
36
+ const pctX = (e.clientX - centerX) / (rect.width / 2);
37
+ const pctY = (e.clientY - centerY) / (rect.height / 2);
38
+
39
+ // Map limits
40
+ rotateX(pctY * -max);
41
+ rotateY(pctX * max);
42
+ scaleTo(scale);
43
+
44
+ // Inner elements move double the max in opposite parallax relative to rotation
45
+ if (innerX) innerX(pctX * -(max * 2));
46
+ if (innerY) innerY(pctY * -(max * 2));
47
+ });
48
+
49
+ el.addEventListener('pointerleave', () => {
50
+ rotateX(0);
51
+ rotateY(0);
52
+ scaleTo(1);
53
+ if (innerX) innerX(0);
54
+ if (innerY) innerY(0);
55
+ });
56
+ });
57
+ }
58
+
59
+ function parseModuleAttr(str) {
60
+ const config = {};
61
+ if (!str) return config;
62
+ str.split(';').forEach(part => {
63
+ const idx = part.indexOf(':');
64
+ if (idx === -1) return;
65
+ const key = part.slice(0, idx).trim();
66
+ const val = part.slice(idx + 1).trim().replace(/['"]/g, '');
67
+ config[key] = isNaN(val) ? val : parseFloat(val);
68
+ });
69
+ return config;
70
+ }