@react-x11/components 0.7.0 → 0.8.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 (46) hide show
  1. package/README.md +214 -31
  2. package/dist/color-picker/ColorPicker.d.ts.map +1 -1
  3. package/dist/color-picker/ColorPicker.js +43 -33
  4. package/dist/color-picker/ColorPicker.js.map +1 -1
  5. package/dist/html/dom.d.ts +8 -1
  6. package/dist/html/dom.d.ts.map +1 -1
  7. package/dist/html/dom.js +17 -2
  8. package/dist/html/dom.js.map +1 -1
  9. package/dist/html/index.d.ts +4 -0
  10. package/dist/html/index.d.ts.map +1 -1
  11. package/dist/html/index.js.map +1 -1
  12. package/dist/internal/scroll.d.ts.map +1 -1
  13. package/dist/internal/scroll.js +32 -15
  14. package/dist/internal/scroll.js.map +1 -1
  15. package/dist/internal/units.d.ts +2 -0
  16. package/dist/internal/units.d.ts.map +1 -0
  17. package/dist/internal/units.js +35 -0
  18. package/dist/internal/units.js.map +1 -0
  19. package/dist/internal/window.d.ts.map +1 -1
  20. package/dist/internal/window.js +4 -2
  21. package/dist/internal/window.js.map +1 -1
  22. package/dist/table/index.d.ts.map +1 -1
  23. package/dist/table/index.js +14 -5
  24. package/dist/table/index.js.map +1 -1
  25. package/dist/terminal/vt/node.d.ts +56 -3
  26. package/dist/terminal/vt/node.d.ts.map +1 -1
  27. package/dist/terminal/vt/node.js +106 -26
  28. package/dist/terminal/vt/node.js.map +1 -1
  29. package/dist/terminal/vt/renderer.d.ts +4 -0
  30. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  31. package/dist/terminal/vt/renderer.js +14 -5
  32. package/dist/terminal/vt/renderer.js.map +1 -1
  33. package/dist/tree/index.d.ts.map +1 -1
  34. package/dist/tree/index.js +6 -4
  35. package/dist/tree/index.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/color-picker/ColorPicker.ts +107 -81
  38. package/src/html/dom.ts +17 -2
  39. package/src/html/index.ts +4 -0
  40. package/src/internal/scroll.ts +34 -15
  41. package/src/internal/units.ts +34 -0
  42. package/src/internal/window.ts +4 -2
  43. package/src/table/index.ts +14 -5
  44. package/src/terminal/vt/node.ts +128 -29
  45. package/src/terminal/vt/renderer.ts +27 -5
  46. package/src/tree/index.ts +6 -4
