Skip to content

新增草稿纸功能#5

Open
ZhengHaoF wants to merge 1 commit into
HsiangNianian:mainfrom
ZhengHaoF:feat/whiteboard-only
Open

新增草稿纸功能#5
ZhengHaoF wants to merge 1 commit into
HsiangNianian:mainfrom
ZhengHaoF:feat/whiteboard-only

Conversation

@ZhengHaoF

@ZhengHaoF ZhengHaoF commented Jul 10, 2026

Copy link
Copy Markdown

新增草稿纸功能

为 Cortex 增加一层全站级的「数字草稿纸」白板,方便用户在答题或浏览页面时划线、演算草稿。

功能

  • 全屏 Canvas 草稿层,覆盖整个网页
  • 顶栏工具条(位于语言切换按钮之后):画笔 / 橡皮 / 清空全部
  • 显式画笔开关(默认关闭),开启时才捕获指针,不影响选项点击
  • 工具条始终叠在画布之上(z-50),绘制中也能随时退出
  • 退出画布自动清空已画内容
  • 测试阶段顶栏仅隐藏搜索 / 语言 / 主题,白板工具条保持可见

设计定位

定位为「考试草稿纸」:对所有受试者一视同仁,不影响 IRT 计分、结果存储与分享链路。草稿为纯内存、结束即弃,对现有逻辑零侵入。

变更文件

  • 新增 src/components/GlobalWhiteboard.tsx
  • src/app/[locale]/layout.tsx:工具条挂载到顶栏(语言切换之后)
  • src/app/globals.css:测试中仅隐藏 .test-hideable

Summary by Sourcery

在整个网站中添加一个全局白板图层,提供类似考试环境的数字草稿板。

New Features:

  • 引入一个 GlobalWhiteboard 组件,渲染全屏画布,用于绘制批注和草稿内容。
  • 添加顶部白板工具栏,提供画笔、橡皮擦和清空全部控制,并可在不干扰正常页面交互的情况下进行切换。

Enhancements:

  • 调整本地化布局,在现有的搜索、语言和主题控制旁边容纳白板工具栏。
  • 优化仅用于测试的顶部导航隐藏逻辑,在隐藏搜索、语言和主题链接的同时保持白板工具栏可见。

Chores:

  • globals.css 中进行轻微的 CSS 格式调整和动画样式统一。
Original summary in English

Summary by Sourcery

Add a global whiteboard layer that provides an exam-style digital scratchpad across the site.

New Features:

  • Introduce a GlobalWhiteboard component that renders a full-screen canvas for drawing annotations and scratch work.
  • Add a top-bar whiteboard toolbar with pen, eraser, and clear-all controls that can be toggled without interfering with normal page interactions.

Enhancements:

  • Adjust the locale layout to host the whiteboard toolbar alongside existing search, language, and theme controls.
  • Refine testing-only top navigation hiding so that search, language, and theme links are hidden while keeping the whiteboard toolbar visible.

Chores:

  • Minor CSS formatting adjustments and animation style normalization in globals.css.

A lightweight global draft-paper canvas (pen/eraser/clear) overlaid on the
whole page, reachable from the top-right toolbar right after the language
switcher. Conceived as a digital scratchpad for the cognitive test: it does
not touch scoring, the result payload, or sharing.

- Single global draft layer; discarded on exit (no persistence)
- Explicit pen toggle (default off) keeps answer options clickable
- Toolbar at z-50 sits above the z-30 canvas, stays clickable while drawing
- During testing only search/language/theme hide; whiteboard toolbar stays
@sourcery-ai

sourcery-ai Bot commented Jul 10, 2026

Copy link
Copy Markdown

审阅者指南

此 PR 添加了一个全局的、非持久化的白板图层(canvas),其工具栏挂载在顶部导航中,并调整了测试时的导航隐藏行为,以在隐藏搜索/语言/主题控件时保持白板工具可见。

GlobalWhiteboard 画笔切换与绘制交互的时序图

sequenceDiagram
  actor User
  participant GlobalWhiteboard
  participant Canvas

  User->>GlobalWhiteboard: click pen button (toggle("pen"))
  GlobalWhiteboard->>GlobalWhiteboard: setTool("pen")
  GlobalWhiteboard->>Canvas: enable pointerEvents auto

  User->>Canvas: pointerDown (onPointerDown)
  Canvas->>GlobalWhiteboard: onPointerDown
  GlobalWhiteboard->>GlobalWhiteboard: getNorm
  GlobalWhiteboard->>GlobalWhiteboard: push Stroke to strokesRef
  GlobalWhiteboard->>Canvas: draw

  User->>Canvas: pointerMove (onPointerMove)
  Canvas->>GlobalWhiteboard: onPointerMove
  GlobalWhiteboard->>GlobalWhiteboard: append Point to currentStrokeRef
  GlobalWhiteboard->>Canvas: draw

  User->>GlobalWhiteboard: click pen button again (toggle("pen"))
  GlobalWhiteboard->>GlobalWhiteboard: clearAll
  GlobalWhiteboard->>Canvas: draw
  GlobalWhiteboard->>GlobalWhiteboard: setTool("none")
  GlobalWhiteboard->>Canvas: disable pointerEvents (none)
