@dcg-overseas/number-line 0.1.30 → 1.0.0-beta.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/README.md CHANGED
@@ -210,11 +210,12 @@ function App() {
210
210
  | `canDelete` | `boolean` | 是否有可删除的选中项(笔迹或弧线) |
211
211
  | `canUndo` | `boolean` | 是否有可撤销的操作 |
212
212
  | `onTogglePen` | `() => void` | 切换画笔工具 |
213
- | `onToggleEraser` | `() => void` | 切换橡皮擦 |
214
- | `onDelete` | `() => void` | 删除当前选中 |
215
- | `onReset` | `() => void` | 清空所有笔迹与选中,并隐藏全部跳数弧线**及分组边界粗刻度**(重新设置 `groupSize/groupCount/min/max` 后恢复) |
213
+ | `onToggleEraser` | `() => void` | 切换橡皮擦 |
214
+ | `onDelete` | `() => void` | 删除当前选中 |
215
+ | `clearAll` | `() => void` | 可撤销地清空全部笔迹、弧线、选择和分组边界粗刻度;连续清空不会增加空历史项 |
216
+ | `onReset` | `() => void` | `clearAll` 的兼容别名,已弃用;新代码请使用 `clearAll` |
216
217
  | `onUndo` | `() => void` | 撤销上一步操作 |
217
- | `showAllArcs` | `() => void` | 重新显示全部跳数弧线**及分组边界粗刻度**(清空删除记录)。即使参数未变、或刚 `onReset` 隐藏后,也能强制把跳数显示出来,且不影响笔迹 |
218
+ | `showAllArcs` | `() => void` | 重新显示全部跳数弧线**及分组边界粗刻度**(清空删除记录)。即使参数未变、或刚 `clearAll` 隐藏后,也能强制把跳数显示出来,且不影响笔迹 |
218
219
 
219
220
  #### 缩放 API(仅 `enableZoom=true` 时有效)
220
221
  | 字段 | 类型 | 说明 |
@@ -234,7 +235,7 @@ function App() {
234
235
  import { NumberLineProvider, NumberLine, useNumberLineContext } from '@dcg-overseas/number-line'
235
236
 
236
237
  function Toolbar() {
237
- const { tool, canDelete, canUndo, onTogglePen, onToggleEraser, onDelete, onReset, onUndo } =
238
+ const { tool, canDelete, canUndo, onTogglePen, onToggleEraser, onDelete, clearAll, onUndo } =
238
239
  useNumberLineContext()
239
240
 
240
241
  return (
@@ -242,7 +243,7 @@ function Toolbar() {
242
243
  <button data-active={tool === 'pen'} onClick={onTogglePen}>✏️ 画笔</button>
243
244
  <button data-active={tool === 'eraser'} onClick={onToggleEraser}>🩹 擦除</button>
244
245
  <button disabled={!canDelete} onClick={onDelete}>🗑 删除</button>
245
- <button onClick={onReset}>♻️ 重置</button>
246
+ <button onClick={clearAll}>♻️ 全部清除</button>
246
247
  <button disabled={!canUndo} onClick={onUndo}>↶ 撤销</button>
247
248
  </div>
248
249
  )
@@ -304,12 +305,9 @@ import type {
304
305
 
305
306
  ### 操作级撤销
306
307
 
307
- 撤销栈记录的是「操作」而非「状态」(`HistoryOp`),分两类:
308
-
309
- - `add` 新增笔迹,撤销 = 移除指定 id
310
- - `delete` — 删除笔迹/弧线,撤销 = 恢复删除前完整快照 + 还原弧线 group index
311
-
312
- 这样即使一次 `delete` 同时删了多条笔迹和多条弧线,撤销也能精确还原。
308
+ 撤销栈记录每次操作发生前的完整状态快照,包括笔迹、已删除弧线、已选弧线和粗分组刻度状态。
309
+ 画笔、橡皮擦、删除选中及 `clearAll` 都复用同一恢复流程,因此一次清空或批量删除可以完整还原。
310
+ `min`、`max`、`groupSize`、`groupCount` `tickStep` 改变时会执行不可撤销初始化并清空历史,防止把上一道题的数据恢复到新坐标范围。
313
311
 
314
312
  ---
315
313
 
package/dist/index.cjs CHANGED
@@ -5,27 +5,16 @@ const React = require("react");
5
5
  function useHistory() {
6
6
  const stackRef = React.useRef([]);
7
7
  const [stackLen, setStackLen] = React.useState(0);
8
- const push = React.useCallback((op) => {
9
- stackRef.current = [...stackRef.current, op];
8
+ const push = React.useCallback((snapshot) => {
9
+ stackRef.current = [...stackRef.current, snapshot];
10
10
  setStackLen(stackRef.current.length);
11
11
  }, []);
12
- const undo = React.useCallback((strokes) => {
13
- if (stackRef.current.length === 0) return { strokes: null, arcIndices: [] };
14
- const op = stackRef.current[stackRef.current.length - 1];
12
+ const undo = React.useCallback(() => {
13
+ if (stackRef.current.length === 0) return null;
14
+ const snapshot = stackRef.current[stackRef.current.length - 1];
15
15
  stackRef.current = stackRef.current.slice(0, -1);
16
16
  setStackLen(stackRef.current.length);
17
- switch (op.type) {
18
- case "add":
19
- return {
20
- strokes: strokes.filter((s) => !op.strokes.some((os) => os.id === s.id)),
21
- arcIndices: []
22
- };
23
- case "delete":
24
- return {
25
- strokes: op.strokesBefore,
26
- arcIndices: op.arcIndices
27
- };
28
- }
17
+ return snapshot;
29
18
  }, []);
30
19
  const clear = React.useCallback(() => {
31
20
  stackRef.current = [];
@@ -247,6 +236,19 @@ function normalizeDomain(lo, hi) {
247
236
  }
248
237
  return { min: x, max: y };
249
238
  }
239
+ function eraseRadius(pointerType) {
240
+ return pointerType === "touch" ? 28 : 16;
241
+ }
242
+ function strokeWithinRadius(d, px, py, radius, w, h) {
243
+ const nums = d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
244
+ if (!nums) return false;
245
+ for (let i = 0; i + 1 < nums.length; i += 2) {
246
+ const x = parseFloat(nums[i]) * w;
247
+ const y = parseFloat(nums[i + 1]) * h;
248
+ if (Math.hypot(x - px, y - py) < radius) return true;
249
+ }
250
+ return false;
251
+ }
250
252
  function NumberLineProvider({
251
253
  min,
252
254
  max,
@@ -285,11 +287,35 @@ function NumberLineProvider({
285
287
  });
286
288
  const [liveStroke, setLiveStroke] = React.useState(null);
287
289
  const svgPoint = React.useRef(null);
290
+ const [eraseHoverIds, setEraseHoverIds] = React.useState(/* @__PURE__ */ new Set());
291
+ const eraseGesture = React.useRef(null);
292
+ const createHistorySnapshot = React.useCallback(
293
+ (strokesOverride) => ({
294
+ strokes: (strokesOverride ?? drawing.strokes).map((stroke) => ({ ...stroke })),
295
+ deletedArcIndices: [...deletedArcIndices],
296
+ selectedArcIndices: [...selectedArcIndices],
297
+ groupTicksHidden
298
+ }),
299
+ [drawing.strokes, deletedArcIndices, selectedArcIndices, groupTicksHidden]
300
+ );
288
301
  React.useEffect(() => {
302
+ drawing.resetStrokes();
303
+ history.clear();
304
+ setLiveStroke(null);
305
+ eraseGesture.current = null;
306
+ setEraseHoverIds(/* @__PURE__ */ new Set());
289
307
  setDeletedArcIndices(/* @__PURE__ */ new Set());
290
308
  setSelectedArcIndices(/* @__PURE__ */ new Set());
291
309
  setGroupTicksHidden(false);
292
- }, [domainMin, domainMax, groupSize, groupCount]);
310
+ }, [
311
+ domainMin,
312
+ domainMax,
313
+ groupSize,
314
+ groupCount,
315
+ tickStep,
316
+ drawing.resetStrokes,
317
+ history.clear
318
+ ]);
293
319
  const getLocalCoords = React.useCallback(
294
320
  (e) => {
295
321
  const svg = svgRef.current;
@@ -304,51 +330,107 @@ function NumberLineProvider({
304
330
  },
305
331
  [svgRef]
306
332
  );
333
+ const strokeIdsNear = React.useCallback(
334
+ (x, y, radius) => {
335
+ const ids = [];
336
+ for (const s of drawing.strokes) {
337
+ if (strokeWithinRadius(s.d, x, y, radius, containerWidth, containerHeight)) {
338
+ ids.push(s.id);
339
+ }
340
+ }
341
+ return ids;
342
+ },
343
+ [drawing.strokes, containerWidth, containerHeight]
344
+ );
345
+ const eraseAt = React.useCallback(
346
+ (x, y, radius) => {
347
+ const gesture = eraseGesture.current;
348
+ if (!gesture) return;
349
+ for (const id of strokeIdsNear(x, y, radius)) {
350
+ if (!gesture.removed.has(id)) {
351
+ gesture.removed.add(id);
352
+ drawing.eraseStroke(id);
353
+ }
354
+ }
355
+ },
356
+ [strokeIdsNear, drawing]
357
+ );
307
358
  const onPointerDown = React.useCallback(
308
359
  (e) => {
309
360
  e.currentTarget.focus();
361
+ if (drawing.tool === "eraser") {
362
+ if (eraseGesture.current) return;
363
+ const coords2 = getLocalCoords(e);
364
+ if (!coords2) return;
365
+ e.currentTarget.setPointerCapture(e.pointerId);
366
+ eraseGesture.current = { before: drawing.strokes, removed: /* @__PURE__ */ new Set() };
367
+ setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
368
+ eraseAt(coords2[0], coords2[1], eraseRadius(e.pointerType));
369
+ return;
370
+ }
310
371
  if (drawing.tool !== "pen") return;
311
372
  e.currentTarget.setPointerCapture(e.pointerId);
312
373
  const coords = getLocalCoords(e);
313
374
  if (!coords) return;
314
375
  drawing.startStroke(coords[0], coords[1], containerWidth, containerHeight);
315
376
  },
316
- [drawing, getLocalCoords, containerWidth, containerHeight]
377
+ [drawing, getLocalCoords, containerWidth, containerHeight, eraseAt]
317
378
  );
318
379
  const onPointerMove = React.useCallback(
319
380
  (e) => {
381
+ if (drawing.tool === "eraser") {
382
+ const coords2 = getLocalCoords(e);
383
+ if (!coords2) return;
384
+ const radius = eraseRadius(e.pointerType);
385
+ if (eraseGesture.current) {
386
+ eraseAt(coords2[0], coords2[1], radius);
387
+ } else {
388
+ const nextIds = strokeIdsNear(coords2[0], coords2[1], radius);
389
+ setEraseHoverIds((prev) => {
390
+ if (nextIds.length === prev.size && nextIds.every((id) => prev.has(id)))
391
+ return prev;
392
+ return new Set(nextIds);
393
+ });
394
+ }
395
+ return;
396
+ }
320
397
  if (drawing.tool !== "pen") return;
321
398
  const coords = getLocalCoords(e);
322
399
  if (!coords) return;
323
400
  const live = drawing.continueStroke(coords[0], coords[1], containerWidth, containerHeight);
324
401
  if (live) setLiveStroke(live);
325
402
  },
326
- [drawing, getLocalCoords, containerWidth, containerHeight]
403
+ [drawing, getLocalCoords, containerWidth, containerHeight, eraseAt, strokeIdsNear]
327
404
  );
328
405
  const onPointerUp = React.useCallback(() => {
406
+ const gesture = eraseGesture.current;
407
+ if (gesture) {
408
+ eraseGesture.current = null;
409
+ if (gesture.removed.size > 0) {
410
+ history.push(createHistorySnapshot(gesture.before));
411
+ }
412
+ return;
413
+ }
329
414
  if (drawing.tool !== "pen") return;
330
415
  const stroke = drawing.endStroke();
331
416
  setLiveStroke(null);
332
417
  if (stroke) {
418
+ history.push(createHistorySnapshot());
333
419
  drawing.addStroke(stroke);
334
- history.push({ type: "add", strokes: [stroke] });
335
420
  }
336
- }, [drawing, history]);
421
+ }, [drawing, history, createHistorySnapshot]);
422
+ const onPointerLeave = React.useCallback(() => {
423
+ if (eraseGesture.current) return;
424
+ setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
425
+ }, []);
337
426
  const onStrokeClick = React.useCallback(
338
427
  (id, e) => {
339
428
  e.stopPropagation();
340
- if (drawing.tool === "eraser") {
341
- const target = drawing.strokes.find((s) => s.id === id);
342
- if (target) {
343
- const strokesBefore = drawing.strokes;
344
- drawing.eraseStroke(id);
345
- history.push({ type: "delete", strokesBefore, arcIndices: [] });
346
- }
347
- } else if (drawing.tool === "none") {
429
+ if (drawing.tool === "none") {
348
430
  drawing.toggleSelect(id);
349
431
  }
350
432
  },
351
- [drawing, history]
433
+ [drawing]
352
434
  );
353
435
  const onArcClick = React.useCallback(
354
436
  (groupIndex, e) => {
@@ -366,39 +448,55 @@ function NumberLineProvider({
366
448
  );
367
449
  const clearArcSelection = React.useCallback(() => setSelectedArcIndices(/* @__PURE__ */ new Set()), []);
368
450
  const onDelete = React.useCallback(() => {
369
- const strokesBefore = drawing.strokes;
370
451
  const deletedStrokes = drawing.strokes.filter((s) => s.selected);
371
452
  const deletedArcs = [...selectedArcIndices];
372
453
  if (deletedStrokes.length === 0 && deletedArcs.length === 0) return;
454
+ history.push(createHistorySnapshot());
373
455
  if (deletedStrokes.length > 0) drawing.deleteSelected();
374
456
  if (deletedArcs.length > 0) {
375
457
  setDeletedArcIndices((prev) => /* @__PURE__ */ new Set([...prev, ...deletedArcs]));
376
458
  setSelectedArcIndices(/* @__PURE__ */ new Set());
377
459
  }
378
- history.push({ type: "delete", strokesBefore, arcIndices: deletedArcs });
379
- }, [drawing, history, selectedArcIndices]);
380
- const onReset = React.useCallback(() => {
381
- drawing.resetStrokes();
382
- history.clear();
460
+ }, [drawing, history, selectedArcIndices, createHistorySnapshot]);
461
+ const clearAll = React.useCallback(() => {
383
462
  const arcCount = Math.max(0, Math.ceil(Number(groupCount) || 0));
463
+ const allArcsDeleted = deletedArcIndices.size >= arcCount && Array.from({ length: arcCount }, (_, index) => index).every(
464
+ (index) => deletedArcIndices.has(index)
465
+ );
466
+ const alreadyClear = drawing.strokes.length === 0 && selectedArcIndices.size === 0 && allArcsDeleted && groupTicksHidden;
467
+ if (alreadyClear) return;
468
+ history.push(createHistorySnapshot());
469
+ drawing.resetStrokes();
470
+ setLiveStroke(null);
471
+ eraseGesture.current = null;
472
+ setEraseHoverIds(/* @__PURE__ */ new Set());
384
473
  setDeletedArcIndices(new Set(Array.from({ length: arcCount }, (_, i) => i)));
385
474
  setSelectedArcIndices(/* @__PURE__ */ new Set());
386
475
  setGroupTicksHidden(true);
387
- }, [drawing, history, groupCount]);
476
+ }, [
477
+ drawing,
478
+ history,
479
+ groupCount,
480
+ deletedArcIndices,
481
+ selectedArcIndices,
482
+ groupTicksHidden,
483
+ createHistorySnapshot
484
+ ]);
485
+ const onReset = clearAll;
388
486
  const showAllArcs = React.useCallback(() => {
389
487
  setDeletedArcIndices((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
390
488
  setGroupTicksHidden(false);
391
489
  }, []);
392
490
  const onUndo = React.useCallback(() => {
393
- const result = history.undo(drawing.strokes);
394
- if (result.strokes !== null) drawing.restoreStrokes(result.strokes);
395
- if (result.arcIndices.length > 0) {
396
- setDeletedArcIndices((prev) => {
397
- const next = new Set(prev);
398
- result.arcIndices.forEach((i) => next.delete(i));
399
- return next;
400
- });
401
- }
491
+ const snapshot = history.undo();
492
+ if (!snapshot) return;
493
+ drawing.restoreStrokes(snapshot.strokes);
494
+ setDeletedArcIndices(new Set(snapshot.deletedArcIndices));
495
+ setSelectedArcIndices(new Set(snapshot.selectedArcIndices));
496
+ setGroupTicksHidden(snapshot.groupTicksHidden);
497
+ setLiveStroke(null);
498
+ eraseGesture.current = null;
499
+ setEraseHoverIds(/* @__PURE__ */ new Set());
402
500
  }, [history, drawing]);
403
501
  const onTogglePen = React.useCallback(
404
502
  () => {
@@ -417,6 +515,7 @@ function NumberLineProvider({
417
515
  const canDelete = drawing.strokes.some((s) => s.selected) || selectedArcIndices.size > 0;
418
516
  React.useEffect(() => {
419
517
  setSelectedArcIndices(/* @__PURE__ */ new Set());
518
+ setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
420
519
  }, [drawing.tool]);
421
520
  const keyHandlers = React.useRef({ toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo });
422
521
  keyHandlers.current = { toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo };
@@ -468,11 +567,13 @@ function NumberLineProvider({
468
567
  tool: drawing.tool,
469
568
  strokes: drawing.strokes,
470
569
  liveStroke,
570
+ eraseHoverIds,
471
571
  selectedArcIndices,
472
572
  deletedArcIndices,
473
573
  onPointerDown,
474
574
  onPointerMove,
475
575
  onPointerUp,
576
+ onPointerLeave,
476
577
  onStrokeClick,
477
578
  onArcClick,
478
579
  canDelete,
@@ -480,6 +581,7 @@ function NumberLineProvider({
480
581
  onTogglePen,
481
582
  onToggleEraser,
482
583
  onDelete,
584
+ clearAll,
483
585
  onReset,
484
586
  onUndo,
485
587
  showAllArcs
@@ -916,11 +1018,10 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
916
1018
  }
917
1019
  return /* @__PURE__ */ jsxRuntime.jsx("g", { className: "nl-arcs-layer", children: arcs });
918
1020
  });
919
- function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
920
- const [hoveredId, setHoveredId] = React.useState(null);
1021
+ function DrawingLayer({ strokes, liveStroke, tool, width, height, eraseHoverIds, onStrokeClick }) {
921
1022
  return /* @__PURE__ */ jsxRuntime.jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
922
1023
  strokes.map((s) => /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
923
- (s.selected || tool === "eraser" && s.id === hoveredId) && /* @__PURE__ */ jsxRuntime.jsx(
1024
+ (s.selected || tool === "eraser" && eraseHoverIds.has(s.id)) && /* @__PURE__ */ jsxRuntime.jsx(
924
1025
  "path",
925
1026
  {
926
1027
  d: s.d,
@@ -945,10 +1046,8 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
945
1046
  vectorEffect: "non-scaling-stroke",
946
1047
  pointerEvents: "stroke",
947
1048
  style: { cursor: tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default" },
948
- onPointerEnter: () => setHoveredId(s.id),
949
- onPointerLeave: () => setHoveredId((prev) => prev === s.id ? null : prev),
950
1049
  onClick: (e) => {
951
- if (tool === "eraser" || tool === "none") {
1050
+ if (tool === "none") {
952
1051
  e.stopPropagation();
953
1052
  onStrokeClick(s.id, e);
954
1053
  }
@@ -1015,11 +1114,13 @@ function NumberLine({ className }) {
1015
1114
  tool,
1016
1115
  strokes,
1017
1116
  liveStroke,
1117
+ eraseHoverIds,
1018
1118
  selectedArcIndices,
1019
1119
  deletedArcIndices,
1020
1120
  onPointerDown,
1021
1121
  onPointerMove,
1022
1122
  onPointerUp,
1123
+ onPointerLeave,
1023
1124
  onStrokeClick,
1024
1125
  onArcClick
1025
1126
  } = useNumberLineContext();
@@ -1186,6 +1287,8 @@ function NumberLine({ className }) {
1186
1287
  onPointerDown: handlePointerDown,
1187
1288
  onPointerMove: handlePointerMove,
1188
1289
  onPointerUp: handlePointerUp,
1290
+ onPointerCancel: handlePointerUp,
1291
+ onPointerLeave,
1189
1292
  onWheel: handleWheel,
1190
1293
  onDoubleClick: handleDoubleClick,
1191
1294
  onMouseEnter: () => enableZoom && setIsZoomActive(true),
@@ -1244,6 +1347,7 @@ function NumberLine({ className }) {
1244
1347
  tool,
1245
1348
  width: w,
1246
1349
  height: h,
1350
+ eraseHoverIds,
1247
1351
  onStrokeClick
1248
1352
  }
1249
1353
  )
package/dist/index.d.ts CHANGED
@@ -29,11 +29,14 @@ export declare interface NumberLineContextValue {
29
29
  tool: Tool;
30
30
  strokes: Stroke[];
31
31
  liveStroke: Stroke | null;
32
+ /** Stroke ids the eraser brush is hovering over (preview of what a press would remove). */
33
+ eraseHoverIds: Set<string>;
32
34
  selectedArcIndices: Set<number>;
33
35
  deletedArcIndices: Set<number>;
34
36
  onPointerDown: (e: default_2.PointerEvent<SVGSVGElement>) => void;
35
37
  onPointerMove: (e: default_2.PointerEvent<SVGSVGElement>) => void;
36
38
  onPointerUp: () => void;
39
+ onPointerLeave: () => void;
37
40
  onStrokeClick: (id: string, e: default_2.PointerEvent) => void;
38
41
  onArcClick: (groupIndex: number, e: default_2.PointerEvent) => void;
39
42
  canDelete: boolean;
@@ -41,6 +44,9 @@ export declare interface NumberLineContextValue {
41
44
  onTogglePen: () => void;
42
45
  onToggleEraser: () => void;
43
46
  onDelete: () => void;
47
+ /** 可撤销地清空笔迹、弧线、选择和粗分组刻度;已为空时不创建历史项。 */
48
+ clearAll: () => void;
49
+ /** @deprecated 请使用语义明确且行为相同的 `clearAll`。 */
44
50
  onReset: () => void;
45
51
  onUndo: () => void;
46
52
  /** restore (re-show) every arc by clearing the deleted-arc record */
package/dist/index.js CHANGED
@@ -3,27 +3,16 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect, creat
3
3
  function useHistory() {
4
4
  const stackRef = useRef([]);
5
5
  const [stackLen, setStackLen] = useState(0);
6
- const push = useCallback((op) => {
7
- stackRef.current = [...stackRef.current, op];
6
+ const push = useCallback((snapshot) => {
7
+ stackRef.current = [...stackRef.current, snapshot];
8
8
  setStackLen(stackRef.current.length);
9
9
  }, []);
10
- const undo = useCallback((strokes) => {
11
- if (stackRef.current.length === 0) return { strokes: null, arcIndices: [] };
12
- const op = stackRef.current[stackRef.current.length - 1];
10
+ const undo = useCallback(() => {
11
+ if (stackRef.current.length === 0) return null;
12
+ const snapshot = stackRef.current[stackRef.current.length - 1];
13
13
  stackRef.current = stackRef.current.slice(0, -1);
14
14
  setStackLen(stackRef.current.length);
15
- switch (op.type) {
16
- case "add":
17
- return {
18
- strokes: strokes.filter((s) => !op.strokes.some((os) => os.id === s.id)),
19
- arcIndices: []
20
- };
21
- case "delete":
22
- return {
23
- strokes: op.strokesBefore,
24
- arcIndices: op.arcIndices
25
- };
26
- }
15
+ return snapshot;
27
16
  }, []);
28
17
  const clear = useCallback(() => {
29
18
  stackRef.current = [];
@@ -245,6 +234,19 @@ function normalizeDomain(lo, hi) {
245
234
  }
246
235
  return { min: x, max: y };
247
236
  }
237
+ function eraseRadius(pointerType) {
238
+ return pointerType === "touch" ? 28 : 16;
239
+ }
240
+ function strokeWithinRadius(d, px, py, radius, w, h) {
241
+ const nums = d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
242
+ if (!nums) return false;
243
+ for (let i = 0; i + 1 < nums.length; i += 2) {
244
+ const x = parseFloat(nums[i]) * w;
245
+ const y = parseFloat(nums[i + 1]) * h;
246
+ if (Math.hypot(x - px, y - py) < radius) return true;
247
+ }
248
+ return false;
249
+ }
248
250
  function NumberLineProvider({
249
251
  min,
250
252
  max,
@@ -283,11 +285,35 @@ function NumberLineProvider({
283
285
  });
284
286
  const [liveStroke, setLiveStroke] = useState(null);
285
287
  const svgPoint = useRef(null);
288
+ const [eraseHoverIds, setEraseHoverIds] = useState(/* @__PURE__ */ new Set());
289
+ const eraseGesture = useRef(null);
290
+ const createHistorySnapshot = useCallback(
291
+ (strokesOverride) => ({
292
+ strokes: (strokesOverride ?? drawing.strokes).map((stroke) => ({ ...stroke })),
293
+ deletedArcIndices: [...deletedArcIndices],
294
+ selectedArcIndices: [...selectedArcIndices],
295
+ groupTicksHidden
296
+ }),
297
+ [drawing.strokes, deletedArcIndices, selectedArcIndices, groupTicksHidden]
298
+ );
286
299
  useEffect(() => {
300
+ drawing.resetStrokes();
301
+ history.clear();
302
+ setLiveStroke(null);
303
+ eraseGesture.current = null;
304
+ setEraseHoverIds(/* @__PURE__ */ new Set());
287
305
  setDeletedArcIndices(/* @__PURE__ */ new Set());
288
306
  setSelectedArcIndices(/* @__PURE__ */ new Set());
289
307
  setGroupTicksHidden(false);
290
- }, [domainMin, domainMax, groupSize, groupCount]);
308
+ }, [
309
+ domainMin,
310
+ domainMax,
311
+ groupSize,
312
+ groupCount,
313
+ tickStep,
314
+ drawing.resetStrokes,
315
+ history.clear
316
+ ]);
291
317
  const getLocalCoords = useCallback(
292
318
  (e) => {
293
319
  const svg = svgRef.current;
@@ -302,51 +328,107 @@ function NumberLineProvider({
302
328
  },
303
329
  [svgRef]
304
330
  );
331
+ const strokeIdsNear = useCallback(
332
+ (x, y, radius) => {
333
+ const ids = [];
334
+ for (const s of drawing.strokes) {
335
+ if (strokeWithinRadius(s.d, x, y, radius, containerWidth, containerHeight)) {
336
+ ids.push(s.id);
337
+ }
338
+ }
339
+ return ids;
340
+ },
341
+ [drawing.strokes, containerWidth, containerHeight]
342
+ );
343
+ const eraseAt = useCallback(
344
+ (x, y, radius) => {
345
+ const gesture = eraseGesture.current;
346
+ if (!gesture) return;
347
+ for (const id of strokeIdsNear(x, y, radius)) {
348
+ if (!gesture.removed.has(id)) {
349
+ gesture.removed.add(id);
350
+ drawing.eraseStroke(id);
351
+ }
352
+ }
353
+ },
354
+ [strokeIdsNear, drawing]
355
+ );
305
356
  const onPointerDown = useCallback(
306
357
  (e) => {
307
358
  e.currentTarget.focus();
359
+ if (drawing.tool === "eraser") {
360
+ if (eraseGesture.current) return;
361
+ const coords2 = getLocalCoords(e);
362
+ if (!coords2) return;
363
+ e.currentTarget.setPointerCapture(e.pointerId);
364
+ eraseGesture.current = { before: drawing.strokes, removed: /* @__PURE__ */ new Set() };
365
+ setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
366
+ eraseAt(coords2[0], coords2[1], eraseRadius(e.pointerType));
367
+ return;
368
+ }
308
369
  if (drawing.tool !== "pen") return;
309
370
  e.currentTarget.setPointerCapture(e.pointerId);
310
371
  const coords = getLocalCoords(e);
311
372
  if (!coords) return;
312
373
  drawing.startStroke(coords[0], coords[1], containerWidth, containerHeight);
313
374
  },
314
- [drawing, getLocalCoords, containerWidth, containerHeight]
375
+ [drawing, getLocalCoords, containerWidth, containerHeight, eraseAt]
315
376
  );
316
377
  const onPointerMove = useCallback(
317
378
  (e) => {
379
+ if (drawing.tool === "eraser") {
380
+ const coords2 = getLocalCoords(e);
381
+ if (!coords2) return;
382
+ const radius = eraseRadius(e.pointerType);
383
+ if (eraseGesture.current) {
384
+ eraseAt(coords2[0], coords2[1], radius);
385
+ } else {
386
+ const nextIds = strokeIdsNear(coords2[0], coords2[1], radius);
387
+ setEraseHoverIds((prev) => {
388
+ if (nextIds.length === prev.size && nextIds.every((id) => prev.has(id)))
389
+ return prev;
390
+ return new Set(nextIds);
391
+ });
392
+ }
393
+ return;
394
+ }
318
395
  if (drawing.tool !== "pen") return;
319
396
  const coords = getLocalCoords(e);
320
397
  if (!coords) return;
321
398
  const live = drawing.continueStroke(coords[0], coords[1], containerWidth, containerHeight);
322
399
  if (live) setLiveStroke(live);
323
400
  },
324
- [drawing, getLocalCoords, containerWidth, containerHeight]
401
+ [drawing, getLocalCoords, containerWidth, containerHeight, eraseAt, strokeIdsNear]
325
402
  );
326
403
  const onPointerUp = useCallback(() => {
404
+ const gesture = eraseGesture.current;
405
+ if (gesture) {
406
+ eraseGesture.current = null;
407
+ if (gesture.removed.size > 0) {
408
+ history.push(createHistorySnapshot(gesture.before));
409
+ }
410
+ return;
411
+ }
327
412
  if (drawing.tool !== "pen") return;
328
413
  const stroke = drawing.endStroke();
329
414
  setLiveStroke(null);
330
415
  if (stroke) {
416
+ history.push(createHistorySnapshot());
331
417
  drawing.addStroke(stroke);
332
- history.push({ type: "add", strokes: [stroke] });
333
418
  }
334
- }, [drawing, history]);
419
+ }, [drawing, history, createHistorySnapshot]);
420
+ const onPointerLeave = useCallback(() => {
421
+ if (eraseGesture.current) return;
422
+ setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
423
+ }, []);
335
424
  const onStrokeClick = useCallback(
336
425
  (id, e) => {
337
426
  e.stopPropagation();
338
- if (drawing.tool === "eraser") {
339
- const target = drawing.strokes.find((s) => s.id === id);
340
- if (target) {
341
- const strokesBefore = drawing.strokes;
342
- drawing.eraseStroke(id);
343
- history.push({ type: "delete", strokesBefore, arcIndices: [] });
344
- }
345
- } else if (drawing.tool === "none") {
427
+ if (drawing.tool === "none") {
346
428
  drawing.toggleSelect(id);
347
429
  }
348
430
  },
349
- [drawing, history]
431
+ [drawing]
350
432
  );
351
433
  const onArcClick = useCallback(
352
434
  (groupIndex, e) => {
@@ -364,39 +446,55 @@ function NumberLineProvider({
364
446
  );
365
447
  const clearArcSelection = useCallback(() => setSelectedArcIndices(/* @__PURE__ */ new Set()), []);
366
448
  const onDelete = useCallback(() => {
367
- const strokesBefore = drawing.strokes;
368
449
  const deletedStrokes = drawing.strokes.filter((s) => s.selected);
369
450
  const deletedArcs = [...selectedArcIndices];
370
451
  if (deletedStrokes.length === 0 && deletedArcs.length === 0) return;
452
+ history.push(createHistorySnapshot());
371
453
  if (deletedStrokes.length > 0) drawing.deleteSelected();
372
454
  if (deletedArcs.length > 0) {
373
455
  setDeletedArcIndices((prev) => /* @__PURE__ */ new Set([...prev, ...deletedArcs]));
374
456
  setSelectedArcIndices(/* @__PURE__ */ new Set());
375
457
  }
376
- history.push({ type: "delete", strokesBefore, arcIndices: deletedArcs });
377
- }, [drawing, history, selectedArcIndices]);
378
- const onReset = useCallback(() => {
379
- drawing.resetStrokes();
380
- history.clear();
458
+ }, [drawing, history, selectedArcIndices, createHistorySnapshot]);
459
+ const clearAll = useCallback(() => {
381
460
  const arcCount = Math.max(0, Math.ceil(Number(groupCount) || 0));
461
+ const allArcsDeleted = deletedArcIndices.size >= arcCount && Array.from({ length: arcCount }, (_, index) => index).every(
462
+ (index) => deletedArcIndices.has(index)
463
+ );
464
+ const alreadyClear = drawing.strokes.length === 0 && selectedArcIndices.size === 0 && allArcsDeleted && groupTicksHidden;
465
+ if (alreadyClear) return;
466
+ history.push(createHistorySnapshot());
467
+ drawing.resetStrokes();
468
+ setLiveStroke(null);
469
+ eraseGesture.current = null;
470
+ setEraseHoverIds(/* @__PURE__ */ new Set());
382
471
  setDeletedArcIndices(new Set(Array.from({ length: arcCount }, (_, i) => i)));
383
472
  setSelectedArcIndices(/* @__PURE__ */ new Set());
384
473
  setGroupTicksHidden(true);
385
- }, [drawing, history, groupCount]);
474
+ }, [
475
+ drawing,
476
+ history,
477
+ groupCount,
478
+ deletedArcIndices,
479
+ selectedArcIndices,
480
+ groupTicksHidden,
481
+ createHistorySnapshot
482
+ ]);
483
+ const onReset = clearAll;
386
484
  const showAllArcs = useCallback(() => {
387
485
  setDeletedArcIndices((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
388
486
  setGroupTicksHidden(false);
389
487
  }, []);
390
488
  const onUndo = useCallback(() => {
391
- const result = history.undo(drawing.strokes);
392
- if (result.strokes !== null) drawing.restoreStrokes(result.strokes);
393
- if (result.arcIndices.length > 0) {
394
- setDeletedArcIndices((prev) => {
395
- const next = new Set(prev);
396
- result.arcIndices.forEach((i) => next.delete(i));
397
- return next;
398
- });
399
- }
489
+ const snapshot = history.undo();
490
+ if (!snapshot) return;
491
+ drawing.restoreStrokes(snapshot.strokes);
492
+ setDeletedArcIndices(new Set(snapshot.deletedArcIndices));
493
+ setSelectedArcIndices(new Set(snapshot.selectedArcIndices));
494
+ setGroupTicksHidden(snapshot.groupTicksHidden);
495
+ setLiveStroke(null);
496
+ eraseGesture.current = null;
497
+ setEraseHoverIds(/* @__PURE__ */ new Set());
400
498
  }, [history, drawing]);
401
499
  const onTogglePen = useCallback(
402
500
  () => {
@@ -415,6 +513,7 @@ function NumberLineProvider({
415
513
  const canDelete = drawing.strokes.some((s) => s.selected) || selectedArcIndices.size > 0;
416
514
  useEffect(() => {
417
515
  setSelectedArcIndices(/* @__PURE__ */ new Set());
516
+ setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
418
517
  }, [drawing.tool]);
419
518
  const keyHandlers = useRef({ toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo });
420
519
  keyHandlers.current = { toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo };
@@ -466,11 +565,13 @@ function NumberLineProvider({
466
565
  tool: drawing.tool,
467
566
  strokes: drawing.strokes,
468
567
  liveStroke,
568
+ eraseHoverIds,
469
569
  selectedArcIndices,
470
570
  deletedArcIndices,
471
571
  onPointerDown,
472
572
  onPointerMove,
473
573
  onPointerUp,
574
+ onPointerLeave,
474
575
  onStrokeClick,
475
576
  onArcClick,
476
577
  canDelete,
@@ -478,6 +579,7 @@ function NumberLineProvider({
478
579
  onTogglePen,
479
580
  onToggleEraser,
480
581
  onDelete,
582
+ clearAll,
481
583
  onReset,
482
584
  onUndo,
483
585
  showAllArcs
@@ -914,11 +1016,10 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
914
1016
  }
915
1017
  return /* @__PURE__ */ jsx("g", { className: "nl-arcs-layer", children: arcs });
916
1018
  });
917
- function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
918
- const [hoveredId, setHoveredId] = useState(null);
1019
+ function DrawingLayer({ strokes, liveStroke, tool, width, height, eraseHoverIds, onStrokeClick }) {
919
1020
  return /* @__PURE__ */ jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
920
1021
  strokes.map((s) => /* @__PURE__ */ jsxs("g", { children: [
921
- (s.selected || tool === "eraser" && s.id === hoveredId) && /* @__PURE__ */ jsx(
1022
+ (s.selected || tool === "eraser" && eraseHoverIds.has(s.id)) && /* @__PURE__ */ jsx(
922
1023
  "path",
923
1024
  {
924
1025
  d: s.d,
@@ -943,10 +1044,8 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
943
1044
  vectorEffect: "non-scaling-stroke",
944
1045
  pointerEvents: "stroke",
945
1046
  style: { cursor: tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default" },
946
- onPointerEnter: () => setHoveredId(s.id),
947
- onPointerLeave: () => setHoveredId((prev) => prev === s.id ? null : prev),
948
1047
  onClick: (e) => {
949
- if (tool === "eraser" || tool === "none") {
1048
+ if (tool === "none") {
950
1049
  e.stopPropagation();
951
1050
  onStrokeClick(s.id, e);
952
1051
  }
@@ -1013,11 +1112,13 @@ function NumberLine({ className }) {
1013
1112
  tool,
1014
1113
  strokes,
1015
1114
  liveStroke,
1115
+ eraseHoverIds,
1016
1116
  selectedArcIndices,
1017
1117
  deletedArcIndices,
1018
1118
  onPointerDown,
1019
1119
  onPointerMove,
1020
1120
  onPointerUp,
1121
+ onPointerLeave,
1021
1122
  onStrokeClick,
1022
1123
  onArcClick
1023
1124
  } = useNumberLineContext();
@@ -1184,6 +1285,8 @@ function NumberLine({ className }) {
1184
1285
  onPointerDown: handlePointerDown,
1185
1286
  onPointerMove: handlePointerMove,
1186
1287
  onPointerUp: handlePointerUp,
1288
+ onPointerCancel: handlePointerUp,
1289
+ onPointerLeave,
1187
1290
  onWheel: handleWheel,
1188
1291
  onDoubleClick: handleDoubleClick,
1189
1292
  onMouseEnter: () => enableZoom && setIsZoomActive(true),
@@ -1242,6 +1345,7 @@ function NumberLine({ className }) {
1242
1345
  tool,
1243
1346
  width: w,
1244
1347
  height: h,
1348
+ eraseHoverIds,
1245
1349
  onStrokeClick
1246
1350
  }
1247
1351
  )
package/package.json CHANGED
@@ -1,47 +1,47 @@
1
- {
2
- "name": "@dcg-overseas/number-line",
3
- "version": "0.1.30",
4
- "description": "Interactive number line component with group arcs and freehand drawing",
5
- "type": "module",
6
- "license": "MIT",
7
- "main": "./dist/index.cjs",
8
- "module": "./dist/index.js",
9
- "types": "./dist/index.d.ts",
10
- "exports": {
11
- ".": {
12
- "import": {
13
- "types": "./dist/index.d.ts",
14
- "default": "./dist/index.js"
15
- },
16
- "require": {
17
- "types": "./dist/index.d.cts",
18
- "default": "./dist/index.cjs"
19
- }
20
- }
21
- },
22
- "files": [
23
- "dist"
24
- ],
25
- "sideEffects": false,
26
- "scripts": {
27
- "build": "vite build",
28
- "build:watch": "vite build --watch",
29
- "typecheck": "tsc --noEmit",
30
- "clean": "rm -rf dist *.tsbuildinfo"
31
- },
32
- "peerDependencies": {
33
- "react": "^18.0.0",
34
- "react-dom": "^18.0.0"
35
- },
36
- "devDependencies": {
37
- "@types/react": "^18.3.3",
38
- "@types/react-dom": "^18.3.0",
39
- "@vitejs/plugin-react": "^4.3.1",
40
- "vite": "^5.3.1",
41
- "vite-plugin-dts": "^3.9.1"
42
- },
43
- "publishConfig": {
44
- "access": "public",
45
- "registry": "https://registry.npmjs.org/"
46
- }
47
- }
1
+ {
2
+ "name": "@dcg-overseas/number-line",
3
+ "version": "1.0.0-beta.0",
4
+ "description": "Interactive number line component with group arcs and freehand drawing",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/index.d.cts",
18
+ "default": "./dist/index.cjs"
19
+ }
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "sideEffects": false,
26
+ "scripts": {
27
+ "build": "vite build",
28
+ "build:watch": "vite build --watch",
29
+ "typecheck": "tsc --noEmit",
30
+ "clean": "rm -rf dist *.tsbuildinfo"
31
+ },
32
+ "peerDependencies": {
33
+ "react": "^18.0.0",
34
+ "react-dom": "^18.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^18.3.3",
38
+ "@types/react-dom": "^18.3.0",
39
+ "@vitejs/plugin-react": "^4.3.1",
40
+ "vite": "^5.3.1",
41
+ "vite-plugin-dts": "^3.9.1"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org/"
46
+ }
47
+ }