Skip to content

Commit

Permalink
Canvas zoom and pinch.
Browse files Browse the repository at this point in the history
  • Loading branch information
SebastianStehle committed Nov 2, 2024
1 parent 95cebc6 commit 4d97587
Show file tree
Hide file tree
Showing 23 changed files with 443 additions and 187 deletions.
130 changes: 130 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export const App = () => {
<Tabs type='card' activeKey={selectedTab} items={SidebarTabs} onChange={doSelectTab} destroyInactiveTabPane={true} />
</Layout.Sider>
<Layout.Content className='editor-content'>
<EditorView spacing={40} />
<EditorView />
</Layout.Content>
<Layout.Sider width={330} className='sidebar-right'
collapsed={!showRightSidebar}
Expand Down
40 changes: 40 additions & 0 deletions src/core/react/Canvas.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@


/*
* mydraft.cc
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved.
*/

import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from './Canvas';

const meta: Meta<typeof Canvas> = {
component: Canvas,
render: () => {
return (
<div style={{ height: '800px', border: '1px solid #e0e0e0', background: '#efefef' }}>
<Canvas contentWidth={500} contentHeight={500} padding={10} onRender={viewBox =>
<div>
<div style={{ background: 'white', position: 'absolute', padding: '2px 4px' }}>
{JSON.stringify(viewBox)}
</div>

<svg height='800' viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.maxX} ${viewBox.maxY}`}>
<g style={{ background: '#fff' }}>
<rect fill='#fff' width='500' height='500' />
<image xlinkHref='https://upload.wikimedia.org/wikipedia/commons/f/fd/Ghostscript_Tiger.svg' width='500' height='500'/>
</g>
</svg>
</div>
} />
</div>
);
},
};

export default meta;
type Story = StoryObj<typeof Canvas>;

export const Default: Story = {};
151 changes: 151 additions & 0 deletions src/core/react/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -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<Transform>(DEFAULT_TRANSFORM);
const contentHeightRef = React.useRef(contentHeight);
const contentWidthRef = React.useRef(contentWidth);
const selectionRef = React.useRef<Selection<HTMLDivElement, unknown, any, any>>();
const sizeRef = React.useRef(size);
const zoomRef = React.useRef<ZoomBehavior<Element, unknown>>();

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 (
<div className={className} ref={doInit}>
{onRender(viewBox, control)}
</div>
);
});

function setExtent(zoom: ZoomBehavior<Element, unknown> | 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);
Loading

0 comments on commit 4d97587

Please sign in to comment.