Loading

文件级变更

Change Details Files
引入一个覆盖整个视口的基于 canvas 的白板,提供画笔/橡皮/清除工具,并仅在显式启用时捕获指针事件。
  • 实现带有 canvas 渲染逻辑、笔画模型和工具状态管理的 GlobalWhiteboard React 客户端组件。
  • 将笔画点归一化为视口相对坐标,并在考虑设备像素比的前提下重绘和调整大小。
  • 处理用于绘制、在一定半径内整段擦除笔画以及清空所有笔画的指针事件,并仅在内存中保留数据。
  • 渲染带有画笔/橡皮/清除按钮、可视化激活状态以及在关闭工具时清空内容的显式退出行为的工具栏 UI。
src/components/GlobalWhiteboard.tsx
将白板工具栏挂载到顶部导航栏中,同时不破坏现有导航布局。
  • 在 locale 布局中导入 GlobalWhiteboard,并将其插入到语言/主题控件之后的右上角导航容器中。
  • 将搜索、语言切换和主题切换包裹在一个 .test-hideable span 中,用于区分测试期间应该被隐藏的内容与白板工具栏。
src/app/[locale]/layout.tsx
优化全局 CSS,以便在测试期间只隐藏可测试隐藏的导航项并保持白板工具栏可见,同时进行轻微格式调整。
  • 将 hide-top-nav 行为从隐藏整个顶部导航栏改为只隐藏被标记为 .test-hideable 的元素,并使用 !important 提高优先级。
  • 收紧 CSS 变量和关键帧的格式,而不改变其行为。
src/app/globals.css

提示与命令

与 Sourcery 交互

  • 触发新的审阅: 在 pull request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审阅评论。
  • 从审阅评论生成 GitHub issue: 通过回复审阅评论让 Sourcery 从该评论创建 issue。也可以在审阅评论下回复 @sourcery-ai issue 来从该评论创建 issue。
  • 生成 pull request 标题: 在 pull request 标题任意位置写上 @sourcery-ai,即可随时生成标题。也可以在 pull request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 pull request 摘要: 在 pull request 正文中任意位置写上 @sourcery-ai summary,即可在指定位置生成 PR 摘要。也可以在 pull request 中评论 @sourcery-ai summary 来在任何时候(重新)生成摘要。
  • 生成审阅者指南: 在 pull request 中评论 @sourcery-ai guide,即可在任何时候(重新)生成审阅者指南。
  • 解决所有 Sourcery 评论: 在 pull request 中评论 @sourcery-ai resolve,即可解决所有 Sourcery 评论。适用于已经处理完所有评论且不希望再看到它们的情况。
  • 忽略所有 Sourcery 审阅: 在 pull request 中评论 @sourcery-ai dismiss,即可忽略所有现有 Sourcery 审阅。特别适用于想从一个新的审阅重新开始的情况——别忘了评论 @sourcery-ai review 来触发新的审阅!

自定义你的体验

访问你的 仪表盘 以:

  • 启用或禁用审阅功能,例如 Sourcery 生成的 pull request 摘要、审阅者指南等。
  • 更改审阅语言。
  • 添加、删除或编辑自定义审阅说明。
  • 调整其他审阅设置。

获取帮助

Original review guide in English

Reviewer's Guide

This PR adds a global, non-persistent whiteboard layer (canvas) with a toolbar mounted in the top nav, and adjusts testing-time nav hiding to keep the whiteboard tools visible while search/language/theme are hidden.

Sequence diagram for GlobalWhiteboard pen toggle and drawing interaction

sequenceDiagram
  actor User
  participant GlobalWhiteboard
  participant Canvas

  User->>GlobalWhiteboard: click pen button (toggle("pen"))
  GlobalWhiteboard->>GlobalWhiteboard: setTool("pen")
  GlobalWhiteboard->>Canvas: enable pointerEvents auto

  User->>Canvas: pointerDown (onPointerDown)
  Canvas->>GlobalWhiteboard: onPointerDown
  GlobalWhiteboard->>GlobalWhiteboard: getNorm
  GlobalWhiteboard->>GlobalWhiteboard: push Stroke to strokesRef
  GlobalWhiteboard->>Canvas: draw

  User->>Canvas: pointerMove (onPointerMove)
  Canvas->>GlobalWhiteboard: onPointerMove
  GlobalWhiteboard->>GlobalWhiteboard: append Point to currentStrokeRef
  GlobalWhiteboard->>Canvas: draw

  User->>GlobalWhiteboard: click pen button again (toggle("pen"))
  GlobalWhiteboard->>GlobalWhiteboard: clearAll
  GlobalWhiteboard->>Canvas: draw
  GlobalWhiteboard->>GlobalWhiteboard: setTool("none")
  GlobalWhiteboard->>Canvas: disable pointerEvents (none)
