Skip to content

Commit a8dd7ad

Browse files
committed
Grid: Create Main Thread Addon for Column Pinning (#9458)
1 parent b7bf06e commit a8dd7ad

5 files changed

Lines changed: 184 additions & 10 deletions

File tree

src/grid/Container.mjs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ class GridContainer extends BaseContainer {
212212
*/
213213
scrollManager = null
214214

215+
/**
216+
* @member {Boolean} hasLockedColumns=false
217+
* @readonly
218+
*/
219+
get hasLockedColumns() {
220+
return this.columns?.items.some(col => col.locked === 'start' || col.locked === 'end') || false
221+
}
222+
215223
/**
216224
* @param {Object} config
217225
*/
@@ -718,7 +726,9 @@ class GridContainer extends BaseContainer {
718726
headerToolbar.passSizeToBody(false);
719727

720728
// 4. Force a full row re-render to apply the new column order and styles
721-
me.body.createViewData()
729+
me.body.createViewData();
730+
731+
me.scrollManager?.updateColumnScrollPinningAddon()
722732
}
723733

724734
/**
@@ -733,9 +743,9 @@ class GridContainer extends BaseContainer {
733743
for (let i = 0, len = columns.length; i < len; i++) {
734744
let column = columns[i];
735745

736-
if (column.locked === 'start' || column.locked === 'left') {
746+
if (column.locked === 'start') {
737747
lockedStart.push(column)
738-
} else if (column.locked === 'end' || column.locked === 'right') {
748+
} else if (column.locked === 'end') {
739749
lockedEnd.push(column)
740750
} else {
741751
unlocked.push(column)

src/grid/ScrollManager.mjs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ class ScrollManager extends Base {
9494
afterSetMounted(value, oldValue) {
9595
if (value) {
9696
this.dragScroll && this.updateDragScrollAddon(true);
97-
this.rowScrollPinning && this.updateRowScrollPinningAddon(true)
97+
this.rowScrollPinning && this.updateRowScrollPinningAddon(true);
98+
this.updateColumnScrollPinningAddon()
9899
} else if (oldValue) {
99100
this.updateDragScrollAddon(false);
100-
this.updateRowScrollPinningAddon(false)
101+
this.updateRowScrollPinningAddon(false);
102+
this.updateColumnScrollPinningAddon(false)
101103
}
102104
}
103105

@@ -121,9 +123,11 @@ class ScrollManager extends Base {
121123
if (oldValue && me.mounted) {
122124
me.dragScroll && me.updateDragScrollAddon(false, oldValue);
123125
me.rowScrollPinning && me.updateRowScrollPinningAddon(false, oldValue);
126+
me.updateColumnScrollPinningAddon(false, oldValue);
124127

125128
me.dragScroll && me.updateDragScrollAddon(true, value);
126-
me.rowScrollPinning && me.updateRowScrollPinningAddon(true, value)
129+
me.rowScrollPinning && me.updateRowScrollPinningAddon(true, value);
130+
me.updateColumnScrollPinningAddon(value)
127131
}
128132
}
129133

@@ -132,6 +136,7 @@ class ScrollManager extends Base {
132136
*/
133137
destroy(...args) {
134138
this.updateRowScrollPinningAddon(false);
139+
this.updateColumnScrollPinningAddon(false);
135140
super.destroy(...args)
136141
}
137142

@@ -198,6 +203,29 @@ class ScrollManager extends Base {
198203
me.gridContainer.headerToolbar.scrollLeft = me.scrollLeft
199204
}
200205

206+
/**
207+
* @param {Boolean} [active]
208+
* @param {String|null} [windowId=this.windowId]
209+
* @returns {Promise<void>}
210+
*/
211+
async updateColumnScrollPinningAddon(active, windowId=this.windowId) {
212+
let me = this;
213+
214+
active = active ?? (me.mounted && me.gridContainer?.hasLockedColumns);
215+
216+
let addon = await Neo.currentWorker.getAddon('GridColumnScrollPinning', windowId);
217+
218+
if (active) {
219+
addon.register({
220+
containerId: me.gridContainer.id,
221+
id : me.id,
222+
windowId
223+
})
224+
} else {
225+
addon.unregister({id: me.id, windowId})
226+
}
227+
}
228+
201229
/**
202230
* @param {Boolean} active
203231
* @param {String|null} [windowId=this.windowId]

src/grid/column/Base.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ class Column extends Base {
4545
*/
4646
hideMode_: 'removeDom',
4747
/**
48-
* Use 'start' or 'left' to pin the column to the start of the row.
49-
* Use 'end' or 'right' to pin the column to the end of the row.
48+
* Use 'start' to pin the column to the start of the row.
49+
* Use 'end' to pin the column to the end of the row.
5050
* Use null for standard, scrollable columns.
5151
* @member {String|null} locked_=null
5252
* @reactive

src/grid/header/Button.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@ class Button extends BaseButton {
158158
NeoArray.remove(cls, 'neo-locked-start');
159159
NeoArray.remove(cls, 'neo-locked-end');
160160

161-
if (value === 'start' || value === 'left') {
161+
if (value === 'start') {
162162
NeoArray.add(cls, 'neo-locked-start')
163-
} else if (value === 'end' || value === 'right') {
163+
} else if (value === 'end') {
164164
NeoArray.add(cls, 'neo-locked-end')
165165
}
166166

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import Base from './Base.mjs';
2+
import DomAccess from '../DomAccess.mjs';
3+
4+
/**
5+
* @summary Main Thread Addon for High-Performance Grid Column Scroll Pinning (Synchronous Engine).
6+
*
7+
* This addon uses a Synchronous Scroll Listener architecture to prevent visual
8+
* column tearing during rapid horizontal scrolling.
9+
*
10+
* **The Synchronous Architecture:**
11+
* 1. **Scroll Driven:** A native `scroll` listener on the Grid Container triggers the logic.
12+
* 2. **Synchronous Injection:** The scroll event synchronously calculates `scrollLeft` and
13+
* immediately applies CSS custom properties (`--grid-locked-start-offset`, `--grid-locked-end-offset`)
14+
* to the root container node. This ensures the optical pinning is injected perfectly in phase
15+
* with the native scroll event, before the browser paints the next frame.
16+
*
17+
* @class Neo.main.addon.GridColumnScrollPinning
18+
* @extends Neo.main.addon.Base
19+
*/
20+
class GridColumnScrollPinning extends Base {
21+
static config = {
22+
/**
23+
* @member {String} className='Neo.main.addon.GridColumnScrollPinning'
24+
* @protected
25+
*/
26+
className: 'Neo.main.addon.GridColumnScrollPinning',
27+
/**
28+
* Remote method access for other workers
29+
* @member {Object} remote
30+
* @protected
31+
*/
32+
remote: {
33+
app: [
34+
'register',
35+
'unregister'
36+
]
37+
}
38+
}
39+
40+
/**
41+
* Stores state per registered Grid Container.
42+
* Shape: { id, containerId, containerNode }
43+
* @member {Map<String, Object>} registrations=new Map()
44+
* @protected
45+
*/
46+
registrations = new Map()
47+
48+
/**
49+
* We bind the scroll handler once to avoid creating new functions per registration.
50+
* @member {Function} boundOnScroll
51+
* @protected
52+
*/
53+
boundOnScroll = this.onScroll.bind(this)
54+
55+
/**
56+
* Re-calculates and applies pinning offsets based on the actual horizontal scroll position.
57+
*
58+
* @param {Object} state Registration state object
59+
* @protected
60+
*/
61+
applyPinning(state) {
62+
let node = state.containerNode,
63+
{clientWidth, scrollLeft, scrollWidth} = node;
64+
65+
// Apply CSS variables to the root container node for inheritance
66+
node.style.setProperty('--grid-locked-start-offset', `${scrollLeft}px`);
67+
node.style.setProperty('--grid-locked-end-offset', `${scrollLeft - (scrollWidth - clientWidth)}px`)
68+
}
69+
70+
/**
71+
* Triggered by native browser scroll. Synchronous execution.
72+
* @param {Event} event
73+
* @protected
74+
*/
75+
onScroll(event) {
76+
let me = this,
77+
containerNode = event.target,
78+
state;
79+
80+
// Find which registration this scroll event belongs to based on the container node
81+
for (const reg of me.registrations.values()) {
82+
if (reg.containerNode === containerNode) {
83+
state = reg;
84+
break
85+
}
86+
}
87+
88+
if (state) {
89+
me.applyPinning(state)
90+
}
91+
}
92+
93+
/**
94+
* Registers a grid for column scroll pinning and attaches native scroll listener.
95+
* @param {Object} data
96+
* @param {String} data.containerId The ID of the grid container
97+
* @param {String} data.id Unique identifier for the registration (e.g. ScrollManager id)
98+
*/
99+
register({containerId, id}) {
100+
let me = this,
101+
containerNode = DomAccess.getElement(containerId);
102+
103+
if (containerNode) {
104+
me.registrations.set(id, {
105+
id,
106+
containerId,
107+
containerNode
108+
});
109+
110+
containerNode.addEventListener('scroll', me.boundOnScroll, {passive: true});
111+
112+
// Apply initial state
113+
me.applyPinning(me.registrations.get(id))
114+
}
115+
}
116+
117+
/**
118+
* Unregisters a grid and cleans up listeners.
119+
* @param {Object} data
120+
* @param {String} data.id
121+
*/
122+
unregister({id}) {
123+
let me = this,
124+
state = me.registrations.get(id);
125+
126+
if (state) {
127+
if (state.containerNode) {
128+
state.containerNode.removeEventListener('scroll', me.boundOnScroll)
129+
}
130+
131+
me.registrations.delete(id)
132+
}
133+
}
134+
}
135+
136+
export default Neo.setupClass(GridColumnScrollPinning);

0 commit comments

Comments
 (0)