[{"data":1,"prerenderedAt":2789},["ShallowReactive",2],{"page-/post/pixel/vue3-logo-creator-ppp":3,"surrounding-page":2780},{"id":4,"title":5,"author":6,"body":7,"date":2764,"description":2765,"extension":2766,"group":2767,"lastmod":2768,"meta":2769,"navigation":1751,"path":2772,"rawbody":2773,"seo":2774,"showTitle":5,"stem":2775,"tags":2776,"versions":2767,"__hash__":2779},"content/post/pixel/vue3-logo-creator-ppp.md","Vue3项目实战：像素风LOGO编辑器 Pixeled Pic Pro","枣把儿",{"type":8,"value":9,"toc":2752},"minimark",[10,14,26,29,32,35,38,41,68,71,83,86,89,104,134,143,262,265,284,292,312,315,327,330,337,343,346,354,357,360,363,368,380,383,386,415,420,423,428,431,434,445,464,467,472,475,478,589,595,598,601,604,609,612,615,646,649,652,655,673,680,683,686,689,692,695,698,701,706,1001,1009,1184,1189,1226,1246,1276,1284,1287,1290,1293,1296,1372,1375,1672,1678,1682,1685,1688,2035,2038,2080,2086,2093,2100,2103,2262,2265,2270,2273,2276,2279,2322,2325,2463,2466,2644,2655,2660,2663,2676,2695,2698,2701,2704,2724,2731,2734,2737,2740,2743,2748],[11,12,13],"h2",{"id":13},"引言",[15,16,17,18,25],"p",{},"上篇文章《",[19,20,24],"a",{"href":21,"rel":22},"https://juejin.cn/post/7310786618391167013",[23],"nofollow","当一个程序员突然想做一款产品","》说到，我在做自己的产品的过程中，萌生了自己做一个像素风LOGO编辑器的想法，并设计了产品的整体功能、技术栈选择、服务器部署全部流程。",[15,27,28],{},"出于一个开发人员本能的对产品排期的排斥，我非常想向身为产品的自己提出一点时间上的吐槽。",[15,30,31],{},"但是我很快就忍住了，因为我想到xw了更好的处理办法。",[11,33,34],{"id":34},"版本划分",[15,36,37],{},"产品的核心功能是：生成任意行和列的方格子，按自己的喜好进行涂抹上色，创作完自己想要的图片后，导出图片即可。",[15,39,40],{},"所以我把核心功能定为V1版本：",[42,43,44,48,51,54,57,65],"ol",{},[45,46,47],"li",{},"一个可拖动的画布，一个可绘制的区域，区域内由带或不带黑色边框的单元格组成，并且可缩放可拖动。",[45,49,50],{},"单元格的形态为正方形格子。可以设置数量，如：100x100，单个方格可以设置大小，如格尺寸为：5x5",[45,52,53],{},"操作模式分为两种： 1. 鼠标模式 2. 绘图模式",[45,55,56],{},"鼠标模式下，可以自由拖拽",[45,58,59,60],{},"绘图模式下，鼠标可在绘图区域的小方格上点击并滑动，小方格会被设置对应的颜色\n",[42,61,62],{},[45,63,64],{},"颜色可配置，并且方便切换：比如tab键自动轮换预制的几种颜色",[45,66,67],{},"绘制后，图片可导出",[15,69,70],{},"我打算这样制定版本号规则：x.y.z",[72,73,74,77,80],"ul",{},[45,75,76],{},"z：通常在未上线阶段或上线后小范围迭代使用，如样式修改、代码优化等不涉及功能的改动后，此版本号加1。",[45,78,79],{},"y：通常为新增功能，bug修复等功能的迭代版本，如我这次开发的产品，我打算以v0.1.0开始，在每完成一个上述的独立功能123456后，此版本号加1，直到产品闭环。",[45,81,82],{},"x：通常为一个产品的稳定版本，此时产品的功能已经完成闭环，与上一个版本比，在功能、操作、逻辑上有重大更新或修复致命bug时，此版本号加1",[15,84,85],{},"这样，我就可以在有限的时间内推进项目的正常开发工作，让大家看到我这段时间的成果，后续还可以掏出时间来持续的迭代产品。显得有条不紊，尽显老鸟风范，皆大欢喜。",[11,87,88],{"id":88},"项目搭建",[15,90,91,92,97,98,103],{},"首先安装",[19,93,96],{"href":94,"rel":95},"https://github.com/Rich-Harris/degit",[23],"degit","这个工具，它可以用来安装",[19,99,102],{"href":100,"rel":101},"https://github.com/vitejs/awesome-vite#templates",[23],"社区模版","里的优秀模版，以此快速启动我们的项目",[105,106,111],"pre",{"className":107,"code":108,"language":109,"meta":110,"style":110},"language-shell shiki shiki-themes github-light","npm install -g degit\n","shell","",[112,113,114],"code",{"__ignoreMap":110},[115,116,119,123,127,131],"span",{"class":117,"line":118},"line",1,[115,120,122],{"class":121},"s7eDp","npm",[115,124,126],{"class":125},"sYBdl"," install",[115,128,130],{"class":129},"sYu0t"," -g",[115,132,133],{"class":125}," degit\n",[15,135,136,137,142],{},"在社区模版里挑选过后，我选择了",[19,138,141],{"href":139,"rel":140},"https://github.com/kirklin/boot-vue/blob/master/README.zh-CN.md",[23],"boot-vue","。作者这样说：",[72,144,145,154,163,172,181,190,199,208,217,226,235,244,253],{},[45,146,147,148,153],{},"⚡ ",[19,149,152],{"href":150,"rel":151},"https://github.com/kirklin/boot-vue#readme",[23],"闪电般的速度","：使用 Vue 3、Vite 和 pnpm 构建，速度飞快 🔥",[45,155,156,157,162],{},"💪 ",[19,158,161],{"href":159,"rel":160},"https://www.typescriptlang.org/",[23],"强类型","：使用 TypeScript 💻",[45,164,165,166,171],{},"🔥 ",[19,167,170],{"href":168,"rel":169},"https://github.com/vuejs/rfcs/pull/227",[23],"最新语法","：使用新的 script setup 语法 🆕",[45,173,174,175,180],{},"📦 ",[19,176,179],{"href":177,"rel":178},"https://chat.openai.com/chat/src/components",[23],"自动导入组件","：自动导入组件 🚚",[45,182,183,184,189],{},"📥 ",[19,185,188],{"href":186,"rel":187},"https://github.com/antfu/unplugin-auto-import",[23],"自动导入 API","：使用 unplugin-auto-import 直接导入 Composition API 和其他 API 📨",[45,191,192,193,198],{},"🎨 ",[19,194,197],{"href":195,"rel":196},"https://unocss.dev/",[23],"UnoCSS"," - 瞬间响应式 CSS 引擎，提供轻量级和快速的样式应用方式。",[45,200,201,202,207],{},"🌼 ",[19,203,206],{"href":204,"rel":205},"https://daisyui.com/",[23],"Daisy"," - 免费开源的 Tailwind CSS 组件库",[45,209,210,211,216],{},"💡 ",[19,212,215],{"href":213,"rel":214},"https://router.vuejs.org/",[23],"官方路由器","：使用 Vue Router v4 🛣️",[45,218,219,220,225],{},"🎉 ",[19,221,224],{"href":222,"rel":223},"https://github.com/rstacruz/nprogress",[23],"加载反馈","：使用 NProgress 提供页面加载进度反馈 🔄",[45,227,228,229,234],{},"🍍 ",[19,230,233],{"href":231,"rel":232},"https://pinia.esm.dev/",[23],"状态管理","：使用 Pinia 进行状态管理 🗃️",[45,236,237,238,243],{},"📜 ",[19,239,242],{"href":240,"rel":241},"https://github.com/kirklin/unocss-preset-chinese",[23],"中文字体预设","：包含中文字体预设 🇨🇳",[45,245,246,247,252],{},"🌍 ",[19,248,251],{"href":249,"rel":250},"https://chat.openai.com/chat/src/locales",[23],"国际化就绪","：使用本地化准备好国际化 🌎",[45,254,255,256,261],{},"☁️ ",[19,257,260],{"href":258,"rel":259},"https://www.netlify.com/",[23],"Netlify 就绪","：可在 Netlify 上零配置部署 ☁️",[15,263,264],{},"可以看到，它的功能已经非常齐全，并且没有太多多余的业务类代码，拿来作为项目的启动模版是非常香的。",[105,266,268],{"className":107,"code":267,"language":109,"meta":110,"style":110},"npx degit kirklin/boot-vue pixeled-pic-pro\n",[112,269,270],{"__ignoreMap":110},[115,271,272,275,278,281],{"class":117,"line":118},[115,273,274],{"class":121},"npx",[115,276,277],{"class":125}," degit",[115,279,280],{"class":125}," kirklin/boot-vue",[115,282,283],{"class":125}," pixeled-pic-pro\n",[15,285,286,287,291],{},"可以看到一个叫",[288,289,290],"strong",{},"pixeled-pic-pro","的项目已经被创建，进入项目目录，开始安装依赖",[105,293,295],{"className":107,"code":294,"language":109,"meta":110,"style":110},"# 没有安装pnpm的话，先自行安装一下\npnpm i\n",[112,296,297,303],{"__ignoreMap":110},[115,298,299],{"class":117,"line":118},[115,300,302],{"class":301},"sAwPA","# 没有安装pnpm的话，先自行安装一下\n",[115,304,306,309],{"class":117,"line":305},2,[115,307,308],{"class":121},"pnpm",[115,310,311],{"class":125}," i\n",[15,313,314],{},"安装完成后，运行一下",[105,316,318],{"className":107,"code":317,"language":109,"meta":110,"style":110},"pnpm dev\n",[112,319,320],{"__ignoreMap":110},[115,321,322,324],{"class":117,"line":118},[115,323,308],{"class":121},[115,325,326],{"class":125}," dev\n",[15,328,329],{},"可以看到页面已经运行在了8888端口下，并且自动开启了一个__inspect的页面",[15,331,332,333,336],{},"打开浏览器，发现还有一个切换主题皮肤的功能（",[19,334,206],{"href":204,"rel":335},[23],"），可以尝试后选择一个比较严肃和正经的皮肤",[15,338,339],{},[340,341],"img",{"alt":110,"src":342},"https://img.zzao.club/article/202411191440537.png",[15,344,345],{},"为了以防开发完了才发现别人的库有打包的问题，我们可以先运行一下打包命令，看看是否正常",[105,347,352],{"className":348,"code":350,"language":351},[349],"language-text","pnpm build\n","text",[112,353,350],{"__ignoreMap":110},[15,355,356],{},"good，没有问题。",[11,358,359],{"id":359},"梳理项目结构",[15,361,362],{},"看看这个项目里的东西有没有需要删除或修改的地方",[15,364,365],{},[288,366,367],{},"router",[15,369,370,373,374,379],{},[288,371,372],{},"路由钩子","里已经结合",[19,375,378],{"href":376,"rel":377},"https://ricostacruz.com/nprogress/",[23],"NProgress","，做了加载的进度反馈，后续如果有其他需要结合路由来判断的状态，也会继续添加在这里。",[15,381,382],{},"模式是 Hash模式，是用createWebHashHistory()创建的，可以看到url上会有一个#标志。",[15,384,385],{},"如果使用createWebHistory()，则创建的是Html5模式，这种模式的url看起来会比较“正常”，但需要服务器做一些配置工作。",[105,387,391],{"className":388,"code":389,"language":390,"meta":110,"style":110},"language-nginx shiki shiki-themes github-light","#nginx部分配置\nlocation / {\n  try_files $uri $uri/ /index.html;\n}\n","nginx",[112,392,393,398,403,409],{"__ignoreMap":110},[115,394,395],{"class":117,"line":118},[115,396,397],{},"#nginx部分配置\n",[115,399,400],{"class":117,"line":305},[115,401,402],{},"location / {\n",[115,404,406],{"class":117,"line":405},3,[115,407,408],{},"  try_files $uri $uri/ /index.html;\n",[115,410,412],{"class":117,"line":411},4,[115,413,414],{},"}\n",[15,416,417],{},[288,418,419],{},"store",[15,421,422],{},"通过pinia进行状态管理，已经有了一个小例子，后续使用的话可以仿照着改造一下。",[15,424,425],{},[288,426,427],{},"pages",[15,429,430],{},"页面部分。 它有两个页面。为了保持简单，我就只用这两个页面，一个写欢迎页，一个写功能页。 标题栏也直接拿来用，不要浪费。",[15,432,433],{},"欢迎页比较简单，我准备放一个功能演示的GIF，配一个大大的按钮【开始创作】",[15,435,436,437,440,441,444],{},"把首页里左上角名称改成",[288,438,439],{},"Pixeled Pic Pro","和作者相关的Footer注释掉，github地址换成自己的。这部分内容在",[288,442,443],{},"layouts","里。",[15,446,447,448,453,454,459,460,463],{},"注意：此项目使用的是基于",[19,449,452],{"href":450,"rel":451},"https://www.tailwindcss.cn/docs/installation",[23],"Tailwind CSS"," 的UI组件库 ",[19,455,458],{"href":456,"rel":457},"https://daisyui.com/docs/themes/",[23],"daisyUI","，而Tailwind CSS是通过",[19,461,197],{"href":195,"rel":462},[23],"来控制引入的，所以开发时需要打开它们的文档，方便查阅。",[15,465,466],{},"此时页面长这个样子",[15,468,469],{},[340,470],{"alt":110,"src":471},"https://img.zzao.club/article/202411191440539.png",[15,473,474],{},"再稍微调整一下样式，我先设置了一个flex布局，画布页面直接flex-1占满",[15,476,477],{},"layouts/index.vue",[105,479,483],{"className":480,"code":481,"language":482,"meta":110,"style":110},"language-html shiki shiki-themes github-light","\u003Ctemplate>\n\u003Cdiv class=\"font-chinese antialiased\">\n    \u003Cdiv class=\"min-h-screen flex flex-col\">\n        \u003CNavbar />\n        \u003CRouterView />\n    \u003C/div>\n    \u003C!-- \u003CFooter /> -->\n\u003C/div>\n\u003C/template>\n","html",[112,484,485,498,516,532,544,554,564,570,580],{"__ignoreMap":110},[115,486,487,491,495],{"class":117,"line":118},[115,488,490],{"class":489},"sgsFI","\u003C",[115,492,494],{"class":493},"shJU0","template",[115,496,497],{"class":489},">\n",[115,499,500,502,505,508,511,514],{"class":117,"line":305},[115,501,490],{"class":489},[115,503,504],{"class":493},"div",[115,506,507],{"class":121}," class",[115,509,510],{"class":489},"=",[115,512,513],{"class":125},"\"font-chinese antialiased\"",[115,515,497],{"class":489},[115,517,518,521,523,525,527,530],{"class":117,"line":405},[115,519,520],{"class":489},"    \u003C",[115,522,504],{"class":493},[115,524,507],{"class":121},[115,526,510],{"class":489},[115,528,529],{"class":125},"\"min-h-screen flex flex-col\"",[115,531,497],{"class":489},[115,533,534,537,541],{"class":117,"line":411},[115,535,536],{"class":489},"        \u003C",[115,538,540],{"class":539},"sB1qb","Navbar",[115,542,543],{"class":489}," />\n",[115,545,547,549,552],{"class":117,"line":546},5,[115,548,536],{"class":489},[115,550,551],{"class":539},"RouterView",[115,553,543],{"class":489},[115,555,557,560,562],{"class":117,"line":556},6,[115,558,559],{"class":489},"    \u003C/",[115,561,504],{"class":493},[115,563,497],{"class":489},[115,565,567],{"class":117,"line":566},7,[115,568,569],{"class":301},"    \u003C!-- \u003CFooter /> -->\n",[115,571,573,576,578],{"class":117,"line":572},8,[115,574,575],{"class":489},"\u003C/",[115,577,504],{"class":493},[115,579,497],{"class":489},[115,581,583,585,587],{"class":117,"line":582},9,[115,584,575],{"class":489},[115,586,494],{"class":493},[115,588,497],{"class":489},[15,590,591,592],{},"画布页面StoreTest.vue初始状态如下，文件名字可以改一改，但是我不改\n",[340,593],{"alt":110,"src":594},"https://img.zzao.club/article/202411191440540.png",[15,596,597],{},"还需要一个操作栏，放置一些常用配置和功能",[15,599,600],{},"我选择把操作栏放在左侧，并且加入几个功能按钮：生成、导出、颜色配置等，按钮可以边写边加，先按主要功能一步一步的来实现。",[15,602,603],{},"设置到了这里，我发现了一个小问题，作者似乎对html使用了scrollbar-gutter：stable这个属性，导致我页面没有滚动条的情况下，在右侧也出现了gutter",[15,605,606],{},[340,607],{"alt":110,"src":608},"https://img.zzao.club/article/202411191440541.png",[15,610,611],{},"于是乎我把这个属性覆盖了一下，一切就正常了起来~",[15,613,614],{},"src/styles/main.css",[105,616,620],{"className":617,"code":618,"language":619,"meta":110,"style":110},"language-css shiki shiki-themes github-light"," html {\n        scrollbar-gutter: auto\n    }\n","css",[112,621,622,630,641],{"__ignoreMap":110},[115,623,624,627],{"class":117,"line":118},[115,625,626],{"class":493}," html",[115,628,629],{"class":489}," {\n",[115,631,632,635,638],{"class":117,"line":305},[115,633,634],{"class":129},"        scrollbar-gutter",[115,636,637],{"class":489},": ",[115,639,640],{"class":129},"auto\n",[115,642,643],{"class":117,"line":405},[115,644,645],{"class":489},"    }\n",[15,647,648],{},"有内味儿了吧？接下来开始实现主体功能",[11,650,651],{"id":651},"画布相关功能",[15,653,654],{},"安装konva、axios(备用)依赖",[105,656,658],{"className":107,"code":657,"language":109,"meta":110,"style":110},"pnpm i konva axios\n",[112,659,660],{"__ignoreMap":110},[115,661,662,664,667,670],{"class":117,"line":118},[115,663,308],{"class":121},[115,665,666],{"class":125}," i",[115,668,669],{"class":125}," konva",[115,671,672],{"class":125}," axios\n",[15,674,675,676,679],{},"结合之前多次头脑风暴的经验，canvas的可玩性是非常高的，可以实现",[288,677,678],{},"很多","有趣但可能没用的小工具，但这里重点是很多。",[15,681,682],{},"很多意味着重复，写代码最忌的就是无脑复制粘贴。",[15,684,685],{},"哪怕写代码时的思考逻辑不是最优的，写出来的代码也不是最“优雅”的，还是要保持基本的封装的潜意识。带着一种“如果别人使用我的组件、插件的时候，他们会不会觉得不方便”的想法去思考如何封装更合理。小到一个方法，大到一个组件，都是一样的道理。",[15,687,688],{},"但也没必要因为追求“最合理”，陷入到一种情绪负担上，你的大脑开始焦虑的时候，你可以提醒自己，做不好是正常的，所有人都会出现这样的问题，做错的过程也同样有用。就如同我现在这个产品一样，分版本迭代，逐步解决问题。",[15,690,691],{},"这里为了在避免后续迭代中产生其他页面，也用到画布的相关操作，我们把整个画布封装成一个类。",[15,693,694],{},"如果另一个新的产品也和画布有关，那我们可以进一步把他抽离，发布到npmjs上。就如同这个boot-vue的作者自己内置的@kirklin/logger、@kirklin/reset-css一样。",[15,696,697],{},"回到正题。",[15,699,700],{},"把这个类随便取一个名字：AppStage，Konva里已经有个Stage的概念了，所以我这里稍微改改。\n回顾一下需要实现的功能，按照思路，罗列出AppStage要实现哪些方法：",[72,702,703],{},[45,704,705],{},"初始化画布 initStage",[105,707,711],{"className":708,"code":709,"language":710,"meta":110,"style":110},"language-typescript shiki shiki-themes github-light","export class AppStage {\n    constructor(\n        ref: Ref,\n        options: AppStageConfig = {\n            isAllowMouseSelectShapes: true,\n            isInitKeyboardEvents: true,\n            mouseMode: \"basic\",\n            scale: false,\n            scaleMember: null,\n        }\n    )\n    ...\n    \n    init(options: AppStageConfig) {\n        console.log(`初始化容器为: ${this.canvasWindow.width} x ${this.canvasWindow.height}`\n        );\n        this.stage = new Konva.Stage({\n            container: this.containerRef,\n            width: this.canvasWindow.width,\n            height: this.canvasWindow.height,\n            id: \"baseStage\",\n            draggable: options.scale\n        });\n    }\n    ...\n}\n","typescript",[112,712,713,726,734,749,764,774,783,793,803,813,819,825,831,837,856,901,907,930,941,952,963,974,980,986,991,996],{"__ignoreMap":110},[115,714,715,719,721,724],{"class":117,"line":118},[115,716,718],{"class":717},"sD7c4","export",[115,720,507],{"class":717},[115,722,723],{"class":121}," AppStage",[115,725,629],{"class":489},[115,727,728,731],{"class":117,"line":305},[115,729,730],{"class":717},"    constructor",[115,732,733],{"class":489},"(\n",[115,735,736,740,743,746],{"class":117,"line":405},[115,737,739],{"class":738},"sqxcx","        ref",[115,741,742],{"class":717},":",[115,744,745],{"class":121}," Ref",[115,747,748],{"class":489},",\n",[115,750,751,754,756,759,762],{"class":117,"line":411},[115,752,753],{"class":738},"        options",[115,755,742],{"class":717},[115,757,758],{"class":121}," AppStageConfig",[115,760,761],{"class":717}," =",[115,763,629],{"class":489},[115,765,766,769,772],{"class":117,"line":546},[115,767,768],{"class":489},"            isAllowMouseSelectShapes: ",[115,770,771],{"class":129},"true",[115,773,748],{"class":489},[115,775,776,779,781],{"class":117,"line":556},[115,777,778],{"class":489},"            isInitKeyboardEvents: ",[115,780,771],{"class":129},[115,782,748],{"class":489},[115,784,785,788,791],{"class":117,"line":566},[115,786,787],{"class":489},"            mouseMode: ",[115,789,790],{"class":125},"\"basic\"",[115,792,748],{"class":489},[115,794,795,798,801],{"class":117,"line":572},[115,796,797],{"class":489},"            scale: ",[115,799,800],{"class":129},"false",[115,802,748],{"class":489},[115,804,805,808,811],{"class":117,"line":582},[115,806,807],{"class":489},"            scaleMember: ",[115,809,810],{"class":129},"null",[115,812,748],{"class":489},[115,814,816],{"class":117,"line":815},10,[115,817,818],{"class":489},"        }\n",[115,820,822],{"class":117,"line":821},11,[115,823,824],{"class":489},"    )\n",[115,826,828],{"class":117,"line":827},12,[115,829,830],{"class":717},"    ...\n",[115,832,834],{"class":117,"line":833},13,[115,835,836],{"class":489},"    \n",[115,838,840,843,846,849,851,853],{"class":117,"line":839},14,[115,841,842],{"class":121},"    init",[115,844,845],{"class":489},"(",[115,847,848],{"class":738},"options",[115,850,742],{"class":717},[115,852,758],{"class":121},[115,854,855],{"class":489},") {\n",[115,857,859,862,865,867,870,873,876,879,881,884,887,889,891,893,895,898],{"class":117,"line":858},15,[115,860,861],{"class":489},"        console.",[115,863,864],{"class":121},"log",[115,866,845],{"class":489},[115,868,869],{"class":125},"`初始化容器为: ${",[115,871,872],{"class":129},"this",[115,874,875],{"class":125},".",[115,877,878],{"class":489},"canvasWindow",[115,880,875],{"class":125},[115,882,883],{"class":489},"width",[115,885,886],{"class":125},"} x ${",[115,888,872],{"class":129},[115,890,875],{"class":125},[115,892,878],{"class":489},[115,894,875],{"class":125},[115,896,897],{"class":489},"height",[115,899,900],{"class":125},"}`\n",[115,902,904],{"class":117,"line":903},16,[115,905,906],{"class":489},"        );\n",[115,908,910,913,916,918,921,924,927],{"class":117,"line":909},17,[115,911,912],{"class":129},"        this",[115,914,915],{"class":489},".stage ",[115,917,510],{"class":717},[115,919,920],{"class":717}," new",[115,922,923],{"class":489}," Konva.",[115,925,926],{"class":121},"Stage",[115,928,929],{"class":489},"({\n",[115,931,933,936,938],{"class":117,"line":932},18,[115,934,935],{"class":489},"            container: ",[115,937,872],{"class":129},[115,939,940],{"class":489},".containerRef,\n",[115,942,944,947,949],{"class":117,"line":943},19,[115,945,946],{"class":489},"            width: ",[115,948,872],{"class":129},[115,950,951],{"class":489},".canvasWindow.width,\n",[115,953,955,958,960],{"class":117,"line":954},20,[115,956,957],{"class":489},"            height: ",[115,959,872],{"class":129},[115,961,962],{"class":489},".canvasWindow.height,\n",[115,964,966,969,972],{"class":117,"line":965},21,[115,967,968],{"class":489},"            id: ",[115,970,971],{"class":125},"\"baseStage\"",[115,973,748],{"class":489},[115,975,977],{"class":117,"line":976},22,[115,978,979],{"class":489},"            draggable: options.scale\n",[115,981,983],{"class":117,"line":982},23,[115,984,985],{"class":489},"        });\n",[115,987,989],{"class":117,"line":988},24,[115,990,645],{"class":489},[115,992,994],{"class":117,"line":993},25,[115,995,830],{"class":717},[115,997,999],{"class":117,"line":998},26,[115,1000,414],{"class":489},[72,1002,1003,1006],{},[45,1004,1005],{},"设置是否可缩放 set",[45,1007,1008],{},"设置可缩放的对象 (macos上触摸板两指上下滑动等同于鼠标放大缩小)",[105,1010,1012],{"className":708,"code":1011,"language":710,"meta":110,"style":110},"// Konva的stage可以监听鼠标滚轮事件\nthis.stage.on(\"wheel\", (e) => {\n    // 通过wheelDelta判断，是在放大还是缩小\n    if (evt.wheelDelta > 0) {\n          // 放大\n          if (this.scaleMember.scaleX() \u003C max) {\n            this.scaleMember.scaleX(this.scaleMember.scaleX() + step);\n            this.scaleMember.scaleY(this.scaleMember.scaleY() + step);\n            // this.scaleMember.move({ x: -offsetX, y: -offsetY }) // 跟随鼠标偏移位置\n          }\n        } else {\n          // 缩小\n          ...\n})\n",[112,1013,1014,1019,1048,1053,1069,1074,1098,1123,1146,1154,1159,1169,1174,1179],{"__ignoreMap":110},[115,1015,1016],{"class":117,"line":118},[115,1017,1018],{"class":301},"// Konva的stage可以监听鼠标滚轮事件\n",[115,1020,1021,1023,1026,1029,1031,1034,1037,1040,1043,1046],{"class":117,"line":305},[115,1022,872],{"class":129},[115,1024,1025],{"class":489},".stage.",[115,1027,1028],{"class":121},"on",[115,1030,845],{"class":489},[115,1032,1033],{"class":125},"\"wheel\"",[115,1035,1036],{"class":489},", (",[115,1038,1039],{"class":738},"e",[115,1041,1042],{"class":489},") ",[115,1044,1045],{"class":717},"=>",[115,1047,629],{"class":489},[115,1049,1050],{"class":117,"line":405},[115,1051,1052],{"class":301},"    // 通过wheelDelta判断，是在放大还是缩小\n",[115,1054,1055,1058,1061,1064,1067],{"class":117,"line":411},[115,1056,1057],{"class":717},"    if",[115,1059,1060],{"class":489}," (evt.wheelDelta ",[115,1062,1063],{"class":717},">",[115,1065,1066],{"class":129}," 0",[115,1068,855],{"class":489},[115,1070,1071],{"class":117,"line":546},[115,1072,1073],{"class":301},"          // 放大\n",[115,1075,1076,1079,1082,1084,1087,1090,1093,1095],{"class":117,"line":556},[115,1077,1078],{"class":717},"          if",[115,1080,1081],{"class":489}," (",[115,1083,872],{"class":129},[115,1085,1086],{"class":489},".scaleMember.",[115,1088,1089],{"class":121},"scaleX",[115,1091,1092],{"class":489},"() ",[115,1094,490],{"class":717},[115,1096,1097],{"class":489}," max) {\n",[115,1099,1100,1103,1105,1107,1109,1111,1113,1115,1117,1120],{"class":117,"line":566},[115,1101,1102],{"class":129},"            this",[115,1104,1086],{"class":489},[115,1106,1089],{"class":121},[115,1108,845],{"class":489},[115,1110,872],{"class":129},[115,1112,1086],{"class":489},[115,1114,1089],{"class":121},[115,1116,1092],{"class":489},[115,1118,1119],{"class":717},"+",[115,1121,1122],{"class":489}," step);\n",[115,1124,1125,1127,1129,1132,1134,1136,1138,1140,1142,1144],{"class":117,"line":572},[115,1126,1102],{"class":129},[115,1128,1086],{"class":489},[115,1130,1131],{"class":121},"scaleY",[115,1133,845],{"class":489},[115,1135,872],{"class":129},[115,1137,1086],{"class":489},[115,1139,1131],{"class":121},[115,1141,1092],{"class":489},[115,1143,1119],{"class":717},[115,1145,1122],{"class":489},[115,1147,1148,1151],{"class":117,"line":582},[115,1149,1150],{"class":301},"            // this.scaleMember.move({ x: -offsetX, y: -offsetY })",[115,1152,1153],{"class":301}," // 跟随鼠标偏移位置\n",[115,1155,1156],{"class":117,"line":815},[115,1157,1158],{"class":489},"          }\n",[115,1160,1161,1164,1167],{"class":117,"line":821},[115,1162,1163],{"class":489},"        } ",[115,1165,1166],{"class":717},"else",[115,1168,629],{"class":489},[115,1170,1171],{"class":117,"line":827},[115,1172,1173],{"class":301},"          // 缩小\n",[115,1175,1176],{"class":117,"line":833},[115,1177,1178],{"class":717},"          ...\n",[115,1180,1181],{"class":117,"line":839},[115,1182,1183],{"class":489},"})\n",[72,1185,1186],{},[45,1187,1188],{},"绑定鼠标事件（落下、移动、抬起）（移动过程中，划过的元素进行填充颜色）",[105,1190,1192],{"className":708,"code":1191,"language":710,"meta":110,"style":110},"type MouseMode = \"basic\" | \"draw\" | \"clip\" | \"fill\";\n",[112,1193,1194],{"__ignoreMap":110},[115,1195,1196,1199,1202,1204,1207,1210,1213,1215,1218,1220,1223],{"class":117,"line":118},[115,1197,1198],{"class":717},"type",[115,1200,1201],{"class":121}," MouseMode",[115,1203,761],{"class":717},[115,1205,1206],{"class":125}," \"basic\"",[115,1208,1209],{"class":717}," |",[115,1211,1212],{"class":125}," \"draw\"",[115,1214,1209],{"class":717},[115,1216,1217],{"class":125}," \"clip\"",[115,1219,1209],{"class":717},[115,1221,1222],{"class":125}," \"fill\"",[115,1224,1225],{"class":489},";\n",[72,1227,1228,1231,1234,1243],{},[45,1229,1230],{},"解绑鼠标事件（有绑定就有解绑）",[45,1232,1233],{},"添加图形",[45,1235,1236,1237,1242],{},"批量添加图形（因为方格要被统一的缩放，并且只有方格可以交互，所以批量添加时需要加入到一个",[19,1238,1241],{"href":1239,"rel":1240},"http://192.241.202.210/docs/groups_and_layers/Groups.html",[23],"Group","中, 方便管理）",[45,1244,1245],{},"设置鼠标模式（切换绘图、拖拽模式，类似ps、ai里的操作）",[105,1247,1249],{"className":708,"code":1248,"language":710,"meta":110,"style":110},"switchMouseMode(mouseMode: MouseMode) {\n    this.mouseMode = mouseMode;\n}\n",[112,1250,1251,1259,1272],{"__ignoreMap":110},[115,1252,1253,1256],{"class":117,"line":118},[115,1254,1255],{"class":121},"switchMouseMode",[115,1257,1258],{"class":489},"(mouseMode: MouseMode) {\n",[115,1260,1261,1264,1267,1269],{"class":117,"line":305},[115,1262,1263],{"class":129},"    this",[115,1265,1266],{"class":489},".mouseMode ",[115,1268,510],{"class":717},[115,1270,1271],{"class":489}," mouseMode;\n",[115,1273,1274],{"class":117,"line":405},[115,1275,414],{"class":489},[72,1277,1278,1281],{},[45,1279,1280],{},"查找图形（比如查找没有上色的图形等功能可以用到）",[45,1282,1283],{},"导出图片 （回到缩放前的状态，然后导出）",[15,1285,1286],{},"由于具体的业务代码对大家可能并无卵用，细节代码就不展开了，代码链接放在文末的小结中。",[11,1288,1289],{"id":1289},"生成格子",[15,1291,1292],{},"由于生成12x12这个格子属于这个产品的业务功能，我这里没有选择把他放进类的方法里，选择在自己的组件里实现",[15,1294,1295],{},"单元格配置先给几个默认的预设，方便在不配置的情况下也能正常使用，视频里用Adobe AI来实现的时候使用了十二根线交错，所以我这里先默认给个12x12的方格布局。并且生成的单元格，默认是正方形，不排除后续加入三角形、圆形。所以我先留个口子。",[105,1297,1299],{"className":708,"code":1298,"language":710,"meta":110,"style":110},"const basicCellConfig = reactive({\n    size: 5, // 单个格子宽高\n    border: 1, // 边框宽度\n    xCount: 12, // 横向有几个\n    yCount: 12, // 纵向有几个\n})\n",[112,1300,1301,1316,1330,1343,1356,1368],{"__ignoreMap":110},[115,1302,1303,1306,1309,1311,1314],{"class":117,"line":118},[115,1304,1305],{"class":717},"const",[115,1307,1308],{"class":129}," basicCellConfig",[115,1310,761],{"class":717},[115,1312,1313],{"class":121}," reactive",[115,1315,929],{"class":489},[115,1317,1318,1321,1324,1327],{"class":117,"line":305},[115,1319,1320],{"class":489},"    size: ",[115,1322,1323],{"class":129},"5",[115,1325,1326],{"class":489},", ",[115,1328,1329],{"class":301},"// 单个格子宽高\n",[115,1331,1332,1335,1338,1340],{"class":117,"line":405},[115,1333,1334],{"class":489},"    border: ",[115,1336,1337],{"class":129},"1",[115,1339,1326],{"class":489},[115,1341,1342],{"class":301},"// 边框宽度\n",[115,1344,1345,1348,1351,1353],{"class":117,"line":411},[115,1346,1347],{"class":489},"    xCount: ",[115,1349,1350],{"class":129},"12",[115,1352,1326],{"class":489},[115,1354,1355],{"class":301},"// 横向有几个\n",[115,1357,1358,1361,1363,1365],{"class":117,"line":546},[115,1359,1360],{"class":489},"    yCount: ",[115,1362,1350],{"class":129},[115,1364,1326],{"class":489},[115,1366,1367],{"class":301},"// 纵向有几个\n",[115,1369,1370],{"class":117,"line":556},[115,1371,1183],{"class":489},[15,1373,1374],{},"生成12x12单元格方法，这里我们先把所有rect生成好，然后传给AppStage类里按Group生成图形",[105,1376,1378],{"className":708,"code":1377,"language":710,"meta":110,"style":110},"const genPixelBoxCells = () => {\n  let { x, y, strokeWidth: border } = PixelRect.value?.getAttrs()\n  let cells = []\n  for (let xIndex = 0; xIndex \u003C basicCellConfig.xCount; xIndex++) {\n    for (let yIndex = 0; yIndex \u003C basicCellConfig.yCount; yIndex++) {\n      let attrs = {\n        x: x + border + basicCellConfig.size * xIndex,\n        y: y + border + basicCellConfig.size * yIndex,\n        width: basicCellConfig.size,\n        height: basicCellConfig.size,\n        strokeWidth: basicCellConfig.border,\n        stroke: 'black',\n        fill: 'white',\n        name: `fillnode-${xIndex}-${yIndex}`,\n        draggable: false,\n      }\n      let rect = new Konva.Rect(attrs)\n      cells.push(rect)\n    }\n  }\n  // 把所有格子放进一个组里，方便同时管理\n  Stage.value.createShapesByGroup(PixelRectGroup.value, cells)\n}\n",[112,1379,1380,1396,1421,1433,1463,1491,1503,1524,1542,1547,1552,1557,1567,1577,1599,1608,1613,1632,1643,1647,1652,1657,1668],{"__ignoreMap":110},[115,1381,1382,1384,1387,1389,1392,1394],{"class":117,"line":118},[115,1383,1305],{"class":717},[115,1385,1386],{"class":121}," genPixelBoxCells",[115,1388,761],{"class":717},[115,1390,1391],{"class":489}," () ",[115,1393,1045],{"class":717},[115,1395,629],{"class":489},[115,1397,1398,1401,1404,1407,1410,1412,1415,1418],{"class":117,"line":305},[115,1399,1400],{"class":717},"  let",[115,1402,1403],{"class":489}," { x, y, ",[115,1405,1406],{"class":738},"strokeWidth",[115,1408,1409],{"class":489},": border } ",[115,1411,510],{"class":717},[115,1413,1414],{"class":489}," PixelRect.value?.",[115,1416,1417],{"class":121},"getAttrs",[115,1419,1420],{"class":489},"()\n",[115,1422,1423,1425,1428,1430],{"class":117,"line":405},[115,1424,1400],{"class":717},[115,1426,1427],{"class":489}," cells ",[115,1429,510],{"class":717},[115,1431,1432],{"class":489}," []\n",[115,1434,1435,1438,1440,1443,1446,1448,1450,1453,1455,1458,1461],{"class":117,"line":411},[115,1436,1437],{"class":717},"  for",[115,1439,1081],{"class":489},[115,1441,1442],{"class":717},"let",[115,1444,1445],{"class":489}," xIndex ",[115,1447,510],{"class":717},[115,1449,1066],{"class":129},[115,1451,1452],{"class":489},"; xIndex ",[115,1454,490],{"class":717},[115,1456,1457],{"class":489}," basicCellConfig.xCount; xIndex",[115,1459,1460],{"class":717},"++",[115,1462,855],{"class":489},[115,1464,1465,1468,1470,1472,1475,1477,1479,1482,1484,1487,1489],{"class":117,"line":546},[115,1466,1467],{"class":717},"    for",[115,1469,1081],{"class":489},[115,1471,1442],{"class":717},[115,1473,1474],{"class":489}," yIndex ",[115,1476,510],{"class":717},[115,1478,1066],{"class":129},[115,1480,1481],{"class":489},"; yIndex ",[115,1483,490],{"class":717},[115,1485,1486],{"class":489}," basicCellConfig.yCount; yIndex",[115,1488,1460],{"class":717},[115,1490,855],{"class":489},[115,1492,1493,1496,1499,1501],{"class":117,"line":556},[115,1494,1495],{"class":717},"      let",[115,1497,1498],{"class":489}," attrs ",[115,1500,510],{"class":717},[115,1502,629],{"class":489},[115,1504,1505,1508,1510,1513,1515,1518,1521],{"class":117,"line":566},[115,1506,1507],{"class":489},"        x: x ",[115,1509,1119],{"class":717},[115,1511,1512],{"class":489}," border ",[115,1514,1119],{"class":717},[115,1516,1517],{"class":489}," basicCellConfig.size ",[115,1519,1520],{"class":717},"*",[115,1522,1523],{"class":489}," xIndex,\n",[115,1525,1526,1529,1531,1533,1535,1537,1539],{"class":117,"line":572},[115,1527,1528],{"class":489},"        y: y ",[115,1530,1119],{"class":717},[115,1532,1512],{"class":489},[115,1534,1119],{"class":717},[115,1536,1517],{"class":489},[115,1538,1520],{"class":717},[115,1540,1541],{"class":489}," yIndex,\n",[115,1543,1544],{"class":117,"line":582},[115,1545,1546],{"class":489},"        width: basicCellConfig.size,\n",[115,1548,1549],{"class":117,"line":815},[115,1550,1551],{"class":489},"        height: basicCellConfig.size,\n",[115,1553,1554],{"class":117,"line":821},[115,1555,1556],{"class":489},"        strokeWidth: basicCellConfig.border,\n",[115,1558,1559,1562,1565],{"class":117,"line":827},[115,1560,1561],{"class":489},"        stroke: ",[115,1563,1564],{"class":125},"'black'",[115,1566,748],{"class":489},[115,1568,1569,1572,1575],{"class":117,"line":833},[115,1570,1571],{"class":489},"        fill: ",[115,1573,1574],{"class":125},"'white'",[115,1576,748],{"class":489},[115,1578,1579,1582,1585,1588,1591,1594,1597],{"class":117,"line":839},[115,1580,1581],{"class":489},"        name: ",[115,1583,1584],{"class":125},"`fillnode-${",[115,1586,1587],{"class":489},"xIndex",[115,1589,1590],{"class":125},"}-${",[115,1592,1593],{"class":489},"yIndex",[115,1595,1596],{"class":125},"}`",[115,1598,748],{"class":489},[115,1600,1601,1604,1606],{"class":117,"line":858},[115,1602,1603],{"class":489},"        draggable: ",[115,1605,800],{"class":129},[115,1607,748],{"class":489},[115,1609,1610],{"class":117,"line":903},[115,1611,1612],{"class":489},"      }\n",[115,1614,1615,1617,1620,1622,1624,1626,1629],{"class":117,"line":909},[115,1616,1495],{"class":717},[115,1618,1619],{"class":489}," rect ",[115,1621,510],{"class":717},[115,1623,920],{"class":717},[115,1625,923],{"class":489},[115,1627,1628],{"class":121},"Rect",[115,1630,1631],{"class":489},"(attrs)\n",[115,1633,1634,1637,1640],{"class":117,"line":932},[115,1635,1636],{"class":489},"      cells.",[115,1638,1639],{"class":121},"push",[115,1641,1642],{"class":489},"(rect)\n",[115,1644,1645],{"class":117,"line":943},[115,1646,645],{"class":489},[115,1648,1649],{"class":117,"line":954},[115,1650,1651],{"class":489},"  }\n",[115,1653,1654],{"class":117,"line":965},[115,1655,1656],{"class":301},"  // 把所有格子放进一个组里，方便同时管理\n",[115,1658,1659,1662,1665],{"class":117,"line":976},[115,1660,1661],{"class":489},"  Stage.value.",[115,1663,1664],{"class":121},"createShapesByGroup",[115,1666,1667],{"class":489},"(PixelRectGroup.value, cells)\n",[115,1669,1670],{"class":117,"line":982},[115,1671,414],{"class":489},[15,1673,1674,1675],{},"此时，页面如下：\n",[340,1676],{"alt":110,"src":1677},"https://img.zzao.club/article/202411191440542.png",[11,1679,1681],{"id":1680},"鼠标模式鼠标事件键盘事件","鼠标模式、鼠标事件、键盘事件",[15,1683,1684],{},"监听鼠标事件，然后实现涂色功能，先默认一个颜色，把功能做出来，然后再实现切换颜色的功能",[15,1686,1687],{},"在AppStage.ts里实现监听和上色功能",[105,1689,1691],{"className":708,"code":1690,"language":710,"meta":110,"style":110},"listenAndFillRect() {\n    // 监听前，需要设置一个绘图对象，涂色时只对这个对象有效\n    if (!this.drawTaget) {\n      console.error(`未设置绘图对象 drawTarget : this.drawTarget(target:any)`);\n      return;\n    }\n\n    this.drawTaget.on(\"mousedown\", (e: any) => {\n    // 如果不是自己的模式，就不执行\n      if (this.mouseMode !== \"fill\") {\n        this.drawTaget.off('mousedown')\n        return;\n      };\n      // 绘图时禁止冒泡, 防止拖拽\n      e.cancelBubble = true;\n      this.fillStatus = \"filling\";\n      e.target.fill(this.fillConfig?.color);\n\n      this.drawTaget.on(\"mousemove\", (e: any) => {\n        if (this.fillStatus === \"filling\") {\n          e.target.fill(this.fillConfig?.color);\n        }\n      });\n\n      this.drawTaget.on(\"mouseup\", () => {\n        this.drawTaget.off(\"mousemove\");\n        this.drawTaget.off(\"mouseup\");\n        this.fillStatus = \"done\";\n      });\n    });\n  }\n",[112,1692,1693,1701,1706,1720,1736,1743,1747,1753,1782,1787,1805,1822,1829,1834,1839,1851,1866,1881,1885,1912,1930,1943,1947,1952,1956,1976,1990,2005,2019,2024,2030],{"__ignoreMap":110},[115,1694,1695,1698],{"class":117,"line":118},[115,1696,1697],{"class":121},"listenAndFillRect",[115,1699,1700],{"class":489},"() {\n",[115,1702,1703],{"class":117,"line":305},[115,1704,1705],{"class":301},"    // 监听前，需要设置一个绘图对象，涂色时只对这个对象有效\n",[115,1707,1708,1710,1712,1715,1717],{"class":117,"line":405},[115,1709,1057],{"class":717},[115,1711,1081],{"class":489},[115,1713,1714],{"class":717},"!",[115,1716,872],{"class":129},[115,1718,1719],{"class":489},".drawTaget) {\n",[115,1721,1722,1725,1728,1730,1733],{"class":117,"line":411},[115,1723,1724],{"class":489},"      console.",[115,1726,1727],{"class":121},"error",[115,1729,845],{"class":489},[115,1731,1732],{"class":125},"`未设置绘图对象 drawTarget : this.drawTarget(target:any)`",[115,1734,1735],{"class":489},");\n",[115,1737,1738,1741],{"class":117,"line":546},[115,1739,1740],{"class":717},"      return",[115,1742,1225],{"class":489},[115,1744,1745],{"class":117,"line":556},[115,1746,645],{"class":489},[115,1748,1749],{"class":117,"line":566},[115,1750,1752],{"emptyLinePlaceholder":1751},true,"\n",[115,1754,1755,1757,1760,1762,1764,1767,1769,1771,1773,1776,1778,1780],{"class":117,"line":572},[115,1756,1263],{"class":129},[115,1758,1759],{"class":489},".drawTaget.",[115,1761,1028],{"class":121},[115,1763,845],{"class":489},[115,1765,1766],{"class":125},"\"mousedown\"",[115,1768,1036],{"class":489},[115,1770,1039],{"class":738},[115,1772,742],{"class":717},[115,1774,1775],{"class":129}," any",[115,1777,1042],{"class":489},[115,1779,1045],{"class":717},[115,1781,629],{"class":489},[115,1783,1784],{"class":117,"line":582},[115,1785,1786],{"class":301},"    // 如果不是自己的模式，就不执行\n",[115,1788,1789,1792,1794,1796,1798,1801,1803],{"class":117,"line":815},[115,1790,1791],{"class":717},"      if",[115,1793,1081],{"class":489},[115,1795,872],{"class":129},[115,1797,1266],{"class":489},[115,1799,1800],{"class":717},"!==",[115,1802,1222],{"class":125},[115,1804,855],{"class":489},[115,1806,1807,1809,1811,1814,1816,1819],{"class":117,"line":821},[115,1808,912],{"class":129},[115,1810,1759],{"class":489},[115,1812,1813],{"class":121},"off",[115,1815,845],{"class":489},[115,1817,1818],{"class":125},"'mousedown'",[115,1820,1821],{"class":489},")\n",[115,1823,1824,1827],{"class":117,"line":827},[115,1825,1826],{"class":717},"        return",[115,1828,1225],{"class":489},[115,1830,1831],{"class":117,"line":833},[115,1832,1833],{"class":489},"      };\n",[115,1835,1836],{"class":117,"line":839},[115,1837,1838],{"class":301},"      // 绘图时禁止冒泡, 防止拖拽\n",[115,1840,1841,1844,1846,1849],{"class":117,"line":858},[115,1842,1843],{"class":489},"      e.cancelBubble ",[115,1845,510],{"class":717},[115,1847,1848],{"class":129}," true",[115,1850,1225],{"class":489},[115,1852,1853,1856,1859,1861,1864],{"class":117,"line":903},[115,1854,1855],{"class":129},"      this",[115,1857,1858],{"class":489},".fillStatus ",[115,1860,510],{"class":717},[115,1862,1863],{"class":125}," \"filling\"",[115,1865,1225],{"class":489},[115,1867,1868,1871,1874,1876,1878],{"class":117,"line":909},[115,1869,1870],{"class":489},"      e.target.",[115,1872,1873],{"class":121},"fill",[115,1875,845],{"class":489},[115,1877,872],{"class":129},[115,1879,1880],{"class":489},".fillConfig?.color);\n",[115,1882,1883],{"class":117,"line":932},[115,1884,1752],{"emptyLinePlaceholder":1751},[115,1886,1887,1889,1891,1893,1895,1898,1900,1902,1904,1906,1908,1910],{"class":117,"line":943},[115,1888,1855],{"class":129},[115,1890,1759],{"class":489},[115,1892,1028],{"class":121},[115,1894,845],{"class":489},[115,1896,1897],{"class":125},"\"mousemove\"",[115,1899,1036],{"class":489},[115,1901,1039],{"class":738},[115,1903,742],{"class":717},[115,1905,1775],{"class":129},[115,1907,1042],{"class":489},[115,1909,1045],{"class":717},[115,1911,629],{"class":489},[115,1913,1914,1917,1919,1921,1923,1926,1928],{"class":117,"line":954},[115,1915,1916],{"class":717},"        if",[115,1918,1081],{"class":489},[115,1920,872],{"class":129},[115,1922,1858],{"class":489},[115,1924,1925],{"class":717},"===",[115,1927,1863],{"class":125},[115,1929,855],{"class":489},[115,1931,1932,1935,1937,1939,1941],{"class":117,"line":965},[115,1933,1934],{"class":489},"          e.target.",[115,1936,1873],{"class":121},[115,1938,845],{"class":489},[115,1940,872],{"class":129},[115,1942,1880],{"class":489},[115,1944,1945],{"class":117,"line":976},[115,1946,818],{"class":489},[115,1948,1949],{"class":117,"line":982},[115,1950,1951],{"class":489},"      });\n",[115,1953,1954],{"class":117,"line":988},[115,1955,1752],{"emptyLinePlaceholder":1751},[115,1957,1958,1960,1962,1964,1966,1969,1972,1974],{"class":117,"line":993},[115,1959,1855],{"class":129},[115,1961,1759],{"class":489},[115,1963,1028],{"class":121},[115,1965,845],{"class":489},[115,1967,1968],{"class":125},"\"mouseup\"",[115,1970,1971],{"class":489},", () ",[115,1973,1045],{"class":717},[115,1975,629],{"class":489},[115,1977,1978,1980,1982,1984,1986,1988],{"class":117,"line":998},[115,1979,912],{"class":129},[115,1981,1759],{"class":489},[115,1983,1813],{"class":121},[115,1985,845],{"class":489},[115,1987,1897],{"class":125},[115,1989,1735],{"class":489},[115,1991,1993,1995,1997,1999,2001,2003],{"class":117,"line":1992},27,[115,1994,912],{"class":129},[115,1996,1759],{"class":489},[115,1998,1813],{"class":121},[115,2000,845],{"class":489},[115,2002,1968],{"class":125},[115,2004,1735],{"class":489},[115,2006,2008,2010,2012,2014,2017],{"class":117,"line":2007},28,[115,2009,912],{"class":129},[115,2011,1858],{"class":489},[115,2013,510],{"class":717},[115,2015,2016],{"class":125}," \"done\"",[115,2018,1225],{"class":489},[115,2020,2022],{"class":117,"line":2021},29,[115,2023,1951],{"class":489},[115,2025,2027],{"class":117,"line":2026},30,[115,2028,2029],{"class":489},"    });\n",[115,2031,2033],{"class":117,"line":2032},31,[115,2034,1651],{"class":489},[15,2036,2037],{},"在组件内实现切换模式功能，这个功能同样封装在AppStage里，两种模式切换的时候对应两套鼠标动作，所以还需要一个listenAndAssignTask功能，来switch case一下模式，对应不同的操作逻辑",[105,2039,2041],{"className":708,"code":2040,"language":710,"meta":110,"style":110},"const changeMode = () => {\n  Stage.value.switchMouseMode(mode.value)\n  Stage.value.listenAndAssignTask()\n}\n",[112,2042,2043,2058,2067,2076],{"__ignoreMap":110},[115,2044,2045,2047,2050,2052,2054,2056],{"class":117,"line":118},[115,2046,1305],{"class":717},[115,2048,2049],{"class":121}," changeMode",[115,2051,761],{"class":717},[115,2053,1391],{"class":489},[115,2055,1045],{"class":717},[115,2057,629],{"class":489},[115,2059,2060,2062,2064],{"class":117,"line":305},[115,2061,1661],{"class":489},[115,2063,1255],{"class":121},[115,2065,2066],{"class":489},"(mode.value)\n",[115,2068,2069,2071,2074],{"class":117,"line":405},[115,2070,1661],{"class":489},[115,2072,2073],{"class":121},"listenAndAssignTask",[115,2075,1420],{"class":489},[115,2077,2078],{"class":117,"line":411},[115,2079,414],{"class":489},[15,2081,2082,2083],{},"再加一个切换颜色的功能，写几个div，背景色就是配置的颜色，横向排列，因为我们这里是方格，所以展示颜色的div也显示成方格，如下图\n",[340,2084],{"alt":110,"src":2085},"https://img.zzao.club/article/202411191440543.png",[15,2087,2088,2089,2092],{},"这个切换颜色的小组件，还可以继续优化一下，做一个动画，点击了谁就跳到第一个位置上。这里",[288,2090,2091],{},"先记下，后续再做","，继续推进功能，避免中途加太多东西，导致延期。",[15,2094,2095,2096,2099],{},"鼠标一个一个的点，明显不方便，我再加一个",[288,2097,2098],{},"tab键切换颜色","的功能",[15,2101,2102],{},"StoreTest.vue",[105,2104,2106],{"className":708,"code":2105,"language":710,"meta":110,"style":110},"const bindKeyboardEvent = () => {\n    window.addEventListener(\"keydown\", (e) => {\n      if (e.code === \"Tab\") {\n        let index = colorConfig.value.findIndex(color => color === selectColor.value)\n        index = index >= colorConfig.value.length - 1 ? 0 : index+1\n        changeColor(colorConfig.value[index])\n      }\n      e.preventDefault();\n    });\n}\n",[112,2107,2108,2123,2146,2160,2192,2231,2239,2243,2254,2258],{"__ignoreMap":110},[115,2109,2110,2112,2115,2117,2119,2121],{"class":117,"line":118},[115,2111,1305],{"class":717},[115,2113,2114],{"class":121}," bindKeyboardEvent",[115,2116,761],{"class":717},[115,2118,1391],{"class":489},[115,2120,1045],{"class":717},[115,2122,629],{"class":489},[115,2124,2125,2128,2131,2133,2136,2138,2140,2142,2144],{"class":117,"line":305},[115,2126,2127],{"class":489},"    window.",[115,2129,2130],{"class":121},"addEventListener",[115,2132,845],{"class":489},[115,2134,2135],{"class":125},"\"keydown\"",[115,2137,1036],{"class":489},[115,2139,1039],{"class":738},[115,2141,1042],{"class":489},[115,2143,1045],{"class":717},[115,2145,629],{"class":489},[115,2147,2148,2150,2153,2155,2158],{"class":117,"line":405},[115,2149,1791],{"class":717},[115,2151,2152],{"class":489}," (e.code ",[115,2154,1925],{"class":717},[115,2156,2157],{"class":125}," \"Tab\"",[115,2159,855],{"class":489},[115,2161,2162,2165,2168,2170,2173,2176,2178,2181,2184,2187,2189],{"class":117,"line":411},[115,2163,2164],{"class":717},"        let",[115,2166,2167],{"class":489}," index ",[115,2169,510],{"class":717},[115,2171,2172],{"class":489}," colorConfig.value.",[115,2174,2175],{"class":121},"findIndex",[115,2177,845],{"class":489},[115,2179,2180],{"class":738},"color",[115,2182,2183],{"class":717}," =>",[115,2185,2186],{"class":489}," color ",[115,2188,1925],{"class":717},[115,2190,2191],{"class":489}," selectColor.value)\n",[115,2193,2194,2197,2199,2201,2204,2206,2209,2212,2215,2218,2220,2223,2226,2228],{"class":117,"line":546},[115,2195,2196],{"class":489},"        index ",[115,2198,510],{"class":717},[115,2200,2167],{"class":489},[115,2202,2203],{"class":717},">=",[115,2205,2172],{"class":489},[115,2207,2208],{"class":129},"length",[115,2210,2211],{"class":717}," -",[115,2213,2214],{"class":129}," 1",[115,2216,2217],{"class":717}," ?",[115,2219,1066],{"class":129},[115,2221,2222],{"class":717}," :",[115,2224,2225],{"class":489}," index",[115,2227,1119],{"class":717},[115,2229,2230],{"class":129},"1\n",[115,2232,2233,2236],{"class":117,"line":556},[115,2234,2235],{"class":121},"        changeColor",[115,2237,2238],{"class":489},"(colorConfig.value[index])\n",[115,2240,2241],{"class":117,"line":566},[115,2242,1612],{"class":489},[115,2244,2245,2248,2251],{"class":117,"line":572},[115,2246,2247],{"class":489},"      e.",[115,2249,2250],{"class":121},"preventDefault",[115,2252,2253],{"class":489},"();\n",[115,2255,2256],{"class":117,"line":582},[115,2257,2029],{"class":489},[115,2259,2260],{"class":117,"line":815},[115,2261,414],{"class":489},[15,2263,2264],{},"目前，页面已经实现了以下效果",[15,2266,2267],{},[340,2268],{"alt":110,"src":2269},"https://img.zzao.club/article/202411191440544.png",[15,2271,2272],{},"最后一步，实现导出图片功能",[11,2274,2275],{"id":2275},"导出功能",[15,2277,2278],{},"Konva内置了toDataURL功能，可以自定义导出的区域",[105,2280,2282],{"className":708,"code":2281,"language":710,"meta":110,"style":110}," // 转dataURL 用于导出\n  toDataURL(options = {}) {\n    return this.stage.toDataURL(options);\n  }\n",[112,2283,2284,2289,2302,2318],{"__ignoreMap":110},[115,2285,2286],{"class":117,"line":118},[115,2287,2288],{"class":301}," // 转dataURL 用于导出\n",[115,2290,2291,2294,2297,2299],{"class":117,"line":305},[115,2292,2293],{"class":121},"  toDataURL",[115,2295,2296],{"class":489},"(options ",[115,2298,510],{"class":717},[115,2300,2301],{"class":489}," {}) {\n",[115,2303,2304,2307,2310,2312,2315],{"class":117,"line":405},[115,2305,2306],{"class":717},"    return",[115,2308,2309],{"class":129}," this",[115,2311,1025],{"class":489},[115,2313,2314],{"class":121},"toDataURL",[115,2316,2317],{"class":489},"(options);\n",[115,2319,2320],{"class":117,"line":411},[115,2321,1651],{"class":489},[15,2323,2324],{},"再封装一个用a标签下载图片的功能",[105,2326,2328],{"className":708,"code":2327,"language":710,"meta":110,"style":110},"export function downloadPNGForCanvas(\n  dataURL: string,\n  filename: string = (+new Date()).toString(),\n) {\n  const a = document.createElement('a')\n  a.download = filename\n  a.href = dataURL\n  document.body.appendChild(a)\n  a.click()\n  a.remove()\n}\n",[112,2329,2330,2342,2354,2382,2386,2409,2419,2429,2440,2450,2459],{"__ignoreMap":110},[115,2331,2332,2334,2337,2340],{"class":117,"line":118},[115,2333,718],{"class":717},[115,2335,2336],{"class":717}," function",[115,2338,2339],{"class":121}," downloadPNGForCanvas",[115,2341,733],{"class":489},[115,2343,2344,2347,2349,2352],{"class":117,"line":305},[115,2345,2346],{"class":738},"  dataURL",[115,2348,742],{"class":717},[115,2350,2351],{"class":129}," string",[115,2353,748],{"class":489},[115,2355,2356,2359,2361,2363,2365,2367,2370,2373,2376,2379],{"class":117,"line":405},[115,2357,2358],{"class":738},"  filename",[115,2360,742],{"class":717},[115,2362,2351],{"class":129},[115,2364,761],{"class":717},[115,2366,1081],{"class":489},[115,2368,2369],{"class":717},"+new",[115,2371,2372],{"class":121}," Date",[115,2374,2375],{"class":489},"()).",[115,2377,2378],{"class":121},"toString",[115,2380,2381],{"class":489},"(),\n",[115,2383,2384],{"class":117,"line":411},[115,2385,855],{"class":489},[115,2387,2388,2391,2394,2396,2399,2402,2404,2407],{"class":117,"line":546},[115,2389,2390],{"class":717},"  const",[115,2392,2393],{"class":129}," a",[115,2395,761],{"class":717},[115,2397,2398],{"class":489}," document.",[115,2400,2401],{"class":121},"createElement",[115,2403,845],{"class":489},[115,2405,2406],{"class":125},"'a'",[115,2408,1821],{"class":489},[115,2410,2411,2414,2416],{"class":117,"line":556},[115,2412,2413],{"class":489},"  a.download ",[115,2415,510],{"class":717},[115,2417,2418],{"class":489}," filename\n",[115,2420,2421,2424,2426],{"class":117,"line":566},[115,2422,2423],{"class":489},"  a.href ",[115,2425,510],{"class":717},[115,2427,2428],{"class":489}," dataURL\n",[115,2430,2431,2434,2437],{"class":117,"line":572},[115,2432,2433],{"class":489},"  document.body.",[115,2435,2436],{"class":121},"appendChild",[115,2438,2439],{"class":489},"(a)\n",[115,2441,2442,2445,2448],{"class":117,"line":582},[115,2443,2444],{"class":489},"  a.",[115,2446,2447],{"class":121},"click",[115,2449,1420],{"class":489},[115,2451,2452,2454,2457],{"class":117,"line":815},[115,2453,2444],{"class":489},[115,2455,2456],{"class":121},"remove",[115,2458,1420],{"class":489},[115,2460,2461],{"class":117,"line":821},[115,2462,414],{"class":489},[15,2464,2465],{},"ok, 可以实现导出功能了。\n创建方格时，我给他们套了一个Rect，方便获取导出时width、height，xy坐标是Group的坐标。",[105,2467,2469],{"className":708,"code":2468,"language":710,"meta":110,"style":110},"const exportImage = () => {\n  // 缩放回原始大小\n  PixelRectGroup.value.scaleX(1)\n  PixelRectGroup.value.scaleY(1)\n  Stage.value.batchDraw()\n  nextTick(() => {\n    // 获取位置\n    let { x, y } = PixelRectGroup.value.absolutePosition()\n    let { width, height } = PixelRect.value.getAttrs()\n    let dataURL = Stage.value.toDataURL({\n      x,\n      y,\n      width,\n      height,\n      pixelRatio: window.devicePixelRatio, \n    })\n    downloadPNGForCanvas(dataURL, '测试')\n  })\n}\n",[112,2470,2471,2486,2491,2504,2516,2525,2537,2542,2560,2576,2592,2597,2602,2607,2612,2617,2622,2635,2640],{"__ignoreMap":110},[115,2472,2473,2475,2478,2480,2482,2484],{"class":117,"line":118},[115,2474,1305],{"class":717},[115,2476,2477],{"class":121}," exportImage",[115,2479,761],{"class":717},[115,2481,1391],{"class":489},[115,2483,1045],{"class":717},[115,2485,629],{"class":489},[115,2487,2488],{"class":117,"line":305},[115,2489,2490],{"class":301},"  // 缩放回原始大小\n",[115,2492,2493,2496,2498,2500,2502],{"class":117,"line":405},[115,2494,2495],{"class":489},"  PixelRectGroup.value.",[115,2497,1089],{"class":121},[115,2499,845],{"class":489},[115,2501,1337],{"class":129},[115,2503,1821],{"class":489},[115,2505,2506,2508,2510,2512,2514],{"class":117,"line":411},[115,2507,2495],{"class":489},[115,2509,1131],{"class":121},[115,2511,845],{"class":489},[115,2513,1337],{"class":129},[115,2515,1821],{"class":489},[115,2517,2518,2520,2523],{"class":117,"line":546},[115,2519,1661],{"class":489},[115,2521,2522],{"class":121},"batchDraw",[115,2524,1420],{"class":489},[115,2526,2527,2530,2533,2535],{"class":117,"line":556},[115,2528,2529],{"class":121},"  nextTick",[115,2531,2532],{"class":489},"(() ",[115,2534,1045],{"class":717},[115,2536,629],{"class":489},[115,2538,2539],{"class":117,"line":566},[115,2540,2541],{"class":301},"    // 获取位置\n",[115,2543,2544,2547,2550,2552,2555,2558],{"class":117,"line":572},[115,2545,2546],{"class":717},"    let",[115,2548,2549],{"class":489}," { x, y } ",[115,2551,510],{"class":717},[115,2553,2554],{"class":489}," PixelRectGroup.value.",[115,2556,2557],{"class":121},"absolutePosition",[115,2559,1420],{"class":489},[115,2561,2562,2564,2567,2569,2572,2574],{"class":117,"line":582},[115,2563,2546],{"class":717},[115,2565,2566],{"class":489}," { width, height } ",[115,2568,510],{"class":717},[115,2570,2571],{"class":489}," PixelRect.value.",[115,2573,1417],{"class":121},[115,2575,1420],{"class":489},[115,2577,2578,2580,2583,2585,2588,2590],{"class":117,"line":815},[115,2579,2546],{"class":717},[115,2581,2582],{"class":489}," dataURL ",[115,2584,510],{"class":717},[115,2586,2587],{"class":489}," Stage.value.",[115,2589,2314],{"class":121},[115,2591,929],{"class":489},[115,2593,2594],{"class":117,"line":821},[115,2595,2596],{"class":489},"      x,\n",[115,2598,2599],{"class":117,"line":827},[115,2600,2601],{"class":489},"      y,\n",[115,2603,2604],{"class":117,"line":833},[115,2605,2606],{"class":489},"      width,\n",[115,2608,2609],{"class":117,"line":839},[115,2610,2611],{"class":489},"      height,\n",[115,2613,2614],{"class":117,"line":858},[115,2615,2616],{"class":489},"      pixelRatio: window.devicePixelRatio, \n",[115,2618,2619],{"class":117,"line":903},[115,2620,2621],{"class":489},"    })\n",[115,2623,2624,2627,2630,2633],{"class":117,"line":909},[115,2625,2626],{"class":121},"    downloadPNGForCanvas",[115,2628,2629],{"class":489},"(dataURL, ",[115,2631,2632],{"class":125},"'测试'",[115,2634,1821],{"class":489},[115,2636,2637],{"class":117,"line":932},[115,2638,2639],{"class":489},"  })\n",[115,2641,2642],{"class":117,"line":943},[115,2643,414],{"class":489},[15,2645,2646,2647,2650,2651,2654],{},"最后用完成的功能画一个图试试看。\n很明显啊，这是个",[288,2648,2649],{},"掘金","的标志，但是略微有些抽象，如果格子多一些后会好很多。\n不过用手画还是略微有点慢呀，看来V2版本的",[288,2652,2653],{},"导入图片自动绘制像素风","要提上日程啦。",[15,2656,2657],{},[340,2658],{"alt":110,"src":2659},"https://img.zzao.club/article/202411191440545.png",[11,2661,2662],{"id":2662},"版本迭代",[15,2664,2665,2666,2669,2670,2675],{},"Pixeled Pic Pro的",[288,2667,2668],{},"V1版本","已经按照自己规划的内容完成了。本篇的代码，我会尽快更新到",[19,2671,2674],{"href":2672,"rel":2673},"https://github.com/zzdaddy/PixeledPicPro",[23],"Github","中。这次的版本定为v0.6.0，我会打一个tag标记。",[15,2677,2678,2679,2682,2683,2686,2687,2690,2691,2694],{},"后续有优化、功能迭代的话我都会按照文章所述的版本命名方式继续在",[19,2680,2674],{"href":2672,"rel":2681},[23],"中更新，感兴趣的朋友可以去",[19,2684,2674],{"href":2672,"rel":2685},[23]," 拉代码玩一玩，考虑到这个产品本体功能可能对大部分同学都不适用，所以大家仅参考我的开发过程和思考方式即可，前端或后端的",[288,2688,2689],{},"V2版本","，我会在整个产品的V1版本闭环后再",[288,2692,2693],{},"酌情更新","。",[15,2696,2697],{},"后续文章为： Nest实战篇、前端+后端部署篇、总结篇。 总计五篇文章。我会收录在我的专栏/分类里方便大家查阅。",[11,2699,2700],{"id":2700},"小结",[15,2702,2703],{},"下面是本次项目的总结：",[72,2705,2706,2709,2712,2715,2718,2721],{},[45,2707,2708],{},"合理的拆分需求，分阶段完成自己的目标",[45,2710,2711],{},"用一个现成的模版，快速启动前端项目，只要看到了成果，就对自己有正反馈。",[45,2713,2714],{},"文档没必要吃透，边做边查也很快，取自己所需即可",[45,2716,2717],{},"按拆分后列好的功能去逐步去实现，按自己的习惯、喜好去划分版本",[45,2719,2720],{},"碰到可复用的代码，先拆出来",[45,2722,2723],{},"收尾后及时总结归纳，加深印象",[15,2725,2726,2727,2730],{},"以上就是本篇全部的内容，文中有",[288,2728,2729],{},"不正确、不清晰","的地方，欢迎在评论区指出，我会尽快更正（不优雅不算）。",[15,2732,2733],{},"如果对大家能有一点点帮助，这将是我继续写下去的最大动力。",[15,2735,2736],{},"喜欢的朋友可以点个关注，继续追更后续的更多内容。有任何前端问题想要咨询的同学，也欢迎加VX：zzdaddy7，我会尽力为你解答。",[15,2738,2739],{},"感谢你的阅读，我是枣把儿~",[15,2741,2742],{},"（丑陋的彩蛋...）",[15,2744,2745],{},[340,2746],{"alt":110,"src":2747},"https://img.zzao.club/article/202411191440546.png",[2749,2750,2751],"style",{},"html pre.shiki code .s7eDp, html code.shiki .s7eDp{--shiki-default:#6F42C1}html pre.shiki code .sYBdl, html code.shiki .sYBdl{--shiki-default:#032F62}html pre.shiki code .sYu0t, html code.shiki .sYu0t{--shiki-default:#005CC5}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sgsFI, html code.shiki .sgsFI{--shiki-default:#24292E}html pre.shiki code .shJU0, html code.shiki .shJU0{--shiki-default:#22863A}html pre.shiki code .sB1qb, html code.shiki .sB1qb{--shiki-default:#B31D28;--shiki-default-font-style:italic}html pre.shiki code .sD7c4, html code.shiki .sD7c4{--shiki-default:#D73A49}html pre.shiki code .sqxcx, html code.shiki .sqxcx{--shiki-default:#E36209}",{"title":110,"searchDepth":305,"depth":305,"links":2753},[2754,2755,2756,2757,2758,2759,2760,2761,2762,2763],{"id":13,"depth":305,"text":13},{"id":34,"depth":305,"text":34},{"id":88,"depth":305,"text":88},{"id":359,"depth":305,"text":359},{"id":651,"depth":305,"text":651},{"id":1289,"depth":305,"text":1289},{"id":1680,"depth":305,"text":1681},{"id":2275,"depth":305,"text":2275},{"id":2662,"depth":305,"text":2662},{"id":2700,"depth":305,"text":2700},"2023-12-11T00:00:00.000Z","一个基于 LeaferUI 的像素风编辑器","md",null,"2025-08-19T00:00:00.000Z",{"published":2770,"category":2771},"2023-12-11 00:00:00","早早集市","/post/pixel/vue3-logo-creator-ppp","---\npublished: 2023-12-11 00:00:00\nauthor: 枣把儿\ntitle: Vue3项目实战：像素风LOGO编辑器 Pixeled Pic Pro\ndescription: 一个基于 LeaferUI 的像素风编辑器\ncategory: 早早集市\ntags: [\"产品\", \"早早集市\", \"Vue3\"]\ndate: 2023-12-11\nlastmod: 2025-08-19\nshowTitle: Vue3项目实战：像素风LOGO编辑器 Pixeled Pic Pro\n---\n## 引言\n\n上篇文章《[当一个程序员突然想做一款产品](https://juejin.cn/post/7310786618391167013)》说到，我在做自己的产品的过程中，萌生了自己做一个像素风LOGO编辑器的想法，并设计了产品的整体功能、技术栈选择、服务器部署全部流程。\n\n出于一个开发人员本能的对产品排期的排斥，我非常想向身为产品的自己提出一点时间上的吐槽。\n\n但是我很快就忍住了，因为我想到xw了更好的处理办法。\n\n## 版本划分\n\n产品的核心功能是：生成任意行和列的方格子，按自己的喜好进行涂抹上色，创作完自己想要的图片后，导出图片即可。\n\n所以我把核心功能定为V1版本：\n\n1.  一个可拖动的画布，一个可绘制的区域，区域内由带或不带黑色边框的单元格组成，并且可缩放可拖动。\n2.  单元格的形态为正方形格子。可以设置数量，如：100x100，单个方格可以设置大小，如格尺寸为：5x5\n3.  操作模式分为两种： 1. 鼠标模式 2. 绘图模式\n4.  鼠标模式下，可以自由拖拽\n5.  绘图模式下，鼠标可在绘图区域的小方格上点击并滑动，小方格会被设置对应的颜色\n    1.  颜色可配置，并且方便切换：比如tab键自动轮换预制的几种颜色\n6.  绘制后，图片可导出\n\n我打算这样制定版本号规则：x.y.z\n\n*   z：通常在未上线阶段或上线后小范围迭代使用，如样式修改、代码优化等不涉及功能的改动后，此版本号加1。\n*   y：通常为新增功能，bug修复等功能的迭代版本，如我这次开发的产品，我打算以v0.1.0开始，在每完成一个上述的独立功能123456后，此版本号加1，直到产品闭环。\n*   x：通常为一个产品的稳定版本，此时产品的功能已经完成闭环，与上一个版本比，在功能、操作、逻辑上有重大更新或修复致命bug时，此版本号加1\n\n这样，我就可以在有限的时间内推进项目的正常开发工作，让大家看到我这段时间的成果，后续还可以掏出时间来持续的迭代产品。显得有条不紊，尽显老鸟风范，皆大欢喜。\n\n## 项目搭建\n\n首先安装[degit](https://github.com/Rich-Harris/degit)这个工具，它可以用来安装[社区模版](https://github.com/vitejs/awesome-vite#templates)里的优秀模版，以此快速启动我们的项目\n\n```shell\nnpm install -g degit\n```\n\n在社区模版里挑选过后，我选择了[boot-vue](https://github.com/kirklin/boot-vue/blob/master/README.zh-CN.md)。作者这样说：\n\n*   ⚡ [闪电般的速度](https://github.com/kirklin/boot-vue#readme)：使用 Vue 3、Vite 和 pnpm 构建，速度飞快 🔥\n*   💪 [强类型](https://www.typescriptlang.org/)：使用 TypeScript 💻\n*   🔥 [最新语法](https://github.com/vuejs/rfcs/pull/227)：使用新的 script setup 语法 🆕\n*   📦 [自动导入组件](https://chat.openai.com/chat/src/components)：自动导入组件 🚚\n*   📥 [自动导入 API](https://github.com/antfu/unplugin-auto-import)：使用 unplugin-auto-import 直接导入 Composition API 和其他 API 📨\n*   🎨 [UnoCSS](https://unocss.dev/) - 瞬间响应式 CSS 引擎，提供轻量级和快速的样式应用方式。\n*   🌼 [Daisy](https://daisyui.com/) - 免费开源的 Tailwind CSS 组件库\n*   💡 [官方路由器](https://router.vuejs.org/)：使用 Vue Router v4 🛣️\n*   🎉 [加载反馈](https://github.com/rstacruz/nprogress)：使用 NProgress 提供页面加载进度反馈 🔄\n*   🍍 [状态管理](https://pinia.esm.dev/)：使用 Pinia 进行状态管理 🗃️\n*   📜 [中文字体预设](https://github.com/kirklin/unocss-preset-chinese)：包含中文字体预设 🇨🇳\n*   🌍 [国际化就绪](https://chat.openai.com/chat/src/locales)：使用本地化准备好国际化 🌎\n*   ☁️ [Netlify 就绪](https://www.netlify.com/)：可在 Netlify 上零配置部署 ☁️\n\n可以看到，它的功能已经非常齐全，并且没有太多多余的业务类代码，拿来作为项目的启动模版是非常香的。\n\n```shell\nnpx degit kirklin/boot-vue pixeled-pic-pro\n```\n\n可以看到一个叫**pixeled-pic-pro**的项目已经被创建，进入项目目录，开始安装依赖\n\n```shell\n# 没有安装pnpm的话，先自行安装一下\npnpm i\n```\n\n安装完成后，运行一下\n\n```shell\npnpm dev\n```\n\n可以看到页面已经运行在了8888端口下，并且自动开启了一个\\_\\_inspect的页面\n\n打开浏览器，发现还有一个切换主题皮肤的功能（[Daisy](https://daisyui.com/)），可以尝试后选择一个比较严肃和正经的皮肤\n\n![](https://img.zzao.club/article/202411191440537.png)\n\n为了以防开发完了才发现别人的库有打包的问题，我们可以先运行一下打包命令，看看是否正常\n\n    pnpm build\n\ngood，没有问题。\n\n## 梳理项目结构\n\n看看这个项目里的东西有没有需要删除或修改的地方\n\n**router**\n\n**路由钩子**里已经结合[NProgress](https://ricostacruz.com/nprogress/)，做了加载的进度反馈，后续如果有其他需要结合路由来判断的状态，也会继续添加在这里。\n\n模式是 Hash模式，是用createWebHashHistory()创建的，可以看到url上会有一个#标志。\n\n如果使用createWebHistory()，则创建的是Html5模式，这种模式的url看起来会比较“正常”，但需要服务器做一些配置工作。\n\n```nginx\n#nginx部分配置\nlocation / {\n  try_files $uri $uri/ /index.html;\n}\n```\n\n**store**\n\n通过pinia进行状态管理，已经有了一个小例子，后续使用的话可以仿照着改造一下。\n\n**pages**\n\n页面部分。 它有两个页面。为了保持简单，我就只用这两个页面，一个写欢迎页，一个写功能页。 标题栏也直接拿来用，不要浪费。\n\n欢迎页比较简单，我准备放一个功能演示的GIF，配一个大大的按钮【开始创作】\n\n把首页里左上角名称改成**Pixeled Pic Pro**和作者相关的Footer注释掉，github地址换成自己的。这部分内容在**layouts**里。\n\n注意：此项目使用的是基于[Tailwind CSS](https://www.tailwindcss.cn/docs/installation) 的UI组件库 [daisyUI](https://daisyui.com/docs/themes/)，而Tailwind CSS是通过[UnoCSS](https://unocss.dev/)来控制引入的，所以开发时需要打开它们的文档，方便查阅。\n\n此时页面长这个样子\n\n![](https://img.zzao.club/article/202411191440539.png)\n\n再稍微调整一下样式，我先设置了一个flex布局，画布页面直接flex-1占满\n\nlayouts/index.vue\n\n```html\n\u003Ctemplate>\n\u003Cdiv class=\"font-chinese antialiased\">\n\t\u003Cdiv class=\"min-h-screen flex flex-col\">\n\t\t\u003CNavbar />\n\t\t\u003CRouterView />\n\t\u003C/div>\n\t\u003C!-- \u003CFooter /> -->\n\u003C/div>\n\u003C/template>\n```\n\n画布页面StoreTest.vue初始状态如下，文件名字可以改一改，但是我不改\n![](https://img.zzao.club/article/202411191440540.png)\n\n还需要一个操作栏，放置一些常用配置和功能\n\n我选择把操作栏放在左侧，并且加入几个功能按钮：生成、导出、颜色配置等，按钮可以边写边加，先按主要功能一步一步的来实现。\n\n设置到了这里，我发现了一个小问题，作者似乎对html使用了scrollbar-gutter：stable这个属性，导致我页面没有滚动条的情况下，在右侧也出现了gutter\n\n![](https://img.zzao.club/article/202411191440541.png)\n\n于是乎我把这个属性覆盖了一下，一切就正常了起来\\~\n\nsrc/styles/main.css\n```css\n html {\n    \tscrollbar-gutter: auto\n    }\n```\n\n有内味儿了吧？接下来开始实现主体功能\n\n## 画布相关功能\n\n安装konva、axios(备用)依赖\n\n```shell\npnpm i konva axios\n```\n\n结合之前多次头脑风暴的经验，canvas的可玩性是非常高的，可以实现**很多**有趣但可能没用的小工具，但这里重点是很多。\n\n很多意味着重复，写代码最忌的就是无脑复制粘贴。\n\n哪怕写代码时的思考逻辑不是最优的，写出来的代码也不是最“优雅”的，还是要保持基本的封装的潜意识。带着一种“如果别人使用我的组件、插件的时候，他们会不会觉得不方便”的想法去思考如何封装更合理。小到一个方法，大到一个组件，都是一样的道理。\n\n但也没必要因为追求“最合理”，陷入到一种情绪负担上，你的大脑开始焦虑的时候，你可以提醒自己，做不好是正常的，所有人都会出现这样的问题，做错的过程也同样有用。就如同我现在这个产品一样，分版本迭代，逐步解决问题。\n\n这里为了在避免后续迭代中产生其他页面，也用到画布的相关操作，我们把整个画布封装成一个类。\n\n如果另一个新的产品也和画布有关，那我们可以进一步把他抽离，发布到npmjs上。就如同这个boot-vue的作者自己内置的@kirklin/logger、@kirklin/reset-css一样。\n\n回到正题。\n\n把这个类随便取一个名字：AppStage，Konva里已经有个Stage的概念了，所以我这里稍微改改。\n回顾一下需要实现的功能，按照思路，罗列出AppStage要实现哪些方法：\n\n* 初始化画布 initStage\n\n```typescript\nexport class AppStage {\n\tconstructor(\n\t\tref: Ref,\n\t\toptions: AppStageConfig = {\n\t\t\tisAllowMouseSelectShapes: true,\n\t\t\tisInitKeyboardEvents: true,\n\t\t\tmouseMode: \"basic\",\n\t\t\tscale: false,\n\t\t\tscaleMember: null,\n\t\t}\n\t)\n\t...\n\t\n\tinit(options: AppStageConfig) {\n\t\tconsole.log(`初始化容器为: ${this.canvasWindow.width} x ${this.canvasWindow.height}`\n\t\t);\n\t\tthis.stage = new Konva.Stage({\n\t\t\tcontainer: this.containerRef,\n\t\t\twidth: this.canvasWindow.width,\n\t\t\theight: this.canvasWindow.height,\n\t\t\tid: \"baseStage\",\n\t\t\tdraggable: options.scale\n\t\t});\n\t}\n\t...\n}\n```\n\n*   设置是否可缩放 set\n*   设置可缩放的对象 (macos上触摸板两指上下滑动等同于鼠标放大缩小)\n\n```typescript\n// Konva的stage可以监听鼠标滚轮事件\nthis.stage.on(\"wheel\", (e) => {\n\t// 通过wheelDelta判断，是在放大还是缩小\n\tif (evt.wheelDelta > 0) {\n          // 放大\n          if (this.scaleMember.scaleX() \u003C max) {\n            this.scaleMember.scaleX(this.scaleMember.scaleX() + step);\n            this.scaleMember.scaleY(this.scaleMember.scaleY() + step);\n            // this.scaleMember.move({ x: -offsetX, y: -offsetY }) // 跟随鼠标偏移位置\n          }\n        } else {\n          // 缩小\n          ...\n})\n```\n\n*   绑定鼠标事件（落下、移动、抬起）（移动过程中，划过的元素进行填充颜色）\n\n```typescript\ntype MouseMode = \"basic\" | \"draw\" | \"clip\" | \"fill\";\n```\n\n*   解绑鼠标事件（有绑定就有解绑）\n*   添加图形\n*   批量添加图形（因为方格要被统一的缩放，并且只有方格可以交互，所以批量添加时需要加入到一个[Group](http://192.241.202.210/docs/groups_and_layers/Groups.html)中, 方便管理）\n*   设置鼠标模式（切换绘图、拖拽模式，类似ps、ai里的操作）\n\n```typescript\nswitchMouseMode(mouseMode: MouseMode) {\n\tthis.mouseMode = mouseMode;\n}\n```\n\n*   查找图形（比如查找没有上色的图形等功能可以用到）\n*   导出图片 （回到缩放前的状态，然后导出）\n\n由于具体的业务代码对大家可能并无卵用，细节代码就不展开了，代码链接放在文末的小结中。\n\n## 生成格子\n\n由于生成12x12这个格子属于这个产品的业务功能，我这里没有选择把他放进类的方法里，选择在自己的组件里实现\n\n单元格配置先给几个默认的预设，方便在不配置的情况下也能正常使用，视频里用Adobe AI来实现的时候使用了十二根线交错，所以我这里先默认给个12x12的方格布局。并且生成的单元格，默认是正方形，不排除后续加入三角形、圆形。所以我先留个口子。\n\n```typescript\nconst basicCellConfig = reactive({\n\tsize: 5, // 单个格子宽高\n\tborder: 1, // 边框宽度\n\txCount: 12, // 横向有几个\n\tyCount: 12, // 纵向有几个\n})\n```\n\n生成12x12单元格方法，这里我们先把所有rect生成好，然后传给AppStage类里按Group生成图形\n\n```typescript\nconst genPixelBoxCells = () => {\n  let { x, y, strokeWidth: border } = PixelRect.value?.getAttrs()\n  let cells = []\n  for (let xIndex = 0; xIndex \u003C basicCellConfig.xCount; xIndex++) {\n    for (let yIndex = 0; yIndex \u003C basicCellConfig.yCount; yIndex++) {\n      let attrs = {\n        x: x + border + basicCellConfig.size * xIndex,\n        y: y + border + basicCellConfig.size * yIndex,\n        width: basicCellConfig.size,\n        height: basicCellConfig.size,\n        strokeWidth: basicCellConfig.border,\n        stroke: 'black',\n        fill: 'white',\n        name: `fillnode-${xIndex}-${yIndex}`,\n        draggable: false,\n      }\n      let rect = new Konva.Rect(attrs)\n      cells.push(rect)\n    }\n  }\n  // 把所有格子放进一个组里，方便同时管理\n  Stage.value.createShapesByGroup(PixelRectGroup.value, cells)\n}\n```\n\n此时，页面如下：\n![](https://img.zzao.club/article/202411191440542.png)\n\n## 鼠标模式、鼠标事件、键盘事件\n\n监听鼠标事件，然后实现涂色功能，先默认一个颜色，把功能做出来，然后再实现切换颜色的功能\n\n在AppStage.ts里实现监听和上色功能\n\n```typescript\nlistenAndFillRect() {\n\t// 监听前，需要设置一个绘图对象，涂色时只对这个对象有效\n    if (!this.drawTaget) {\n      console.error(`未设置绘图对象 drawTarget : this.drawTarget(target:any)`);\n      return;\n    }\n\n    this.drawTaget.on(\"mousedown\", (e: any) => {\n    // 如果不是自己的模式，就不执行\n\t  if (this.mouseMode !== \"fill\") {\n        this.drawTaget.off('mousedown')\n        return;\n      };\n      // 绘图时禁止冒泡, 防止拖拽\n      e.cancelBubble = true;\n      this.fillStatus = \"filling\";\n      e.target.fill(this.fillConfig?.color);\n\n      this.drawTaget.on(\"mousemove\", (e: any) => {\n        if (this.fillStatus === \"filling\") {\n          e.target.fill(this.fillConfig?.color);\n        }\n      });\n\n      this.drawTaget.on(\"mouseup\", () => {\n        this.drawTaget.off(\"mousemove\");\n        this.drawTaget.off(\"mouseup\");\n        this.fillStatus = \"done\";\n      });\n    });\n  }\n```\n\n在组件内实现切换模式功能，这个功能同样封装在AppStage里，两种模式切换的时候对应两套鼠标动作，所以还需要一个listenAndAssignTask功能，来switch case一下模式，对应不同的操作逻辑\n\n```typescript\nconst changeMode = () => {\n  Stage.value.switchMouseMode(mode.value)\n  Stage.value.listenAndAssignTask()\n}\n```\n\n再加一个切换颜色的功能，写几个div，背景色就是配置的颜色，横向排列，因为我们这里是方格，所以展示颜色的div也显示成方格，如下图\n![](https://img.zzao.club/article/202411191440543.png)\n\n这个切换颜色的小组件，还可以继续优化一下，做一个动画，点击了谁就跳到第一个位置上。这里**先记下，后续再做**，继续推进功能，避免中途加太多东西，导致延期。\n\n鼠标一个一个的点，明显不方便，我再加一个**tab键切换颜色**的功能\n\nStoreTest.vue\n\n```typescript\nconst bindKeyboardEvent = () => {\n    window.addEventListener(\"keydown\", (e) => {\n      if (e.code === \"Tab\") {\n        let index = colorConfig.value.findIndex(color => color === selectColor.value)\n        index = index >= colorConfig.value.length - 1 ? 0 : index+1\n        changeColor(colorConfig.value[index])\n      }\n      e.preventDefault();\n    });\n}\n```\n\n目前，页面已经实现了以下效果\n\n![](https://img.zzao.club/article/202411191440544.png)\n\n最后一步，实现导出图片功能\n\n## 导出功能\n\nKonva内置了toDataURL功能，可以自定义导出的区域\n\n```typescript\n // 转dataURL 用于导出\n  toDataURL(options = {}) {\n    return this.stage.toDataURL(options);\n  }\n```\n\n再封装一个用a标签下载图片的功能\n\n```typescript\nexport function downloadPNGForCanvas(\n  dataURL: string,\n  filename: string = (+new Date()).toString(),\n) {\n  const a = document.createElement('a')\n  a.download = filename\n  a.href = dataURL\n  document.body.appendChild(a)\n  a.click()\n  a.remove()\n}\n```\n\nok, 可以实现导出功能了。\n创建方格时，我给他们套了一个Rect，方便获取导出时width、height，xy坐标是Group的坐标。\n\n```typescript\nconst exportImage = () => {\n  // 缩放回原始大小\n  PixelRectGroup.value.scaleX(1)\n  PixelRectGroup.value.scaleY(1)\n  Stage.value.batchDraw()\n  nextTick(() => {\n    // 获取位置\n    let { x, y } = PixelRectGroup.value.absolutePosition()\n    let { width, height } = PixelRect.value.getAttrs()\n    let dataURL = Stage.value.toDataURL({\n      x,\n      y,\n      width,\n      height,\n      pixelRatio: window.devicePixelRatio, \n    })\n    downloadPNGForCanvas(dataURL, '测试')\n  })\n}\n```\n\n最后用完成的功能画一个图试试看。\n很明显啊，这是个**掘金**的标志，但是略微有些抽象，如果格子多一些后会好很多。\n不过用手画还是略微有点慢呀，看来V2版本的**导入图片自动绘制像素风**要提上日程啦。\n\n![](https://img.zzao.club/article/202411191440545.png)\n\n## 版本迭代\n\nPixeled Pic Pro的**V1版本**已经按照自己规划的内容完成了。本篇的代码，我会尽快更新到[Github](https://github.com/zzdaddy/PixeledPicPro)中。这次的版本定为v0.6.0，我会打一个tag标记。\n\n后续有优化、功能迭代的话我都会按照文章所述的版本命名方式继续在[Github](https://github.com/zzdaddy/PixeledPicPro)中更新，感兴趣的朋友可以去[Github](https://github.com/zzdaddy/PixeledPicPro) 拉代码玩一玩，考虑到这个产品本体功能可能对大部分同学都不适用，所以大家仅参考我的开发过程和思考方式即可，前端或后端的**V2版本**，我会在整个产品的V1版本闭环后再**酌情更新**。\n\n后续文章为： Nest实战篇、前端+后端部署篇、总结篇。 总计五篇文章。我会收录在我的专栏/分类里方便大家查阅。\n\n## 小结\n\n下面是本次项目的总结：\n\n*   合理的拆分需求，分阶段完成自己的目标\n*   用一个现成的模版，快速启动前端项目，只要看到了成果，就对自己有正反馈。\n*   文档没必要吃透，边做边查也很快，取自己所需即可\n*   按拆分后列好的功能去逐步去实现，按自己的习惯、喜好去划分版本\n*   碰到可复用的代码，先拆出来\n*   收尾后及时总结归纳，加深印象\n\n以上就是本篇全部的内容，文中有**不正确、不清晰**的地方，欢迎在评论区指出，我会尽快更正（不优雅不算）。\n\n如果对大家能有一点点帮助，这将是我继续写下去的最大动力。\n\n喜欢的朋友可以点个关注，继续追更后续的更多内容。有任何前端问题想要咨询的同学，也欢迎加VX：zzdaddy7，我会尽力为你解答。\n\n感谢你的阅读，我是枣把儿\\~\n\n（丑陋的彩蛋...）\n\n![](https://img.zzao.club/article/202411191440546.png)\n\n\n",{"title":5,"description":2765},"post/pixel/vue3-logo-creator-ppp",[2777,2771,2778],"产品","Vue3","C9vGQn6ppcabqcZmM9L8hinEPq6_hRWzjQhROa6o_YY",[2781,2785],{"title":2782,"path":2783,"stem":2784},"OpenClaw 安装入门（Windows）","/post/zzao/openclaw/openclaw-install-windows","post/zzao/openclaw/openclaw-install-windows",{"title":2786,"path":2787,"stem":2788},"假设你是AI，你的Skill应该是什么样的","/post/zzao/ai-skill-structure","post/zzao/ai-skill-structure",1779005087138]