Loading

File-Level Changes

Change Details Files
Introduce a full-viewport canvas-based whiteboard with pen/eraser/clear tools that captures pointer events only when explicitly enabled.
  • Implement GlobalWhiteboard React client component with canvas rendering logic, stroke model, and tool state management.
  • Normalize stroke points to viewport-relative coordinates and redraw with device-pixel-ratio aware resizing.
  • Handle pointer events for drawing, erasing whole strokes within a radius, and clearing all strokes, keeping data only in memory.
  • Render a toolbar UI with pen/eraser/clear buttons, visual active state, and explicit exit behavior that clears content when turning tools off.
src/components/GlobalWhiteboard.tsx
Mount the whiteboard toolbar into the top navigation bar without disrupting existing nav layout.
  • Import GlobalWhiteboard into the locale layout and insert it into the top-right nav container after language/theme controls.
  • Wrap search, language toggle, and theme toggle in a .test-hideable span to distinguish what should be hidden during testing from the whiteboard toolbar.
src/app/[locale]/layout.tsx
Refine global CSS to hide only test-hideable nav items during testing and keep the whiteboard toolbar visible, plus minor formatting changes.
  • Change hide-top-nav behavior from hiding the entire top nav bar to hiding only elements marked .test-hideable, with !important specificity.
  • Tighten CSS variable and keyframe formatting without altering behavior.
src/app/globals.css

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了两个问题,并留下了一些整体性的反馈:

  • 在 GlobalWhiteboard 中,getNormonPointerDownonPointerMove 使用了 React.PointerEvent,但没有导入 React 本身;可以选择从 "react" 中导入 type React,或者改用 DOM 的 PointerEvent 类型,以避免类型错误。
给 AI Agent 的提示
Please address the comments from this code review:

## Overall Comments
- In GlobalWhiteboard, `getNorm`, `onPointerDown`, and `onPointerMove` use `React.PointerEvent` but React itself isn’t imported; either import `type React` from "react" or switch these to the DOM `PointerEvent` type to avoid type errors.

## Individual Comments

