@lexical/table 0.47.0 → 0.47.1-nightly.20260713.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.
@@ -124,6 +124,34 @@ const isPointerDownOnEvent = (event: PointerEvent) => {
124
124
  return (event.buttons & 1) === 1;
125
125
  };
126
126
 
127
+ // Distance (px) from a scroll container edge at which drag auto-scroll kicks
128
+ // in, and the maximum scroll delta applied per animation frame.
129
+ const AUTO_SCROLL_EDGE_ZONE = 40;
130
+ const AUTO_SCROLL_MAX_STEP = 18;
131
+
132
+ // Given a pointer position and the start/end edges of a scroll container on one
133
+ // axis, return the signed per-frame scroll delta: negative near the start edge,
134
+ // positive near the end edge, 0 while outside both edge zones. The delta ramps
135
+ // up the deeper the pointer is into the zone (and is capped once it reaches or
136
+ // passes the edge).
137
+ function autoScrollStep(pos: number, start: number, end: number): number {
138
+ const speed = (depth: number) =>
139
+ Math.max(
140
+ 1,
141
+ Math.ceil(
142
+ (Math.min(AUTO_SCROLL_EDGE_ZONE, depth) / AUTO_SCROLL_EDGE_ZONE) *
143
+ AUTO_SCROLL_MAX_STEP,
144
+ ),
145
+ );
146
+ if (pos <= start + AUTO_SCROLL_EDGE_ZONE) {
147
+ return -speed(start + AUTO_SCROLL_EDGE_ZONE - pos);
148
+ }
149
+ if (pos >= end - AUTO_SCROLL_EDGE_ZONE) {
150
+ return speed(pos - (end - AUTO_SCROLL_EDGE_ZONE));
151
+ }
152
+ return 0;
153
+ }
154
+
127
155
  export function isHTMLTableElement(el: unknown): el is HTMLTableElement {
128
156
  return isHTMLElement(el) && el.nodeName === 'TABLE';
129
157
  }
@@ -274,23 +302,217 @@ function $handleTableClick(
274
302
  });
275
303
  }
276
304
 
