Base-Layout and Canvas controlling

This commit is contained in:
Julian Krauser 2024-08-15 11:55:14 +02:00
parent f7f15815f8
commit b7b8eccef5
24 changed files with 10960 additions and 0 deletions

15
.eslintrc.cjs Normal file
View file

@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

30
.gitignore vendored
View file

@ -9,3 +9,33 @@ docs/_book
# TODO: where does this rule come from?
test/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

5
.prettierrc.json Normal file
View file

@ -0,0 +1,5 @@
{
"tabWidth": 2,
"printWidth": 120,
"trailingComma": "es5"
}

1
env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnPanic</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10287
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

59
package.json Normal file
View file

@ -0,0 +1,59 @@
{
"name": "unpanic",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"bp": "npm run build-only && npm run preview",
"generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/icon.svg"
},
"dependencies": {
"@headlessui/vue": "^1.7.22",
"@heroicons/vue": "^2.1.5",
"axios": "^1.7.2",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"qrcode": "^1.5.3",
"qs": "^6.12.3",
"socket.io-client": "^4.7.5",
"uuid": "^10.0.0",
"vue": "^3.4.29",
"vue-cryptojs": "^2.4.7",
"vue-router": "^4.3.3",
"vuedraggable": "^2.24.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/node": "^20.14.5",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.5",
"@types/qs": "^6.9.15",
"@types/uuid": "^10.0.0",
"@vite-pwa/assets-generator": "^0.2.4",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"npm-run-all2": "^6.2.0",
"postcss": "^8.4.40",
"prettier": "^3.2.5",
"sass": "^1.77.8",
"tailwindcss": "^3.4.7",
"typescript": "~5.4.0",
"vite": "^5.3.1",
"vite-plugin-pwa": "^0.20.1",
"vite-plugin-vue-devtools": "^7.3.1",
"vue-tsc": "^2.0.21"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

7
src/App.vue Normal file
View file

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

74
src/components/canvas.vue Normal file
View file

@ -0,0 +1,74 @@
<template>
<div ref="bounding" class="h-full w-full overflow-hidden">
<div class="absolute w-fit rounded-md bottom-5 left-5 flex flex-row select-none cursor-pointer">
<div
class="w-5 p-2 box-content text-center bg-gray-200 hover:bg-gray-300 rounded-l-md"
@click="scale(canvasScale + 0.2)"
>
+
</div>
<div class="w-fit min-w-12 p-2 box-content bg-gray-200 text-center" @click="centerCanvas">
{{ Math.round(canvasScale * 100) }}%
</div>
<div
class="w-5 p-2 box-content text-center bg-gray-200 hover:bg-gray-300 rounded-r-md"
@click="scale(canvasScale - 0.2)"
>
-
</div>
</div>
<svg ref="diagram" tabindex="0" class="h-full w-full outline-none">
<g ref="canvas" id="canvas">
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { CanvasElement, CanvasSmbl } from "../helpers/symbols";
import { moveScaleApplay } from "../helpers/canvas";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
canvas: null as null | CanvasElement,
canvasScale: 1 as number,
};
},
mounted() {
this.canvas = this.$refs.canvas;
this.canvas[CanvasSmbl] = {
data: {
position: { x: 0, y: 0 },
scale: 1,
cell: 24,
},
};
if (this.canvas != null) {
moveScaleApplay(this.canvas, this.updateScale);
this.centerCanvas();
}
},
methods: {
centerCanvas() {
this.canvas[CanvasSmbl].scale(1);
const size = this.canvas.getBoundingClientRect();
const offsetX = (this.$refs.bounding.clientWidth - size.width) / 2;
const offsetY = (this.$refs.bounding.clientHeight - size.height) / 2;
this.canvas[CanvasSmbl].move(offsetX, offsetY);
},
scale(scale: number) {
this.canvas[CanvasSmbl].scale(scale);
},
updateScale(newScale: number) {
this.canvasScale = newScale;
this.$forceUpdate();
},
},
});
</script>

43
src/components/card.vue Normal file
View file

@ -0,0 +1,43 @@
<template>
<div
class="absolute top-1/2 -translate-y-1/2 h-2/3 w-1/4 max-w-64 p-2 border-2 border-gray-200 bg-white drop-shadow-md transition-all duration-200"
:class="[
type == 'left'
? 'rounded-r-md left-0 pr-[2rem] border-l-0'
: 'rounded-l-md right-0 pl-[2rem] border-r-0',
visible
? type == 'left'
? '-translate-x-[calc(100%-3rem)]'
: 'translate-x-[calc(100%-3rem)]'
: 'translate-x-0'
]"
>
Hallo
<div
class="absolute top-1/2 -translate-y-1/2 h-32 w-16 flex justify-center items-center bg-gray-300 rounded-md drop-shadow-md"
:class="type == 'right' ? 'left-0 -translate-x-1/2' : 'right-0 translate-x-1/2'"
@click="visible = !visible"
>
x
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from 'vue'
import { mapState, mapActions } from 'pinia'
</script>
<script lang="ts">
export default defineComponent({
props: {
type: String as PropType<'left' | 'right'>
},
data() {
return {
visible: true as boolean
}
},
methods: {}
})
</script>