### Comment 1
<location path="src/components/GlobalWhiteboard.tsx" line_range="184-193" />
<code_context>
+  const exitCanvas = () => {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The "再次点击退出画布" affordance is a non-button span, which limits keyboard accessibility.

The exit hint is implemented as a clickable `<span>` without `role` or `tabIndex`, so it’s not operable via keyboard. Since it triggers an action, it should be a `<button>` or an element with an appropriate ARIA role, focusability, and keyboard activation handling, while preserving the current styling.

Suggested implementation:

```typescript
  const exitCanvas = () => {
    const t = toolRef.current;
    if (t !== "none") toggle(t);
  };

  const active = tool !== "none";

  const btnBase =
    "flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors";
  const btnIdle = "text-muted-foreground hover:text-foreground hover:bg-accent";
  const btnActive = "bg-primary text-primary-foreground shadow-xs";

```

```typescript
<button
  type="button"
  className="text-xs text-muted-foreground cursor-pointer"
  onClick={exitCanvas}
>
  再次点击退出画布
</button>

```

I can’t see the exact markup where the "再次点击退出画布" hint is rendered, so you’ll need to adapt the SEARCH block to match your current `<span>` exactly (including its `className` and any other props).

When doing so:
1. Make sure the new element is a `<button>` with `type="button"` and `onClick={exitCanvas}`.
2. Preserve all existing styling-related classes on the button so the UI remains unchanged.
3. If the span had conditional rendering or was nested in other elements, only replace the span’s opening/closing tags and attributes; keep the surrounding layout intact.
</issue_to_address>

### Comment 2
<location path="src/components/GlobalWhiteboard.tsx" line_range="43" />
<code_context>
+  const strokesRef = useRef<Stroke[]>([]);
+  const drawingRef = useRef(false);
+  const currentStrokeRef = useRef<Stroke | null>(null);
+  const [tool, setTool] = useState<Tool>("none");
+  const toolRef = useRef<Tool>("none");
+  useEffect(() => {
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the whiteboard by using a single `tool` state, shared stroke/eraser helpers, and one exit hint element to reduce duplicated logic and mental overhead.

The dual `tool` state + `toolRef` and some duplicated logic do add unnecessary complexity. You can simplify while preserving all current behavior (including “click same tool clears + exits”).

### 1. Remove dual tool tracking (`tool` + `toolRef`)

You don’t need `toolRef` + syncing `useEffect` if you make your handlers depend on `tool` via `useCallback`. That keeps a single source of truth and removes the sync effect.

```ts
const [tool, setTool] = useState<Tool>("none");

const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
    currentStrokeRef.current = stroke;
    strokesRef.current.push(stroke);
    draw();
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    currentStrokeRef.current?.points.push(p);
    draw();
  },
  [tool, draw],
);

const endStroke = useCallback(() => {
  drawingRef.current = false;
  currentStrokeRef.current = null;
}, []);
```

Then `toggle`/`exitCanvas` can use `tool` directly:

```ts
const toggle = useCallback(
  (t: Exclude<Tool, "none">) => {
    if (tool === t) {
      // 再次点击同一工具 = 退出画布,清空已画内容
      strokesRef.current = [];
      draw();
      setTool("none");
    } else {
      setTool(t);
    }
  },
  [tool, draw],
);

const exitCanvas = useCallback(() => {
  if (tool !== "none") toggle(tool as Exclude<Tool, "none">);
}, [tool, toggle]);
```

This drops `toolRef` and the sync `useEffect` entirely.

### 2. Extract small stroke/eraser helpers to reduce imperative duplication

You can encapsulate the stroke lifecycle so `onPointerDown`/`onPointerMove` only dispatch based on the current tool, keeping side-effects in one place.

```ts
const startStroke = (p: Point) => {
  const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
  currentStrokeRef.current = stroke;
  strokesRef.current.push(stroke);
  draw();
};

const continueStroke = (p: Point) => {
  if (!currentStrokeRef.current) return;
  currentStrokeRef.current.points.push(p);
  draw();
};

const eraseAt = (p: Point) => {
  const w = window.innerWidth;
  const h = window.innerHeight;
  const strokes = strokesRef.current;
  const r2 = ERASER_RADIUS * ERASER_RADIUS;
  let changed = false;

  for (let i = strokes.length - 1; i >= 0; i--) {
    const hit = strokes[i].points.some((pt) => {
      const dx = (pt.x - p.x) * w;
      const dy = (pt.y - p.y) * h;
      return dx * dx + dy * dy <= r2;
    });
    if (hit) {
      strokes.splice(i, 1);
      changed = true;
    }
  }
  if (changed) draw();
};
```

Handlers then become thin:

```ts
const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      startStroke(p);
    }
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      continueStroke(p);
    }
  },
  [tool, draw],
);
```

### 3. Deduplicate the exit hint rendering

The “再次点击退出画布” span is duplicated for pen and eraser with identical behavior. You can render it once when any tool is active without changing semantics:

```tsx
const active = tool !== "none";

return (
  <div className="relative z-50 flex items-center gap-0.5 rounded-lg border bg-background/80 p-0.5 backdrop-blur-sm">
    <button
      type="button"
      aria-label="Pen"
      title="画笔(划线)"
      aria-pressed={tool === "pen"}
      onClick={() => toggle("pen")}
      className={`${btnBase} ${tool === "pen" ? btnActive : btnIdle}`}
    >
      <Pen className="h-4 w-4" />
    </button>

    <button
      type="button"
      aria-label="Eraser"
      title="橡皮(擦除整条线)"
      aria-pressed={tool === "eraser"}
      onClick={() => toggle("eraser")}
      className={`${btnBase} ${tool === "eraser" ? btnActive : btnIdle}`}
    >
      <Eraser className="h-4 w-4" />
    </button>

    {active && (
      <span
        onClick={exitCanvas}
        className="cursor-pointer select-none whitespace-nowrap pl-0.5 pr-1.5 text-xs text-foreground"
      >
        再次点击退出画布
      </span>
    )}

    <button
      type="button"
      aria-label="Clear all"
      title="清空全部草稿"
      onClick={clearAll}
      className={`${btnBase} ${btnIdle}`}
    >
      <Trash2 className="h-4 w-4" />
    </button>
  </div>
);
```

These small refactors keep the feature set intact but reduce mental overhead by eliminating dual tool sources, centralizing stroke logic, and removing duplicated UI.
</issue_to_address>

Sourcery 对开源项目免费——如果你觉得我们的代码评审有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据这些反馈改进后续的评审。
Original comment in English

Hey - I've found 2 issues, and left some high level feedback:

  • In GlobalWhiteboard, getNorm, onPointerDown, and onPointerMove use React.PointerEvent but React itself isn’t imported; either import type React from "react" or switch these to the DOM PointerEvent type to avoid type errors.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In GlobalWhiteboard, `getNorm`, `onPointerDown`, and `onPointerMove` use `React.PointerEvent` but React itself isn’t imported; either import `type React` from "react" or switch these to the DOM `PointerEvent` type to avoid type errors.

## Individual Comments

### Comment 1
<location path="src/components/GlobalWhiteboard.tsx" line_range="184-193" />
<code_context>
+  const exitCanvas = () => {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The "再次点击退出画布" affordance is a non-button span, which limits keyboard accessibility.

The exit hint is implemented as a clickable `<span>` without `role` or `tabIndex`, so it’s not operable via keyboard. Since it triggers an action, it should be a `<button>` or an element with an appropriate ARIA role, focusability, and keyboard activation handling, while preserving the current styling.

Suggested implementation:

```typescript
  const exitCanvas = () => {
    const t = toolRef.current;
    if (t !== "none") toggle(t);
  };

  const active = tool !== "none";

  const btnBase =
    "flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors";
  const btnIdle = "text-muted-foreground hover:text-foreground hover:bg-accent";
  const btnActive = "bg-primary text-primary-foreground shadow-xs";

```

```typescript
<button
  type="button"
  className="text-xs text-muted-foreground cursor-pointer"
  onClick={exitCanvas}
>
  再次点击退出画布
</button>

```

I can’t see the exact markup where the "再次点击退出画布" hint is rendered, so you’ll need to adapt the SEARCH block to match your current `<span>` exactly (including its `className` and any other props).

When doing so:
1. Make sure the new element is a `<button>` with `type="button"` and `onClick={exitCanvas}`.
2. Preserve all existing styling-related classes on the button so the UI remains unchanged.
3. If the span had conditional rendering or was nested in other elements, only replace the span’s opening/closing tags and attributes; keep the surrounding layout intact.
</issue_to_address>

### Comment 2
<location path="src/components/GlobalWhiteboard.tsx" line_range="43" />
<code_context>
+  const strokesRef = useRef<Stroke[]>([]);
+  const drawingRef = useRef(false);
+  const currentStrokeRef = useRef<Stroke | null>(null);
+  const [tool, setTool] = useState<Tool>("none");
+  const toolRef = useRef<Tool>("none");
+  useEffect(() => {
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the whiteboard by using a single `tool` state, shared stroke/eraser helpers, and one exit hint element to reduce duplicated logic and mental overhead.

The dual `tool` state + `toolRef` and some duplicated logic do add unnecessary complexity. You can simplify while preserving all current behavior (including “click same tool clears + exits”).

### 1. Remove dual tool tracking (`tool` + `toolRef`)

You don’t need `toolRef` + syncing `useEffect` if you make your handlers depend on `tool` via `useCallback`. That keeps a single source of truth and removes the sync effect.

```ts
const [tool, setTool] = useState<Tool>("none");

const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
    currentStrokeRef.current = stroke;
    strokesRef.current.push(stroke);
    draw();
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    currentStrokeRef.current?.points.push(p);
    draw();
  },
  [tool, draw],
);

const endStroke = useCallback(() => {
  drawingRef.current = false;
  currentStrokeRef.current = null;
}, []);
```

Then `toggle`/`exitCanvas` can use `tool` directly:

```ts
const toggle = useCallback(
  (t: Exclude<Tool, "none">) => {
    if (tool === t) {
      // 再次点击同一工具 = 退出画布,清空已画内容
      strokesRef.current = [];
      draw();
      setTool("none");
    } else {
      setTool(t);
    }
  },
  [tool, draw],
);

const exitCanvas = useCallback(() => {
  if (tool !== "none") toggle(tool as Exclude<Tool, "none">);
}, [tool, toggle]);
```

This drops `toolRef` and the sync `useEffect` entirely.

### 2. Extract small stroke/eraser helpers to reduce imperative duplication

You can encapsulate the stroke lifecycle so `onPointerDown`/`onPointerMove` only dispatch based on the current tool, keeping side-effects in one place.

```ts
const startStroke = (p: Point) => {
  const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
  currentStrokeRef.current = stroke;
  strokesRef.current.push(stroke);
  draw();
};

const continueStroke = (p: Point) => {
  if (!currentStrokeRef.current) return;
  currentStrokeRef.current.points.push(p);
  draw();
};

const eraseAt = (p: Point) => {
  const w = window.innerWidth;
  const h = window.innerHeight;
  const strokes = strokesRef.current;
  const r2 = ERASER_RADIUS * ERASER_RADIUS;
  let changed = false;

  for (let i = strokes.length - 1; i >= 0; i--) {
    const hit = strokes[i].points.some((pt) => {
      const dx = (pt.x - p.x) * w;
      const dy = (pt.y - p.y) * h;
      return dx * dx + dy * dy <= r2;
    });
    if (hit) {
      strokes.splice(i, 1);
      changed = true;
    }
  }
  if (changed) draw();
};
```

Handlers then become thin:

```ts
const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      startStroke(p);
    }
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      continueStroke(p);
    }
  },
  [tool, draw],
);
```

### 3. Deduplicate the exit hint rendering

The “再次点击退出画布” span is duplicated for pen and eraser with identical behavior. You can render it once when any tool is active without changing semantics:

```tsx
const active = tool !== "none";

return (
  <div className="relative z-50 flex items-center gap-0.5 rounded-lg border bg-background/80 p-0.5 backdrop-blur-sm">
    <button
      type="button"
      aria-label="Pen"
      title="画笔(划线)"
      aria-pressed={tool === "pen"}
      onClick={() => toggle("pen")}
      className={`${btnBase} ${tool === "pen" ? btnActive : btnIdle}`}
    >
      <Pen className="h-4 w-4" />
    </button>

    <button
      type="button"
      aria-label="Eraser"
      title="橡皮(擦除整条线)"
      aria-pressed={tool === "eraser"}
      onClick={() => toggle("eraser")}
      className={`${btnBase} ${tool === "eraser" ? btnActive : btnIdle}`}
    >
      <Eraser className="h-4 w-4" />
    </button>

    {active && (
      <span
        onClick={exitCanvas}
        className="cursor-pointer select-none whitespace-nowrap pl-0.5 pr-1.5 text-xs text-foreground"
      >
        再次点击退出画布
      </span>
    )}

    <button
      type="button"
      aria-label="Clear all"
      title="清空全部草稿"
      onClick={clearAll}
      className={`${btnBase} ${btnIdle}`}
    >
      <Trash2 className="h-4 w-4" />
    </button>
  </div>
);
```

These small refactors keep the feature set intact but reduce mental overhead by eliminating dual tool sources, centralizing stroke logic, and removing duplicated UI.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +184 to +193
const exitCanvas = () => {
const t = toolRef.current;
if (t !== "none") toggle(t);
};

const active = tool !== "none";

const btnBase =
"flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors";
const btnIdle = "text-muted-foreground hover:text-foreground hover:bg-accent";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): "再次点击退出画布" 这个交互目前是一个非按钮的 span,会限制键盘可访问性。

现在的退出提示是用一个可点击的 <span> 来实现的,但没有 roletabIndex,所以无法通过键盘操作。由于它会触发一个动作,应该改成 <button>,或者使用一个具有合适 ARIA role、可聚焦并支持键盘激活的元素,同时保持现有样式不变。

建议的实现方式如下:

  const exitCanvas = () => {
    const t = toolRef.current;
    if (t !== "none") toggle(t);
  };

  const active = tool !== "none";

  const btnBase =
    "flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors";
  const btnIdle = "text-muted-foreground hover:text-foreground hover:bg-accent";
  const btnActive = "bg-primary text-primary-foreground shadow-xs";
<button
  type="button"
  className="text-xs text-muted-foreground cursor-pointer"
  onClick={exitCanvas}
>
  再次点击退出画布
</button>

我现在看不到 "再次点击退出画布" 提示的具体标记结构,所以你需要根据当前的 <span>,在 SEARCH 区块中进行适配(包括它的 className 以及其它所有属性)。

在修改时请注意:

  1. 确保新元素是一个 <button>,并带有 type="button"onClick={exitCanvas}
  2. 保留原有的所有样式相关类名,确保 UI 外观保持一致。
  3. 如果原来的 span 是条件渲染的,或被嵌套在其它元素中,只替换 span 的开始/结束标签和属性,保持周围布局不变。
Original comment in English

suggestion (bug_risk): The "再次点击退出画布" affordance is a non-button span, which limits keyboard accessibility.

The exit hint is implemented as a clickable <span> without role or tabIndex, so it’s not operable via keyboard. Since it triggers an action, it should be a <button> or an element with an appropriate ARIA role, focusability, and keyboard activation handling, while preserving the current styling.

Suggested implementation:

  const exitCanvas = () => {
    const t = toolRef.current;
    if (t !== "none") toggle(t);
  };

  const active = tool !== "none";

  const btnBase =
    "flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors";
  const btnIdle = "text-muted-foreground hover:text-foreground hover:bg-accent";
  const btnActive = "bg-primary text-primary-foreground shadow-xs";
<button
  type="button"
  className="text-xs text-muted-foreground cursor-pointer"
  onClick={exitCanvas}
>
  再次点击退出画布
</button>

I can’t see the exact markup where the "再次点击退出画布" hint is rendered, so you’ll need to adapt the SEARCH block to match your current <span> exactly (including its className and any other props).

When doing so:

  1. Make sure the new element is a <button> with type="button" and onClick={exitCanvas}.
  2. Preserve all existing styling-related classes on the button so the UI remains unchanged.
  3. If the span had conditional rendering or was nested in other elements, only replace the span’s opening/closing tags and attributes; keep the surrounding layout intact.

const strokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef(false);
const currentStrokeRef = useRef<Stroke | null>(null);
const [tool, setTool] = useState<Tool>("none");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): 建议通过只使用一个 tool 状态、共享的画笔/橡皮辅助函数,以及单一的退出提示元素来简化白板逻辑,从而减少重复逻辑和心智负担。

当前双重的 tool 状态 + toolRef 再加上一些重复逻辑,会带来不必要的复杂度。在不改变现有行为(包括“再次点击同一工具清空并退出”)的前提下,可以进行简化。

1. 移除双重工具跟踪(tool + toolRef

如果让事件处理函数通过 useCallback 依赖 tool,就不需要 toolRef 和同步的 useEffect。这样可以保持单一的真值来源,并移除同步副作用。

const [tool, setTool] = useState<Tool>("none");

const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
    currentStrokeRef.current = stroke;
    strokesRef.current.push(stroke);
    draw();
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    currentStrokeRef.current?.points.push(p);
    draw();
  },
  [tool, draw],
);

const endStroke = useCallback(() => {
  drawingRef.current = false;
  currentStrokeRef.current = null;
}, []);

然后让 toggle / exitCanvas 直接使用 tool

const toggle = useCallback(
  (t: Exclude<Tool, "none">) => {
    if (tool === t) {
      // 再次点击同一工具 = 退出画布,清空已画内容
      strokesRef.current = [];
      draw();
      setTool("none");
    } else {
      setTool(t);
    }
  },
  [tool, draw],
);

const exitCanvas = useCallback(() => {
  if (tool !== "none") toggle(tool as Exclude<Tool, "none">);
}, [tool, toggle]);

这样可以完全移除 toolRef 和同步的 useEffect

2. 抽取小的画笔/橡皮辅助函数,减少命令式代码重复

可以将笔划的生命周期封装起来,让 onPointerDown / onPointerMove 只根据当前工具类型做分发,从而把副作用集中在一个位置。

const startStroke = (p: Point) => {
  const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
  currentStrokeRef.current = stroke;
  strokesRef.current.push(stroke);
  draw();
};

const continueStroke = (p: Point) => {
  if (!currentStrokeRef.current) return;
  currentStrokeRef.current.points.push(p);
  draw();
};

const eraseAt = (p: Point) => {
  const w = window.innerWidth;
  const h = window.innerHeight;
  const strokes = strokesRef.current;
  const r2 = ERASER_RADIUS * ERASER_RADIUS;
  let changed = false;

  for (let i = strokes.length - 1; i >= 0; i--) {
    const hit = strokes[i].points.some((pt) => {
      const dx = (pt.x - p.x) * w;
      const dy = (pt.y - p.y) * h;
      return dx * dx + dy * dy <= r2;
    });
    if (hit) {
      strokes.splice(i, 1);
      changed = true;
    }
  }
  if (changed) draw();
};

这样事件处理函数就可以变得更精简:

const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      startStroke(p);
    }
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      continueStroke(p);
    }
  },
  [tool, draw],
);

