diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/build-and-test.yaml
similarity index 52%
rename from .github/workflows/test-ui.yaml
rename to .github/workflows/build-and-test.yaml
index bbc9470..8e2c5f8 100644
--- a/.github/workflows/test-ui.yaml
+++ b/.github/workflows/build-and-test.yaml
@@ -1,4 +1,4 @@
-name: UI Tests
+name: Build and Test
on:
pull_request:
@@ -6,6 +6,29 @@ on:
- main
jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24.x"
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Run build
+ run: pnpm build
+
+ - name: Run tsc check
+ run: pnpm tsc --project tsconfig.json
+
test:
runs-on: ubuntu-latest
container:
@@ -27,5 +50,8 @@ jobs:
- name: Install dependencies
run: pnpm install
- - name: Run tests
+ - name: Run unit tests
+ run: pnpm test
+
+ - name: Run UI tests
run: pnpm test-storybook
diff --git a/.github/workflows/build-build.yaml b/.github/workflows/build-build.yaml
deleted file mode 100644
index 35ecb6e..0000000
--- a/.github/workflows/build-build.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: Run build
-
-on:
- pull_request:
- branches:
- - main
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "24.x"
-
- - name: Install dependencies
- run: pnpm install
-
- - name: Run build
- run: pnpm build
diff --git a/.github/workflows/build-tests.yaml b/.github/workflows/build-tests.yaml
deleted file mode 100644
index 457500b..0000000
--- a/.github/workflows/build-tests.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: Run unit tests
-
-on:
- pull_request:
- branches:
- - main
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "24.x"
-
- - name: Install dependencies
- run: pnpm install
-
- - name: Run tests
- run: pnpm test
diff --git a/.github/workflows/build-tsc.yaml b/.github/workflows/build-tsc.yaml
deleted file mode 100644
index a4779fd..0000000
--- a/.github/workflows/build-tsc.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: Run tsc check
-
-on:
- pull_request:
- branches:
- - main
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "24.x"
-
- - name: Install dependencies
- run: pnpm install
-
- - name: Run tests
- run: pnpm tsc --project tsconfig.json
diff --git a/README.md b/README.md
index 738da69..fdaf894 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# React Paint
+[](https://www.npmjs.com/package/@yorab/react-paint)
+[](https://github.com/YoRab/react-paint-editor/blob/main/LICENSE.txt)
+[](https://github.com/YoRab/react-paint-editor/actions/workflows/build-and-test.yaml)
+
An open-source canvas-based library for React used for image annotation or as a digital whiteboard.
## Features
diff --git a/src/canvas/utils/shapes/line.ts b/src/canvas/utils/shapes/line.ts
index 026906e..eb1f290 100644
--- a/src/canvas/utils/shapes/line.ts
+++ b/src/canvas/utils/shapes/line.ts
@@ -118,7 +118,7 @@ export const buildTriangleOnLine = (center: Point, rotation: number, lineStyle:
export const drawLine = (ctx: CanvasRenderingContext2D, shape: ShapeEntity<'line'>): void => {
drawPathWithFillAndStroke(ctx, shape.path, shape.style)
for (const arrow of shape.arrows ?? []) {
- updateCanvasContext(ctx, arrow.style)
+ updateCanvasContext(ctx, { ...arrow.style, opacity: 100 })
drawTriangle(ctx, arrow)
}
}
diff --git a/src/canvas/utils/shapes/picture.ts b/src/canvas/utils/shapes/picture.ts
index 155d063..8e9909d 100644
--- a/src/canvas/utils/shapes/picture.ts
+++ b/src/canvas/utils/shapes/picture.ts
@@ -42,7 +42,10 @@ const createPictureShape = (
height,
ratio: height === 0 ? 1 : width / height,
src: storedSrc,
- img
+ img,
+ style: {
+ opacity: 100
+ }
},
settings
)
diff --git a/src/editor/components/settings/SettingsBar.tsx b/src/editor/components/settings/SettingsBar.tsx
index 9df7479..db382b3 100644
--- a/src/editor/components/settings/SettingsBar.tsx
+++ b/src/editor/components/settings/SettingsBar.tsx
@@ -142,6 +142,7 @@ const SettingsItems = ({
disabled={disabled}
field='fontBold'
icon={boldIcon}
+ title='Gras'
valueChanged={handleShapeFontFamilyChange}
values={selectedShapeTool.settings.fontBold.values}
value={selectedShape.style?.fontBold}
@@ -154,6 +155,7 @@ const SettingsItems = ({
disabled={disabled}
field='fontItalic'
icon={italicIcon}
+ title='Italique'
valueChanged={handleShapeFontFamilyChange}
values={selectedShapeTool.settings.fontItalic.values}
value={selectedShape.style?.fontItalic}
@@ -275,6 +277,7 @@ const SettingsItems = ({
disabled={disabled}
field='fontBold'
icon={boldIcon}
+ title='Gras'
valueChanged={handleShapeFontFamilyChange}
values={activeTool.settings.fontBold.values}
value={activeTool.settings.fontBold.default}
@@ -287,6 +290,7 @@ const SettingsItems = ({
disabled={disabled}
field='fontItalic'
icon={italicIcon}
+ title='Italique'
valueChanged={handleShapeFontFamilyChange}
values={activeTool.settings.fontItalic.values}
value={activeTool.settings.fontItalic.default}
diff --git a/src/editor/components/settings/ToggleField.tsx b/src/editor/components/settings/ToggleField.tsx
index 73d79f9..4189830 100644
--- a/src/editor/components/settings/ToggleField.tsx
+++ b/src/editor/components/settings/ToggleField.tsx
@@ -8,12 +8,13 @@ type ShapeStyleSelectType = {
disabled?: boolean | undefined
field: string
icon: string
+ title?: string
values: boolean[]
value?: boolean | undefined
valueChanged: (field: string, value: boolean) => void
}
-const ToggleField = ({ setSelectedSettings, disabled = false, field, icon, values, value = false, valueChanged }: ShapeStyleSelectType) => {
+const ToggleField = ({ setSelectedSettings, disabled = false, field, icon, title, values, value = false, valueChanged }: ShapeStyleSelectType) => {
const [customKey] = useState(uniqueId('settings_'))
const handleClick = () => {
@@ -25,7 +26,7 @@ const ToggleField = ({ setSelectedSettings, disabled = false, field, icon, value
if (values.length !== 2) return null
- return
+ return
}
export default ToggleField
diff --git a/stories/tests/Canvas.stories.tsx b/stories/tests/Canvas.stories.tsx
deleted file mode 100644
index 631dc60..0000000
--- a/stories/tests/Canvas.stories.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react-vite'
-import { expect, userEvent, within } from 'storybook/test'
-import { Canvas, Editor, type StateData, useReactPaint } from '../../src/index'
-
-const getCurrentDataRef = { current: null as null | (() => StateData) }
-
-const ReactPaintWrapper = (args: Parameters[0]) => {
- const { editorProps, canvasProps, getCurrentData } = useReactPaint(args)
- getCurrentDataRef.current = getCurrentData
-
- return (
-
-
-
- )
-}
-
-const meta = {
- title: 'Tests/Canvas',
- component: ReactPaintWrapper,
- parameters: {
- layout: 'centered'
- }
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const DrawBrushShape: Story = {
- args: {},
- play: async ({ canvasElement }) => {
- const view = within(canvasElement)
-
- // 1. Sélectionner la brosse (gestion bouton direct ou sous-menu)
- let brushButton = view.queryByTestId('tool-brush')
- if (!brushButton) {
- const toggleBtn = await view.findByRole('button', { name: 'Toggle tools' })
- await userEvent.click(toggleBtn)
- brushButton = await view.findByTestId('tool-brush')
- }
- await userEvent.click(brushButton)
-
- // 2. Localiser le draw canvas
- const drawCanvas = await view.findByTestId('draw-canvas')
-
- // Coordonnées relatives à la position du canvas dans le viewport
- const rect = drawCanvas.getBoundingClientRect()
- const cx = rect.left + rect.width / 2
- const cy = rect.top + rect.height / 2
- const r = Math.min(rect.width, rect.height) * 0.2
- const STEPS = 16
-
- // 3. Mousedown sur le canvas (déclenche focusin → isInsideCanvas = true)
- const user = userEvent.setup()
- await user.pointer({
- target: drawCanvas,
- keys: '[MouseLeft>]',
- coords: { x: cx + r, y: cy }
- })
-
- // Laisser React traiter focusin et enregistrer le listener mouseup sur document
- await new Promise(res => setTimeout(res, 50))
-
- // 4. Tracé du cercle
- for (let i = 1; i <= STEPS; i++) {
- const angle = (i / STEPS) * 2 * Math.PI
- await user.pointer({
- target: drawCanvas,
- coords: { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) }
- })
- }
-
- // 5. Mouseup → sauvegarde du tracé
- await user.pointer({
- target: drawCanvas,
- keys: '[/MouseLeft]',
- coords: { x: cx + r, y: cy }
- })
- await new Promise(res => setTimeout(res, 100))
-
- // 6. Comparer les données exportées
- expect(getCurrentDataRef.current).not.toBeNull()
- const data = getCurrentDataRef.current!()
-
- expect(data.shapes).toHaveLength(1)
- expect(data.shapes![0]).toMatchObject({
- type: 'brush',
- style: {
- strokeColor: 'black',
- opacity: 100,
- lineWidth: 10,
- lineDash: 0
- }
- })
- expect(data.shapes![0]).not.toHaveProperty('id')
- expect(data.shapes![0]).not.toHaveProperty('path')
- expect(data.shapes![0]).not.toHaveProperty('computed')
- expect(data.shapes![0]).not.toHaveProperty('selection')
- const brushShape = data.shapes![0] as { points: [number, number][][] }
- expect(brushShape.points).toHaveLength(1)
- expect(brushShape.points[0]!.length).toBeGreaterThan(STEPS * 0.5)
- }
-}
diff --git a/stories/tests/draw/DrawBrushShape.stories.tsx b/stories/tests/draw/DrawBrushShape.stories.tsx
new file mode 100644
index 0000000..636422f
--- /dev/null
+++ b/stories/tests/draw/DrawBrushShape.stories.tsx
@@ -0,0 +1,98 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, within } from 'storybook/test'
+import {
+ ReactPaintWrapper,
+ getCurrentDataRef,
+ selectTool,
+ assertNoInternalFields,
+ setColorSetting,
+ setRangeSetting,
+ setSelectSetting
+} from './../helpers'
+
+const meta = {
+ title: 'Tests/Draw',
+ component: ReactPaintWrapper,
+ parameters: {
+ layout: 'centered'
+ }
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const OFFSET = 120
+
+export const DrawBrushShape: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const view = within(canvasElement)
+ await selectTool(view, 'brush')
+
+ // --- Shape 1 settings ---
+ await setColorSetting(view, 'Couleur du trait', 'red')
+ await setRangeSetting(view, 'Epaisseur du trait', 14)
+ await setSelectSetting(view, 'Type de traits', '1')
+ await setRangeSetting(view, 'Opacité', 50)
+
+ const drawCanvas = await view.findByTestId('draw-canvas')
+ const rect = drawCanvas.getBoundingClientRect()
+ const cx = rect.left + rect.width / 2
+ const cy = rect.top + rect.height / 2
+ const r = Math.min(rect.width, rect.height) * 0.15
+ const STEPS = 16
+
+ const user = userEvent.setup()
+
+ // --- Draw shape 1 (centered at cx - OFFSET) ---
+ const cx1 = cx - OFFSET
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx1 + r, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ for (let i = 1; i <= STEPS; i++) {
+ const angle = (i / STEPS) * 2 * Math.PI
+ await user.pointer({ target: drawCanvas, coords: { x: cx1 + r * Math.cos(angle), y: cy + r * Math.sin(angle) } })
+ }
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx1 + r, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Draw shape 2 (centered at cx + OFFSET, inherits shape 1's tool settings) ---
+ const cx2 = cx + OFFSET
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx2 + r, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ for (let i = 1; i <= STEPS; i++) {
+ const angle = (i / STEPS) * 2 * Math.PI
+ await user.pointer({ target: drawCanvas, coords: { x: cx2 + r * Math.cos(angle), y: cy + r * Math.sin(angle) } })
+ }
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx2 + r, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Select shape 2 (brush does not auto-select, switch to selection tool and click on the stroke) ---
+ await selectTool(view, 'selection')
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx2 + r, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Shape 2 settings (shape 2 is now selected) ---
+ await setColorSetting(view, 'Couleur du trait', 'green')
+ await setRangeSetting(view, 'Epaisseur du trait', 8)
+ await setSelectSetting(view, 'Type de traits', '2')
+ await setRangeSetting(view, 'Opacité', 75)
+
+ // --- Assertions ---
+ expect(getCurrentDataRef.current).not.toBeNull()
+ const data = getCurrentDataRef.current!()
+
+ expect(data.shapes).toHaveLength(2)
+
+ expect(data.shapes![1]).toMatchObject({ type: 'brush', style: { strokeColor: 'red', opacity: 50, lineWidth: 14, lineDash: 1 } })
+ assertNoInternalFields(data.shapes![1])
+ const brush1 = data.shapes![1] as { points: [number, number][][] }
+ expect(brush1.points).toHaveLength(1)
+ expect(brush1.points[0]!.length).toBeGreaterThan(STEPS * 0.5)
+
+ expect(data.shapes![0]).toMatchObject({ type: 'brush', style: { strokeColor: 'green', opacity: 75, lineWidth: 8, lineDash: 2 } })
+ assertNoInternalFields(data.shapes![0])
+ const brush2 = data.shapes![0] as { points: [number, number][][] }
+ expect(brush2.points).toHaveLength(1)
+ expect(brush2.points[0]!.length).toBeGreaterThan(STEPS * 0.5)
+ }
+}
diff --git a/stories/tests/draw/DrawCircle.stories.tsx b/stories/tests/draw/DrawCircle.stories.tsx
new file mode 100644
index 0000000..3169ddf
--- /dev/null
+++ b/stories/tests/draw/DrawCircle.stories.tsx
@@ -0,0 +1,90 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, within } from 'storybook/test'
+import {
+ ReactPaintWrapper,
+ getCurrentDataRef,
+ selectTool,
+ assertNoInternalFields,
+ setColorSetting,
+ setRangeSetting,
+ setSelectSetting
+} from './../helpers'
+
+const meta = {
+ title: 'Tests/Draw',
+ component: ReactPaintWrapper,
+ parameters: {
+ layout: 'centered'
+ }
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const OFFSET = 120
+
+export const DrawCircle: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const view = within(canvasElement)
+ await selectTool(view, 'circle')
+
+ // --- Shape 1 settings ---
+ await setColorSetting(view, 'Couleur du trait', 'red')
+ await setColorSetting(view, 'Couleur de fond', 'blue')
+ await setRangeSetting(view, 'Epaisseur du trait', 5)
+ await setSelectSetting(view, 'Type de traits', '1')
+ await setRangeSetting(view, 'Opacité', 50)
+
+ const drawCanvas = await view.findByTestId('draw-canvas')
+ const rect = drawCanvas.getBoundingClientRect()
+ const cx = rect.left + rect.width / 2
+ const cy = rect.top + rect.height / 2
+
+ const user = userEvent.setup()
+
+ // --- Draw shape 1 (centered at cx - OFFSET) ---
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx - OFFSET - 50, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, coords: { x: cx - OFFSET + 50, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx - OFFSET + 50, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Draw shape 2 (centered at cx + OFFSET, with shape 1's settings) ---
+ await selectTool(view, 'circle')
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx + OFFSET - 50, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, coords: { x: cx + OFFSET + 50, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx + OFFSET + 50, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Shape 2 settings (shape 2 is now selected) ---
+ await setColorSetting(view, 'Couleur du trait', 'green')
+ await setColorSetting(view, 'Couleur de fond', 'yellow')
+ await setRangeSetting(view, 'Epaisseur du trait', 10)
+ await setSelectSetting(view, 'Type de traits', '2')
+ await setRangeSetting(view, 'Opacité', 75)
+
+ // --- Assertions ---
+ expect(getCurrentDataRef.current).not.toBeNull()
+ const data = getCurrentDataRef.current!()
+
+ expect(data.shapes).toHaveLength(2)
+
+ expect(data.shapes![1]).toMatchObject({
+ type: 'circle',
+ style: { strokeColor: 'red', fillColor: 'blue', opacity: 50, lineWidth: 5, lineDash: 1 }
+ })
+ assertNoInternalFields(data.shapes![1])
+ expect((data.shapes![1] as { radius: number }).radius).toBeGreaterThan(0)
+
+ expect(data.shapes![0]).toMatchObject({
+ type: 'circle',
+ style: { strokeColor: 'green', fillColor: 'yellow', opacity: 75, lineWidth: 10, lineDash: 2 }
+ })
+ assertNoInternalFields(data.shapes![0])
+ expect((data.shapes![0] as { radius: number }).radius).toBeGreaterThan(0)
+ }
+}
diff --git a/stories/tests/draw/DrawCurve.stories.tsx b/stories/tests/draw/DrawCurve.stories.tsx
new file mode 100644
index 0000000..4b342e3
--- /dev/null
+++ b/stories/tests/draw/DrawCurve.stories.tsx
@@ -0,0 +1,115 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, within } from 'storybook/test'
+import {
+ ReactPaintWrapper,
+ getCurrentDataRef,
+ selectTool,
+ assertNoInternalFields,
+ setColorSetting,
+ setRangeSetting,
+ setSelectSetting,
+ openContextMenuAndClick
+} from './../helpers'
+
+const meta = {
+ title: 'Tests/Draw',
+ component: ReactPaintWrapper,
+ parameters: {
+ layout: 'centered'
+ }
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const OFFSET = 120
+
+export const DrawCurve: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const view = within(canvasElement)
+ await selectTool(view, 'curve')
+
+ // --- Shape 1 settings ---
+ await setColorSetting(view, 'Couleur du trait', 'red')
+ await setColorSetting(view, 'Couleur de fond', 'blue')
+ await setRangeSetting(view, 'Epaisseur du trait', 5)
+ await setSelectSetting(view, 'Type de traits', '1')
+ await setSelectSetting(view, 'Fermer les points', 'Oui')
+ await setRangeSetting(view, 'Opacité', 50)
+
+ const drawCanvas = await view.findByTestId('draw-canvas')
+ const rect = drawCanvas.getBoundingClientRect()
+ const cx = rect.left + rect.width / 2
+ const cy = rect.top + rect.height / 2
+
+ const user = userEvent.setup()
+
+ // --- Draw shape 1 (centered at cx - OFFSET) ---
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx - OFFSET - 60, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx - OFFSET, y: cy - 60 } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx - OFFSET + 60, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Draw shape 2 (centered at cx + OFFSET, with shape 1's settings) ---
+ await selectTool(view, 'curve')
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx + OFFSET - 60, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx + OFFSET, y: cy - 60 } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx + OFFSET + 60, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+ // Exit drawing mode, then click the shape to select it
+ await user.pointer({ target: drawCanvas, keys: '[MouseRight]', coords: { x: cx + OFFSET + 60, y: cy } })
+ await new Promise(res => setTimeout(res, 200))
+
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft]', coords: { x: cx + OFFSET, y: cy - 20 } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Shape 2 settings (shape 2 is now selected) ---
+ await setColorSetting(view, 'Couleur du trait', 'green')
+ await setColorSetting(view, 'Couleur de fond', 'yellow')
+ await setRangeSetting(view, 'Epaisseur du trait', 10)
+ await setSelectSetting(view, 'Type de traits', '2')
+ await setSelectSetting(view, 'Fermer les points', 'Non')
+ await setRangeSetting(view, 'Opacité', 75)
+
+ // --- Add a point to shape 2 by double-clicking between P0 (cx+OFFSET-60, cy) and P1 (cx+OFFSET, cy-60) ---
+ // Midpoint: (cx + OFFSET - 30, cy - 30) — well inside the 50-unit hit radius of the segment.
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft][MouseLeft]', coords: { x: cx + OFFSET - 30, y: cy - 30 } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Assert shape 2 now has 4 points ---
+ expect(getCurrentDataRef.current).not.toBeNull()
+ expect((getCurrentDataRef.current!().shapes![0] as { points: unknown[] }).points.length).toBe(4)
+
+ // --- Delete the added point via context menu ---
+ // Right-click on the newly added point (index 1, same client coords as the double-click above).
+ // selectShape detects the vertex anchor → context menu shows "Delete point".
+ await openContextMenuAndClick(user, drawCanvas, view, cx + OFFSET - 30, cy - 30, 'Delete point')
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Assertions ---
+ expect(getCurrentDataRef.current).not.toBeNull()
+ const data = getCurrentDataRef.current!()
+
+ expect(data.shapes).toHaveLength(2)
+
+ expect(data.shapes![1]).toMatchObject({
+ type: 'curve',
+ style: { strokeColor: 'red', fillColor: 'blue', opacity: 50, lineWidth: 5, lineDash: 1, closedPoints: 1 }
+ })
+ assertNoInternalFields(data.shapes![1])
+ expect((data.shapes![1] as { points: unknown[] }).points.length).toBeGreaterThanOrEqual(3)
+
+ expect(data.shapes![0]).toMatchObject({
+ type: 'curve',
+ style: { strokeColor: 'green', fillColor: 'yellow', opacity: 75, lineWidth: 10, lineDash: 2, closedPoints: 0 }
+ })
+ assertNoInternalFields(data.shapes![0])
+ // Point added then deleted → back to 3 points.
+ expect((data.shapes![0] as { points: unknown[] }).points.length).toBe(3)
+ }
+}
diff --git a/stories/tests/draw/DrawEllipse.stories.tsx b/stories/tests/draw/DrawEllipse.stories.tsx
new file mode 100644
index 0000000..2d24c93
--- /dev/null
+++ b/stories/tests/draw/DrawEllipse.stories.tsx
@@ -0,0 +1,94 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, within } from 'storybook/test'
+import {
+ ReactPaintWrapper,
+ getCurrentDataRef,
+ selectTool,
+ assertNoInternalFields,
+ setColorSetting,
+ setRangeSetting,
+ setSelectSetting
+} from './../helpers'
+
+const meta = {
+ title: 'Tests/Draw',
+ component: ReactPaintWrapper,
+ parameters: {
+ layout: 'centered'
+ }
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const OFFSET = 120
+
+export const DrawEllipse: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const view = within(canvasElement)
+ await selectTool(view, 'ellipse')
+
+ // --- Shape 1 settings ---
+ await setColorSetting(view, 'Couleur du trait', 'red')
+ await setColorSetting(view, 'Couleur de fond', 'blue')
+ await setRangeSetting(view, 'Epaisseur du trait', 5)
+ await setSelectSetting(view, 'Type de traits', '1')
+ await setRangeSetting(view, 'Opacité', 50)
+
+ const drawCanvas = await view.findByTestId('draw-canvas')
+ const rect = drawCanvas.getBoundingClientRect()
+ const cx = rect.left + rect.width / 2
+ const cy = rect.top + rect.height / 2
+
+ const user = userEvent.setup()
+
+ // --- Draw shape 1 (centered at cx - OFFSET) ---
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx - OFFSET - 60, y: cy - 30 } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, coords: { x: cx - OFFSET + 60, y: cy + 30 } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx - OFFSET + 60, y: cy + 30 } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Draw shape 2 (centered at cx + OFFSET, with shape 1's settings) ---
+ await selectTool(view, 'ellipse')
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx + OFFSET - 60, y: cy - 30 } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, coords: { x: cx + OFFSET + 60, y: cy + 30 } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx + OFFSET + 60, y: cy + 30 } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Shape 2 settings (shape 2 is now selected) ---
+ await setColorSetting(view, 'Couleur du trait', 'green')
+ await setColorSetting(view, 'Couleur de fond', 'yellow')
+ await setRangeSetting(view, 'Epaisseur du trait', 10)
+ await setSelectSetting(view, 'Type de traits', '2')
+ await setRangeSetting(view, 'Opacité', 75)
+
+ // --- Assertions ---
+ expect(getCurrentDataRef.current).not.toBeNull()
+ const data = getCurrentDataRef.current!()
+
+ expect(data.shapes).toHaveLength(2)
+
+ expect(data.shapes![1]).toMatchObject({
+ type: 'ellipse',
+ style: { strokeColor: 'red', fillColor: 'blue', opacity: 50, lineWidth: 5, lineDash: 1 }
+ })
+ assertNoInternalFields(data.shapes![1])
+ const el1 = data.shapes![1] as { radiusX: number; radiusY: number }
+ expect(el1.radiusX).toBeGreaterThan(0)
+ expect(el1.radiusY).toBeGreaterThan(0)
+
+ expect(data.shapes![0]).toMatchObject({
+ type: 'ellipse',
+ style: { strokeColor: 'green', fillColor: 'yellow', opacity: 75, lineWidth: 10, lineDash: 2 }
+ })
+ assertNoInternalFields(data.shapes![0])
+ const el2 = data.shapes![0] as { radiusX: number; radiusY: number }
+ expect(el2.radiusX).toBeGreaterThan(0)
+ expect(el2.radiusY).toBeGreaterThan(0)
+ }
+}
diff --git a/stories/tests/draw/DrawLine.stories.tsx b/stories/tests/draw/DrawLine.stories.tsx
new file mode 100644
index 0000000..e91de43
--- /dev/null
+++ b/stories/tests/draw/DrawLine.stories.tsx
@@ -0,0 +1,84 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, within } from 'storybook/test'
+import {
+ ReactPaintWrapper,
+ getCurrentDataRef,
+ selectTool,
+ assertNoInternalFields,
+ setColorSetting,
+ setRangeSetting,
+ setSelectSetting
+} from './../helpers'
+
+const meta = {
+ title: 'Tests/Draw',
+ component: ReactPaintWrapper,
+ parameters: {
+ layout: 'centered'
+ }
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const OFFSET = 120
+
+export const DrawLine: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const view = within(canvasElement)
+ await selectTool(view, 'line')
+
+ // --- Shape 1 settings ---
+ await setColorSetting(view, 'Couleur du trait', 'red')
+ await setRangeSetting(view, 'Epaisseur du trait', 5)
+ await setSelectSetting(view, 'Type de traits', '1')
+ await setSelectSetting(view, 'Flèches', '1')
+ await setRangeSetting(view, 'Opacité', 50)
+
+ const drawCanvas = await view.findByTestId('draw-canvas')
+ const rect = drawCanvas.getBoundingClientRect()
+ const cx = rect.left + rect.width / 2
+ const cy = rect.top + rect.height / 2
+
+ const user = userEvent.setup()
+
+ // --- Draw shape 1 (centered at cx - OFFSET) ---
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx - OFFSET - 60, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, coords: { x: cx - OFFSET + 60, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx - OFFSET + 60, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Draw shape 2 (centered at cx + OFFSET, with shape 1's settings) ---
+ await selectTool(view, 'line')
+ await user.pointer({ target: drawCanvas, keys: '[MouseLeft>]', coords: { x: cx + OFFSET - 60, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, coords: { x: cx + OFFSET + 60, y: cy } })
+ await new Promise(res => setTimeout(res, 50))
+ await user.pointer({ target: drawCanvas, keys: '[/MouseLeft]', coords: { x: cx + OFFSET + 60, y: cy } })
+ await new Promise(res => setTimeout(res, 100))
+
+ // --- Shape 2 settings (shape 2 is now selected) ---
+ await setColorSetting(view, 'Couleur du trait', 'green')
+ await setRangeSetting(view, 'Epaisseur du trait', 10)
+ await setSelectSetting(view, 'Type de traits', '2')
+ await setSelectSetting(view, 'Flèches', '2')
+ await setRangeSetting(view, 'Opacité', 75)
+
+ // --- Assertions ---
+ expect(getCurrentDataRef.current).not.toBeNull()
+ const data = getCurrentDataRef.current!()
+
+ expect(data.shapes).toHaveLength(2)
+
+ expect(data.shapes![1]).toMatchObject({ type: 'line', style: { strokeColor: 'red', opacity: 50, lineWidth: 5, lineDash: 1, lineArrow: 1 } })
+ assertNoInternalFields(data.shapes![1])
+ expect((data.shapes![1] as { points: unknown[] }).points).toHaveLength(2)
+
+ expect(data.shapes![0]).toMatchObject({ type: 'line', style: { strokeColor: 'green', opacity: 75, lineWidth: 10, lineDash: 2, lineArrow: 2 } })
+ assertNoInternalFields(data.shapes![0])
+ expect((data.shapes![0] as { points: unknown[] }).points).toHaveLength(2)
+ }
+}
diff --git a/stories/tests/draw/DrawPictureFromUpload.stories.tsx b/stories/tests/draw/DrawPictureFromUpload.stories.tsx
new file mode 100644
index 0000000..e13947b
--- /dev/null
+++ b/stories/tests/draw/DrawPictureFromUpload.stories.tsx
@@ -0,0 +1,56 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, within } from 'storybook/test'
+import {
+ ReactPaintWrapper,
+ getCurrentDataRef,
+ assertNoInternalFields,
+ setRangeSetting
+} from './../helpers'
+
+const meta = {
+ title: 'Tests/Draw',
+ component: ReactPaintWrapper,
+ parameters: {
+ layout: 'centered'
+ }
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+// SVG content used for the file-upload test.
+const SVG_CONTENT = ""
+
+export const DrawPictureFromUpload: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const view = within(canvasElement)
+
+ const file = new File([SVG_CONTENT], 'test.svg', { type: 'image/svg+xml' })
+
+ // Open the menu
+ await userEvent.click(await view.findByTitle('Menu'))
+
+ // Upload the file via the hidden file input inside the "Upload picture" label
+ const uploadLabel = await view.findByTitle('Upload picture')
+ await userEvent.upload(uploadLabel, file)
+
+ // Wait for the image to load and the shape to be added (auto-selected)
+ await new Promise(res => setTimeout(res, 500))
+
+ // --- Change opacity on the selected picture ---
+ await setRangeSetting(view, 'Opacité', 40)
+
+ // --- Assertions ---
+ expect(getCurrentDataRef.current).not.toBeNull()
+ const data = getCurrentDataRef.current!()
+
+ expect(data.shapes).toHaveLength(1)
+ expect(data.shapes![0]).toMatchObject({ type: 'picture', style: { opacity: 40 } })
+ assertNoInternalFields(data.shapes![0])
+ const pic = data.shapes![0] as { width: number; height: number; src: string }
+ expect(pic.width).toBeGreaterThan(0)
+ expect(pic.height).toBeGreaterThan(0)
+ expect(pic.src).toContain('