Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
- `vp check`
- `vp run typecheck`

## 字圖與鏤空描寫

查詢未收錄的字詞時,字圖頁提供整詞字圖與分段字圖,可用滑鼠或觸控描寫。
重複出現的字各有獨立描寫區,筆跡不會寫到另一個同字;「清除描寫」會清空整詞及所有分段的筆跡。

## 使用自己的 Cloudflare R2 開發(必看)

本專案啟動前,**必須先將靜態資源與字典資料上傳到自己的 R2**。
Expand Down
7 changes: 4 additions & 3 deletions src/components/CharacterImageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -874,8 +874,9 @@ export function CharacterImageView({
</td>
</tr>
) : (
segments.map((segment) => (
<tr key={segment.part}>
// Repeated terms need separate row, canvas and drawing-state identities (#175).
segments.map((segment, index) => (
<tr key={`${index}:${segment.part}`}>
<td style={{ verticalAlign: "top", padding: 4 }}>
<div
className="charimg-practice-box"
Expand All @@ -888,7 +889,7 @@ export function CharacterImageView({
style={{ width: SEGMENT_IMAGE_SIZE, height: SEGMENT_IMAGE_SIZE }}
/>
{renderPracticeCanvas(
`segment:${segment.part}`,
`segment:${index}:${segment.part}`,
SEGMENT_IMAGE_SIZE,
SEGMENT_IMAGE_SIZE,
)}
Expand Down
79 changes: 79 additions & 0 deletions tests/e2e/charimg-drawing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "./_fixtures";

// #175: identical segment text must not route input to the last occurrence.
const REPEATED_WORD_PATH = `/${encodeURIComponent("飛飛龍飛")}`;

async function inkFlags(canvases: Locator): Promise<boolean[]> {
return canvases.evaluateAll((elements) =>
elements.map((element) => {
const canvas = element as HTMLCanvasElement;
const pixels = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data;
return pixels.some((value, index) => index % 4 === 3 && value > 0);
}),
);
}

async function drawingOrigin(canvas: Locator): Promise<{ x: number; y: number }> {
await canvas.scrollIntoViewIfNeeded();
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
return { x: box!.x + 40, y: box!.y + 40 };
}

async function drawMouse(page: Page, canvas: Locator): Promise<void> {
const { x, y } = await drawingOrigin(canvas);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + 50, y + 50, { steps: 10 });
await page.mouse.up();
}

for (const input of ["mouse", "touch"] as const) {
test.describe(`repeated hollow glyphs: ${input}`, () => {
test.use({
hasTouch: input === "touch",
viewport: input === "touch" ? { width: 390, height: 844 } : { width: 1280, height: 800 },
});

test("draws each occurrence independently and clears every canvas", async ({ page }) => {
await page.goto(REPEATED_WORD_PATH);
const canvases = page.locator(".charimg-draw-canvas");
// Main word + 飛 / 飛 / 龍 / 飛, with adjacent and non-adjacent repeats.
await expect(canvases).toHaveCount(5);
await expect(page.getByLabel("鏤空描寫模式")).toBeChecked();
expect(await inkFlags(canvases)).toEqual([false, false, false, false, false]);

const session = input === "touch" ? await page.context().newCDPSession(page) : null;
try {
for (let index = 0; index < 5; index++) {
const canvas = canvases.nth(index);
if (session) {
const { x, y } = await drawingOrigin(canvas);
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x, y, id: 1 }],
});
for (let step = 1; step <= 10; step++) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + step * 5, y: y + step * 5, id: 1 }],
});
}
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} else {
await drawMouse(page, canvas);
}
expect(await inkFlags(canvases)).toEqual(
Array.from({ length: 5 }, (_, position) => position <= index),
);
}

await page.getByRole("button", { name: "清除描寫" }).click();
expect(await inkFlags(canvases)).toEqual([false, false, false, false, false]);
} finally {
await session?.detach();
}
});
});
}
Loading