3. 去重退出提示元素的渲染逻辑

“再次点击退出画布” 的 span 在画笔和橡皮中都重复出现,并且行为完全相同。你可以在任意工具激活时只渲染一次这个提示,不会改变语义:

const active = tool !== "none";

return (
  <div className="relative z-50 flex items-center gap-0.5 rounded-lg border bg-background/80 p-0.5 backdrop-blur-sm">
    <button
      type="button"
      aria-label="Pen"
      title="画笔(划线)"
      aria-pressed={tool === "pen"}
      onClick={() => toggle("pen")}
      className={`${btnBase} ${tool === "pen" ? btnActive : btnIdle}`}
    >
      <Pen className="h-4 w-4" />
    </button>

    <button
      type="button"
      aria-label="Eraser"
      title="橡皮(擦除整条线)"
      aria-pressed={tool === "eraser"}
      onClick={() => toggle("eraser")}
      className={`${btnBase} ${tool === "eraser" ? btnActive : btnIdle}`}
    >
      <Eraser className="h-4 w-4" />
    </button>

    {active && (
      <span
        onClick={exitCanvas}
        className="cursor-pointer select-none whitespace-nowrap pl-0.5 pr-1.5 text-xs text-foreground"
      >
        再次点击退出画布
      </span>
    )}

    <button
      type="button"
      aria-label="Clear all"
      title="清空全部草稿"
      onClick={clearAll}
      className={`${btnBase} ${btnIdle}`}
    >
      <Trash2 className="h-4 w-4" />
    </button>
  </div>
);