@@ -491,7 +491,16 @@ export function ColorPicker(props: ColorPickerProps): ReactElement {
491
491
  node: DrawnNode | null,
492
492
  ev: MouseEvent,
493
493
  ): { x: number; y: number } | null => {
494
- const box = node?.abs;
494
+ // `getClientRects()`, not `abs`. `abs` is **device** pixels and an
495
+ // event's `x`/`y` are **logical** — react-x11's docs/scale.md — so
496
+ // subtracting one from the other is only right at scale 1. On a retina
497
+ // panel a press at the middle of this box read as a press a quarter of
498
+ // the way in from its corner: the thumb sat near the corner however the
499
+ // pointer moved, and the far side of every axis was unreachable. The
500
+ // client rect is the same box already divided by the node's scale,
501
+ // which is the unit the pointer arrives in, so the arithmetic below is
502
+ // the arithmetic it always was.
503
+ const [box] = node?.getClientRects() ?? [];
495
504
  if (!box?.width || !box.height) return null;
496
505
  return {
497
506
  x: clamp01((ev.x - box.x) / box.width),
@@ -658,6 +667,41 @@ export function ColorPicker(props: ColorPickerProps): ReactElement {
658
667
  onKeyDown: focusable ? handleKey : undefined,
659
668
  });
660
669
 
670
+ /**
671
+ * The gradient and its border, as one element *under* the thumb.
672
+ *
673
+ * Core paints a node's children first and its border after them, so a
674
+ * border on the box the thumb lives in is painted across the thumb — the
675
+ * grey line over the white handle. On a sibling below it, the thumb wins,
676
+ * which is the order a handle wants: it is the thing being pointed at.
677
+ *
678
+ * It fixes the geometry too. An absolutely positioned child is laid out
679
+ * from its parent's *padding* box, so a border on the axis box shifted
680
+ * every thumb by its width — a strip handle overhung the top by 1px and
681
+ * the bottom by 3, and sat 1px right of the colour it was naming. Here
682
+ * the thumb's parent has no border, so `left` and `top` mean what they
683
+ * say: the same outer box `fractionIn` measures a press against.
684
+ */
685
+ const framed = (
686
+ axis: 'area' | 'hue' | 'alpha',
687
+ pane: ReactElement,
688
+ ): ReactElement =>
689
+ hx(
690
+ 'box',
691
+ {
692
+ key: 'frame',
693
+ style: [
694
+ s.pane,
695
+ {
696
+ borderWidth: theme.borderWidth,
697
+ borderColor:
698
+ cursorShown && active === axis ? theme.borderFocus : theme.border,
699
+ },
700
+ ],
701
+ },
702
+ pane,
703
+ );
704
+
661
705
  const renderArea = (): ReactElement =>
662
706
  hx(
663
707
  'box',
@@ -671,40 +715,34 @@ export function ColorPicker(props: ColorPickerProps): ReactElement {
671
715
  model.s * 100,
672
716
  100,
673
717
  ),
674
- style: [
675
- s.area,
676
- {
677
- borderWidth: theme.borderWidth,
678
- borderColor:
679
- cursorShown && active === 'area'
680
- ? theme.borderFocus
681
- : theme.border,
682
- },
683
- ],
718
+ style: s.area,
684
719
  ...track('area', areaTo),
685
720
  },
686
- hx('canvas', {
687
- style: s.pane,
688
- // Everything the drawing reads: the hue, and the size, which is a
689
- // constant here precisely so this key can name it.
690
- cacheKey: `sv:${Math.round(model.h)}:${CONTENT}x${AREA_H}`,
691
- onDraw: (ctx, { width, height }) => {
692
- ctx.fillStyle = hue;
693
- ctx.fillRect(0, 0, width, height);
694
- // Legacy comma spelling in the stops, for the same reason
695
- // `formatColor` emits it: it is what ntk's parser understands.
696
- const white = ctx.createLinearGradient(0, 0, width, 0);
697
- white.addColorStop(0, 'rgb(255, 255, 255)');
698
- white.addColorStop(1, 'rgba(255, 255, 255, 0)');
699
- ctx.fillStyle = white;
700
- ctx.fillRect(0, 0, width, height);
701
- const black = ctx.createLinearGradient(0, 0, 0, height);
702
- black.addColorStop(0, 'rgba(0, 0, 0, 0)');
703
- black.addColorStop(1, 'rgb(0, 0, 0)');
704
- ctx.fillStyle = black;
705
- ctx.fillRect(0, 0, width, height);
706
- },
707
- }),
721
+ framed(
722
+ 'area',
723
+ hx('canvas', {
724
+ style: s.pane,
725
+ // Everything the drawing reads: the hue, and the size, which is a
726
+ // constant here precisely so this key can name it.
727
+ cacheKey: `sv:${Math.round(model.h)}:${CONTENT}x${AREA_H}`,
728
+ onDraw: (ctx, { width, height }) => {
729
+ ctx.fillStyle = hue;
730
+ ctx.fillRect(0, 0, width, height);
731
+ // Legacy comma spelling in the stops, for the same reason
732
+ // `formatColor` emits it: it is what ntk's parser understands.
733
+ const white = ctx.createLinearGradient(0, 0, width, 0);
734
+ white.addColorStop(0, 'rgb(255, 255, 255)');
735
+ white.addColorStop(1, 'rgba(255, 255, 255, 0)');
736
+ ctx.fillStyle = white;
737
+ ctx.fillRect(0, 0, width, height);
738
+ const black = ctx.createLinearGradient(0, 0, 0, height);
739
+ black.addColorStop(0, 'rgba(0, 0, 0, 0)');
740
+ black.addColorStop(1, 'rgb(0, 0, 0)');
741
+ ctx.fillStyle = black;
742
+ ctx.fillRect(0, 0, width, height);
743
+ },
744
+ }),
745
+ ),
708
746
  hx('box', {
709
747
  style: [
710
748
  s.areaThumb,
@@ -730,33 +768,27 @@ export function ColorPicker(props: ColorPickerProps): ReactElement {
730
768
  model.h,
731
769
  360,
732
770
  ),
733
- style: [
734
- s.strip,
735
- {
736
- borderWidth: theme.borderWidth,
737
- borderColor:
738
- cursorShown && active === 'hue'
739
- ? theme.borderFocus
740
- : theme.border,
741
- },
742
- ],
771
+ style: s.strip,
743
772
  ...track('hue', hueTo),
744
773
  },
745
- hx('canvas', {
746
- style: s.pane,
747
- cacheKey: `hue:${CONTENT}x${STRIP_H}`,
748
- onDraw: (ctx, { width, height }) => {
749
- const ramp = ctx.createLinearGradient(0, 0, width, 0);
750
- for (let stop = 0; stop <= 6; stop += 1) {
751
- ramp.addColorStop(
752
- stop / 6,
753
- opaqueHex(channelsFromHsv(stop * 60, 1, 1)),
754
- );
755
- }
756
- ctx.fillStyle = ramp;
757
- ctx.fillRect(0, 0, width, height);
758
- },
759
- }),
774
+ framed(
775
+ 'hue',
776
+ hx('canvas', {
777
+ style: s.pane,
778
+ cacheKey: `hue:${CONTENT}x${STRIP_H}`,
779
+ onDraw: (ctx, { width, height }) => {
780
+ const ramp = ctx.createLinearGradient(0, 0, width, 0);
781
+ for (let stop = 0; stop <= 6; stop += 1) {
782
+ ramp.addColorStop(
783
+ stop / 6,
784
+ opaqueHex(channelsFromHsv(stop * 60, 1, 1)),
785
+ );
786
+ }
787
+ ctx.fillStyle = ramp;
788
+ ctx.fillRect(0, 0, width, height);
789
+ },
790
+ }),
791
+ ),
760
792
  hx('box', {
761
793
  style: [
762
794
  s.stripThumb,
@@ -778,31 +810,25 @@ export function ColorPicker(props: ColorPickerProps): ReactElement {
778
810
  model.a * 100,
779
811
  100,
780
812
  ),
781
- style: [
782
- s.strip,
783
- {
784
- borderWidth: theme.borderWidth,
785
- borderColor:
786
- cursorShown && active === 'alpha'
787
- ? theme.borderFocus
788
- : theme.border,
789
- },
790
- ],
813
+ style: s.strip,
791
814
  ...track('alpha', alphaTo),
792
815
  },
793
- hx('canvas', {
794
- style: s.pane,
795
- cacheKey: `alpha:${opaqueHex(model)}:${CONTENT}x${STRIP_H}`,
796
- onDraw: (ctx, { width, height }) => {
797
- drawChecker(ctx, width, height);
798
- const ramp = ctx.createLinearGradient(0, 0, width, 0);
799
- const { r, g, b } = model;
800
- ramp.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0)`);
801
- ramp.addColorStop(1, `rgb(${r}, ${g}, ${b})`);
802
- ctx.fillStyle = ramp;
803
- ctx.fillRect(0, 0, width, height);
804
- },
805
- }),
816
+ framed(
817
+ 'alpha',
818
+ hx('canvas', {
819
+ style: s.pane,
820
+ cacheKey: `alpha:${opaqueHex(model)}:${CONTENT}x${STRIP_H}`,
821
+ onDraw: (ctx, { width, height }) => {
822
+ drawChecker(ctx, width, height);
823
+ const ramp = ctx.createLinearGradient(0, 0, width, 0);
824
+ const { r, g, b } = model;
825
+ ramp.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0)`);
826
+ ramp.addColorStop(1, `rgb(${r}, ${g}, ${b})`);
827
+ ctx.fillStyle = ramp;
828
+ ctx.fillRect(0, 0, width, height);
829
+ },
830
+ }),
831
+ ),
806
832
  hx('box', {
807
833
  style: [
808
834
  s.stripThumb,
package/src/html/dom.ts CHANGED
@@ -157,6 +157,11 @@ export type SheetRef =
157
157
  * an extension resets the parser, because a mid-document edit can change the
158
158
  * tree arbitrarily and pretending otherwise is how a streaming renderer
159
159
  * shows a document nobody wrote.
160
+ *
161
+ * That delta is available only until the source says it is `complete`: an
162
+ * ended parse cannot be extended, so every later change re-parses. A caller
163
+ * that wants the append path — a stream, a `<Markdown partial>` — keeps
164
+ * `complete` false until the last chunk, which is what the flag is for.
160
165
  */
161
166
  export class HtmlSource {
162
167
  /** The tree, live: it grows as chunks are written. */
@@ -178,12 +183,22 @@ export class HtmlSource {
178
183
  this.document = handler.root;
179
184
  }
180
185
 
181
- /** Set the whole source. Appends when it can; re-parses when it cannot. */
186
+ /** Set the whole source. Appends when it can — an extension of what was
187
+ * written, into a parser that has not been ended — and re-parses when it
188
+ * cannot. */
182
189
  setSource(source: string, complete: boolean): boolean {
183
190
  let changed = false;
184
191
  if (source === this._written) {
185
192
  changed = false;
186
- } else if (source.startsWith(this._written)) {
193
+ } else if (!this.complete && source.startsWith(this._written)) {
194
+ // The append path is open only while the parser is. `end()` runs the
195
+ // tokenizer's EOF, and htmlparser2 answers any later chunk with
196
+ // `.write() after done!`, so a completed parse re-parses like any other
197
+ // edit. Without the guard the failure landed far from its cause: `<Html
198
+ // partial={false}>` passes `complete` on *every* render, so whether an
199
+ // edit crashed depended on the caret — one in the middle of the document
200
+ // is not a prefix and took the reset path, one at the end was a prefix,
201
+ // took this branch, and threw inside the reconciler's commit.
187
202
  this._parser.write(source.slice(this._written.length));
188
203
  this._written = source;
189
204
  changed = true;
package/src/html/index.ts CHANGED
@@ -87,6 +87,10 @@ export interface HtmlProps {
87
87
  * that extends the last one is written as a delta, so the nodes already
88
88
  * parsed keep their identity and their layout. Set false when the stream
89
89
  * ends.
90
+ *
91
+ * False ends the parse, and that is final: a later `source` re-parses even
92
+ * when it extends the last one. An editor handing over the whole document
93
+ * on every keystroke wants the default.
90
94
  */
91
95
  partial?: boolean;
92
96
  /** Mouse selection, Ctrl+A / Ctrl+C, PRIMARY. Default true. */
@@ -44,6 +44,7 @@ import type { DrawnNode, ScrollableNode } from 'react-x11';
44
44
  import type { RowHeights, RowKey } from './heights.js';
45
45
  import { afterLayout, cancelAfterLayout } from './timers.js';
46
46
  import type { LayoutTick } from './timers.js';
47
+ import { scaleOf } from './units.js';
47
48
 
48
49
  /** All a reveal needs of a row: `<TreeRow>` and `<TableRow>` are both this. */
49
50
  interface Keyed {
@@ -148,13 +149,17 @@ export function useReveal(sources: RevealSources): Reveal {
148
149
  const scrollTo = useCallback((y: number): void => {
149
150
  const box = src.current.box.current;
150
151
  if (!box) return;
152
+ // `y` is logical, like everything the reveal computes with; the raw
153
+ // node properties are device (../internal/units.ts), divided here so the
154
+ // clamp and the early-out speak the caller's unit.
155
+ const s = scaleOf(box);
151
156
  // Clamped here rather than left to the container, so what comes back on
152
157
  // `onScroll` is the number that was asked for and can be recognised.
153
158
  const to = Math.min(
154
159
  Math.max(0, y),
155
- Math.max(0, box.contentHeight - box.abs.height),
160
+ Math.max(0, (box.contentHeight - box.abs.height) / s),
156
161
  );
157
- if (to === box.scrollY) return;
162
+ if (to === box.scrollY / s) return;
158
163
  asked.current = to;
159
164
  box.scrollTo({ y: to });
160
165
  }, []);
@@ -166,9 +171,10 @@ export function useReveal(sources: RevealSources): Reveal {
166
171
  const box = src.current.box.current;
167
172
  const want = owedShift.current;
168
173
  if (!box || want === 0) return;
169
- const was = box.scrollY;
174
+ const s = scaleOf(box);
175
+ const was = box.scrollY / s;
170
176
  scrollTo(was + want);
171
- owedShift.current = want - (box.scrollY - was);
177
+ owedShift.current = want - (box.scrollY / s - was);
172
178
  }, [scrollTo]);
173
179
 
174
180
  const nudge = useCallback(
@@ -208,7 +214,10 @@ export function useReveal(sources: RevealSources): Reveal {
208
214
  settled.current = null;
209
215
  return;
210
216
  }
211
- const viewport = box.abs.height;
217
+ // Logical, like the height index the unmounted arithmetic mixes with —
218
+ // the raw properties are device (../internal/units.ts).
219
+ const s = scaleOf(box);
220
+ const viewport = box.abs.height / s;
212
221
  if (viewport <= 0) return; // nothing laid out to scroll inside yet
213
222
 
214
223
  // How far the row is outside the pane, in pixels of scrolling. The row's
@@ -223,16 +232,20 @@ export function useReveal(sources: RevealSources): Reveal {
223
232
  : null;
224
233
  const placed = mounted
225
234
  ? {
226
- above: box.abs.y - mounted.abs.y,
227
- below: mounted.abs.y + mounted.abs.height - (box.abs.y + viewport),
228
- height: mounted.abs.height,
235
+ above: (box.abs.y - mounted.abs.y) / s,
236
+ below:
237
+ (mounted.abs.y +
238
+ mounted.abs.height -
239
+ (box.abs.y + box.abs.height)) /
240
+ s,
241
+ height: mounted.abs.height / s,
229
242
  }
230
243
  : {
231
- above: box.scrollY - heights.offsetAt(at),
244
+ above: box.scrollY / s - heights.offsetAt(at),
232
245
  below:
233
246
  heights.offsetAt(at) +
234
247
  heights.heightAt(at) -
235
- (box.scrollY + viewport),
248
+ (box.scrollY / s + viewport),
236
249
  height: heights.heightAt(at),
237
250
  };
238
251
  /**
@@ -254,8 +267,8 @@ export function useReveal(sources: RevealSources): Reveal {
254
267
  */
255
268
  let certain = mounted !== null || heights.isMeasured(at);
256
269
  if (certain && heights.hasMeasurements()) {
257
- const from = Math.min(at, heights.indexAt(box.scrollY));
258
- const to = Math.max(at, heights.indexAt(box.scrollY + viewport));
270
+ const from = Math.min(at, heights.indexAt(box.scrollY / s));
271
+ const to = Math.max(at, heights.indexAt(box.scrollY / s + viewport));
259
272
  for (let i = from; i <= to && certain; i++) {
260
273
  certain = heights.isMeasured(i);
261
274
  }
@@ -283,8 +296,10 @@ export function useReveal(sources: RevealSources): Reveal {
283
296
  }
284
297
  // A move the pane cannot make yet leaves the debt standing: the layout
285
298
  // that admits the rows just appended is the one that will let it finish.
299
+ // `was` in device, compared against the same raw property — whether it
300
+ // moved is unit-free; the request itself goes through logical scrollTo.
286
301
  const was = box.scrollY;
287
- scrollTo(was + move);
302
+ scrollTo(was / s + move);
288
303
  if (box.scrollY !== was) {
289
304
  stuck.current = null; // it moved; the scroll it caused brings us back
290
305
  return;
@@ -323,8 +338,12 @@ export function useReveal(sources: RevealSources): Reveal {
323
338
 
324
339
  const heard = useCallback((scrollY: number): void => {
325
340
  // A scroll this component did not ask for ends the whole chase, not just
326
- // the outstanding half of it.
327
- if (scrollY !== asked.current) {
341
+ // the outstanding half of it. Within half a pixel is ours: the payload
342
+ // is the device offset divided back to logical, and at a fractional
343
+ // scale that round trip need not be bit-identical to what was asked.
344
+ const ours =
345
+ asked.current !== null && Math.abs(scrollY - asked.current) < 0.5;
346
+ if (!ours) {
328
347
  owed.current = null;
329
348
  settled.current = null;
330
349
  owedShift.current = 0;
@@ -0,0 +1,34 @@
1
+ // One logical pixel is `scale` device pixels — react-x11's docs/scale.md.
2
+ //
3
+ // **Shared between `<Tree>` and `<Table>`** like the rest of this directory;
4
+ // see the header of `./heights.ts` for why it is internal rather than a
5
+ // shared module with a subpath.
6
+ //
7
+ // The virtualizers keep their whole model in logical pixels: style lengths,
8
+ // the `onScroll`/`onViewport` payloads, the height index, and the props
9
+ // (`rowHeight`, `estimatedRowHeight`) are all logical. But the **raw node
10
+ // properties** they read back between events — `scrollY`, `contentHeight`,
11
+ // `abs` — are device pixels. At scale 1 the two units coincide, which is how
12
+ // every harness test and every Xvfb check passed while a retina display
13
+ // doubled every raw read: the offset re-read after layout landed at twice
14
+ // the logical value, the slice was rebuilt a viewport below where the user
15
+ // was looking, and the table example went blank on a flick (and stayed
16
+ // blank after an append-and-reveal). Every raw read goes through this
17
+ // divisor so the arithmetic stays in one unit.
18
+ //
19
+ // **A pointer is the other half of this.** An event's `x`/`y` are logical,
20
+ // so a gesture measured against a laid-out box wants the box in that unit:
21
+ // `node.getClientRects()[0]`, which is `abs` already divided, rather than
22
+ // `abs` and a multiply. `<ColorPicker>` compared the two directly and put
23
+ // its thumb a quarter of the way from the corner on every retina panel.
24
+ // Reach for `scaleOf` where there is no rect to ask for — a raw `scrollY`,
25
+ // a `contentHeight`, a constant that never passes through a style.
26
+ //
27
+ // `scale` is on every retained node at run time but not on the public
28
+ // `DrawnNode` type — hence the structural read, and `object` rather than
29
+ // `{ scale?: unknown }` in the signature: a weak type with no property in
30
+ // common with `DrawnNode` fails TS2559 at every call site.
31
+ export function scaleOf(node: object | null | undefined): number {
32
+ const s = node ? (node as { scale?: unknown }).scale : undefined;
33
+ return typeof s === 'number' && s > 0 ? s : 1;
34
+ }
@@ -48,6 +48,7 @@ import type { ScrollableNode } from 'react-x11';
48
48
  import type { RowHeights, RowKey } from './heights.js';
49
49
  import { cancelLater, later } from './timers.js';
50
50
  import type { DelayTick } from './timers.js';
51
+ import { scaleOf } from './units.js';
51
52
 
52
53
  /** All this needs of a row — `TreeRow` and `TableRow` are both this. */
53
54
  interface Keyed {
@@ -298,7 +299,6 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
298
299
  // one has no slice to rebuild, and re-rendering it on a scroll it
299
300
  // already drew would be work for nothing.
300
301
  if (!inp.current.virtualizing) return;
301
- (globalThis as any).__scrolls = ((globalThis as any).__scrolls ?? 0) + 1;
302
302
  const now = Date.now();
303
303
  const s = vel.current;
304
304
  const dt = now - s.t;
@@ -332,7 +332,9 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
332
332
  const { box, virtualizing } = inp.current;
333
333
  const node = box.current;
334
334
  if (!node || !virtualizing) return;
335
- const y = node.scrollY;
335
+ // The raw property is device pixels; the viewport model — like the
336
+ // `onScroll` payload `scrolled` is fed — is logical (../internal/units.ts).
337
+ const y = node.scrollY / scaleOf(node);
336
338
  setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
337
339
  }, []);
338
340
 
@@ -70,6 +70,7 @@ import {
70
70
  cancelLater,
71
71
  later,
72
72
  } from '../internal/timers.js';
73
+ import { scaleOf } from '../internal/units.js';
73
74
  import type { DelayTick } from '../internal/timers.js';
74
75
  import { useReveal } from '../internal/scroll.js';
75
76
  import {
@@ -953,7 +954,10 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
953
954
  const syncScroll = useCallback((): void => {
954
955
  const box = body.current;
955
956
  if (!box) return;
956
- const x = box.scrollX;
957
+ // Device on the raw property; the header margin it feeds is a style
958
+ // length, logical — the same division every raw read here makes
959
+ // (../internal/units.ts).
960
+ const x = box.scrollX / scaleOf(box);
957
961
  setScrollX((prev) => (prev === x ? prev : x));
958
962
  winSync();
959
963
  }, [winSync]);
@@ -992,13 +996,18 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
992
996
  ).total;
993
997
  const box = body.current;
994
998
  const rows = orderedRef.current;
995
- const anchor = box ? heights.indexAt(box.scrollY) : 0;
999
+ const anchor = box ? heights.indexAt(box.scrollY / scaleOf(box)) : 0;
996
1000
  let shift = 0;
997
1001
  let changed = false;
998
1002
  for (const [id, { node, at }] of rowNodes.current) {
999
1003
  if (rows[at]?.id !== id) continue; // drawn against a list that moved
1000
- if (node.abs.width !== expected) continue;
1001
- const height = node.abs.height;
1004
+ // `abs` is device; the resolved widths and the height index are
1005
+ // logical. Within a pixel rather than exact: a fractional scale
1006
+ // rounds the device width to the pixel grid, and the stale-width
1007
+ // layouts this gate exists for are whole columns off, not fractions.
1008
+ const s = scaleOf(node);
1009
+ if (Math.abs(node.abs.width / s - expected) >= 1) continue;
1010
+ const height = node.abs.height / s;
1002
1011
  const was = heights.heightAt(at);
1003
1012
  if (!heights.measure(id, at, height)) continue;
1004
1013
  changed = true;
@@ -1026,7 +1035,7 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1026
1035
  if (uniform || !virtualizing) return false;
1027
1036
  const box = body.current;
1028
1037
  if (!box) return false;
1029
- const anchor = heights.indexAt(box.scrollY);
1038
+ const anchor = heights.indexAt(box.scrollY / scaleOf(box));
1030
1039
  const before = heights.offsetAt(anchor);
1031
1040
  if (!heights.adapt()) return false;
1032
1041
  reveal.nudge(heights.offsetAt(anchor) - before);