234
src/helpers/canvas.ts Normal file
View file

@ -0,0 +1,234 @@
import { CanvasSmbl, ProcessedSmbl, type CanvasElement, type Point, type Pointer, type CanvasData } from "./symbols";
export const listen = (
el: Element | GlobalEventHandlers,
type: string,
listener: EventListenerOrEventListenerObject,
once?: boolean
) => el.addEventListener(type, listener, { passive: true, once });
export const listenDel = (
el: Element | GlobalEventHandlers,
type: string,
listener: EventListenerOrEventListenerObject,
capture?: boolean
) => el?.removeEventListener(type, listener, { capture });
export const pointInCanvas = (
canvasData: { position: { x: number; y: number }; scale: number },
x: number,
y: number
) => ({
x: (x - canvasData.position.x) / canvasData.scale,
y: (y - canvasData.position.y) / canvasData.scale,
});
export function moveScaleApplay(canvas: CanvasElement, updateScale?: { (newScale: number): void }) {
const canvasData = canvas[CanvasSmbl]!.data;
const gripUpdate = canvas.ownerSVGElement ? applayGrid(canvas.ownerSVGElement, canvasData) : () => {};
function transform() {
canvas.style.transform = `matrix(${canvasData.scale}, 0, 0, ${canvasData.scale}, ${canvasData.position.x}, ${canvasData.position.y})`;
gripUpdate();
}
function scale(nextScale: number, originPoint: Point) {
if (nextScale < 0.5 || nextScale > 4) {
if (nextScale < 0.5) canvasData.scale = 0.5;
if (nextScale > 4) canvasData.scale = 4;
return;
}
const divis = nextScale / canvasData.scale;
canvasData.scale = nextScale;
canvasData.position.x = divis * (canvasData.position.x - originPoint.x) + originPoint.x;
canvasData.position.y = divis * (canvasData.position.y - originPoint.y) + originPoint.y;
if (updateScale) {
updateScale(canvasData.scale);
}
transform();
}
// move, scale with fingers
if (canvas.ownerSVGElement) {
applayFingers(canvas.ownerSVGElement, canvasData, scale, transform);
}
// scale with mouse wheel
canvas.ownerSVGElement?.addEventListener(
"wheel",
/** @param {WheelEvent} evt */ (evt) => {
evt.preventDefault();
const delta = evt.deltaY || evt.deltaX;
const scaleStep =
Math.abs(delta) < 50
? 0.05 // trackpad pitch
: 0.25; // mouse wheel
scale(canvasData.scale + (delta < 0 ? scaleStep : -scaleStep), evtPoint(evt));
}
);
if (canvas[CanvasSmbl]) {
canvas[CanvasSmbl].move = function (x, y) {
canvasData.position.x = x;
canvasData.position.y = y;
transform();
};
canvas[CanvasSmbl].scale = function (newScale) {
let bounding = canvas.ownerSVGElement;
let width = bounding?.clientWidth ?? 0;
let height = bounding?.clientHeight ?? 0;
scale(newScale, { x: width / 2, y: height / 2 });
transform();
};
}
}
function applayFingers(
svg: SVGSVGElement,
canvasData: { position: Point; scale: number },
scaleFn: { (nextScale: number, originPoint: Point): void },
transformFn: { (): void }
) {
let firstPointer: Pointer | null;
let secondPointer: Pointer | null;
let distance: number | null;
let center: Point | null;
/** @param {PointerEvent} evt */
function cancel(evt: any) {
distance = null;
center = null;
if (firstPointer?.id === evt.pointerId) {
firstPointer = null;
}
if (secondPointer?.id === evt.pointerId) {
secondPointer = null;
}
if (!firstPointer && !secondPointer) {
listenDel(svg, "pointermove", move);
listenDel(svg, "pointercancel", cancel);
listenDel(svg, "pointerup", cancel);
}
}
/** @param {PointerEvent} evt */
function move(evt: any) {
if (evt[ProcessedSmbl]) {
return;
}
if ((firstPointer && !secondPointer) || (!firstPointer && secondPointer)) {
// move with one pointer
canvasData.position.x = evt.clientX + ((firstPointer || secondPointer)?.shift?.x || 0);
canvasData.position.y = evt.clientY + ((firstPointer || secondPointer)?.shift?.y || 0);
transformFn();
return;
}
if (
!secondPointer ||
!firstPointer ||
(secondPointer?.id !== evt.pointerId && firstPointer?.id !== evt.pointerId)
) {
return;
}
const distanceNew = Math.hypot(firstPointer.pos.x - secondPointer.pos.x, firstPointer.pos.y - secondPointer.pos.y);
const centerNew = {
x: (firstPointer.pos.x + secondPointer.pos.x) / 2,
y: (firstPointer.pos.y + secondPointer.pos.y) / 2,
};
// not first move
if (distance && center) {
canvasData.position.x = canvasData.position.x + centerNew.x - center.x;
canvasData.position.y = canvasData.position.y + centerNew.y - center.y;
scaleFn((canvasData.scale / distance) * distanceNew, centerNew);
}
distance = distanceNew;
center = centerNew;
if (firstPointer.id === evt.pointerId) {
firstPointer = evtPointer(evt, canvasData);
}
if (secondPointer.id === evt.pointerId) {
secondPointer = evtPointer(evt, canvasData);
}
}
listen(
svg,
"pointerdown",
/** @param {PointerEvent} evt */ (evt: any) => {
if (evt[ProcessedSmbl] || (!firstPointer && !evt.isPrimary) || (firstPointer && secondPointer)) {
return;
}
svg.setPointerCapture(evt.pointerId);
if (!firstPointer) {
listen(svg, "pointermove", move);
listen(svg, "pointercancel", cancel);
listen(svg, "pointerup", cancel);
}
if (!firstPointer) {
firstPointer = evtPointer(evt, canvasData);
return;
}
if (!secondPointer) {
secondPointer = evtPointer(evt, canvasData);
}
}
);
}
function applayGrid(svg: SVGSVGElement, canvasData: CanvasData) {
let curOpacity: number;
function backImg(opacity: number) {
if (curOpacity !== opacity) {
curOpacity = opacity;
svg.style.backgroundImage = `radial-gradient(rgb(73 80 87 / ${opacity}) 1px, transparent 0)`;
}
}
backImg(0.7);
svg.style.backgroundSize = `${canvasData.cell}px ${canvasData.cell}px`;
return function () {
const size = canvasData.cell * canvasData.scale;
if (canvasData.scale < 0.5) {
backImg(0);
} else if (canvasData.scale <= 0.9) {
backImg(0.3);
} else {
backImg(0.7);
}
svg.style.backgroundSize = `${size}px ${size}px`;
svg.style.backgroundPosition = `${canvasData.position.x}px ${canvasData.position.y}px`;
};
}
function evtPoint(evt: any): Point {
return { x: evt.clientX, y: evt.clientY };
}
function evtPointer(evt: any, canvasData: { position: Point; scale: number }): Pointer {
return {
id: evt.pointerId,
pos: evtPoint(evt),
shift: {
x: canvasData.position.x - evt.clientX,
y: canvasData.position.y - evt.clientY,
},
};
}

