magic-canvas-yonava 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/MagicCanvas.vue.d.ts +9 -0
- package/dist/MagicCanvas.vue.js +36 -0
- package/dist/backgroundPattern.d.ts +6 -0
- package/dist/backgroundPattern.js +36 -0
- package/dist/camera/index.d.ts +15 -0
- package/dist/camera/index.js +20 -0
- package/dist/camera/panZoom.d.ts +23 -0
- package/dist/camera/panZoom.js +100 -0
- package/dist/camera/utils.d.ts +17 -0
- package/dist/camera/utils.js +18 -0
- package/dist/coordinates/index.d.ts +44 -0
- package/dist/coordinates/index.js +58 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/localStorage.d.ts +35 -0
- package/dist/localStorage.js +20 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.js +1 -0
- package/dist/useMagicCanvas.d.ts +2 -0
- package/dist/useMagicCanvas.js +54 -0
- package/package.json +29 -0
- package/src/MagicCanvas.vue +35 -0
- package/src/backgroundPattern.ts +68 -0
- package/src/camera/index.ts +36 -0
- package/src/camera/panZoom.ts +125 -0
- package/src/camera/utils.ts +41 -0
- package/src/coordinates/index.ts +96 -0
- package/src/index.ts +3 -0
- package/src/localStorage.ts +52 -0
- package/src/types.ts +37 -0
- package/src/useMagicCanvas.ts +74 -0
- package/tsconfig.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# magic-canvas
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{
|
|
2
|
+
canvasRef: (canvas: HTMLCanvasElement) => void;
|
|
3
|
+
cleanup: (canvas: HTMLCanvasElement) => void;
|
|
4
|
+
}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{
|
|
5
|
+
canvasRef: (canvas: HTMLCanvasElement) => void;
|
|
6
|
+
cleanup: (canvas: HTMLCanvasElement) => void;
|
|
7
|
+
}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
8
|
+
declare const _default: typeof __VLS_export;
|
|
9
|
+
export default _default;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { twMerge } from "tailwind-merge";
|
|
2
|
+
import { onBeforeUnmount, onMounted, ref } from "vue";
|
|
3
|
+
const props = defineProps();
|
|
4
|
+
const canvas = ref();
|
|
5
|
+
onMounted(() => {
|
|
6
|
+
if (!canvas.value)
|
|
7
|
+
throw new Error("Canvas not found in DOM. Check ref link.");
|
|
8
|
+
props.canvasRef(canvas.value);
|
|
9
|
+
});
|
|
10
|
+
onBeforeUnmount(() => {
|
|
11
|
+
if (!canvas.value)
|
|
12
|
+
throw new Error("Canvas not found in DOM. Check ref link.");
|
|
13
|
+
props.cleanup(canvas.value);
|
|
14
|
+
});
|
|
15
|
+
const __VLS_ctx = {
|
|
16
|
+
...{},
|
|
17
|
+
...{},
|
|
18
|
+
...{},
|
|
19
|
+
...{},
|
|
20
|
+
};
|
|
21
|
+
let __VLS_components;
|
|
22
|
+
let __VLS_intrinsics;
|
|
23
|
+
let __VLS_directives;
|
|
24
|
+
__VLS_asFunctionalElement1(__VLS_intrinsics.canvas, __VLS_intrinsics.canvas)({
|
|
25
|
+
...({
|
|
26
|
+
...__VLS_ctx.$attrs,
|
|
27
|
+
class: __VLS_ctx.twMerge(__VLS_ctx.$attrs.class, ['w-full', 'h-full']),
|
|
28
|
+
}),
|
|
29
|
+
ref: "canvas",
|
|
30
|
+
});
|
|
31
|
+
// @ts-ignore
|
|
32
|
+
[$attrs, $attrs, twMerge,];
|
|
33
|
+
const __VLS_export = (await import('vue')).defineComponent({
|
|
34
|
+
__typeProps: {},
|
|
35
|
+
});
|
|
36
|
+
export default {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Camera } from './camera';
|
|
2
|
+
import type { Coordinate, DrawFns } from './types';
|
|
3
|
+
export type DrawPattern = (ctx: CanvasRenderingContext2D, at: Coordinate, alpha: string) => void;
|
|
4
|
+
export declare const useBackgroundPattern: ({ panX, panY, zoom }: Camera["state"], drawPattern: DrawFns["backgroundPattern"]) => {
|
|
5
|
+
draw: (ctx: CanvasRenderingContext2D) => void;
|
|
6
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getMagicCoordinates } from './coordinates';
|
|
2
|
+
const STAGGER = 100;
|
|
3
|
+
const START_PATTERN_FADE_OUT = 0.6;
|
|
4
|
+
const PATTERN_FULLY_FADED_OUT = 0.25;
|
|
5
|
+
const computeAlpha = (z) => {
|
|
6
|
+
if (z <= PATTERN_FULLY_FADED_OUT)
|
|
7
|
+
return '00';
|
|
8
|
+
if (z >= START_PATTERN_FADE_OUT)
|
|
9
|
+
return '';
|
|
10
|
+
const strPercent = String(Math.floor(((z - PATTERN_FULLY_FADED_OUT) /
|
|
11
|
+
(START_PATTERN_FADE_OUT - PATTERN_FULLY_FADED_OUT)) *
|
|
12
|
+
100));
|
|
13
|
+
return strPercent.length === 1 ? `0${strPercent}` : strPercent;
|
|
14
|
+
};
|
|
15
|
+
export const useBackgroundPattern = ({ panX, panY, zoom }, drawPattern) => {
|
|
16
|
+
const draw = (ctx) => {
|
|
17
|
+
if (zoom.value <= PATTERN_FULLY_FADED_OUT)
|
|
18
|
+
return;
|
|
19
|
+
const startingCoords = getMagicCoordinates({
|
|
20
|
+
clientX: 0,
|
|
21
|
+
clientY: 0,
|
|
22
|
+
}, ctx);
|
|
23
|
+
const endingCoords = getMagicCoordinates({
|
|
24
|
+
clientX: window.innerWidth + STAGGER,
|
|
25
|
+
clientY: window.innerHeight + STAGGER,
|
|
26
|
+
}, ctx);
|
|
27
|
+
const offsetX = (panX.value / zoom.value) % STAGGER;
|
|
28
|
+
const offsetY = (panY.value / zoom.value) % STAGGER;
|
|
29
|
+
for (let x = startingCoords.x + offsetX; x < endingCoords.x; x += STAGGER) {
|
|
30
|
+
for (let y = startingCoords.y + offsetY; y < endingCoords.y; y += STAGGER) {
|
|
31
|
+
drawPattern.value(ctx, { x, y }, computeAlpha(zoom.value));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return { draw };
|
|
36
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
export declare const useCamera: (canvas: Ref<HTMLCanvasElement | undefined>, storageKey: string) => {
|
|
3
|
+
transformAndClear: (ctx: CanvasRenderingContext2D) => void;
|
|
4
|
+
actions: {
|
|
5
|
+
zoomIn: (increment?: number) => void;
|
|
6
|
+
zoomOut: (decrement?: number) => void;
|
|
7
|
+
};
|
|
8
|
+
state: {
|
|
9
|
+
panX: import("@vueuse/shared").RemovableRef<number>;
|
|
10
|
+
panY: import("@vueuse/shared").RemovableRef<number>;
|
|
11
|
+
zoom: import("@vueuse/shared").RemovableRef<number>;
|
|
12
|
+
};
|
|
13
|
+
cleanup: (ref: HTMLCanvasElement) => void;
|
|
14
|
+
};
|
|
15
|
+
export type Camera = ReturnType<typeof useCamera>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { usePanAndZoom } from './panZoom';
|
|
2
|
+
import { addTransform, getDevicePixelRatio, } from './utils';
|
|
3
|
+
export const useCamera = (canvas, storageKey) => {
|
|
4
|
+
const { getTransform: getPZTransform, ...rest } = usePanAndZoom(canvas, storageKey);
|
|
5
|
+
const dpr = getDevicePixelRatio();
|
|
6
|
+
const dprTransform = {
|
|
7
|
+
scaleX: dpr,
|
|
8
|
+
scaleY: dpr,
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
...rest,
|
|
12
|
+
transformAndClear: (ctx) => {
|
|
13
|
+
ctx.resetTransform();
|
|
14
|
+
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
15
|
+
const transforms = [dprTransform, getPZTransform()];
|
|
16
|
+
for (const t of transforms)
|
|
17
|
+
addTransform(ctx, t);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Ref } from "vue";
|
|
2
|
+
export declare const MIN_ZOOM = 0.2;
|
|
3
|
+
export declare const MAX_ZOOM = 10;
|
|
4
|
+
export declare const ZOOM_SENSITIVITY = 0.02;
|
|
5
|
+
export declare const PAN_SENSITIVITY = 1;
|
|
6
|
+
export declare const usePanAndZoom: (canvas: Ref<HTMLCanvasElement | undefined>, storageKey: string) => {
|
|
7
|
+
actions: {
|
|
8
|
+
zoomIn: (increment?: number) => void;
|
|
9
|
+
zoomOut: (decrement?: number) => void;
|
|
10
|
+
};
|
|
11
|
+
state: {
|
|
12
|
+
panX: import("@vueuse/shared").RemovableRef<number>;
|
|
13
|
+
panY: import("@vueuse/shared").RemovableRef<number>;
|
|
14
|
+
zoom: import("@vueuse/shared").RemovableRef<number>;
|
|
15
|
+
};
|
|
16
|
+
getTransform: () => {
|
|
17
|
+
scaleX: number;
|
|
18
|
+
scaleY: number;
|
|
19
|
+
translateX: number;
|
|
20
|
+
translateY: number;
|
|
21
|
+
};
|
|
22
|
+
cleanup: (ref: HTMLCanvasElement) => void;
|
|
23
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useLocalStorage } from "@vueuse/core";
|
|
2
|
+
import { localKeys } from "../localStorage";
|
|
3
|
+
import { onMounted } from "vue";
|
|
4
|
+
import { MOUSE_BUTTONS } from "magic-utils-yonava";
|
|
5
|
+
export const MIN_ZOOM = 0.2;
|
|
6
|
+
export const MAX_ZOOM = 10;
|
|
7
|
+
export const ZOOM_SENSITIVITY = 0.02;
|
|
8
|
+
export const PAN_SENSITIVITY = 1;
|
|
9
|
+
export const usePanAndZoom = (canvas, storageKey) => {
|
|
10
|
+
const panX = useLocalStorage(localKeys.cameraPanX(storageKey), 0);
|
|
11
|
+
const panY = useLocalStorage(localKeys.cameraPanY(storageKey), 0);
|
|
12
|
+
const zoom = useLocalStorage(localKeys.cameraZoom(storageKey), 1);
|
|
13
|
+
const setZoom = (ev) => {
|
|
14
|
+
const rect = canvas.value.getBoundingClientRect();
|
|
15
|
+
const cx = ev.clientX - rect.left;
|
|
16
|
+
const cy = ev.clientY - rect.top;
|
|
17
|
+
// clamp deltaY to a max range to prevent mice with large deltaY notches from feeling too sensitive
|
|
18
|
+
const normalizedDelta = Math.max(-100, Math.min(100, ev.deltaY));
|
|
19
|
+
const zoomFactor = Math.exp(-normalizedDelta * ZOOM_SENSITIVITY);
|
|
20
|
+
const clampedZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom.value * zoomFactor));
|
|
21
|
+
const scale = clampedZoom / zoom.value;
|
|
22
|
+
panX.value = cx - (cx - panX.value) * scale;
|
|
23
|
+
panY.value = cy - (cy - panY.value) * scale;
|
|
24
|
+
zoom.value = clampedZoom;
|
|
25
|
+
};
|
|
26
|
+
const setPan = (ev) => {
|
|
27
|
+
panX.value -= ev.deltaX * PAN_SENSITIVITY;
|
|
28
|
+
panY.value -= ev.deltaY * PAN_SENSITIVITY;
|
|
29
|
+
};
|
|
30
|
+
const onWheel = (ev) => {
|
|
31
|
+
ev.preventDefault();
|
|
32
|
+
const isPanning = !ev.ctrlKey;
|
|
33
|
+
const maneuverCamera = isPanning ? setPan : setZoom;
|
|
34
|
+
maneuverCamera(ev);
|
|
35
|
+
};
|
|
36
|
+
let lastX = 0;
|
|
37
|
+
let lastY = 0;
|
|
38
|
+
let middleMouseDown = false;
|
|
39
|
+
const onMousedown = (ev) => {
|
|
40
|
+
middleMouseDown = ev.button === MOUSE_BUTTONS.middle;
|
|
41
|
+
if (!middleMouseDown)
|
|
42
|
+
return;
|
|
43
|
+
lastX = ev.clientX;
|
|
44
|
+
lastY = ev.clientY;
|
|
45
|
+
};
|
|
46
|
+
const onMousemove = (ev) => {
|
|
47
|
+
if (!middleMouseDown)
|
|
48
|
+
return;
|
|
49
|
+
setPan({
|
|
50
|
+
deltaX: lastX - ev.clientX,
|
|
51
|
+
deltaY: lastY - ev.clientY,
|
|
52
|
+
});
|
|
53
|
+
lastX = ev.clientX;
|
|
54
|
+
lastY = ev.clientY;
|
|
55
|
+
};
|
|
56
|
+
const onMouseup = () => {
|
|
57
|
+
lastX = 0;
|
|
58
|
+
lastY = 0;
|
|
59
|
+
middleMouseDown = false;
|
|
60
|
+
};
|
|
61
|
+
onMounted(() => {
|
|
62
|
+
if (!canvas.value)
|
|
63
|
+
throw new Error("canvas not found in DOM");
|
|
64
|
+
canvas.value.addEventListener("wheel", onWheel, { passive: false });
|
|
65
|
+
canvas.value.addEventListener("mousedown", onMousedown);
|
|
66
|
+
canvas.value.addEventListener("mousemove", onMousemove);
|
|
67
|
+
document.addEventListener("mouseup", onMouseup);
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
actions: {
|
|
71
|
+
zoomIn: (increment = 12.5) => setZoom({
|
|
72
|
+
deltaY: -increment,
|
|
73
|
+
clientX: window.innerWidth / 2,
|
|
74
|
+
clientY: window.innerHeight / 2,
|
|
75
|
+
}),
|
|
76
|
+
zoomOut: (decrement = 12.5) => setZoom({
|
|
77
|
+
deltaY: decrement,
|
|
78
|
+
clientX: window.innerWidth / 2,
|
|
79
|
+
clientY: window.innerHeight / 2,
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
state: {
|
|
83
|
+
panX,
|
|
84
|
+
panY,
|
|
85
|
+
zoom,
|
|
86
|
+
},
|
|
87
|
+
getTransform: () => ({
|
|
88
|
+
scaleX: zoom.value,
|
|
89
|
+
scaleY: zoom.value,
|
|
90
|
+
translateX: panX.value,
|
|
91
|
+
translateY: panY.value,
|
|
92
|
+
}),
|
|
93
|
+
cleanup: (ref) => {
|
|
94
|
+
ref.removeEventListener("wheel", onWheel);
|
|
95
|
+
ref.removeEventListener("mousedown", onMousedown);
|
|
96
|
+
ref.removeEventListener("mousemove", onMousemove);
|
|
97
|
+
document.removeEventListener("mouseup", onMouseup);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TransformProps = {
|
|
2
|
+
/** corresponds to `a` in {@link CanvasRenderingContext2D.setTransform} */
|
|
3
|
+
scaleX: number;
|
|
4
|
+
/** corresponds to `b` in {@link CanvasRenderingContext2D.setTransform} */
|
|
5
|
+
skewY: number;
|
|
6
|
+
/** corresponds to `c` in {@link CanvasRenderingContext2D.setTransform} */
|
|
7
|
+
skewX: number;
|
|
8
|
+
/** corresponds to `d` in {@link CanvasRenderingContext2D.setTransform} */
|
|
9
|
+
scaleY: number;
|
|
10
|
+
/** corresponds to `e` in {@link CanvasRenderingContext2D.setTransform} */
|
|
11
|
+
translateX: number;
|
|
12
|
+
/** corresponds to `f` in {@link CanvasRenderingContext2D.setTransform} */
|
|
13
|
+
translateY: number;
|
|
14
|
+
};
|
|
15
|
+
export type TransformOptions = Partial<TransformProps>;
|
|
16
|
+
export declare const getDevicePixelRatio: () => number;
|
|
17
|
+
export declare const addTransform: (ctx: CanvasRenderingContext2D, t: TransformOptions) => void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const getDevicePixelRatio = () => window.devicePixelRatio ?? 1;
|
|
2
|
+
export const addTransform = (ctx, t) => {
|
|
3
|
+
const translateX = t.translateX ?? 0;
|
|
4
|
+
const translateY = t.translateY ?? 0;
|
|
5
|
+
if (translateX !== 0 || translateY !== 0) {
|
|
6
|
+
ctx.translate(translateX, translateY);
|
|
7
|
+
}
|
|
8
|
+
const scaleX = t.scaleX ?? 1;
|
|
9
|
+
const scaleY = t.scaleY ?? 1;
|
|
10
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
11
|
+
ctx.scale(scaleX, scaleY);
|
|
12
|
+
}
|
|
13
|
+
const skewX = t.skewX ?? 0;
|
|
14
|
+
const skewY = t.skewY ?? 0;
|
|
15
|
+
if (skewX !== 0 || skewY !== 0) {
|
|
16
|
+
ctx.transform(1, skewY, skewX, 1, 0, 0);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Ref } from "vue";
|
|
2
|
+
import { Coordinate } from "../types";
|
|
3
|
+
export declare const getCanvasTransform: (ctx: CanvasRenderingContext2D) => {
|
|
4
|
+
panX: number;
|
|
5
|
+
panY: number;
|
|
6
|
+
zoom: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* the coordinates in the real world. aka the browser
|
|
10
|
+
*/
|
|
11
|
+
export type ClientCoords = Pick<MouseEvent, "clientX" | "clientY">;
|
|
12
|
+
/**
|
|
13
|
+
* the coordinates in the magic canvas world
|
|
14
|
+
*/
|
|
15
|
+
export type MagicCoords = Coordinate;
|
|
16
|
+
export type WithZoom<T> = T & {
|
|
17
|
+
/**
|
|
18
|
+
* the scale factor of the canvas
|
|
19
|
+
*/
|
|
20
|
+
zoom: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* magic coordinates are coordinates transformed by the pan and zoom of the camera.
|
|
24
|
+
*
|
|
25
|
+
* if the user has panned their camera 10px to the left, running this function with
|
|
26
|
+
* `clientCoords` set to (0, 0) will return (-10, 0, 1)
|
|
27
|
+
*/
|
|
28
|
+
export declare const getMagicCoordinates: (clientCoords: ClientCoords, ctx: CanvasRenderingContext2D) => WithZoom<MagicCoords>;
|
|
29
|
+
/**
|
|
30
|
+
* client coordinates are the raw coordinates corresponding to the clients physical screen.
|
|
31
|
+
*
|
|
32
|
+
* the top left corner is (0, 0) and bottom right corner is (window.innerWidth, window.innerHeight).
|
|
33
|
+
*/
|
|
34
|
+
export declare const getClientCoordinates: (magicCoords: MagicCoords, ctx: CanvasRenderingContext2D) => WithZoom<ClientCoords>;
|
|
35
|
+
export declare const useMagicCoordinates: (canvas: Ref<HTMLCanvasElement | undefined>) => {
|
|
36
|
+
coordinates: Ref<{
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
}, Coordinate | {
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
}>;
|
|
43
|
+
cleanup: (ref: HTMLCanvasElement) => void;
|
|
44
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getDevicePixelRatio } from "../camera/utils";
|
|
2
|
+
import { onMounted, ref } from "vue";
|
|
3
|
+
import { getCtx } from "magic-utils-yonava";
|
|
4
|
+
export const getCanvasTransform = (ctx) => {
|
|
5
|
+
const { a, e, f } = ctx.getTransform();
|
|
6
|
+
// TODO investigate why dpr isn't already factored into ctx. Camera should add it with the PZ transform!
|
|
7
|
+
const dpr = getDevicePixelRatio();
|
|
8
|
+
const zoom = a / dpr;
|
|
9
|
+
const panX = e / dpr;
|
|
10
|
+
const panY = f / dpr;
|
|
11
|
+
return { panX, panY, zoom };
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* magic coordinates are coordinates transformed by the pan and zoom of the camera.
|
|
15
|
+
*
|
|
16
|
+
* if the user has panned their camera 10px to the left, running this function with
|
|
17
|
+
* `clientCoords` set to (0, 0) will return (-10, 0, 1)
|
|
18
|
+
*/
|
|
19
|
+
export const getMagicCoordinates = (clientCoords, ctx) => {
|
|
20
|
+
const rect = ctx.canvas.getBoundingClientRect();
|
|
21
|
+
const localX = clientCoords.clientX - rect.left;
|
|
22
|
+
const localY = clientCoords.clientY - rect.top;
|
|
23
|
+
const { panX, panY, zoom } = getCanvasTransform(ctx);
|
|
24
|
+
const x = (localX - panX) / zoom;
|
|
25
|
+
const y = (localY - panY) / zoom;
|
|
26
|
+
return { x, y, zoom };
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* client coordinates are the raw coordinates corresponding to the clients physical screen.
|
|
30
|
+
*
|
|
31
|
+
* the top left corner is (0, 0) and bottom right corner is (window.innerWidth, window.innerHeight).
|
|
32
|
+
*/
|
|
33
|
+
export const getClientCoordinates = (magicCoords, ctx) => {
|
|
34
|
+
const { panX, panY, zoom } = getCanvasTransform(ctx);
|
|
35
|
+
const { x, y } = magicCoords;
|
|
36
|
+
return {
|
|
37
|
+
clientX: x * zoom + panX,
|
|
38
|
+
clientY: y * zoom + panY,
|
|
39
|
+
zoom,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export const useMagicCoordinates = (canvas) => {
|
|
43
|
+
const coordinates = ref({ x: 0, y: 0 });
|
|
44
|
+
const captureCoords = (ev) => (coordinates.value = getMagicCoordinates(ev, getCtx(canvas)));
|
|
45
|
+
onMounted(() => {
|
|
46
|
+
if (!canvas.value)
|
|
47
|
+
throw new Error("Canvas not found in DOM. Check ref link.");
|
|
48
|
+
canvas.value.addEventListener("mousemove", captureCoords);
|
|
49
|
+
canvas.value.addEventListener("wheel", captureCoords);
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
coordinates,
|
|
53
|
+
cleanup: (ref) => {
|
|
54
|
+
ref.removeEventListener("mousemove", captureCoords);
|
|
55
|
+
ref.removeEventListener("wheel", captureCoords);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a registry for all localStorage keys this application uses
|
|
3
|
+
*/
|
|
4
|
+
export declare const localKeys: {
|
|
5
|
+
/** camera `panX` state in magic canvas - {@link Camera.state} */
|
|
6
|
+
readonly cameraPanX: (key: string) => `camera-pan-x-${string}`;
|
|
7
|
+
/** camera `panY` state in magic canvas - {@link Camera.state} */
|
|
8
|
+
readonly cameraPanY: (key: string) => `camera-pan-y-${string}`;
|
|
9
|
+
/** camera `zoom` state in magic canvas - {@link Camera.state} */
|
|
10
|
+
readonly cameraZoom: (key: string) => `camera-zoom-${string}`;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* all return values of localStorage are, by default, string.
|
|
14
|
+
* this type allows string to be narrowed to types such as 'true' | 'false'
|
|
15
|
+
*/
|
|
16
|
+
type TypeOverride = {};
|
|
17
|
+
type LocalObj = typeof localKeys;
|
|
18
|
+
/**
|
|
19
|
+
* @example
|
|
20
|
+
* type T = TypeOrReturnType<number> // number
|
|
21
|
+
* type TFunc = TypeOrReturnType<() => number> // number
|
|
22
|
+
*/
|
|
23
|
+
type TypeOrReturnType<T> = T extends (...args: any[]) => infer U ? U : T;
|
|
24
|
+
type LocalKeys = TypeOrReturnType<LocalObj[keyof LocalObj]>;
|
|
25
|
+
type LocalType<T extends LocalKeys> = T extends keyof TypeOverride ? TypeOverride[T] : string;
|
|
26
|
+
/**
|
|
27
|
+
* perform **type safe** localStorage actions
|
|
28
|
+
*/
|
|
29
|
+
export declare const local: {
|
|
30
|
+
get: <T extends LocalKeys>(key: T) => string | null;
|
|
31
|
+
set: <T extends LocalKeys, K extends LocalType<T>>(key: T, value: K) => void;
|
|
32
|
+
remove: <T extends LocalKeys>(key: T) => void;
|
|
33
|
+
clear: () => void;
|
|
34
|
+
};
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a registry for all localStorage keys this application uses
|
|
3
|
+
*/
|
|
4
|
+
export const localKeys = {
|
|
5
|
+
/** camera `panX` state in magic canvas - {@link Camera.state} */
|
|
6
|
+
cameraPanX: (key) => `camera-pan-x-${key}`,
|
|
7
|
+
/** camera `panY` state in magic canvas - {@link Camera.state} */
|
|
8
|
+
cameraPanY: (key) => `camera-pan-y-${key}`,
|
|
9
|
+
/** camera `zoom` state in magic canvas - {@link Camera.state} */
|
|
10
|
+
cameraZoom: (key) => `camera-zoom-${key}`,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* perform **type safe** localStorage actions
|
|
14
|
+
*/
|
|
15
|
+
export const local = {
|
|
16
|
+
get: (key) => localStorage.getItem(key),
|
|
17
|
+
set: (key, value) => localStorage.setItem(key, value),
|
|
18
|
+
remove: (key) => localStorage.removeItem(key),
|
|
19
|
+
clear: localStorage.clear,
|
|
20
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { DrawPattern } from './backgroundPattern';
|
|
3
|
+
import type { Camera } from './camera';
|
|
4
|
+
export type Coordinate = {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
};
|
|
8
|
+
export type DrawContent = (ctx: CanvasRenderingContext2D) => void;
|
|
9
|
+
export type DrawFns = {
|
|
10
|
+
content: Ref<DrawContent>;
|
|
11
|
+
backgroundPattern: Ref<DrawPattern>;
|
|
12
|
+
};
|
|
13
|
+
export type MagicCanvasProps = {
|
|
14
|
+
canvas: Ref<HTMLCanvasElement | undefined>;
|
|
15
|
+
camera: Omit<Camera, 'cleanup'>;
|
|
16
|
+
cursorCoordinates: Ref<Coordinate>;
|
|
17
|
+
ref: {
|
|
18
|
+
canvasRef: (canvas: HTMLCanvasElement) => void;
|
|
19
|
+
cleanup: (canvas: HTMLCanvasElement) => void;
|
|
20
|
+
};
|
|
21
|
+
draw: DrawFns;
|
|
22
|
+
};
|
|
23
|
+
export type MagicCanvasOptions = {
|
|
24
|
+
/**
|
|
25
|
+
* a key that is used to track the camera state in localStorage
|
|
26
|
+
*/
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
};
|
|
29
|
+
export type UseMagicCanvas = (options?: MagicCanvasOptions) => MagicCanvasProps;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useElementSize } from "@vueuse/core";
|
|
2
|
+
import { onMounted, ref, watch } from "vue";
|
|
3
|
+
import { useBackgroundPattern } from "./backgroundPattern";
|
|
4
|
+
import { useCamera } from "./camera";
|
|
5
|
+
import { getDevicePixelRatio } from "./camera/utils";
|
|
6
|
+
import { useMagicCoordinates } from "./coordinates";
|
|
7
|
+
import { getCtx } from "magic-utils-yonava";
|
|
8
|
+
const REPAINT_FPS = 60;
|
|
9
|
+
const initCanvasWidthHeight = (canvas) => {
|
|
10
|
+
if (!canvas)
|
|
11
|
+
throw new Error("Canvas not found in DOM. Check ref link.");
|
|
12
|
+
const dpr = getDevicePixelRatio();
|
|
13
|
+
const rect = canvas.getBoundingClientRect();
|
|
14
|
+
canvas.width = rect.width * dpr;
|
|
15
|
+
canvas.height = rect.height * dpr;
|
|
16
|
+
};
|
|
17
|
+
export const useMagicCanvas = (options = {}) => {
|
|
18
|
+
const canvas = ref();
|
|
19
|
+
const canvasBoxSize = useElementSize(canvas);
|
|
20
|
+
const drawContent = ref(() => { });
|
|
21
|
+
const drawBackgroundPattern = ref(() => { });
|
|
22
|
+
let repaintInterval;
|
|
23
|
+
onMounted(() => {
|
|
24
|
+
initCanvasWidthHeight(canvas.value);
|
|
25
|
+
repaintInterval = setInterval(repaintCanvas, 1000 / REPAINT_FPS);
|
|
26
|
+
});
|
|
27
|
+
watch([canvasBoxSize.width, canvasBoxSize.height], () => initCanvasWidthHeight(canvas.value));
|
|
28
|
+
const { cleanup: cleanupCamera, ...camera } = useCamera(canvas, options?.storageKey ?? "[default-storage-key]");
|
|
29
|
+
const { coordinates: cursorCoordinates, cleanup: cleanupCoords } = useMagicCoordinates(canvas);
|
|
30
|
+
const pattern = useBackgroundPattern(camera.state, drawBackgroundPattern);
|
|
31
|
+
const repaintCanvas = () => {
|
|
32
|
+
const ctx = getCtx(canvas);
|
|
33
|
+
camera.transformAndClear(ctx);
|
|
34
|
+
pattern.draw(ctx);
|
|
35
|
+
drawContent.value(ctx);
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
canvas,
|
|
39
|
+
camera,
|
|
40
|
+
cursorCoordinates,
|
|
41
|
+
ref: {
|
|
42
|
+
canvasRef: (ref) => (canvas.value = ref),
|
|
43
|
+
cleanup: (ref) => {
|
|
44
|
+
cleanupCoords(ref);
|
|
45
|
+
cleanupCamera(ref);
|
|
46
|
+
clearInterval(repaintInterval);
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
draw: {
|
|
50
|
+
content: drawContent,
|
|
51
|
+
backgroundPattern: drawBackgroundPattern,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "magic-canvas-yonava",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.cjs.js",
|
|
5
|
+
"module": "dist/index.esm.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.es.js",
|
|
10
|
+
"require": "./dist/index.cjs.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build:types": "vue-tsc -b ."
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"vue": "^3.5.18"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@vueuse/core": "^13.6.0",
|
|
22
|
+
"magic-utils-yonava": "^1.0.7",
|
|
23
|
+
"tailwind-merge": "^3.3.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^24.8.1",
|
|
27
|
+
"vue-tsc": "^3.1.1"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { type ClassNameValue, twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
import { onBeforeUnmount, onMounted, ref } from "vue";
|
|
5
|
+
|
|
6
|
+
import type { MagicCanvasProps } from "./types";
|
|
7
|
+
|
|
8
|
+
const props = defineProps<MagicCanvasProps["ref"]>();
|
|
9
|
+
|
|
10
|
+
const canvas = ref<HTMLCanvasElement>();
|
|
11
|
+
|
|
12
|
+
onMounted(() => {
|
|
13
|
+
if (!canvas.value)
|
|
14
|
+
throw new Error("Canvas not found in DOM. Check ref link.");
|
|
15
|
+
props.canvasRef(canvas.value);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
onBeforeUnmount(() => {
|
|
19
|
+
if (!canvas.value)
|
|
20
|
+
throw new Error("Canvas not found in DOM. Check ref link.");
|
|
21
|
+
props.cleanup(canvas.value);
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<canvas
|
|
27
|
+
v-bind="{
|
|
28
|
+
...$attrs,
|
|
29
|
+
class: twMerge($attrs.class as ClassNameValue, ['w-full', 'h-full']),
|
|
30
|
+
}"
|
|
31
|
+
ref="canvas"
|
|
32
|
+
>
|
|
33
|
+
Sorry, your browser does not support canvas.
|
|
34
|
+
</canvas>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getMagicCoordinates } from './coordinates';
|
|
2
|
+
|
|
3
|
+
import type { Camera } from './camera';
|
|
4
|
+
import type { Coordinate, DrawFns } from './types';
|
|
5
|
+
|
|
6
|
+
const STAGGER = 100;
|
|
7
|
+
|
|
8
|
+
const START_PATTERN_FADE_OUT = 0.6;
|
|
9
|
+
const PATTERN_FULLY_FADED_OUT = 0.25;
|
|
10
|
+
|
|
11
|
+
const computeAlpha = (z: number) => {
|
|
12
|
+
if (z <= PATTERN_FULLY_FADED_OUT) return '00';
|
|
13
|
+
if (z >= START_PATTERN_FADE_OUT) return '';
|
|
14
|
+
const strPercent = String(
|
|
15
|
+
Math.floor(
|
|
16
|
+
((z - PATTERN_FULLY_FADED_OUT) /
|
|
17
|
+
(START_PATTERN_FADE_OUT - PATTERN_FULLY_FADED_OUT)) *
|
|
18
|
+
100,
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
return strPercent.length === 1 ? `0${strPercent}` : strPercent;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DrawPattern = (
|
|
25
|
+
ctx: CanvasRenderingContext2D,
|
|
26
|
+
at: Coordinate,
|
|
27
|
+
alpha: string,
|
|
28
|
+
) => void;
|
|
29
|
+
|
|
30
|
+
export const useBackgroundPattern = (
|
|
31
|
+
{ panX, panY, zoom }: Camera['state'],
|
|
32
|
+
drawPattern: DrawFns['backgroundPattern'],
|
|
33
|
+
) => {
|
|
34
|
+
const draw = (ctx: CanvasRenderingContext2D) => {
|
|
35
|
+
if (zoom.value <= PATTERN_FULLY_FADED_OUT) return;
|
|
36
|
+
|
|
37
|
+
const startingCoords = getMagicCoordinates(
|
|
38
|
+
{
|
|
39
|
+
clientX: 0,
|
|
40
|
+
clientY: 0,
|
|
41
|
+
},
|
|
42
|
+
ctx,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const endingCoords = getMagicCoordinates(
|
|
46
|
+
{
|
|
47
|
+
clientX: window.innerWidth + STAGGER,
|
|
48
|
+
clientY: window.innerHeight + STAGGER,
|
|
49
|
+
},
|
|
50
|
+
ctx,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const offsetX = (panX.value / zoom.value) % STAGGER;
|
|
54
|
+
const offsetY = (panY.value / zoom.value) % STAGGER;
|
|
55
|
+
|
|
56
|
+
for (let x = startingCoords.x + offsetX; x < endingCoords.x; x += STAGGER) {
|
|
57
|
+
for (
|
|
58
|
+
let y = startingCoords.y + offsetY;
|
|
59
|
+
y < endingCoords.y;
|
|
60
|
+
y += STAGGER
|
|
61
|
+
) {
|
|
62
|
+
drawPattern.value(ctx, { x, y }, computeAlpha(zoom.value));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return { draw };
|
|
68
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
import { usePanAndZoom } from './panZoom';
|
|
4
|
+
import {
|
|
5
|
+
type TransformOptions,
|
|
6
|
+
addTransform,
|
|
7
|
+
getDevicePixelRatio,
|
|
8
|
+
} from './utils';
|
|
9
|
+
|
|
10
|
+
export const useCamera = (
|
|
11
|
+
canvas: Ref<HTMLCanvasElement | undefined>,
|
|
12
|
+
storageKey: string,
|
|
13
|
+
) => {
|
|
14
|
+
const { getTransform: getPZTransform, ...rest } = usePanAndZoom(
|
|
15
|
+
canvas,
|
|
16
|
+
storageKey,
|
|
17
|
+
);
|
|
18
|
+
const dpr = getDevicePixelRatio();
|
|
19
|
+
|
|
20
|
+
const dprTransform: TransformOptions = {
|
|
21
|
+
scaleX: dpr,
|
|
22
|
+
scaleY: dpr,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...rest,
|
|
27
|
+
transformAndClear: (ctx: CanvasRenderingContext2D) => {
|
|
28
|
+
ctx.resetTransform();
|
|
29
|
+
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
30
|
+
const transforms = [dprTransform, getPZTransform()];
|
|
31
|
+
for (const t of transforms) addTransform(ctx, t);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type Camera = ReturnType<typeof useCamera>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useLocalStorage } from "@vueuse/core";
|
|
2
|
+
import { localKeys } from "../localStorage";
|
|
3
|
+
|
|
4
|
+
import { type Ref, onMounted } from "vue";
|
|
5
|
+
import { MOUSE_BUTTONS } from "magic-utils-yonava";
|
|
6
|
+
|
|
7
|
+
export const MIN_ZOOM = 0.2;
|
|
8
|
+
export const MAX_ZOOM = 10;
|
|
9
|
+
|
|
10
|
+
export const ZOOM_SENSITIVITY = 0.02;
|
|
11
|
+
export const PAN_SENSITIVITY = 1;
|
|
12
|
+
|
|
13
|
+
export const usePanAndZoom = (
|
|
14
|
+
canvas: Ref<HTMLCanvasElement | undefined>,
|
|
15
|
+
storageKey: string
|
|
16
|
+
) => {
|
|
17
|
+
const panX = useLocalStorage(localKeys.cameraPanX(storageKey), 0);
|
|
18
|
+
const panY = useLocalStorage(localKeys.cameraPanY(storageKey), 0);
|
|
19
|
+
const zoom = useLocalStorage(localKeys.cameraZoom(storageKey), 1);
|
|
20
|
+
|
|
21
|
+
const setZoom = (ev: Pick<WheelEvent, "clientX" | "clientY" | "deltaY">) => {
|
|
22
|
+
const rect = canvas.value!.getBoundingClientRect();
|
|
23
|
+
const cx = ev.clientX - rect.left;
|
|
24
|
+
const cy = ev.clientY - rect.top;
|
|
25
|
+
|
|
26
|
+
// clamp deltaY to a max range to prevent mice with large deltaY notches from feeling too sensitive
|
|
27
|
+
const normalizedDelta = Math.max(-100, Math.min(100, ev.deltaY));
|
|
28
|
+
const zoomFactor = Math.exp(-normalizedDelta * ZOOM_SENSITIVITY);
|
|
29
|
+
const clampedZoom = Math.min(
|
|
30
|
+
MAX_ZOOM,
|
|
31
|
+
Math.max(MIN_ZOOM, zoom.value * zoomFactor)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const scale = clampedZoom / zoom.value;
|
|
35
|
+
|
|
36
|
+
panX.value = cx - (cx - panX.value) * scale;
|
|
37
|
+
panY.value = cy - (cy - panY.value) * scale;
|
|
38
|
+
zoom.value = clampedZoom;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const setPan = (ev: Pick<WheelEvent, "deltaX" | "deltaY">) => {
|
|
42
|
+
panX.value -= ev.deltaX * PAN_SENSITIVITY;
|
|
43
|
+
panY.value -= ev.deltaY * PAN_SENSITIVITY;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const onWheel = (ev: WheelEvent) => {
|
|
47
|
+
ev.preventDefault();
|
|
48
|
+
|
|
49
|
+
const isPanning = !ev.ctrlKey;
|
|
50
|
+
const maneuverCamera = isPanning ? setPan : setZoom;
|
|
51
|
+
maneuverCamera(ev);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let lastX = 0;
|
|
55
|
+
let lastY = 0;
|
|
56
|
+
let middleMouseDown = false;
|
|
57
|
+
|
|
58
|
+
const onMousedown = (ev: MouseEvent) => {
|
|
59
|
+
middleMouseDown = ev.button === MOUSE_BUTTONS.middle;
|
|
60
|
+
if (!middleMouseDown) return;
|
|
61
|
+
|
|
62
|
+
lastX = ev.clientX;
|
|
63
|
+
lastY = ev.clientY;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onMousemove = (ev: MouseEvent) => {
|
|
67
|
+
if (!middleMouseDown) return;
|
|
68
|
+
|
|
69
|
+
setPan({
|
|
70
|
+
deltaX: lastX - ev.clientX,
|
|
71
|
+
deltaY: lastY - ev.clientY,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
lastX = ev.clientX;
|
|
75
|
+
lastY = ev.clientY;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const onMouseup = () => {
|
|
79
|
+
lastX = 0;
|
|
80
|
+
lastY = 0;
|
|
81
|
+
middleMouseDown = false;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
onMounted(() => {
|
|
85
|
+
if (!canvas.value) throw new Error("canvas not found in DOM");
|
|
86
|
+
canvas.value.addEventListener("wheel", onWheel, { passive: false });
|
|
87
|
+
canvas.value.addEventListener("mousedown", onMousedown);
|
|
88
|
+
canvas.value.addEventListener("mousemove", onMousemove);
|
|
89
|
+
document.addEventListener("mouseup", onMouseup);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
actions: {
|
|
94
|
+
zoomIn: (increment = 12.5) =>
|
|
95
|
+
setZoom({
|
|
96
|
+
deltaY: -increment,
|
|
97
|
+
clientX: window.innerWidth / 2,
|
|
98
|
+
clientY: window.innerHeight / 2,
|
|
99
|
+
}),
|
|
100
|
+
zoomOut: (decrement = 12.5) =>
|
|
101
|
+
setZoom({
|
|
102
|
+
deltaY: decrement,
|
|
103
|
+
clientX: window.innerWidth / 2,
|
|
104
|
+
clientY: window.innerHeight / 2,
|
|
105
|
+
}),
|
|
106
|
+
},
|
|
107
|
+
state: {
|
|
108
|
+
panX,
|
|
109
|
+
panY,
|
|
110
|
+
zoom,
|
|
111
|
+
},
|
|
112
|
+
getTransform: () => ({
|
|
113
|
+
scaleX: zoom.value,
|
|
114
|
+
scaleY: zoom.value,
|
|
115
|
+
translateX: panX.value,
|
|
116
|
+
translateY: panY.value,
|
|
117
|
+
}),
|
|
118
|
+
cleanup: (ref: HTMLCanvasElement) => {
|
|
119
|
+
ref.removeEventListener("wheel", onWheel);
|
|
120
|
+
ref.removeEventListener("mousedown", onMousedown);
|
|
121
|
+
ref.removeEventListener("mousemove", onMousemove);
|
|
122
|
+
document.removeEventListener("mouseup", onMouseup);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type TransformProps = {
|
|
2
|
+
/** corresponds to `a` in {@link CanvasRenderingContext2D.setTransform} */
|
|
3
|
+
scaleX: number;
|
|
4
|
+
/** corresponds to `b` in {@link CanvasRenderingContext2D.setTransform} */
|
|
5
|
+
skewY: number;
|
|
6
|
+
/** corresponds to `c` in {@link CanvasRenderingContext2D.setTransform} */
|
|
7
|
+
skewX: number;
|
|
8
|
+
/** corresponds to `d` in {@link CanvasRenderingContext2D.setTransform} */
|
|
9
|
+
scaleY: number;
|
|
10
|
+
/** corresponds to `e` in {@link CanvasRenderingContext2D.setTransform} */
|
|
11
|
+
translateX: number;
|
|
12
|
+
/** corresponds to `f` in {@link CanvasRenderingContext2D.setTransform} */
|
|
13
|
+
translateY: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type TransformOptions = Partial<TransformProps>;
|
|
17
|
+
|
|
18
|
+
export const getDevicePixelRatio = () => window.devicePixelRatio ?? 1;
|
|
19
|
+
|
|
20
|
+
export const addTransform = (
|
|
21
|
+
ctx: CanvasRenderingContext2D,
|
|
22
|
+
t: TransformOptions,
|
|
23
|
+
) => {
|
|
24
|
+
const translateX = t.translateX ?? 0;
|
|
25
|
+
const translateY = t.translateY ?? 0;
|
|
26
|
+
if (translateX !== 0 || translateY !== 0) {
|
|
27
|
+
ctx.translate(translateX, translateY);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const scaleX = t.scaleX ?? 1;
|
|
31
|
+
const scaleY = t.scaleY ?? 1;
|
|
32
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
33
|
+
ctx.scale(scaleX, scaleY);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const skewX = t.skewX ?? 0;
|
|
37
|
+
const skewY = t.skewY ?? 0;
|
|
38
|
+
if (skewX !== 0 || skewY !== 0) {
|
|
39
|
+
ctx.transform(1, skewY, skewX, 1, 0, 0);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getDevicePixelRatio } from "../camera/utils";
|
|
2
|
+
|
|
3
|
+
import { type Ref, onMounted, ref } from "vue";
|
|
4
|
+
import { Coordinate } from "../types";
|
|
5
|
+
import { getCtx } from "magic-utils-yonava";
|
|
6
|
+
|
|
7
|
+
export const getCanvasTransform = (ctx: CanvasRenderingContext2D) => {
|
|
8
|
+
const { a, e, f } = ctx.getTransform();
|
|
9
|
+
// TODO investigate why dpr isn't already factored into ctx. Camera should add it with the PZ transform!
|
|
10
|
+
const dpr = getDevicePixelRatio();
|
|
11
|
+
const zoom = a / dpr;
|
|
12
|
+
const panX = e / dpr;
|
|
13
|
+
const panY = f / dpr;
|
|
14
|
+
return { panX, panY, zoom };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* the coordinates in the real world. aka the browser
|
|
19
|
+
*/
|
|
20
|
+
export type ClientCoords = Pick<MouseEvent, "clientX" | "clientY">;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* the coordinates in the magic canvas world
|
|
24
|
+
*/
|
|
25
|
+
export type MagicCoords = Coordinate;
|
|
26
|
+
|
|
27
|
+
export type WithZoom<T> = T & {
|
|
28
|
+
/**
|
|
29
|
+
* the scale factor of the canvas
|
|
30
|
+
*/
|
|
31
|
+
zoom: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* magic coordinates are coordinates transformed by the pan and zoom of the camera.
|
|
36
|
+
*
|
|
37
|
+
* if the user has panned their camera 10px to the left, running this function with
|
|
38
|
+
* `clientCoords` set to (0, 0) will return (-10, 0, 1)
|
|
39
|
+
*/
|
|
40
|
+
export const getMagicCoordinates = (
|
|
41
|
+
clientCoords: ClientCoords,
|
|
42
|
+
ctx: CanvasRenderingContext2D
|
|
43
|
+
): WithZoom<MagicCoords> => {
|
|
44
|
+
const rect = ctx.canvas.getBoundingClientRect();
|
|
45
|
+
const localX = clientCoords.clientX - rect.left;
|
|
46
|
+
const localY = clientCoords.clientY - rect.top;
|
|
47
|
+
|
|
48
|
+
const { panX, panY, zoom } = getCanvasTransform(ctx);
|
|
49
|
+
|
|
50
|
+
const x = (localX - panX) / zoom;
|
|
51
|
+
const y = (localY - panY) / zoom;
|
|
52
|
+
|
|
53
|
+
return { x, y, zoom };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* client coordinates are the raw coordinates corresponding to the clients physical screen.
|
|
58
|
+
*
|
|
59
|
+
* the top left corner is (0, 0) and bottom right corner is (window.innerWidth, window.innerHeight).
|
|
60
|
+
*/
|
|
61
|
+
export const getClientCoordinates = (
|
|
62
|
+
magicCoords: MagicCoords,
|
|
63
|
+
ctx: CanvasRenderingContext2D
|
|
64
|
+
): WithZoom<ClientCoords> => {
|
|
65
|
+
const { panX, panY, zoom } = getCanvasTransform(ctx);
|
|
66
|
+
const { x, y } = magicCoords;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
clientX: x * zoom + panX,
|
|
70
|
+
clientY: y * zoom + panY,
|
|
71
|
+
zoom,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const useMagicCoordinates = (
|
|
76
|
+
canvas: Ref<HTMLCanvasElement | undefined>
|
|
77
|
+
) => {
|
|
78
|
+
const coordinates = ref<MagicCoords>({ x: 0, y: 0 });
|
|
79
|
+
const captureCoords = (ev: MouseEvent) =>
|
|
80
|
+
(coordinates.value = getMagicCoordinates(ev, getCtx(canvas)));
|
|
81
|
+
|
|
82
|
+
onMounted(() => {
|
|
83
|
+
if (!canvas.value)
|
|
84
|
+
throw new Error("Canvas not found in DOM. Check ref link.");
|
|
85
|
+
canvas.value.addEventListener("mousemove", captureCoords);
|
|
86
|
+
canvas.value.addEventListener("wheel", captureCoords);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
coordinates,
|
|
91
|
+
cleanup: (ref: HTMLCanvasElement) => {
|
|
92
|
+
ref.removeEventListener("mousemove", captureCoords);
|
|
93
|
+
ref.removeEventListener("wheel", captureCoords);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MagicCanvasOptions } from './types';
|
|
2
|
+
|
|
3
|
+
/* eslint-enable */
|
|
4
|
+
|
|
5
|
+
type LocalStorageGetter = (...args: any[]) => string;
|
|
6
|
+
type LocalStorageRecord = Record<string, string | LocalStorageGetter>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* a registry for all localStorage keys this application uses
|
|
10
|
+
*/
|
|
11
|
+
export const localKeys = {
|
|
12
|
+
/** camera `panX` state in magic canvas - {@link Camera.state} */
|
|
13
|
+
cameraPanX: (key: string) =>
|
|
14
|
+
`camera-pan-x-${key}` as const,
|
|
15
|
+
/** camera `panY` state in magic canvas - {@link Camera.state} */
|
|
16
|
+
cameraPanY: (key: string) =>
|
|
17
|
+
`camera-pan-y-${key}` as const,
|
|
18
|
+
/** camera `zoom` state in magic canvas - {@link Camera.state} */
|
|
19
|
+
cameraZoom: (key: string) =>
|
|
20
|
+
`camera-zoom-${key}` as const,
|
|
21
|
+
} as const satisfies LocalStorageRecord;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* all return values of localStorage are, by default, string.
|
|
25
|
+
* this type allows string to be narrowed to types such as 'true' | 'false'
|
|
26
|
+
*/
|
|
27
|
+
type TypeOverride = {};
|
|
28
|
+
|
|
29
|
+
type LocalObj = typeof localKeys;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @example
|
|
33
|
+
* type T = TypeOrReturnType<number> // number
|
|
34
|
+
* type TFunc = TypeOrReturnType<() => number> // number
|
|
35
|
+
*/
|
|
36
|
+
type TypeOrReturnType<T> = T extends (...args: any[]) => infer U ? U : T;
|
|
37
|
+
|
|
38
|
+
type LocalKeys = TypeOrReturnType<LocalObj[keyof LocalObj]>;
|
|
39
|
+
type LocalType<T extends LocalKeys> = T extends keyof TypeOverride
|
|
40
|
+
? TypeOverride[T]
|
|
41
|
+
: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* perform **type safe** localStorage actions
|
|
45
|
+
*/
|
|
46
|
+
export const local = {
|
|
47
|
+
get: <T extends LocalKeys>(key: T) => localStorage.getItem(key),
|
|
48
|
+
set: <T extends LocalKeys, K extends LocalType<T>>(key: T, value: K) =>
|
|
49
|
+
localStorage.setItem(key, value),
|
|
50
|
+
remove: <T extends LocalKeys>(key: T) => localStorage.removeItem(key),
|
|
51
|
+
clear: localStorage.clear,
|
|
52
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
|
|
2
|
+
import type { Ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
import type { DrawPattern } from './backgroundPattern';
|
|
5
|
+
import type { Camera } from './camera';
|
|
6
|
+
|
|
7
|
+
export type Coordinate = {
|
|
8
|
+
x: number,
|
|
9
|
+
y: number,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DrawContent = (ctx: CanvasRenderingContext2D) => void;
|
|
13
|
+
|
|
14
|
+
export type DrawFns = {
|
|
15
|
+
content: Ref<DrawContent>;
|
|
16
|
+
backgroundPattern: Ref<DrawPattern>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type MagicCanvasProps = {
|
|
20
|
+
canvas: Ref<HTMLCanvasElement | undefined>;
|
|
21
|
+
camera: Omit<Camera, 'cleanup'>;
|
|
22
|
+
cursorCoordinates: Ref<Coordinate>;
|
|
23
|
+
ref: {
|
|
24
|
+
canvasRef: (canvas: HTMLCanvasElement) => void;
|
|
25
|
+
cleanup: (canvas: HTMLCanvasElement) => void;
|
|
26
|
+
};
|
|
27
|
+
draw: DrawFns;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type MagicCanvasOptions = {
|
|
31
|
+
/**
|
|
32
|
+
* a key that is used to track the camera state in localStorage
|
|
33
|
+
*/
|
|
34
|
+
storageKey?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type UseMagicCanvas = (options?: MagicCanvasOptions) => MagicCanvasProps;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useElementSize } from "@vueuse/core";
|
|
2
|
+
|
|
3
|
+
import { onMounted, ref, watch } from "vue";
|
|
4
|
+
|
|
5
|
+
import { type DrawPattern, useBackgroundPattern } from "./backgroundPattern";
|
|
6
|
+
import { useCamera } from "./camera";
|
|
7
|
+
import { getDevicePixelRatio } from "./camera/utils";
|
|
8
|
+
import { useMagicCoordinates } from "./coordinates";
|
|
9
|
+
import type { DrawContent, UseMagicCanvas } from "./types";
|
|
10
|
+
import { getCtx } from "magic-utils-yonava";
|
|
11
|
+
|
|
12
|
+
const REPAINT_FPS = 60;
|
|
13
|
+
|
|
14
|
+
const initCanvasWidthHeight = (canvas: HTMLCanvasElement | undefined) => {
|
|
15
|
+
if (!canvas) throw new Error("Canvas not found in DOM. Check ref link.");
|
|
16
|
+
|
|
17
|
+
const dpr = getDevicePixelRatio();
|
|
18
|
+
const rect = canvas.getBoundingClientRect();
|
|
19
|
+
canvas.width = rect.width * dpr;
|
|
20
|
+
canvas.height = rect.height * dpr;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const useMagicCanvas: UseMagicCanvas = (options = {}) => {
|
|
24
|
+
const canvas = ref<HTMLCanvasElement>();
|
|
25
|
+
const canvasBoxSize = useElementSize(canvas);
|
|
26
|
+
|
|
27
|
+
const drawContent = ref<DrawContent>(() => {});
|
|
28
|
+
const drawBackgroundPattern = ref<DrawPattern>(() => {});
|
|
29
|
+
|
|
30
|
+
let repaintInterval: NodeJS.Timeout;
|
|
31
|
+
|
|
32
|
+
onMounted(() => {
|
|
33
|
+
initCanvasWidthHeight(canvas.value);
|
|
34
|
+
repaintInterval = setInterval(repaintCanvas, 1000 / REPAINT_FPS);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
watch([canvasBoxSize.width, canvasBoxSize.height], () =>
|
|
38
|
+
initCanvasWidthHeight(canvas.value)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const { cleanup: cleanupCamera, ...camera } = useCamera(
|
|
42
|
+
canvas,
|
|
43
|
+
options?.storageKey ?? "[default-storage-key]"
|
|
44
|
+
);
|
|
45
|
+
const { coordinates: cursorCoordinates, cleanup: cleanupCoords } =
|
|
46
|
+
useMagicCoordinates(canvas);
|
|
47
|
+
|
|
48
|
+
const pattern = useBackgroundPattern(camera.state, drawBackgroundPattern);
|
|
49
|
+
|
|
50
|
+
const repaintCanvas = () => {
|
|
51
|
+
const ctx = getCtx(canvas);
|
|
52
|
+
camera.transformAndClear(ctx);
|
|
53
|
+
pattern.draw(ctx);
|
|
54
|
+
drawContent.value(ctx);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
canvas,
|
|
59
|
+
camera,
|
|
60
|
+
cursorCoordinates,
|
|
61
|
+
ref: {
|
|
62
|
+
canvasRef: (ref) => (canvas.value = ref),
|
|
63
|
+
cleanup: (ref) => {
|
|
64
|
+
cleanupCoords(ref);
|
|
65
|
+
cleanupCamera(ref);
|
|
66
|
+
clearInterval(repaintInterval);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
draw: {
|
|
70
|
+
content: drawContent,
|
|
71
|
+
backgroundPattern: drawBackgroundPattern,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"outDir": "./dist",
|
|
4
|
+
"rootDir": "./src",
|
|
5
|
+
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"lib": ["ES2023", "DOM"],
|
|
9
|
+
"types": ["node"],
|
|
10
|
+
"moduleResolution": "node",
|
|
11
|
+
"allowJs": false,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
}
|