这些小的重构可以在保持现有功能不变的前提下,降低心智负担:去掉双重工具状态、集中笔划逻辑,并移除重复的 UI。

Original comment in English

issue (complexity): Consider simplifying the whiteboard by using a single tool state, shared stroke/eraser helpers, and one exit hint element to reduce duplicated logic and mental overhead.

The dual tool state + toolRef and some duplicated logic do add unnecessary complexity. You can simplify while preserving all current behavior (including “click same tool clears + exits”).

1. Remove dual tool tracking (tool + toolRef)

You don’t need toolRef + syncing useEffect if you make your handlers depend on tool via useCallback. That keeps a single source of truth and removes the sync effect.

const [tool, setTool] = useState<Tool>("none");

const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
    currentStrokeRef.current = stroke;
    strokesRef.current.push(stroke);
    draw();
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
      return;
    }
    currentStrokeRef.current?.points.push(p);
    draw();
  },
  [tool, draw],
);

const endStroke = useCallback(() => {
  drawingRef.current = false;
  currentStrokeRef.current = null;
}, []);

Then toggle/exitCanvas can use tool directly:

const toggle = useCallback(
  (t: Exclude<Tool, "none">) => {
    if (tool === t) {
      // 再次点击同一工具 = 退出画布,清空已画内容
      strokesRef.current = [];
      draw();
      setTool("none");
    } else {
      setTool(t);
    }
  },
  [tool, draw],
);

