feat: 接入 iconify 图标库,右栏改成 Figma 式收起分组

图标统一走 unplugin-icons + hugeicons,构建期内联,出口收在 src/ui/icons.ts。
顺带修掉 TopBar 那个 undo 图标缺弧命令的 path。

右栏下半四组默认全收,收起态只有一行 32px;顶栏导出按钮改成先展开
「导出与模板」再滚,否则滚到底只看得到一行标题。

属性区去掉分组标题,改成 Figma 那种「字母/图标 + 无边框输入框」两列密排,
新增 W/H 宽高比锁(比例在开锁那一刻定格,不每次编辑现算)。

删掉 Sidebar.vue 和 panels/CanvasPanel.vue:上一轮改 Figma 布局之后
它们已无人 import,--sidebar-w 同批遗留。
This commit is contained in:
2026-09-05 17:51:43 +08:00
parent ee303bcd44
commit 3a3798e37b
16 changed files with 722 additions and 619 deletions
+62
View File
@@ -9,14 +9,20 @@
"vue": "^3.5.13",
},
"devDependencies": {
"@iconify-json/hugeicons": "^1.2.34",
"@iconify-json/mingcute": "^1.2.8",
"@iconify-json/solar": "^1.2.10",
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.7.3",
"unplugin-icons": "^23.0.1",
"vite": "^7.0.0",
"vue-tsc": "^2.2.0",
},
},
},
"packages": {
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
@@ -89,8 +95,26 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@iconify-json/hugeicons": ["@iconify-json/hugeicons@1.2.34", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-DxTrDx+P/NNgBE0wo6yXRMSyzZziCUqyb/X/HLgvI/z8XynchCw0ptNeNwHac6B5vq3PtR0R2ltCSSGiltkzLg=="],
"@iconify-json/mingcute": ["@iconify-json/mingcute@1.2.8", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-J8VXpRwTpx5yzOzyhx3T4IXnfXIKTWFl3rZ9okcW4c+NMMuwkLvdmM4rkjB83tnYIhg5+kkeMpmw8tvSnxd4rQ=="],
"@iconify-json/solar": ["@iconify-json/solar@1.2.10", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-ChpZ3kNw8nDhHfUoLq5JJ3AdIzytFm57dFkjAS0fjXnL5a7WwibTINjMKfvqQDhuXBrgqejNLyGp8CCpG8UyCg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/utils": ["@iconify/utils@3.1.5", "", { "dependencies": { "@antfu/install-pkg": "^2.0.1", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-nT+AWyh5caHY6xa0PdLXpUooIz6l7qsT+adqsCTYBfbdn5Q18VrWs2fvuTa7KEoJnPBfEPqWkmAIht0EHtCi2A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -177,6 +201,8 @@
"@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
@@ -195,6 +221,8 @@
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -223,6 +251,8 @@
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
"fabric": ["fabric@7.4.0", "", { "optionalDependencies": { "canvas": "^3.2.0", "jsdom": "^26.1.0" } }, "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -245,6 +275,8 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
@@ -253,6 +285,8 @@
"jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="],
"local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -265,6 +299,8 @@
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
@@ -279,16 +315,24 @@
"nwsapi": ["nwsapi@2.2.27", "", {}, "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"pkg-types": ["pkg-types@2.3.2", "", { "dependencies": { "confbox": "^0.3.0", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
@@ -297,6 +341,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -329,6 +375,8 @@
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
@@ -343,6 +391,12 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unplugin-icons": ["unplugin-icons@23.0.1", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^3.1.0", "local-pkg": "^1.1.2", "obug": "^2.1.1", "unplugin": "^2.3.11" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "svelte"] }, "sha512-rv0XEJepajKzDLvRUWASM8K+8+/CCfZn2jtogXqg6RIp7kpatRc/aFrVJn8ANQA09e++lPEEv9yX8cC9enc+QQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
@@ -357,6 +411,8 @@
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
@@ -371,6 +427,12 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"@iconify/utils/@antfu/install-pkg": ["@antfu/install-pkg@2.0.1", "", { "dependencies": { "package-manager-detector": "^1.7.0", "tinyexec": "^1.2.4" } }, "sha512-iCKVQcIC0e3oDxEfs3SHQGW+ovhBMZmS1TE+bTk50rVyMCBmCfClv7Qi3HQKlumYwvjb/iIMeWCW2i67q6kFfQ=="],
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
}
}
+4
View File
@@ -15,8 +15,12 @@
"vue": "^3.5.13"
},
"devDependencies": {
"@iconify-json/hugeicons": "^1.2.34",
"@iconify-json/mingcute": "^1.2.8",
"@iconify-json/solar": "^1.2.10",
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.7.3",
"unplugin-icons": "^23.0.1",
"vite": "^7.0.0",
"vue-tsc": "^2.2.0"
}
+13
View File
@@ -0,0 +1,13 @@
// 查图标名:node scripts/find-icon.mjs <关键词...>
// 在已安装的 @iconify-json/{hugeicons,solar,mingcute} 里按名字模糊匹配。
import { readFileSync } from "node:fs";
const sets = ["hugeicons", "solar", "mingcute"];
const terms = process.argv.slice(2);
for (const s of sets) {
const j = JSON.parse(readFileSync(`node_modules/@iconify-json/${s}/icons.json`, "utf8"));
const names = Object.keys(j.icons);
for (const t of terms) {
const hit = names.filter((n) => n.includes(t));
if (hit.length) console.log(`[${s}] ${t}: ` + hit.slice(0, 14).join(", ") + (hit.length > 14 ? ` … (${hit.length})` : ""));
}
}
+16 -17
View File
@@ -3,8 +3,7 @@
* 【T7 — 底部悬浮胶囊】
*
* 浮在画布区下缘居中的圆角胶囊,装高频快捷操作:
* − | 缩放百分比(点开选 50% / 100% / 200% / 适应画布 / 缩放到选中)| +
* ⤢ 适应画布
* − | 缩放百分比(点开选 50% / 100% / 200% / 适应画布 / 缩放到选中)| + | 适应画布
*
* 缩放数值读 `useViewport().zoom`(revision 无关,zoom 是 vue ref,会跟着
* 视口变化自动更新)。它是**浮层**,绝对定位在 .area-stage 里,不占布局空间。
@@ -20,6 +19,7 @@ import { useEditor } from "@/editor/useEditor";
import { useViewport } from "@/editor/useViewport";
import { runCommand } from "@/editor/commands";
import { keyHintOf } from "@/editor/useShortcuts";
import { IconZoomIn, IconZoomOut, IconFit, IconChevronDown } from "@/ui/icons";
const { zoom, zoomAt, MIN_ZOOM, MAX_ZOOM } = useViewport();
const { canvas } = useEditor();
@@ -64,16 +64,6 @@ onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
});
/* ================================================================ 图标 */
const ICON_ATTRS = 'width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
const icons: Record<string, string> = {
minus: `<svg ${ICON_ATTRS}><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
plus: `<svg ${ICON_ATTRS}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
chevron: `<svg ${ICON_ATTRS}><path d="m6 9 6 6 6-6"/></svg>`,
fit: `<svg ${ICON_ATTRS}><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>`,
};
/** 当前 zoom 是否正好是某个菜单数值档(±0.05% 容差),用于打勾。 */
function atLevel(target: number): boolean {
return Math.abs(zoom.value - target / 100) < 0.0005;
@@ -83,12 +73,14 @@ function atLevel(target: number): boolean {
<template>
<div class="dock" ref="root">
<!-- 缩放 − / + -->
<button class="zbtn" title="缩小" aria-label="缩小" :disabled="!canZoomOut" @click="runCommand('zoomOut')" v-html="icons.minus" />
<button class="zbtn" title="缩小" aria-label="缩小" :disabled="!canZoomOut" @click="runCommand('zoomOut')">
<IconZoomOut />
</button>
<!-- 缩放百分比 → 小菜单 -->
<button class="pct" :class="{ open: menuOpen }" title="缩放级别" aria-label="缩放级别" @click="menuOpen = !menuOpen">
<span class="pct-num">{{ pct }}%</span>
<span class="pct-caret" v-html="icons.chevron"></span>
<IconChevronDown class="pct-caret" />
</button>
<div v-if="menuOpen" class="menu" role="menu">
@@ -120,12 +112,16 @@ function atLevel(target: number): boolean {
</button>
</div>
<button class="zbtn" title="放大" aria-label="放大" :disabled="!canZoomIn" @click="runCommand('zoomIn')" v-html="icons.plus" />
<button class="zbtn" title="放大" aria-label="放大" :disabled="!canZoomIn" @click="runCommand('zoomIn')">
<IconZoomIn />
</button>
<span class="vsep" aria-hidden="true"></span>
<!-- 适应画布 -->
<button class="zbtn fit" title="适应画布(Shift+1)" aria-label="适应画布" @click="runCommand('zoomFit')" v-html="icons.fit" />
<button class="zbtn fit" title="适应画布(Shift+1)" aria-label="适应画布" @click="runCommand('zoomFit')">
<IconFit />
</button>
</div>
</template>
@@ -199,8 +195,11 @@ function atLevel(target: number): boolean {
font-weight: 600;
}
.pct-caret {
display: inline-flex;
color: var(--fg-dim);
font-size: 14px;
}
.dock .icon {
font-size: 15px;
}
/* ---- 缩放菜单 ---- */
+246 -122
View File
@@ -1,23 +1,26 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { ActiveSelection, Rect } from "fabric";
import type { FabricObject } from "fabric";
import { useEditor, layerKindOf } from "@/editor/useEditor";
import { commit } from "@/editor/useHistory";
import type { TransformValues } from "@/types";
import { IconAngle, IconRadius, IconOpacity, IconLinked, IconUnlinked } from "@/ui/icons";
/**
* 【T8】右侧属性检查器。
*
* 只编辑**选中对象**的几何与外观,分组:
* - 位置与尺寸:X / Y / W / H
* - 变换:缩放 X / Y、旋转角度
* - 外观:不透明度、圆角(矩形走 rx/ry;图片靠 clipPath 挂圆角矩形;文字没有)
* 只编辑**选中对象**的几何与外观,从上到下:
* X / Y、W / H(带宽高比锁)、缩放 X / Y、旋转角度与圆角、不透明度。
* 圆角对矩形走 rx/ry,对图片靠挂一个圆角矩形 clipPath,文字没有这一项。
*
* 数据流:画布是唯一真源,fabric 不是响应式的,这里靠 `revision` 这个信号
* 重新推导显示值(拖拽过程中 object:moving 每帧都 bump,数字跟着走)。
* 反向写回走 `obj.set` + `setCoords` + `requestRenderAll`,编辑结束才 `commit()`。
*
* 版式照 Figma 的设计面板收窄:没有分组标题,字段是「图标/字母 + 无边框输入框」
* 的胶囊,两列一行铺开。右栏总高有限,标题行越少留给下面工具分组的越多。
*
* 两条容易做错的语义(任务书点名,见文件末尾的纯函数区):
* 1. W/H 是缩放后的**实际占位尺寸** = 原始尺寸 × scale,不是 `getScaledWidth()`——
* 后者会把 `strokeWidth`(默认 1,即使 stroke 为 null)也算进去,导致
@@ -70,6 +73,39 @@ const values = computed<TransformValues | null>(() => {
};
});
/** 面板顶部那行小标题,告诉用户这些字段作用在什么上。 */
const headline = computed(() => {
void revision.value;
const obj = singleObject();
if (obj) {
const kind = layerKindOf(obj);
return kind === "image" ? "图片" : kind === "text" ? "文字" : kind === "rect" ? "矩形" : "对象";
}
return activeObject.value ? "多选" : "画板";
});
/**
* 宽高比例锁(Figma 设计面板 W/H 之间那个链条)。
*
* 比例**在开锁那一刻定格**,不是每次编辑现算——现算的话,「改 W → 联动 H」
* 本身就会把 w/h 改掉,下一次按键读到的是被自己改过的比例,连打几个字符
* 就会漂走。选中对象换了也重新定格。
*/
const ratioLock = ref(false);
const lockedAspect = ref(1);
function captureAspect() {
const v = values.value;
if (v && v.height > 0) lockedAspect.value = v.width / v.height;
}
watch(ratioLock, (on) => {
if (on) captureAspect();
});
watch(activeObject, () => {
if (ratioLock.value) captureAspect();
});
/** 写回后统一收尾:更新包围盒、重绘、通知其它视图。**不**记历史。 */
function finish(obj: FabricObject) {
obj.setCoords();
@@ -109,6 +145,10 @@ function editWidth(raw: string) {
const natural = obj.width ?? 0;
if (target == null || natural <= 0) return;
obj.set({ scaleX: scaleForSize(natural, target) });
if (ratioLock.value) {
const naturalH = obj.height ?? 0;
if (naturalH > 0) obj.set({ scaleY: scaleForSize(naturalH, target / lockedAspect.value) });
}
finish(obj);
}
@@ -119,6 +159,10 @@ function editHeight(raw: string) {
const natural = obj.height ?? 0;
if (target == null || natural <= 0) return;
obj.set({ scaleY: scaleForSize(natural, target) });
if (ratioLock.value) {
const naturalW = obj.width ?? 0;
if (naturalW > 0) obj.set({ scaleX: scaleForSize(naturalW, target * lockedAspect.value) });
}
finish(obj);
}
@@ -248,159 +292,239 @@ function normalizeAngle(angle: number): number {
<template>
<aside class="inspector">
<h2>属性</h2>
<div class="head">
<span class="head-kind">{{ headline }}</span>
</div>
<!-- 选中单个对象:可编辑的几何 / 外观 -->
<template v-if="values">
<section class="group">
<h3>位置与尺寸</h3>
<div class="grid">
<label class="field">
<span class="field-label">X</span>
<input type="number" :value="values.x" step="1" @input="editX(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">Y</span>
<input type="number" :value="values.y" step="1" @input="editY(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">W</span>
<input type="number" :value="values.width" min="0" step="1" @input="editWidth(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">H</span>
<input type="number" :value="values.height" min="0" step="1" @input="editHeight(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</div>
</section>
<section class="group">
<h3>变换</h3>
<div class="grid">
<label class="field">
<span class="field-label">缩放 X</span>
<input type="number" :value="values.scaleX" min="0" step="0.01" @input="editScaleX(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">缩放 Y</span>
<input type="number" :value="values.scaleY" min="0" step="0.01" @input="editScaleY(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">旋转</span>
<input type="number" :value="values.angle" step="1" @input="editAngle(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</div>
</section>
<section class="group">
<h3>外观</h3>
<div class="field">
<span class="field-label">不透明度</span>
<div class="row opacity-row">
<input
type="range"
min="0"
max="100"
step="1"
:value="Math.round(values.opacity * 100)"
@input="onOpacityInput"
@change="commitEdit"
/>
<span class="value">{{ Math.round(values.opacity * 100) }}%</span>
</div>
</div>
<label v-if="values.cornerRadius !== undefined" class="field">
<span class="field-label">圆角</span>
<input type="number" :value="values.cornerRadius" min="0" step="1" @input="editRadius(($event.target as HTMLInputElement).value)" @change="commitEdit" />
<div class="grid">
<label class="f" title="X(未旋转时的左上角)">
<span class="k">X</span>
<input type="number" step="1" :value="values.x" @input="editX(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</section>
<label class="f" title="Y(未旋转时的左上角)">
<span class="k">Y</span>
<input type="number" step="1" :value="values.y" @input="editY(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</div>
<div class="grid with-lock">
<label class="f" title="宽(缩放后的占位像素)">
<span class="k">W</span>
<input type="number" min="0" step="1" :value="values.width" @input="editWidth(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="f" title="高(缩放后的占位像素)">
<span class="k">H</span>
<input type="number" min="0" step="1" :value="values.height" @input="editHeight(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<button
type="button"
class="lock"
:class="{ on: ratioLock }"
:title="ratioLock ? '已锁定宽高比,改一边另一边联动' : '锁定宽高比'"
:aria-pressed="ratioLock"
@click="ratioLock = !ratioLock"
>
<IconLinked v-if="ratioLock" />
<IconUnlinked v-else />
</button>
</div>
<div class="grid">
<label class="f" title="水平缩放倍数">
<span class="k">SX</span>
<input type="number" min="0" step="0.01" :value="values.scaleX" @input="editScaleX(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="f" title="垂直缩放倍数">
<span class="k">SY</span>
<input type="number" min="0" step="0.01" :value="values.scaleY" @input="editScaleY(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</div>
<div class="grid">
<label class="f" title="旋转角度">
<IconAngle class="k-icon" />
<input type="number" step="1" :value="values.angle" @input="editAngle(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label v-if="values.cornerRadius !== undefined" class="f" title="圆角半径(像素)">
<IconRadius class="k-icon" />
<input type="number" min="0" step="1" :value="values.cornerRadius" @input="editRadius(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</div>
<div class="f wide" title="不透明度">
<IconOpacity class="k-icon" />
<input
class="slider"
type="range"
min="0"
max="100"
step="1"
:value="Math.round(values.opacity * 100)"
@input="onOpacityInput"
@change="commitEdit"
/>
<span class="pct">{{ Math.round(values.opacity * 100) }}%</span>
</div>
</template>
<!-- 多选:不做几何编辑 -->
<template v-else-if="activeObject">
<p class="meta">已选中多个对象。几何属性只在单选时编辑。</p>
</template>
<p v-else-if="activeObject" class="meta">已选中多个对象。几何属性只在单选时编辑。</p>
<!-- 没选中:画布属性(只读,改尺寸是 T7 的活) -->
<!-- 没选中:画板尺寸(只读,改尺寸在顶栏) -->
<template v-else>
<section class="group">
<h3>画布</h3>
<div class="grid">
<label class="field">
<span class="field-label">宽</span>
<input type="number" disabled :value="size.width" />
</label>
<label class="field">
<span class="field-label">高</span>
<input type="number" disabled :value="size.height" />
</label>
</div>
<p class="meta">画布尺寸由顶部工具栏修改。</p>
</section>
<div class="grid">
<label class="f" title="画板宽度(在顶栏修改)">
<span class="k">W</span>
<input type="number" disabled :value="size.width" />
</label>
<label class="f" title="画板高度(在顶栏修改)">
<span class="k">H</span>
<input type="number" disabled :value="size.height" />
</label>
</div>
<p class="meta">画板尺寸在顶栏修改。选中画布上的对象,这里会变成它的几何属性。</p>
</template>
</aside>
</template>
<style scoped>
.inspector {
padding: 10px 0;
overflow-y: auto;
padding: 8px 10px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.inspector h2 {
margin: 0 12px 8px;
/* 顶部一行小标题:只说明这些字段作用在什么上,不占一整块。 */
.head {
display: flex;
align-items: center;
height: 20px;
}
.head-kind {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--fg-dim);
text-transform: uppercase;
}
.group {
padding: 0 12px;
margin-bottom: 14px;
}
.group h3 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
letter-spacing: 0.06em;
color: var(--fg-dim);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
gap: 6px;
}
/* W/H 那行末尾要留出比例锁的位置 */
.grid.with-lock {
grid-template-columns: 1fr 1fr 24px;
}
.field-label {
display: block;
margin-bottom: 4px;
/**
* 字段胶囊:左边一个 12px 宽的字母或图标当标签,右边无边框输入框。
* 平时无边框,悬停才描一道线——密集排布下边框太抢眼。
*/
.f {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
margin: 0;
padding: 0 8px;
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--bg3);
cursor: text;
}
.f:hover {
border-color: var(--line);
}
.f:focus-within {
border-color: var(--accent);
}
.f.wide {
gap: 8px;
/* 这一行装的是滑杆不是输入框,光标不该变成 I 形。 */
cursor: default;
}
.k {
flex: none;
width: 15px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--fg-dim);
}
.k-icon {
color: var(--fg-dim);
font-size: 14px;
}
/* 覆盖全局 input 的边框/内边距:胶囊自己就是那层壳。 */
.f input {
width: 100%;
min-width: 0;
height: 100%;
padding: 0;
border: none;
background: transparent;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.f input:focus {
outline: none;
}
/* 数字输入框的上下箭头在这个密度下只会挤掉数字,去掉。 */
.f input[type="number"]::-webkit-outer-spin-button,
.f input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.f input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.slider {
flex: 1 1 auto;
}
.pct {
flex: none;
min-width: 34px;
text-align: right;
font-size: 11px;
color: var(--fg-dim);
}
.value {
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.opacity-row input[type="range"] {
flex: 1;
/* 比例锁:和字段等高的方形图标按钮 */
.lock {
display: grid;
place-items: center;
width: 24px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--fg-dim);
}
.lock:hover {
background: var(--bg3);
color: var(--fg);
}
.lock.on {
color: var(--accent);
}
.lock .icon {
font-size: 14px;
}
.meta {
margin: 0;
margin: 2px 0 0;
color: var(--fg-dim);
font-size: 12px;
font-size: 11px;
line-height: 1.6;
}
.inspector input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+32 -104
View File
@@ -31,19 +31,21 @@
* 绘制序号(那一层换算在 useLayers 内部)。
*/
import {
computed,
defineComponent,
h,
nextTick,
onBeforeUnmount,
ref,
type PropType,
type VNode,
} from "vue";
import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import type { LayerInfo, LayerKind } from "@/types";
import { useEditor } from "@/editor/useEditor";
import { useLayers } from "@/editor/useLayers";
import {
IconImage,
IconText,
IconRect,
IconEye,
IconEyeOff,
IconLocked,
IconUnlocked,
IconDelete,
IconTemplate,
} from "@/ui/icons";
const { canvas, revision, activeObject } = useEditor();
const { layers, selectLayer, renameLayer, toggleVisible, toggleLocked, deleteLayer, moveLayerToRow } =
@@ -61,95 +63,15 @@ const SCROLL_STEP = 12;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/* ------------------------------------------------ 图标(本地小部件) */
/* ------------------------------------------------ 图标 */
function iconSvg(children: VNode[]): VNode {
return h(
"svg",
{
width: 14,
height: 14,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": 1.25,
"stroke-linecap": "round",
"stroke-linejoin": "round",
"aria-hidden": "true",
},
children,
);
}
function kindNodes(kind: LayerKind): VNode[] {
switch (kind) {
case "image":
return [
h("rect", { x: 2.3, y: 3.5, width: 11.4, height: 9, rx: 1.5 }),
h("circle", { cx: 5.6, cy: 6.5, r: 1.1 }),
h("path", { d: "M3.1 11.4l2.9-3.1 2.5 2.5 1.8-1.9 2.6 2.5" }),
];
case "text":
return [h("path", { d: "M3.4 4.5h9.2M8 4.5v7" })];
case "rect":
return [h("rect", { x: 3.6, y: 3.6, width: 8.8, height: 8.8, rx: 1.6 })];
case "other":
return [h("path", { d: "M8 2.7l1.7 3.6 3.6 1.7-3.6 1.7-1.7 3.6-1.7-3.6-3.6-1.7 3.6-1.7Z" })];
}
}
const KindIcon = defineComponent({
name: "KindIcon",
props: { kind: { type: String as PropType<LayerKind>, required: true } },
setup(p) {
return () => iconSvg(kindNodes(p.kind));
},
});
/** 显隐眼睛:off = 眼睛加一道斜杠(隐藏态)。 */
const EyeIcon = defineComponent({
name: "EyeIcon",
props: { off: { type: Boolean, default: false } },
setup(p) {
const eye = "M2.6 8C4.3 5.6 6.3 4.3 8 4.3s3.7 1.3 5.4 3.7c-1.7 2.4-3.7 3.7-5.4 3.7S4.3 10.4 2.6 8Z";
return () =>
iconSvg(
p.off
? [h("path", { d: eye }), h("line", { x1: 3, y1: 3.2, x2: 13, y2: 12.8 })]
: [h("path", { d: eye }), h("circle", { cx: 8, cy: 8, r: 1.6 })],
);
},
});
/** 锁:open = 已解锁(锁梁翘起、没扣进锁体)。 */
const LockIcon = defineComponent({
name: "LockIcon",
props: { open: { type: Boolean, default: false } },
setup(p) {
return () =>
iconSvg([
h("rect", { x: 3.9, y: 7.2, width: 8.2, height: 5.6, rx: 1.4 }),
h(
"path",
p.open
? { d: "M5.4 7.2v-1.8a2.6 2.6 0 0 1 4.8-1.2" }
: { d: "M5.4 7.2V5.4a2.6 2.6 0 0 1 5.2 0V7.2" },
),
]);
},
});
const TrashIcon = defineComponent({
name: "TrashIcon",
setup() {
return () =>
iconSvg([
h("path", { d: "M4.6 5.4h6.8" }),
h("path", { d: "M6 5.4l.4 6.1a1.1 1.1 0 0 0 1.1 1h1a1.1 1.1 0 0 0 1.1-1l.4-6.1" }),
h("path", { d: "M6.5 3.7h3" }),
]);
},
});
/** 行首的类型图标。`other` 兜底用「多层」那个,表示不属于以上三类的对象。 */
const KIND_ICON: Record<LayerKind, unknown> = {
image: IconImage,
text: IconText,
rect: IconRect,
other: IconTemplate,
};
/* ------------------------------------------------ 状态 */
@@ -368,7 +290,7 @@ function removeLayer(layer: LayerInfo) {
:title="layer.locked ? `${layer.name}(已锁定)` : layer.name"
@dblclick="onRowDblclick($event, layer)"
>
<KindIcon class="kind-icon" :kind="layer.kind" />
<component :is="KIND_ICON[layer.kind]" class="kind-icon" />
<input
v-if="editingId === layer.id"
@@ -390,7 +312,8 @@ function removeLayer(layer: LayerInfo) {
:title="layer.visible ? '隐藏图层' : '显示图层'"
@click="onToggleVisible(layer)"
>
<EyeIcon :off="!layer.visible" />
<IconEye v-if="layer.visible" />
<IconEyeOff v-else />
</button>
<button
class="row-btn"
@@ -398,10 +321,11 @@ function removeLayer(layer: LayerInfo) {
:title="layer.locked ? '解锁图层' : '锁定图层'"
@click="onToggleLocked(layer)"
>
<LockIcon :open="!layer.locked" />
<IconLocked v-if="layer.locked" />
<IconUnlocked v-else />
</button>
<button class="row-btn btn-del" type="button" title="删除图层" @click="removeLayer(layer)">
<TrashIcon />
<IconDelete />
</button>
</span>
</div>
@@ -410,7 +334,7 @@ function removeLayer(layer: LayerInfo) {
<div v-if="drag" class="drop-line" :style="{ top: drag.slot * ROW_H + 'px' }"></div>
<div v-if="drag && dragInfo" class="drag-ghost" :style="{ top: drag.ghostTop + 'px' }">
<KindIcon class="kind-icon" :kind="dragInfo.kind" />
<component :is="KIND_ICON[dragInfo.kind]" class="kind-icon" />
<span class="name">{{ dragInfo.name }}</span>
</div>
</div>
@@ -501,8 +425,12 @@ function removeLayer(layer: LayerInfo) {
}
.kind-icon {
flex: none;
color: var(--fg-dim);
font-size: 14px;
}
.row-btn .icon {
font-size: 14px;
}
.name {
+81 -38
View File
@@ -4,6 +4,25 @@ import { ActiveSelection, FabricImage, Rect, Textbox } from "fabric";
import { useEditor } from "@/editor/useEditor";
import { runCommand } from "@/editor/commands";
import { commit } from "@/editor/useHistory";
import {
IconBringToFront,
IconSendToBack,
IconDuplicate,
IconDelete,
IconRadius,
IconBold,
IconTextLeft,
IconTextCenter,
IconTextRight,
IconAlignLeft,
IconAlignCenterH,
IconAlignRight,
IconAlignTop,
IconAlignCenterV,
IconAlignBottom,
IconDistributeH,
IconDistributeV,
} from "@/ui/icons";
/**
* 选中对象的圆角浮动编辑条,贴在对象包围盒上方,顶到画布区上缘时翻到下方。
@@ -11,10 +30,13 @@ import { commit } from "@/editor/useHistory";
*
* 内容随选中类型变(任务书表格):
* - 图片:圆角、置顶/置底、创建副本、删除
* - 文字:字号、颜色、对齐、粗细
* - 文字:字号、颜色、对齐、粗细、创建副本、删除
* - 矩形:填色、圆角、置顶/置底、删除
* - 多选:对齐与分布、创建副本、删除
*
* 按钮全是图标(`@/ui/icons`),文字说明留在 `title` / `aria-label` 里——这条浮条
* 压在画布上,中文按钮把它撑得比对象还宽。
*
* 能走命令的一律走命令(bringToFront / sendToBack / duplicate / delete),
* 文字、填色、圆角、对齐分布没有对应命令,直接 `obj.set()` + `requestRenderAll()` + `commit()`。
*
@@ -369,13 +391,14 @@ function distributeSelected(axis: "x" | "y") {
<!-- 图片 -->
<template v-if="kind === 'image'">
<label class="num" title="圆角半径(像素)">
<span class="lbl">圆角</span>
<IconRadius class="lbl" />
<input type="number" :value="imageRadius" min="0" step="1" @change="onImageRadius" />
</label>
<button title="置于顶层" @click="runCommand('bringToFront')">置顶</button>
<button title="置于底层" @click="runCommand('sendToBack')">置底</button>
<button title="创建副本" @click="runCommand('duplicate')">副本</button>
<button class="danger" title="删除" @click="runCommand('delete')">删除</button>
<span class="sep" aria-hidden="true"></span>
<button title="置于顶层" aria-label="置于顶层" @click="runCommand('bringToFront')"><IconBringToFront /></button>
<button title="置于底层" aria-label="置于底层" @click="runCommand('sendToBack')"><IconSendToBack /></button>
<button title="创建副本" aria-label="创建副本" @click="runCommand('duplicate')"><IconDuplicate /></button>
<button class="danger" title="删除" aria-label="删除" @click="runCommand('delete')"><IconDelete /></button>
</template>
<!-- 文字 -->
@@ -385,43 +408,48 @@ function distributeSelected(axis: "x" | "y") {
</label>
<input type="color" :value="textFill" title="文字颜色" @input="onTextFill" @change="onTextFillDone" />
<div class="seg">
<button :class="{ on: textAlign === 'left' }" title="左对齐" @click="setTextAlign('left')">左</button>
<button :class="{ on: textAlign === 'center' }" title="居中" @click="setTextAlign('center')">中</button>
<button :class="{ on: textAlign === 'right' }" title="右对齐" @click="setTextAlign('right')">右</button>
<button :class="{ on: textAlign === 'left' }" title="左对齐" aria-label="左对齐" @click="setTextAlign('left')"><IconTextLeft /></button>
<button :class="{ on: textAlign === 'center' }" title="居中" aria-label="居中" @click="setTextAlign('center')"><IconTextCenter /></button>
<button :class="{ on: textAlign === 'right' }" title="右对齐" aria-label="右对齐" @click="setTextAlign('right')"><IconTextRight /></button>
</div>
<button class="bold" :class="{ on: textBold }" title="加粗" @click="toggleBold">B</button>
<button :class="{ on: textBold }" title="加粗" aria-label="加粗" @click="toggleBold"><IconBold /></button>
<span class="sep" aria-hidden="true"></span>
<button title="创建副本" aria-label="创建副本" @click="runCommand('duplicate')"><IconDuplicate /></button>
<button class="danger" title="删除" aria-label="删除" @click="runCommand('delete')"><IconDelete /></button>
</template>
<!-- 矩形 -->
<template v-else-if="kind === 'rect'">
<input type="color" :value="rectFill" title="填充色" @input="onRectFill" @change="onRectFillDone" />
<label class="num" title="圆角半径(像素)">
<span class="lbl">圆角</span>
<IconRadius class="lbl" />
<input type="number" :value="rectRadius" min="0" step="1" @change="onRectRadius" />
</label>
<button title="置于顶层" @click="runCommand('bringToFront')">置顶</button>
<button title="置于底层" @click="runCommand('sendToBack')">置底</button>
<button class="danger" title="删除" @click="runCommand('delete')">删除</button>
<span class="sep" aria-hidden="true"></span>
<button title="置于顶层" aria-label="置于顶层" @click="runCommand('bringToFront')"><IconBringToFront /></button>
<button title="置于底层" aria-label="置于底层" @click="runCommand('sendToBack')"><IconSendToBack /></button>
<button class="danger" title="删除" aria-label="删除" @click="runCommand('delete')"><IconDelete /></button>
</template>
<!-- 多选 -->
<template v-else>
<div class="seg">
<button title="左对齐" @click="alignSelected('left')">左</button>
<button title="水平居中" @click="alignSelected('center')">中</button>
<button title="右对齐" @click="alignSelected('right')">右</button>
<button title="左对齐" aria-label="左对齐" @click="alignSelected('left')"><IconAlignLeft /></button>
<button title="水平居中" aria-label="水平居中" @click="alignSelected('center')"><IconAlignCenterH /></button>
<button title="右对齐" aria-label="右对齐" @click="alignSelected('right')"><IconAlignRight /></button>
</div>
<div class="seg">
<button title="顶对齐" @click="alignSelected('top')">顶</button>
<button title="垂直居中" @click="alignSelected('middle')">中</button>
<button title="底对齐" @click="alignSelected('bottom')">底</button>
<button title="顶对齐" aria-label="顶对齐" @click="alignSelected('top')"><IconAlignTop /></button>
<button title="垂直居中" aria-label="垂直居中" @click="alignSelected('middle')"><IconAlignCenterV /></button>
<button title="底对齐" aria-label="底对齐" @click="alignSelected('bottom')"><IconAlignBottom /></button>
</div>
<div class="seg">
<button title="水平分布(等间距)" :disabled="!canDistribute" @click="distributeSelected('x')">横分</button>
<button title="垂直分布(等间距)" :disabled="!canDistribute" @click="distributeSelected('y')">纵分</button>
<button title="水平分布(等间距)" aria-label="水平分布" :disabled="!canDistribute" @click="distributeSelected('x')"><IconDistributeH /></button>
<button title="垂直分布(等间距)" aria-label="垂直分布" :disabled="!canDistribute" @click="distributeSelected('y')"><IconDistributeV /></button>
</div>
<button title="创建副本" @click="runCommand('duplicate')">副本</button>
<button class="danger" title="删除" @click="runCommand('delete')">删除</button>
<span class="sep" aria-hidden="true"></span>
<button title="创建副本" aria-label="创建副本" @click="runCommand('duplicate')"><IconDuplicate /></button>
<button class="danger" title="删除" aria-label="删除" @click="runCommand('delete')"><IconDelete /></button>
</template>
</div>
</template>
@@ -449,21 +477,35 @@ function distributeSelected(axis: "x" | "y") {
transform: translate(-50%, 0);
}
/* 全是图标按钮了:无边框方块,悬停才出底色。文字标签留在 title 里。 */
.selection-menu button {
padding: 3px 8px;
font-size: 12px;
line-height: 1.4;
display: grid;
place-items: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
}
.selection-menu button:hover:not(:disabled) {
background: var(--bg);
color: var(--fg);
}
.selection-menu .icon {
font-size: 15px;
}
.selection-menu button.on {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.selection-menu button.danger:hover {
border-color: var(--danger);
.selection-menu button.danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 22%, transparent);
color: var(--danger);
}
@@ -472,18 +514,19 @@ function distributeSelected(axis: "x" | "y") {
cursor: default;
}
.selection-menu .bold {
font-weight: 700;
}
/* 一组互斥/同族的按钮贴在一起,和相邻的独立按钮拉开距离。 */
.seg {
display: flex;
gap: 2px;
gap: 0;
padding: 0 2px;
}
.seg button {
padding: 3px 6px;
/* 分隔竖线:把「改样式」和「改层序 / 删除」分成两段。 */
.sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--line);
}
.num {
@@ -495,7 +538,7 @@ function distributeSelected(axis: "x" | "y") {
.num .lbl {
color: var(--fg-dim);
font-size: 12px;
font-size: 14px;
}
.num input {
-82
View File
@@ -1,82 +0,0 @@
<script setup lang="ts">
import { computed } from "vue";
import { useEditor } from "@/editor/useEditor";
import CanvasPanel from "./panels/CanvasPanel.vue";
import SourcePanel from "./panels/SourcePanel.vue";
import FilterPanel from "./panels/FilterPanel.vue";
import TextPanel from "./panels/TextPanel.vue";
import ExportPanel from "./panels/ExportPanel.vue";
/**
* 右侧栏。面板的显示与否由画布当前选中对象决定——这是 Fabric 的对象模型白送的,
* 不需要自己做状态机。
*
* **这个文件在并行开发期间由主控独占。** 各条线只实现自己的面板组件,
* 面板的挂载位置和显示条件已经在这里接好了。
*/
const { selectionKind } = useEditor();
const showFilter = computed(() => selectionKind.value === "image");
const showText = computed(() => selectionKind.value === "text");
</script>
<template>
<aside class="sidebar">
<section class="panel">
<h2>画布</h2>
<CanvasPanel />
</section>
<section class="panel">
<h2>背景</h2>
<SourcePanel />
</section>
<section v-if="showFilter" class="panel">
<h2>滤镜</h2>
<FilterPanel />
</section>
<section v-if="showText" class="panel">
<h2>文字</h2>
<TextPanel />
</section>
<section class="panel">
<h2>导出与模板</h2>
<ExportPanel />
</section>
<p v-if="selectionKind === 'none'" class="hint">选中画布上的图片或文字,右栏会出现对应的面板。</p>
</aside>
</template>
<style scoped>
.sidebar {
border-left: 1px solid var(--line);
background: var(--bg2);
overflow-y: auto;
padding-bottom: 24px;
}
.panel {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.panel h2 {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--fg-dim);
text-transform: uppercase;
}
.hint {
padding: 12px 14px;
margin: 0;
color: var(--fg-dim);
line-height: 1.6;
}
</style>
+102 -135
View File
@@ -5,126 +5,96 @@ import SourcePanel from "./panels/SourcePanel.vue";
import FilterPanel from "./panels/FilterPanel.vue";
import TextPanel from "./panels/TextPanel.vue";
import ExportPanel from "./panels/ExportPanel.vue";
import {
IconImage,
IconFilter,
IconText,
IconExport,
IconPlus,
IconChevronDown,
} from "@/ui/icons";
import { toolGroupRequest, type ToolGroupId } from "@/ui/toolGroups";
/**
* 【T10 — 工具面板的重新安置】
* 右栏下半:背景 / 滤镜 / 文字 / 导出与模板 四个工具面板的容器。
*
* 旧版把所有东西塞在一个右侧栏里。改成 Figma 式布局之后,那四个面板
* (背景 / 滤镜 / 文字 / 导出与模板)没有任何挂载点——功能不可达。这里
* 把它们收成**可折叠的分组**,占右栏下半层(App.vue 已挂好,不动布局):
* 版式照 Figma 的设计面板:每组平时只是**一行 32px 的标题**(图标 + 名字 + 右侧
* 加号),点开才展开内容。**默认四组全收**——这四个面板加起来一千多行控件,
* 常驻展开的话右栏一屏塞不下,找任何一个都得先滚。
*
* | 组 | 来源 | 内容何时可用 |
* |----------|---------------|---------------------------|
* | 背景 | SourcePanel | 常驻 |
* | 滤镜 | FilterPanel | 选中图片时 |
* | 文字 | TextPanel | 选中文字时 |
* | 导出与模板| ExportPanel | 常驻 |
* 自动展开只发生在一种情况:选中的对象类型正好对应某一组(选图片 → 滤镜,
* 选文字 → 文字)。这是「我要改这个」的明确信号,替用户省一次点击;反过来
* 不自动收起,收起是用户自己的事。
*
* 规则:
* - 分组标题可手动折叠(展开与否只是 UI 状态,不销毁面板)。
* - **选中对象自动展开相关的那组**:选中图片自动展开「滤镜」,
* 选中文字自动展开「文字」。
* - 内容是否可用看 `selectionKind`(filter 要图片、text 要文字)。
* 面板组件**保持挂载**(用 v-show 而不是 v-if 隐藏),这样它们内部
* watch activeObject 的状态能一直跟随画布;条件不满足时组内显示一行提示。
* 面板组件用 v-show 而不是 v-if 隐藏,保持挂载,它们内部 watch activeObject
* 的状态才能一直跟着画布走。滤镜和文字组在条件不满足时显示一行提示。
*
* 别处要把用户送到某个面板,走 `@/ui/toolGroups` 的 `openToolGroup(id)`。
*/
const { selectionKind } = useEditor();
type GroupId = "bg" | "filter" | "text" | "export";
const GROUPS = [
{ id: "bg", label: "背景", icon: IconImage, scope: "" },
{ id: "filter", label: "滤镜", icon: IconFilter, scope: "图片" },
{ id: "text", label: "文字", icon: IconText, scope: "文字" },
{ id: "export", label: "导出与模板", icon: IconExport, scope: "" },
] as const;
/** 每组是否展开。展开与否是纯 UI 状态;组的内容是否可用看 selectionKind。 */
const open = reactive<Record<GroupId, boolean>>({
bg: true,
/** 每组是否展开。纯 UI 状态;组的内容是否可用看 selectionKind。 */
const open = reactive<Record<ToolGroupId, boolean>>({
bg: false,
filter: false,
text: false,
export: true,
export: false,
});
const isImage = computed(() => selectionKind.value === "image");
const isText = computed(() => selectionKind.value === "text");
/** 选中对象类型变化 → 自动展开相关的那组。只展开不收起,收起是用户自己的事。 */
watch(selectionKind, (kind) => {
if (kind === "image") open.filter = true;
else if (kind === "text") open.text = true;
});
function toggle(id: GroupId) {
/** 别处(顶栏的导出按钮)点名要展开某一组。 */
watch(toolGroupRequest, (req) => {
if (req) open[req.id] = true;
});
function toggle(id: ToolGroupId) {
open[id] = !open[id];
}
</script>
<template>
<div class="tool-panels">
<!-- ================= 背景 ================= -->
<section class="tp-sec" :class="{ open: open.bg }">
<button
type="button"
class="tp-head"
:aria-expanded="open.bg"
@click="toggle('bg')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">背景</span>
<section v-for="g in GROUPS" :key="g.id" class="sec" :class="{ open: open[g.id] }">
<button type="button" class="head" :aria-expanded="open[g.id]" @click="toggle(g.id)">
<component :is="g.icon" class="head-icon" />
<span class="title">{{ g.label }}</span>
<span v-if="g.scope && !open[g.id]" class="scope">{{ g.scope }}</span>
<IconChevronDown v-if="open[g.id]" class="mark" />
<IconPlus v-else class="mark" />
</button>
<div v-show="open.bg" class="tp-body">
<SourcePanel />
</div>
</section>
<!-- ================= 滤镜(选中图片时可用) ================= -->
<section class="tp-sec" :class="{ open: open.filter }">
<button
type="button"
class="tp-head"
:aria-expanded="open.filter"
@click="toggle('filter')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">滤镜</span>
<span class="tp-scope">作用于图片</span>
</button>
<div v-show="open.filter" class="tp-body">
<FilterPanel v-show="isImage" />
<p v-if="!isImage" class="tp-hint">
在画布上选中一张图片后,这一组会自动展开,列出可叠加的滤镜。
</p>
</div>
</section>
<div v-show="open[g.id]" class="body">
<template v-if="g.id === 'bg'">
<SourcePanel />
</template>
<!-- ================= 文字(选中文字时可用) ================= -->
<section class="tp-sec" :class="{ open: open.text }">
<button
type="button"
class="tp-head"
:aria-expanded="open.text"
@click="toggle('text')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">文字</span>
<span class="tp-scope">作用于文字</span>
</button>
<div v-show="open.text" class="tp-body">
<TextPanel v-show="isText" />
<p v-if="!isText" class="tp-hint">
用顶栏文字工具(T)添加文字,或选中画布上已有的文字,再到这里调字体样式。
</p>
</div>
</section>
<template v-else-if="g.id === 'filter'">
<FilterPanel v-show="isImage" />
<p v-if="!isImage" class="hint">选中画布上的一张图片,这里会列出可叠加的滤镜。</p>
</template>
<!-- ================= 导出与模板 ================= -->
<section class="tp-sec" :class="{ open: open.export }">
<button
type="button"
class="tp-head"
:aria-expanded="open.export"
@click="toggle('export')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">导出与模板</span>
</button>
<div v-show="open.export" class="tp-body">
<ExportPanel />
<template v-else-if="g.id === 'text'">
<TextPanel v-show="isText" />
<p v-if="!isText" class="hint">用文字工具(T)新建,或选中已有文字,再到这里调字体样式。</p>
</template>
<template v-else>
<ExportPanel />
</template>
</div>
</section>
</div>
@@ -132,82 +102,79 @@ function toggle(id: GroupId) {
<style scoped>
.tool-panels {
padding: 2px 0 18px;
padding-bottom: 18px;
}
/* 分组之间一条细分隔线,整体顶到右栏上边缘 */
.tp-sec {
.sec {
border-top: 1px solid var(--line);
}
.tp-sec:first-child {
border-top: none;
}
/* 分组标题:整行可点,覆盖全局 button 外观 */
.tp-head {
/* 标题行:整行可点,覆盖全局 button 外观。收起态就是这一行的全部高度。 */
.head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
height: 32px;
padding: 0 10px;
border: none;
border-radius: 0;
background: transparent;
color: var(--fg);
color: var(--fg-dim);
cursor: pointer;
text-align: left;
}
.tp-head:hover {
.head:hover {
background: var(--bg3);
border-color: transparent;
color: var(--fg);
}
.tp-head:focus-visible {
.head:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
/* 展开/收起的三角,收拢朝右、展开朝下 */
.chev {
flex: none;
width: 0;
height: 0;
border-left: 4px solid var(--fg-dim);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.12s ease;
}
.tp-sec.open .chev {
transform: rotate(90deg);
}
.tp-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--fg-dim);
}
.tp-sec.open .tp-title {
.sec.open .head {
color: var(--fg);
}
/* 组的适用范围提示,靠右 */
.tp-scope {
.head-icon {
font-size: 15px;
}
.title {
font-size: 12px;
font-weight: 500;
}
/* 收起时在右边提示这组作用在什么上;展开后收掉,让位给内容。 */
.scope {
margin-left: auto;
font-size: 11px;
font-size: 10px;
color: var(--fg-dim);
opacity: 0.85;
opacity: 0.7;
white-space: nowrap;
}
.tp-body {
padding: 0 12px 12px;
/* 展开/收起的记号:收起是加号,展开是朝下的角标(和 Figma 一致)。 */
.mark {
margin-left: auto;
font-size: 14px;
color: var(--fg-dim);
}
.scope + .mark {
margin-left: 6px;
}
.head:hover .mark {
color: var(--fg);
}
/* 组展开了但条件不满足(没选图片/文字)时给一行提示,不渲染控制项 */
.tp-hint {
color: var(--fg-dim);
font-size: 12px;
line-height: 1.7;
.body {
padding: 2px 10px 12px;
}
.hint {
margin: 2px 0 0;
color: var(--fg-dim);
font-size: 11px;
line-height: 1.7;
}
</style>
+41 -38
View File
@@ -8,42 +8,36 @@
* 当前工具高亮读 `useEditor().tool`;切换一律 `runCommand(...)`,不直接写
* `tool.value`——工具栏与快捷键共用命令表,抄一份必然走偏。每个按钮的 tooltip
* 用 `keyHintOf(commandId)` 显示键位,保证键位只在一处(commands.ts)维护。
* 图标从 `@/ui/icons` 取(hugeicons,构建期内联,见那个文件的说明)。
*
* 中 —— 画布尺寸。逻辑搬自旧的 CanvasPanel.vue(T10 的活,只读不复写):
* 预设下拉、比例锁下拉、自定义宽高。预设表直接用 @/editor/canvasSize 的
* SIZE_PRESETS / RATIOS / applyRatio。
* 中 —— 画布尺寸:预设下拉、比例锁下拉、自定义宽高。预设表直接用
* @/editor/canvasSize 的 SIZE_PRESETS / RATIOS / applyRatio。
*
* 右 —— 撤销 / 重做(useHistory 的 canUndo / canRedo 控制禁用态)+ 导出入口。
* 导出面板本体是 T10 的事,这里只把右栏下半的滚动容器(.right-bottom,T10 的
* ToolPanels 就在里面)滚到底——导出与模板组在 ToolPanels 里是常驻组。
* 导出按钮只负责把右栏下半的滚动容器(.right-bottom,即 ToolPanels)滚到底,
* 面板本体在那里。
*/
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useEditor } from "@/editor/useEditor";
import { useHistory } from "@/editor/useHistory";
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
import { runCommand } from "@/editor/commands";
import { keyHintOf } from "@/editor/useShortcuts";
import type { ToolId } from "@/types";
import {
IconSelect,
IconText,
IconRect,
IconImage,
IconUndo,
IconRedo,
IconExport,
} from "@/ui/icons";
import { openToolGroup } from "@/ui/toolGroups";
const { tool, size, setSize } = useEditor();
const { canUndo, canRedo } = useHistory();
/* ================================================================ 图标
* 仓里不引图标库,用内联 SVG 手画(都是简单形状)。stroke 用 currentColor,
* 颜色跟着按钮的 CSS 走。
*/
const ICON_ATTRS = 'width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
const icons: Record<string, string> = {
select: `<svg ${ICON_ATTRS}><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="M13 13l6 6"/></svg>`,
text: `<svg ${ICON_ATTRS}><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>`,
rect: `<svg ${ICON_ATTRS}><rect x="4.5" y="4.5" width="15" height="15" rx="1.5"/></svg>`,
image: `<svg ${ICON_ATTRS}><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><circle cx="9" cy="9" r="1.6"/><path d="M21 15l-4.6-4.6L6 21"/></svg>`,
undo: `<svg ${ICON_ATTRS}><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 0 0 1-5.5 5.5H11"/></svg>`,
redo: `<svg ${ICON_ATTRS}><path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/></svg>`,
export: `<svg ${ICON_ATTRS}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
};
/* ================================================================ 工具组 */
/**
@@ -52,10 +46,10 @@ const icons: Record<string, string> = {
* 由 `toolOf(cmd)` 映射回 useEditor().tool 的值来判定。
*/
const TOOLS = [
{ cmd: "toolSelect", label: "选择工具", icon: icons.select },
{ cmd: "toolText", label: "文字工具", icon: icons.text },
{ cmd: "toolRect", label: "矩形工具", icon: icons.rect },
{ cmd: "toolImage", label: "图片工具", icon: icons.image },
{ cmd: "toolSelect", label: "选择工具", icon: IconSelect },
{ cmd: "toolText", label: "文字工具", icon: IconText },
{ cmd: "toolRect", label: "矩形工具", icon: IconRect },
{ cmd: "toolImage", label: "图片工具", icon: IconImage },
] as const;
const TOOL_OF: Record<string, ToolId> = {
@@ -101,7 +95,7 @@ function pickPreset(id: string) {
if (p) setSize({ width: p.width, height: p.height });
}
/** 手动改宽/高。带比例锁时另一侧按 applyRatio 联动(和旧 CanvasPanel 一致)。 */
/** 手动改宽/高。带比例锁时另一侧按 applyRatio 联动。 */
function edit(dimension: "width" | "height", raw: string) {
const n = Number(raw);
if (!Number.isFinite(n) || n < 16) return;
@@ -121,9 +115,14 @@ const presetTitle = computed(() => {
/* ================================================================ 导出入口(右) */
/** 右栏下半是滚动容器,导出与模板组常驻在最底。滚到底露出导出区。
* T10 落地前那里是空的,滚动是幂等操作,先这么挂着。 */
function scrollToExport() {
/**
* 右栏下半是滚动容器,「导出与模板」是最后一组。
* 先展开那一组再滚——四个组默认全收,只滚不展开的话滚到底看到的还是一行标题。
* `nextTick` 是等展开后的内容渲染进 DOM,`scrollHeight` 才是最终高度。
*/
async function scrollToExport() {
openToolGroup("export");
await nextTick();
const scroller = document.querySelector<HTMLElement>(".right-bottom");
if (!scroller) return;
scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
@@ -142,8 +141,9 @@ function scrollToExport() {
:title="tip(t.cmd, t.label)"
:aria-label="t.label"
@click="runCommand(t.cmd)"
v-html="t.icon"
/>
>
<component :is="t.icon" />
</button>
</div>
<!-- 中:画布尺寸(预设 + 比例锁 + 自定义宽高) -->
@@ -203,24 +203,27 @@ function scrollToExport() {
:title="tip('undo', '撤销')"
aria-label="撤销"
@click="runCommand('undo')"
v-html="icons.undo"
/>
>
<IconUndo />
</button>
<button
class="tbtn"
:disabled="!canRedo"
:title="tip('redo', '重做')"
aria-label="重做"
@click="runCommand('redo')"
v-html="icons.redo"
/>
>
<IconRedo />
</button>
<span class="vr" aria-hidden="true"></span>
<button
class="tbtn"
title="导出(面板在右侧,点了滚过去)"
aria-label="导出"
@click="scrollToExport"
v-html="icons.export"
/>
>
<IconExport />
</button>
</div>
</header>
</template>
-78
View File
@@ -1,78 +0,0 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useEditor } from "@/editor/useEditor";
import { useViewport } from "@/editor/useViewport";
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
// 【T10 迁移中】这个面板的内容要拆进顶部工具栏(尺寸预设)与右侧检查器(画布属性)。
// 预览缩放已经从 useEditor 移到 useViewport,这里临时接过去,等 T10 拆完这个文件就删。
const { size, setSize, addText } = useEditor();
const { zoom: displayScale } = useViewport();
const presetId = ref<string>(SIZE_PRESETS[0].id);
const ratioId = ref<string>("free");
const ratio = computed(() => RATIOS.find((r) => r.id === ratioId.value)?.value ?? 0);
function pickPreset(id: string) {
presetId.value = id;
const p = SIZE_PRESETS.find((s) => s.id === id);
if (p) setSize({ width: p.width, height: p.height });
}
function edit(dimension: "width" | "height", raw: string) {
const n = Number(raw);
if (!Number.isFinite(n) || n < 16) return;
presetId.value = "custom";
setSize(applyRatio({ ...size.value, [dimension]: n }, ratio.value, dimension));
}
</script>
<template>
<div class="field">
<label>尺寸预设</label>
<select :value="presetId" @change="pickPreset(($event.target as HTMLSelectElement).value)">
<option v-for="p in SIZE_PRESETS" :key="p.id" :value="p.id">
{{ p.label }}({{ p.width }}×{{ p.height }})
</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="field">
<label>锁定比例</label>
<select v-model="ratioId">
<option v-for="r in RATIOS" :key="r.id" :value="r.id">{{ r.label }}</option>
</select>
</div>
<div class="field row">
<input
type="number"
:value="size.width"
@change="edit('width', ($event.target as HTMLInputElement).value)"
/>
<span class="times">×</span>
<input
type="number"
:value="size.height"
@change="edit('height', ($event.target as HTMLInputElement).value)"
/>
</div>
<p class="meta">预览缩放 {{ Math.round(displayScale * 100) }}%,导出始终是上面的全分辨率。</p>
<button @click="addText()">加一个文本框</button>
</template>
<style scoped>
.times {
color: var(--fg-dim);
}
.meta {
color: var(--fg-dim);
margin: 0 0 10px;
line-height: 1.6;
}
</style>
+12 -3
View File
@@ -19,9 +19,6 @@
/* 画布区的底色,比面板更深一档,让画布本身浮出来。 */
--stage-bg: #0f0f11;
/* 旧的单侧栏宽度,等 T10 把旧面板迁完就删。 */
--sidebar-w: 320px;
color-scheme: dark;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 13px;
@@ -82,3 +79,15 @@ label {
gap: 8px;
align-items: center;
}
/* 图标(unplugin-icons 给每个图标组件挂的 defaultClass)。
* hugeicons 是 fill:none + stroke:currentColor,所以颜色跟着按钮的 color 走,
* 这里只管尺寸和基线对齐。要另一档大小就在容器上写 `.icon { font-size: … }`——
* 图标的宽高是 1em。 */
.icon {
width: 1em;
height: 1em;
flex: none;
font-size: 16px;
display: block;
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 全站图标出口。**组件不要直接写 `~icons/...`**,一律从这里 import。
*
* 这么做的理由是「同一个动作在任何地方长同一个样」:删除在图层行、右键菜单、
* 选中浮条上是三个组件,但只要它们都用 `IconDelete`,换图标就是改这一行。
*
* 图标集统一用 **hugeicons**(24×24、`fill:none` + `stroke:currentColor`、
* stroke-width 1.5),风格和 Figma 的界面图标一致。混用别的集会在同一行里
* 出现粗细不一的描边,所以要加新图标先在 hugeicons 里找:
*
* node scripts/find-icon.mjs <关键词>
*
* `~icons/*` 由 vite.config.ts 的 unplugin-icons 在构建期解析成 Vue 组件,
* 只有用到的图标会进产物,不联网。类型来自 tsconfig 的
* `unplugin-icons/types/vue`。
*
* 尺寸和颜色由 CSS 控制:插件给每个图标挂了 `.icon` 类,规则在 style.css。
*/
/* ---- 工具 ---- */
export { default as IconSelect } from "~icons/hugeicons/cursor-01";
export { default as IconText } from "~icons/hugeicons/text-font";
export { default as IconRect } from "~icons/hugeicons/square";
export { default as IconImage } from "~icons/hugeicons/image-01";
/* ---- 历史与导出 ---- */
export { default as IconUndo } from "~icons/hugeicons/undo";
export { default as IconRedo } from "~icons/hugeicons/redo";
export { default as IconExport } from "~icons/hugeicons/download-01";
/* ---- 视口 ---- */
export { default as IconZoomIn } from "~icons/hugeicons/plus-sign";
export { default as IconZoomOut } from "~icons/hugeicons/minus-sign";
export { default as IconFit } from "~icons/hugeicons/maximize-01";
/* ---- 图层行 ---- */
export { default as IconEye } from "~icons/hugeicons/eye";
export { default as IconEyeOff } from "~icons/hugeicons/eye-off";
export { default as IconLocked } from "~icons/hugeicons/square-lock-01";
export { default as IconUnlocked } from "~icons/hugeicons/square-unlock-01";
export { default as IconDelete } from "~icons/hugeicons/delete-02";
export { default as IconDuplicate } from "~icons/hugeicons/copy-01";
/* ---- 层序 ---- */
export { default as IconBringToFront } from "~icons/hugeicons/layer-bring-to-front";
export { default as IconSendToBack } from "~icons/hugeicons/layer-send-to-back";
export { default as IconBringForward } from "~icons/hugeicons/arrow-up-01";
export { default as IconSendBackward } from "~icons/hugeicons/arrow-down-01";
/* ---- 属性 ---- */
export { default as IconArtboard } from "~icons/hugeicons/artboard";
export { default as IconLinked } from "~icons/hugeicons/link-02";
export { default as IconUnlinked } from "~icons/hugeicons/unlink-02";
export { default as IconAngle } from "~icons/hugeicons/angle";
export { default as IconRadius } from "~icons/hugeicons/square-round-corner";
export { default as IconOpacity } from "~icons/hugeicons/contrast";
/* ---- 对齐与分布 ---- */
export { default as IconAlignLeft } from "~icons/hugeicons/align-left";
export { default as IconAlignCenterH } from "~icons/hugeicons/align-horizontal-center";
export { default as IconAlignRight } from "~icons/hugeicons/align-right";
export { default as IconAlignTop } from "~icons/hugeicons/align-top";
export { default as IconAlignCenterV } from "~icons/hugeicons/align-vertical-center";
export { default as IconAlignBottom } from "~icons/hugeicons/align-bottom";
export { default as IconDistributeH } from "~icons/hugeicons/distribute-horizontal-center";
export { default as IconDistributeV } from "~icons/hugeicons/distribute-vertical-center";
/* ---- 文字 ---- */
export { default as IconBold } from "~icons/hugeicons/text-bold";
export { default as IconTextLeft } from "~icons/hugeicons/text-align-left";
export { default as IconTextCenter } from "~icons/hugeicons/text-align-center";
export { default as IconTextRight } from "~icons/hugeicons/text-align-right";
/* ---- 右栏分组 ---- */
export { default as IconFilter } from "~icons/hugeicons/blur";
export { default as IconTemplate } from "~icons/hugeicons/layers-01";
/* ---- 通用 ---- */
export { default as IconChevronRight } from "~icons/hugeicons/chevron-right";
export { default as IconChevronDown } from "~icons/hugeicons/chevron-down";
export { default as IconPlus } from "~icons/hugeicons/plus-sign";
+22
View File
@@ -0,0 +1,22 @@
import { ref } from "vue";
/** 右栏下半的四个工具分组。 */
export type ToolGroupId = "bg" | "filter" | "text" | "export";
/**
* 「请求展开某一组」的信号。
*
* 四个组默认全部收起,所以顶栏那个导出按钮光滚到底是没用的——滚过去只看得到
* 一行标题。别处要把用户送到某个面板前,就调 `openToolGroup(id)`,
* `ToolPanels` watch 这个 ref 把对应的组打开。
*
* 带一个自增序号,是为了让**连点两次同一个组**也能触发 watch:只放 id 的话
* 第二次赋的是同一个字符串,ref 不变,watch 不响应,用户会觉得按钮坏了。
*/
export const toolGroupRequest = ref<{ id: ToolGroupId; seq: number } | null>(null);
let seq = 0;
export function openToolGroup(id: ToolGroupId) {
toolGroupRequest.value = { id, seq: seq++ };
}
+1 -1
View File
@@ -4,7 +4,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"types": ["vite/client", "node", "unplugin-icons/types/vue"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
+9 -1
View File
@@ -1,6 +1,7 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Icons from "unplugin-icons/vite";
import { fontsPlugin } from "./src/fonts/vite-plugin-fonts";
/**
@@ -9,7 +10,14 @@ import { fontsPlugin } from "./src/fonts/vite-plugin-fonts";
* 这是刻意的——自用工具不值得为此单起一个后端进程。
*/
export default defineConfig({
plugins: [vue(), fontsPlugin()],
plugins: [
vue(),
// 图标:`import IconFoo from "~icons/solar/xxx-linear"` 在**构建期**从
// `@iconify-json/*` 里取出 SVG 编成 Vue 组件,不联网、不打包整套图标集。
// `defaultClass` 让所有图标共享一条样式规则(尺寸/描边色见 style.css 的 .icon)。
Icons({ compiler: "vue3", scale: 1, defaultClass: "icon" }),
fontsPlugin(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),