277
- const onPointerUp = () => {
305
+ let lastClientX = event.clientX;
306
+ let lastClientY = event.clientY;
307
+ let autoScrollRafId: number | null = null;
308
+
309
+ const stopSelecting = () => {
278
310
  tableObserver.isSelecting = false;
311
+ if (autoScrollRafId !== null) {
312
+ editorWindow.cancelAnimationFrame(autoScrollRafId);
313
+ autoScrollRafId = null;
314
+ }
279
315
  editorWindow.removeEventListener('pointerup', onPointerUp);
280
316
  editorWindow.removeEventListener('pointermove', onPointerMove);
281
317
  };
282
318
 
319
+ // Resolve the table cell under the given viewport coordinates via the
320
+ // table's own root so elementsFromPoint isn't retargeted; narrow with the
321
+ // type guards rather than casting so the detached-table case (Node) falls
322
+ // through to no hit-test.
323
+ const resolveFocusCellFromPoint = (
324
+ clientX: number,
325
+ clientY: number,
326
+ ): TableDOMCell | null => {
327
+ const tableRoot = tableElement.getRootNode();
328
+ if (!isDOMDocumentNode(tableRoot) && !isDOMShadowRoot(tableRoot)) {
329
+ return null;
330
+ }
331
+ for (const el of tableRoot.elementsFromPoint(clientX, clientY)) {
332
+ const cell = getDOMCellInTableFromTarget(tableElement, el);
333
+ if (cell) {
334
+ return cell;
335
+ }
336
+ }
337
+ return null;
338
+ };
339
+
340
+ const applyFocusCell = (focusCell: TableDOMCell, override: boolean) => {
341
+ // Fallback: set anchor if still missing (handles race conditions)
342
+ if (tableObserver.anchorCell === null) {
343
+ editor.update(() => {
344
+ tableObserver.$setAnchorCellForSelection(focusCell);
345
+ });
346
+ }
347
+ if (
348
+ tableObserver.focusCell === null ||
349
+ focusCell.elem !== tableObserver.focusCell.elem
350
+ ) {
351
+ tableObservers.setNextFocus({
352
+ focusCell,
353
+ override,
354
+ tableKey: tableObserver.tableNodeKey,
355
+ });
356
+ editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
357
+ }
358
+ };
359
+
360
+ // Walk up from the table to the nearest ancestor that can actually scroll
361
+ // on the requested axis (the scrollable-tables wrapper for 'x'). Returns
362
+ // null when none is found, in which case the caller may fall back to the
363
+ // window.
364
+ const findScrollContainer = (axis: 'x' | 'y'): HTMLElement | null => {
365
+ for (
366
+ let el: HTMLElement | null = tableElement.parentElement;
367
+ el;
368
+ el = el.parentElement
369
+ ) {
370
+ const canScroll =
371
+ axis === 'x'
372
+ ? el.scrollWidth > el.clientWidth
373
+ : el.scrollHeight > el.clientHeight;
374
+ if (canScroll) {
375
+ const style = editorWindow.getComputedStyle(el);
376
+ const overflow = axis === 'x' ? style.overflowX : style.overflowY;
377
+ if (overflow === 'auto' || overflow === 'scroll') {
378
+ return el;
379
+ }
380
+ }
381
+ }
382
+ return null;
383
+ };
384
+
385
+ // Scroll `container` (or the window when null) on `axis` if the pointer is
386
+ // within the edge zone. Returns whether it actually scrolled.
387
+ const scrollAxis = (
388
+ container: HTMLElement | null,
389
+ pos: number,
390
+ axis: 'x' | 'y',
391
+ ): boolean => {
392
+ let start: number;
393
+ let end: number;
394
+ if (container === null) {
395
+ start = 0;
396
+ end = axis === 'x' ? editorWindow.innerWidth : editorWindow.innerHeight;
397
+ } else {
398
+ const rect = container.getBoundingClientRect();
399
+ start = axis === 'x' ? rect.left : rect.top;
400
+ end = axis === 'x' ? rect.right : rect.bottom;
401
+ }
402
+ const step = autoScrollStep(pos, start, end);
403
+ if (step === 0) {
404
+ return false;
405
+ }
406
+ if (container === null) {
407
+ const before =
408
+ axis === 'x' ? editorWindow.scrollX : editorWindow.scrollY;
409
+ editorWindow.scrollBy(axis === 'x' ? step : 0, axis === 'x' ? 0 : step);
410
+ return (
411
+ (axis === 'x' ? editorWindow.scrollX : editorWindow.scrollY) !==
412
+ before
413
+ );
414
+ }
415
+ if (axis === 'x') {
416
+ const before = container.scrollLeft;
417
+ container.scrollLeft += step;
418
+ return container.scrollLeft !== before;
419
+ }
420
+ const before = container.scrollTop;
421
+ container.scrollTop += step;
422
+ return container.scrollTop !== before;
423
+ };
424
+
425
+ // Clamp the last pointer position into the visible bounds of the scroll
426
+ // container(s) so a pointer dragged past an edge still hit-tests onto the
427
+ // newly-revealed cell instead of empty space beyond the table.
428
+ const clampHitPoint = (
429
+ hContainer: HTMLElement | null,
430
+ vContainer: HTMLElement | null,
431
+ ): [number, number] => {
432
+ let x = lastClientX;
433
+ let y = lastClientY;
434
+ if (hContainer === null) {
435
+ x = Math.min(Math.max(x, 1), editorWindow.innerWidth - 1);
436
+ } else {
437
+ const rect = hContainer.getBoundingClientRect();
438
+ x = Math.min(Math.max(x, rect.left + 1), rect.right - 1);
439
+ }
440
+ if (vContainer === null) {
441
+ y = Math.min(Math.max(y, 1), editorWindow.innerHeight - 1);
442
+ } else {
443
+ const rect = vContainer.getBoundingClientRect();
444
+ y = Math.min(Math.max(y, rect.top + 1), rect.bottom - 1);
445
+ }
446
+ return [x, y];
447
+ };
448
+
449
+ const isNearScrollEdge = (): boolean => {
450
+ const hContainer = findScrollContainer('x');
451
+ if (hContainer !== null) {
452
+ const rect = hContainer.getBoundingClientRect();
453
+ if (autoScrollStep(lastClientX, rect.left, rect.right) !== 0) {
454
+ return true;
455
+ }
456
+ }
457
+ const vContainer = findScrollContainer('y');
458
+ const vStart =
459
+ vContainer === null ? 0 : vContainer.getBoundingClientRect().top;
460
+ const vEnd =
461
+ vContainer === null
462
+ ? editorWindow.innerHeight
463
+ : vContainer.getBoundingClientRect().bottom;
464
+ return autoScrollStep(lastClientY, vStart, vEnd) !== 0;
465
+ };
466
+
467
+ const tickAutoScroll = () => {
468
+ autoScrollRafId = null;
469
+ if (!tableObserver.isSelecting) {
470
+ return;
471
+ }
472
+ const hContainer = findScrollContainer('x');
473
+ const vContainer = findScrollContainer('y');
474
+ // Only the table's own wrapper scrolls horizontally; pages don't
475
+ // auto-scroll sideways. Vertically we fall back to the window.
476
+ const scrolledX =
477
+ hContainer !== null && scrollAxis(hContainer, lastClientX, 'x');
478
+ const scrolledY = scrollAxis(vContainer, lastClientY, 'y');
479
+ if (scrolledX || scrolledY) {
480
+ const [hitX, hitY] = clampHitPoint(hContainer, vContainer);
481
+ const focusCell = resolveFocusCellFromPoint(hitX, hitY);
482
+ if (focusCell) {
483
+ applyFocusCell(focusCell, false);
484
+ }
485
+ autoScrollRafId = editorWindow.requestAnimationFrame(tickAutoScroll);
486
+ }
487
+ };
488
+
489
+ const maybeStartAutoScroll = () => {
490
+ // Touch taps don't initiate table selection, so they shouldn't scroll.
491
+ if (
492
+ autoScrollRafId !== null ||
493
+ tableObserver.pointerType === 'touch' ||
494
+ !isNearScrollEdge()
495
+ ) {
496
+ return;
497
+ }
498
+ autoScrollRafId = editorWindow.requestAnimationFrame(tickAutoScroll);
499
+ };
500
+
501
+ const onPointerUp = () => {
502
+ stopSelecting();
503
+ };
504
+
283
505
  const onPointerMove = (moveEvent: PointerEvent) => {
284
506
  if (!isPointerDownOnEvent(moveEvent) && tableObserver.isSelecting) {
285
- tableObserver.isSelecting = false;
286
- editorWindow.removeEventListener('pointerup', onPointerUp);
287
- editorWindow.removeEventListener('pointermove', onPointerMove);
507
+ stopSelecting();
288
508
  return;
289
509
  }
290
510
  const moveTarget = getComposedEventTarget(moveEvent);
291
511
  if (!isDOMNode(moveTarget)) {
292
512
  return;
293
513
  }
514
+ lastClientX = moveEvent.clientX;
515
+ lastClientY = moveEvent.clientY;
294
516
  let focusCell: null | TableDOMCell = null;
295
517
  // In firefox the moveEvent.target may be captured so we must always
296
518
  // consult the coordinates #7245
@@ -298,43 +520,17 @@ function $handleTableClick(
298
520
  if (override) {
299
521
  focusCell = getDOMCellInTableFromTarget(tableElement, moveTarget);
300
522
  } else {
301
- // Resolve via the table's own root so elementsFromPoint isn't
302
- // retargeted; narrow with the type guards rather than casting so the
303
- // detached-table case (Node) falls through to no hit-test.
304
- const tableRoot = tableElement.getRootNode();
305
- if (!isDOMDocumentNode(tableRoot) && !isDOMShadowRoot(tableRoot)) {
306
- return;
307
- }
308
- for (const el of tableRoot.elementsFromPoint(
523
+ focusCell = resolveFocusCellFromPoint(
309
524
  moveEvent.clientX,
310
525
  moveEvent.clientY,
311
- )) {
312
- focusCell = getDOMCellInTableFromTarget(tableElement, el);
313
- if (focusCell) {
314
- break;
315
- }
316
- }
526
+ );
317
527
  }
318
528
  if (focusCell) {
319
- const anchorCell = focusCell;
320
- // Fallback: set anchor if still missing (handles race conditions)
321
- if (tableObserver.anchorCell === null) {
322
- editor.update(() => {
323
- tableObserver.$setAnchorCellForSelection(anchorCell);
324
- });
325
- }
326
- if (
327
- tableObserver.focusCell === null ||
328
- focusCell.elem !== tableObserver.focusCell.elem
329
- ) {
330
- tableObservers.setNextFocus({
331
- focusCell,
332
- override,
333
- tableKey: tableObserver.tableNodeKey,
334
- });
335
- editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
336
- }
529
+ applyFocusCell(focusCell, override);
337
530
  }
531
+ // Keep the selection reachable when dragging toward/past an edge of a
532
+ // scrollable table (#7153).
533
+ maybeStartAutoScroll();
338
534
  };
339
535
  editorWindow.addEventListener(
340
536
  'pointerup',