const exitCanvas = useCallback(() => {
  if (tool !== "none") toggle(tool as Exclude<Tool, "none">);
}, [tool, toggle]);

This drops toolRef and the sync useEffect entirely.

2. Extract small stroke/eraser helpers to reduce imperative duplication

You can encapsulate the stroke lifecycle so onPointerDown/onPointerMove only dispatch based on the current tool, keeping side-effects in one place.

const startStroke = (p: Point) => {
  const stroke: Stroke = { points: [p], color: PEN_COLOR, size: PEN_SIZE };
  currentStrokeRef.current = stroke;
  strokesRef.current.push(stroke);
  draw();
};

const continueStroke = (p: Point) => {
  if (!currentStrokeRef.current) return;
  currentStrokeRef.current.points.push(p);
  draw();
};

const eraseAt = (p: Point) => {
  const w = window.innerWidth;
  const h = window.innerHeight;
  const strokes = strokesRef.current;
  const r2 = ERASER_RADIUS * ERASER_RADIUS;
  let changed = false;

  for (let i = strokes.length - 1; i >= 0; i--) {
    const hit = strokes[i].points.some((pt) => {
      const dx = (pt.x - p.x) * w;
      const dy = (pt.y - p.y) * h;
      return dx * dx + dy * dy <= r2;
    });
    if (hit) {
      strokes.splice(i, 1);
      changed = true;
    }
  }
  if (changed) draw();
};

