mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
faz 1 & 2
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
219
frontend/bun.lock
Normal file
219
frontend/bun.lock
Normal file
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst.ts": "^0.7.0-rc2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.1",
|
||||
"vue-tsc": "^3.2.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@myriaddreamin/typst-ts-renderer": ["@myriaddreamin/typst-ts-renderer@0.7.0-rc2", "", {}, "sha512-god1tcb2YJDkQfA8gLGcAmykVGBpNKorqqDkXVy3InC18KRbsverJhlrHoONurNIU9JuIHoWjJ2D1ntpjPgzbA=="],
|
||||
|
||||
"@myriaddreamin/typst-ts-web-compiler": ["@myriaddreamin/typst-ts-web-compiler@0.7.0-rc2", "", {}, "sha512-WFO/ecKUfeclld5uDxyjgpnIafKpp2LrS6T1vY+CHaSxCm099AneAQIYFg+OtX+NbFpJsLGCBFSw/qppJJmBAw=="],
|
||||
|
||||
"@myriaddreamin/typst.ts": ["@myriaddreamin/typst.ts@0.7.0-rc2", "", { "dependencies": { "idb": "^7.1.1" }, "peerDependencies": { "@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2" }, "optionalPeers": ["@myriaddreamin/typst-ts-renderer", "@myriaddreamin/typst-ts-web-compiler"] }, "sha512-VM8JqsRcL3AEJ5cuPBn/YvnGTXK/BRPlxdGB2bR48Of/8OIGaPiunv2QfZBIMBBrtbTygUOtAY9BZvkS1AFqgA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.31", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.31", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.31", "", { "dependencies": { "@vue/compiler-core": "3.5.31", "@vue/shared": "3.5.31" } }, "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.31", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.31", "@vue/compiler-dom": "3.5.31", "@vue/compiler-ssr": "3.5.31", "@vue/shared": "3.5.31", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.31", "", { "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/shared": "3.5.31" } }, "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@3.2.6", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.31", "", { "dependencies": { "@vue/shared": "3.5.31" } }, "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.31", "", { "dependencies": { "@vue/reactivity": "3.5.31", "@vue/shared": "3.5.31" } }, "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.31", "", { "dependencies": { "@vue/reactivity": "3.5.31", "@vue/runtime-core": "3.5.31", "@vue/shared": "3.5.31", "csstype": "^3.2.3" } }, "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.31", "", { "dependencies": { "@vue/compiler-ssr": "3.5.31", "@vue/shared": "3.5.31" }, "peerDependencies": { "vue": "3.5.31" } }, "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.31", "", {}, "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw=="],
|
||||
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.31", "", { "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", "@vue/runtime-dom": "3.5.31", "@vue/server-renderer": "3.5.31", "@vue/shared": "3.5.31" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.2.6", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.6" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2",
|
||||
"@myriaddreamin/typst.ts": "^0.7.0-rc2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.1",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
BIN
frontend/public/fonts/NotoSans-Bold.ttf
Normal file
BIN
frontend/public/fonts/NotoSans-Bold.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/NotoSans-BoldItalic.ttf
Normal file
BIN
frontend/public/fonts/NotoSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/NotoSans-Italic.ttf
Normal file
BIN
frontend/public/fonts/NotoSans-Italic.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/NotoSans-Regular.ttf
Normal file
BIN
frontend/public/fonts/NotoSans-Regular.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/NotoSansMono-Regular.ttf
Normal file
BIN
frontend/public/fonts/NotoSansMono-Regular.ttf
Normal file
Binary file not shown.
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
115
frontend/src/App.vue
Normal file
115
frontend/src/App.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import EditorCanvas from './components/editor/EditorCanvas.vue'
|
||||
import ToolboxPanel from './components/panels/ToolboxPanel.vue'
|
||||
import PropertiesPanel from './components/panels/PropertiesPanel.vue'
|
||||
import { useTemplateStore } from './stores/template'
|
||||
import { useEditorStore } from './stores/editor'
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// Delete / Backspace — seçili elemanı sil
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementId) {
|
||||
// Input/textarea içindeyse yoksay
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
|
||||
e.preventDefault()
|
||||
const id = editorStore.selectedElementId
|
||||
if (id && id !== 'root') {
|
||||
editorStore.clearSelection()
|
||||
templateStore.removeElement(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Escape — seçimi temizle
|
||||
if (e.key === 'Escape') {
|
||||
editorStore.clearSelection()
|
||||
}
|
||||
|
||||
// Ctrl+Z — undo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
templateStore.undo()
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Z — redo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
templateStore.redo()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<header class="app-header">
|
||||
<h1>dreport</h1>
|
||||
<span class="app-header__subtitle">Belge Tasarim Araci</span>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<aside class="app-sidebar app-sidebar--left">
|
||||
<ToolboxPanel />
|
||||
</aside>
|
||||
<EditorCanvas />
|
||||
<aside class="app-sidebar app-sidebar--right">
|
||||
<PropertiesPanel />
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.app-header__subtitle {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 260px;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-sidebar--right {
|
||||
border-right: none;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
150
frontend/src/components/editor/EditorCanvas.vue
Normal file
150
frontend/src/components/editor/EditorCanvas.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useTypstCompiler } from '../../composables/useTypstCompiler'
|
||||
import TypstSvgLayer from './TypstSvgLayer.vue'
|
||||
import InteractionOverlay from './InteractionOverlay.vue'
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const { typstMarkup } = storeToRefs(templateStore)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(800)
|
||||
|
||||
// Typst compiler
|
||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(typstMarkup)
|
||||
|
||||
// mm → px dönüşüm katsayısı
|
||||
const scale = computed(() => {
|
||||
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
|
||||
})
|
||||
|
||||
// Sayfa boyutu px cinsinden + margin CSS variables
|
||||
const pageStyle = computed(() => {
|
||||
const w = templateStore.template.page.width * scale.value
|
||||
const h = templateStore.template.page.height * scale.value
|
||||
const m = templateStore.template.root.padding
|
||||
return {
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
'--page-margin-top': `${m.top * scale.value}px`,
|
||||
'--page-margin-right': `${m.right * scale.value}px`,
|
||||
'--page-margin-bottom': `${m.bottom * scale.value}px`,
|
||||
'--page-margin-left': `${m.left * scale.value}px`,
|
||||
}
|
||||
})
|
||||
|
||||
// Container boyutunu izle
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (entry) containerWidth.value = entry.contentRect.width
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
dispose()
|
||||
})
|
||||
|
||||
// Zoom
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
editorStore.setZoom(editorStore.zoom + delta)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-canvas" ref="containerRef" @wheel="onWheel">
|
||||
<!-- Hata banner -->
|
||||
<div v-if="error" class="editor-canvas__error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Derleme göstergesi -->
|
||||
<div v-if="compiling" class="editor-canvas__compiling">
|
||||
Derleniyor...
|
||||
</div>
|
||||
|
||||
<!-- Sayfa -->
|
||||
<div class="editor-canvas__page" :style="pageStyle">
|
||||
<TypstSvgLayer :svg="svg" />
|
||||
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
||||
</div>
|
||||
|
||||
<!-- Zoom göstergesi -->
|
||||
<div class="editor-canvas__zoom">
|
||||
%{{ editorStore.zoomPercent }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-canvas {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-canvas__page {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-canvas__error {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.editor-canvas__compiling {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 16px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.editor-canvas__zoom {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
211
frontend/src/components/editor/ElementHandle.vue
Normal file
211
frontend/src/components/editor/ElementHandle.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TemplateElement, ContainerElement } from '../../core/types'
|
||||
import { isContainer } from '../../core/types'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
|
||||
const props = defineProps<{
|
||||
element: TemplateElement
|
||||
scale: number
|
||||
}>()
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const templateStore = useTemplateStore()
|
||||
|
||||
const isSelected = computed(() => editorStore.selectedElementId === props.element.id)
|
||||
const isContainerEl = computed(() => isContainer(props.element))
|
||||
const isAbsolute = computed(() => props.element.position.type === 'absolute')
|
||||
|
||||
// --- CSS style: layout'u Typst ile eşleştir ---
|
||||
const layoutStyle = computed(() => {
|
||||
const el = props.element
|
||||
const s = props.scale
|
||||
const style: Record<string, string> = {}
|
||||
|
||||
// Absolute positioning
|
||||
if (el.position.type === 'absolute') {
|
||||
style.position = 'absolute'
|
||||
style.left = `${el.position.x * s}px`
|
||||
style.top = `${el.position.y * s}px`
|
||||
}
|
||||
|
||||
// Boyut
|
||||
const w = el.size.width
|
||||
const h = el.size.height
|
||||
if (w.type === 'fixed') style.width = `${w.value * s}px`
|
||||
else if (w.type === 'fr') style.flex = `${w.value} 1 0%`
|
||||
// auto → doğal boyut, CSS default
|
||||
|
||||
if (h.type === 'fixed') style.height = `${h.value * s}px`
|
||||
// auto/fr height → CSS default
|
||||
|
||||
// Container ise flexbox
|
||||
if (isContainer(el)) {
|
||||
const c = el as ContainerElement
|
||||
style.display = 'flex'
|
||||
style.flexDirection = c.direction === 'row' ? 'row' : 'column'
|
||||
if (c.gap > 0) style.gap = `${c.gap * s}px`
|
||||
if (c.padding.top || c.padding.right || c.padding.bottom || c.padding.left) {
|
||||
style.padding = `${c.padding.top * s}px ${c.padding.right * s}px ${c.padding.bottom * s}px ${c.padding.left * s}px`
|
||||
}
|
||||
|
||||
// align (cross-axis)
|
||||
const alignMap = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' }
|
||||
if (c.direction === 'column') {
|
||||
style.alignItems = alignMap[c.align] || 'stretch'
|
||||
} else {
|
||||
style.alignItems = alignMap[c.align] || 'flex-start'
|
||||
}
|
||||
|
||||
// justify (main-axis)
|
||||
const justifyMap = { start: 'flex-start', center: 'center', end: 'flex-end', 'space-between': 'space-between' }
|
||||
style.justifyContent = justifyMap[c.justify] || 'flex-start'
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
// --- Drag state (sadece absolute elemanlar) ---
|
||||
const pointerStart = ref({ x: 0, y: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragTransform = ref({ x: 0, y: 0 })
|
||||
|
||||
const isInteracting = computed(() => isDragging.value)
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
e.stopPropagation()
|
||||
editorStore.selectElement(props.element.id)
|
||||
|
||||
if (!isAbsolute.value) return
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
|
||||
pointerStart.value = { x: e.clientX, y: e.clientY }
|
||||
dragTransform.value = { x: 0, y: 0 }
|
||||
isDragging.value = true
|
||||
editorStore.setDragging(true)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
dragTransform.value = {
|
||||
x: e.clientX - pointerStart.value.x,
|
||||
y: e.clientY - pointerStart.value.y,
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
editorStore.setDragging(false)
|
||||
|
||||
if (props.element.position.type !== 'absolute') return
|
||||
|
||||
const dxMm = dragTransform.value.x / props.scale
|
||||
const dyMm = dragTransform.value.y / props.scale
|
||||
|
||||
if (Math.abs(dxMm) > 0.1 || Math.abs(dyMm) > 0.1) {
|
||||
templateStore.updateElementPosition(props.element.id, {
|
||||
type: 'absolute',
|
||||
x: Math.round((props.element.position.x + dxMm) * 10) / 10,
|
||||
y: Math.round((props.element.position.y + dyMm) * 10) / 10,
|
||||
})
|
||||
}
|
||||
dragTransform.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const dragStyle = computed(() => {
|
||||
if (!isDragging.value) return {}
|
||||
return { transform: `translate(${dragTransform.value.x}px, ${dragTransform.value.y}px)` }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="element-handle"
|
||||
:class="{
|
||||
'element-handle--selected': isSelected,
|
||||
'element-handle--interacting': isInteracting,
|
||||
'element-handle--container': isContainerEl,
|
||||
'element-handle--absolute': isAbsolute,
|
||||
'element-handle--leaf': !isContainerEl,
|
||||
}"
|
||||
:style="{ ...layoutStyle, ...dragStyle }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<!-- Container çocuklarını recursive render et -->
|
||||
<template v-if="isContainerEl && 'children' in element">
|
||||
<ElementHandle
|
||||
v-for="child in (element as ContainerElement).children"
|
||||
:key="child.id"
|
||||
:element="child"
|
||||
:scale="scale"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Seçim göstergesi -->
|
||||
<div v-if="isSelected" class="selection-border" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.element-handle {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 2px;
|
||||
}
|
||||
|
||||
.element-handle--absolute {
|
||||
position: absolute;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.element-handle--leaf {
|
||||
/* Leaf elemanlar tıklanabilir alan */
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
/* Hover efekti */
|
||||
.element-handle:hover > .selection-border,
|
||||
.element-handle--selected > .selection-border {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selection-border {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.element-handle--container > .selection-border {
|
||||
border-color: rgb(139, 92, 246);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.element-handle:hover > .selection-border {
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.element-handle--container:hover > .selection-border {
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.element-handle--selected > .selection-border {
|
||||
border-color: rgb(59, 130, 246);
|
||||
}
|
||||
|
||||
.element-handle--selected.element-handle--container > .selection-border {
|
||||
border-color: rgb(139, 92, 246);
|
||||
}
|
||||
|
||||
.element-handle--interacting {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
670
frontend/src/components/editor/InteractionOverlay.vue
Normal file
670
frontend/src/components/editor/InteractionOverlay.vue
Normal file
@@ -0,0 +1,670 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { ElementLayout } from '../../core/template-to-typst'
|
||||
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
|
||||
import { isContainer, sz } from '../../core/types'
|
||||
|
||||
const props = defineProps<{
|
||||
scale: number
|
||||
layout: Record<string, ElementLayout>
|
||||
pageWidthPt: number
|
||||
}>()
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
// pt→px dönüşüm katsayısı
|
||||
const ptToPx = computed(() => {
|
||||
const pageWidthPx = templateStore.template.page.width * props.scale
|
||||
return props.pageWidthPt > 0 ? pageWidthPx / props.pageWidthPt : 1
|
||||
})
|
||||
|
||||
// Tüm elemanları flat olarak topla (root hariç)
|
||||
const flatElements = computed(() => {
|
||||
const result: TemplateElement[] = []
|
||||
function walk(el: TemplateElement) {
|
||||
if (isContainer(el)) {
|
||||
for (const child of el.children) {
|
||||
result.push(child)
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(templateStore.template.root)
|
||||
return result
|
||||
})
|
||||
|
||||
// Tüm container'lar (root dahil) — drop target tespiti için
|
||||
const allContainers = computed(() => {
|
||||
const result: ContainerElement[] = [templateStore.template.root]
|
||||
function walk(el: TemplateElement) {
|
||||
if (isContainer(el)) {
|
||||
result.push(el)
|
||||
for (const child of el.children) walk(child)
|
||||
}
|
||||
}
|
||||
for (const child of templateStore.template.root.children) walk(child)
|
||||
return result
|
||||
})
|
||||
|
||||
function getElementStyle(el: TemplateElement) {
|
||||
const l = props.layout[el.id]
|
||||
if (!l) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const h = l.height * s
|
||||
const minH = 8
|
||||
const actualH = Math.max(h, minH)
|
||||
const yOffset = h < minH ? (minH - h) / 2 : 0
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${l.x * s}px`,
|
||||
top: `${l.y * s - yOffset}px`,
|
||||
width: `${l.width * s}px`,
|
||||
height: `${actualH}px`,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Seçim ---
|
||||
|
||||
function onElementClick(e: PointerEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
if (didDrag.value) return
|
||||
editorStore.selectElement(id)
|
||||
}
|
||||
|
||||
function onCanvasClick() {
|
||||
editorStore.selectElement('root')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Ortak drop target sistemi
|
||||
// ============================================================
|
||||
|
||||
const dropTargetContainerId = ref<string | null>(null)
|
||||
const dropVisualIndex = ref<number | null>(null)
|
||||
const dropLogicalIndex = ref<number | null>(null)
|
||||
|
||||
/** Mouse pozisyonuna göre en derin container'ı bul */
|
||||
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
|
||||
const s = ptToPx.value
|
||||
let best: ContainerElement = templateStore.template.root
|
||||
|
||||
for (const c of allContainers.value) {
|
||||
if (c.id === excludeId) continue
|
||||
const l = props.layout[c.id]
|
||||
if (!l) continue
|
||||
|
||||
const cx = l.x * s
|
||||
const cy = l.y * s
|
||||
const cw = l.width * s
|
||||
const ch = l.height * s
|
||||
|
||||
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
|
||||
// Daha küçük (daha derin) container'ı tercih et
|
||||
const bestL = props.layout[best.id]
|
||||
if (!bestL || (cw * ch < bestL.width * s * bestL.height * s)) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Container içinde drop index hesapla */
|
||||
function computeDropIndex(container: ContainerElement, mouseY: number, excludeId?: string) {
|
||||
const s = ptToPx.value
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
|
||||
|
||||
let visualIdx = flowChildren.length
|
||||
|
||||
for (let i = 0; i < flowChildren.length; i++) {
|
||||
const l = props.layout[flowChildren[i].id]
|
||||
if (!l) continue
|
||||
const centerY = l.y * s + (l.height * s) / 2
|
||||
if (mouseY < centerY) {
|
||||
visualIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Mantıksal index: excludeId aynı container'daysa offset hesapla
|
||||
let logicalIdx = visualIdx
|
||||
if (excludeId) {
|
||||
const allFlow = container.children.filter(c => c.position.type !== 'absolute')
|
||||
const currentIdx = allFlow.findIndex(c => c.id === excludeId)
|
||||
if (currentIdx >= 0) {
|
||||
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
|
||||
// flowChildren zaten excludeId hariç, dolayısıyla visualIdx doğrudan gerçek insert indexi
|
||||
// Ama reorderChild fromIndex/toIndex aynı liste üzerinde çalışır
|
||||
// Gerçek listedeki index'e çevir
|
||||
let realIdx = 0
|
||||
let count = 0
|
||||
for (let i = 0; i < allFlow.length; i++) {
|
||||
if (allFlow[i].id === excludeId) continue
|
||||
if (count === visualIdx) { realIdx = i; break }
|
||||
count++
|
||||
realIdx = i + 1
|
||||
}
|
||||
logicalIdx = realIdx
|
||||
if (realIdx > currentIdx) logicalIdx--
|
||||
}
|
||||
}
|
||||
|
||||
return { visualIdx, logicalIdx }
|
||||
}
|
||||
|
||||
function updateDropFromMouse(mouseX: number, mouseY: number, excludeId?: string) {
|
||||
const container = findDeepestContainer(mouseX, mouseY, excludeId)
|
||||
dropTargetContainerId.value = container.id
|
||||
|
||||
const { visualIdx, logicalIdx } = computeDropIndex(container, mouseY, excludeId)
|
||||
dropVisualIndex.value = visualIdx
|
||||
dropLogicalIndex.value = logicalIdx
|
||||
}
|
||||
|
||||
function clearDropTarget() {
|
||||
dropTargetContainerId.value = null
|
||||
dropVisualIndex.value = null
|
||||
dropLogicalIndex.value = null
|
||||
}
|
||||
|
||||
// Drop indicator pozisyonu (ortak)
|
||||
const dropIndicatorStyle = computed(() => {
|
||||
if (dropTargetContainerId.value === null || dropVisualIndex.value === null) {
|
||||
return { display: 'none' }
|
||||
}
|
||||
|
||||
const container = templateStore.getElementById(dropTargetContainerId.value)
|
||||
if (!container || !isContainer(container)) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const idx = dropVisualIndex.value
|
||||
|
||||
// Sürüklenen elemanı çıkar
|
||||
const dragId = dragElementId.value
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
||||
|
||||
// Gap'in ortasına yerleştir: üstteki elemanın alt kenarı ile alttaki elemanın üst kenarı arası
|
||||
let y = 0
|
||||
if (idx === 0 && flowChildren.length > 0) {
|
||||
// İlk pozisyon: ilk elemanın üst kenarı ile container üst kenarı arası
|
||||
const l = props.layout[flowChildren[0].id]
|
||||
const cl = props.layout[container.id]
|
||||
if (l && cl) {
|
||||
y = (cl.y * s + l.y * s) / 2 // container top ile eleman top arası
|
||||
} else if (l) {
|
||||
y = l.y * s - 4
|
||||
}
|
||||
} else if (idx < flowChildren.length && idx > 0) {
|
||||
// Ortada: üstteki elemanın altı ile alttaki elemanın üstü arası
|
||||
const above = props.layout[flowChildren[idx - 1].id]
|
||||
const below = props.layout[flowChildren[idx].id]
|
||||
if (above && below) {
|
||||
const aboveBottom = (above.y + above.height) * s
|
||||
const belowTop = below.y * s
|
||||
y = (aboveBottom + belowTop) / 2
|
||||
}
|
||||
} else if (idx === 0 && flowChildren.length === 0) {
|
||||
// Boş container
|
||||
const cl = props.layout[container.id]
|
||||
if (cl) y = cl.y * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
// Son pozisyon: son elemanın altından gap kadar aşağıda
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
if (l) {
|
||||
const gapPx = container.gap * props.scale
|
||||
y = (l.y + l.height) * s + gapPx / 2
|
||||
}
|
||||
}
|
||||
|
||||
const cl = props.layout[container.id]
|
||||
const x = cl ? cl.x * s : 0
|
||||
const width = cl ? cl.width * s : 100
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${width}px`,
|
||||
height: '2px',
|
||||
background: 'rgb(59, 130, 246)',
|
||||
borderRadius: '1px',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none' as const,
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Mevcut eleman sürükleme (reorder + cross-container move)
|
||||
// ============================================================
|
||||
|
||||
const isDragging = ref(false)
|
||||
const didDrag = ref(false)
|
||||
const dragElementId = ref<string | null>(null)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const dragGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||
|
||||
function onDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
if (el.position.type === 'absolute') {
|
||||
onAbsoluteDragStart(e, el)
|
||||
return
|
||||
}
|
||||
|
||||
const l = props.layout[el.id]
|
||||
if (!l) return
|
||||
|
||||
const s = ptToPx.value
|
||||
dragElementId.value = el.id
|
||||
didDrag.value = false
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
dragGhost.value = {
|
||||
x: l.x * s,
|
||||
y: l.y * s,
|
||||
width: l.width * s,
|
||||
height: l.height * s,
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onDragMove)
|
||||
window.addEventListener('pointerup', onDragEnd)
|
||||
}
|
||||
|
||||
function onDragMove(e: PointerEvent) {
|
||||
if (!dragElementId.value) return
|
||||
|
||||
const overlayEl = document.querySelector('.interaction-overlay')
|
||||
if (!overlayEl) return
|
||||
const overlayRect = overlayEl.getBoundingClientRect()
|
||||
|
||||
const x = e.clientX - overlayRect.left - dragOffset.value.x
|
||||
const y = e.clientY - overlayRect.top - dragOffset.value.y
|
||||
const mouseX = e.clientX - overlayRect.left
|
||||
const mouseY = e.clientY - overlayRect.top
|
||||
|
||||
if (!isDragging.value) {
|
||||
const dx = Math.abs(x - dragGhost.value.x)
|
||||
const dy = Math.abs(y - dragGhost.value.y)
|
||||
if (dx < 4 && dy < 4) return
|
||||
isDragging.value = true
|
||||
didDrag.value = true
|
||||
editorStore.setDragging(true)
|
||||
}
|
||||
|
||||
dragGhost.value.x = x
|
||||
dragGhost.value.y = y
|
||||
|
||||
updateDropFromMouse(mouseX, mouseY, dragElementId.value)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
window.removeEventListener('pointermove', onDragMove)
|
||||
window.removeEventListener('pointerup', onDragEnd)
|
||||
|
||||
if (isDragging.value && dragElementId.value && dropTargetContainerId.value !== null && dropLogicalIndex.value !== null) {
|
||||
const currentParent = templateStore.getParent(dragElementId.value)
|
||||
const targetContainerId = dropTargetContainerId.value
|
||||
|
||||
if (currentParent && currentParent.id === targetContainerId) {
|
||||
// Aynı container içinde reorder
|
||||
const currentIdx = currentParent.children.findIndex(c => c.id === dragElementId.value)
|
||||
if (currentIdx !== -1 && currentIdx !== dropLogicalIndex.value) {
|
||||
templateStore.reorderChild(currentParent.id, currentIdx, dropLogicalIndex.value)
|
||||
}
|
||||
} else {
|
||||
// Farklı container'a taşı
|
||||
templateStore.moveElement(dragElementId.value, targetContainerId, dropLogicalIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragElementId.value = null
|
||||
editorStore.setDragging(false)
|
||||
clearDropTarget()
|
||||
setTimeout(() => { didDrag.value = false }, 50)
|
||||
}
|
||||
|
||||
// --- Absolute eleman drag ---
|
||||
|
||||
const absoluteDragId = ref<string | null>(null)
|
||||
const absoluteDragStart = ref({ mouseX: 0, mouseY: 0, elX: 0, elY: 0 })
|
||||
|
||||
function onAbsoluteDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
if (el.position.type !== 'absolute') return
|
||||
|
||||
absoluteDragId.value = el.id
|
||||
didDrag.value = false
|
||||
absoluteDragStart.value = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
elX: el.position.x,
|
||||
elY: el.position.y,
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onAbsoluteDragMove)
|
||||
window.addEventListener('pointerup', onAbsoluteDragEnd)
|
||||
}
|
||||
|
||||
function onAbsoluteDragMove(e: PointerEvent) {
|
||||
if (!absoluteDragId.value) return
|
||||
|
||||
const dx = e.clientX - absoluteDragStart.value.mouseX
|
||||
const dy = e.clientY - absoluteDragStart.value.mouseY
|
||||
|
||||
if (!isDragging.value) {
|
||||
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return
|
||||
isDragging.value = true
|
||||
didDrag.value = true
|
||||
editorStore.setDragging(true)
|
||||
}
|
||||
|
||||
const pxToMm = 1 / props.scale
|
||||
const newX = Math.max(0, absoluteDragStart.value.elX + dx * pxToMm)
|
||||
const newY = Math.max(0, absoluteDragStart.value.elY + dy * pxToMm)
|
||||
|
||||
templateStore.updateElementPosition(absoluteDragId.value, {
|
||||
type: 'absolute',
|
||||
x: Math.round(newX * 10) / 10,
|
||||
y: Math.round(newY * 10) / 10,
|
||||
})
|
||||
}
|
||||
|
||||
function onAbsoluteDragEnd() {
|
||||
window.removeEventListener('pointermove', onAbsoluteDragMove)
|
||||
window.removeEventListener('pointerup', onAbsoluteDragEnd)
|
||||
|
||||
isDragging.value = false
|
||||
absoluteDragId.value = null
|
||||
editorStore.setDragging(false)
|
||||
setTimeout(() => { didDrag.value = false }, 50)
|
||||
}
|
||||
|
||||
// --- Resize ---
|
||||
|
||||
const isResizing = ref(false)
|
||||
const resizeElementId = ref<string | null>(null)
|
||||
const resizeHandle = ref('')
|
||||
const resizeStart = ref({ mouseX: 0, mouseY: 0, x: 0, y: 0, width: 0, height: 0 })
|
||||
const resizeGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||
const resizeFinalMm = ref({ width: 0, height: 0 })
|
||||
|
||||
function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const l = props.layout[elId]
|
||||
if (!l) return
|
||||
|
||||
resizeElementId.value = elId
|
||||
resizeHandle.value = handle
|
||||
isResizing.value = true
|
||||
|
||||
const s = ptToPx.value
|
||||
const ptToMm = 1 / 2.8346
|
||||
|
||||
resizeStart.value = {
|
||||
mouseX: e.clientX, mouseY: e.clientY,
|
||||
x: l.x * s, y: l.y * s,
|
||||
width: l.width * s, height: l.height * s,
|
||||
}
|
||||
resizeGhost.value = { x: l.x * s, y: l.y * s, width: l.width * s, height: l.height * s }
|
||||
resizeFinalMm.value = { width: l.width * ptToMm, height: l.height * ptToMm }
|
||||
|
||||
window.addEventListener('pointermove', onResizeMove)
|
||||
window.addEventListener('pointerup', onResizeEnd)
|
||||
}
|
||||
|
||||
function onResizeMove(e: PointerEvent) {
|
||||
if (!resizeElementId.value) return
|
||||
|
||||
const dx = e.clientX - resizeStart.value.mouseX
|
||||
const dy = e.clientY - resizeStart.value.mouseY
|
||||
const handle = resizeHandle.value
|
||||
const pxToMm = 1 / props.scale
|
||||
|
||||
let gx = resizeStart.value.x, gy = resizeStart.value.y
|
||||
let gw = resizeStart.value.width, gh = resizeStart.value.height
|
||||
|
||||
if (handle.includes('e')) gw = Math.max(20, resizeStart.value.width + dx)
|
||||
if (handle.includes('w')) { gw = Math.max(20, resizeStart.value.width - dx); gx = resizeStart.value.x + dx }
|
||||
if (handle.includes('s')) gh = Math.max(10, resizeStart.value.height + dy)
|
||||
if (handle.includes('n')) { gh = Math.max(10, resizeStart.value.height - dy); gy = resizeStart.value.y + dy }
|
||||
|
||||
resizeGhost.value = { x: gx, y: gy, width: gw, height: gh }
|
||||
|
||||
const startWMm = resizeStart.value.width * pxToMm
|
||||
const startHMm = resizeStart.value.height * pxToMm
|
||||
let wMm = startWMm, hMm = startHMm
|
||||
if (handle.includes('e')) wMm = Math.max(5, startWMm + dx * pxToMm)
|
||||
if (handle.includes('w')) wMm = Math.max(5, startWMm - dx * pxToMm)
|
||||
if (handle.includes('s')) hMm = Math.max(3, startHMm + dy * pxToMm)
|
||||
if (handle.includes('n')) hMm = Math.max(3, startHMm - dy * pxToMm)
|
||||
|
||||
resizeFinalMm.value = { width: Math.round(wMm * 10) / 10, height: Math.round(hMm * 10) / 10 }
|
||||
}
|
||||
|
||||
function onResizeEnd() {
|
||||
window.removeEventListener('pointermove', onResizeMove)
|
||||
window.removeEventListener('pointerup', onResizeEnd)
|
||||
|
||||
if (resizeElementId.value) {
|
||||
const handle = resizeHandle.value
|
||||
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
|
||||
if (handle.includes('e') || handle.includes('w')) sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
||||
if (handle.includes('s') || handle.includes('n')) sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
|
||||
templateStore.updateElementSize(resizeElementId.value, sizeUpdate)
|
||||
}
|
||||
|
||||
isResizing.value = false
|
||||
resizeElementId.value = null
|
||||
resizeHandle.value = ''
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toolbox sürükle-bırak (HTML5 drag API)
|
||||
// ============================================================
|
||||
|
||||
function onToolboxDragOver(e: DragEvent) {
|
||||
if (!editorStore.draggedNewElement) return
|
||||
e.preventDefault()
|
||||
|
||||
const overlayEl = e.currentTarget as HTMLElement
|
||||
const rect = overlayEl.getBoundingClientRect()
|
||||
const mouseX = e.clientX - rect.left
|
||||
const mouseY = e.clientY - rect.top
|
||||
|
||||
updateDropFromMouse(mouseX, mouseY)
|
||||
}
|
||||
|
||||
function onToolboxDragLeave() {
|
||||
clearDropTarget()
|
||||
}
|
||||
|
||||
function onToolboxDrop(e: DragEvent) {
|
||||
const newEl = editorStore.draggedNewElement
|
||||
if (!newEl) return
|
||||
|
||||
const targetId = dropTargetContainerId.value ?? 'root'
|
||||
const idx = dropLogicalIndex.value ?? undefined
|
||||
|
||||
templateStore.addChild(targetId, newEl, idx)
|
||||
editorStore.selectElement(newEl.id)
|
||||
editorStore.endDragNewElement()
|
||||
clearDropTarget()
|
||||
}
|
||||
|
||||
// Aktif sürükleme var mı (eleman veya toolbox)
|
||||
const isAnyDragActive = computed(() =>
|
||||
(isDragging.value && dragElementId.value !== null) || !!editorStore.draggedNewElement
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="interaction-overlay"
|
||||
:class="{ 'interaction-overlay--drop-active': isAnyDragActive }"
|
||||
@pointerdown.self="onCanvasClick"
|
||||
@dragover.prevent="onToolboxDragOver"
|
||||
@dragleave="onToolboxDragLeave"
|
||||
@drop.prevent="onToolboxDrop"
|
||||
>
|
||||
<!-- Element handles -->
|
||||
<div
|
||||
v-for="el in flatElements"
|
||||
:key="el.id"
|
||||
class="element-handle"
|
||||
:class="{
|
||||
'element-handle--selected': editorStore.selectedElementId === el.id,
|
||||
'element-handle--container': isContainer(el),
|
||||
'element-handle--dragging': isDragging && dragElementId === el.id,
|
||||
'element-handle--drop-target': isContainer(el) && dropTargetContainerId === el.id && isAnyDragActive,
|
||||
}"
|
||||
:style="getElementStyle(el)"
|
||||
@pointerdown="(e: PointerEvent) => { onElementClick(e, el.id); onDragStart(e, el) }"
|
||||
>
|
||||
<!-- Selection border -->
|
||||
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
||||
<div class="resize-handle resize-handle--se" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')" />
|
||||
<div class="resize-handle resize-handle--sw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'sw')" />
|
||||
<div class="resize-handle resize-handle--ne" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')" />
|
||||
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drag ghost (mevcut eleman sürükleme) -->
|
||||
<div
|
||||
v-if="isDragging && dragElementId"
|
||||
class="drag-ghost"
|
||||
:style="{
|
||||
left: `${dragGhost.x}px`,
|
||||
top: `${dragGhost.y}px`,
|
||||
width: `${dragGhost.width}px`,
|
||||
height: `${dragGhost.height}px`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Resize ghost -->
|
||||
<div
|
||||
v-if="isResizing && resizeElementId"
|
||||
class="resize-ghost"
|
||||
:style="{
|
||||
left: `${resizeGhost.x}px`,
|
||||
top: `${resizeGhost.y}px`,
|
||||
width: `${resizeGhost.width}px`,
|
||||
height: `${resizeGhost.height}px`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Drop indicator (ortak — hem eleman hem toolbox sürükleme) -->
|
||||
<div v-if="isAnyDragActive" :style="dropIndicatorStyle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.interaction-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.element-handle {
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.element-handle--dragging {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Selection border */
|
||||
.selection-border {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.element-handle--container > .selection-border {
|
||||
border-color: rgb(139, 92, 246);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Container'ları hafif kenarlıkla göster (root hariç — root overlay'de flatElements'te yok) */
|
||||
.element-handle--container {
|
||||
outline: 1px dashed rgba(139, 92, 246, 0.25);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Hover efekti */
|
||||
.element-handle:not(.element-handle--selected):hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border: 1.5px solid rgba(59, 130, 246, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: white;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
border-radius: 1px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle--se { right: -3px; bottom: -3px; cursor: se-resize; }
|
||||
.resize-handle--sw { left: -3px; bottom: -3px; cursor: sw-resize; }
|
||||
.resize-handle--ne { right: -3px; top: -3px; cursor: ne-resize; }
|
||||
.resize-handle--nw { left: -3px; top: -3px; cursor: nw-resize; }
|
||||
|
||||
/* Drag ghost */
|
||||
.drag-ghost {
|
||||
position: absolute;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1.5px dashed rgb(59, 130, 246);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* Resize ghost */
|
||||
.resize-ghost {
|
||||
position: absolute;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* Sürükleme aktifken container'ları göster */
|
||||
.interaction-overlay--drop-active .element-handle--container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1.5px dashed rgba(139, 92, 246, 0.5);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Drop hedef container highlight */
|
||||
.element-handle--drop-target::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border: 2px solid rgb(139, 92, 246) !important;
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/components/editor/TypstSvgLayer.vue
Normal file
35
frontend/src/components/editor/TypstSvgLayer.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
svg: string | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="typst-svg-layer" v-if="svg" v-html="svg" />
|
||||
<div class="typst-svg-layer typst-svg-layer--empty" v-else>
|
||||
<span>Derleniyor...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.typst-svg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.typst-svg-layer :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.typst-svg-layer--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
446
frontend/src/components/panels/PropertiesPanel.vue
Normal file
446
frontend/src/components/panels/PropertiesPanel.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { isContainer } from '../../core/types'
|
||||
import type {
|
||||
TemplateElement,
|
||||
ContainerElement,
|
||||
StaticTextElement,
|
||||
LineElement,
|
||||
TextStyle,
|
||||
SizeValue,
|
||||
} from '../../core/types'
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
const selectedElement = computed(() => {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return null
|
||||
return templateStore.getElementById(id) ?? null
|
||||
})
|
||||
|
||||
const parentElement = computed(() => {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return null
|
||||
return templateStore.getParent(id) ?? null
|
||||
})
|
||||
|
||||
// --- Generic updater ---
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
const el = selectedElement.value
|
||||
if (!el) return
|
||||
update({ style: { ...el.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function updateSize(axis: 'width' | 'height', sv: SizeValue) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElementSize(id, { [axis]: sv })
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
function togglePositioning() {
|
||||
const el = selectedElement.value
|
||||
if (!el) return
|
||||
if (el.position.type === 'flow') {
|
||||
templateStore.updateElementPosition(el.id, { type: 'absolute', x: 0, y: 0 })
|
||||
} else {
|
||||
templateStore.updateElementPosition(el.id, { type: 'flow' })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
function deleteElement() {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id || id === 'root') return
|
||||
editorStore.clearSelection()
|
||||
templateStore.removeElement(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="properties-panel">
|
||||
<div v-if="!selectedElement" class="properties-panel__empty">
|
||||
Bir eleman seçin
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">
|
||||
{{ selectedElement.type === 'container' ? 'Container' : selectedElement.type === 'static_text' ? 'Metin' : selectedElement.type === 'line' ? 'Çizgi' : 'Eleman' }}
|
||||
<span class="prop-id">{{ selectedElement.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Positioning -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Pozisyon</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Mod</label>
|
||||
<select class="prop-input prop-select" :value="selectedElement.position.type" @change="togglePositioning">
|
||||
<option value="flow">Flow</option>
|
||||
<option value="absolute">Absolute</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="selectedElement.position.type === 'absolute'">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">X (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5"
|
||||
:value="selectedElement.position.x"
|
||||
@input="(e) => templateStore.updateElementPosition(selectedElement!.id, { type: 'absolute', x: parseFloat((e.target as HTMLInputElement).value) || 0, y: (selectedElement!.position as any).y ?? 0 })" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Y (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5"
|
||||
:value="selectedElement.position.y"
|
||||
@input="(e) => templateStore.updateElementPosition(selectedElement!.id, { type: 'absolute', x: (selectedElement!.position as any).x ?? 0, y: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Size -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Boyut</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Genişlik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="selectedElement.size.width.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateSize('width', { type: 'auto' })
|
||||
else if (t === 'fr') updateSize('width', { type: 'fr', value: 1 })
|
||||
else updateSize('width', { type: 'fixed', value: 50 })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedElement.size.width.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.size.width as any).value"
|
||||
@input="(e) => updateSize('width', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
||||
</div>
|
||||
<div v-if="selectedElement.size.width.type === 'fr'" class="prop-row">
|
||||
<label class="prop-label">fr</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.size.width as any).value"
|
||||
@input="(e) => updateSize('width', { type: 'fr', value: parseFloat((e.target as HTMLInputElement).value) || 1 })" />
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yükseklik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="selectedElement.size.height.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateSize('height', { type: 'auto' })
|
||||
else if (t === 'fr') updateSize('height', { type: 'fr', value: 1 })
|
||||
else updateSize('height', { type: 'fixed', value: 20 })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedElement.size.height.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.size.height as any).value"
|
||||
@input="(e) => updateSize('height', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text style (static_text, text) -->
|
||||
<div v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'" class="prop-section">
|
||||
<div class="prop-section__title">Metin Stili</div>
|
||||
|
||||
<div v-if="selectedElement.type === 'static_text'" class="prop-row">
|
||||
<label class="prop-label">Metin</label>
|
||||
<input class="prop-input" type="text"
|
||||
:value="(selectedElement as StaticTextElement).content"
|
||||
@input="(e) => update({ content: (e.target as HTMLInputElement).value } as any)" />
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Boyut (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.style as TextStyle).fontSize ?? 11"
|
||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kalınlık</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement.style as TextStyle).fontWeight ?? 'normal'"
|
||||
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="bold">Kalın</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement.style as TextStyle).color ?? '#000000'"
|
||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement.style as TextStyle).align ?? 'left'"
|
||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
||||
<option value="left">Sol</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="right">Sag</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line style -->
|
||||
<div v-if="selectedElement.type === 'line'" class="prop-section">
|
||||
<div class="prop-section__title">Çizgi Stili</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kalınlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.25" min="0.25"
|
||||
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
|
||||
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as LineElement).style.strokeColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container properties -->
|
||||
<div v-if="isContainer(selectedElement)" class="prop-section">
|
||||
<div class="prop-section__title">Container Ayarları</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yön</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ContainerElement).direction"
|
||||
@change="(e) => update({ direction: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="column">Dikey</option>
|
||||
<option value="row">Yatay</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Boşluk (mm)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).gap"
|
||||
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ContainerElement).align"
|
||||
@change="(e) => update({ align: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="start">Baş</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="end">Son</option>
|
||||
<option value="stretch">Esnet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Padding -->
|
||||
<div class="prop-section__subtitle">Padding (mm)</div>
|
||||
<div class="prop-row-grid">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Üst</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.top"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, top: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Sag</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.right"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, right: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Alt</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.bottom"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, bottom: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Sol</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.left"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, left: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Style -->
|
||||
<div class="prop-section__subtitle">Stil</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Arka plan</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as ContainerElement).style.backgroundColor ?? '#ffffff'"
|
||||
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="(selectedElement as ContainerElement).style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık rengi</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as ContainerElement).style.borderColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.5" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Radius (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
|
||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div v-if="selectedElement.id !== 'root'" class="prop-section">
|
||||
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.properties-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.properties-panel__empty {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.prop-section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.prop-section__title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prop-section__subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.prop-id {
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prop-row-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prop-row-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.prop-input {
|
||||
width: 100px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.prop-input:focus {
|
||||
outline: none;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.prop-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-color {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-clear {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.prop-delete-btn {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prop-delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/panels/ToolboxPanel.vue
Normal file
156
frontend/src/components/panels/ToolboxPanel.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { TemplateElement } from '../../core/types'
|
||||
import { sz } from '../../core/types'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
let idCounter = Date.now()
|
||||
function nextId(prefix: string) {
|
||||
return `${prefix}_${(++idCounter).toString(36)}`
|
||||
}
|
||||
|
||||
interface ToolItem {
|
||||
label: string
|
||||
icon: string
|
||||
create: () => TemplateElement
|
||||
}
|
||||
|
||||
const tools: ToolItem[] = [
|
||||
{
|
||||
label: 'Metin',
|
||||
icon: 'T',
|
||||
create: () => ({
|
||||
id: nextId('txt'),
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 11, color: '#000000' },
|
||||
content: 'Yeni metin',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Container',
|
||||
icon: '▢',
|
||||
create: () => ({
|
||||
id: nextId('cnt'),
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fr(1), height: sz.auto() },
|
||||
direction: 'column' as const,
|
||||
gap: 3,
|
||||
padding: { top: 5, right: 5, bottom: 5, left: 5 },
|
||||
align: 'stretch' as const,
|
||||
justify: 'start' as const,
|
||||
style: {},
|
||||
children: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Cizgi',
|
||||
icon: '—',
|
||||
create: () => ({
|
||||
id: nextId('ln'),
|
||||
type: 'line',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fr(1), height: sz.auto() },
|
||||
style: { strokeColor: '#000000', strokeWidth: 0.5 },
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
function onDragStart(e: DragEvent, tool: ToolItem) {
|
||||
const el = tool.create()
|
||||
editorStore.startDragNewElement(el)
|
||||
// Drag data (fallback)
|
||||
e.dataTransfer?.setData('text/plain', el.id)
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
editorStore.endDragNewElement()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbox-panel">
|
||||
<div class="toolbox-panel__title">Arac Kutusu</div>
|
||||
<div class="toolbox-panel__grid">
|
||||
<div
|
||||
v-for="tool in tools"
|
||||
:key="tool.label"
|
||||
class="toolbox-item"
|
||||
draggable="true"
|
||||
@dragstart="(e) => onDragStart(e, tool)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span class="toolbox-item__icon">{{ tool.icon }}</span>
|
||||
<span class="toolbox-item__label">{{ tool.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbox-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.toolbox-panel__title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbox-panel__grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
cursor: grab;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbox-item:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.toolbox-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.toolbox-item__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.toolbox-item__label {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
80
frontend/src/composables/useTypstCompiler.ts
Normal file
80
frontend/src/composables/useTypstCompiler.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import type { ElementLayout } from '../core/template-to-typst'
|
||||
|
||||
export function useTypstCompiler(markup: Ref<string>) {
|
||||
const svg = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const compiling = ref(false)
|
||||
const layout = ref<Record<string, ElementLayout>>({})
|
||||
|
||||
let worker: Worker | null = null
|
||||
let requestId = 0
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function initWorker() {
|
||||
worker = new Worker(new URL('../workers/typst.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
worker.onmessage = (e: MessageEvent<{
|
||||
type: string
|
||||
svg?: string
|
||||
layout?: Record<string, ElementLayout>
|
||||
error?: string
|
||||
id: number
|
||||
}>) => {
|
||||
const data = e.data
|
||||
if (data.id !== requestId) return
|
||||
|
||||
compiling.value = false
|
||||
if (data.type === 'result') {
|
||||
svg.value = data.svg ?? null
|
||||
layout.value = data.layout ?? {}
|
||||
error.value = null
|
||||
} else if (data.type === 'error') {
|
||||
error.value = data.error ?? 'Bilinmeyen derleme hatası'
|
||||
}
|
||||
}
|
||||
|
||||
worker.onerror = () => {
|
||||
compiling.value = false
|
||||
error.value = 'Worker hatası — yeniden başlatılıyor'
|
||||
worker?.terminate()
|
||||
worker = null
|
||||
setTimeout(initWorker, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function compile(typstMarkup: string) {
|
||||
if (!worker) initWorker()
|
||||
requestId++
|
||||
compiling.value = true
|
||||
worker!.postMessage({ type: 'compile', markup: typstMarkup, id: requestId })
|
||||
}
|
||||
|
||||
watch(
|
||||
markup,
|
||||
(newMarkup) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
compile(newMarkup)
|
||||
}, 200)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function dispose() {
|
||||
worker?.terminate()
|
||||
worker = null
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
return {
|
||||
svg,
|
||||
error,
|
||||
compiling,
|
||||
layout,
|
||||
compile: () => compile(markup.value),
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
60
frontend/src/composables/useUndoRedo.ts
Normal file
60
frontend/src/composables/useUndoRedo.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
|
||||
export function useUndoRedo<T>(source: Ref<T>, maxHistory = 50) {
|
||||
const undoStack = ref<string[]>([]) as Ref<string[]>
|
||||
const redoStack = ref<string[]>([]) as Ref<string[]>
|
||||
|
||||
let skipWatch = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Başlangıç snapshot'ı
|
||||
undoStack.value.push(JSON.stringify(source.value))
|
||||
|
||||
watch(
|
||||
source,
|
||||
() => {
|
||||
if (skipWatch) return
|
||||
|
||||
// Debounce: hızlı ardışık değişiklikleri birleştir
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
const snap = JSON.stringify(source.value)
|
||||
const last = undoStack.value[undoStack.value.length - 1]
|
||||
if (snap === last) return
|
||||
|
||||
undoStack.value.push(snap)
|
||||
if (undoStack.value.length > maxHistory) {
|
||||
undoStack.value.shift()
|
||||
}
|
||||
redoStack.value = []
|
||||
}, 300)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function undo() {
|
||||
if (undoStack.value.length <= 1) return
|
||||
const current = undoStack.value.pop()!
|
||||
redoStack.value.push(current)
|
||||
const prev = undoStack.value[undoStack.value.length - 1]
|
||||
applySnapshot(prev)
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (redoStack.value.length === 0) return
|
||||
const next = redoStack.value.pop()!
|
||||
undoStack.value.push(next)
|
||||
applySnapshot(next)
|
||||
}
|
||||
|
||||
function applySnapshot(snap: string) {
|
||||
skipWatch = true
|
||||
Object.assign(source.value as object, JSON.parse(snap))
|
||||
skipWatch = false
|
||||
}
|
||||
|
||||
const canUndo = () => undoStack.value.length > 1
|
||||
const canRedo = () => redoStack.value.length > 0
|
||||
|
||||
return { undo, redo, canUndo, canRedo }
|
||||
}
|
||||
396
frontend/src/core/template-to-typst.ts
Normal file
396
frontend/src/core/template-to-typst.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import type {
|
||||
Template,
|
||||
TemplateElement,
|
||||
ContainerElement,
|
||||
StaticTextElement,
|
||||
TextElement,
|
||||
LineElement,
|
||||
TextStyle,
|
||||
SizeValue,
|
||||
SizeConstraint,
|
||||
} from './types'
|
||||
import { isContainer } from './types'
|
||||
|
||||
/**
|
||||
* Template JSON → Typst markup dönüşümü.
|
||||
* Container-based layout + layout query (her element için pozisyon/boyut bilgisi).
|
||||
*/
|
||||
export function templateToTypst(template: Template, data?: Record<string, unknown>): string {
|
||||
const lines: string[] = []
|
||||
|
||||
const { page, root } = template
|
||||
const p = root.padding
|
||||
lines.push(
|
||||
`#set page(width: ${page.width}mm, height: ${page.height}mm, margin: (top: ${p.top}mm, right: ${p.right}mm, bottom: ${p.bottom}mm, left: ${p.left}mm))`
|
||||
)
|
||||
lines.push('')
|
||||
|
||||
if (data) {
|
||||
lines.push(`#let data = ${jsonToTypstDict(data)}`)
|
||||
} else {
|
||||
lines.push(`#let data = (:)`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
// Tüm elemanları topla — topological order: leaf'ler önce, container'lar sonra
|
||||
const allElements = collectTopological(root)
|
||||
|
||||
// Her element'in content'ini #let ile tanımla + label ata
|
||||
for (const el of allElements) {
|
||||
const v = idToVar(el.id)
|
||||
// Root container: sayfa margin'leri zaten padding'i karşılıyor, inset ekleme
|
||||
const content = el === root
|
||||
? renderContainerContent(el, true)
|
||||
: renderElementContent(el)
|
||||
lines.push(`#let ${v} = ${content}`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
// Kök container'ı renderla — her eleman label'lı olmalı
|
||||
lines.push(renderRootWithLabels(root))
|
||||
lines.push('')
|
||||
|
||||
// Layout query — her eleman parent'ının available width'i ile ölçülür
|
||||
lines.push(generateLayoutQuery(allElements, root, page.width))
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// --- Topological sort: leaf'ler önce ---
|
||||
|
||||
function collectTopological(root: ContainerElement): TemplateElement[] {
|
||||
const result: TemplateElement[] = []
|
||||
function walk(el: TemplateElement) {
|
||||
if (isContainer(el)) {
|
||||
for (const child of el.children) walk(child)
|
||||
}
|
||||
result.push(el)
|
||||
}
|
||||
walk(root)
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Element content rendering ---
|
||||
|
||||
function renderElementContent(el: TemplateElement): string {
|
||||
switch (el.type) {
|
||||
case 'container':
|
||||
return renderContainerContent(el)
|
||||
case 'static_text':
|
||||
return renderStaticTextContent(el)
|
||||
case 'text':
|
||||
return renderTextContent(el)
|
||||
case 'line':
|
||||
return renderLineContent(el)
|
||||
}
|
||||
}
|
||||
|
||||
function renderContainerContent(el: ContainerElement, skipPadding = false): string {
|
||||
const boxParams = buildBoxParams(el, skipPadding)
|
||||
|
||||
const flowChildren = el.children.filter(c => c.position.type !== 'absolute')
|
||||
const absoluteChildren = el.children.filter(c => c.position.type === 'absolute')
|
||||
|
||||
const innerParts: string[] = []
|
||||
|
||||
if (flowChildren.length > 0) {
|
||||
const dir = el.direction === 'row' ? 'ltr' : 'ttb'
|
||||
const gap = el.gap > 0 ? `, spacing: ${el.gap}mm` : ''
|
||||
|
||||
if (flowChildren.length === 1) {
|
||||
// Label'lı referans
|
||||
innerParts.push(`#[#${idToVar(flowChildren[0].id)} <${flowChildren[0].id}>]`)
|
||||
} else {
|
||||
const items = flowChildren.map(c =>
|
||||
` [#${idToVar(c.id)} <${c.id}>]`
|
||||
).join(',\n')
|
||||
innerParts.push(`#stack(dir: ${dir}${gap},\n${items}\n )`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of absoluteChildren) {
|
||||
if (child.position.type === 'absolute') {
|
||||
innerParts.push(
|
||||
`#place(top + left, dx: ${child.position.x}mm, dy: ${child.position.y}mm)[#${idToVar(child.id)} <${child.id}>]`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Boş container'a minimum yükseklik ver
|
||||
if (innerParts.length === 0) {
|
||||
innerParts.push('#v(5mm)')
|
||||
}
|
||||
|
||||
const inner = innerParts.join('\n ')
|
||||
return `box(${boxParams})[\n ${inner}\n]`
|
||||
}
|
||||
|
||||
function renderStaticTextContent(el: StaticTextElement): string {
|
||||
const sizeParams = buildBoxSizeParams(el.size, false)
|
||||
const textCmd = buildTextCommand(el.style, escapeTypstContent(el.content))
|
||||
|
||||
if (sizeParams) {
|
||||
return `box(${sizeParams})[${textCmd}]`
|
||||
}
|
||||
return `[${textCmd}]`
|
||||
}
|
||||
|
||||
function renderTextContent(el: TextElement): string {
|
||||
const sizeParams = buildBoxSizeParams(el.size, false)
|
||||
const dataAccess = `#data.${el.binding.path}`
|
||||
const content = el.content ? escapeTypstContent(el.content) + dataAccess : dataAccess
|
||||
const textCmd = buildTextCommand(el.style, content)
|
||||
|
||||
if (sizeParams) {
|
||||
return `box(${sizeParams})[${textCmd}]`
|
||||
}
|
||||
return `[${textCmd}]`
|
||||
}
|
||||
|
||||
function renderLineContent(el: LineElement): string {
|
||||
const stroke = el.style.strokeWidth ?? 0.5
|
||||
const color = el.style.strokeColor ?? '#000000'
|
||||
// line() fr kabul etmez; measure() göreceli birimleri çözemez
|
||||
// Bu yüzden line'ı box(width: 100%) ile sarıyoruz
|
||||
if (el.size.width.type === 'fr' || el.size.width.type === 'auto') {
|
||||
return `box(width: 100%)[#line(length: 100%, stroke: ${stroke}pt + rgb("${color}"))]`
|
||||
}
|
||||
const widthStr = sizeValueToTypst(el.size.width)
|
||||
return `line(length: ${widthStr}, stroke: ${stroke}pt + rgb("${color}"))`
|
||||
}
|
||||
|
||||
// --- Root rendering with labels ---
|
||||
|
||||
function renderRootWithLabels(root: ContainerElement): string {
|
||||
return `#[#${idToVar(root.id)} <${root.id}>]`
|
||||
}
|
||||
|
||||
// --- Layout query ---
|
||||
|
||||
function generateLayoutQuery(
|
||||
elements: TemplateElement[],
|
||||
root: ContainerElement,
|
||||
pageWidth: number,
|
||||
): string {
|
||||
// Her eleman için parent'ın available width'ini hesapla
|
||||
const parentMap = buildParentMap(root)
|
||||
const widthMap = computeAvailableWidths(root, pageWidth, parentMap)
|
||||
|
||||
const varLines = elements.map(el => {
|
||||
const v = idToVar(el.id)
|
||||
const availW = widthMap.get(el.id) ?? pageWidth
|
||||
return ` let ${v}p = locate(<${el.id}>).position()
|
||||
let ${v}s = measure(${v}, width: ${Math.round(availW * 100) / 100}mm)
|
||||
result += "${el.id}:" + repr(${v}p.x) + "," + repr(${v}p.y) + "," + repr(${v}s.width) + "," + repr(${v}s.height) + "|"`
|
||||
}).join('\n')
|
||||
|
||||
return `#context {
|
||||
let result = ""
|
||||
${varLines}
|
||||
place(bottom + right, text(size: 0.1pt, fill: white)[#result])
|
||||
}`
|
||||
}
|
||||
|
||||
/** Her elemanın parent'ını tutan map */
|
||||
function buildParentMap(root: ContainerElement): Map<string, ContainerElement> {
|
||||
const map = new Map<string, ContainerElement>()
|
||||
function walk(parent: ContainerElement) {
|
||||
for (const child of parent.children) {
|
||||
map.set(child.id, parent)
|
||||
if (isContainer(child)) walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return map
|
||||
}
|
||||
|
||||
/** Her eleman için measure'a verilecek available width (mm) hesapla */
|
||||
function computeAvailableWidths(
|
||||
root: ContainerElement,
|
||||
pageWidth: number,
|
||||
parentMap: Map<string, ContainerElement>,
|
||||
): Map<string, number> {
|
||||
const map = new Map<string, number>()
|
||||
|
||||
// Root: sayfa margin'leri root.padding'den geliyor, root box'ta inset yok
|
||||
// Root'un content area genişliği = sayfa - margin sol - margin sağ
|
||||
const rootContentWidth = pageWidth - root.padding.left - root.padding.right
|
||||
map.set(root.id, rootContentWidth)
|
||||
|
||||
function getContainerInnerWidth(c: ContainerElement): number {
|
||||
const ownWidth = map.get(c.id) ?? rootContentWidth
|
||||
// Root'un padding'i zaten sayfa margin olarak uygulandı, tekrar çıkarma
|
||||
if (c.id === root.id) return ownWidth
|
||||
return ownWidth - c.padding.left - c.padding.right
|
||||
}
|
||||
|
||||
function walk(container: ContainerElement) {
|
||||
const innerW = getContainerInnerWidth(container)
|
||||
|
||||
// row container ise çocuklar genişliği paylaşır
|
||||
// column container ise her çocuk full genişlik alır
|
||||
if (container.direction === 'column') {
|
||||
for (const child of container.children) {
|
||||
// Fixed genişlikli çocuk kendi genişliğini alır, diğerleri parent inner width
|
||||
const childW = child.size.width.type === 'fixed' ? child.size.width.value : innerW
|
||||
map.set(child.id, childW)
|
||||
if (isContainer(child)) walk(child)
|
||||
}
|
||||
} else {
|
||||
// row: fixed genişlikli çocukları çıkar, kalanı fr'lara dağıt
|
||||
let usedWidth = 0
|
||||
let totalFr = 0
|
||||
const gap = container.gap * Math.max(0, container.children.length - 1)
|
||||
|
||||
for (const child of container.children) {
|
||||
if (child.size.width.type === 'fixed') {
|
||||
usedWidth += child.size.width.value
|
||||
} else if (child.size.width.type === 'fr') {
|
||||
totalFr += child.size.width.value
|
||||
}
|
||||
}
|
||||
|
||||
const remainingW = Math.max(0, innerW - usedWidth - gap)
|
||||
|
||||
for (const child of container.children) {
|
||||
let childW: number
|
||||
if (child.size.width.type === 'fixed') {
|
||||
childW = child.size.width.value
|
||||
} else if (child.size.width.type === 'fr') {
|
||||
childW = totalFr > 0 ? (child.size.width.value / totalFr) * remainingW : remainingW
|
||||
} else {
|
||||
childW = innerW // auto
|
||||
}
|
||||
map.set(child.id, childW)
|
||||
if (isContainer(child)) walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
return map
|
||||
}
|
||||
|
||||
// --- Yardımcılar ---
|
||||
|
||||
function idToVar(id: string): string {
|
||||
return 'v_' + id.replace(/[^a-zA-Z0-9]/g, '_')
|
||||
}
|
||||
|
||||
function buildBoxParams(el: ContainerElement, skipPadding = false): string {
|
||||
const parts: string[] = []
|
||||
|
||||
// box() fr kabul etmez, fr → 100% olarak çevir
|
||||
const sizeParams = buildBoxSizeParams(el.size, false)
|
||||
if (sizeParams) parts.push(sizeParams)
|
||||
|
||||
if (!skipPadding) {
|
||||
const hasPadding = el.padding.top > 0 || el.padding.right > 0 || el.padding.bottom > 0 || el.padding.left > 0
|
||||
if (hasPadding) {
|
||||
parts.push(`inset: (top: ${el.padding.top}mm, right: ${el.padding.right}mm, bottom: ${el.padding.bottom}mm, left: ${el.padding.left}mm)`)
|
||||
}
|
||||
}
|
||||
|
||||
const styleParams = buildContainerStyleParams(el)
|
||||
if (styleParams) parts.push(styleParams)
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
function buildBoxSizeParams(size: SizeConstraint, allowFr = true): string {
|
||||
const parts: string[] = []
|
||||
const w = sizeValueToTypst(size.width, allowFr)
|
||||
if (w !== 'auto') parts.push(`width: ${w}`)
|
||||
const h = sizeValueToTypst(size.height, allowFr)
|
||||
if (h !== 'auto') parts.push(`height: ${h}`)
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
function sizeValueToTypst(sv: SizeValue, allowFr = true): string {
|
||||
switch (sv.type) {
|
||||
case 'fixed': return `${sv.value}mm`
|
||||
case 'auto': return 'auto'
|
||||
case 'fr': return allowFr ? `${sv.value}fr` : '100%'
|
||||
}
|
||||
}
|
||||
|
||||
function buildContainerStyleParams(el: ContainerElement): string {
|
||||
const parts: string[] = []
|
||||
if (el.style.backgroundColor) parts.push(`fill: rgb("${el.style.backgroundColor}")`)
|
||||
if (el.style.borderColor && (el.style.borderWidth ?? 0) > 0) {
|
||||
parts.push(`stroke: ${el.style.borderWidth ?? 1}pt + rgb("${el.style.borderColor}")`)
|
||||
}
|
||||
if (el.style.borderRadius && el.style.borderRadius > 0) {
|
||||
parts.push(`radius: ${el.style.borderRadius}pt`)
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
function buildTextCommand(style: TextStyle, content: string): string {
|
||||
const parts: string[] = []
|
||||
if (style.fontSize) parts.push(`size: ${style.fontSize}pt`)
|
||||
if (style.fontWeight === 'bold') parts.push(`weight: "bold"`)
|
||||
if (style.fontFamily) parts.push(`font: "${style.fontFamily}"`)
|
||||
if (style.color) parts.push(`fill: rgb("${style.color}")`)
|
||||
|
||||
const params = parts.join(', ')
|
||||
let result = `#text(${params})[${content}]`
|
||||
|
||||
if (style.align && style.align !== 'left') {
|
||||
result = `#align(${style.align})[${result}]`
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function escapeTypstContent(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/\]/g, '\\]')
|
||||
.replace(/#/g, '\\#')
|
||||
.replace(/\$/g, '\\$')
|
||||
.replace(/@/g, '\\@')
|
||||
.replace(/</g, '\\<')
|
||||
.replace(/>/g, '\\>')
|
||||
}
|
||||
|
||||
function jsonToTypstDict(obj: unknown): string {
|
||||
if (obj === null || obj === undefined) return 'none'
|
||||
if (typeof obj === 'string') return `"${obj.replace(/"/g, '\\"')}"`
|
||||
if (typeof obj === 'number') return String(obj)
|
||||
if (typeof obj === 'boolean') return obj ? 'true' : 'false'
|
||||
if (Array.isArray(obj)) {
|
||||
const items = obj.map(item => jsonToTypstDict(item)).join(', ')
|
||||
return `(${items},)`
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const entries = Object.entries(obj as Record<string, unknown>)
|
||||
.map(([key, val]) => `${key}: ${jsonToTypstDict(val)}`)
|
||||
.join(', ')
|
||||
return `(${entries})`
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
// --- Layout data parsing ---
|
||||
|
||||
export interface ElementLayout {
|
||||
x: number // pt
|
||||
y: number // pt
|
||||
width: number // pt
|
||||
height: number // pt
|
||||
}
|
||||
|
||||
export function parseLayoutFromSvg(svgString: string): Record<string, ElementLayout> {
|
||||
const result: Record<string, ElementLayout> = {}
|
||||
const matches = svgString.matchAll(/([a-zA-Z0-9_-]+):([\d.]+)pt,([\d.]+)pt,([\d.]+)pt,([\d.]+)pt\|/g)
|
||||
for (const m of matches) {
|
||||
result[m[1]] = {
|
||||
x: parseFloat(m[2]),
|
||||
y: parseFloat(m[3]),
|
||||
width: parseFloat(m[4]),
|
||||
height: parseFloat(m[5]),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
176
frontend/src/core/types.ts
Normal file
176
frontend/src/core/types.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Template JSON veri modeli tip tanımları
|
||||
|
||||
// --- Boyut sistemi ---
|
||||
|
||||
/** Sabit mm, içeriğe göre (auto), veya kalan alanı doldur (fr) */
|
||||
export type SizeValue =
|
||||
| { type: 'fixed'; value: number } // mm
|
||||
| { type: 'auto' }
|
||||
| { type: 'fr'; value: number } // ör: 1fr, 2fr
|
||||
|
||||
export interface SizeConstraint {
|
||||
width: SizeValue
|
||||
height: SizeValue
|
||||
minWidth?: number // mm
|
||||
minHeight?: number // mm
|
||||
maxWidth?: number // mm
|
||||
maxHeight?: number // mm
|
||||
}
|
||||
|
||||
// Kısayol oluşturucular
|
||||
export const sz = {
|
||||
fixed: (value: number): SizeValue => ({ type: 'fixed', value }),
|
||||
auto: (): SizeValue => ({ type: 'auto' }),
|
||||
fr: (value = 1): SizeValue => ({ type: 'fr', value }),
|
||||
}
|
||||
|
||||
export interface PageSettings {
|
||||
width: number // mm
|
||||
height: number // mm
|
||||
}
|
||||
|
||||
export interface Padding {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
export type PositionMode =
|
||||
| { type: 'flow' } // Container flow'una katıl (varsayılan)
|
||||
| { type: 'absolute'; x: number; y: number } // Container içinde absolute (mm)
|
||||
|
||||
// --- Stil ---
|
||||
|
||||
export interface TextStyle {
|
||||
fontSize?: number // pt
|
||||
fontWeight?: 'normal' | 'bold'
|
||||
fontFamily?: string
|
||||
color?: string // hex
|
||||
align?: 'left' | 'center' | 'right'
|
||||
}
|
||||
|
||||
export interface LineStyle {
|
||||
strokeColor?: string
|
||||
strokeWidth?: number // pt
|
||||
}
|
||||
|
||||
export interface ContainerStyle {
|
||||
backgroundColor?: string
|
||||
borderColor?: string
|
||||
borderWidth?: number // pt
|
||||
borderRadius?: number // pt
|
||||
}
|
||||
|
||||
// --- Binding ---
|
||||
|
||||
export interface ScalarBinding {
|
||||
type: 'scalar'
|
||||
path: string // ör: "firma.unvan"
|
||||
}
|
||||
|
||||
export type ElementBinding = ScalarBinding
|
||||
|
||||
// --- Element tipleri ---
|
||||
|
||||
interface BaseElement {
|
||||
id: string
|
||||
position: PositionMode
|
||||
size: SizeConstraint
|
||||
}
|
||||
|
||||
export interface StaticTextElement extends BaseElement {
|
||||
type: 'static_text'
|
||||
content: string
|
||||
style: TextStyle
|
||||
}
|
||||
|
||||
export interface TextElement extends BaseElement {
|
||||
type: 'text'
|
||||
content?: string // opsiyonel prefix
|
||||
binding: ScalarBinding
|
||||
style: TextStyle
|
||||
}
|
||||
|
||||
export interface LineElement extends BaseElement {
|
||||
type: 'line'
|
||||
style: LineStyle
|
||||
}
|
||||
|
||||
export interface ContainerElement extends BaseElement {
|
||||
type: 'container'
|
||||
direction: 'row' | 'column'
|
||||
gap: number // mm — çocuklar arası boşluk
|
||||
padding: Padding
|
||||
align: 'start' | 'center' | 'end' | 'stretch'
|
||||
justify: 'start' | 'center' | 'end' | 'space-between'
|
||||
style: ContainerStyle
|
||||
children: TemplateElement[]
|
||||
}
|
||||
|
||||
export type LeafElement = StaticTextElement | TextElement | LineElement
|
||||
export type TemplateElement = LeafElement | ContainerElement
|
||||
|
||||
// --- Template ---
|
||||
|
||||
/** Sayfa kök container gibi davranır */
|
||||
export interface Template {
|
||||
id: string
|
||||
name: string
|
||||
page: PageSettings
|
||||
fonts: string[]
|
||||
root: ContainerElement // kök container = sayfa
|
||||
}
|
||||
|
||||
// --- Editor state ---
|
||||
|
||||
export interface EditorState {
|
||||
selectedElementId: string | null
|
||||
zoom: number // 0.25 - 4.0
|
||||
panX: number
|
||||
panY: number
|
||||
isDragging: boolean
|
||||
}
|
||||
|
||||
// --- Yardımcılar ---
|
||||
|
||||
export function isContainer(el: TemplateElement): el is ContainerElement {
|
||||
return el.type === 'container'
|
||||
}
|
||||
|
||||
export function isLeaf(el: TemplateElement): el is LeafElement {
|
||||
return el.type !== 'container'
|
||||
}
|
||||
|
||||
/** Ağaçta bir element'i ID ile bulur */
|
||||
export function findElementById(
|
||||
root: ContainerElement,
|
||||
id: string
|
||||
): TemplateElement | undefined {
|
||||
if (root.id === id) return root
|
||||
for (const child of root.children) {
|
||||
if (child.id === id) return child
|
||||
if (isContainer(child)) {
|
||||
const found = findElementById(child, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Bir element'in parent container'ını bulur */
|
||||
export function findParent(
|
||||
root: ContainerElement,
|
||||
id: string
|
||||
): ContainerElement | undefined {
|
||||
for (const child of root.children) {
|
||||
if (child.id === id) return root
|
||||
if (isContainer(child)) {
|
||||
const found = findParent(child, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
8
frontend/src/main.ts
Normal file
8
frontend/src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import './styles/editor.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.mount('#app')
|
||||
65
frontend/src/stores/editor.ts
Normal file
65
frontend/src/stores/editor.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TemplateElement } from '../core/types'
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const selectedElementId = ref<string | null>(null)
|
||||
const zoom = ref(1)
|
||||
const panX = ref(0)
|
||||
const panY = ref(0)
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Toolbox'tan sürüklenen eleman (henüz eklenmedi)
|
||||
const draggedNewElement = ref<TemplateElement | null>(null)
|
||||
const dropTargetContainerId = ref<string | null>(null)
|
||||
|
||||
const zoomPercent = computed(() => Math.round(zoom.value * 100))
|
||||
|
||||
function selectElement(id: string | null) {
|
||||
selectedElementId.value = id
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedElementId.value = null
|
||||
}
|
||||
|
||||
function setZoom(value: number) {
|
||||
zoom.value = Math.max(0.25, Math.min(4, value))
|
||||
}
|
||||
|
||||
function setDragging(value: boolean) {
|
||||
isDragging.value = value
|
||||
}
|
||||
|
||||
// Toolbox drag
|
||||
function startDragNewElement(el: TemplateElement) {
|
||||
draggedNewElement.value = el
|
||||
}
|
||||
|
||||
function setDropTargetContainer(id: string | null) {
|
||||
dropTargetContainerId.value = id
|
||||
}
|
||||
|
||||
function endDragNewElement() {
|
||||
draggedNewElement.value = null
|
||||
dropTargetContainerId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
selectedElementId,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
isDragging,
|
||||
draggedNewElement,
|
||||
dropTargetContainerId,
|
||||
zoomPercent,
|
||||
selectElement,
|
||||
clearSelection,
|
||||
setZoom,
|
||||
setDragging,
|
||||
startDragNewElement,
|
||||
setDropTargetContainer,
|
||||
endDragNewElement,
|
||||
}
|
||||
})
|
||||
137
frontend/src/stores/template.ts
Normal file
137
frontend/src/stores/template.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Template, TemplateElement, ContainerElement, SizeConstraint, PositionMode } from '../core/types'
|
||||
import { findElementById, findParent, isContainer, sz } from '../core/types'
|
||||
import { templateToTypst } from '../core/template-to-typst'
|
||||
import { useUndoRedo } from '../composables/useUndoRedo'
|
||||
|
||||
function createDefaultTemplate(): Template {
|
||||
return {
|
||||
id: 'tpl_default',
|
||||
name: 'Yeni Şablon',
|
||||
page: { width: 210, height: 297 },
|
||||
fonts: ['Noto Sans'],
|
||||
root: {
|
||||
id: 'root',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
direction: 'column',
|
||||
gap: 5,
|
||||
padding: { top: 15, right: 15, bottom: 15, left: 15 },
|
||||
align: 'stretch',
|
||||
justify: 'start',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_001',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 18, fontWeight: 'bold', color: '#1a1a1a' },
|
||||
content: 'dreport',
|
||||
},
|
||||
{
|
||||
id: 'el_002',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 11, color: '#666666' },
|
||||
content: 'Belge tasarım aracı — sürükle ve bırak',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const useTemplateStore = defineStore('template', () => {
|
||||
const template = ref<Template>(createDefaultTemplate())
|
||||
|
||||
const typstMarkup = computed(() => templateToTypst(template.value))
|
||||
|
||||
// Undo / Redo
|
||||
const { undo, redo, canUndo, canRedo } = useUndoRedo(template)
|
||||
|
||||
// --- Element CRUD ---
|
||||
|
||||
function getElementById(id: string): TemplateElement | undefined {
|
||||
return findElementById(template.value.root, id)
|
||||
}
|
||||
|
||||
function getParent(id: string): ContainerElement | undefined {
|
||||
return findParent(template.value.root, id)
|
||||
}
|
||||
|
||||
/** Bir container'a çocuk ekle */
|
||||
function addChild(parentId: string, element: TemplateElement, index?: number) {
|
||||
const parent = getElementById(parentId)
|
||||
if (!parent || !isContainer(parent)) return
|
||||
if (index !== undefined) {
|
||||
parent.children.splice(index, 0, element)
|
||||
} else {
|
||||
parent.children.push(element)
|
||||
}
|
||||
}
|
||||
|
||||
/** Element'i ağaçtan kaldır */
|
||||
function removeElement(elementId: string) {
|
||||
const parent = getParent(elementId)
|
||||
if (!parent) return
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx !== -1) parent.children.splice(idx, 1)
|
||||
}
|
||||
|
||||
/** Element'i başka bir container'a taşı */
|
||||
function moveElement(elementId: string, targetParentId: string, index?: number) {
|
||||
const el = getElementById(elementId)
|
||||
if (!el) return
|
||||
removeElement(elementId)
|
||||
addChild(targetParentId, el, index)
|
||||
}
|
||||
|
||||
/** Absolute pozisyon güncelle */
|
||||
function updateElementPosition(elementId: string, position: PositionMode) {
|
||||
const el = getElementById(elementId)
|
||||
if (el) el.position = position
|
||||
}
|
||||
|
||||
/** Boyut güncelle */
|
||||
function updateElementSize(elementId: string, size: Partial<SizeConstraint>) {
|
||||
const el = getElementById(elementId)
|
||||
if (el) {
|
||||
el.size = { ...el.size, ...size }
|
||||
}
|
||||
}
|
||||
|
||||
/** Herhangi bir element özelliğini güncelle */
|
||||
function updateElement(elementId: string, updates: Partial<TemplateElement>) {
|
||||
const el = getElementById(elementId)
|
||||
if (el) Object.assign(el, updates)
|
||||
}
|
||||
|
||||
/** Çocuk sırasını değiştir (aynı parent içinde) */
|
||||
function reorderChild(parentId: string, fromIndex: number, toIndex: number) {
|
||||
const parent = getElementById(parentId)
|
||||
if (!parent || !isContainer(parent)) return
|
||||
const [moved] = parent.children.splice(fromIndex, 1)
|
||||
parent.children.splice(toIndex, 0, moved)
|
||||
}
|
||||
|
||||
return {
|
||||
template,
|
||||
typstMarkup,
|
||||
getElementById,
|
||||
getParent,
|
||||
addChild,
|
||||
removeElement,
|
||||
moveElement,
|
||||
updateElementPosition,
|
||||
updateElementSize,
|
||||
updateElement,
|
||||
reorderChild,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
}
|
||||
})
|
||||
19
frontend/src/styles/editor.css
Normal file
19
frontend/src/styles/editor.css
Normal file
@@ -0,0 +1,19 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
background: #f1f5f9;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
82
frontend/src/workers/typst.worker.ts
Normal file
82
frontend/src/workers/typst.worker.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/// Typst WASM Web Worker
|
||||
/// Ana thread'i bloklamadan Typst markup → SVG derleme yapar.
|
||||
|
||||
import { $typst, TypstSnippet } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
|
||||
|
||||
let initialized = false
|
||||
|
||||
const FONT_FILES = [
|
||||
'/fonts/NotoSans-Regular.ttf',
|
||||
'/fonts/NotoSans-Bold.ttf',
|
||||
'/fonts/NotoSans-Italic.ttf',
|
||||
'/fonts/NotoSans-BoldItalic.ttf',
|
||||
'/fonts/NotoSansMono-Regular.ttf',
|
||||
]
|
||||
|
||||
async function ensureInit() {
|
||||
if (initialized) return
|
||||
|
||||
console.log('[typst-worker] Başlatılıyor...')
|
||||
|
||||
try {
|
||||
// Fontları URL olarak preload et (init öncesinde)
|
||||
const fontUrls = FONT_FILES.map(f => new URL(f, self.location.origin).href)
|
||||
$typst.use(TypstSnippet.preloadFonts(fontUrls))
|
||||
|
||||
await $typst.setCompilerInitOptions({
|
||||
getModule: () =>
|
||||
fetch('/wasm/typst_ts_web_compiler_bg.wasm').then(r => {
|
||||
console.log('[typst-worker] Compiler WASM yüklendi:', r.status)
|
||||
return r.arrayBuffer()
|
||||
}),
|
||||
})
|
||||
await $typst.setRendererInitOptions({
|
||||
getModule: () =>
|
||||
fetch('/wasm/typst_ts_renderer_bg.wasm').then(r => {
|
||||
console.log('[typst-worker] Renderer WASM yüklendi:', r.status)
|
||||
return r.arrayBuffer()
|
||||
}),
|
||||
})
|
||||
|
||||
initialized = true
|
||||
console.log('[typst-worker] Başlatma tamamlandı')
|
||||
} catch (initErr) {
|
||||
console.error('[typst-worker] Başlatma hatası:', initErr)
|
||||
throw initErr
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async (e: MessageEvent<{ type: string; markup: string; id: number }>) => {
|
||||
const { type, markup, id } = e.data
|
||||
|
||||
if (type === 'compile') {
|
||||
console.log(`[typst-worker] Derleme başladı (id: ${id})`)
|
||||
try {
|
||||
await ensureInit()
|
||||
const svg = await $typst.svg({ mainContent: markup })
|
||||
|
||||
// SVG'den layout bilgisini parse et
|
||||
const layout: Record<string, { x: number; y: number; width: number; height: number }> = {}
|
||||
const matches = svg.matchAll(/([a-zA-Z0-9_-]+):([\d.]+)pt,([\d.]+)pt,([\d.]+)pt,([\d.]+)pt\|/g)
|
||||
for (const m of matches) {
|
||||
layout[m[1]] = {
|
||||
x: parseFloat(m[2]),
|
||||
y: parseFloat(m[3]),
|
||||
width: parseFloat(m[4]),
|
||||
height: parseFloat(m[5]),
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[typst-worker] Derleme başarılı (id: ${id}, elements: ${Object.keys(layout).length})`)
|
||||
self.postMessage({ type: 'result', svg, layout, id })
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[typst-worker] Derleme hatası (id: ${id}):`, err)
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: errorMsg,
|
||||
id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user