Skip to content
This repository was archived by the owner on Apr 24, 2025. It is now read-only.
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
185 changes: 97 additions & 88 deletions package-lock.json

Large diffs are not rendered by default.

36 changes: 15 additions & 21 deletions packages/pkg.module.board/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@
import React, { useState, useRef, useEffect } from 'react';
import { Stage } from 'react-konva';
import Konva from 'konva';
import { useDebouncedFunction } from '@xipkg/utils';
import CanvasLayer from './CanvasLayer';
import { ToolType } from './types';
import { useBoardStore, useUIStore } from './store';
import { useWheelZoom } from './hooks';
import { useZoom } from './hooks';
import { BackgroundLayer } from './components';
import { ZoomMenu } from './components/ZoomMenu';

export const Board: React.FC = () => {
// Выбранный инструмент
const [selectedTool, setSelectedTool] = useState<ToolType>('pen');
const { boardElements } = useBoardStore();

const stageRef = useRef<Konva.Stage>(null);

// Получаем scale, setScale, zoomIn и zoomOut из UI‑стора
const { scale, setStagePosition } = useUIStore();
const { boardElements } = useBoardStore();
const { setStagePosition } = useUIStore();

const boardWidth = window.innerWidth;
const boardHeight = window.innerHeight;

// Пример хоткеев: Escape – переключиться в режим выделения,
// Delete – удалить выделенные элементы (реализовать логику выбора)
Expand All @@ -34,38 +37,29 @@ export const Board: React.FC = () => {
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);

const handleWheel = useWheelZoom(stageRef);

const debouncedSetStagePos = useDebouncedFunction((x, y) => {
setStagePosition({ x, y });
}, 100);

const handleDragMove = (e: Konva.KonvaEventObject<DragEvent>) => {
debouncedSetStagePos(e.target.x(), e.target.y());
};
const { handleWheel, handleZoomIn, handleZoomOut, handleResetZoom } = useZoom(stageRef);

const handleOnWheel = (e: Konva.KonvaEventObject<WheelEvent>) => {
setStagePosition({ x: e.target.x(), y: e.target.y() });
setStagePosition(e.currentTarget.position());
handleWheel(e);
};

// Получаем размеры доски (для примера используем window.innerWidth и window.innerHeight - 50)
const boardWidth = window.innerWidth;
const boardHeight = window.innerHeight;

return (
<div className="flex h-full w-full flex-col">
<div className="relative flex-1 overflow-hidden">
<ZoomMenu zoomIn={handleZoomIn} zoomOut={handleZoomOut} resetZoom={handleResetZoom} />
<Stage
width={boardWidth}
height={boardHeight}
ref={stageRef}
className="bg-gray-0"
onWheel={handleOnWheel}
onDragMove={handleDragMove}
onDragEnd={(e) => {
setStagePosition(e.currentTarget.position());
}}
draggable
>
<BackgroundLayer scaleValue={scale} />
<BackgroundLayer />
<CanvasLayer boardElements={boardElements} selectedTool={selectedTool} />
</Stage>
</div>
Expand Down
45 changes: 24 additions & 21 deletions packages/pkg.module.board/components/BackgroundLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,10 @@
import React, { useEffect, useMemo } from 'react';
import { Layer, Shape } from 'react-konva';
import { useUIStore } from '../store';
import { boardGridStep } from '../const';
import { baseDotSize, baseGridStep, minDotSize } from '../const';

type BackgroundLayerPropsT = {
scaleValue: number;
};

export const BackgroundLayer = ({ scaleValue }: BackgroundLayerPropsT) => {
const { viewport, setViewport, stagePosition } = useUIStore();
export const BackgroundLayer = () => {
const { viewport, setViewport, stagePosition, scale } = useUIStore();

useEffect(() => {
const updateSize = () => {
Expand All @@ -23,37 +19,44 @@ export const BackgroundLayer = ({ scaleValue }: BackgroundLayerPropsT) => {
}, [setViewport]);

const dots = useMemo(() => {
const visibleWidth = viewport.width / scaleValue;
const visibleHeight = viewport.height / scaleValue;
const visibleWidth = viewport.width / scale;
const visibleHeight = viewport.height / scale;

const stepMultiplier = 2 ** Math.round(Math.log2(1 / scale));

let gridStep = baseGridStep * stepMultiplier;

let dotSize = Math.max(baseDotSize * stepMultiplier ** 1, minDotSize);

if (scale < 0.01) {
gridStep /= 2;
dotSize /= 2;
}

const buffer = Math.max(visibleWidth, visibleHeight) * 2;

const startX =
Math.floor((-stagePosition.x / scaleValue - buffer) / boardGridStep) * boardGridStep;
const startX = Math.floor((-stagePosition.x / scale - buffer) / gridStep) * gridStep;
const endX =
Math.ceil((-stagePosition.x / scaleValue + visibleWidth + buffer) / boardGridStep) *
boardGridStep;
const startY =
Math.floor((-stagePosition.y / scaleValue - buffer) / boardGridStep) * boardGridStep;
Math.ceil((-stagePosition.x / scale + visibleWidth + buffer) / gridStep) * gridStep;
const startY = Math.floor((-stagePosition.y / scale - buffer) / gridStep) * gridStep;
const endY =
Math.ceil((-stagePosition.y / scaleValue + visibleHeight + buffer) / boardGridStep) *
boardGridStep;
Math.ceil((-stagePosition.y / scale + visibleHeight + buffer) / gridStep) * gridStep;

return (
<Shape
sceneFunc={(context) => {
context.fillStyle = '#e8e8e8';
for (let x = startX; x <= endX; x += boardGridStep) {
for (let y = startY; y <= endY; y += boardGridStep) {
for (let x = startX; x <= endX; x += gridStep) {
for (let y = startY; y <= endY; y += gridStep) {
context.beginPath();
context.arc(x, y, 4 / scaleValue, 0, Math.PI * 2);
context.arc(x, y, dotSize, 0, Math.PI * 2);
context.fill();
}
}
}}
/>
);
}, [viewport.width, viewport.height, scaleValue, stagePosition]);
}, [viewport.width, viewport.height, scale, stagePosition]);

return <Layer listening={false}>{dots}</Layer>;
};
28 changes: 20 additions & 8 deletions packages/pkg.module.board/components/ZoomMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,35 @@ import { Plus, Minus } from '@xipkg/icons';
import { Button } from '@xipkg/button';
import { useUIStore } from '../store';

export const ZoomMenu = () => {
// Получаем значения из UI-стора
const { scale, zoomIn, zoomOut } = useUIStore();
type ZoomMenuPropsT = {
zoomIn: () => void;
zoomOut: () => void;
resetZoom: () => void;
};

export const ZoomMenu = ({ zoomIn, zoomOut, resetZoom }: ZoomMenuPropsT) => {
const { scale } = useUIStore();

return (
<div className="border-gray-10 absolute bottom-3 right-3 z-30">
<div className="bg-gray-0 border-gray-10 flex items-center justify-center rounded-xl border p-1">
<div className="absolute bottom-4 right-4 z-30">
<div className="bg-gray-0 border-gray-10 flex items-center justify-center gap-2 rounded-2xl border p-1">
<Button
className="hover:bg-brand-0 pointer-events-auto flex h-6 w-6 items-center justify-center rounded-lg p-0 lg:h-8 lg:w-8"
className="hover:bg-brand-0 pointer-events-auto flex h-8 w-8 items-center justify-center rounded-xl p-0 focus:bg-transparent"
variant="ghost"
onClick={() => zoomOut()}
>
<Minus className="h-4 w-4 fill-gray-100 lg:h-6 lg:w-6" />
</Button>
<div className="flex h-8 items-center justify-center">{(scale * 100).toFixed(0)}%</div>
<Button
className="hover:bg-brand-0 pointer-events-auto flex h-6 w-6 items-center justify-center rounded-lg p-0 lg:h-8 lg:w-8"
className="min-w-[60px] items-center justify-center px-2 py-1 hover:bg-transparent focus:bg-transparent active:bg-transparent"
variant="ghost"
size="s"
onClick={() => resetZoom()}
>
{scale < 0.01 ? '< 1%' : `${(scale * 100).toFixed(0)}%`}
</Button>
<Button
className="hover:bg-brand-0 pointer-events-auto flex h-8 w-8 items-center justify-center rounded-xl p-0 focus:bg-transparent"
variant="ghost"
onClick={() => zoomIn()}
>
Expand Down
4 changes: 3 additions & 1 deletion packages/pkg.module.board/const.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const boardGridStep = 40;
export const baseGridStep = 40;
export const baseDotSize = 2;
export const minDotSize = 1;
2 changes: 1 addition & 1 deletion packages/pkg.module.board/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { useWheelZoom } from './useWheelZoom';
export { useZoom } from './useWheelZoom';
107 changes: 96 additions & 11 deletions packages/pkg.module.board/hooks/useWheelZoom.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,49 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// hooks/useWheelZoom.ts
import Konva from 'konva';
import { useCallback } from 'react';
import { calculateZoom, defaultZoomConfig } from '../utils';
import { useUIStore } from '../store';
import { roundScale, zoomLevels } from '../utils/zoomConfig';

/**
* Хук для обработки масштабирования (зума) при помощи колесика мыши/тачпада.
* Принимает ссылку на Stage и возвращает обработчик onWheel.
*/
export const useWheelZoom = (stageRef: React.RefObject<any>) => {
const { setScale } = useUIStore();
export const useZoom = (stageRef: React.RefObject<Konva.Stage | null>) => {
const { setScale, setStagePosition } = useUIStore();

const handleWheel = useCallback(
(e: any) => {
(e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();

const stage = stageRef.current;
if (!stage) return;

const scaleBy = 1.1;
const oldScale = stage.scaleX();
const pointer = stage.getPointerPosition();
if (!pointer) return;

// Определяем координаты точки, на которую указывает курсор, относительно текущего масштаба
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
};

// Если прокрутка вниз – уменьшаем масштаб, иначе – увеличиваем
let newScale = e.evt.deltaY > 0 ? oldScale / scaleBy : oldScale * scaleBy;
// Ограничиваем масштаб от 50% до 300%
newScale = Math.max(0.5, Math.min(newScale, 3));
const delta = e.evt.deltaY;

const baseScaleStep = 0.01;
Copy link
Contributor

Choose a reason for hiding this comment

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

Как будто константы можно вне компонента выносить


const adjustedFactor = Math.max(0.1, oldScale * 2);

const scaleStep = baseScaleStep * adjustedFactor;

let newScale = delta > 0 ? oldScale - scaleStep : oldScale + scaleStep;

newScale = Math.max(
defaultZoomConfig.minScale,
Math.min(newScale, defaultZoomConfig.maxScale),
);

newScale = roundScale(newScale);
setScale(newScale);

// Обновляем масштаб и позицию Stage, чтобы точка под курсором оставалась на месте
Expand All @@ -46,5 +58,78 @@ export const useWheelZoom = (stageRef: React.RefObject<any>) => {
[stageRef, setScale],
);

return handleWheel;
const handleZoom = useCallback(
(direction: 'in' | 'out') => {
const stage = stageRef.current;
if (!stage) return;

const oldScale = stage.scaleX();

const uniqueZoomLevels = [...new Set([...zoomLevels, oldScale])].sort((a, b) => a - b);
const currentIndex = uniqueZoomLevels.indexOf(oldScale);

let newScale = oldScale;

if (direction === 'in') {
if (currentIndex < uniqueZoomLevels.length - 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

А нельзя ли объединить этот if с if выше?

newScale = uniqueZoomLevels[currentIndex + 1];
}
} else if (currentIndex > 0) {
newScale = uniqueZoomLevels[currentIndex - 1];
}

if (newScale === oldScale) return;

// Ограничиваем масштаб в рамках minScale и maxScale
newScale = Math.max(
defaultZoomConfig.minScale,
Math.min(newScale, defaultZoomConfig.maxScale),
);

const result = calculateZoom(stageRef, newScale, null, defaultZoomConfig);
if (!result) return;

const { newScale: finalScale, newPos } = result;

stage.to({
scaleX: finalScale,
scaleY: finalScale,
x: newPos.x,
y: newPos.y,
duration: defaultZoomConfig.animationDuration / 1000,
easing: Konva.Easings.Linear,
onUpdate: () => {
setScale(finalScale);
},
});

setStagePosition({ x: newPos.x, y: newPos.y });
stage.batchDraw();
},
[setScale, setStagePosition, stageRef],
);

const handleZoomIn = useCallback(() => handleZoom('in'), [handleZoom]);
const handleZoomOut = useCallback(() => handleZoom('out'), [handleZoom]);

const handleResetZoom = useCallback(() => {
const stage = stageRef.current;
if (!stage) return;

setScale(1);
setStagePosition({ x: stage.x(), y: stage.y() });

stage.to({
scaleX: 1,
scaleY: 1,
x: stage.x(),
y: stage.y(),
duration: defaultZoomConfig.animationDuration / 1000,
easing: Konva.Easings.Linear,
});

stage.batchDraw();
}, [stageRef, setScale, setStagePosition]);

return { handleWheel, handleZoomIn, handleZoomOut, handleResetZoom };
};
12 changes: 12 additions & 0 deletions packages/pkg.module.board/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,15 @@ export interface BoardElement {
// Свойства для изображения
src?: string;
}

export interface Point {
x: number;
y: number;
}

export interface ZoomConfig {
minScale: number;
maxScale: number;
scaleBy: number;
animationDuration: number;
}
36 changes: 36 additions & 0 deletions packages/pkg.module.board/utils/calculateBoardZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Stage } from 'konva/lib/Stage';
import { Point, ZoomConfig } from '../types';

export const calculateZoom = (
stageRef: React.RefObject<Stage | null>,
newScale: number,
pointer: Point | null,
config: ZoomConfig,
): { newScale: number; newPos: Point } | null => {
const stage = stageRef?.current;
if (!stage) return null;

const { minScale, maxScale } = config;

if (newScale > maxScale || newScale < minScale) return null;

const oldScale = stage.scaleX();

const center = { x: stage.width() / 2, y: stage.height() / 2 };
const zoomPointer = pointer || center;

const mousePointTo = {
x: zoomPointer.x / oldScale - stage.x() / oldScale,
y: zoomPointer.y / oldScale - stage.y() / oldScale,
};

const newPos = {
x: zoomPointer.x - mousePointTo.x * newScale,
y: zoomPointer.y - mousePointTo.y * newScale,
};

newPos.x = Math.round(newPos.x * 100) / 100;
newPos.y = Math.round(newPos.y * 100) / 100;

return { newScale, newPos };
};
Loading
Loading