),
};
+
+export const MultipleSnappingWindows: Story = {
+ args: {
+ ...windowArgs,
+ title: 'Primary Window',
+ width: 420,
+ height: 280,
+ initialX: 32,
+ initialY: 48,
+ },
+ render: (args) => (
+ <>
+
+ Drag me to any edge or corner to snap independently.
+
+
+ This second window snaps on its own too.
+
+ >
+ ),
+};
+
+export const SnapDisabled: Story = {
+ args: {
+ ...windowArgs,
+ title: 'Snap Disabled',
+ snapEnabled: false,
+ width: 440,
+ height: 300,
+ initialX: 72,
+ initialY: 72,
+ },
+ render: (args) => (
+
+ Snapping is turned off for this window.
+
+ ),
+};
+
+export const CustomSnapThreshold: Story = {
+ args: {
+ ...windowArgs,
+ title: 'Custom Threshold',
+ snapThreshold: 40,
+ width: 440,
+ height: 300,
+ initialX: 72,
+ initialY: 72,
+ },
+ render: (args) => (
+
+ This window uses a wider snap threshold.
+
+ ),
+};
diff --git a/src/components/window/Window.test.tsx b/src/components/window/Window.test.tsx
index 26463d4..e1d05eb 100644
--- a/src/components/window/Window.test.tsx
+++ b/src/components/window/Window.test.tsx
@@ -1,6 +1,12 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Window } from './Window';
+import styles from './Window.module.css';
+
+function createPointerEvent(type: string, init: PointerEventInit) {
+ return new PointerEvent(type, init);
+}
describe('Window', () => {
it('renders with title bar and window body', () => {
@@ -20,6 +26,22 @@ describe('Window', () => {
expect(windowEl.style.zIndex).toBe('99');
});
+ it('raises the active window z-index when dragging starts', () => {
+ const { container } = render();
+ const titleBar = screen.getByText('Test').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const windowEl = container.firstChild as HTMLElement;
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ });
+
+ expect(windowEl.style.zIndex).toBe('100');
+ });
+
it('renders status bar when provided', () => {
render(
Status content}>
@@ -38,6 +60,201 @@ describe('Window', () => {
expect(screen.queryByText('Status content')).toBeNull();
});
+ describe('snap preview behavior', () => {
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true });
+ Object.defineProperty(window, 'innerHeight', { value: 768, writable: true });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders a preview while dragging into the right snap zone', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(container.querySelector(`.${styles.snapPreview}`)).not.toBeNull();
+ });
+
+ const preview = container.querySelector(`.${styles.snapPreview}`) as HTMLElement;
+ expect(preview).toHaveClass(styles.snapPreview);
+ expect(preview).toHaveAttribute('aria-hidden', 'true');
+ expect(preview.getAttribute('role')).toBeNull();
+ expect(preview.style.top).toBe('0px');
+ expect(preview.style.left).toBe('512px');
+ expect(preview.style.width).toBe('512px');
+ expect(preview.style.height).toBe('768px');
+ expect(preview.style.zIndex).toBe('101');
+ expect(container.firstChild?.nextSibling).toBe(preview);
+ });
+
+ it('removes the preview after pointerup', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(container.querySelector(`.${styles.snapPreview}`)).toBeNull();
+ });
+ });
+
+ it('removes the preview when leaving the snap zone before pointerup', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 512, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(container.querySelector(`.${styles.snapPreview}`)).toBeNull();
+ });
+ });
+
+ it('does not render a preview when snapEnabled is false', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(container.querySelector(`.${styles.snapPreview}`)).toBeNull();
+ });
+ });
+
+ it('does not snap at 41px from the edge with snapThreshold=40', async () => {
+ const { container } = render(
+ ,
+ );
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const windowEl = container.firstChild as HTMLElement;
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 983, clientY: 384, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 983, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(container.querySelector(`.${styles.snapPreview}`)).toBeNull();
+ expect(windowEl.style.width).toBe('400px');
+ expect(windowEl.style.height).toBe('300px');
+ });
+ });
+
+ it('snaps at 39px from the edge with snapThreshold=40', async () => {
+ const { container } = render(
+ ,
+ );
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const windowEl = container.firstChild as HTMLElement;
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 985, clientY: 384, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 985, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(container.querySelector(`.${styles.snapPreview}`)).toBeNull();
+ expect(windowEl.style.left).toBe('512px');
+ expect(windowEl.style.top).toBe('0px');
+ expect(windowEl.style.width).toBe('512px');
+ expect(windowEl.style.height).toBe('768px');
+ });
+ });
+
+ it('does not show a snap preview while maximized and restores to the original size', async () => {
+ const onRestore = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const windowEl = container.firstChild as HTMLElement;
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ });
+
+ expect(container.querySelector(`.${styles.snapPreview}`)).toBeNull();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Restore' }));
+
+ await waitFor(() => {
+ expect(onRestore).toHaveBeenCalledOnce();
+ expect(windowEl.style.width).toBe('400px');
+ expect(windowEl.style.height).toBe('300px');
+ });
+ });
+
+ it('ignores pointerdown on title-bar buttons for snap dragging', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const minimizeButton = screen.getByRole('button', { name: 'Minimize' });
+
+ act(() => {
+ minimizeButton.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(titleBar.setPointerCapture).not.toHaveBeenCalled();
+ expect(container.querySelector(`.${styles.snapPreview}`)).toBeNull();
+ });
+ });
+ });
+
it('calls onClose callback', async () => {
const onClose = vi.fn();
render();
@@ -46,6 +263,95 @@ describe('Window', () => {
expect(onClose).toHaveBeenCalledOnce();
});
+ describe('snap commit behavior', () => {
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true });
+ Object.defineProperty(window, 'innerHeight', { value: 768, writable: true });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('snaps to the right half on commit', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const windowEl = container.firstChild as HTMLElement;
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(windowEl.style.left).toBe('512px');
+ expect(windowEl.style.top).toBe('0px');
+ expect(windowEl.style.width).toBe('512px');
+ expect(windowEl.style.height).toBe('768px');
+ });
+ });
+
+ it('snaps to the top half on commit', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const windowEl = container.firstChild as HTMLElement;
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 512, clientY: 10, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 512, clientY: 10, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(windowEl.style.left).toBe('0px');
+ expect(windowEl.style.top).toBe('0px');
+ expect(windowEl.style.width).toBe('1024px');
+ expect(windowEl.style.height).toBe('384px');
+ });
+ });
+
+ it('restores the floating size when dragging a snapped window away from the edge', async () => {
+ const { container } = render();
+ const titleBar = screen.getByText('My Window').closest('.title-bar') as HTMLElement;
+ titleBar.setPointerCapture = vi.fn();
+ titleBar.releasePointerCapture = vi.fn();
+ const windowEl = container.firstChild as HTMLElement;
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 100, clientY: 100, pointerId: 1, bubbles: true }),
+ );
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 1014, clientY: 384, pointerId: 1 }));
+ });
+
+ await waitFor(() => {
+ expect(windowEl.style.left).toBe('512px');
+ expect(windowEl.style.top).toBe('0px');
+ expect(windowEl.style.width).toBe('512px');
+ expect(windowEl.style.height).toBe('768px');
+ });
+
+ act(() => {
+ titleBar.dispatchEvent(
+ createPointerEvent('pointerdown', { clientX: 600, clientY: 100, pointerId: 2, bubbles: true }),
+ );
+ });
+
+ expect(windowEl.style.width).toBe('400px');
+ expect(windowEl.style.height).toBe('300px');
+ });
+ });
+
describe('responsive sizing', () => {
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 400, writable: true });
diff --git a/src/components/window/Window.tsx b/src/components/window/Window.tsx
index 64f98ce..5de74a4 100644
--- a/src/components/window/Window.tsx
+++ b/src/components/window/Window.tsx
@@ -24,6 +24,8 @@ export interface WindowProps {
onMaximize?: () => void;
onRestore?: () => void;
onClose?: () => void;
+ snapEnabled?: boolean;
+ snapThreshold?: number;
style?: CSSProperties;
className?: string;
zIndex?: number;
@@ -71,6 +73,8 @@ export function Window({
onMaximize,
onRestore,
onClose,
+ snapEnabled = true,
+ snapThreshold = 20,
style,
className,
zIndex,
@@ -78,6 +82,10 @@ export function Window({
const titleBarRef = useRef(null);
const [isMinimized, setIsMinimized] = useState(minimizedProp ?? false);
const [isMaximized, setIsMaximized] = useState(maximizedProp ?? false);
+ const [activeZIndex, setActiveZIndex] = useState(zIndex ?? 1);
+ const preSnapPosition = useRef<{ x: number; y: number } | null>(null);
+ const preSnapSize = useRef<{ width: number; height: number } | null>(null);
+ const [isSnapped, setIsSnapped] = useState(false);
// Calculate responsive initial size and position
const responsiveInitial = useMemo(
@@ -85,11 +93,13 @@ export function Window({
[width, height, initialX, initialY],
);
+ const snapActive = !isMaximized && snapEnabled;
+
// ドラッグ・リサイズで共有する単一ポジション state
const [position, setPosition] = useState({ x: responsiveInitial.x, y: responsiveInitial.y });
// useResizable must come BEFORE useDraggable so we can use the live size for drag bounds
- const { size, getResizeHandleProps } = useResizable({
+ const { size, setSize, getResizeHandleProps } = useResizable({
initialWidth: responsiveInitial.width,
initialHeight: responsiveInitial.height,
initialX: responsiveInitial.x,
@@ -103,14 +113,50 @@ export function Window({
});
// useDraggable uses the LIVE size from useResizable for bounds, not the initial size
- const { dragHandleProps } = useDraggable({
+ const { dragHandleProps, snapTarget } = useDraggable({
initialX: responsiveInitial.x,
initialY: responsiveInitial.y,
position,
onPositionChange: setPosition,
+ snapEnabled: snapActive,
+ snapThreshold: snapActive ? snapThreshold : undefined,
+ minWidth: snapActive ? 200 : undefined,
+ minHeight: snapActive ? 100 : undefined,
bounds: { width: size.width, height: size.height },
+ onSnapCommit: snapActive
+ ? (target) => {
+ preSnapPosition.current = { x: position.x, y: position.y };
+ preSnapSize.current = { width: size.width, height: size.height };
+ setPosition({ x: target.x, y: target.y });
+ setSize({ width: target.width, height: target.height });
+ setIsSnapped(true);
+ }
+ : undefined,
+ onDragStart: () => {
+ setActiveZIndex((current) => current + 1);
+
+ if (!isSnapped || !preSnapPosition.current || !preSnapSize.current) {
+ return;
+ }
+
+ setPosition(preSnapPosition.current);
+ setSize(preSnapSize.current);
+ setIsSnapped(false);
+ },
});
+ const snapPreviewStyle =
+ snapTarget && !isMinimized && !isMaximized
+ ? {
+ position: 'fixed' as const,
+ top: snapTarget.y,
+ left: snapTarget.x,
+ width: snapTarget.width,
+ height: snapTarget.height,
+ zIndex: activeZIndex + 1,
+ }
+ : null;
+
const handleMinimize = () => {
setIsMinimized((prev) => !prev);
onMinimize?.();
@@ -133,7 +179,6 @@ export function Window({
left: 4,
width: 220,
height: 'auto',
- zIndex,
...style,
}
: isMaximized
@@ -143,7 +188,6 @@ export function Window({
left: 0,
width: '100vw',
height: '100vh',
- zIndex,
...style,
}
: {
@@ -152,14 +196,13 @@ export function Window({
left: position.x,
width: size.width,
height: size.height,
- zIndex,
...style,
};
- return (
+ const windowRoot = (
);
+
+ return (
+ <>
+ {windowRoot}
+ {snapPreviewStyle && (
+
+ )}
+ >
+ );
}
diff --git a/src/components/window/Window.wiring.test.tsx b/src/components/window/Window.wiring.test.tsx
index ca8ba25..0e8d6bd 100644
--- a/src/components/window/Window.wiring.test.tsx
+++ b/src/components/window/Window.wiring.test.tsx
@@ -1,4 +1,4 @@
-import { render } from '@testing-library/react';
+import { render, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the hooks to verify they're called with correct arguments
@@ -20,6 +20,7 @@ describe('Window hook wiring', () => {
mockUseResizable.mockReturnValue({
size: { width: 400, height: 300 },
position: { x: 50, y: 50 },
+ setSize: vi.fn(),
getResizeHandleProps: vi.fn(() => ({ style: {}, onPointerDown: vi.fn() })),
});
mockUseDraggable.mockReturnValue({
@@ -46,6 +47,19 @@ describe('Window hook wiring', () => {
});
});
+ it('passes snap options and snap commit handler to useDraggable', () => {
+ render();
+
+ const draggableCall = mockUseDraggable.mock.calls[0][0];
+ expect(draggableCall).toMatchObject({
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ });
+ expect(typeof draggableCall.onSnapCommit).toBe('function');
+ });
+
it('passes live size from useResizable to useDraggable bounds', () => {
// Set up useResizable to return a specific size
mockUseResizable.mockReturnValue({
@@ -88,4 +102,44 @@ describe('Window hook wiring', () => {
const lastDraggableCall = mockUseDraggable.mock.calls[mockUseDraggable.mock.calls.length - 1][0];
expect(lastDraggableCall.bounds).toEqual({ width: 600, height: 450 });
});
+
+ it('commits snap target to both position and size', () => {
+ const setSize = vi.fn();
+
+ mockUseResizable.mockReturnValue({
+ size: { width: 400, height: 300 },
+ position: { x: 50, y: 50 },
+ setSize,
+ getResizeHandleProps: vi.fn(() => ({ style: {}, onPointerDown: vi.fn() })),
+ });
+ mockUseDraggable.mockReturnValue({
+ position: { x: 50, y: 50 },
+ setPosition: vi.fn(),
+ dragHandleProps: { onPointerDown: vi.fn() },
+ });
+
+ render();
+
+ const draggableCall = mockUseDraggable.mock.calls[0][0];
+ expect(typeof draggableCall.onSnapCommit).toBe('function');
+ const onSnapCommit = draggableCall.onSnapCommit as (target: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ zone: string;
+ }) => void;
+
+ act(() => {
+ onSnapCommit({
+ x: 512,
+ y: 0,
+ width: 512,
+ height: 768,
+ zone: 'right',
+ });
+ });
+
+ expect(setSize).toHaveBeenCalledWith({ width: 512, height: 768 });
+ });
});
diff --git a/src/hooks/snap.test.ts b/src/hooks/snap.test.ts
new file mode 100644
index 0000000..46035ab
--- /dev/null
+++ b/src/hooks/snap.test.ts
@@ -0,0 +1,231 @@
+import { describe, it, expect } from 'vitest';
+import { getSnapTarget } from './snap';
+
+describe('getSnapTarget', () => {
+ const viewportWidth = 1024;
+ const viewportHeight = 768;
+ const threshold = 20;
+ const minWidth = 200;
+ const minHeight = 100;
+
+ it('returns left snap target near the left edge', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 10,
+ pointerY: 300,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 0, width: 512, height: 768, zone: 'left' });
+ });
+
+ it('returns right snap target near the right edge', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 1014,
+ pointerY: 300,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 512, y: 0, width: 512, height: 768, zone: 'right' });
+ });
+
+ it('returns top snap target near the top edge', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 500,
+ pointerY: 10,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 0, width: 1024, height: 384, zone: 'top' });
+ });
+
+ it('returns bottom snap target near the bottom edge', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 500,
+ pointerY: 758,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 384, width: 1024, height: 384, zone: 'bottom' });
+ });
+
+ it('returns top-left snap target near the top-left corner', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 10,
+ pointerY: 10,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 0, width: 512, height: 384, zone: 'top-left' });
+ });
+
+ it('returns top-right snap target near the top-right corner', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 1014,
+ pointerY: 10,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 512, y: 0, width: 512, height: 384, zone: 'top-right' });
+ });
+
+ it('returns bottom-left snap target near the bottom-left corner', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 10,
+ pointerY: 758,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 384, width: 512, height: 384, zone: 'bottom-left' });
+ });
+
+ it('returns bottom-right snap target near the bottom-right corner', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 1014,
+ pointerY: 758,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 512, y: 384, width: 512, height: 384, zone: 'bottom-right' });
+ });
+
+ it('returns null for a center pointer', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 500,
+ pointerY: 500,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toBeNull();
+ });
+
+ it('returns null when pointer is threshold plus one away from an edge', () => {
+ expect(
+ getSnapTarget({
+ pointerX: threshold + 1,
+ pointerY: 300,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toBeNull();
+ });
+
+ it('prefers the corner zone when pointer is within both thresholds', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 10,
+ pointerY: 10,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ })?.zone,
+ ).toBe('top-left');
+ });
+
+ it('returns null when the viewport is too small for the minimum dimensions', () => {
+ expect(
+ getSnapTarget({
+ pointerX: 10,
+ pointerY: 10,
+ viewportWidth: 300,
+ viewportHeight: 150,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toBeNull();
+ });
+
+ it('splits odd viewport sizes without leaving a 1px gap', () => {
+ const oddViewportWidth = 1023;
+ const oddViewportHeight = 767;
+
+ expect(
+ getSnapTarget({
+ pointerX: 10,
+ pointerY: 300,
+ viewportWidth: oddViewportWidth,
+ viewportHeight: oddViewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 0, width: 511, height: 767, zone: 'left' });
+
+ expect(
+ getSnapTarget({
+ pointerX: oddViewportWidth - 10,
+ pointerY: 300,
+ viewportWidth: oddViewportWidth,
+ viewportHeight: oddViewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 511, y: 0, width: 512, height: 767, zone: 'right' });
+
+ expect(
+ getSnapTarget({
+ pointerX: 500,
+ pointerY: 10,
+ viewportWidth: oddViewportWidth,
+ viewportHeight: oddViewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 0, width: 1023, height: 383, zone: 'top' });
+
+ expect(
+ getSnapTarget({
+ pointerX: 500,
+ pointerY: oddViewportHeight - 10,
+ viewportWidth: oddViewportWidth,
+ viewportHeight: oddViewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+ }),
+ ).toEqual({ x: 0, y: 383, width: 1023, height: 384, zone: 'bottom' });
+ });
+});
diff --git a/src/hooks/snap.ts b/src/hooks/snap.ts
new file mode 100644
index 0000000..6b9f3f5
--- /dev/null
+++ b/src/hooks/snap.ts
@@ -0,0 +1,77 @@
+export type SnapZone =
+ | 'left'
+ | 'right'
+ | 'top'
+ | 'bottom'
+ | 'top-left'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-right';
+
+export type SnapTarget = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ zone: SnapZone;
+};
+
+export function getSnapTarget({
+ pointerX,
+ pointerY,
+ viewportWidth,
+ viewportHeight,
+ threshold,
+ minWidth,
+ minHeight,
+}: {
+ pointerX: number;
+ pointerY: number;
+ viewportWidth: number;
+ viewportHeight: number;
+ threshold: number;
+ minWidth: number;
+ minHeight: number;
+}): SnapTarget | null {
+ const halfWidth = Math.floor(viewportWidth / 2);
+ const halfHeight = Math.floor(viewportHeight / 2);
+
+ const nearLeft = pointerX <= threshold;
+ const nearRight = pointerX >= viewportWidth - threshold;
+ const nearTop = pointerY <= threshold;
+ const nearBottom = pointerY >= viewportHeight - threshold;
+
+ let zone: SnapZone | null = null;
+
+ if (nearTop && nearLeft) zone = 'top-left';
+ else if (nearTop && nearRight) zone = 'top-right';
+ else if (nearBottom && nearLeft) zone = 'bottom-left';
+ else if (nearBottom && nearRight) zone = 'bottom-right';
+ else if (nearLeft) zone = 'left';
+ else if (nearRight) zone = 'right';
+ else if (nearTop) zone = 'top';
+ else if (nearBottom) zone = 'bottom';
+
+ if (!zone) return null;
+
+ const target =
+ zone === 'left'
+ ? { x: 0, y: 0, width: halfWidth, height: viewportHeight }
+ : zone === 'right'
+ ? { x: halfWidth, y: 0, width: viewportWidth - halfWidth, height: viewportHeight }
+ : zone === 'top'
+ ? { x: 0, y: 0, width: viewportWidth, height: halfHeight }
+ : zone === 'bottom'
+ ? { x: 0, y: halfHeight, width: viewportWidth, height: viewportHeight - halfHeight }
+ : zone === 'top-left'
+ ? { x: 0, y: 0, width: halfWidth, height: halfHeight }
+ : zone === 'top-right'
+ ? { x: halfWidth, y: 0, width: viewportWidth - halfWidth, height: halfHeight }
+ : zone === 'bottom-left'
+ ? { x: 0, y: halfHeight, width: halfWidth, height: viewportHeight - halfHeight }
+ : { x: halfWidth, y: halfHeight, width: viewportWidth - halfWidth, height: viewportHeight - halfHeight };
+
+ if (target.width < minWidth || target.height < minHeight) return null;
+
+ return { ...target, zone };
+}
diff --git a/src/hooks/useDraggable.test.ts b/src/hooks/useDraggable.test.ts
index e4a88c3..ee864b5 100644
--- a/src/hooks/useDraggable.test.ts
+++ b/src/hooks/useDraggable.test.ts
@@ -2,6 +2,10 @@ import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useDraggable } from './useDraggable';
+function createPointerEvent(type: string, init: PointerEventInit) {
+ return new PointerEvent(type, init);
+}
+
describe('useDraggable', () => {
it('returns default initial position', () => {
const { result } = renderHook(() => useDraggable());
@@ -26,6 +30,357 @@ describe('useDraggable', () => {
expect(typeof result.current.dragHandleProps.onPointerDown).toBe('function');
});
+ it('captures the pointer on pointerdown', () => {
+ const { result, unmount } = renderHook(() => useDraggable());
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 10,
+ clientY: 10,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ expect(currentTarget.setPointerCapture).toHaveBeenCalledWith(1);
+ unmount();
+ });
+
+ describe('snap behavior', () => {
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerWidth', { value: 800, writable: true });
+ Object.defineProperty(window, 'innerHeight', { value: 600, writable: true });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('sets snapTarget when pointer moves into a snap zone', () => {
+ const { result, unmount } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 5, clientY: 240, pointerId: 1 }));
+ });
+
+ expect((result.current as { snapTarget: unknown }).snapTarget).toEqual({
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 600,
+ zone: 'left',
+ });
+
+ unmount();
+ });
+
+ it('clears snapTarget when pointer returns to the center', () => {
+ const { result, unmount } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 5, clientY: 240, pointerId: 1 }));
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 400, clientY: 300, pointerId: 1 }));
+ });
+
+ expect((result.current as { snapTarget: unknown }).snapTarget).toBeNull();
+ unmount();
+ });
+
+ it('calls onSnapCommit once on pointerup when snap target exists', () => {
+ const onSnapCommit = vi.fn();
+ const { result } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ onSnapCommit,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 5, clientY: 240, pointerId: 1 }));
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 5, clientY: 240, pointerId: 1 }));
+ });
+
+ expect(onSnapCommit).toHaveBeenCalledTimes(1);
+ expect(onSnapCommit).toHaveBeenCalledWith({
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 600,
+ zone: 'left',
+ });
+ expect((result.current as { snapTarget: unknown }).snapTarget).toBeNull();
+ });
+
+ it('does not call onSnapCommit when snapEnabled is false', () => {
+ const onSnapCommit = vi.fn();
+ const { result } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: false,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ onSnapCommit,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 5, clientY: 240, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 5, clientY: 240, pointerId: 1 }));
+ });
+
+ expect(onSnapCommit).not.toHaveBeenCalled();
+ expect((result.current as { snapTarget: unknown }).snapTarget).toBeNull();
+ });
+
+ it('continues to update drag position during pointermove', () => {
+ const { result, unmount } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 130, clientY: 145, pointerId: 1 }));
+ });
+
+ expect(result.current.position).toEqual({ x: 130, y: 145 });
+ unmount();
+ });
+
+ it('stops updating snap state and position after pointerup', () => {
+ const onSnapCommit = vi.fn();
+ const { result } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ onSnapCommit,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 5, clientY: 240, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointerup', { clientX: 5, clientY: 240, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 400, clientY: 300, pointerId: 1 }));
+ });
+
+ expect(onSnapCommit).toHaveBeenCalledTimes(1);
+ expect((result.current as { snapTarget: unknown }).snapTarget).toBeNull();
+ expect(result.current.position).toEqual({ x: 5, y: 240 });
+ });
+
+ it('cleans up drag state on pointercancel', () => {
+ const onRemoveEventListener = vi.spyOn(window, 'removeEventListener');
+ const { result } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ act(() => {
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 5, clientY: 240, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointercancel', { clientX: 5, clientY: 240, pointerId: 1 }));
+ window.dispatchEvent(createPointerEvent('pointermove', { clientX: 400, clientY: 300, pointerId: 1 }));
+ });
+
+ expect(currentTarget.releasePointerCapture).toHaveBeenCalledWith(1);
+ expect(onRemoveEventListener).toHaveBeenCalledWith('pointermove', expect.any(Function));
+ expect(onRemoveEventListener).toHaveBeenCalledWith('pointerup', expect.any(Function));
+ expect(onRemoveEventListener).toHaveBeenCalledWith('pointercancel', expect.any(Function));
+ expect((result.current as { snapTarget: unknown }).snapTarget).toBeNull();
+ expect(result.current.position).toEqual({ x: 5, y: 240 });
+ });
+
+ it('cleans up drag state when the hook unmounts during dragging', () => {
+ const onRemoveEventListener = vi.spyOn(window, 'removeEventListener');
+ const { result, unmount } = renderHook(() =>
+ useDraggable({
+ initialX: 100,
+ initialY: 100,
+ bounds: { width: 200, height: 150 },
+ snapEnabled: true,
+ snapThreshold: 20,
+ minWidth: 200,
+ minHeight: 100,
+ }),
+ );
+
+ const currentTarget = document.createElement('div');
+ currentTarget.setPointerCapture = vi.fn();
+ currentTarget.releasePointerCapture = vi.fn();
+
+ act(() => {
+ result.current.dragHandleProps.onPointerDown({
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ target: document.createElement('div'),
+ currentTarget,
+ } as unknown as React.PointerEvent);
+ });
+
+ unmount();
+
+ expect(currentTarget.releasePointerCapture).toHaveBeenCalledWith(1);
+ expect(onRemoveEventListener).toHaveBeenCalledWith('pointermove', expect.any(Function));
+ expect(onRemoveEventListener).toHaveBeenCalledWith('pointerup', expect.any(Function));
+ });
+ });
+
describe('viewport clamping', () => {
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 800, writable: true });
diff --git a/src/hooks/useDraggable.ts b/src/hooks/useDraggable.ts
index ea16c37..f64f41e 100644
--- a/src/hooks/useDraggable.ts
+++ b/src/hooks/useDraggable.ts
@@ -1,4 +1,6 @@
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { flushSync } from 'react-dom';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { getSnapTarget, type SnapTarget } from './snap';
export interface DraggablePosition {
x: number;
@@ -13,6 +15,7 @@ export interface DraggableBounds {
export interface UseDraggableResult {
position: DraggablePosition;
setPosition: (pos: DraggablePosition) => void;
+ snapTarget: SnapTarget | null;
dragHandleProps: {
onPointerDown: (e: React.PointerEvent) => void;
};
@@ -42,10 +45,32 @@ export function useDraggable(options?: {
/** 外部から位置を渡す場合 (Window.tsx の単一 position state を共有) */
position?: DraggablePosition;
onPositionChange?: (pos: DraggablePosition) => void;
+ snapEnabled?: boolean;
+ snapThreshold?: number;
+ minWidth?: number;
+ minHeight?: number;
+ onSnapCommit?: (target: SnapTarget) => void;
+ onDragStart?: () => void;
/** ウィンドウサイズを指定してビューポート内に収める */
bounds?: DraggableBounds;
}): UseDraggableResult {
const bounds = options?.bounds;
+ const snapOptionsRef = useRef({
+ snapEnabled: options?.snapEnabled,
+ snapThreshold: options?.snapThreshold ?? 20,
+ minWidth: options?.minWidth ?? 200,
+ minHeight: options?.minHeight ?? 100,
+ onSnapCommit: options?.onSnapCommit,
+ onDragStart: options?.onDragStart,
+ });
+ snapOptionsRef.current = {
+ snapEnabled: options?.snapEnabled,
+ snapThreshold: options?.snapThreshold ?? 20,
+ minWidth: options?.minWidth ?? 200,
+ minHeight: options?.minHeight ?? 100,
+ onSnapCommit: options?.onSnapCommit,
+ onDragStart: options?.onDragStart,
+ };
// Calculate clamped initial position
const initialPosition = useMemo(() => {
@@ -57,6 +82,7 @@ export function useDraggable(options?: {
}, [options?.initialX, options?.initialY, bounds]);
const [internalPosition, setInternalPosition] = useState(initialPosition);
+ const [snapTarget, setSnapTarget] = useState(null);
// 外部 state が渡されていればそちらを使う
const externalPosition = options?.position;
@@ -91,10 +117,23 @@ export function useDraggable(options?: {
target: Element;
} | null>(null);
+ const listenersRef = useRef<{
+ move: (e: PointerEvent) => void;
+ up: (e: PointerEvent) => void;
+ cancel: (e: PointerEvent) => void;
+ }>({
+ move: () => {},
+ up: () => {},
+ cancel: () => {},
+ });
+
// 最新の position を ref で保持(stale closure 防止)
const positionRef = useRef(position);
positionRef.current = position;
+ const snapTargetRef = useRef(null);
+ snapTargetRef.current = snapTarget;
+
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!dragState.current || e.pointerId !== dragState.current.pointerId) return;
@@ -104,25 +143,94 @@ export function useDraggable(options?: {
x: dragState.current.startX + dx,
y: dragState.current.startY + dy,
});
+
+ if (snapOptionsRef.current.snapEnabled === false) {
+ if (snapTargetRef.current !== null) {
+ snapTargetRef.current = null;
+ setSnapTarget(null);
+ }
+ return;
+ }
+
+ const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1024;
+ const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 768;
+ const nextSnapTarget = getSnapTarget({
+ pointerX: e.clientX,
+ pointerY: e.clientY,
+ viewportWidth,
+ viewportHeight,
+ threshold: snapOptionsRef.current.snapThreshold,
+ minWidth: snapOptionsRef.current.minWidth,
+ minHeight: snapOptionsRef.current.minHeight,
+ });
+
+ if (nextSnapTarget?.zone !== snapTargetRef.current?.zone) {
+ snapTargetRef.current = nextSnapTarget;
+ setSnapTarget(nextSnapTarget);
+ } else {
+ snapTargetRef.current = nextSnapTarget;
+ }
},
- [setPosition],
+ [setPosition, setSnapTarget],
+ );
+
+ const cleanupDrag = useCallback(
+ (pointerId: number) => {
+ const currentDragState = dragState.current;
+ if (!currentDragState || currentDragState.pointerId !== pointerId) return;
+ currentDragState.target.releasePointerCapture(pointerId);
+ window.removeEventListener('pointermove', listenersRef.current.move);
+ window.removeEventListener('pointerup', listenersRef.current.up);
+ window.removeEventListener('pointercancel', listenersRef.current.cancel);
+ dragState.current = null;
+ snapTargetRef.current = null;
+ setSnapTarget(null);
+ },
+ [setSnapTarget],
);
const onPointerUp = useCallback(
(e: PointerEvent) => {
if (!dragState.current || e.pointerId !== dragState.current.pointerId) return;
- dragState.current.target.releasePointerCapture(e.pointerId);
- window.removeEventListener('pointermove', onPointerMove);
- window.removeEventListener('pointerup', onPointerUp);
- dragState.current = null;
+ const currentSnapTarget = snapTargetRef.current;
+ if (currentSnapTarget && snapOptionsRef.current.snapEnabled !== false) {
+ snapOptionsRef.current.onSnapCommit?.(currentSnapTarget);
+ }
+ cleanupDrag(e.pointerId);
+ },
+ [cleanupDrag],
+ );
+
+ const onPointerCancel = useCallback(
+ (e: PointerEvent) => {
+ cleanupDrag(e.pointerId);
+ },
+ [cleanupDrag],
+ );
+
+ useEffect(
+ () => () => {
+ if (!dragState.current) return;
+ cleanupDrag(dragState.current.pointerId);
},
- [onPointerMove],
+ [cleanupDrag],
);
+ // 最新のリスナーを ref に反映(stale closure 防止)
+ useEffect(() => {
+ listenersRef.current = {
+ move: onPointerMove,
+ up: onPointerUp,
+ cancel: onPointerCancel,
+ };
+ }, [onPointerMove, onPointerUp, onPointerCancel]);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
// ボタンをクリックした場合はドラッグを開始しない
if ((e.target as Element).closest('button')) return;
+ flushSync(() => {
+ snapOptionsRef.current.onDragStart?.();
+ });
e.currentTarget.setPointerCapture(e.pointerId);
// positionRef.current を使うことでリサイズ後の実際の位置から正しくドラッグ開始できる
dragState.current = {
@@ -135,13 +243,15 @@ export function useDraggable(options?: {
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
+ window.addEventListener('pointercancel', onPointerCancel);
},
- [onPointerMove, onPointerUp],
+ [onPointerMove, onPointerUp, onPointerCancel],
);
return {
position,
setPosition,
+ snapTarget,
dragHandleProps: { onPointerDown },
};
}
diff --git a/src/hooks/useResizable.ts b/src/hooks/useResizable.ts
index 6a84e49..754800e 100644
--- a/src/hooks/useResizable.ts
+++ b/src/hooks/useResizable.ts
@@ -17,6 +17,7 @@ const cursorMap: Record = {
export interface UseResizableResult {
size: { width: number; height: number };
position: { x: number; y: number };
+ setSize: (size: { width: number; height: number }) => void;
getResizeHandleProps: (direction: ResizeDirection) => {
style: CSSProperties;
onPointerDown: (e: React.PointerEvent) => void;
@@ -101,6 +102,14 @@ export function useResizable(options?: {
const position = options?.position ?? internalPosition;
const setPosition = options?.onPositionChange ?? setInternalPosition;
+ const setSizeConstrained = useCallback(
+ (newSize: { width: number; height: number }) => {
+ const clampedWidth = Math.max(minWidth, newSize.width);
+ const clampedHeight = Math.max(minHeight, newSize.height);
+ setSize({ width: clampedWidth, height: clampedHeight });
+ },
+ [minWidth, minHeight],
+ );
// Post-mount viewport resize handling - only when BOTH flags are enabled
useEffect(() => {
if (!reconcileOnResize || !clampToViewportEnabled) return;
@@ -216,5 +225,5 @@ export function useResizable(options?: {
[onPointerMove, onPointerUp],
);
- return { size, position, getResizeHandleProps };
+ return { size, position, setSize: setSizeConstrained, getResizeHandleProps };
}