From 4d975877d13b7c52ae6395ff89d2296581c31229 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 2 Nov 2024 17:33:38 +0100 Subject: [PATCH] Canvas zoom and pinch. --- package-lock.json | 130 ++++++++++++++++ package.json | 3 + src/App.tsx | 2 +- src/core/react/Canvas.stories.tsx | 40 +++++ src/core/react/Canvas.tsx | 151 +++++++++++++++++++ src/core/react/ColorPicker.scss | 10 +- src/core/react/ColorPicker.tsx | 2 +- src/core/react/index.ts | 1 + src/core/utils/helpers.ts | 10 +- src/core/utils/math-helper.ts | 2 +- src/wireframes/components/EditorView.scss | 11 +- src/wireframes/components/EditorView.tsx | 47 +++--- src/wireframes/components/PrintDiagram.tsx | 29 ++-- src/wireframes/components/actions/index.ts | 1 - src/wireframes/components/actions/use-ui.tsx | 49 ------ src/wireframes/components/menu/UIMenu.tsx | 6 - src/wireframes/model/actions/ui.spec.ts | 8 +- src/wireframes/model/actions/ui.ts | 8 - src/wireframes/renderer/CanvasView.tsx | 32 +--- src/wireframes/renderer/Editor.tsx | 54 +++---- src/wireframes/renderer/RenderLayer.tsx | 5 +- src/wireframes/renderer/SelectionAdorner.tsx | 9 +- src/wireframes/renderer/TransformAdorner.tsx | 20 ++- 23 files changed, 443 insertions(+), 187 deletions(-) create mode 100644 src/core/react/Canvas.stories.tsx create mode 100644 src/core/react/Canvas.tsx delete mode 100644 src/wireframes/components/actions/use-ui.tsx diff --git a/package-lock.json b/package-lock.json index ae8b317f..2d461a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@vitest/utils": "^0.34.6", "antd": "5.11.2", "classnames": "^2.3.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "date-fns": "^2.30.0", "deep-object-diff": "^1.1.9", "file-saver": "^2.0.5", @@ -44,6 +46,7 @@ "@storybook/addon-links": "^7.5.3", "@storybook/react": "^7.5.3", "@storybook/react-vite": "^7.5.3", + "@types/d3-zoom": "^3.0.8", "@types/file-saver": "^2.0.7", "@types/history": "^4.7.11", "@types/lodash": "4.14.202", @@ -6190,6 +6193,37 @@ "@types/node": "*" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/detect-port": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/detect-port/-/detect-port-1.3.5.tgz", @@ -9313,6 +9347,102 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/package.json b/package.json index 393663a4..af499b72 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "@vitest/utils": "^0.34.6", "antd": "5.11.2", "classnames": "^2.3.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "date-fns": "^2.30.0", "deep-object-diff": "^1.1.9", "file-saver": "^2.0.5", @@ -51,6 +53,7 @@ "@storybook/addon-links": "^7.5.3", "@storybook/react": "^7.5.3", "@storybook/react-vite": "^7.5.3", + "@types/d3-zoom": "^3.0.8", "@types/file-saver": "^2.0.7", "@types/history": "^4.7.11", "@types/lodash": "4.14.202", diff --git a/src/App.tsx b/src/App.tsx index c5659283..e59fbc91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -140,7 +140,7 @@ export const App = () => { - + = { + component: Canvas, + render: () => { + return ( +
+ +
+
+ {JSON.stringify(viewBox)} +
+ + + + + + + +
+ } /> +
+ ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/core/react/Canvas.tsx b/src/core/react/Canvas.tsx new file mode 100644 index 00000000..525216e6 --- /dev/null +++ b/src/core/react/Canvas.tsx @@ -0,0 +1,151 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import { select, Selection } from 'd3-selection'; +import { zoom, ZoomBehavior, ZoomTransform } from 'd3-zoom'; +import * as React from 'react'; +import { SizeMeProps, withSize } from 'react-sizeme'; + +export interface CanvasProps { + onRender: (viewbox: ViewBox, control: Control) => React.ReactNode; + contentWidth: number; + contentHeight: number; + padding: number; + className?: string; +} + +type Transform = { + k: number; + x: number; + y: number; +}; + +const DEFAULT_TRANSFORM: Transform = { + k: 1, + x: 0, + y: 0, +}; + +export type ViewBox = { + zoom: number; + minX: number; + minY: number; + maxX: number; + maxY: number; +}; + +export interface Control { + reset(): void; +} + +const CanvasComponent = React.memo((props: SizeMeProps & CanvasProps) => { + const { + className, + contentHeight, + contentWidth, + onRender, + padding, + size, + } = props; + + const [transform, setTransform] = React.useState(DEFAULT_TRANSFORM); + const contentHeightRef = React.useRef(contentHeight); + const contentWidthRef = React.useRef(contentWidth); + const selectionRef = React.useRef>(); + const sizeRef = React.useRef(size); + const zoomRef = React.useRef>(); + + contentWidthRef.current = contentWidth; + contentHeightRef.current = contentHeight; + sizeRef.current = size; + + const doInit = React.useCallback((container: HTMLDivElement) => { + if (!container) { + return; + } + + zoomRef.current = zoom() + .scaleExtent([0.5, 4]) + .filter(event => { + return event.button === 1 || event.type === 'wheel'; + }) + .on('zoom', ({ transform }: { transform: Transform }) => { + setTransform(transform); + }); + + setExtent(zoomRef.current, contentWidthRef.current, contentHeightRef.current); + + selectionRef.current = select(container); + selectionRef.current.call(zoomRef.current as any); + }, []); + + React.useMemo(() => { + setExtent(zoomRef.current, contentWidth, contentHeight); + }, [contentHeight, contentWidth]); + + const viewBox = React.useMemo(() => { + const zoom = transform.k; + const w = size.width || 0; + const h = size.height || 0; + + return { + minX: round(-transform.x / zoom), + minY: round(-transform.y / zoom), + maxX: round(w / zoom), + maxY: round(h / zoom), + zoom: round(zoom), + }; + }, [transform, size]); + + const control = React.useMemo(() => { + return { + reset: () => { + const s = sizeRef.current; + + const targetW = s.width! - 2 * padding; + const targetH = s.height! - 2 * padding; + + const zoomX = targetW / contentWidth; + const zoomY = targetH / contentHeight; + + const k = Math.min(zoomX, zoomY); + + const x = (targetW - k * contentWidth) / 2 + padding; + const y = (targetH - k * contentHeight) / 2 + padding; + + zoomRef.current?.transform(selectionRef.current as any, new ZoomTransform(k, x, y)); + }, + }; + }, [contentHeight, contentWidth, padding]); + + React.useEffect(() => { + control.reset(); + }, [control]); + + return ( +
+ {onRender(viewBox, control)} +
+ ); +}); + +function setExtent(zoom: ZoomBehavior | undefined, width: number, height: number) { + if (!zoom) { + return; + } + + const overlapX = width * 0.4; + const overlapY = height * 0.4; + + zoom.translateExtent([[-overlapX, -overlapY], [width + overlapX, height + overlapY]]); +} + +function round(source: number) { + return parseFloat(source.toFixed(2)); +} + +export const Canvas = withSize({ monitorHeight: true, monitorWidth: true })(CanvasComponent); \ No newline at end of file diff --git a/src/core/react/ColorPicker.scss b/src/core/react/ColorPicker.scss index 57193925..475a57c8 100644 --- a/src/core/react/ColorPicker.scss +++ b/src/core/react/ColorPicker.scss @@ -8,6 +8,11 @@ $size: 22px; @include clearfix; max-width: 210px; min-width: 210px; + + & .color-picker-color { + margin-right: 2px; + margin-bottom: 2px; + } } &-color { @@ -30,12 +35,11 @@ $size: 22px; } &-inner { - @include circle($size - 8px); - margin: 1px; + @include circle($size - 4px); } &:hover { - border-color: $color-border-dark; + border-color: #555; } &.selected { diff --git a/src/core/react/ColorPicker.tsx b/src/core/react/ColorPicker.tsx index 36dd4692..149b2513 100644 --- a/src/core/react/ColorPicker.tsx +++ b/src/core/react/ColorPicker.tsx @@ -122,7 +122,7 @@ export const ColorPicker = React.memo((props: ColorPickerProps) => { const placement = popoverPlacement || 'left'; return ( - +