Handlers then become thin:

const onPointerDown = useCallback(
  (e: React.PointerEvent) => {
    if (tool === "none") return;
    e.preventDefault();
    try {
      (e.target as Element).setPointerCapture?.(e.pointerId);
    } catch {}

    drawingRef.current = true;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      startStroke(p);
    }
  },
  [tool, draw],
);

const onPointerMove = useCallback(
  (e: React.PointerEvent) => {
    if (!drawingRef.current) return;
    const p = getNorm(e);
    if (tool === "eraser") {
      eraseAt(p);
    } else {
      continueStroke(p);
    }
  },
  [tool, draw],
);

3. Deduplicate the exit hint rendering

The “再次点击退出画布” span is duplicated for pen and eraser with identical behavior. You can render it once when any tool is active without changing semantics:

const active = tool !== "none";

return (
  <div className="relative z-50 flex items-center gap-0.5 rounded-lg border bg-background/80 p-0.5 backdrop-blur-sm">
    <button
      type="button"
      aria-label="Pen"
      title="画笔(划线)"
      aria-pressed={tool === "pen"}
      onClick={() => toggle("pen")}
      className={`${btnBase} ${tool === "pen" ? btnActive : btnIdle}`}
    >
      <Pen className="h-4 w-4" />
    </button>

    <button
      type="button"
      aria-label="Eraser"
      title="橡皮(擦除整条线)"
      aria-pressed={tool === "eraser"}
      onClick={() => toggle("eraser")}
      className={`${btnBase} ${tool === "eraser" ? btnActive : btnIdle}`}
    >
      <Eraser className="h-4 w-4" />
    </button>

    {active && (
      <span
        onClick={exitCanvas}
        className="cursor-pointer select-none whitespace-nowrap pl-0.5 pr-1.5 text-xs text-foreground"
      >
        再次点击退出画布
      </span>
    )}

    <button
      type="button"
      aria-label="Clear all"
      title="清空全部草稿"
      onClick={clearAll}
      className={`${btnBase} ${btnIdle}`}
    >
      <Trash2 className="h-4 w-4" />
    </button>
  </div>
);

These small refactors keep the feature set intact but reduce mental overhead by eliminating dual tool sources, centralizing stroke logic, and removing duplicated UI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant