@celestia-island/hikari 0.43.9 → 0.43.10
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
|
@@ -268,6 +268,122 @@ function scrollerStrip(
|
|
|
268
268
|
return { frame, items, scene };
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/** A strip a BROWSER could have laid out: every element reports both of the
|
|
272
|
+
* readings a real engine keeps apart — the box it is DRAWN in
|
|
273
|
+
* (`getBoundingClientRect`, which every transform moves) and the box it is
|
|
274
|
+
* LAID OUT in (`offsetLeft`/`offsetTop`/`offsetWidth`/`offsetHeight` along
|
|
275
|
+
* an `offsetParent` chain, which no transform touches). One model drives
|
|
276
|
+
* both, exactly as a browser would, so a test can re-lay the strip out (a
|
|
277
|
+
* reorder, an insertion, a font change) or paint a transform on it and see
|
|
278
|
+
* which reading the resolution followed. */
|
|
279
|
+
function laidStrip(
|
|
280
|
+
layout: Box[],
|
|
281
|
+
frameLayout: { left: number; top: number; w: number; h: number },
|
|
282
|
+
frameDrawn: Box,
|
|
283
|
+
scene: {
|
|
284
|
+
scale?: number;
|
|
285
|
+
scroll?: number;
|
|
286
|
+
border?: { left: number; top: number };
|
|
287
|
+
/** The frame is `position: static`, so it is nobody's `offsetParent`:
|
|
288
|
+
* the items' layout numbers are then measured from further up — the
|
|
289
|
+
* fact that decides whether the frame's border still has to be added. */
|
|
290
|
+
staticFrame?: boolean;
|
|
291
|
+
} = {},
|
|
292
|
+
): {
|
|
293
|
+
frame: HTMLElement;
|
|
294
|
+
items: HTMLElement[];
|
|
295
|
+
laid: Box[];
|
|
296
|
+
relayout: (next: Box[]) => void;
|
|
297
|
+
setFrameDrawn: (next: Box) => void;
|
|
298
|
+
} {
|
|
299
|
+
const frame = document.createElement("div");
|
|
300
|
+
document.body.appendChild(frame);
|
|
301
|
+
const live = layout.map((box) => ({ ...box }));
|
|
302
|
+
const pins: Array<() => void> = [];
|
|
303
|
+
const border = scene.border ?? { left: 0, top: 0 };
|
|
304
|
+
const sceneOf = () => ({ scale: scene.scale ?? 1, scroll: scene.scroll ?? 0 });
|
|
305
|
+
frame.style.borderLeftWidth = `${border.left}px`;
|
|
306
|
+
frame.style.borderTopWidth = `${border.top}px`;
|
|
307
|
+
|
|
308
|
+
/** The box a browser draws for a laid-out box inside this frame. */
|
|
309
|
+
const drawn = (box: Box): Box => {
|
|
310
|
+
const { scale, scroll } = sceneOf();
|
|
311
|
+
const left = frameDrawn.left + (box.left - frameLayout.left - scroll) * scale;
|
|
312
|
+
const top = frameDrawn.top + (box.top - frameLayout.top) * scale;
|
|
313
|
+
return {
|
|
314
|
+
left,
|
|
315
|
+
right: left + (box.right - box.left) * scale,
|
|
316
|
+
top,
|
|
317
|
+
bottom: top + (box.bottom - box.top) * scale,
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
let frameDrawnBox = frameDrawn;
|
|
322
|
+
Object.defineProperty(frame, "getBoundingClientRect", {
|
|
323
|
+
configurable: true,
|
|
324
|
+
value: () => asRect(frameDrawnBox),
|
|
325
|
+
});
|
|
326
|
+
Object.defineProperty(frame, "offsetWidth", { configurable: true, get: () => frameLayout.w });
|
|
327
|
+
Object.defineProperty(frame, "offsetHeight", { configurable: true, get: () => frameLayout.h });
|
|
328
|
+
Object.defineProperty(frame, "offsetParent", { configurable: true, get: () => null });
|
|
329
|
+
Object.defineProperty(frame, "offsetLeft", { configurable: true, get: () => frameLayout.left });
|
|
330
|
+
Object.defineProperty(frame, "offsetTop", { configurable: true, get: () => frameLayout.top });
|
|
331
|
+
Object.defineProperty(frame, "scrollLeft", { configurable: true, get: () => sceneOf().scroll });
|
|
332
|
+
Object.defineProperty(frame, "scrollTop", { configurable: true, get: () => 0 });
|
|
333
|
+
|
|
334
|
+
const items = live.map((box, i) => {
|
|
335
|
+
const el = document.createElement("div");
|
|
336
|
+
frame.appendChild(el);
|
|
337
|
+
Object.defineProperty(el, "offsetParent", {
|
|
338
|
+
configurable: true,
|
|
339
|
+
get: () => (scene.staticFrame ? null : frame),
|
|
340
|
+
});
|
|
341
|
+
// A positioned frame is the items' offsetParent, so their offsets are
|
|
342
|
+
// measured from its PADDING edge; a static one is skipped and they are
|
|
343
|
+
// measured from the same place as the frame itself.
|
|
344
|
+
Object.defineProperty(el, "offsetLeft", {
|
|
345
|
+
configurable: true,
|
|
346
|
+
get: () =>
|
|
347
|
+
scene.staticFrame ? live[i].left : live[i].left - frameLayout.left - border.left,
|
|
348
|
+
});
|
|
349
|
+
Object.defineProperty(el, "offsetTop", {
|
|
350
|
+
configurable: true,
|
|
351
|
+
get: () => (scene.staticFrame ? live[i].top : live[i].top - frameLayout.top - border.top),
|
|
352
|
+
});
|
|
353
|
+
Object.defineProperty(el, "offsetWidth", {
|
|
354
|
+
configurable: true,
|
|
355
|
+
get: () => live[i].right - live[i].left,
|
|
356
|
+
});
|
|
357
|
+
Object.defineProperty(el, "offsetHeight", {
|
|
358
|
+
configurable: true,
|
|
359
|
+
get: () => live[i].bottom - live[i].top,
|
|
360
|
+
});
|
|
361
|
+
const pin = () =>
|
|
362
|
+
Object.defineProperty(el, "getBoundingClientRect", {
|
|
363
|
+
configurable: true,
|
|
364
|
+
value: () => asRect(drawn(live[i])),
|
|
365
|
+
});
|
|
366
|
+
pin();
|
|
367
|
+
pins.push(pin);
|
|
368
|
+
return el;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
frame,
|
|
373
|
+
items,
|
|
374
|
+
laid: live,
|
|
375
|
+
relayout: (next: Box[]) => {
|
|
376
|
+
next.forEach((box, i) => {
|
|
377
|
+
live[i] = { ...box };
|
|
378
|
+
});
|
|
379
|
+
pins.forEach((pin) => pin());
|
|
380
|
+
},
|
|
381
|
+
setFrameDrawn: (next: Box) => {
|
|
382
|
+
frameDrawnBox = next;
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
271
387
|
const scopes: EffectScope[] = [];
|
|
272
388
|
|
|
273
389
|
/** Mount the composable inside a real effect scope (the component case). */
|
|
@@ -971,6 +1087,319 @@ describe("usePointerReorder", () => {
|
|
|
971
1087
|
expect(drops, "the drop is reported in the indices the strip has now").toEqual([[1, 2]]);
|
|
972
1088
|
});
|
|
973
1089
|
|
|
1090
|
+
it("resolves against where the strip has RE-LAID the held item out", () => {
|
|
1091
|
+
// A font, a zoom or an insertion around the strip re-lays it out under a
|
|
1092
|
+
// live drag, so the item the pointer is holding is no longer where the
|
|
1093
|
+
// press found it. The snapshot describes the old place; the layout
|
|
1094
|
+
// numbers describe the new one, and the resolution has to follow the
|
|
1095
|
+
// strip the user is looking at. The chips here shrink mid-drag, which
|
|
1096
|
+
// brings the held chip's midpoint back behind the pointer: the walk that
|
|
1097
|
+
// the press-time midpoint stopped short of now runs past the strip.
|
|
1098
|
+
const laid: Box[] = [
|
|
1099
|
+
{ left: 0, right: 100, top: 0, bottom: 40 },
|
|
1100
|
+
{ left: 100, right: 200, top: 0, bottom: 40 },
|
|
1101
|
+
{ left: 200, right: 300, top: 0, bottom: 40 },
|
|
1102
|
+
];
|
|
1103
|
+
const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, { left: 0, right: 300, top: 0, bottom: 40 });
|
|
1104
|
+
const { handle, drops } = harness("x", strip.items);
|
|
1105
|
+
|
|
1106
|
+
press(handle, strip.items, 1, { x: 150, y: 20 });
|
|
1107
|
+
move(140, 20);
|
|
1108
|
+
expect(handle.dragging.value).toBe(true);
|
|
1109
|
+
expect(handle.dragOver.value, "the press-time midpoint is still ahead of the pointer").toBe(1);
|
|
1110
|
+
|
|
1111
|
+
// The strip is re-laid out under the drag: every chip, the held one
|
|
1112
|
+
// included, is drawn at its new place.
|
|
1113
|
+
strip.relayout([
|
|
1114
|
+
{ left: 0, right: 50, top: 0, bottom: 40 },
|
|
1115
|
+
{ left: 50, right: 100, top: 0, bottom: 40 },
|
|
1116
|
+
{ left: 100, right: 150, top: 0, bottom: 40 },
|
|
1117
|
+
]);
|
|
1118
|
+
move(140, 20);
|
|
1119
|
+
expect(handle.dragOver.value, "the pointer is past every chip of the new strip").toBe(2);
|
|
1120
|
+
up(140, 20);
|
|
1121
|
+
expect(drops).toEqual([[1, 2]]);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it("ignores a transform painted on the held item while it follows the layout", () => {
|
|
1125
|
+
// The whole reason the snapshot exists: a transform on the item — the
|
|
1126
|
+
// drag's own lift, a host animation, a zoomed wrapper — is painted, not
|
|
1127
|
+
// laid out, and must not move the line or the midpoint the resolution
|
|
1128
|
+
// reads. The layout numbers are blind to it, so following the layout
|
|
1129
|
+
// costs nothing here: the same gesture resolves the same way with the
|
|
1130
|
+
// held chip drawn twice as large and shifted.
|
|
1131
|
+
const laid: Box[] = [
|
|
1132
|
+
{ left: 0, right: 100, top: 0, bottom: 40 },
|
|
1133
|
+
{ left: 100, right: 200, top: 0, bottom: 40 },
|
|
1134
|
+
{ left: 200, right: 300, top: 0, bottom: 40 },
|
|
1135
|
+
];
|
|
1136
|
+
const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, { left: 0, right: 300, top: 0, bottom: 40 });
|
|
1137
|
+
const { handle, drops } = harness("x", strip.items);
|
|
1138
|
+
|
|
1139
|
+
press(handle, strip.items, 0, { x: 10, y: 20 });
|
|
1140
|
+
move(150, 20);
|
|
1141
|
+
expect(handle.dragOver.value).toBe(1);
|
|
1142
|
+
|
|
1143
|
+
// A host transform lands on the held chip: an edge-anchored cross-axis
|
|
1144
|
+
// scale, plus a hard translation that carries its drawn box onto the line
|
|
1145
|
+
// below and its drawn midpoint far past where it is laid out — the class
|
|
1146
|
+
// of transform an earlier wave recorded as unfixable while the drawn box
|
|
1147
|
+
// was what got measured. The pointer sits inside the moved band (where
|
|
1148
|
+
// measuring the drawn box makes the held chip the only candidate line, so
|
|
1149
|
+
// the walk stops on its own translated midpoint) and past the second
|
|
1150
|
+
// chip's midpoint, which is where the layout answers "land after the
|
|
1151
|
+
// second chip".
|
|
1152
|
+
reshape(strip.items[0], { left: 100, right: 300, top: 40, bottom: 96 });
|
|
1153
|
+
move(180, 45);
|
|
1154
|
+
expect(handle.dragOver.value, "180 is past the second chip's midpoint of 150").toBe(1);
|
|
1155
|
+
up(180, 45);
|
|
1156
|
+
expect(drops).toEqual([[0, 1]]);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it("carries the derived geometry through a frame that scrolls", () => {
|
|
1160
|
+
// The derivation reads a LAYOUT position and maps it through the box the
|
|
1161
|
+
// frame is drawn in, so the frame's own scroll (which slides the content
|
|
1162
|
+
// inside that box) has to be taken off in the same units. The strip is
|
|
1163
|
+
// scrolled mid-drag; the pointer stays where it is, over a different
|
|
1164
|
+
// chip of the content.
|
|
1165
|
+
const laid: Box[] = [
|
|
1166
|
+
{ left: 0, right: 100, top: 0, bottom: 40 },
|
|
1167
|
+
{ left: 100, right: 200, top: 0, bottom: 40 },
|
|
1168
|
+
{ left: 200, right: 300, top: 0, bottom: 40 },
|
|
1169
|
+
{ left: 300, right: 400, top: 0, bottom: 40 },
|
|
1170
|
+
];
|
|
1171
|
+
const scene = { scale: 1, scroll: 0 };
|
|
1172
|
+
const strip = laidStrip(
|
|
1173
|
+
laid,
|
|
1174
|
+
{ left: 0, top: 0, w: 400, h: 40 },
|
|
1175
|
+
{ left: 0, right: 400, top: 0, bottom: 40 },
|
|
1176
|
+
scene,
|
|
1177
|
+
);
|
|
1178
|
+
const { handle, drops } = harness("x", strip.items);
|
|
1179
|
+
|
|
1180
|
+
press(handle, strip.items, 0, { x: 10, y: 20 });
|
|
1181
|
+
move(40, 20);
|
|
1182
|
+
expect(handle.dragOver.value, "40 is still short of the first chip's midpoint of 50").toBe(0);
|
|
1183
|
+
|
|
1184
|
+
scene.scroll = 120; // the frame scrolls the strip under the drag
|
|
1185
|
+
strip.relayout(laid); // re-pin the drawn boxes at the new scroll
|
|
1186
|
+
move(40, 20);
|
|
1187
|
+
expect(handle.dragOver.value, "the chips moved back under the pointer with the content").toBe(1);
|
|
1188
|
+
up(40, 20);
|
|
1189
|
+
expect(drops).toEqual([[0, 1]]);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("reads the frame's border the way the frame hands its layout numbers over", () => {
|
|
1193
|
+
// The items' layout numbers are measured from their offsetParent's
|
|
1194
|
+
// PADDING edge — so when the frame is that offsetParent its border has to
|
|
1195
|
+
// be added back, and when the frame is `position: static` (nobody's
|
|
1196
|
+
// offsetParent) the same numbers are already measured from its BORDER
|
|
1197
|
+
// edge and adding it again would count it twice. Both shapes here are
|
|
1198
|
+
// laid out identically and drawn identically: the resolution has to
|
|
1199
|
+
// answer the same for both, and it has to keep the border's own scale.
|
|
1200
|
+
const layout: Box[] = [
|
|
1201
|
+
{ left: 20, right: 60, top: 20, bottom: 60 },
|
|
1202
|
+
{ left: 80, right: 120, top: 20, bottom: 60 },
|
|
1203
|
+
{ left: 140, right: 180, top: 20, bottom: 60 },
|
|
1204
|
+
{ left: 200, right: 240, top: 20, bottom: 60 },
|
|
1205
|
+
];
|
|
1206
|
+
const frameLayout = { left: 0, top: 0, w: 260, h: 80 };
|
|
1207
|
+
const frameDrawn: Box = { left: 0, right: 260, top: 0, bottom: 80 };
|
|
1208
|
+
const border = { left: 20, top: 20 };
|
|
1209
|
+
for (const staticFrame of [false, true]) {
|
|
1210
|
+
const strip = laidStrip(layout, frameLayout, frameDrawn, { border, staticFrame });
|
|
1211
|
+
const { handle, drops } = harness("x", strip.items);
|
|
1212
|
+
press(handle, strip.items, 0, { x: 30, y: 35 });
|
|
1213
|
+
move(102, 62);
|
|
1214
|
+
expect(handle.dragging.value, `staticFrame=${staticFrame}`).toBe(true);
|
|
1215
|
+
expect(handle.dragOver.value, `staticFrame=${staticFrame} lands after the first chip`).toBe(1);
|
|
1216
|
+
up(102, 62);
|
|
1217
|
+
expect(drops, `staticFrame=${staticFrame}`).toEqual([[0, 1]]);
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it("resolves exactly as the held item's own drawn box says, across frame shapes", () => {
|
|
1222
|
+
// The differential form of the test above: for every shape a frame can
|
|
1223
|
+
// have — positioned or static, with or without a border, drawn at its own
|
|
1224
|
+
// size or scaled, scrolled or not, one line or two — the resolution has
|
|
1225
|
+
// to answer what the held item's TRUE layout box answers. The sweep walks
|
|
1226
|
+
// the pointer over each strip and compares against `indexAt` handed that
|
|
1227
|
+
// true box, so any place the derived geometry drifts shows up wherever
|
|
1228
|
+
// the drift can be observed, without hand-picking pointers.
|
|
1229
|
+
const single: Box[] = [
|
|
1230
|
+
{ left: 12, right: 52, top: 12, bottom: 52 },
|
|
1231
|
+
{ left: 72, right: 112, top: 12, bottom: 52 },
|
|
1232
|
+
{ left: 132, right: 172, top: 12, bottom: 52 },
|
|
1233
|
+
];
|
|
1234
|
+
const wrapped: Box[] = [
|
|
1235
|
+
{ left: 12, right: 52, top: 12, bottom: 52 },
|
|
1236
|
+
{ left: 72, right: 112, top: 12, bottom: 52 },
|
|
1237
|
+
{ left: 12, right: 52, top: 72, bottom: 112 },
|
|
1238
|
+
];
|
|
1239
|
+
const scenes = [
|
|
1240
|
+
{ layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: {} },
|
|
1241
|
+
{ layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 5, right: 235, top: 7, bottom: 112 }, scene: { scale: 1.5 } },
|
|
1242
|
+
{ layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 5, right: 235, top: 7, bottom: 112 }, scene: { scale: 1.5, border: { left: 12, top: 12 } } },
|
|
1243
|
+
{ layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 5, right: 235, top: 7, bottom: 112 }, scene: { scale: 1.5, border: { left: 12, top: 12 }, staticFrame: true } },
|
|
1244
|
+
{ layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: { border: { left: 12, top: 12 } } },
|
|
1245
|
+
{ layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: { border: { left: 12, top: 12 }, staticFrame: true } },
|
|
1246
|
+
{ layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: { scroll: 25 } },
|
|
1247
|
+
{ layout: wrapped, frame: { left: 0, top: 0, w: 200, h: 130 }, drawn: { left: 0, right: 200, top: 0, bottom: 130 }, scene: {} },
|
|
1248
|
+
{ layout: wrapped, frame: { left: 0, top: 0, w: 200, h: 130 }, drawn: { left: 0, right: 300, top: 0, bottom: 195 }, scene: { scale: 1.5, border: { left: 8, top: 8 } } },
|
|
1249
|
+
];
|
|
1250
|
+
let compared = 0;
|
|
1251
|
+
for (const [index, entry] of scenes.entries()) {
|
|
1252
|
+
const strip = laidStrip(entry.layout, entry.frame, entry.drawn, entry.scene);
|
|
1253
|
+
const rects = strip.items.map((el) => el.getBoundingClientRect());
|
|
1254
|
+
// The line filter's tolerance is half a pixel wide, so the sweep has to
|
|
1255
|
+
// land ON the band edges, not only near them.
|
|
1256
|
+
const crosses = new Set<number>();
|
|
1257
|
+
for (let cross = -10; cross <= 140; cross += 5) crosses.add(cross);
|
|
1258
|
+
for (const rect of rects) {
|
|
1259
|
+
for (const edge of [rect.top, rect.bottom]) {
|
|
1260
|
+
for (const delta of [-2, -1, -0.5, 0, 0.5, 1, 2]) crosses.add(Number((edge + delta).toFixed(2)));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
for (let from = 0; from < strip.items.length; from += 1) {
|
|
1264
|
+
// The held item's own layout box, drawn — the geometry the resolution
|
|
1265
|
+
// is supposed to be reading.
|
|
1266
|
+
const truth = strip.items[from].getBoundingClientRect();
|
|
1267
|
+
const trueOrigin = {
|
|
1268
|
+
band: { lo: truth.top, hi: truth.bottom },
|
|
1269
|
+
mid: truth.left + truth.width / 2,
|
|
1270
|
+
};
|
|
1271
|
+
for (let main = -20; main <= 240; main += 20) {
|
|
1272
|
+
for (const cross of crosses) {
|
|
1273
|
+
const { handle } = harness("x", strip.items);
|
|
1274
|
+
press(handle, strip.items, from, { x: main - 40, y: cross });
|
|
1275
|
+
move(main, cross);
|
|
1276
|
+
const got = handle.dragOver.value;
|
|
1277
|
+
const want = indexAt(rects, "x", main, cross, from, trueOrigin);
|
|
1278
|
+
expect(got, `scene ${index} from ${from} at (${main}, ${cross})`).toBe(want);
|
|
1279
|
+
up(main, cross);
|
|
1280
|
+
compared += 1;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
expect(compared, "the sweep really ran").toBeGreaterThan(1000);
|
|
1286
|
+
}, 20_000);
|
|
1287
|
+
|
|
1288
|
+
it("keeps the carried snapshot when only half of the box can be read", () => {
|
|
1289
|
+
// A box that reports one side and not the other cannot place anything: a
|
|
1290
|
+
// zero width would put the midpoint on the item's own edge. The gesture
|
|
1291
|
+
// here is one the derivation WOULD answer differently — the strip is
|
|
1292
|
+
// re-laid out under the drag — so the assertion is the carried
|
|
1293
|
+
// snapshot's answer, which is what a strip with no layout numbers at all
|
|
1294
|
+
// gets.
|
|
1295
|
+
const laid: Box[] = [
|
|
1296
|
+
{ left: 0, right: 100, top: 0, bottom: 40 },
|
|
1297
|
+
{ left: 100, right: 200, top: 0, bottom: 40 },
|
|
1298
|
+
{ left: 200, right: 300, top: 0, bottom: 40 },
|
|
1299
|
+
];
|
|
1300
|
+
const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, {
|
|
1301
|
+
left: 0,
|
|
1302
|
+
right: 300,
|
|
1303
|
+
top: 0,
|
|
1304
|
+
bottom: 40,
|
|
1305
|
+
});
|
|
1306
|
+
Object.defineProperty(strip.items[1], "offsetWidth", { configurable: true, get: () => 0 });
|
|
1307
|
+
const { handle, drops } = harness("x", strip.items);
|
|
1308
|
+
|
|
1309
|
+
press(handle, strip.items, 1, { x: 150, y: 20 });
|
|
1310
|
+
move(140, 20);
|
|
1311
|
+
expect(handle.dragging.value).toBe(true);
|
|
1312
|
+
expect(handle.dragOver.value, "the press-time midpoint still stops the walk").toBe(1);
|
|
1313
|
+
|
|
1314
|
+
strip.relayout([
|
|
1315
|
+
{ left: 0, right: 50, top: 0, bottom: 40 },
|
|
1316
|
+
{ left: 50, right: 100, top: 0, bottom: 40 },
|
|
1317
|
+
{ left: 100, right: 150, top: 0, bottom: 40 },
|
|
1318
|
+
]);
|
|
1319
|
+
move(140, 20);
|
|
1320
|
+
expect(handle.dragOver.value, "half a box is no box: the snapshot still resolves").toBe(1);
|
|
1321
|
+
up(140, 20);
|
|
1322
|
+
expect(drops).toEqual([]);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
it("keeps the carried snapshot when the layout chain cannot be added up", () => {
|
|
1326
|
+
// A chain that runs through something with no offsets of its own — an
|
|
1327
|
+
// element inside `<svg>` reports no `offsetLeft` at all — sums to NaN,
|
|
1328
|
+
// and a NaN origin answers every comparison the same way: it would place
|
|
1329
|
+
// the item at the end of the strip. The chain is checked, and the gesture
|
|
1330
|
+
// falls back to the snapshot.
|
|
1331
|
+
const laid: Box[] = [
|
|
1332
|
+
{ left: 0, right: 100, top: 0, bottom: 40 },
|
|
1333
|
+
{ left: 100, right: 200, top: 0, bottom: 40 },
|
|
1334
|
+
{ left: 200, right: 300, top: 0, bottom: 40 },
|
|
1335
|
+
];
|
|
1336
|
+
const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, {
|
|
1337
|
+
left: 0,
|
|
1338
|
+
right: 300,
|
|
1339
|
+
top: 0,
|
|
1340
|
+
bottom: 40,
|
|
1341
|
+
});
|
|
1342
|
+
const { handle, drops } = harness("x", strip.items);
|
|
1343
|
+
|
|
1344
|
+
press(handle, strip.items, 1, { x: 150, y: 20 });
|
|
1345
|
+
move(140, 20);
|
|
1346
|
+
expect(handle.dragOver.value).toBe(1);
|
|
1347
|
+
|
|
1348
|
+
// The held chip's chain now passes through an element with no offsets.
|
|
1349
|
+
const svgish = document.createElement("div");
|
|
1350
|
+
Object.defineProperty(svgish, "offsetLeft", { configurable: true, get: () => undefined });
|
|
1351
|
+
Object.defineProperty(svgish, "offsetTop", { configurable: true, get: () => undefined });
|
|
1352
|
+
Object.defineProperty(svgish, "offsetParent", {
|
|
1353
|
+
configurable: true,
|
|
1354
|
+
get: () => strip.frame,
|
|
1355
|
+
});
|
|
1356
|
+
Object.defineProperty(strip.items[1], "offsetParent", {
|
|
1357
|
+
configurable: true,
|
|
1358
|
+
get: () => svgish,
|
|
1359
|
+
});
|
|
1360
|
+
strip.relayout([
|
|
1361
|
+
{ left: 0, right: 50, top: 0, bottom: 40 },
|
|
1362
|
+
{ left: 50, right: 100, top: 0, bottom: 40 },
|
|
1363
|
+
{ left: 100, right: 150, top: 0, bottom: 40 },
|
|
1364
|
+
]);
|
|
1365
|
+
move(140, 20);
|
|
1366
|
+
expect(handle.dragOver.value, "an unaddable chain keeps the snapshot's answer").toBe(1);
|
|
1367
|
+
up(140, 20);
|
|
1368
|
+
expect(drops).toEqual([]);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it("keeps the carried snapshot when the held item collapses", () => {
|
|
1372
|
+
// A held chip re-laid out to no height has no line to be on: its derived
|
|
1373
|
+
// band would be a point and the line filter would drop it, which is a
|
|
1374
|
+
// resolution the layout cannot justify. The fallback answers instead.
|
|
1375
|
+
const laid: Box[] = [
|
|
1376
|
+
{ left: 0, right: 100, top: 0, bottom: 40 },
|
|
1377
|
+
{ left: 100, right: 200, top: 0, bottom: 40 },
|
|
1378
|
+
{ left: 200, right: 300, top: 0, bottom: 40 },
|
|
1379
|
+
];
|
|
1380
|
+
const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, {
|
|
1381
|
+
left: 0,
|
|
1382
|
+
right: 300,
|
|
1383
|
+
top: 0,
|
|
1384
|
+
bottom: 40,
|
|
1385
|
+
});
|
|
1386
|
+
const { handle, drops } = harness("x", strip.items);
|
|
1387
|
+
|
|
1388
|
+
press(handle, strip.items, 1, { x: 150, y: 20 });
|
|
1389
|
+
move(140, 20);
|
|
1390
|
+
expect(handle.dragOver.value).toBe(1);
|
|
1391
|
+
|
|
1392
|
+
strip.relayout([
|
|
1393
|
+
{ left: 0, right: 50, top: 0, bottom: 40 },
|
|
1394
|
+
{ left: 50, right: 100, top: 20, bottom: 20 },
|
|
1395
|
+
{ left: 100, right: 150, top: 0, bottom: 40 },
|
|
1396
|
+
]);
|
|
1397
|
+
move(140, 20);
|
|
1398
|
+
expect(handle.dragOver.value, "a chip with no height has no line to stand on").toBe(1);
|
|
1399
|
+
up(140, 20);
|
|
1400
|
+
expect(drops).toEqual([]);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
974
1403
|
it("ends the gesture the moment the strip empties", () => {
|
|
975
1404
|
// Nothing left to resolve against is the same answer as the element being
|
|
976
1405
|
// gone: the gesture is over then, not at the release — a drag left
|
|
@@ -360,6 +360,78 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
|
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/** Where an element's border box sits in a space its ancestors share: the
|
|
364
|
+
* one number CSS transforms do not touch. `offsetLeft`/`offsetTop` are
|
|
365
|
+
* LAYOUT positions — the drag's own lift, a host `scale()`, a FLIP
|
|
366
|
+
* animation and a transition mid-flight all leave them exactly where they
|
|
367
|
+
* were — so walking the offsetParent chain of the item and of its frame
|
|
368
|
+
* and subtracting gives the item's current place inside the frame however
|
|
369
|
+
* it is drawn. Both sides are walked to the same root, so a frame that is
|
|
370
|
+
* not itself an offsetParent (a static frame) cancels exactly.
|
|
371
|
+
*
|
|
372
|
+
* `through` reports whether the walk passed THROUGH the frame, which is
|
|
373
|
+
* what decides how the two differ: an offset is measured from the
|
|
374
|
+
* offsetParent's PADDING edge, so a chain that runs through the frame
|
|
375
|
+
* leaves the difference measured from the frame's padding box (its border
|
|
376
|
+
* has to be added back), while a chain that skips it — a frame that is
|
|
377
|
+
* `position: static` is nobody's offsetParent — leaves the difference
|
|
378
|
+
* measured from the frame's BORDER box, where adding the border again
|
|
379
|
+
* would double-count it.
|
|
380
|
+
*
|
|
381
|
+
* The chain must be walked LIVE: a transform (or even
|
|
382
|
+
* `will-change: transform`) on an ancestor rebinds `offsetParent`. */
|
|
383
|
+
function layoutOffset(
|
|
384
|
+
el: HTMLElement,
|
|
385
|
+
frame: HTMLElement | null,
|
|
386
|
+
): { x: number; y: number; through: boolean } {
|
|
387
|
+
let x = 0;
|
|
388
|
+
let y = 0;
|
|
389
|
+
let through = false;
|
|
390
|
+
for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) {
|
|
391
|
+
x += node.offsetLeft;
|
|
392
|
+
y += node.offsetTop;
|
|
393
|
+
if (node === frame) through = true;
|
|
394
|
+
}
|
|
395
|
+
return { x, y, through };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The pressed item's layout geometry read off the LIVE strip rather than
|
|
399
|
+
* carried from the press — the answer to "where is the item I am holding
|
|
400
|
+
* NOW?", which is what changes when the list reorders, an item is added or
|
|
401
|
+
* removed around it, a font or a zoom changes, or the strip re-wraps.
|
|
402
|
+
* `null` when there is no box to read (no layout engine at all, a detached
|
|
403
|
+
* element, a collapsed or partially-readable one), which keeps the caller
|
|
404
|
+
* on the carried snapshot. */
|
|
405
|
+
function derivedOrigin(): PointerReorderOrigin | null {
|
|
406
|
+
if (!pressedItem || !pressedFrame || !pressedFrame.isConnected) return null;
|
|
407
|
+
const box = frameBox(pressedFrame);
|
|
408
|
+
const itemW = pressedItem.offsetWidth;
|
|
409
|
+
const itemH = pressedItem.offsetHeight;
|
|
410
|
+
if (!(itemW > 0) || !(itemH > 0)) return null;
|
|
411
|
+
if (!(box.w > 0) || !(box.h > 0)) return null;
|
|
412
|
+
const at = layoutOffset(pressedItem, pressedFrame);
|
|
413
|
+
const of = layoutOffset(pressedFrame, null);
|
|
414
|
+
// A chain that runs through something without layout offsets of its own
|
|
415
|
+
// (an SVG ancestor, a foreignObject boundary) sums to NaN rather than
|
|
416
|
+
// failing: nothing can be placed from it, so the snapshot keeps the call.
|
|
417
|
+
if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return null;
|
|
418
|
+
if (!Number.isFinite(of.x) || !Number.isFinite(of.y)) return null;
|
|
419
|
+
const scaleX = box.laidW > 0 ? box.w / box.laidW : 1;
|
|
420
|
+
const scaleY = box.laidH > 0 ? box.h / box.laidH : 1;
|
|
421
|
+
/** The frame's own border, in the units it is DRAWN at — and only when
|
|
422
|
+
* the walk into the frame came through it (`through`). */
|
|
423
|
+
const style = getComputedStyle(pressedFrame);
|
|
424
|
+
const borderLeft = at.through ? (parseFloat(style.borderLeftWidth) || 0) * scaleX : 0;
|
|
425
|
+
const borderTop = at.through ? (parseFloat(style.borderTopWidth) || 0) * scaleY : 0;
|
|
426
|
+
const left = box.x + borderLeft + (at.x - of.x - box.scrollX) * scaleX;
|
|
427
|
+
const top = box.y + borderTop + (at.y - of.y - box.scrollY) * scaleY;
|
|
428
|
+
const width = itemW * scaleX;
|
|
429
|
+
const height = itemH * scaleY;
|
|
430
|
+
return axis === "x"
|
|
431
|
+
? { band: { lo: top, hi: top + height }, mid: left + width / 2 }
|
|
432
|
+
: { band: { lo: left, hi: left + width }, mid: top + height / 2 };
|
|
433
|
+
}
|
|
434
|
+
|
|
363
435
|
/** The pressed item's layout geometry for `indexAt`, placed where the
|
|
364
436
|
* frame puts it NOW. The snapshot is in VIEWPORT coordinates, so anything
|
|
365
437
|
* that moves the frame under it — the auto-scroll this composable drives,
|
|
@@ -385,6 +457,8 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
|
|
|
385
457
|
* A frame with no box to read any of this from falls back to the plain
|
|
386
458
|
* displacement. */
|
|
387
459
|
function layoutOrigin(): PointerReorderOrigin | null {
|
|
460
|
+
const live = derivedOrigin();
|
|
461
|
+
if (live) return live;
|
|
388
462
|
if (!pressedBand || !pressedFrameBox) return null;
|
|
389
463
|
const at = pressedFrameBox;
|
|
390
464
|
const now = pressedFrame && pressedFrame.isConnected ? frameBox(pressedFrame) : at;
|