react-x11 2.18.6 → 2.19.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.18.6",
3
+ "version": "2.19.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -110,7 +110,7 @@
110
110
  "optionalDependencies": {
111
111
  "@windowkit/appkit": "^0.12.0",
112
112
  "@windowkit/wayland": "^3.1.1",
113
- "@windowkit/win32": "^0.0.2",
113
+ "@windowkit/win32": "^0.0.3",
114
114
  "dbus-native": "^0.15.1",
115
115
  "x11-dri": "^0.9.0"
116
116
  },
@@ -577,6 +577,20 @@ export class BackendContext2D {
577
577
  * dashes of an animated edge were repainted sixteen times a second and
578
578
  * never moved.
579
579
  */
580
+ /**
581
+ * Text drawn under a transform that scales is drawn scaled, glyphs and
582
+ * all: both bridges lay a text layout out once and draw its outlines
583
+ * through the context's matrix (Direct2D's `DrawTextLayout`, CoreText's
584
+ * `CTFrameDraw`). ntk's context on X11 cannot — its glyphs come out of a
585
+ * cache rasterized at the size they were shaped at, and a transform
586
+ * moves where each lands, not how big it is — and says nothing, which is
587
+ * the same answer. So an element that would rather draw a layout it has
588
+ * than shape a new one at every step of a zoom asks this first.
589
+ */
590
+ get scalesText() {
591
+ return true;
592
+ }
593
+
580
594
  get lineDashOffset() {
581
595
  return this._state.dashOffset;
582
596
  }
package/src/node.d.ts CHANGED
@@ -142,6 +142,15 @@ export interface Context2D {
142
142
  strokeRect(x: number, y: number, width: number, height: number): void;
143
143
  clearRect(x: number, y: number, width: number, height: number): void;
144
144
  fillText(text: string, x: number, y: number): void;
145
+ /**
146
+ * `true` where text drawn under a transform that scales is drawn scaled,
147
+ * glyphs and all — the Windows and macOS contexts. Absent on X11, where
148
+ * ntk rasterizes glyphs at the size the text was shaped at and a
149
+ * transform moves only where each one lands. An element that would draw
150
+ * a layout it already has at another size, rather than shape a new one,
151
+ * asks this first.
152
+ */
153
+ readonly scalesText?: boolean;
145
154
  measureText(text: string): { width: number; [key: string]: unknown };
146
155
  /**
147
156
  * Composite an offscreen `Surface` (`react-x11/ntk`) — whole at a point,
@@ -490,6 +490,7 @@ export class NodeAnimation {
490
490
  if (this.root) {
491
491
  this.root.needsLayout = true;
492
492
  this.root._floorsDirty = true;
493
+ this.root._floorsSources.add(this);
493
494
  }
494
495
  }
495
496
  // A tick writes `this.style` without going through `_retarget`, so it
@@ -322,6 +322,11 @@ export class WindowInvalidate {
322
322
  // panned — was a full measurement of the content floors a step.
323
323
  if (reason !== 'scroll' && reason !== 'position') {
324
324
  this._floorsDirty = true;
325
+ // …and which node changed, so that a change inside a box that sizes
326
+ // itself measures that box and nothing around it (`_floorsScope`).
327
+ // A claim that names no node, or names the window, is the tree's.
328
+ if (source && source !== this) this._floorsSources.add(source);
329
+ else this._floorsUnscoped = true;
325
330
  }
326
331
  if (reason !== 'scroll' && reason !== 'resize' && reason !== 'position') {
327
332
  this._floorsContentDirty = true;
@@ -415,6 +415,7 @@ export class NodeLayoutHost {
415
415
  // content floors were measured from, as any style change is
416
416
  root._floorsDirty = true;
417
417
  root._floorsContentDirty = true;
418
+ root._floorsUnscoped = true;
418
419
  }
419
420
  }
420
421
 
@@ -944,6 +945,7 @@ export class WindowLayoutHost {
944
945
  this._failedHosts.clear();
945
946
  this._floorsDirty = true;
946
947
  this._floorsContentDirty = true;
948
+ this._floorsUnscoped = true;
947
949
  return true;
948
950
  }
949
951
  }
@@ -196,6 +196,7 @@ export class NodeQueries {
196
196
  if (applyLayoutStyle(this.yoga, this.style, before) && this.root) {
197
197
  this.root._floorsDirty = true;
198
198
  this.root._floorsContentDirty = true;
199
+ this.root._floorsSources.add(this);
199
200
  }
200
201
  }
201
202
  }
@@ -233,6 +234,7 @@ export class WindowQueries {
233
234
  // a query block may carry layout properties, so the floors measured from
234
235
  // the styles it is replacing are not the answer any more
235
236
  this._floorsDirty = true;
237
+ this._floorsUnscoped = true;
236
238
  for (const node of [...this._sizeQueryNodes]) {
237
239
  if (node.destroyed) this._sizeQueryNodes.delete(node);
238
240
  else node._sizeQueriesChanged();
@@ -324,7 +326,10 @@ export class WindowQueries {
324
326
  // a block may carry layout properties, so the floors measured from the
325
327
  // styles it is replacing are not the answer any more — the same debt a
326
328
  // window query leaves (`_resolveSizeQueries`)
327
- if (changed) this._floorsDirty = true;
329
+ if (changed) {
330
+ this._floorsDirty = true;
331
+ this._floorsUnscoped = true;
332
+ }
328
333
  return changed;
329
334
  }
330
335
  }
@@ -197,6 +197,88 @@ export function collectFloorStale(node, stale, found, sweep) {
197
197
  }
198
198
  }
199
199
 
200
+ /**
201
+ * Whether `node` is a box its content cannot size and its surroundings cannot
202
+ * size either — an edge a floor measurement may stop at
203
+ * (`WindowNode._floorsScope`).
204
+ *
205
+ * Absolutely positioned, with a width and a height its style names as
206
+ * numbers: it is out of its parent's flow, so nothing measured inside it
207
+ * reaches anything above it (`contentSpan` skips it, and no floor is written
208
+ * on it), and every pass lays it out at those numbers wherever it sits, so
209
+ * what is inside it is measured the same in a pass over the window and in a
210
+ * pass over it alone. Nothing in its own box resolves against its parent —
211
+ * a percentage of padding would — and neither it nor its parent is a layout
212
+ * host, whose children are measured tree by tree.
213
+ *
214
+ * A card on a graph, a popover laid out inside the window, a sized overlay:
215
+ * the boxes a change inside is most often confined to. An in-flow box with a
216
+ * size of its own would do as well along its cross axis, but on its main
217
+ * axis a flex line can grow it past its width or shrink it under a minimum
218
+ * the author named, and that is a question about its siblings.
219
+ */
220
+ export function isFloorBoundary(node) {
221
+ const style = node.style;
222
+ return (
223
+ node.yoga != null &&
224
+ !node.isWindow &&
225
+ style.position === 'absolute' &&
226
+ style.display !== 'none' &&
227
+ typeof style.width === 'number' &&
228
+ typeof style.height === 'number' &&
229
+ typeof style.minWidth !== 'string' &&
230
+ typeof style.minHeight !== 'string' &&
231
+ typeof style.maxWidth !== 'string' &&
232
+ typeof style.maxHeight !== 'string' &&
233
+ !PADDINGS.some((key) => typeof style[key] === 'string') &&
234
+ node._host === null &&
235
+ (node.parent?._host ?? null) === null
236
+ );
237
+ }
238
+
239
+ const PADDINGS = [
240
+ 'padding',
241
+ 'paddingTop',
242
+ 'paddingRight',
243
+ 'paddingBottom',
244
+ 'paddingLeft',
245
+ 'paddingHorizontal',
246
+ 'paddingVertical',
247
+ 'paddingStart',
248
+ 'paddingEnd',
249
+ ];
250
+
251
+ /**
252
+ * The box that holds a change to `node` in (`isFloorBoundary`), or null when
253
+ * none does below `root`. Looked for from the parent up: a node that changed
254
+ * may have changed its own size, and it is what is *around* the change that
255
+ * has to hold still.
256
+ */
257
+ export function floorBoundaryOf(node, root) {
258
+ for (let n = node.parent; n && n !== root; n = n.parent) {
259
+ if (n.isWindow) return null;
260
+ if (isFloorBoundary(n)) return n;
261
+ }
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Whether yoga has a change on record anywhere but inside `boundaries` and on
267
+ * the way down to them (`paths`) — something that changed with no node
268
+ * saying so, which a measurement confined to the boundaries would miss. A
269
+ * dirty node's ancestors are dirty too, so the walk goes down dirty nodes
270
+ * only, and a clean one has nothing under it to find.
271
+ */
272
+ export function dirtyOutside(node, boundaries, paths) {
273
+ for (const child of node.children) {
274
+ if (!child.yoga || child.isWindow || !child.yoga.isDirty()) continue;
275
+ if (boundaries.has(child)) continue;
276
+ if (!paths.has(child)) return true;
277
+ if (dirtyOutside(child, boundaries, paths)) return true;
278
+ }
279
+ return false;
280
+ }
281
+
200
282
  /**
201
283
  * After a pass that settled the widths: which of the height floors, each a
202
284
  * height *for a width*, were measured for a width their node no longer has.
@@ -216,7 +298,7 @@ export function collectFloorStale(node, stale, found, sweep) {
216
298
  * the pixel grid off and the real one with it on, so the same layout reads
217
299
  * a fraction apart between them.
218
300
  */
219
- export function probeHeightFloors(node, root, hit) {
301
+ export function probeHeightFloors(node, root, hit, stop = root) {
220
302
  for (const child of node.children) {
221
303
  if (!child.yoga || child.isWindow) continue;
222
304
  if (child.style.display === 'none') continue;
@@ -230,10 +312,10 @@ export function probeHeightFloors(node, root, hit) {
230
312
  at !== undefined &&
231
313
  child._heightForWidth(width) !== child._heightForWidth(at)
232
314
  ) {
233
- markHeightStale(child, root, hit);
315
+ markHeightStale(child, root, hit, stop);
234
316
  }
235
317
  } else {
236
- probeHeightFloors(child, root, hit);
318
+ probeHeightFloors(child, root, hit, stop);
237
319
  }
238
320
  // whatever the extent is, it is the extent for this width now — a
239
321
  // stale one is about to be measured here, a clean one was just checked
@@ -241,10 +323,12 @@ export function probeHeightFloors(node, root, hit) {
241
323
  }
242
324
  }
243
325
 
244
- /** A leaf whose height moved takes every extent above it with it. */
245
- function markHeightStale(node, root, hit) {
326
+ /** A leaf whose height moved takes every extent above it with it — up to
327
+ * `stop`, a box that sizes itself (`isFloorBoundary`), whose extent is its
328
+ * style's whatever moved inside it. */
329
+ function markHeightStale(node, root, hit, stop = root) {
246
330
  hit.marked = true;
247
- for (let n = node; n && n !== root; n = n.parent) {
331
+ for (let n = node; n && n !== stop; n = n.parent) {
248
332
  if (n._floorH === undefined) continue;
249
333
  n._floorH = undefined;
250
334
  root._floorsStale.add(n);
@@ -4,9 +4,12 @@
4
4
 
5
5
  import { measuringExactly } from '../../styles.js';
6
6
  import { availableArea } from '../../screens.js';
7
+ import { Yoga } from '../../yoga.js';
7
8
  import {
8
9
  captureLeafHeights,
9
10
  collectFloorStale,
11
+ dirtyOutside,
12
+ floorBoundaryOf,
10
13
  probeHeightFloors,
11
14
  setMeasuringShrink,
12
15
  restoreShrink,
@@ -27,6 +30,26 @@ import {
27
30
  scaleWindowGeometry,
28
31
  } from './hints.js';
29
32
 
33
+ // Escape hatch, read once like the others: for measuring the scoped floors
34
+ // against the whole-tree ones on the same build, and as first aid if a
35
+ // change inside a card is ever measured wrong. `=== '1'` for the reason
36
+ // REACT_X11_NO_SCROLL_BLIT gives.
37
+ const NO_SCOPED_FLOORS = process.env.REACT_X11_NO_SCOPED_FLOORS === '1';
38
+
39
+ /**
40
+ * Mark a box laid out alone (`_measureBoundary`) for the window's pass to lay
41
+ * out again. It was laid out at the size that pass will give it, so yoga
42
+ * would answer from its cache and keep what the measurement left inside —
43
+ * placed off the pixel grid, with the shrink borrowed. A style set to
44
+ * something else and back is how a box with no measure function says it
45
+ * changed.
46
+ */
47
+ function relayOut(node) {
48
+ const shrink = node.style.flexShrink ?? 1;
49
+ node.yoga.setFlexShrink(shrink + 1);
50
+ node.yoga.setFlexShrink(shrink);
51
+ }
52
+
30
53
  /** Content-sized windows, installed onto `WindowNode.prototype` by window.js. */
31
54
  export class WindowSize {
32
55
  /**
@@ -102,6 +125,21 @@ export class WindowSize {
102
125
  this._sweepLayoutHosts();
103
126
  const found = { width: false, height: false };
104
127
  this._floorsStale.clear();
128
+ // Decided afresh on every call rather than once a frame: something
129
+ // between the two calls a frame makes — a size query resolving — can
130
+ // turn a scoped change into one that is not, and the wider collection
131
+ // is the one that is always right.
132
+ const scope = this._floorsScope();
133
+ this._floorsScopeNow = scope;
134
+ if (scope !== null) {
135
+ for (const boundary of scope) {
136
+ // its children's floors are written from here; its own extent is
137
+ // its style's, and stands
138
+ this._floorsStale.add(boundary);
139
+ collectFloorStale(boundary, this._floorsStale, found, false);
140
+ }
141
+ return found;
142
+ }
105
143
  // the root's own children are written from here too, and its direction
106
144
  // can move like any node's
107
145
  this._floorsStale.add(this);
@@ -109,6 +147,123 @@ export class WindowSize {
109
147
  return found;
110
148
  }
111
149
 
150
+ /**
151
+ * The boxes the changes since the last measurement are confined to, or
152
+ * null when a measurement has to look at the whole tree.
153
+ *
154
+ * Every change to a box tree announces itself through `invalidate` with
155
+ * the node it changed (`_floorsSources`), and every node above it is
156
+ * dirty in yoga for it — which is all `collectFloorStale` can see, so a
157
+ * text that changed inside a card took every extent from the card up to
158
+ * the window with it, and the measurement that followed laid the whole
159
+ * tree out three times to read them back. A box that sizes itself
160
+ * (`isFloorBoundary`) holds the change in: its extent is its style's, the
161
+ * extents above it are what they were, and what is inside it can be
162
+ * measured by laying it out alone.
163
+ *
164
+ * Scoped only when all of that is known to hold: every change has such a
165
+ * box around it, yoga has nothing dirty outside them and the way down to
166
+ * them (a change nobody named), the tree has been measured once already,
167
+ * the window is the width it was measured at and sizes itself from no
168
+ * content, and no layout host or container query is in play. Anything
169
+ * else is the whole tree, as it always was.
170
+ */
171
+ _floorsScope() {
172
+ const sources = this._floorsSources;
173
+ if (
174
+ NO_SCOPED_FLOORS ||
175
+ this._floorsUnscoped ||
176
+ sources.size === 0 ||
177
+ !this._floorsSwept ||
178
+ this._layoutHosts.size !== 0 ||
179
+ this._containerQueryNodes.size !== 0 ||
180
+ this._floorsWidth !== (this.window?.width ?? this._requestedSize?.width)
181
+ ) {
182
+ return null;
183
+ }
184
+ const props = this.props;
185
+ if (
186
+ isAutoSize(props.width) ||
187
+ isAutoSize(props.height) ||
188
+ CONTENT_BOUND_PROPS.some((key) => isContentBound(props[key]))
189
+ ) {
190
+ return null;
191
+ }
192
+ const boundaries = new Set();
193
+ for (const source of sources) {
194
+ // gone, or moved to another window: its old parent's child list is
195
+ // the change that counts, and that parent said so itself
196
+ if (source.destroyed || source.root !== this) continue;
197
+ const boundary = floorBoundaryOf(source, this);
198
+ if (boundary === null) return null;
199
+ boundaries.add(boundary);
200
+ }
201
+ if (boundaries.size === 0) return null;
202
+ // the outermost of nested ones: measuring it measures what it holds
203
+ const paths = new Set();
204
+ for (const boundary of [...boundaries]) {
205
+ for (let n = boundary.parent; n && n !== this; n = n.parent) {
206
+ if (boundaries.has(n)) {
207
+ boundaries.delete(boundary);
208
+ break;
209
+ }
210
+ }
211
+ }
212
+ for (const boundary of boundaries) {
213
+ for (let n = boundary.parent; n && n !== this; n = n.parent) {
214
+ paths.add(n);
215
+ }
216
+ }
217
+ if (dirtyOutside(this, boundaries, paths)) return null;
218
+ return boundaries;
219
+ }
220
+
221
+ /**
222
+ * One boundary's floors, measured with it laid out alone — the passes
223
+ * `_measureContentSpans` runs over the whole tree, run over the box: its
224
+ * size is its style's in either, so the space everything inside it is
225
+ * laid out in is the same. `probe` is the height measurement's check for
226
+ * leaves whose width moved, stopped at the box, whose own extent stands.
227
+ */
228
+ _measureBoundary(boundary, axis, probe = false) {
229
+ const yoga = boundary.yoga;
230
+ const dir =
231
+ boundary.direction === 'rtl' ? Yoga.DIRECTION_RTL : Yoga.DIRECTION_LTR;
232
+ if (axis === 'width') {
233
+ const shrunk = [];
234
+ setMeasuringShrink(boundary, axis, shrunk);
235
+ this._scopedFloorPasses += 1;
236
+ yoga.calculateLayout(undefined, undefined, dir);
237
+ contentSpan(boundary, axis, null, this);
238
+ restoreShrink(shrunk);
239
+ relayOut(boundary);
240
+ return;
241
+ }
242
+ this._scopedFloorPasses += 1;
243
+ yoga.calculateLayout(undefined, undefined, dir);
244
+ if (probe) {
245
+ const hit = { marked: false, owed: false };
246
+ probeHeightFloors(boundary, this, hit, boundary);
247
+ if (hit.marked) {
248
+ this._writeFloors('height');
249
+ this._scopedFloorPasses += 1;
250
+ yoga.calculateLayout(undefined, undefined, dir);
251
+ }
252
+ }
253
+ const intrinsic = new Map();
254
+ captureLeafHeights(boundary, intrinsic);
255
+ const frozen = [];
256
+ freezeWidths(boundary, frozen);
257
+ const shrunk = [];
258
+ setMeasuringShrink(boundary, axis, shrunk);
259
+ this._scopedFloorPasses += 1;
260
+ yoga.calculateLayout(undefined, 0, dir);
261
+ contentSpan(boundary, axis, intrinsic, this);
262
+ restoreWidths(frozen);
263
+ restoreShrink(shrunk);
264
+ relayOut(boundary);
265
+ }
266
+
112
267
  /** The floors on the children of every node found stale, from the extents
113
268
  * they carry — `writeFloors` for each. */
114
269
  _writeFloors(axis) {
@@ -203,28 +358,77 @@ export class WindowSize {
203
358
  */
204
359
  _applyContentFloors(width, height) {
205
360
  const found = this._collectFloorStale();
361
+ const scope = this._floorsScopeNow;
362
+ if (scope !== null) {
363
+ this._applyScopedFloors(scope, found, width, height);
364
+ } else {
365
+ measuringExactly(() => {
366
+ if (found.width) this._measureWidthFloors();
367
+ else this._writeFloors('width');
368
+ // from the extents on hand; a stale one writes the style's minimum,
369
+ // which is the floor coming off ahead of its measurement below
370
+ this._writeFloors('height');
371
+ });
372
+ let heights = found.height;
373
+ let probed = false;
374
+ if (!heights) {
375
+ this._layoutRoot(width, height);
376
+ heights = this._probeHeightFloors().owed;
377
+ probed = true;
378
+ }
379
+ if (heights) {
380
+ measuringExactly(() => this._measureHeightFloors(width, !probed));
381
+ this._layoutRoot(width, height);
382
+ }
383
+ }
384
+ this._floorsDirty = false;
385
+ this._floorsContentDirty = false;
386
+ this._floorsSwept = true;
387
+ this._floorsWidth = width;
388
+ this._floorsSources.clear();
389
+ this._floorsUnscoped = false;
390
+ this._floorsScopeNow = null;
391
+ }
392
+
393
+ /**
394
+ * `_applyContentFloors` for changes confined to boxes that size
395
+ * themselves (`_floorsScope`): the same measurements in the same order,
396
+ * each over one box laid out alone rather than over the window, and the
397
+ * window's one real pass after them — which yoga answers from its cache
398
+ * everywhere but the boxes and the way down to them.
399
+ */
400
+ _applyScopedFloors(boundaries, found, width, height) {
206
401
  measuringExactly(() => {
207
- if (found.width) this._measureWidthFloors();
208
- else this._writeFloors('width');
209
- // from the extents on hand; a stale one writes the style's minimum,
210
- // which is the floor coming off ahead of its measurement below
402
+ if (found.width) {
403
+ this._writeFloors('width');
404
+ for (const boundary of boundaries) {
405
+ this._measureBoundary(boundary, 'width');
406
+ }
407
+ }
408
+ this._writeFloors('width');
211
409
  this._writeFloors('height');
212
410
  });
213
411
  let heights = found.height;
214
412
  let probed = false;
215
413
  if (!heights) {
216
414
  this._layoutRoot(width, height);
217
- heights = this._probeHeightFloors().owed;
415
+ const hit = { marked: false, owed: false };
416
+ for (const boundary of boundaries) {
417
+ probeHeightFloors(boundary, this, hit, boundary);
418
+ }
419
+ heights = hit.owed;
218
420
  probed = true;
219
421
  }
220
422
  if (heights) {
221
- measuringExactly(() => this._measureHeightFloors(width, !probed));
423
+ measuringExactly(() => {
424
+ this._writeFloors('height');
425
+ for (const boundary of boundaries) {
426
+ this._measureBoundary(boundary, 'height', !probed);
427
+ }
428
+ this._writeFloors('height');
429
+ });
222
430
  this._layoutRoot(width, height);
223
431
  }
224
- this._floorsDirty = false;
225
- this._floorsContentDirty = false;
226
- this._floorsSwept = true;
227
- this._floorsWidth = width;
228
432
  }
229
433
 
230
434
  /**
@@ -267,6 +267,12 @@ export class WindowNode extends Scrollable(Node) {
267
267
  this._floorsDirty = true;
268
268
  this._floorsWidth = null;
269
269
  this._floorsStale = new Set();
270
+ // the nodes whose changes the next measurement owes, and whether one
271
+ // came with no node to name (`_floorsScope`)
272
+ this._floorsSources = new Set();
273
+ this._floorsUnscoped = false;
274
+ this._floorsScopeNow = null;
275
+ this._scopedFloorPasses = 0;
270
276
  // whether the first floors pass has run (`collectFloorStale`'s sweep)
271
277
  this._floorsSwept = false;
272
278
  this._floorsMeasured = 0;
package/src/paintcache.js CHANGED
@@ -83,19 +83,26 @@ const MAX_ITEM_PIXELS = 256 * 256;
83
83
  * grow this forever; the cost of forgetting is one extra live paint. */
84
84
  const MAX_PENDING = 4096;
85
85
 
86
- /** Whether a context is drawing 1:1 into device space.
87
- *
88
- * A cached surface is pixels at a fixed size and orientation. Under a scaled
89
- * or rotated CTM, compositing it would *resample* those pixels where a live
90
- * paint would re-rasterize at the right size, so the cache has to stand
91
- * aside. react-x11 only ever translates during paint, and only inside
92
- * `<canvas>`, so this is insurance rather than a live case.
86
+ /**
87
+ * Whether `ctx` draws on the device's pixel grid, wherever its origin is: a
88
+ * surface rendered at device resolution composites through it pixel for
89
+ * pixel. An integer translation keeps that — and it is how an overlay pane
90
+ * over a `<glarea>` draws, from the window's coordinates into its layer's
91
+ * (src/win32/overlay.js) — so declining it painted every cached drawing on a
92
+ * surface's children live, every time a pane repainted: every card of a
93
+ * graph, on every step of a pan. A scale, a rotation or a fraction of a
94
+ * pixel does not, and paints live.
93
95
  */
94
96
  function isDeviceSpace(ctx) {
95
97
  const m = ctx.getTransform?.();
96
98
  if (!m) return true;
97
99
  return (
98
- m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0
100
+ m.a === 1 &&
101
+ m.b === 0 &&
102
+ m.c === 0 &&
103
+ m.d === 1 &&
104
+ Number.isInteger(m.e) &&
105
+ Number.isInteger(m.f)
99
106
  );
100
107
  }
101
108