27
src/helpers/symbols.ts Normal file
View file

@ -0,0 +1,27 @@
export const CanvasSmbl = Symbol("Canvas");
export const ProcessedSmbl = Symbol("processed");
export const MovementXSmbl = Symbol("movementX");
export const MovementYSmbl = Symbol("movementY");
export interface Point {
x: number;
y: number;
}
export interface Pointer {
id: number;
pos: Point;
shift: Point;
}
export interface CanvasData {
position: Point;
scale: number;
cell: number;
}
export interface CanvasElement extends SVGGElement {
[CanvasSmbl]?: Canvas;
}
export interface Canvas {
move?(x: number, y: number): void;
scale?(scale: number): void;
data: CanvasData;
}

36
src/main.css Normal file
View file

@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #c9c9c9 transparent;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 5px;
height: 5px;
}
*::-webkit-scrollbar-track {
background: transparent; /*f1f1f1;*/
}
*::-webkit-scrollbar-thumb {
background-color: #c9c9c9;
border-radius: 12px;
border: 0px solid #ffffff;
}
html,
body {
@apply h-full w-screen m-0 p-0 overflow-hidden bg-white;
height: 100svh;
}
#app {
@apply w-full h-full overflow-hidden flex flex-col;
}

14
src/main.ts Normal file
View file

@ -0,0 +1,14 @@
import './main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

15
src/router/index.ts Normal file
View file

@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'dashboard',
component: Dashboard
}
]
})
export default router

12
src/stores/counter.ts Normal file
View file

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

10
src/views/Dashboard.vue Normal file
View file

@ -0,0 +1,10 @@
<template>
<Card type="left" />
<Card type="right" />
<Canvas />
</template>
<script lang="ts" setup>
import Card from '../components/card.vue'
import Canvas from '../components/canvas.vue'
</script>

8
tailwind.config.js Normal file
View file

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {}
},
plugins: []
}

14
tsconfig.app.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.ts", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

21
tsconfig.node.json Normal file
View file

@ -0,0 +1,21 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"src/**/*.ts",
"src/**/*.vue"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

18
vite.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})