├── .nojekyll ├── .DS_Store ├── img ├── header.png ├── label-M3.png ├── label-M4.png ├── label-M6.png ├── label-Wago_221.png ├── label-TX20_screws.png ├── screenshot_halagen.png ├── label-M3_heat_insert.png └── screenshot_batch_yaml.png ├── icons ├── inserts │ ├── heat.png │ └── wood.png ├── nuts │ ├── nut-cap.png │ ├── nut-lock.png │ └── nut-standard.png ├── washers │ ├── fender.png │ ├── flat.png │ ├── split.png │ ├── star-exterior.png │ └── star-interior.png ├── electronics │ ├── generic.png │ ├── wago-alt1.png │ ├── wago-alt2.png │ ├── wago-logo.png │ └── wire-nut.png ├── fasteners │ ├── screw-hex.png │ ├── screw-pan.png │ ├── screw-bugle.png │ ├── screw-flat.png │ ├── screw-oval.png │ ├── screw-round.png │ ├── screw-tbolt.png │ ├── screw-trim.png │ ├── screw-truss.png │ ├── screw-wafer.png │ ├── thumb-screw.png │ ├── screw-pan-hex.png │ ├── screw-fillister.png │ ├── screw-thumb-knurled.png │ └── screw-truss-modified.png └── heads │ ├── deprecated │ ├── flat.png │ ├── hex.png │ ├── torx.png │ ├── phillips.png │ ├── robinson.png │ └── slotted-phillips.png │ ├── Screw_Head_-_Square_External.svg │ ├── Screw_Head_-_Hex_External.svg │ ├── Screw_Head_-_TA.svg │ ├── Screw_Head_-_Slotted.svg │ ├── Screw_Head_-_Robertson.svg │ ├── Screw_Head_-_Cross.svg │ ├── Screw_Head_-_Hex_Socket.svg │ ├── Screw_Head_-_Phillips.svg │ ├── Screw_Head_-_Pozidriv.svg │ ├── Screw_Head_-_Torx.svg │ └── Screw_Head_-_Torx_Tamperproof.svg ├── .claude └── settings.local.json ├── .gitignore ├── LICENSE ├── examples └── labels.yaml ├── lib ├── yaml.js ├── LICENSES.txt └── codemirror.css ├── README.md ├── TODO.md ├── index.html ├── styles-bootstrap.css ├── styles.css └── script.js /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/.DS_Store -------------------------------------------------------------------------------- /img/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/header.png -------------------------------------------------------------------------------- /img/label-M3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/label-M3.png -------------------------------------------------------------------------------- /img/label-M4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/label-M4.png -------------------------------------------------------------------------------- /img/label-M6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/label-M6.png -------------------------------------------------------------------------------- /icons/inserts/heat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/inserts/heat.png -------------------------------------------------------------------------------- /icons/inserts/wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/inserts/wood.png -------------------------------------------------------------------------------- /icons/nuts/nut-cap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/nuts/nut-cap.png -------------------------------------------------------------------------------- /icons/nuts/nut-lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/nuts/nut-lock.png -------------------------------------------------------------------------------- /icons/washers/fender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/washers/fender.png -------------------------------------------------------------------------------- /icons/washers/flat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/washers/flat.png -------------------------------------------------------------------------------- /icons/washers/split.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/washers/split.png -------------------------------------------------------------------------------- /img/label-Wago_221.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/label-Wago_221.png -------------------------------------------------------------------------------- /img/label-TX20_screws.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/label-TX20_screws.png -------------------------------------------------------------------------------- /img/screenshot_halagen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/screenshot_halagen.png -------------------------------------------------------------------------------- /icons/electronics/generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/electronics/generic.png -------------------------------------------------------------------------------- /icons/fasteners/screw-hex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-hex.png -------------------------------------------------------------------------------- /icons/fasteners/screw-pan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-pan.png -------------------------------------------------------------------------------- /icons/nuts/nut-standard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/nuts/nut-standard.png -------------------------------------------------------------------------------- /img/label-M3_heat_insert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/label-M3_heat_insert.png -------------------------------------------------------------------------------- /img/screenshot_batch_yaml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/img/screenshot_batch_yaml.png -------------------------------------------------------------------------------- /icons/electronics/wago-alt1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/electronics/wago-alt1.png -------------------------------------------------------------------------------- /icons/electronics/wago-alt2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/electronics/wago-alt2.png -------------------------------------------------------------------------------- /icons/electronics/wago-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/electronics/wago-logo.png -------------------------------------------------------------------------------- /icons/electronics/wire-nut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/electronics/wire-nut.png -------------------------------------------------------------------------------- /icons/fasteners/screw-bugle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-bugle.png -------------------------------------------------------------------------------- /icons/fasteners/screw-flat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-flat.png -------------------------------------------------------------------------------- /icons/fasteners/screw-oval.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-oval.png -------------------------------------------------------------------------------- /icons/fasteners/screw-round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-round.png -------------------------------------------------------------------------------- /icons/fasteners/screw-tbolt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-tbolt.png -------------------------------------------------------------------------------- /icons/fasteners/screw-trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-trim.png -------------------------------------------------------------------------------- /icons/fasteners/screw-truss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-truss.png -------------------------------------------------------------------------------- /icons/fasteners/screw-wafer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-wafer.png -------------------------------------------------------------------------------- /icons/fasteners/thumb-screw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/thumb-screw.png -------------------------------------------------------------------------------- /icons/heads/deprecated/flat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/heads/deprecated/flat.png -------------------------------------------------------------------------------- /icons/heads/deprecated/hex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/heads/deprecated/hex.png -------------------------------------------------------------------------------- /icons/heads/deprecated/torx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/heads/deprecated/torx.png -------------------------------------------------------------------------------- /icons/washers/star-exterior.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/washers/star-exterior.png -------------------------------------------------------------------------------- /icons/washers/star-interior.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/washers/star-interior.png -------------------------------------------------------------------------------- /icons/fasteners/screw-pan-hex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-pan-hex.png -------------------------------------------------------------------------------- /icons/fasteners/screw-fillister.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-fillister.png -------------------------------------------------------------------------------- /icons/heads/deprecated/phillips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/heads/deprecated/phillips.png -------------------------------------------------------------------------------- /icons/heads/deprecated/robinson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/heads/deprecated/robinson.png -------------------------------------------------------------------------------- /icons/fasteners/screw-thumb-knurled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-thumb-knurled.png -------------------------------------------------------------------------------- /icons/fasteners/screw-truss-modified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/fasteners/screw-truss-modified.png -------------------------------------------------------------------------------- /icons/heads/deprecated/slotted-phillips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timmmmmmmmm/halagen/HEAD/icons/heads/deprecated/slotted-phillips.png -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Square_External.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Hex_External.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_TA.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Slotted.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Robertson.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Cross.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Hex_Socket.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Phillips.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(mkdir:*)", 5 | "Bash(open:*)", 6 | "Bash(rm:*)", 7 | "Bash(find:*)", 8 | "Bash(mv:*)", 9 | "Bash(curl:*)" 10 | ], 11 | "deny": [] 12 | } 13 | } -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Pozidriv.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Torx.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/heads/Screw_Head_-_Torx_Tamperproof.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | ehthumbs.db 8 | Thumbs.db 9 | 10 | # Editor files 11 | *.swp 12 | *.swo 13 | *~ 14 | .vscode/ 15 | .idea/ 16 | 17 | # Logs 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # Dependencies 24 | node_modules/ 25 | bower_components/ 26 | 27 | # Build outputs 28 | dist/ 29 | build/ 30 | *.min.js 31 | *.min.css 32 | 33 | # Allow library files 34 | !lib/*.min.js 35 | !lib/*.min.css 36 | 37 | # Environment variables 38 | .env 39 | .env.local 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | 44 | # Temporary files 45 | *.tmp 46 | *.temp -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hardware Label Maker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /examples/labels.yaml: -------------------------------------------------------------------------------- 1 | # Optional global settings 2 | long_png: true # Generate one long PNG strip 3 | cut_marks: true # Include cut marks between labels 4 | 5 | labels: 6 | - title: "M4 × 12" 7 | subtext: "DIN 7984" 8 | icon: "Head_Hex" 9 | width_mm: 50 10 | height_mm: 12 11 | 12 | - title: "M6 × 20" 13 | subtext: "Socket Head" 14 | icon: "Screw_Hex" 15 | width_mm: 55 16 | height_mm: 18 17 | 18 | - title: "M3 × 8" 19 | subtext: "Phillips" 20 | icon: "Head_Phillips" 21 | width_mm: 45 22 | height_mm: 9 23 | 24 | - title: "M5 Nut" 25 | subtext: "Standard" 26 | icon: "Nut_Standard" 27 | width_mm: 40 28 | height_mm: 12 29 | 30 | - title: "M4 Washer" 31 | subtext: "Flat" 32 | icon: "Washer_Flat" 33 | width_mm: 35 34 | height_mm: 9 35 | 36 | - title: "M8 × 25" 37 | subtext: "Hex Head" 38 | icon: "Head_Hex" 39 | width_mm: 60 40 | height_mm: 24 41 | 42 | - title: "Heat Insert" 43 | subtext: "M4 × 6" 44 | icon: "Insert_Heat" 45 | width_mm: 45 46 | height_mm: 12 47 | 48 | - title: "Wire Connector" 49 | subtext: "2-Way" 50 | icon: "Elec_WireNut" 51 | width_mm: 50 52 | height_mm: 18 53 | 54 | - title: "M5 × 16" 55 | subtext: "Torx T25" 56 | icon: "Head_Torx" 57 | width_mm: 52 58 | height_mm: 18 59 | 60 | - title: "Split Washer" 61 | subtext: "M6" 62 | icon: "Washer_Split" 63 | width_mm: 38 64 | height_mm: 9 -------------------------------------------------------------------------------- /lib/yaml.js: -------------------------------------------------------------------------------- 1 | !function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")}); -------------------------------------------------------------------------------- /lib/LICENSES.txt: -------------------------------------------------------------------------------- 1 | This directory contains third-party libraries with the following licenses: 2 | 3 | ================================================================================ 4 | CodeMirror 5.65.16 5 | ================================================================================ 6 | Copyright (C) 2017 by Marijn Haverbeke and others 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | 26 | ================================================================================ 27 | JSZip 3.10.1 (using MIT License) 28 | ================================================================================ 29 | Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # halagen (hardware label generator) 2 | 3 | A browser-based label generator for hardware components including screws, bolts, washers, nuts, and electrical components. Features a what-you-see-is-what-you-get editor with real-time preview window. Generate printable PNG labels with customizable dimensions and icons, ideally to be used in label-print editors like Brother's P-touch Editor. 4 | 5 | Visit [timmmmmmmmm.github.io/halagen](https://timmmmmmmmm.github.io/halagen) to use the tool directly in your browser. 6 | 7 |
8 | 9 | halagen Interface 10 | 11 |
12 | 13 | ## Features 14 | 15 | - **No backend required** - Works entirely in your browser 16 | - **Customizable labels** - Set custom height (9-24mm) and width (20-100mm) values with precise control 17 | - **Custom icon loading** - Upload your own icons or use the built-in collection 18 | - **Real-time preview** - See your label at actual size before downloading 19 | - **Multi-column support** - Create labels with multiple columns when your container has sections 20 | - **YAML batch processor** - Generate multiple labels at once using YAML input 21 | - **PNG export** - Download labels as high-quality PNG files ready for printing 22 | 23 | ## Example Output 24 | 25 | Here are some examples of labels generated with halagen: 26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
M3 LabelM3 Heat InsertM4 Label
M6 LabelTX20 ScrewsWago 221
39 |
40 | 41 | ## Multi-column labels 42 | 43 | halagen supports multi-column labels for when your storage containers have multiple sections. This feature allows you to create a single label that covers multiple compartments, with each column representing a different section of your container. Simply specify the number of columns needed and fill in the content for each section. 44 | 45 | ## YAML batch processor 46 | 47 | halagen includes a powerful YAML batch processor that allows you to generate multiple labels simultaneously. This feature is particularly useful when you need to create many labels at once. 48 | 49 | **AI/LLM Integration**: The YAML format is designed to work seamlessly with AI assistants and Large Language Models (LLMs). Simply take fore example a screenshot of your hardware shopping basket or current organizer, and ask an AI to generate the YAML configuration for all the parts it can identify. This makes organizing large quantities of hardware components incredibly efficient. 50 | 51 |
52 | YAML Batch Processor 53 |
54 | 55 | Example YAML format: 56 | ```yaml 57 | # Global settings (optional) 58 | width_mm: 50 59 | height_mm: 12 60 | png_dpi: 300 61 | 62 | labels: 63 | - title: "M4 × 12" 64 | subtext: "DIN 7984" 65 | icon: "heads_hex_socket" 66 | rotate: false 67 | - title: "M6 × 20" 68 | subtext: "Hex Bolt" 69 | icon: "fasteners_screw_hex" 70 | width_mm: 45 71 | height_mm: 18 72 | ``` 73 | 74 | ## License 75 | 76 | This project is open source and available under the [MIT License](LICENSE). 77 | 78 | ## Icon attribution 79 | 80 | Some of the icons in the current icon set are based on designs by **Joe Jankowiak**, available at: https://www.printables.com/model/621771-gridfinity-bin-label-icons 81 | 82 | Used under the Creative Commons Attribution 4.0 International License (CC BY 4.0). 83 | https://creativecommons.org/licenses/by/4.0/ -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Feature Requests from Reddit Feedback 2 | 3 | ## **Priority Recommendations** 4 | 5 | **Phase 1 (Quick wins):** 6 | - UI Framework Migration (Bootstrap 5) 7 | - Icon Count Selection Feature (0/1/2 icons) 8 | - Text centering 9 | - Toggle second row 10 | 11 | **Phase 2 (Enhanced functionality):** 12 | - Basic crimping icons 13 | - Icon overlay system 14 | - QR code integration 15 | 16 | ## **Future Considerations / Maybe Later** 17 | - **Direct printing integration**: Enable direct printing to label printers without saving as PNG first 18 | - Would require integration with browser printing APIs or label printer SDKs 19 | - Complex implementation, uncertain browser support 20 | - Users can continue using current PNG → label printer workflow 21 | 22 | # Feature Details 23 | 24 | ## **UI Framework Migration** 25 | **Move to Bootstrap 5 for better component system and styling** 26 | 27 | **Implementation Plan:** 28 | - Add Bootstrap 5 CSS to existing HTML (CDN or local copy) 29 | - Migrate existing form controls to Bootstrap classes 30 | - Update button styling to use Bootstrap button components 31 | - Implement Bootstrap button groups for icon count selector 32 | - Use Bootstrap grid system for better layout balance 33 | - Update input fields to use Bootstrap form controls 34 | - Maintain current functionality while improving visual consistency 35 | - Test responsive behavior on different screen sizes 36 | 37 | **Benefits:** 38 | - Professional, consistent styling out of the box 39 | - Ready-made components (button groups, form controls, modals) 40 | - No build process required - works with current static setup 41 | - Easier to implement future UI features 42 | 43 | ## **Icon Count Selection Feature** 44 | **Addresses no-image mode and dual icon support requests** 45 | 46 | **Implementation Plan:** 47 | - Add icon count selector as Bootstrap-style button group with three buttons: `[0] [1] [2]` 48 | - Position above height setting, left of width setting for UI balance 49 | - Default state: 1 icon (current behavior) 50 | - Button group styling: Connected buttons, active button highlighted, inactive buttons subdued 51 | 52 | **Behavior per mode:** 53 | - **0 Icons**: Hide all icon selectors, text area expands to full label width 54 | - **1 Icon**: Current behavior, single icon selector visible 55 | - **2 Icons**: Show two icon selectors (Icon 1, Icon 2), arrange side by side in label 56 | 57 | **Layout adjustments:** 58 | - 0 icons: Text spans full width, centered or left-aligned based on text alignment setting 59 | - 1 icon: Current layout (icon left, text right) 60 | - 2 icons: Icons positioned left side (stacked or side-by-side), text area adjusted accordingly 61 | 62 | **UI considerations:** 63 | - When switching from 2→1 icons, preserve Icon 1 selection, clear Icon 2 64 | - When switching from 1→0 icons, hide icon selector but preserve selection for when user switches back 65 | - Update preview canvas in real-time when mode changes 66 | 67 | ## **Text Centering/Justification** 68 | Add center alignment option for text 69 | 70 | ## **Toggle Second Row** 71 | Option to disable subtitle/subtext completely for single-line labels 72 | 73 | ## **Crimping/Electrical Icons** 74 | Add ferrules, terminal connections, and other electrical component icons 75 | 76 | ## **Icon Overlay System** 77 | Implement Cullenect-style overlays where tool interface icons are overlaid on bolt shapes 78 | 79 | ## **QR Code Integration** 80 | Add QR codes to labels for linking to specifications, inventory systems, or part databases 81 | 82 | **Implementation Plan:** 83 | - Add "QR Code" section in label editor UI 84 | - Input field with 20-25 character limit for reliable scanning at 12mm height 85 | - Live character counter showing remaining characters 86 | - Helper text: "Use TinyURL.com to shorten long URLs" 87 | - Real-time QR code preview as user types 88 | - Use client-side QR library (qrcode.js or qrcode-generator) 89 | - Position QR on right side of label, adjust text layout accordingly 90 | - QR version 2-3 (25x25 to 29x29 modules) with error correction level L 91 | - Disable QR generation if over character limit 92 | 93 | ## **Implementation Notes** 94 | - The dual icon feature would work well with the existing fastener/head icon structure you already have 95 | - The overlay concept could leverage your existing SVG head icons to create composite visuals -------------------------------------------------------------------------------- /lib/codemirror.css: -------------------------------------------------------------------------------- 1 | .CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:rgba(255,255,0,.4)}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:''}span.CodeMirror-selectedtext{background:0 0} -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | halagen 7 | 8 | 9 | 10 | 11 | 12 |
13 | 16 |
17 |
18 | 19 |
20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 | mm 37 |
38 |
39 | 40 |
41 |
42 | 43 | 44 |
45 | 46 |
47 |
48 | 49 |
50 |
51 | 52 | mm 53 |
54 |
55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 |
63 | heads_hex_socket 64 |
65 |
66 |
67 |
M3
68 |
M3
69 |
M3
70 |
71 |
72 |
8 mm
73 |
10 mm
74 |
12 mm
75 |
76 |
77 |
78 |
79 | 80 | 81 |
82 |
83 | 84 |
85 | 86 | 3 87 | 88 |
89 |
90 |
91 | 92 |
93 | 94 | 3 95 | 96 |
97 |
98 |
99 |
100 |
101 | 102 |
103 | 106 | 107 | 108 | 113 | 118 | 119 | 120 |
121 |
122 | 123 |
124 |
125 | 128 | 129 |
130 |
131 | 132 | DPI 133 | 134 | 135 |
136 |
137 | 138 | 139 |
140 |
141 | 142 | 143 |
144 |
145 |
146 |
147 |
148 | 149 |
150 | 151 | 167 |
168 | 169 | 184 |
185 | 186 | 187 |
188 | 189 | 190 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | -------------------------------------------------------------------------------- /styles-bootstrap.css: -------------------------------------------------------------------------------- 1 | /* Custom styles for halagen - Bootstrap 5 compatible */ 2 | 3 | /* Remove global reset - Bootstrap handles this */ 4 | body { 5 | background-color: #f8f9fa; 6 | padding-top: 25px; 7 | } 8 | 9 | /* Container adjustments for Bootstrap */ 10 | .container-fluid { 11 | min-width: calc(var(--label-width, 40mm) * 2 + 200px + 120px); 12 | width: max-content; 13 | position: relative; 14 | } 15 | 16 | /* Header logo styling - preserve existing */ 17 | .header-logo { 18 | position: absolute; 19 | top: -70px; 20 | left: -30px; 21 | z-index: 1000; 22 | width: auto; 23 | height: auto; 24 | } 25 | 26 | .header-logo .logo { 27 | height: 150px; 28 | width: auto; 29 | filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)) drop-shadow(0 1px 2px rgba(0,0,0,0.15)); 30 | } 31 | 32 | /* WYSIWYG Preview Interactions - preserve existing */ 33 | .clickable-icon { 34 | cursor: pointer; 35 | transition: all 0.2s ease; 36 | border-radius: 4px; 37 | position: relative; 38 | } 39 | 40 | .clickable-icon:hover { 41 | background-color: rgba(0, 123, 255, 0.1); 42 | box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.3); 43 | } 44 | 45 | .clickable-icon:hover::after { 46 | content: "Click to change"; 47 | position: absolute; 48 | background: rgba(0, 0, 0, 0.8); 49 | color: white; 50 | padding: 1px 3px; 51 | border-radius: 2px; 52 | font-size: 4px; 53 | top: -8px; 54 | left: 50%; 55 | transform: translateX(-50%) scale(0.5); 56 | white-space: nowrap; 57 | pointer-events: none; 58 | z-index: 10; 59 | } 60 | 61 | .editable-text { 62 | cursor: text; 63 | min-width: 20px; 64 | min-height: 1em; 65 | border-radius: 2px; 66 | transition: all 0.2s ease; 67 | padding: 1px 2px; 68 | text-align: left !important; 69 | direction: ltr !important; 70 | } 71 | 72 | .editable-text:hover { 73 | background-color: rgba(0, 123, 255, 0.05); 74 | outline: 1px solid rgba(0, 123, 255, 0.2); 75 | } 76 | 77 | .editable-text:focus { 78 | background-color: rgba(0, 123, 255, 0.05); 79 | outline: 2px solid rgba(0, 123, 255, 0.4); 80 | } 81 | 82 | /* Tab content styling - preserve existing layout */ 83 | .tab-content { 84 | display: block; 85 | padding: 20px; 86 | background: white; 87 | border: 1px solid #dee2e6; 88 | border-radius: 0 0 8px 8px; 89 | position: relative; 90 | z-index: 0; 91 | } 92 | 93 | #batch-tab { 94 | display: block; 95 | padding: 20px; 96 | background: white; 97 | border: 1px solid #dee2e6; 98 | border-radius: 0 0 8px 8px; 99 | position: relative; 100 | z-index: 0; 101 | } 102 | 103 | /* Label preview styles - preserve existing */ 104 | .label { 105 | display: flex; 106 | align-items: center; 107 | background: white; 108 | border: 1px solid #ddd; 109 | border-radius: 2px; 110 | padding: 2px; 111 | height: 12mm; 112 | width: 40mm; 113 | box-shadow: 0 1px 3px rgba(0,0,0,0.2); 114 | transform: scale(2); 115 | margin: 30px; 116 | } 117 | 118 | .label-icon { 119 | width: 8mm; 120 | height: 8mm; 121 | margin: 2mm; 122 | flex-shrink: 0; 123 | display: flex; 124 | align-items: center; 125 | justify-content: center; 126 | } 127 | 128 | .label-icon .icon { 129 | width: 100%; 130 | height: 100%; 131 | fill: #333; 132 | } 133 | 134 | .label-text { 135 | flex: 1; 136 | padding: 1mm; 137 | overflow: hidden; 138 | display: flex; 139 | flex-direction: column; 140 | justify-content: center; 141 | } 142 | 143 | .main-text { 144 | font-size: 12px; 145 | font-weight: bold; 146 | color: #000; 147 | line-height: 1.2; 148 | margin-bottom: 1px; 149 | display: flex; 150 | gap: 2px; 151 | width: 100%; 152 | align-items: center; 153 | justify-content: flex-start; 154 | } 155 | 156 | .sub-text { 157 | font-size: 8px; 158 | color: #666; 159 | line-height: 1.2; 160 | display: flex; 161 | gap: 2px; 162 | width: 100%; 163 | align-items: center; 164 | justify-content: flex-start; 165 | } 166 | 167 | /* Dynamic label heights - preserve existing */ 168 | .label[data-height="9"] { 169 | height: 9mm; 170 | } 171 | 172 | .label[data-height="12"] { 173 | height: 12mm; 174 | } 175 | 176 | .label[data-height="18"] { 177 | height: 18mm; 178 | } 179 | 180 | .label[data-height="24"] { 181 | height: 24mm; 182 | } 183 | 184 | .label[data-height="9"] .label-icon { 185 | width: 6mm; 186 | height: 6mm; 187 | } 188 | 189 | .label[data-height="9"] .main-text { 190 | font-size: 6px; 191 | } 192 | 193 | .label[data-height="9"] .sub-text { 194 | font-size: 4px; 195 | } 196 | 197 | .label[data-height="18"] .label-icon { 198 | width: 14mm; 199 | height: 14mm; 200 | } 201 | 202 | .label[data-height="18"] .main-text { 203 | font-size: 12px; 204 | } 205 | 206 | .label[data-height="18"] .sub-text { 207 | font-size: 8px; 208 | } 209 | 210 | .label[data-height="24"] .label-icon { 211 | width: 18mm; 212 | height: 18mm; 213 | } 214 | 215 | .label[data-height="24"] .main-text { 216 | font-size: 14px; 217 | } 218 | 219 | .label[data-height="24"] .sub-text { 220 | font-size: 10px; 221 | } 222 | 223 | /* WYSIWYG Editor Layout - preserve existing */ 224 | .wysiwyg-editor { 225 | margin-bottom: 20px; 226 | } 227 | 228 | .width-control { 229 | display: flex; 230 | justify-content: center; 231 | margin-bottom: 10px; 232 | } 233 | 234 | .dimensional-line { 235 | display: flex; 236 | align-items: center; 237 | gap: 8px; 238 | } 239 | 240 | .dimensional-line.horizontal { 241 | flex-direction: row; 242 | } 243 | 244 | .dimensional-line.vertical { 245 | flex-direction: column; 246 | height: 120px; 247 | justify-content: center; 248 | } 249 | 250 | .dimension-arrow { 251 | font-size: 14px; 252 | color: #666; 253 | font-weight: bold; 254 | cursor: pointer; 255 | padding: 4px 8px; 256 | border-radius: 4px; 257 | transition: all 0.2s ease; 258 | user-select: none; 259 | } 260 | 261 | .dimension-arrow:hover { 262 | background-color: #e9ecef; 263 | color: #495057; 264 | } 265 | 266 | .dimension-arrow:active { 267 | background-color: #dee2e6; 268 | transform: scale(0.95); 269 | } 270 | 271 | .dimension-control { 272 | display: flex; 273 | flex-direction: column; 274 | align-items: center; 275 | gap: 4px; 276 | } 277 | 278 | .dimension-control .input-group { 279 | width: 95px !important; 280 | } 281 | 282 | /* Hide number input arrows */ 283 | .dimension-control input[type="number"]::-webkit-outer-spin-button, 284 | .dimension-control input[type="number"]::-webkit-inner-spin-button { 285 | -webkit-appearance: none; 286 | margin: 0; 287 | } 288 | 289 | .dimension-control input[type="number"] { 290 | -moz-appearance: textfield; 291 | appearance: textfield; 292 | } 293 | 294 | .dimension-control.vertical { 295 | writing-mode: vertical-lr; 296 | text-orientation: mixed; 297 | } 298 | 299 | .dimension-control.vertical .input-group { 300 | writing-mode: horizontal-tb; 301 | text-orientation: upright; 302 | width: 95px !important; 303 | } 304 | 305 | 306 | .preview-container { 307 | display: flex; 308 | align-items: center; 309 | justify-content: center; 310 | margin-bottom: 20px; 311 | position: relative; 312 | min-height: 120px; 313 | width: 100%; 314 | min-width: calc(var(--label-width, 40mm) * 2 + 160px); 315 | overflow: visible; 316 | } 317 | 318 | .height-control { 319 | display: flex; 320 | align-items: center; 321 | position: absolute; 322 | left: 50%; 323 | top: 50%; 324 | transform: translate(calc(-100% - var(--label-width, 40mm) - 40px), -50%); 325 | z-index: 10; 326 | } 327 | 328 | .preview-area { 329 | display: flex; 330 | justify-content: center; 331 | align-items: center; 332 | position: relative; 333 | } 334 | 335 | .preview-area #header-label-preview { 336 | transform: scale(2); 337 | margin: 30px; 338 | } 339 | 340 | .column-controls-right { 341 | display: flex; 342 | flex-direction: column; 343 | gap: 15px; 344 | align-items: flex-start; 345 | position: absolute; 346 | left: 50%; 347 | top: 50%; 348 | transform: translate(calc(var(--label-width, 40mm) + 40px), -50%); 349 | z-index: 10; 350 | } 351 | 352 | .main-text-control, 353 | .sub-text-control { 354 | display: flex; 355 | flex-direction: column; 356 | gap: 4px; 357 | align-items: center; 358 | } 359 | 360 | .main-text-control label, 361 | .sub-text-control label { 362 | font-size: 11px; 363 | color: #666; 364 | font-weight: 600; 365 | margin: 0; 366 | } 367 | 368 | .column-buttons-right { 369 | display: flex; 370 | align-items: center; 371 | gap: 4px; 372 | } 373 | 374 | .column-btn-small { 375 | width: 20px; 376 | height: 20px; 377 | border: 1px solid #dee2e6; 378 | background: white; 379 | color: #6c757d; 380 | font-size: 12px; 381 | font-weight: bold; 382 | border-radius: 3px; 383 | cursor: pointer; 384 | display: flex; 385 | align-items: center; 386 | justify-content: center; 387 | transition: all 0.2s ease; 388 | padding: 0; 389 | margin: 0; 390 | } 391 | 392 | .column-btn-small:hover { 393 | background: #e9ecef; 394 | border-color: #adb5bd; 395 | color: #495057; 396 | } 397 | 398 | .column-btn-small:active { 399 | background: #dee2e6; 400 | transform: scale(0.95); 401 | } 402 | 403 | .column-controls-right .column-count { 404 | font-size: 12px; 405 | font-weight: 600; 406 | color: #495057; 407 | min-width: 15px; 408 | text-align: center; 409 | } 410 | 411 | /* Text column styling */ 412 | .main-text-column { 413 | flex: 1; 414 | text-align: left; 415 | font-size: inherit; 416 | font-weight: inherit; 417 | color: inherit; 418 | line-height: inherit; 419 | padding-right: 4px; 420 | white-space: nowrap; 421 | overflow: hidden; 422 | min-width: 0; 423 | direction: ltr; 424 | text-overflow: clip; 425 | text-align: left !important; 426 | scroll-behavior: auto; 427 | } 428 | 429 | .main-text-column::-webkit-scrollbar { 430 | display: none; 431 | } 432 | 433 | .sub-text-column { 434 | flex: 1; 435 | text-align: left; 436 | font-size: inherit; 437 | font-weight: inherit; 438 | color: inherit; 439 | line-height: inherit; 440 | padding-right: 4px; 441 | white-space: nowrap; 442 | overflow: hidden; 443 | min-width: 0; 444 | direction: ltr; 445 | text-overflow: clip; 446 | text-align: left !important; 447 | scroll-behavior: auto; 448 | } 449 | 450 | .sub-text-column::-webkit-scrollbar { 451 | display: none; 452 | } 453 | 454 | /* Validation result styling */ 455 | .validation-result { 456 | margin-top: 20px; 457 | padding: 15px; 458 | border-radius: 4px; 459 | display: none; 460 | } 461 | 462 | .validation-result.success { 463 | background: #d4edda; 464 | border: 1px solid #c3e6cb; 465 | color: #155724; 466 | } 467 | 468 | .validation-result.error { 469 | background: #f8d7da; 470 | border: 1px solid #f5c6cb; 471 | color: #721c24; 472 | } 473 | 474 | .validation-result.warning { 475 | background: #fff3cd; 476 | border: 1px solid #ffeaa7; 477 | color: #856404; 478 | } 479 | 480 | /* CodeMirror YAML Editor Styling */ 481 | .CodeMirror { 482 | border: 2px solid #e0e0e0; 483 | border-radius: 6px; 484 | font-family: 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 485 | font-size: 14px; 486 | line-height: 1.4; 487 | background: white; 488 | transition: border-color 0.3s; 489 | } 490 | 491 | .CodeMirror-focused { 492 | border-color: #3498db; 493 | box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); 494 | } 495 | 496 | .CodeMirror-gutters { 497 | background: #f8f9fa; 498 | border-right: 1px solid #e9ecef; 499 | } 500 | 501 | .CodeMirror-linenumber { 502 | color: #6c757d; 503 | font-size: 12px; 504 | } 505 | 506 | .CodeMirror-cursor { 507 | border-left: 2px solid #3498db; 508 | } 509 | 510 | /* YAML Syntax Highlighting */ 511 | .cm-comment { 512 | color: #6c757d; 513 | font-style: italic; 514 | } 515 | 516 | .cm-string { 517 | color: #28a745; 518 | } 519 | 520 | .cm-number { 521 | color: #dc3545; 522 | } 523 | 524 | .cm-keyword { 525 | color: #6f42c1; 526 | font-weight: bold; 527 | } 528 | 529 | .cm-atom { 530 | color: #fd7e14; 531 | } 532 | 533 | .cm-def { 534 | color: #007bff; 535 | font-weight: bold; 536 | } 537 | 538 | .cm-variable { 539 | color: #495057; 540 | } 541 | 542 | .cm-punctuation { 543 | color: #6c757d; 544 | } 545 | 546 | /* Hide original textarea when CodeMirror is active */ 547 | #yaml-input { 548 | display: none; 549 | } 550 | 551 | /* Icon Picker Styles - preserve existing */ 552 | .icon-picker { 553 | position: fixed; 554 | top: 50%; 555 | left: 50%; 556 | transform: translate(-50%, -50%); 557 | z-index: 9999; 558 | background: white; 559 | border-radius: 8px; 560 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); 561 | max-width: 600px; 562 | width: 90vw; 563 | } 564 | 565 | .icon-picker-overlay { 566 | position: fixed; 567 | top: 0; 568 | left: 0; 569 | right: 0; 570 | bottom: 0; 571 | background: rgba(0, 0, 0, 0.5); 572 | z-index: 9998; 573 | opacity: 0; 574 | visibility: hidden; 575 | transition: all 0.3s ease; 576 | } 577 | 578 | .icon-picker-overlay.active { 579 | opacity: 1; 580 | visibility: visible; 581 | } 582 | 583 | .icon-picker-header { 584 | display: flex; 585 | justify-content: space-between; 586 | align-items: center; 587 | padding: 16px 20px; 588 | border-bottom: 1px solid #e9ecef; 589 | background: #f8f9fa; 590 | border-radius: 8px 8px 0 0; 591 | } 592 | 593 | .icon-picker-header h3 { 594 | margin: 0; 595 | font-size: 18px; 596 | color: #495057; 597 | } 598 | 599 | .icon-picker-close { 600 | background: none; 601 | border: none; 602 | font-size: 24px; 603 | color: #6c757d; 604 | cursor: pointer; 605 | padding: 0; 606 | width: 30px; 607 | height: 30px; 608 | display: flex; 609 | align-items: center; 610 | justify-content: center; 611 | border-radius: 4px; 612 | transition: all 0.2s ease; 613 | } 614 | 615 | .icon-picker-close:hover { 616 | background: #e9ecef; 617 | color: #495057; 618 | } 619 | 620 | .icon-picker-search { 621 | padding: 12px; 622 | border-bottom: 1px solid #e9ecef; 623 | display: flex; 624 | gap: 8px; 625 | align-items: center; 626 | } 627 | 628 | .icon-picker-search input[type="text"] { 629 | flex: 1; 630 | padding: 8px 12px; 631 | border: 1px solid #dee2e6; 632 | border-radius: 4px; 633 | font-size: 14px; 634 | margin: 0; 635 | } 636 | 637 | .icon-picker-grid { 638 | display: grid; 639 | grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); 640 | gap: 4px; 641 | padding: 8px; 642 | max-height: 400px; 643 | overflow-y: auto; 644 | } 645 | 646 | .icon-picker-item { 647 | display: flex; 648 | flex-direction: column; 649 | align-items: center; 650 | padding: 4px; 651 | border: 2px solid transparent; 652 | border-radius: 4px; 653 | cursor: pointer; 654 | transition: all 0.3s; 655 | background: #f8f9fa; 656 | text-align: center; 657 | position: relative; 658 | } 659 | 660 | .icon-picker-item:hover { 661 | background: #e9ecef; 662 | border-color: #3498db; 663 | } 664 | 665 | .icon-picker-item.selected { 666 | background: #e3f2fd; 667 | border-color: #3498db; 668 | } 669 | 670 | .icon-picker-item img { 671 | width: 28px; 672 | height: 28px; 673 | object-fit: contain; 674 | margin-bottom: 3px; 675 | } 676 | 677 | .icon-picker-item span { 678 | font-size: 9px; 679 | color: #495057; 680 | line-height: 1.1; 681 | word-break: break-word; 682 | } 683 | 684 | .icon-picker-category { 685 | grid-column: 1 / -1; 686 | font-size: 12px; 687 | font-weight: 600; 688 | color: #6c757d; 689 | margin-top: 8px; 690 | margin-bottom: 4px; 691 | padding-bottom: 4px; 692 | border-bottom: 1px solid #e9ecef; 693 | } 694 | 695 | .icon-picker-category:first-child { 696 | margin-top: 0; 697 | } 698 | 699 | .upload-icon-btn { 700 | background: #28a745; 701 | color: white; 702 | border: none; 703 | padding: 8px 16px; 704 | border-radius: 4px; 705 | font-size: 12px; 706 | cursor: pointer; 707 | transition: background 0.3s; 708 | margin-bottom: 8px; 709 | } 710 | 711 | .upload-icon-btn:hover { 712 | background: #218838; 713 | } 714 | 715 | .custom-icon-category { 716 | background: #e8f5e8; 717 | border: 1px solid #28a745; 718 | border-radius: 4px; 719 | padding: 4px 8px; 720 | color: #155724; 721 | font-weight: bold; 722 | } 723 | 724 | .delete-icon-btn { 725 | position: absolute; 726 | top: 2px; 727 | right: 2px; 728 | background: #dc3545; 729 | color: white; 730 | border: none; 731 | border-radius: 50%; 732 | width: 18px; 733 | height: 18px; 734 | font-size: 12px; 735 | line-height: 1; 736 | cursor: pointer; 737 | display: none; 738 | align-items: center; 739 | justify-content: center; 740 | z-index: 10; 741 | padding: 0; 742 | } 743 | 744 | .rename-icon-btn { 745 | position: absolute; 746 | top: 2px; 747 | right: 22px; 748 | background: #0d6efd; 749 | color: white; 750 | border: none; 751 | border-radius: 50%; 752 | width: 18px; 753 | height: 18px; 754 | font-size: 10px; 755 | line-height: 1; 756 | cursor: pointer; 757 | display: none; 758 | align-items: center; 759 | justify-content: center; 760 | z-index: 10; 761 | padding: 0; 762 | font-weight: bold; 763 | } 764 | 765 | .icon-picker-item:hover .delete-icon-btn, 766 | .icon-picker-item:hover .rename-icon-btn { 767 | display: flex; 768 | } 769 | 770 | .delete-icon-btn:hover { 771 | background: #c82333; 772 | } 773 | 774 | .rename-icon-btn:hover { 775 | background: #0b5ed7; 776 | } 777 | 778 | /* Footer Styles */ 779 | footer { 780 | margin-top: 20px; 781 | } 782 | 783 | .attribution { 784 | text-align: center; 785 | font-size: 12px; 786 | color: #666; 787 | } 788 | 789 | .attribution a { 790 | color: #0d6efd; 791 | text-decoration: none; 792 | } 793 | 794 | .attribution a:hover { 795 | text-decoration: underline; 796 | } 797 | 798 | /* Custom DPI preset button styling to match Bootstrap */ 799 | .dpi-presets .btn { 800 | font-size: 10px; 801 | padding: 2px 8px; 802 | } 803 | 804 | /* Make download button group match height of settings column */ 805 | .export-section .btn-group { 806 | height: 100%; 807 | } 808 | 809 | .export-section .btn-group .btn { 810 | height: 100%; 811 | } 812 | 813 | 814 | /* Consistent alignment and font styling for export controls */ 815 | .form-check.form-switch .form-check-label { 816 | font-size: 13px; 817 | margin: 0; 818 | padding-left: 0 !important; 819 | } 820 | 821 | .form-check.form-switch { 822 | padding-left: 0 !important; 823 | } 824 | 825 | /* Responsive adjustments */ 826 | @media (max-width: 768px) { 827 | .container-fluid { 828 | padding: 10px; 829 | } 830 | 831 | .preview-container { 832 | padding: 20px; 833 | min-height: 150px; 834 | } 835 | 836 | .tab-content { 837 | padding: 15px; 838 | } 839 | } -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 9 | background-color: #f5f5f5; 10 | color: #333; 11 | line-height: 1.6; 12 | padding-top: 25px; 13 | } 14 | 15 | .container { 16 | max-width: 600px; 17 | min-width: calc(var(--label-width, 40mm) * 2 + 200px + 120px); /* label * scale + controls + margins */ 18 | margin: 0 auto; 19 | padding: 15px; 20 | width: max-content; 21 | position: relative; 22 | } 23 | 24 | /* Header logo styling */ 25 | .header-logo { 26 | position: absolute; 27 | top: -70px; 28 | left: -30px; 29 | z-index: 1000; 30 | width: auto; 31 | height: auto; 32 | } 33 | 34 | .header-logo .logo { 35 | height: 150px; 36 | width: auto; 37 | filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)) drop-shadow(0 1px 2px rgba(0,0,0,0.15)); 38 | } 39 | 40 | header { 41 | text-align: center; 42 | margin-bottom: 20px; 43 | } 44 | 45 | /* WYSIWYG Preview Interactions */ 46 | 47 | .clickable-icon { 48 | cursor: pointer; 49 | transition: all 0.2s ease; 50 | border-radius: 4px; 51 | position: relative; 52 | } 53 | 54 | .clickable-icon:hover { 55 | background-color: rgba(0, 123, 255, 0.1); 56 | box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.3); 57 | } 58 | 59 | .clickable-icon:hover::after { 60 | content: "Click to change"; 61 | position: absolute; 62 | background: rgba(0, 0, 0, 0.8); 63 | color: white; 64 | padding: 1px 3px; 65 | border-radius: 2px; 66 | font-size: 4px; 67 | top: -8px; 68 | left: 50%; 69 | transform: translateX(-50%) scale(0.5); 70 | white-space: nowrap; 71 | pointer-events: none; 72 | z-index: 10; 73 | } 74 | 75 | .editable-text { 76 | cursor: text; 77 | min-width: 20px; 78 | min-height: 1em; 79 | border-radius: 2px; 80 | transition: all 0.2s ease; 81 | padding: 1px 2px; 82 | text-align: left !important; 83 | direction: ltr !important; 84 | } 85 | 86 | .editable-text:hover { 87 | background-color: rgba(0, 123, 255, 0.05); 88 | outline: 1px solid rgba(0, 123, 255, 0.2); 89 | } 90 | 91 | .editable-text:focus { 92 | background-color: rgba(0, 123, 255, 0.05); 93 | outline: 2px solid rgba(0, 123, 255, 0.4); 94 | } 95 | 96 | /* Column Controls */ 97 | .column-controls { 98 | display: flex; 99 | flex-direction: column; 100 | gap: 8px; 101 | } 102 | 103 | .column-control-row { 104 | display: flex; 105 | align-items: center; 106 | justify-content: space-between; 107 | padding: 8px 12px; 108 | background: #f8f9fa; 109 | border: 1px solid #e9ecef; 110 | border-radius: 6px; 111 | } 112 | 113 | .column-control-row span { 114 | font-size: 14px; 115 | color: #495057; 116 | } 117 | 118 | .column-buttons { 119 | display: flex; 120 | align-items: center; 121 | gap: 8px; 122 | } 123 | 124 | .column-btn { 125 | width: 28px; 126 | height: 28px; 127 | border: 1px solid #dee2e6; 128 | background: white; 129 | color: #6c757d; 130 | font-size: 14px; 131 | font-weight: bold; 132 | border-radius: 4px; 133 | cursor: pointer; 134 | display: flex; 135 | align-items: center; 136 | justify-content: center; 137 | transition: all 0.2s ease; 138 | } 139 | 140 | .column-btn:hover { 141 | background: #e9ecef; 142 | border-color: #adb5bd; 143 | color: #495057; 144 | } 145 | 146 | .column-btn:active { 147 | background: #dee2e6; 148 | transform: scale(0.95); 149 | } 150 | 151 | .column-count { 152 | font-size: 14px; 153 | font-weight: 600; 154 | color: #495057; 155 | min-width: 20px; 156 | text-align: center; 157 | } 158 | 159 | main { 160 | display: flex; 161 | flex-direction: column; 162 | gap: 0; 163 | } 164 | 165 | .tab-container { 166 | background: transparent; 167 | border-radius: 0; 168 | box-shadow: none; 169 | overflow: visible; 170 | margin-bottom: 0; 171 | } 172 | 173 | .tab-buttons { 174 | display: flex; 175 | background: transparent; 176 | border-bottom: none; 177 | margin-bottom: -1px; 178 | position: relative; 179 | z-index: 1; 180 | } 181 | 182 | .tab-button { 183 | flex: 1; 184 | background: #f8f9fa; 185 | color: #666; 186 | border: 1px solid #e0e0e0; 187 | border-bottom: none; 188 | padding: 15px 20px; 189 | font-size: 16px; 190 | font-weight: 500; 191 | cursor: pointer; 192 | transition: all 0.3s; 193 | border-radius: 8px 8px 0 0; 194 | margin-right: 2px; 195 | position: relative; 196 | margin-bottom: 0; 197 | box-shadow: none; 198 | transform: none; 199 | } 200 | 201 | .tab-button:last-child { 202 | margin-right: 0; 203 | } 204 | 205 | .tab-button:hover { 206 | background: #e9ecef; 207 | color: #2c3e50; 208 | box-shadow: none; 209 | transform: none; 210 | } 211 | 212 | .tab-button.active { 213 | background: white; 214 | color: #2c3e50; 215 | border-color: #e0e0e0; 216 | border-bottom: 1px solid white; 217 | z-index: 2; 218 | font-weight: 600; 219 | box-shadow: none; 220 | transform: none; 221 | } 222 | 223 | .tab-button.active::after { 224 | content: ''; 225 | position: absolute; 226 | bottom: -1px; 227 | left: -1px; 228 | right: -1px; 229 | height: 1px; 230 | background: white; 231 | z-index: 1; 232 | } 233 | 234 | .tab-content { 235 | display: block; 236 | padding: 15px; 237 | background: white; 238 | border: 1px solid #e0e0e0; 239 | border-radius: 0 0 8px 8px; 240 | position: relative; 241 | z-index: 0; 242 | } 243 | 244 | #batch-tab { 245 | display: block; 246 | padding: 15px; 247 | background: white; 248 | border: 1px solid #e0e0e0; 249 | border-radius: 0 0 8px 8px; 250 | position: relative; 251 | z-index: 0; 252 | } 253 | 254 | .controls { 255 | background: transparent; 256 | padding: 0; 257 | border-radius: 0; 258 | box-shadow: none; 259 | } 260 | 261 | 262 | .label-settings { 263 | margin-top: 0; 264 | padding: 25px; 265 | background: #f8f9fa; 266 | border-radius: 8px; 267 | border: 1px solid #e9ecef; 268 | } 269 | 270 | .label-dimensions { 271 | margin-bottom: 15px; 272 | } 273 | 274 | .dimension-row { 275 | display: flex; 276 | gap: 15px; 277 | align-items: end; 278 | } 279 | 280 | .dimension-item { 281 | flex: 1; 282 | min-width: 0; 283 | } 284 | 285 | .dimension-item label { 286 | display: block; 287 | margin-bottom: 4px; 288 | font-weight: 600; 289 | color: #2c3e50; 290 | font-size: 14px; 291 | } 292 | 293 | .dimension-item select, 294 | .dimension-item input { 295 | width: 100%; 296 | padding: 8px; 297 | border: 2px solid #e0e0e0; 298 | border-radius: 6px; 299 | font-size: 14px; 300 | transition: all 0.3s; 301 | background: white; 302 | } 303 | 304 | .dimension-item select:focus, 305 | .dimension-item input:focus { 306 | outline: none; 307 | border-color: #3498db; 308 | box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); 309 | } 310 | 311 | .label-settings h3 { 312 | margin-top: 0; 313 | margin-bottom: 20px; 314 | color: #2c3e50; 315 | font-size: 1.2em; 316 | } 317 | 318 | .button-group { 319 | display: flex; 320 | gap: 10px; 321 | margin-top: 20px; 322 | } 323 | 324 | .button-group button { 325 | flex: 1; 326 | margin: 0; 327 | } 328 | 329 | /* DPI Controls - removed, now using inline layout */ 330 | 331 | .dpi-preset-btn { 332 | background: transparent; 333 | border: 1px solid #ccc; 334 | border-radius: 4px; 335 | height: 20px; 336 | font-size: 9px; 337 | font-weight: normal; 338 | color: #888; 339 | cursor: pointer; 340 | transition: all 0.3s; 341 | display: flex; 342 | align-items: center; 343 | justify-content: center; 344 | padding: 0 4px; 345 | box-shadow: none; 346 | transform: none; 347 | } 348 | 349 | .dpi-preset-btn:hover { 350 | background: #f5f5f5; 351 | border-color: #999; 352 | color: #666; 353 | box-shadow: none; 354 | transform: none; 355 | } 356 | 357 | .dpi-preset-btn:active { 358 | background: #3498db; 359 | border-color: #3498db; 360 | color: white; 361 | box-shadow: none; 362 | transform: none; 363 | } 364 | 365 | .png-options { 366 | margin-top: 6px; 367 | } 368 | 369 | .checkbox-label { 370 | display: flex; 371 | align-items: center; 372 | gap: 8px; 373 | font-size: 14px; 374 | cursor: pointer; 375 | margin: 0; 376 | } 377 | 378 | .checkbox-label input[type="checkbox"] { 379 | width: auto; 380 | margin: 0; 381 | } 382 | 383 | 384 | /* Export Section - Three Column Layout */ 385 | .export-section { 386 | margin-top: 15px; 387 | } 388 | 389 | .export-columns { 390 | display: flex; 391 | gap: 15px; 392 | align-items: stretch; 393 | } 394 | 395 | .export-column { 396 | flex: 1; 397 | display: flex; 398 | flex-direction: column; 399 | } 400 | 401 | .export-column .control-group { 402 | margin-bottom: 0; 403 | } 404 | 405 | /* Compact checkbox styling */ 406 | .checkbox-label.compact { 407 | margin-bottom: 8px; 408 | } 409 | 410 | /* DPI settings container */ 411 | .dpi-setting { 412 | display: flex; 413 | flex-direction: column; 414 | gap: 4px; 415 | } 416 | 417 | /* DPI input line */ 418 | .dpi-line { 419 | display: flex; 420 | align-items: center; 421 | gap: 6px; 422 | padding-right: 2px; /* Account for button gap to align edges */ 423 | } 424 | 425 | .dpi-line label { 426 | font-size: 13px; 427 | color: #6c757d; 428 | margin: 0; 429 | min-width: fit-content; 430 | } 431 | 432 | .dpi-line input { 433 | width: 75px; 434 | padding: 4px 6px; 435 | font-size: 12px; 436 | margin: 0; 437 | margin-left: auto; /* Push to right */ 438 | } 439 | 440 | /* DPI preset buttons line */ 441 | .dpi-presets { 442 | display: flex; 443 | gap: 4px; 444 | margin-left: 0; /* Align with label */ 445 | width: 100%; 446 | } 447 | 448 | .dpi-preset-btn { 449 | padding: 2px 8px; 450 | font-size: 10px; 451 | height: auto; 452 | flex: 1; /* Equal width distribution */ 453 | text-align: center; 454 | } 455 | 456 | .export-button { 457 | background-color: #3498db; 458 | color: white; 459 | border: none; 460 | padding: 12px 20px; 461 | border-radius: 6px; 462 | font-size: 14px; 463 | font-weight: 600; 464 | cursor: pointer; 465 | transition: all 0.3s; 466 | box-shadow: 0 2px 4px rgba(52, 152, 219, 0.2); 467 | width: 100%; 468 | height: 100%; 469 | display: flex; 470 | align-items: center; 471 | justify-content: center; 472 | } 473 | 474 | .export-button:hover { 475 | background-color: #2980b9; 476 | box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3); 477 | transform: translateY(-1px); 478 | } 479 | 480 | #download-png.export-button { 481 | background-color: #27ae60; 482 | box-shadow: 0 2px 4px rgba(39, 174, 96, 0.2); 483 | } 484 | 485 | #download-png.export-button:hover { 486 | background-color: #219a52; 487 | box-shadow: 0 4px 8px rgba(39, 174, 96, 0.3); 488 | } 489 | 490 | @media (max-width: 600px) { 491 | .export-columns { 492 | flex-direction: column; 493 | gap: 15px; 494 | } 495 | } 496 | 497 | .batch-controls { 498 | max-width: 800px; 499 | margin: 0 auto; 500 | } 501 | 502 | .help-section { 503 | margin-bottom: 30px; 504 | } 505 | 506 | .help-section details { 507 | background: #f8f9fa; 508 | border-radius: 6px; 509 | padding: 15px; 510 | border: 1px solid #e9ecef; 511 | } 512 | 513 | .help-section summary { 514 | cursor: pointer; 515 | font-weight: 600; 516 | color: #2c3e50; 517 | margin-bottom: 10px; 518 | } 519 | 520 | .help-content { 521 | margin-top: 15px; 522 | } 523 | 524 | .llm-prompt { 525 | width: 100%; 526 | min-height: 200px; 527 | font-family: 'Courier New', monospace; 528 | font-size: 14px; 529 | background: #f8f9fa; 530 | border: 1px solid #e0e0e0; 531 | border-radius: 4px; 532 | padding: 15px; 533 | resize: vertical; 534 | line-height: 1.4; 535 | } 536 | 537 | .copy-button { 538 | background-color: #17a2b8; 539 | margin-top: 10px; 540 | } 541 | 542 | .copy-button:hover { 543 | background-color: #138496; 544 | } 545 | 546 | #yaml-input { 547 | width: 100%; 548 | min-height: 300px; 549 | font-family: 'Courier New', monospace; 550 | font-size: 14px; 551 | background: white; 552 | border: 2px solid #e0e0e0; 553 | border-radius: 4px; 554 | padding: 15px; 555 | resize: vertical; 556 | line-height: 1.4; 557 | } 558 | 559 | #yaml-input:focus { 560 | outline: none; 561 | border-color: #3498db; 562 | } 563 | 564 | /* Show fallback textarea if CodeMirror fails to load */ 565 | .codemirror-loaded #yaml-input { 566 | display: none; 567 | } 568 | 569 | .batch-options { 570 | margin: 20px 0; 571 | padding: 15px; 572 | background: #f8f9fa; 573 | border-radius: 6px; 574 | border: 1px solid #e9ecef; 575 | } 576 | 577 | .checkbox-group { 578 | display: flex; 579 | flex-direction: column; 580 | gap: 10px; 581 | } 582 | 583 | .checkbox-label { 584 | display: flex; 585 | align-items: center; 586 | gap: 8px; 587 | font-size: 14px; 588 | color: #2c3e50; 589 | cursor: pointer; 590 | } 591 | 592 | .checkbox-label input[type="checkbox"] { 593 | width: auto; 594 | margin: 0; 595 | cursor: pointer; 596 | } 597 | 598 | .batch-buttons { 599 | display: flex; 600 | gap: 10px; 601 | margin: 20px 0; 602 | } 603 | 604 | .batch-buttons button { 605 | flex: 1; 606 | } 607 | 608 | .validation-result { 609 | margin-top: 20px; 610 | padding: 15px; 611 | border-radius: 4px; 612 | display: none; 613 | } 614 | 615 | .validation-result.success { 616 | background: #d4edda; 617 | border: 1px solid #c3e6cb; 618 | color: #155724; 619 | } 620 | 621 | .validation-result.error { 622 | background: #f8d7da; 623 | border: 1px solid #f5c6cb; 624 | color: #721c24; 625 | } 626 | 627 | .validation-result.warning { 628 | background: #fff3cd; 629 | border: 1px solid #ffeaa7; 630 | color: #856404; 631 | } 632 | 633 | .control-group { 634 | margin-bottom: 10px; 635 | } 636 | 637 | .control-group label { 638 | display: block; 639 | margin-bottom: 4px; 640 | font-weight: 600; 641 | color: #2c3e50; 642 | font-size: 14px; 643 | } 644 | 645 | .control-group input, 646 | .control-group select { 647 | width: 100%; 648 | padding: 8px; 649 | border: 2px solid #e0e0e0; 650 | border-radius: 6px; 651 | font-size: 14px; 652 | transition: all 0.3s; 653 | background: white; 654 | } 655 | 656 | .control-group input:focus, 657 | .control-group select:focus { 658 | outline: none; 659 | border-color: #3498db; 660 | box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); 661 | } 662 | 663 | button { 664 | background-color: #3498db; 665 | color: white; 666 | border: none; 667 | padding: 8px 16px; 668 | border-radius: 6px; 669 | font-size: 14px; 670 | font-weight: 500; 671 | cursor: pointer; 672 | margin-bottom: 10px; 673 | transition: all 0.3s; 674 | box-shadow: 0 2px 4px rgba(52, 152, 219, 0.2); 675 | } 676 | 677 | button:hover { 678 | background-color: #2980b9; 679 | box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3); 680 | transform: translateY(-1px); 681 | } 682 | 683 | #download-png { 684 | background-color: #27ae60; 685 | box-shadow: 0 2px 4px rgba(39, 174, 96, 0.2); 686 | } 687 | 688 | #download-png:hover { 689 | background-color: #219a52; 690 | box-shadow: 0 4px 8px rgba(39, 174, 96, 0.3); 691 | } 692 | 693 | 694 | .label { 695 | display: flex; 696 | align-items: center; 697 | background: white; 698 | border: 1px solid #ddd; 699 | border-radius: 2px; 700 | padding: 2px; 701 | height: 12mm; 702 | width: 40mm; 703 | box-shadow: 0 1px 3px rgba(0,0,0,0.2); 704 | transform: scale(2); 705 | margin: 30px; 706 | } 707 | 708 | .label-icon { 709 | width: 8mm; 710 | height: 8mm; 711 | margin: 2mm; 712 | flex-shrink: 0; 713 | display: flex; 714 | align-items: center; 715 | justify-content: center; 716 | } 717 | 718 | .label-icon .icon { 719 | width: 100%; 720 | height: 100%; 721 | fill: #333; 722 | } 723 | 724 | .label-text { 725 | flex: 1; 726 | padding: 1mm; 727 | overflow: hidden; 728 | display: flex; 729 | flex-direction: column; 730 | justify-content: center; 731 | } 732 | 733 | .main-text { 734 | font-size: 12px; 735 | font-weight: bold; 736 | color: #000; 737 | line-height: 1.2; 738 | margin-bottom: 1px; 739 | display: flex; 740 | gap: 2px; 741 | width: 100%; 742 | align-items: center; 743 | justify-content: flex-start; 744 | } 745 | 746 | .sub-text { 747 | font-size: 8px; 748 | color: #666; 749 | line-height: 1.2; 750 | display: flex; 751 | gap: 2px; 752 | width: 100%; 753 | align-items: center; 754 | justify-content: flex-start; 755 | } 756 | 757 | .label[data-height="9"] { 758 | height: 9mm; 759 | } 760 | 761 | .label[data-height="12"] { 762 | height: 12mm; 763 | } 764 | 765 | .label[data-height="18"] { 766 | height: 18mm; 767 | } 768 | 769 | .label[data-height="24"] { 770 | height: 24mm; 771 | } 772 | 773 | .label[data-height="9"] .label-icon { 774 | width: 6mm; 775 | height: 6mm; 776 | } 777 | 778 | .label[data-height="9"] .main-text { 779 | font-size: 6px; 780 | } 781 | 782 | .label[data-height="9"] .sub-text { 783 | font-size: 4px; 784 | } 785 | 786 | .label[data-height="18"] .label-icon { 787 | width: 14mm; 788 | height: 14mm; 789 | } 790 | 791 | .label[data-height="18"] .main-text { 792 | font-size: 12px; 793 | } 794 | 795 | .label[data-height="18"] .sub-text { 796 | font-size: 8px; 797 | } 798 | 799 | .label[data-height="24"] .label-icon { 800 | width: 18mm; 801 | height: 18mm; 802 | } 803 | 804 | .label[data-height="24"] .main-text { 805 | font-size: 14px; 806 | } 807 | 808 | .label[data-height="24"] .sub-text { 809 | font-size: 10px; 810 | } 811 | 812 | @media (max-width: 768px) { 813 | .tab-content { 814 | grid-template-columns: 1fr; 815 | padding: 20px; 816 | } 817 | 818 | .container { 819 | padding: 10px; 820 | } 821 | 822 | .tab-buttons { 823 | flex-direction: row; 824 | } 825 | 826 | .tab-button { 827 | padding: 12px 16px; 828 | font-size: 14px; 829 | } 830 | 831 | .preview-container { 832 | padding: 20px; 833 | min-height: 150px; 834 | } 835 | 836 | .label-settings { 837 | padding: 20px; 838 | } 839 | 840 | .button-group { 841 | flex-direction: column; 842 | } 843 | 844 | .button-group button { 845 | margin-right: 0; 846 | margin-bottom: 10px; 847 | } 848 | } 849 | 850 | /* CodeMirror YAML Editor Styling */ 851 | .CodeMirror { 852 | border: 2px solid #e0e0e0; 853 | border-radius: 6px; 854 | font-family: 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 855 | font-size: 14px; 856 | line-height: 1.4; 857 | background: white; 858 | transition: border-color 0.3s; 859 | } 860 | 861 | .CodeMirror-focused { 862 | border-color: #3498db; 863 | box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); 864 | } 865 | 866 | .CodeMirror-gutters { 867 | background: #f8f9fa; 868 | border-right: 1px solid #e9ecef; 869 | } 870 | 871 | .CodeMirror-linenumber { 872 | color: #6c757d; 873 | font-size: 12px; 874 | } 875 | 876 | .CodeMirror-cursor { 877 | border-left: 2px solid #3498db; 878 | } 879 | 880 | /* YAML Syntax Highlighting */ 881 | .cm-comment { 882 | color: #6c757d; 883 | font-style: italic; 884 | } 885 | 886 | .cm-string { 887 | color: #28a745; 888 | } 889 | 890 | .cm-number { 891 | color: #dc3545; 892 | } 893 | 894 | .cm-keyword { 895 | color: #6f42c1; 896 | font-weight: bold; 897 | } 898 | 899 | .cm-atom { 900 | color: #fd7e14; 901 | } 902 | 903 | .cm-def { 904 | color: #007bff; 905 | font-weight: bold; 906 | } 907 | 908 | .cm-variable { 909 | color: #495057; 910 | } 911 | 912 | .cm-punctuation { 913 | color: #6c757d; 914 | } 915 | 916 | /* Hide original textarea when CodeMirror is active */ 917 | #yaml-input { 918 | display: none; 919 | } 920 | 921 | /* Icon Picker Styles */ 922 | .icon-picker { 923 | position: fixed; 924 | top: 50%; 925 | left: 50%; 926 | transform: translate(-50%, -50%); 927 | z-index: 9999; 928 | background: white; 929 | border-radius: 8px; 930 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); 931 | max-width: 600px; 932 | width: 90vw; 933 | } 934 | 935 | .icon-picker-overlay { 936 | position: fixed; 937 | top: 0; 938 | left: 0; 939 | right: 0; 940 | bottom: 0; 941 | background: rgba(0, 0, 0, 0.5); 942 | z-index: 9998; 943 | opacity: 0; 944 | visibility: hidden; 945 | transition: all 0.3s ease; 946 | } 947 | 948 | .icon-picker-overlay.active { 949 | opacity: 1; 950 | visibility: visible; 951 | } 952 | 953 | .icon-picker-header { 954 | display: flex; 955 | justify-content: space-between; 956 | align-items: center; 957 | padding: 16px 20px; 958 | border-bottom: 1px solid #e9ecef; 959 | background: #f8f9fa; 960 | border-radius: 8px 8px 0 0; 961 | } 962 | 963 | .icon-picker-header h3 { 964 | margin: 0; 965 | font-size: 18px; 966 | color: #495057; 967 | } 968 | 969 | .icon-picker-close { 970 | background: none; 971 | border: none; 972 | font-size: 24px; 973 | color: #6c757d; 974 | cursor: pointer; 975 | padding: 0; 976 | width: 30px; 977 | height: 30px; 978 | display: flex; 979 | align-items: center; 980 | justify-content: center; 981 | border-radius: 4px; 982 | transition: all 0.2s ease; 983 | } 984 | 985 | .icon-picker-close:hover { 986 | background: #e9ecef; 987 | color: #495057; 988 | } 989 | 990 | .icon-picker-selected { 991 | display: flex; 992 | align-items: center; 993 | gap: 12px; 994 | padding: 12px; 995 | border: 2px solid #e0e0e0; 996 | border-radius: 6px; 997 | background: white; 998 | cursor: pointer; 999 | transition: all 0.3s; 1000 | } 1001 | 1002 | .icon-picker-selected:hover { 1003 | border-color: #3498db; 1004 | } 1005 | 1006 | .icon-picker-selected.active { 1007 | border-color: #3498db; 1008 | box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); 1009 | } 1010 | 1011 | .selected-icon { 1012 | display: flex; 1013 | align-items: center; 1014 | gap: 8px; 1015 | flex: 1; 1016 | } 1017 | 1018 | .selected-icon img { 1019 | width: 24px; 1020 | height: 24px; 1021 | object-fit: contain; 1022 | } 1023 | 1024 | .selected-icon span { 1025 | font-size: 14px; 1026 | color: #495057; 1027 | } 1028 | 1029 | .icon-picker-button { 1030 | background: #f8f9fa; 1031 | border: 1px solid #dee2e6; 1032 | border-radius: 4px; 1033 | padding: 6px 12px; 1034 | font-size: 12px; 1035 | color: #6c757d; 1036 | cursor: pointer; 1037 | transition: all 0.3s; 1038 | } 1039 | 1040 | .icon-picker-button:hover { 1041 | background: #e9ecef; 1042 | color: #495057; 1043 | } 1044 | 1045 | .icon-picker-dropdown { 1046 | /* This class is no longer used in the modal layout */ 1047 | display: none; 1048 | overflow: hidden; 1049 | margin-top: 4px; 1050 | } 1051 | 1052 | .icon-picker-search { 1053 | padding: 12px; 1054 | border-bottom: 1px solid #e9ecef; 1055 | display: flex; 1056 | gap: 8px; 1057 | align-items: center; 1058 | } 1059 | 1060 | .icon-picker-search input[type="text"] { 1061 | flex: 1; 1062 | padding: 8px 12px; 1063 | border: 1px solid #dee2e6; 1064 | border-radius: 4px; 1065 | font-size: 14px; 1066 | margin: 0; 1067 | } 1068 | 1069 | .icon-picker-grid { 1070 | display: grid; 1071 | grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); 1072 | gap: 4px; 1073 | padding: 8px; 1074 | max-height: 400px; 1075 | overflow-y: auto; 1076 | } 1077 | 1078 | .icon-picker-item { 1079 | display: flex; 1080 | flex-direction: column; 1081 | align-items: center; 1082 | padding: 4px; 1083 | border: 2px solid transparent; 1084 | border-radius: 4px; 1085 | cursor: pointer; 1086 | transition: all 0.3s; 1087 | background: #f8f9fa; 1088 | text-align: center; 1089 | } 1090 | 1091 | .icon-picker-item:hover { 1092 | background: #e9ecef; 1093 | border-color: #3498db; 1094 | } 1095 | 1096 | .icon-picker-item.selected { 1097 | background: #e3f2fd; 1098 | border-color: #3498db; 1099 | } 1100 | 1101 | .icon-picker-item img { 1102 | width: 28px; 1103 | height: 28px; 1104 | object-fit: contain; 1105 | margin-bottom: 3px; 1106 | } 1107 | 1108 | .icon-picker-item span { 1109 | font-size: 9px; 1110 | color: #495057; 1111 | line-height: 1.1; 1112 | word-break: break-word; 1113 | } 1114 | 1115 | .icon-picker-category { 1116 | grid-column: 1 / -1; 1117 | font-size: 12px; 1118 | font-weight: 600; 1119 | color: #6c757d; 1120 | margin-top: 8px; 1121 | margin-bottom: 4px; 1122 | padding-bottom: 4px; 1123 | border-bottom: 1px solid #e9ecef; 1124 | } 1125 | 1126 | .icon-picker-category:first-child { 1127 | margin-top: 0; 1128 | } 1129 | 1130 | /* Icon Upload Section */ 1131 | .icon-upload-section { 1132 | padding: 12px; 1133 | border-bottom: 1px solid #e9ecef; 1134 | background: #f8f9fa; 1135 | text-align: center; 1136 | } 1137 | 1138 | .upload-icon-btn { 1139 | background: #28a745; 1140 | color: white; 1141 | border: none; 1142 | padding: 8px 16px; 1143 | border-radius: 4px; 1144 | font-size: 12px; 1145 | cursor: pointer; 1146 | transition: background 0.3s; 1147 | margin-bottom: 8px; 1148 | } 1149 | 1150 | .upload-icon-btn:hover { 1151 | background: #218838; 1152 | } 1153 | 1154 | .upload-instructions { 1155 | color: #6c757d; 1156 | font-size: 11px; 1157 | line-height: 1.3; 1158 | } 1159 | 1160 | .upload-instructions a { 1161 | color: #007bff; 1162 | text-decoration: none; 1163 | } 1164 | 1165 | .upload-instructions a:hover { 1166 | text-decoration: underline; 1167 | } 1168 | 1169 | .custom-icon-category { 1170 | background: #e8f5e8; 1171 | border: 1px solid #28a745; 1172 | border-radius: 4px; 1173 | padding: 4px 8px; 1174 | color: #155724; 1175 | font-weight: bold; 1176 | } 1177 | 1178 | .delete-icon-btn { 1179 | position: absolute; 1180 | top: 2px; 1181 | right: 2px; 1182 | background: #dc3545; 1183 | color: white; 1184 | border: none; 1185 | border-radius: 50%; 1186 | width: 18px; 1187 | height: 18px; 1188 | min-width: 18px; 1189 | min-height: 18px; 1190 | max-width: 18px; 1191 | max-height: 18px; 1192 | font-size: 12px; 1193 | line-height: 1; 1194 | cursor: pointer; 1195 | display: none; 1196 | align-items: center; 1197 | justify-content: center; 1198 | z-index: 10; 1199 | padding: 0; 1200 | } 1201 | 1202 | .icon-picker-item { 1203 | position: relative; 1204 | } 1205 | 1206 | .icon-picker-item:hover .delete-icon-btn { 1207 | display: flex; 1208 | } 1209 | 1210 | .delete-icon-btn:hover { 1211 | background: #c82333; 1212 | } 1213 | 1214 | /* Footer Styles */ 1215 | footer { 1216 | margin-top: 20px; 1217 | } 1218 | 1219 | .attribution { 1220 | text-align: center; 1221 | font-size: 12px; 1222 | color: #666; 1223 | } 1224 | 1225 | .attribution a { 1226 | color: #3498db; 1227 | text-decoration: none; 1228 | } 1229 | 1230 | .attribution a:hover { 1231 | text-decoration: underline; 1232 | } 1233 | 1234 | /* Label with Controls Styles */ 1235 | .label-with-controls { 1236 | display: flex; 1237 | align-items: center; 1238 | justify-content: space-between; 1239 | margin-bottom: 4px; 1240 | } 1241 | 1242 | .inline-controls { 1243 | display: flex; 1244 | align-items: center; 1245 | gap: 4px; 1246 | } 1247 | 1248 | /* Tooltip Icon Styles */ 1249 | .tooltip-icon { 1250 | display: inline-block; 1251 | width: 16px; 1252 | height: 16px; 1253 | background: #6c757d; 1254 | color: white; 1255 | border-radius: 50%; 1256 | font-size: 12px; 1257 | font-weight: bold; 1258 | text-align: center; 1259 | line-height: 16px; 1260 | cursor: help; 1261 | margin-left: 6px; 1262 | transition: background-color 0.3s; 1263 | } 1264 | 1265 | .tooltip-icon:hover { 1266 | background: #495057; 1267 | } 1268 | 1269 | .small-column-btn, .small-sub-column-btn { 1270 | background: white; 1271 | border: 1px solid #dee2e6; 1272 | border-radius: 4px; 1273 | width: 24px; 1274 | height: 24px; 1275 | font-size: 14px; 1276 | font-weight: bold; 1277 | color: #6c757d; 1278 | cursor: pointer; 1279 | transition: all 0.3s; 1280 | display: flex; 1281 | align-items: center; 1282 | justify-content: center; 1283 | padding: 0; 1284 | box-shadow: none; 1285 | transform: none; 1286 | } 1287 | 1288 | .small-column-btn:hover, .small-sub-column-btn:hover { 1289 | background: #e9ecef; 1290 | border-color: #adb5bd; 1291 | box-shadow: none; 1292 | transform: none; 1293 | } 1294 | 1295 | .small-column-btn:active, .small-sub-column-btn:active { 1296 | background: #3498db; 1297 | border-color: #3498db; 1298 | color: white; 1299 | box-shadow: none; 1300 | transform: none; 1301 | } 1302 | 1303 | .main-text-inputs { 1304 | display: flex; 1305 | gap: 6px; 1306 | } 1307 | 1308 | .main-text-input, 1309 | .sub-text-input { 1310 | flex: 1; 1311 | min-width: 0; 1312 | padding: 6px; 1313 | border: 2px solid #e0e0e0; 1314 | border-radius: 6px; 1315 | font-size: 14px; 1316 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 1317 | background: white; 1318 | transition: border-color 0.3s; 1319 | } 1320 | 1321 | .main-text-input:focus, 1322 | .sub-text-input:focus { 1323 | outline: none; 1324 | border-color: #3498db; 1325 | box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); 1326 | } 1327 | 1328 | .sub-text-inputs { 1329 | display: flex; 1330 | gap: 6px; 1331 | } 1332 | 1333 | /* Old sub-column-btn styles removed - now using .small-sub-column-btn above */ 1334 | 1335 | .main-text-column { 1336 | flex: 1; 1337 | text-align: left; 1338 | font-size: inherit; 1339 | font-weight: inherit; 1340 | color: inherit; 1341 | line-height: inherit; 1342 | padding-right: 4px; 1343 | white-space: nowrap; 1344 | overflow: hidden; 1345 | min-width: 0; 1346 | direction: ltr; 1347 | text-overflow: clip; 1348 | text-align: left !important; 1349 | scroll-behavior: auto; 1350 | } 1351 | 1352 | .main-text-column::-webkit-scrollbar { 1353 | display: none; 1354 | } 1355 | 1356 | .sub-text-column { 1357 | flex: 1; 1358 | text-align: left; 1359 | font-size: inherit; 1360 | font-weight: inherit; 1361 | color: inherit; 1362 | line-height: inherit; 1363 | padding-right: 4px; 1364 | white-space: nowrap; 1365 | overflow: hidden; 1366 | min-width: 0; 1367 | direction: ltr; 1368 | text-overflow: clip; 1369 | text-align: left !important; 1370 | scroll-behavior: auto; 1371 | } 1372 | 1373 | .sub-text-column::-webkit-scrollbar { 1374 | display: none; 1375 | } 1376 | 1377 | /* Column count displays removed */ 1378 | 1379 | /* WYSIWYG Editor Layout */ 1380 | .wysiwyg-editor { 1381 | margin-bottom: 20px; 1382 | } 1383 | 1384 | .width-control { 1385 | display: flex; 1386 | justify-content: center; 1387 | margin-bottom: 10px; 1388 | } 1389 | 1390 | .dimensional-line { 1391 | display: flex; 1392 | align-items: center; 1393 | gap: 8px; 1394 | } 1395 | 1396 | .dimensional-line.horizontal { 1397 | flex-direction: row; 1398 | } 1399 | 1400 | .dimensional-line.vertical { 1401 | flex-direction: column; 1402 | height: 120px; 1403 | justify-content: center; 1404 | } 1405 | 1406 | .dimension-arrow { 1407 | font-size: 14px; 1408 | color: #666; 1409 | font-weight: bold; 1410 | } 1411 | 1412 | .dimension-control { 1413 | display: flex; 1414 | flex-direction: column; 1415 | align-items: center; 1416 | gap: 4px; 1417 | } 1418 | 1419 | .dimension-control.vertical { 1420 | writing-mode: vertical-lr; 1421 | text-orientation: mixed; 1422 | } 1423 | 1424 | .dimension-control input { 1425 | width: 60px; 1426 | padding: 4px 6px; 1427 | border: 1px solid #ddd; 1428 | border-radius: 4px; 1429 | font-size: 12px; 1430 | text-align: center; 1431 | } 1432 | 1433 | .dimension-control.vertical input { 1434 | writing-mode: horizontal-tb; 1435 | text-orientation: upright; 1436 | } 1437 | 1438 | .dimension-control label { 1439 | font-size: 10px; 1440 | color: #666; 1441 | margin: 0; 1442 | white-space: nowrap; 1443 | } 1444 | 1445 | .dimension-control.vertical label { 1446 | writing-mode: vertical-lr; 1447 | text-orientation: mixed; 1448 | } 1449 | 1450 | .preview-container { 1451 | display: flex; 1452 | align-items: center; 1453 | justify-content: center; 1454 | margin-bottom: 20px; 1455 | position: relative; 1456 | min-height: 120px; 1457 | width: 100%; 1458 | min-width: calc(var(--label-width, 40mm) * 2 + 160px); /* ensure controls fit */ 1459 | overflow: visible; 1460 | } 1461 | 1462 | .height-control { 1463 | display: flex; 1464 | align-items: center; 1465 | position: absolute; 1466 | left: 50%; 1467 | top: 50%; 1468 | transform: translate(calc(-100% - var(--label-width, 40mm) - 40px), -50%); 1469 | z-index: 10; 1470 | } 1471 | 1472 | .preview-area { 1473 | display: flex; 1474 | justify-content: center; 1475 | align-items: center; 1476 | position: relative; 1477 | } 1478 | 1479 | .preview-area #header-label-preview { 1480 | transform: scale(2); 1481 | margin: 30px; 1482 | } 1483 | 1484 | .column-controls-right { 1485 | display: flex; 1486 | flex-direction: column; 1487 | gap: 15px; 1488 | align-items: flex-start; 1489 | position: absolute; 1490 | left: 50%; 1491 | top: 50%; 1492 | transform: translate(calc(var(--label-width, 40mm) + 40px), -50%); 1493 | z-index: 10; 1494 | } 1495 | 1496 | .main-text-control, 1497 | .sub-text-control { 1498 | display: flex; 1499 | flex-direction: column; 1500 | gap: 4px; 1501 | align-items: center; 1502 | } 1503 | 1504 | .main-text-control label, 1505 | .sub-text-control label { 1506 | font-size: 11px; 1507 | color: #666; 1508 | font-weight: 600; 1509 | margin: 0; 1510 | } 1511 | 1512 | .column-buttons-right { 1513 | display: flex; 1514 | align-items: center; 1515 | gap: 4px; 1516 | } 1517 | 1518 | .column-btn-small { 1519 | width: 20px; 1520 | height: 20px; 1521 | border: 1px solid #dee2e6; 1522 | background: white; 1523 | color: #6c757d; 1524 | font-size: 12px; 1525 | font-weight: bold; 1526 | border-radius: 3px; 1527 | cursor: pointer; 1528 | display: flex; 1529 | align-items: center; 1530 | justify-content: center; 1531 | transition: all 0.2s ease; 1532 | padding: 0; 1533 | margin: 0; 1534 | box-shadow: none; 1535 | transform: none; 1536 | } 1537 | 1538 | .column-btn-small:hover { 1539 | background: #e9ecef; 1540 | border-color: #adb5bd; 1541 | color: #495057; 1542 | box-shadow: none; 1543 | transform: none; 1544 | } 1545 | 1546 | .column-btn-small:active { 1547 | background: #dee2e6; 1548 | box-shadow: none; 1549 | transform: scale(0.95); 1550 | } 1551 | 1552 | .column-controls-right .column-count { 1553 | font-size: 12px; 1554 | font-weight: 600; 1555 | color: #495057; 1556 | min-width: 15px; 1557 | text-align: center; 1558 | } 1559 | 1560 | /* Full width export section */ 1561 | .full-width { 1562 | width: 100%; 1563 | } 1564 | 1565 | .export-section .button-group { 1566 | display: flex; 1567 | gap: 10px; 1568 | margin-top: 15px; 1569 | } 1570 | 1571 | .export-section .button-group button { 1572 | flex: 1; 1573 | } 1574 | 1575 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | class LabelMaker { 2 | constructor() { 3 | this.icons = this.loadIconsFromStructure(); 4 | 5 | this.customIcons = this.loadCustomIcons(); 6 | this.initializeEventListeners(); 7 | this.initializeDefaultColumns(); 8 | this.updatePreview(); 9 | this.initializeTabs(); 10 | this.loadAvailableIcons(); 11 | this.initializeYamlEditor(); 12 | this.initializeIconPicker(); 13 | this.initializeIconUpload(); 14 | this.loadDPISettings(); 15 | } 16 | 17 | loadIconsFromStructure() { 18 | const iconMapping = { 19 | 'electronics': { 20 | 'wago-logo': 'Wago Logo', 21 | 'wago-alt1': 'Wago Alt 1', 22 | 'wago-alt2': 'Wago Alt 2', 23 | 'wire-nut': 'Wire Nut', 24 | 'generic': 'Generic Electrical' 25 | }, 26 | 'heads': { 27 | 'cross': 'Cross Head', 28 | 'hex-external': 'Hex External', 29 | 'hex-socket': 'Hex Socket', 30 | 'phillips': 'Phillips Head', 31 | 'pozidriv': 'Pozidriv', 32 | 'robertson': 'Robertson Head', 33 | 'slotted': 'Slotted Head', 34 | 'square-external': 'Square External', 35 | 'ta': 'TA Head', 36 | 'torx': 'Torx Head', 37 | 'torx-tamperproof': 'Torx Tamperproof' 38 | }, 39 | 'inserts': { 40 | 'heat': 'Heat Insert', 41 | 'wood': 'Wood Insert' 42 | }, 43 | 'nuts': { 44 | 'nut-cap': 'Cap Nut', 45 | 'nut-lock': 'Lock Nut', 46 | 'nut-standard': 'Standard Nut' 47 | }, 48 | 'fasteners': { 49 | 'screw-round': 'Round Screw', 50 | 'screw-tbolt': 'T-Bolt', 51 | 'screw-truss': 'Truss Screw', 52 | 'screw-truss-modified': 'Truss Modified', 53 | 'screw-wafer': 'Wafer Screw', 54 | 'screw-bugle': 'Bugle Screw', 55 | 'screw-fillister': 'Fillister Screw', 56 | 'screw-flat': 'Flat Screw', 57 | 'screw-hex': 'Hex Screw', 58 | 'screw-oval': 'Oval Screw', 59 | 'screw-pan': 'Pan Screw', 60 | 'screw-pan-hex': 'Pan Hex Screw', 61 | 'screw-thumb-knurled': 'Thumb Knurled', 62 | 'screw-trim': 'Trim Screw', 63 | 'thumb-screw': 'Thumb Screw' 64 | }, 65 | 'washers': { 66 | 'fender': 'Fender Washer', 67 | 'flat': 'Flat Washer', 68 | 'split': 'Split Washer', 69 | 'star-exterior': 'Star Exterior', 70 | 'star-interior': 'Star Interior' 71 | } 72 | }; 73 | 74 | const icons = {}; 75 | const availableIcons = []; 76 | 77 | Object.entries(iconMapping).forEach(([category, items]) => { 78 | Object.entries(items).forEach(([filename, displayName]) => { 79 | const iconKey = `${category}_${filename}`.replace(/-/g, '_'); 80 | // Determine file extension based on category - heads use SVG, others use PNG 81 | const extension = category === 'heads' ? 'svg' : 'png'; 82 | // For heads, convert filename to match actual file structure 83 | let actualFilename = filename; 84 | if (category === 'heads') { 85 | actualFilename = `Screw_Head_-_${filename.split('-').map(word => 86 | word.charAt(0).toUpperCase() + word.slice(1) 87 | ).join('_')}`; 88 | } 89 | const iconPath = `icons/${category}/${actualFilename}.${extension}`; 90 | icons[iconKey] = iconPath; 91 | availableIcons.push(iconKey); 92 | }); 93 | }); 94 | 95 | this.availableIcons = availableIcons; 96 | return icons; 97 | } 98 | 99 | setupEditableTextHandlers(element) { 100 | const resetScroll = () => { 101 | element.scrollLeft = 0; 102 | // Force selection to start if text is focused 103 | if (document.activeElement === element) { 104 | const selection = window.getSelection(); 105 | if (selection.rangeCount > 0) { 106 | const range = selection.getRangeAt(0); 107 | if (range.endOffset > element.textContent.length || element.scrollLeft > 0) { 108 | element.scrollLeft = 0; 109 | } 110 | } 111 | } 112 | }; 113 | 114 | element.addEventListener('input', () => { 115 | this.syncEditableTextToInputs(); 116 | // Force scroll to beginning to show start of text 117 | setTimeout(resetScroll, 0); 118 | }); 119 | 120 | element.addEventListener('blur', () => { 121 | this.syncEditableTextToInputs(); 122 | resetScroll(); 123 | }); 124 | 125 | element.addEventListener('focus', () => { 126 | resetScroll(); 127 | }); 128 | 129 | element.addEventListener('keyup', () => { 130 | resetScroll(); 131 | }); 132 | } 133 | 134 | initializeEventListeners() { 135 | // Basic form elements 136 | const iconSelect = document.getElementById('icon-select'); 137 | const labelHeight = document.getElementById('label-height'); 138 | const labelWidth = document.getElementById('label-width'); 139 | const downloadPng = document.getElementById('download-png'); 140 | const validateYaml = document.getElementById('validate-yaml'); 141 | const generateZip = document.getElementById('generate-zip'); 142 | 143 | if (iconSelect) iconSelect.addEventListener('change', () => { 144 | this.updatePreview(); 145 | this.syncSingleToYaml(); 146 | }); 147 | if (labelHeight) labelHeight.addEventListener('change', () => { 148 | this.updatePreview(); 149 | this.syncSingleToYaml(); 150 | }); 151 | if (labelWidth) labelWidth.addEventListener('input', () => { 152 | this.updatePreview(); 153 | this.syncSingleToYaml(); 154 | }); 155 | if (downloadPng) downloadPng.addEventListener('click', () => this.downloadPNG()); 156 | 157 | // Dimension arrow controls 158 | this.initializeDimensionArrows(); 159 | 160 | // DPI preset buttons 161 | document.querySelectorAll('[data-dpi]').forEach(btn => { 162 | btn.addEventListener('click', (e) => { 163 | const dpi = e.target.dataset.dpi; 164 | document.getElementById('png-dpi').value = dpi; 165 | }); 166 | }); 167 | 168 | const downloadSvg = document.getElementById('download-svg'); 169 | if (downloadSvg) downloadSvg.addEventListener('click', () => this.downloadSVG()); 170 | if (validateYaml) validateYaml.addEventListener('click', () => this.validateYAML()); 171 | if (generateZip) generateZip.addEventListener('click', () => this.generateZIP()); 172 | 173 | // WYSIWYG preview interactions 174 | this.setupPreviewInteractions(); 175 | 176 | // Initial setup for text inputs 177 | this.setupMainTextInputs(); 178 | this.setupSubTextInputs(); 179 | } 180 | 181 | initializeDimensionArrows() { 182 | // Width arrows (horizontal) 183 | const widthArrows = document.querySelectorAll('.width-control .dimension-arrow'); 184 | const widthInput = document.getElementById('label-width'); 185 | 186 | if (widthArrows.length >= 2 && widthInput) { 187 | const leftArrow = widthArrows[0]; // ← 188 | const rightArrow = widthArrows[1]; // → 189 | 190 | leftArrow.addEventListener('click', () => { 191 | const currentValue = parseInt(widthInput.value); 192 | const minValue = parseInt(widthInput.min); 193 | if (currentValue > minValue) { 194 | widthInput.value = currentValue - 1; 195 | widthInput.dispatchEvent(new Event('input')); 196 | } 197 | }); 198 | 199 | rightArrow.addEventListener('click', () => { 200 | const currentValue = parseInt(widthInput.value); 201 | const maxValue = parseInt(widthInput.max); 202 | if (currentValue < maxValue) { 203 | widthInput.value = currentValue + 1; 204 | widthInput.dispatchEvent(new Event('input')); 205 | } 206 | }); 207 | } 208 | 209 | // Height arrows (vertical) 210 | const heightArrows = document.querySelectorAll('.height-control .dimension-arrow'); 211 | const heightInput = document.getElementById('label-height'); 212 | 213 | if (heightArrows.length >= 2 && heightInput) { 214 | const upArrow = heightArrows[0]; // ↑ 215 | const downArrow = heightArrows[1]; // ↓ 216 | 217 | upArrow.addEventListener('click', () => { 218 | const currentValue = parseInt(heightInput.value); 219 | const maxValue = parseInt(heightInput.max); 220 | if (currentValue < maxValue) { 221 | heightInput.value = currentValue + 1; 222 | heightInput.dispatchEvent(new Event('change')); 223 | } 224 | }); 225 | 226 | downArrow.addEventListener('click', () => { 227 | const currentValue = parseInt(heightInput.value); 228 | const minValue = parseInt(heightInput.min); 229 | if (currentValue > minValue) { 230 | heightInput.value = currentValue - 1; 231 | heightInput.dispatchEvent(new Event('change')); 232 | } 233 | }); 234 | } 235 | } 236 | 237 | setupPreviewInteractions() { 238 | // Add click handler to existing overlay 239 | const overlay = document.querySelector('.icon-picker-overlay'); 240 | if (overlay) { 241 | overlay.addEventListener('click', () => { 242 | this.closeIconPicker(); 243 | }); 244 | } 245 | 246 | // Click handler for preview icon to open icon picker 247 | const previewIcon = document.querySelector('.clickable-icon'); 248 | if (previewIcon) { 249 | previewIcon.addEventListener('click', () => { 250 | this.openIconPicker(); 251 | }); 252 | } 253 | 254 | // Content editable text change handlers 255 | document.querySelectorAll('.editable-text').forEach(element => { 256 | this.setupEditableTextHandlers(element); 257 | }); 258 | 259 | 260 | // Column control buttons in form 261 | document.querySelectorAll('.column-btn, .column-btn-small').forEach(btn => { 262 | btn.addEventListener('click', (e) => { 263 | const action = e.target.dataset.action; 264 | const type = e.target.dataset.type; 265 | this.updateColumn(type, action); 266 | }); 267 | }); 268 | } 269 | 270 | syncEditableTextToInputs() { 271 | // Sync main text 272 | const mainTextColumns = document.querySelectorAll('.main-text-column'); 273 | const mainTextInputs = document.querySelectorAll('.main-text-input'); 274 | 275 | mainTextColumns.forEach((column, index) => { 276 | if (mainTextInputs[index]) { 277 | mainTextInputs[index].value = column.textContent.trim(); 278 | } 279 | }); 280 | 281 | // Sync sub text 282 | const subTextColumns = document.querySelectorAll('.sub-text-column'); 283 | const subTextInputs = document.querySelectorAll('.sub-text-input'); 284 | 285 | subTextColumns.forEach((column, index) => { 286 | if (subTextInputs[index]) { 287 | subTextInputs[index].value = column.textContent.trim(); 288 | } 289 | }); 290 | 291 | // Sync to YAML 292 | this.syncSingleToYaml(); 293 | } 294 | 295 | syncSingleToYaml() { 296 | // Only sync if YAML editor is initialized 297 | if (!this.yamlEditor) { 298 | return; 299 | } 300 | 301 | // Get current state from single tab 302 | const iconSelect = document.getElementById('icon-select').value; 303 | const width = parseInt(document.getElementById('label-width').value); 304 | const height = parseInt(document.getElementById('label-height').value); 305 | 306 | const mainTextInputs = document.querySelectorAll('.main-text-input'); 307 | const mainTexts = Array.from(mainTextInputs).map(input => input.value.trim()).filter(text => text); 308 | 309 | const subTextInputs = document.querySelectorAll('.sub-text-input'); 310 | const subTexts = Array.from(subTextInputs).map(input => input.value.trim()).filter(text => text); 311 | 312 | // Generate YAML for the current label 313 | let yamlContent = `# Hardware Label Generator - Current Label from Single Tab 314 | # This YAML is auto-synced with the Single tab 315 | # Available icons: ${this.availableIcons.join(', ')} 316 | 317 | # Global settings (optional) 318 | long_png: true # Generate one continuous PNG strip of all labels 319 | cut_marks: true # Add cut marks between labels for easy trimming 320 | export_svg: true # Also generate SVG files (vector format, scalable) 321 | png_dpi: 300 # PNG export resolution in dots per inch (50-1200) 322 | 323 | labels: 324 | - icon: "${iconSelect}" 325 | width_mm: ${width} 326 | height_mm: ${height}`; 327 | 328 | // Add main text columns 329 | if (mainTexts.length > 0) { 330 | yamlContent += `\n maintext_columns:`; 331 | mainTexts.forEach(text => { 332 | yamlContent += `\n - "${text}"`; 333 | }); 334 | } 335 | 336 | // Add sub text columns 337 | if (subTexts.length > 0) { 338 | yamlContent += `\n subtext_columns:`; 339 | subTexts.forEach(text => { 340 | yamlContent += `\n - "${text}"`; 341 | }); 342 | } 343 | 344 | yamlContent += `\n rotate: false 345 | 346 | # You can add more labels below by copying the format above 347 | # Example: 348 | # - icon: "heads_phillips" 349 | # width_mm: 50 350 | # height_mm: 12 351 | # maintext_columns: 352 | # - "M4" 353 | # - "M4" 354 | # subtext_columns: 355 | # - "16 mm" 356 | # - "20 mm" 357 | # rotate: false 358 | `; 359 | 360 | // Update the YAML editor 361 | this.yamlEditor.setValue(yamlContent); 362 | } 363 | 364 | 365 | addHiddenInput(isMain, value = '') { 366 | const container = document.getElementById(isMain ? 'main-text-inputs' : 'sub-text-inputs'); 367 | const input = document.createElement('input'); 368 | input.type = 'text'; 369 | input.className = isMain ? 'main-text-input' : 'sub-text-input'; 370 | input.value = value; 371 | input.addEventListener('input', () => this.updatePreview()); 372 | container.appendChild(input); 373 | } 374 | 375 | removeHiddenInput(isMain) { 376 | const container = document.getElementById(isMain ? 'main-text-inputs' : 'sub-text-inputs'); 377 | const inputs = container.querySelectorAll(isMain ? '.main-text-input' : '.sub-text-input'); 378 | if (inputs.length > 1) { 379 | const lastInput = inputs[inputs.length - 1]; 380 | lastInput.remove(); 381 | } 382 | } 383 | 384 | initializeDefaultColumns() { 385 | // Set up event listeners for all pre-existing input fields 386 | document.querySelectorAll('.main-text-input').forEach(input => { 387 | input.addEventListener('input', () => this.updatePreview()); 388 | }); 389 | 390 | document.querySelectorAll('.sub-text-input').forEach(input => { 391 | input.addEventListener('input', () => this.updatePreview()); 392 | }); 393 | } 394 | 395 | updateColumn(type, action) { 396 | const isMain = type === 'main'; 397 | const container = document.getElementById(isMain ? 'main-text-inputs' : 'sub-text-inputs'); 398 | const inputClass = isMain ? 'main-text-input' : 'sub-text-input'; 399 | 400 | let currentCount = container.querySelectorAll(`.${inputClass}`).length; 401 | 402 | if (action === 'add' && currentCount < 8) { 403 | currentCount++; 404 | } else if (action === 'remove' && currentCount > 1) { 405 | currentCount--; 406 | } 407 | 408 | this.updateTextInputs(container, inputClass, currentCount, isMain); 409 | this.updateColumnDisplay(type, currentCount); 410 | this.updatePreviewFromInputs(); 411 | this.syncSingleToYaml(); 412 | } 413 | 414 | updateColumnDisplay(type, count) { 415 | const countElement = document.getElementById(type === 'main' ? 'main-column-count' : 'sub-column-count'); 416 | if (countElement) { 417 | countElement.textContent = count; 418 | } 419 | } 420 | 421 | updatePreviewFromInputs() { 422 | // Update the preview based on hidden inputs, then sync to editable elements 423 | const mainInputs = document.querySelectorAll('.main-text-input'); 424 | const subInputs = document.querySelectorAll('.sub-text-input'); 425 | 426 | // Update main text columns 427 | const mainContainer = document.querySelector('.main-text'); 428 | mainContainer.innerHTML = ''; 429 | Array.from(mainInputs).forEach((input, index) => { 430 | const column = document.createElement('div'); 431 | column.className = 'main-text-column editable-text'; 432 | column.contentEditable = true; 433 | column.textContent = input.value.trim() || `New ${index + 1}`; 434 | 435 | this.setupEditableTextHandlers(column); 436 | 437 | mainContainer.appendChild(column); 438 | }); 439 | 440 | // Update sub text columns 441 | const subContainer = document.querySelector('.sub-text'); 442 | subContainer.innerHTML = ''; 443 | Array.from(subInputs).forEach((input, index) => { 444 | const column = document.createElement('div'); 445 | column.className = 'sub-text-column editable-text'; 446 | column.contentEditable = true; 447 | column.textContent = input.value.trim() || `Sub ${index + 1}`; 448 | 449 | this.setupEditableTextHandlers(column); 450 | 451 | subContainer.appendChild(column); 452 | }); 453 | 454 | // Update other preview elements 455 | this.updatePreview(); 456 | 457 | } 458 | 459 | updateTextInputs(container, inputClass, columnCount, isMain) { 460 | const currentInputs = container.querySelectorAll(`.${inputClass}`); 461 | const currentValues = Array.from(currentInputs).map(input => input.value); 462 | 463 | // Clear existing inputs 464 | container.innerHTML = ''; 465 | 466 | // Create new inputs 467 | for (let i = 0; i < columnCount; i++) { 468 | const input = document.createElement('input'); 469 | input.type = 'text'; 470 | input.className = inputClass; 471 | input.placeholder = isMain ? `Column ${i + 1}` : `Sub Column ${i + 1}`; 472 | input.value = currentValues[i] || ''; 473 | input.addEventListener('input', () => this.updatePreview()); 474 | container.appendChild(input); 475 | } 476 | } 477 | 478 | setupMainTextInputs() { 479 | // Add event listeners to existing inputs 480 | document.querySelectorAll('.main-text-input').forEach(input => { 481 | input.addEventListener('input', () => this.updatePreview()); 482 | }); 483 | } 484 | 485 | setupSubTextInputs() { 486 | // Add event listeners to existing inputs 487 | document.querySelectorAll('.sub-text-input').forEach(input => { 488 | input.addEventListener('input', () => this.updatePreview()); 489 | }); 490 | } 491 | 492 | updatePreview() { 493 | const iconSelect = document.getElementById('icon-select').value; 494 | const height = document.getElementById('label-height').value; 495 | const width = document.getElementById('label-width').value; 496 | 497 | const labelPreview = document.getElementById('header-label-preview'); 498 | const iconContainer = labelPreview.querySelector('.label-icon img'); 499 | 500 | // Get icon path from either built-in icons or custom icons 501 | const iconPath = this.icons[iconSelect] || this.customIcons[iconSelect] || this.icons['heads_hex_socket']; 502 | if (iconContainer) { 503 | iconContainer.src = iconPath; 504 | iconContainer.alt = iconSelect; 505 | } 506 | 507 | labelPreview.setAttribute('data-height', height); 508 | labelPreview.style.width = `${width}mm`; 509 | labelPreview.style.height = `${height}mm`; 510 | 511 | // Update CSS custom property for control positioning 512 | document.documentElement.style.setProperty('--label-width', `${width}mm`); 513 | 514 | // Dynamically set icon size based on height (height - 2mm for 1mm margin on each side) 515 | const iconSize = Math.max(6, height - 2); // Minimum 6mm, otherwise height - 2mm 516 | const labelIcon = labelPreview.querySelector('.label-icon'); 517 | if (labelIcon) { 518 | labelIcon.style.width = `${iconSize}mm`; 519 | labelIcon.style.height = `${iconSize}mm`; 520 | } 521 | 522 | // Check if sub text has any non-empty columns 523 | const subTextColumns = document.querySelectorAll('.sub-text-column'); 524 | const hasSubText = Array.from(subTextColumns).some(col => col.textContent.trim()); 525 | const subTextContainer = document.querySelector('.sub-text'); 526 | 527 | if (!hasSubText) { 528 | subTextContainer.style.display = 'none'; 529 | } else { 530 | subTextContainer.style.display = 'flex'; 531 | } 532 | 533 | } 534 | 535 | async downloadPNG() { 536 | try { 537 | const canvas = document.createElement('canvas'); 538 | const ctx = canvas.getContext('2d'); 539 | 540 | const height = parseInt(document.getElementById('label-height').value); 541 | const width = parseInt(document.getElementById('label-width').value); 542 | const mainTextInputs = document.querySelectorAll('.main-text-input'); 543 | const mainTexts = Array.from(mainTextInputs).map(input => input.value.trim()).filter(text => text); 544 | const subTextInputs = document.querySelectorAll('.sub-text-input'); 545 | const subTexts = Array.from(subTextInputs).map(input => input.value.trim()).filter(text => text); 546 | const iconSelect = document.getElementById('icon-select').value; 547 | const dpi = this.validateDPI(parseInt(document.getElementById('png-dpi').value) || 96); 548 | const shouldRotate = document.getElementById('export-rotate').checked; 549 | const mmToPx = dpi / 25.4; 550 | 551 | // Always set canvas to normal dimensions first (we'll rotate after drawing) 552 | canvas.width = width * mmToPx; 553 | canvas.height = height * mmToPx; 554 | 555 | // Always use transparent background 556 | 557 | const iconSize = (height - 2) * mmToPx; 558 | const iconX = 1 * mmToPx; 559 | const iconY = 1 * mmToPx; 560 | 561 | await this.drawIcon(ctx, iconX, iconY, iconSize, iconSelect); 562 | 563 | const textX = iconX + iconSize + (2 * mmToPx); 564 | const textAreaWidth = canvas.width - textX - (1 * mmToPx); 565 | 566 | ctx.fillStyle = 'black'; 567 | ctx.textAlign = 'left'; 568 | ctx.textBaseline = 'top'; 569 | 570 | const mainFontSize = this.calculateFontSize(height); 571 | const subFontSize = mainFontSize * 0.75; 572 | 573 | ctx.font = `bold ${mainFontSize * mmToPx}px Arial`; 574 | const textY = subTexts.length > 0 ? iconY + (iconSize * 0.2) : iconY + (iconSize * 0.4); 575 | 576 | // Handle multiple columns for main text 577 | if (mainTexts.length === 0) { 578 | const defaultTexts = ['M3', 'M3', 'M3']; 579 | const columnWidth = textAreaWidth / defaultTexts.length; 580 | defaultTexts.forEach((text, index) => { 581 | const columnX = textX + (index * columnWidth); 582 | ctx.textAlign = 'left'; 583 | ctx.fillText(text, columnX, textY); 584 | }); 585 | } else { 586 | const columnWidth = textAreaWidth / mainTexts.length; 587 | mainTexts.forEach((text, index) => { 588 | const columnX = textX + (index * columnWidth); 589 | ctx.textAlign = 'left'; 590 | ctx.fillText(text, columnX, textY); 591 | }); 592 | } 593 | 594 | // Handle multiple columns for sub text 595 | ctx.font = `${subFontSize * mmToPx}px Arial`; 596 | ctx.fillStyle = '#666'; 597 | const subTextY = textY + (mainFontSize * mmToPx * 1.2); 598 | 599 | if (subTexts.length === 0) { 600 | const defaultSubTexts = ['8 mm', '10 mm', '12 mm']; 601 | const columnWidth = textAreaWidth / defaultSubTexts.length; 602 | defaultSubTexts.forEach((text, index) => { 603 | const columnX = textX + (index * columnWidth); 604 | ctx.textAlign = 'left'; 605 | ctx.fillText(text, columnX, subTextY); 606 | }); 607 | } else { 608 | const columnWidth = textAreaWidth / subTexts.length; 609 | subTexts.forEach((text, index) => { 610 | const columnX = textX + (index * columnWidth); 611 | ctx.textAlign = 'left'; 612 | ctx.fillText(text, columnX, subTextY); 613 | }); 614 | } 615 | 616 | // Apply rotation if requested 617 | let finalCanvas = canvas; 618 | if (shouldRotate) { 619 | finalCanvas = this.rotateCanvas(canvas, 90); 620 | } 621 | 622 | // Save DPI setting to localStorage 623 | this.saveDPISettings(); 624 | 625 | const link = document.createElement('a'); 626 | const labelName = mainTexts.length > 0 ? mainTexts.join('_') : 'label'; 627 | const rotation = shouldRotate ? '_rotated' : ''; 628 | link.download = `label-${labelName.replace(/[^a-zA-Z0-9]/g, '_')}-${dpi}dpi${rotation}.png`; 629 | link.href = finalCanvas.toDataURL(); 630 | link.click(); 631 | } catch (error) { 632 | console.error('PNG download failed:', error); 633 | alert('Failed to download PNG. Please try again.'); 634 | } 635 | } 636 | 637 | async downloadSVG() { 638 | try { 639 | const height = parseInt(document.getElementById('label-height').value); 640 | const width = parseInt(document.getElementById('label-width').value); 641 | const mainTextInputs = document.querySelectorAll('.main-text-input'); 642 | const mainTexts = Array.from(mainTextInputs).map(input => input.value.trim()).filter(text => text); 643 | const subTextInputs = document.querySelectorAll('.sub-text-input'); 644 | const subTexts = Array.from(subTextInputs).map(input => input.value.trim()).filter(text => text); 645 | const iconSelect = document.getElementById('icon-select').value; 646 | const shouldRotate = document.getElementById('export-rotate').checked; 647 | 648 | const svg = await this.generateLabelSVG({ 649 | height_mm: height, 650 | width_mm: width, 651 | columns: mainTexts.length > 0 ? mainTexts : ['M3', 'M3', 'M3'], 652 | subtext_columns: subTexts.length > 0 ? subTexts : ['8 mm', '10 mm', '12 mm'], 653 | icon: iconSelect, 654 | rotate: shouldRotate 655 | }); 656 | 657 | // Save DPI setting to localStorage 658 | this.saveDPISettings(); 659 | 660 | const blob = new Blob([svg], { type: 'image/svg+xml' }); 661 | const link = document.createElement('a'); 662 | const labelName = mainTexts.length > 0 ? mainTexts.join('_') : 'label'; 663 | const rotation = shouldRotate ? '_rotated' : ''; 664 | link.download = `label-${labelName.replace(/[^a-zA-Z0-9]/g, '_')}${rotation}.svg`; 665 | link.href = URL.createObjectURL(blob); 666 | link.click(); 667 | URL.revokeObjectURL(link.href); 668 | } catch (error) { 669 | console.error('SVG download failed:', error); 670 | alert('Failed to download SVG. Please try again.'); 671 | } 672 | } 673 | 674 | async generateLabelSVG(label) { 675 | const originalHeight = label.height_mm; 676 | const originalWidth = label.width_mm; 677 | const shouldRotate = label.rotate || false; 678 | 679 | const mainTexts = label.columns ? label.columns.filter(col => col.trim()) : [label.title]; 680 | const subTexts = label.subtext_columns ? label.subtext_columns.filter(col => col.trim()) : (label.subtext ? [label.subtext] : []); 681 | const iconSelect = label.icon; 682 | const svgDpi = 96; // Fixed SVG DPI 683 | 684 | // Always use original dimensions for layout calculations 685 | const iconSize = originalHeight - 2; 686 | const iconX = 1; 687 | const iconY = 1; 688 | const textX = iconX + iconSize + 2; 689 | const textAreaWidth = originalWidth - textX - 1; 690 | 691 | // For rotation, swap dimensions only for the SVG canvas 692 | const canvasHeight = shouldRotate ? originalWidth : originalHeight; 693 | const canvasWidth = shouldRotate ? originalHeight : originalWidth; 694 | 695 | // Use 96 PPI for SVG (Inkscape standard) 696 | const dpi = 96; 697 | const mmToPx = dpi / 25.4; // ~3.78 698 | const baseFontSize = this.calculateFontSize(originalHeight); 699 | const mainFontSizePx = baseFontSize * mmToPx; 700 | const subFontSizePx = mainFontSizePx * 0.75; 701 | 702 | // Convert to px-based viewBox for consistent sizing with PNG 703 | const viewBoxWidth = canvasWidth * mmToPx; 704 | const viewBoxHeight = canvasHeight * mmToPx; 705 | const scale = mmToPx; // For converting mm coordinates to px 706 | 707 | let svgContent = ` 708 | 709 | 710 | 711 | ${svgDpi} 712 | ${svgDpi} 713 | 714 | 715 | `; 716 | 717 | // Add rotation group if needed 718 | if (shouldRotate) { 719 | // For 90-degree rotation, we need to rotate around the center and then translate 720 | // to ensure the content fits within the swapped canvas dimensions 721 | const originalViewBoxWidth = originalWidth * mmToPx; 722 | const originalViewBoxHeight = originalHeight * mmToPx; 723 | const centerX = originalViewBoxWidth / 2; 724 | const centerY = originalViewBoxHeight / 2; 725 | 726 | // Calculate the translation needed after rotation to center content in new canvas 727 | const translateX = (viewBoxWidth - originalViewBoxWidth) / 2; 728 | const translateY = (viewBoxHeight - originalViewBoxHeight) / 2; 729 | 730 | svgContent += ``; 731 | } 732 | 733 | // Add transparent background box for easier selection in design software 734 | const backgroundWidth = originalWidth * mmToPx; 735 | const backgroundHeight = originalHeight * mmToPx; 736 | svgContent += ``; 737 | 738 | // Add icon - convert coordinates to pixel space 739 | const iconXPx = iconX * scale; 740 | const iconYPx = iconY * scale; 741 | const iconSizePx = iconSize * scale; 742 | 743 | const iconPath = this.icons[iconSelect] || this.customIcons[iconSelect] || this.icons['heads_hex_socket']; 744 | if (iconPath.endsWith('.svg')) { 745 | try { 746 | const response = await fetch(iconPath); 747 | const iconSvg = await response.text(); 748 | const parser = new DOMParser(); 749 | const iconDoc = parser.parseFromString(iconSvg, 'image/svg+xml'); 750 | const iconSvgElement = iconDoc.documentElement; 751 | 752 | // Get original viewBox or width/height to calculate proper scale 753 | const viewBox = iconSvgElement.getAttribute('viewBox'); 754 | let originalWidth = 100, originalHeight = 100; // fallback 755 | 756 | if (viewBox) { 757 | const parts = viewBox.split(' '); 758 | if (parts.length === 4) { 759 | originalWidth = parseFloat(parts[2]); 760 | originalHeight = parseFloat(parts[3]); 761 | } 762 | } else { 763 | const widthAttr = iconSvgElement.getAttribute('width'); 764 | const heightAttr = iconSvgElement.getAttribute('height'); 765 | if (widthAttr) originalWidth = parseFloat(widthAttr.replace(/\D/g, '')); 766 | if (heightAttr) originalHeight = parseFloat(heightAttr.replace(/\D/g, '')); 767 | } 768 | 769 | // Calculate scale to fit icon in square 770 | const iconScale = iconSizePx / Math.max(originalWidth, originalHeight); 771 | 772 | // Extract the inner content and scale it properly 773 | const iconContent = iconSvgElement.innerHTML; 774 | svgContent += ``; 775 | svgContent += iconContent; 776 | svgContent += ''; 777 | } catch (error) { 778 | console.error('Failed to embed SVG icon:', error); 779 | } 780 | } else { 781 | // For PNG icons, embed as image 782 | svgContent += ``; 783 | } 784 | 785 | // Add main text - position so bottom of text is on horizontal centerline 786 | const textXPx = textX * scale; 787 | const textAreaWidthPx = textAreaWidth * scale; 788 | const centerYPx = (originalHeight * mmToPx) / 2; // Use original height for centerline 789 | const textYPx = subTexts.length > 0 ? centerYPx : centerYPx; 790 | 791 | if (mainTexts.length === 1) { 792 | svgContent += `${this.escapeXml(mainTexts[0])}`; 793 | } else { 794 | const columnWidthPx = textAreaWidthPx / mainTexts.length; 795 | mainTexts.forEach((text, index) => { 796 | const columnXPx = textXPx + (index * columnWidthPx); 797 | svgContent += `${this.escapeXml(text)}`; 798 | }); 799 | } 800 | 801 | // Add sub text - position below main text with same spacing 802 | if (subTexts.length > 0) { 803 | const subTextYPx = textYPx + (mainFontSizePx * 0.3) + (subFontSizePx * 0.8); // Adjust spacing for new positioning 804 | if (subTexts.length === 1) { 805 | svgContent += `${this.escapeXml(subTexts[0])}`; 806 | } else { 807 | const columnWidthPx = textAreaWidthPx / subTexts.length; 808 | subTexts.forEach((text, index) => { 809 | const columnXPx = textXPx + (index * columnWidthPx); 810 | svgContent += `${this.escapeXml(text)}`; 811 | }); 812 | } 813 | } 814 | 815 | // Close rotation group if needed 816 | if (shouldRotate) { 817 | svgContent += ''; 818 | } 819 | 820 | svgContent += ''; 821 | return svgContent; 822 | } 823 | 824 | escapeXml(str) { 825 | return str.replace(/&/g, '&') 826 | .replace(//g, '>') 828 | .replace(/"/g, '"') 829 | .replace(/'/g, '''); 830 | } 831 | 832 | calculateFontSize(height) { 833 | switch(height) { 834 | case 9: return 3; 835 | case 12: return 4; 836 | case 18: return 6.5; 837 | case 24: return 8; 838 | default: return 4; 839 | } 840 | } 841 | 842 | async drawIcon(ctx, x, y, size, iconType) { 843 | const iconPath = this.icons[iconType] || this.customIcons[iconType] || this.icons['heads_hex_socket']; 844 | 845 | return new Promise((resolve, reject) => { 846 | if (iconPath.endsWith('.svg')) { 847 | // Handle SVG icons 848 | fetch(iconPath) 849 | .then(response => response.text()) 850 | .then(svgText => { 851 | const img = new Image(); 852 | const svgBlob = new Blob([svgText], { type: 'image/svg+xml' }); 853 | const url = URL.createObjectURL(svgBlob); 854 | 855 | img.onload = () => { 856 | try { 857 | ctx.drawImage(img, x, y, size, size); 858 | URL.revokeObjectURL(url); 859 | resolve(); 860 | } catch (error) { 861 | URL.revokeObjectURL(url); 862 | reject(error); 863 | } 864 | }; 865 | 866 | img.onerror = (error) => { 867 | URL.revokeObjectURL(url); 868 | console.error('Failed to load SVG icon:', iconPath, error); 869 | reject(error); 870 | }; 871 | 872 | img.src = url; 873 | }) 874 | .catch(error => { 875 | console.error('Failed to fetch SVG:', iconPath, error); 876 | reject(error); 877 | }); 878 | } else { 879 | // Handle PNG/JPG icons 880 | const img = new Image(); 881 | img.onload = () => { 882 | try { 883 | ctx.drawImage(img, x, y, size, size); 884 | resolve(); 885 | } catch (error) { 886 | reject(error); 887 | } 888 | }; 889 | img.onerror = (error) => { 890 | console.error('Failed to load icon:', iconPath, error); 891 | reject(error); 892 | }; 893 | img.src = iconPath; 894 | } 895 | }); 896 | } 897 | 898 | initializeTabs() { 899 | const tabButtons = document.querySelectorAll('.btn[data-tab]'); 900 | const tabContents = document.querySelectorAll('.tab-content'); 901 | 902 | tabButtons.forEach(button => { 903 | button.addEventListener('click', () => { 904 | const targetTab = button.dataset.tab; 905 | 906 | tabButtons.forEach(btn => btn.classList.remove('active')); 907 | tabContents.forEach(content => content.style.display = 'none'); 908 | 909 | button.classList.add('active'); 910 | document.getElementById(`${targetTab}-tab`).style.display = 'block'; 911 | 912 | // Refresh CodeMirror editor when batch tab becomes visible 913 | if (targetTab === 'batch' && this.yamlEditor) { 914 | setTimeout(() => { 915 | this.syncSingleToYaml(); 916 | this.yamlEditor.refresh(); 917 | }, 100); 918 | } 919 | }); 920 | }); 921 | } 922 | 923 | validateYAML() { 924 | const yamlInput = this.yamlEditor ? this.yamlEditor.getValue().trim() : document.getElementById('yaml-input').value.trim(); 925 | const resultDiv = document.getElementById('validation-result'); 926 | 927 | if (!yamlInput) { 928 | this.showValidationResult('Please enter YAML content to validate.', 'error'); 929 | return; 930 | } 931 | 932 | try { 933 | const parsed = this.parseYAML(yamlInput); 934 | const validation = this.validateLabels(parsed); 935 | 936 | if (validation.isValid) { 937 | this.showValidationResult(`✅ YAML is valid! Found ${validation.labelCount} labels.`, 'success'); 938 | } else { 939 | this.showValidationResult(`❌ Validation failed:\n${validation.errors.join('\n')}`, 'error'); 940 | } 941 | } catch (error) { 942 | this.showValidationResult(`❌ YAML parsing error: ${error.message}`, 'error'); 943 | } 944 | } 945 | 946 | async generateZIP() { 947 | const yamlInput = this.yamlEditor ? this.yamlEditor.getValue().trim() : document.getElementById('yaml-input').value.trim(); 948 | 949 | if (!yamlInput) { 950 | this.showValidationResult('Please enter YAML content first.', 'error'); 951 | return; 952 | } 953 | 954 | try { 955 | const parsed = this.parseYAML(yamlInput); 956 | const validation = this.validateLabels(parsed); 957 | 958 | if (!validation.isValid) { 959 | this.showValidationResult(`❌ Cannot generate ZIP: ${validation.errors.join(', ')}`, 'error'); 960 | return; 961 | } 962 | 963 | this.showValidationResult('⏳ Generating labels and creating ZIP...', 'warning'); 964 | 965 | const labels = parsed.labels; 966 | const zip = new JSZip(); 967 | 968 | // Check for export options from YAML settings 969 | const generateLongPng = parsed.long_png || false; 970 | const includeCutMarks = parsed.cut_marks || false; 971 | const generateSvg = parsed.export_svg || false; 972 | const dpiSetting = parsed.png_dpi || 300; 973 | 974 | // Generate individual labels 975 | for (let i = 0; i < labels.length; i++) { 976 | const label = labels[i]; 977 | const titleText = label.title || (label.columns ? label.columns.join('_') : 'label'); 978 | 979 | // Generate PNG 980 | const canvas = await this.generateLabelCanvas(label, dpiSetting); 981 | const imageData = canvas.toDataURL().split(',')[1]; 982 | const pngFilename = `label_${i + 1}_${titleText.replace(/[^a-zA-Z0-9]/g, '_')}.png`; 983 | zip.file(pngFilename, imageData, { base64: true }); 984 | 985 | // Generate SVG if requested 986 | if (generateSvg) { 987 | const svgContent = await this.generateLabelSVG(label); 988 | const svgFilename = `label_${i + 1}_${titleText.replace(/[^a-zA-Z0-9]/g, '_')}.svg`; 989 | zip.file(svgFilename, svgContent); 990 | } 991 | } 992 | 993 | // Generate long PNG strip if requested 994 | if (generateLongPng) { 995 | const longPngCanvas = await this.generateLongPngStrip(labels, includeCutMarks, dpiSetting); 996 | const longPngData = longPngCanvas.toDataURL().split(',')[1]; 997 | 998 | // Calculate total strip length - no extra space for cut marks 999 | const totalStripLength = labels.reduce((sum, label) => sum + label.width_mm, 0); 1000 | 1001 | const longPngFilename = `labels_strip_${totalStripLength}mm.png`; 1002 | 1003 | zip.file(longPngFilename, longPngData, { base64: true }); 1004 | } 1005 | 1006 | const zipBlob = await zip.generateAsync({ type: 'blob' }); 1007 | const link = document.createElement('a'); 1008 | link.href = URL.createObjectURL(zipBlob); 1009 | link.download = 'hardware_labels.zip'; 1010 | link.click(); 1011 | 1012 | let message = `✅ ZIP generated successfully with ${labels.length} labels!`; 1013 | if (generateLongPng) { 1014 | message += ' Long PNG strip included.'; 1015 | } 1016 | if (generateSvg) { 1017 | message += ' SVG files included.'; 1018 | } 1019 | 1020 | this.showValidationResult(message, 'success'); 1021 | 1022 | } catch (error) { 1023 | this.showValidationResult(`❌ Error generating ZIP: ${error.message}`, 'error'); 1024 | } 1025 | } 1026 | 1027 | parseYAML(yamlString) { 1028 | const lines = yamlString.split('\n'); 1029 | const result = { labels: [] }; 1030 | let currentLabel = null; 1031 | let inLabels = false; 1032 | let currentArray = null; 1033 | let currentArrayKey = null; 1034 | 1035 | for (let line of lines) { 1036 | const originalLine = line; 1037 | line = line.trim(); 1038 | if (!line || line.startsWith('#')) continue; 1039 | 1040 | if (line === 'labels:') { 1041 | inLabels = true; 1042 | continue; 1043 | } 1044 | 1045 | if (inLabels) { 1046 | if (line.startsWith('- ') && originalLine.match(/^ - /)) { 1047 | // New label item (starts with exactly 2 spaces + dash) 1048 | if (currentLabel) { 1049 | // Finish any pending array 1050 | if (currentArray && currentArrayKey && currentArray.length > 0) { 1051 | currentLabel[currentArrayKey] = currentArray; 1052 | } 1053 | if (Object.keys(currentLabel).length > 0) { 1054 | result.labels.push(currentLabel); 1055 | } 1056 | } 1057 | currentLabel = {}; 1058 | currentArray = null; 1059 | currentArrayKey = null; 1060 | 1061 | const keyValue = line.substring(2).trim(); 1062 | if (keyValue.includes(':')) { 1063 | const [key, value] = keyValue.split(':', 2); 1064 | const trimmedKey = key.trim(); 1065 | const trimmedValue = value.trim(); 1066 | if (trimmedKey && trimmedValue) { 1067 | currentLabel[trimmedKey] = this.parseValue(trimmedValue); 1068 | } 1069 | } 1070 | } else if (line.startsWith('- ') && originalLine.match(/^ - /) && currentArray) { 1071 | // Array item (starts with exactly 6 spaces + dash) 1072 | const value = line.substring(2).trim(); 1073 | currentArray.push(this.parseValue(value)); 1074 | } else if (line.includes(':') && currentLabel) { 1075 | // Finish previous array if exists 1076 | if (currentArray && currentArrayKey && currentArray.length > 0) { 1077 | currentLabel[currentArrayKey] = currentArray; 1078 | } 1079 | 1080 | const [key, value] = line.split(':', 2); 1081 | const trimmedKey = key.trim(); 1082 | const trimmedValue = value.trim(); 1083 | if (trimmedKey) { 1084 | // Check if the value is just a comment (starts with #) 1085 | const parsedValue = this.parseValue(trimmedValue); 1086 | if (trimmedValue && !trimmedValue.startsWith('#') && parsedValue !== '') { 1087 | // Has real value on same line 1088 | currentLabel[trimmedKey] = parsedValue; 1089 | currentArray = null; 1090 | currentArrayKey = null; 1091 | } else { 1092 | // Start of array - prepare to collect items (empty value or comment only) 1093 | currentArray = []; 1094 | currentArrayKey = trimmedKey; 1095 | } 1096 | } 1097 | } 1098 | } else { 1099 | // Handle global options outside of labels 1100 | if (line.includes(':')) { 1101 | const [key, value] = line.split(':', 2); 1102 | const trimmedKey = key.trim(); 1103 | if (trimmedKey === 'long_png' || trimmedKey === 'cut_marks' || trimmedKey === 'export_svg' || trimmedKey === 'width_mm' || trimmedKey === 'height_mm' || trimmedKey === 'png_dpi') { 1104 | result[trimmedKey] = this.parseValue(value.trim()); 1105 | } 1106 | } 1107 | } 1108 | } 1109 | 1110 | if (currentLabel) { 1111 | // Finish any pending array 1112 | if (currentArray && currentArrayKey && currentArray.length > 0) { 1113 | currentLabel[currentArrayKey] = currentArray; 1114 | } 1115 | if (Object.keys(currentLabel).length > 0) { 1116 | result.labels.push(currentLabel); 1117 | } 1118 | } 1119 | 1120 | return result; 1121 | } 1122 | 1123 | parseValue(value) { 1124 | // Remove inline comments 1125 | const commentIndex = value.indexOf('#'); 1126 | if (commentIndex !== -1) { 1127 | value = value.substring(0, commentIndex).trim(); 1128 | } 1129 | 1130 | if (value.startsWith('"') && value.endsWith('"')) { 1131 | return value.slice(1, -1); 1132 | } 1133 | if (value.startsWith("'") && value.endsWith("'")) { 1134 | return value.slice(1, -1); 1135 | } 1136 | if (value === 'true') { 1137 | return true; 1138 | } 1139 | if (value === 'false') { 1140 | return false; 1141 | } 1142 | if (!isNaN(value) && value !== '') { 1143 | return Number(value); 1144 | } 1145 | return value; 1146 | } 1147 | 1148 | validateLabels(parsed) { 1149 | const errors = []; 1150 | const validHeights = [9, 12, 18, 24]; 1151 | 1152 | if (!parsed.labels || !Array.isArray(parsed.labels)) { 1153 | errors.push('Missing or invalid "labels" array'); 1154 | return { isValid: false, errors, labelCount: 0 }; 1155 | } 1156 | 1157 | if (parsed.labels.length === 0) { 1158 | errors.push('No labels found'); 1159 | return { isValid: false, errors, labelCount: 0 }; 1160 | } 1161 | 1162 | // Validate optional global settings 1163 | if (parsed.long_png !== undefined && typeof parsed.long_png !== 'boolean') { 1164 | errors.push('Invalid long_png setting. Must be true or false if provided'); 1165 | } 1166 | 1167 | if (parsed.cut_marks !== undefined && typeof parsed.cut_marks !== 'boolean') { 1168 | errors.push('Invalid cut_marks setting. Must be true or false if provided'); 1169 | } 1170 | 1171 | if (parsed.export_svg !== undefined && typeof parsed.export_svg !== 'boolean') { 1172 | errors.push('Invalid export_svg setting. Must be true or false if provided'); 1173 | } 1174 | 1175 | // Validate global width_mm and height_mm 1176 | if (parsed.width_mm !== undefined && (typeof parsed.width_mm !== 'number' || parsed.width_mm < 20 || parsed.width_mm > 100)) { 1177 | errors.push('Invalid global width_mm. Must be a number between 20 and 100 if provided'); 1178 | } 1179 | 1180 | if (parsed.height_mm !== undefined && (typeof parsed.height_mm !== 'number' || !validHeights.includes(parsed.height_mm))) { 1181 | errors.push(`Invalid global height_mm. Must be one of: ${validHeights.join(', ')} if provided`); 1182 | } 1183 | 1184 | if (parsed.png_dpi !== undefined && (typeof parsed.png_dpi !== 'number' || parsed.png_dpi < 50 || parsed.png_dpi > 1200)) { 1185 | errors.push('Invalid global png_dpi. Must be a number between 50 and 1200 if provided'); 1186 | } 1187 | 1188 | parsed.labels.forEach((label, index) => { 1189 | const labelNum = index + 1; 1190 | 1191 | // Normalize maintext_columns to columns for backwards compatibility 1192 | if (label.maintext_columns && !label.columns) { 1193 | label.columns = label.maintext_columns; 1194 | } 1195 | 1196 | // Apply global defaults 1197 | if (!label.width_mm && parsed.width_mm) { 1198 | label.width_mm = parsed.width_mm; 1199 | } 1200 | if (!label.height_mm && parsed.height_mm) { 1201 | label.height_mm = parsed.height_mm; 1202 | } 1203 | 1204 | // Check for either title (single column) or columns (multi-column) 1205 | if (!label.title && !label.columns) { 1206 | errors.push(`Label ${labelNum}: Missing title or columns (or maintext_columns)`); 1207 | } else if (label.title && label.columns) { 1208 | errors.push(`Label ${labelNum}: Cannot have both title and columns. Use either title for single column or columns/maintext_columns for multi-column`); 1209 | } else if (label.title && typeof label.title !== 'string') { 1210 | errors.push(`Label ${labelNum}: Invalid title. Must be a string`); 1211 | } else if (label.columns && (!Array.isArray(label.columns) || label.columns.length === 0 || label.columns.length > 8)) { 1212 | errors.push(`Label ${labelNum}: Invalid columns/maintext_columns. Must be an array with 1-8 string elements`); 1213 | } else if (label.columns && label.columns.some(col => typeof col !== 'string')) { 1214 | errors.push(`Label ${labelNum}: Invalid columns/maintext_columns. All column values must be strings`); 1215 | } 1216 | 1217 | if (!label.icon || typeof label.icon !== 'string') { 1218 | errors.push(`Label ${labelNum}: Missing or invalid icon`); 1219 | } else if (!this.availableIcons.includes(label.icon)) { 1220 | errors.push(`Label ${labelNum}: Invalid icon "${label.icon}". Must be one of: ${this.availableIcons.join(', ')}`); 1221 | } 1222 | 1223 | if (!label.width_mm || typeof label.width_mm !== 'number' || label.width_mm < 20 || label.width_mm > 100) { 1224 | errors.push(`Label ${labelNum}: Invalid width_mm. Must be a number between 20 and 100`); 1225 | } 1226 | 1227 | if (!label.height_mm || typeof label.height_mm !== 'number' || !validHeights.includes(label.height_mm)) { 1228 | errors.push(`Label ${labelNum}: Invalid height_mm. Must be one of: ${validHeights.join(', ')}`); 1229 | } 1230 | 1231 | if (label.subtext && typeof label.subtext !== 'string') { 1232 | errors.push(`Label ${labelNum}: Invalid subtext. Must be a string if provided`); 1233 | } 1234 | 1235 | if (label.subtext_columns && (!Array.isArray(label.subtext_columns) || label.subtext_columns.length === 0 || label.subtext_columns.length > 8)) { 1236 | errors.push(`Label ${labelNum}: Invalid subtext_columns. Must be an array with 1-8 string elements if provided`); 1237 | } else if (label.subtext_columns && label.subtext_columns.some(col => typeof col !== 'string')) { 1238 | errors.push(`Label ${labelNum}: Invalid subtext_columns. All column values must be strings`); 1239 | } 1240 | }); 1241 | 1242 | return { 1243 | isValid: errors.length === 0, 1244 | errors, 1245 | labelCount: parsed.labels.length 1246 | }; 1247 | } 1248 | 1249 | async generateLabelCanvas(label, dpi = 300) { 1250 | const canvas = document.createElement('canvas'); 1251 | const ctx = canvas.getContext('2d'); 1252 | 1253 | // Normalize maintext_columns to columns for backwards compatibility 1254 | if (label.maintext_columns && !label.columns) { 1255 | label.columns = label.maintext_columns; 1256 | } 1257 | 1258 | const height = label.height_mm; 1259 | const width = label.width_mm; 1260 | const mainTexts = label.columns ? label.columns.filter(col => col.trim()) : [label.title]; 1261 | const subTexts = label.subtext_columns ? label.subtext_columns.filter(col => col.trim()) : (label.subtext ? [label.subtext] : []); 1262 | const iconSelect = label.icon; 1263 | const mmToPx = dpi / 25.4; 1264 | 1265 | canvas.width = width * mmToPx; 1266 | canvas.height = height * mmToPx; 1267 | 1268 | // Always use transparent background 1269 | 1270 | const iconSize = (height - 2) * mmToPx; 1271 | const iconX = 1 * mmToPx; 1272 | const iconY = 1 * mmToPx; 1273 | 1274 | await this.drawIcon(ctx, iconX, iconY, iconSize, iconSelect); 1275 | 1276 | const textX = iconX + iconSize + (2 * mmToPx); 1277 | const textAreaWidth = canvas.width - textX - (1 * mmToPx); 1278 | 1279 | ctx.fillStyle = 'black'; 1280 | ctx.textAlign = 'left'; 1281 | ctx.textBaseline = 'top'; 1282 | 1283 | const mainFontSize = this.calculateFontSize(height); 1284 | const subFontSize = mainFontSize * 0.75; 1285 | 1286 | ctx.font = `bold ${mainFontSize * mmToPx}px Arial`; 1287 | const textY = subTexts.length > 0 ? iconY + (iconSize * 0.2) : iconY + (iconSize * 0.4); 1288 | 1289 | // Handle multiple columns for main text 1290 | if (mainTexts.length === 1) { 1291 | ctx.fillText(mainTexts[0], textX, textY); 1292 | } else { 1293 | const columnWidth = textAreaWidth / mainTexts.length; 1294 | mainTexts.forEach((text, index) => { 1295 | const columnX = textX + (index * columnWidth); 1296 | ctx.textAlign = 'left'; 1297 | ctx.fillText(text, columnX, textY); 1298 | }); 1299 | } 1300 | 1301 | // Handle multiple columns for sub text 1302 | if (subTexts.length > 0) { 1303 | ctx.font = `${subFontSize * mmToPx}px Arial`; 1304 | ctx.fillStyle = '#666'; 1305 | const subTextY = textY + (mainFontSize * mmToPx * 1.2); 1306 | 1307 | if (subTexts.length === 1) { 1308 | ctx.fillText(subTexts[0], textX, subTextY); 1309 | } else { 1310 | const columnWidth = textAreaWidth / subTexts.length; 1311 | subTexts.forEach((text, index) => { 1312 | const columnX = textX + (index * columnWidth); 1313 | ctx.textAlign = 'left'; 1314 | ctx.fillText(text, columnX, subTextY); 1315 | }); 1316 | } 1317 | } 1318 | 1319 | return canvas; 1320 | } 1321 | 1322 | async generateLongPngStrip(labels, includeCutMarks, dpi = 300) { 1323 | const mmToPx = dpi / 25.4; 1324 | 1325 | // Calculate dimensions - horizontal strip 1326 | const firstLabel = labels[0]; 1327 | const labelHeight = firstLabel.height_mm * mmToPx; 1328 | 1329 | let totalWidth = 0; 1330 | const labelCanvases = []; 1331 | 1332 | // Generate individual label canvases and calculate total width 1333 | for (const label of labels) { 1334 | const canvas = await this.generateLabelCanvas(label, dpi); 1335 | labelCanvases.push(canvas); 1336 | totalWidth += canvas.width; 1337 | // No extra space for cut marks - they're just visual lines 1338 | } 1339 | 1340 | // Create the horizontal strip canvas 1341 | const stripCanvas = document.createElement('canvas'); 1342 | const stripCtx = stripCanvas.getContext('2d'); 1343 | 1344 | stripCanvas.width = totalWidth; 1345 | stripCanvas.height = labelHeight; 1346 | 1347 | // Fill with white background 1348 | stripCtx.fillStyle = 'white'; 1349 | stripCtx.fillRect(0, 0, stripCanvas.width, stripCanvas.height); 1350 | 1351 | // Draw labels and cut marks horizontally 1352 | let currentX = 0; 1353 | 1354 | for (let i = 0; i < labelCanvases.length; i++) { 1355 | const labelCanvas = labelCanvases[i]; 1356 | 1357 | // Draw the label 1358 | stripCtx.drawImage(labelCanvas, currentX, 0); 1359 | currentX += labelCanvas.width; 1360 | 1361 | // Draw cut marks between labels (except after the last one) - no space taken 1362 | if (i < labelCanvases.length - 1 && includeCutMarks) { 1363 | this.drawCutMarks(stripCtx, currentX, labelHeight); 1364 | } 1365 | } 1366 | 1367 | return stripCanvas; 1368 | } 1369 | 1370 | drawCutMarks(ctx, x, labelHeight) { 1371 | const cutMarkLength = labelHeight * 0.1; // 10% of label height 1372 | const cutMarkThickness = 1; 1373 | 1374 | ctx.strokeStyle = '#666'; 1375 | ctx.lineWidth = cutMarkThickness; 1376 | 1377 | // Draw cut mark at the exact boundary between labels 1378 | 1379 | // Top cut mark 1380 | ctx.beginPath(); 1381 | ctx.moveTo(x, 0); 1382 | ctx.lineTo(x, cutMarkLength); 1383 | ctx.stroke(); 1384 | 1385 | // Bottom cut mark 1386 | ctx.beginPath(); 1387 | ctx.moveTo(x, labelHeight - cutMarkLength); 1388 | ctx.lineTo(x, labelHeight); 1389 | ctx.stroke(); 1390 | } 1391 | 1392 | showValidationResult(message, type) { 1393 | const resultDiv = document.getElementById('validation-result'); 1394 | resultDiv.className = `validation-result ${type}`; 1395 | resultDiv.innerHTML = message.replace(/\n/g, '
'); 1396 | resultDiv.style.display = 'block'; 1397 | } 1398 | 1399 | loadAvailableIcons() { 1400 | // Use all available icons from the icon picker + custom icons 1401 | this.availableIcons = Object.keys(this.icons).concat(Object.keys(this.customIcons)); 1402 | // Only generate prompt if YAML editor is ready 1403 | if (this.yamlEditor) { 1404 | this.generatePrompt(); 1405 | } 1406 | } 1407 | 1408 | loadCustomIcons() { 1409 | try { 1410 | const stored = localStorage.getItem('customIcons'); 1411 | return stored ? JSON.parse(stored) : {}; 1412 | } catch (error) { 1413 | console.error('Error loading custom icons:', error); 1414 | return {}; 1415 | } 1416 | } 1417 | 1418 | saveCustomIcons() { 1419 | try { 1420 | localStorage.setItem('customIcons', JSON.stringify(this.customIcons)); 1421 | } catch (error) { 1422 | console.error('Error saving custom icons:', error); 1423 | } 1424 | } 1425 | 1426 | initializeIconUpload() { 1427 | const uploadBtn = document.getElementById('upload-icon-btn'); 1428 | const fileInput = document.getElementById('icon-upload'); 1429 | 1430 | uploadBtn.addEventListener('click', () => { 1431 | fileInput.click(); 1432 | }); 1433 | 1434 | fileInput.addEventListener('change', (event) => { 1435 | const file = event.target.files[0]; 1436 | if (file) { 1437 | this.handleIconUpload(file); 1438 | } 1439 | }); 1440 | } 1441 | 1442 | async handleIconUpload(file) { 1443 | // Validate file type 1444 | if (!file.type.startsWith('image/png')) { 1445 | alert('Please upload a PNG image file.'); 1446 | return; 1447 | } 1448 | 1449 | // Validate file size (max 2MB) 1450 | if (file.size > 2 * 1024 * 1024) { 1451 | alert('File size must be less than 2MB.'); 1452 | return; 1453 | } 1454 | 1455 | try { 1456 | // Create image element to validate dimensions 1457 | const img = new Image(); 1458 | const canvas = document.createElement('canvas'); 1459 | const ctx = canvas.getContext('2d'); 1460 | 1461 | const imageDataUrl = await new Promise((resolve, reject) => { 1462 | const reader = new FileReader(); 1463 | reader.onload = (e) => resolve(e.target.result); 1464 | reader.onerror = reject; 1465 | reader.readAsDataURL(file); 1466 | }); 1467 | 1468 | await new Promise((resolve, reject) => { 1469 | img.onload = () => { 1470 | // Validate minimum size 1471 | if (img.width < 32 || img.height < 32) { 1472 | alert('Image must be at least 32x32 pixels.'); 1473 | reject(new Error('Too small')); 1474 | return; 1475 | } 1476 | 1477 | // Resize to standard size, maintaining aspect ratio and centering 1478 | const targetSize = 128; 1479 | canvas.width = targetSize; 1480 | canvas.height = targetSize; 1481 | 1482 | // Fill with white background 1483 | ctx.fillStyle = 'white'; 1484 | ctx.fillRect(0, 0, targetSize, targetSize); 1485 | 1486 | // Calculate scaling to fit within target size while maintaining aspect ratio 1487 | const scale = Math.min(targetSize / img.width, targetSize / img.height); 1488 | const scaledWidth = img.width * scale; 1489 | const scaledHeight = img.height * scale; 1490 | 1491 | // Center the image 1492 | const offsetX = (targetSize - scaledWidth) / 2; 1493 | const offsetY = (targetSize - scaledHeight) / 2; 1494 | 1495 | ctx.drawImage(img, offsetX, offsetY, scaledWidth, scaledHeight); 1496 | 1497 | resolve(); 1498 | }; 1499 | img.onerror = reject; 1500 | img.src = imageDataUrl; 1501 | }); 1502 | 1503 | // Get processed image data 1504 | const processedDataUrl = canvas.toDataURL('image/png'); 1505 | 1506 | // Generate unique name for the icon 1507 | const baseName = file.name.replace(/\.[^/.]+$/, '').replace(/[^a-zA-Z0-9]/g, '_'); 1508 | let iconName = `Custom_${baseName}`; 1509 | let counter = 1; 1510 | 1511 | // Ensure unique name 1512 | while (this.icons[iconName] || this.customIcons[iconName]) { 1513 | iconName = `Custom_${baseName}_${counter}`; 1514 | counter++; 1515 | } 1516 | 1517 | // Save to custom icons 1518 | this.customIcons[iconName] = processedDataUrl; 1519 | this.saveCustomIcons(); 1520 | 1521 | // Update available icons list 1522 | this.loadAvailableIcons(); 1523 | 1524 | // Refresh the icon picker 1525 | this.populateIconGrid(); 1526 | 1527 | // Auto-select the new icon 1528 | this.selectIcon(iconName); 1529 | 1530 | alert(`✅ Icon "${iconName}" uploaded successfully!`); 1531 | 1532 | } catch (error) { 1533 | console.error('Error processing uploaded icon:', error); 1534 | if (error.message !== 'Too small') { 1535 | alert('Error processing uploaded image. Please try again.'); 1536 | } 1537 | } 1538 | } 1539 | 1540 | 1541 | 1542 | generatePrompt() { 1543 | const yamlTemplate = `# Hardware Label Generator - Batch YAML Template 1544 | # 1545 | # 💡 TIP: Copy this template to your favorite LLM tool (ChatGPT, Claude, etc.) 1546 | # and ask it to generate labels for your specific hardware collection! 1547 | # Example prompt: "Generate 20 labels for my M3-M8 bolt collection with various lengths" 1548 | # 1549 | # Available icons: ${this.availableIcons.join(', ')} 1550 | 1551 | # Global settings (optional - applied to all labels unless overridden) 1552 | long_png: true # Generate one continuous PNG strip of all labels 1553 | cut_marks: true # Add cut marks between labels for easy trimming 1554 | export_svg: true # Also generate SVG files (vector format, scalable) 1555 | width_mm: 50 # Default width for all labels (20-100mm) 1556 | height_mm: 12 # Default height for all labels (9, 12, 18, or 24mm) 1557 | png_dpi: 300 # PNG export resolution in dots per inch (50-1200) 1558 | 1559 | labels: 1560 | # Multi-column label example (great for drawer compartments) 1561 | - icon: "heads_hex_socket" # Icon to display on the left 1562 | maintext_columns: # Main text columns (1-8 columns max) 1563 | - "M3" 1564 | - "M3" 1565 | - "M3" 1566 | subtext_columns: # Optional sub-text columns 1567 | - "8 mm" 1568 | - "10 mm" 1569 | - "12 mm" 1570 | rotate: false # Rotate label 90 degrees (default: false) 1571 | 1572 | # Another multi-column example 1573 | - icon: "heads_hex_socket" 1574 | maintext_columns: 1575 | - "M4" 1576 | - "M4" 1577 | - "M4" 1578 | subtext_columns: 1579 | - "14 mm" 1580 | - "16 mm" 1581 | - "18 mm" 1582 | rotate: false # Rotate label 90 degrees (default: false) 1583 | 1584 | # Single column alternatives (if you prefer): 1585 | # - title: "M5 × 20" # Single main text 1586 | # subtext: "DIN 7984" # Optional single sub-text 1587 | # icon: "fasteners_screw_hex" 1588 | # width_mm: 45 # Override global width 1589 | # height_mm: 18 # Override global height 1590 | # rotate: false # Rotate label 90 degrees (default: false) 1591 | `; 1592 | 1593 | // Set the template in the YAML editor 1594 | if (this.yamlEditor) { 1595 | this.yamlEditor.setValue(yamlTemplate); 1596 | } else { 1597 | document.getElementById('yaml-input').value = yamlTemplate; 1598 | } 1599 | } 1600 | 1601 | initializeYamlEditor() { 1602 | // Wait for CodeMirror to be available 1603 | if (typeof CodeMirror === 'undefined') { 1604 | setTimeout(() => this.initializeYamlEditor(), 100); 1605 | return; 1606 | } 1607 | 1608 | const yamlTextarea = document.getElementById('yaml-input'); 1609 | 1610 | this.yamlEditor = CodeMirror.fromTextArea(yamlTextarea, { 1611 | mode: 'yaml', 1612 | theme: 'default', 1613 | lineNumbers: true, 1614 | lineWrapping: true, 1615 | indentUnit: 2, 1616 | tabSize: 2, 1617 | indentWithTabs: false, 1618 | autoCloseBrackets: true, 1619 | matchBrackets: true, 1620 | foldGutter: true, 1621 | gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], 1622 | placeholder: 'Paste your YAML here...' 1623 | }); 1624 | 1625 | // Set initial size 1626 | this.yamlEditor.setSize(null, 400); 1627 | 1628 | // Add class to indicate CodeMirror loaded successfully 1629 | document.body.classList.add('codemirror-loaded'); 1630 | 1631 | // Update the editor when the tab becomes visible 1632 | this.yamlEditor.refresh(); 1633 | 1634 | // Load template after editor is ready 1635 | this.generatePrompt(); 1636 | 1637 | // Sync initial state from single tab 1638 | setTimeout(() => { 1639 | this.syncSingleToYaml(); 1640 | }, 100); 1641 | 1642 | } 1643 | 1644 | initializeIconPicker() { 1645 | const iconCategories = { 1646 | 'Electronics': ['electronics_wago_logo', 'electronics_wago_alt1', 'electronics_wago_alt2', 'electronics_wire_nut', 'electronics_generic'], 1647 | 'Screw Heads': ['heads_cross', 'heads_hex_external', 'heads_hex_socket', 'heads_phillips', 'heads_pozidriv', 'heads_robertson', 'heads_slotted', 'heads_square_external', 'heads_ta', 'heads_torx', 'heads_torx_tamperproof'], 1648 | 'Inserts': ['inserts_heat', 'inserts_wood'], 1649 | 'Nuts': ['nuts_nut_cap', 'nuts_nut_lock', 'nuts_nut_standard'], 1650 | 'Screws': ['fasteners_screw_round', 'fasteners_screw_tbolt', 'fasteners_screw_truss', 'fasteners_screw_truss_modified', 'fasteners_screw_wafer', 'fasteners_screw_bugle', 'fasteners_screw_fillister', 'fasteners_screw_flat', 'fasteners_screw_hex', 'fasteners_screw_oval', 'fasteners_screw_pan', 'fasteners_screw_pan_hex', 'fasteners_screw_thumb_knurled', 'fasteners_screw_trim', 'fasteners_thumb_screw'], 1651 | 'Washers': ['washers_fender', 'washers_flat', 'washers_split', 'washers_star_exterior', 'washers_star_interior'] 1652 | }; 1653 | 1654 | const iconNames = { 1655 | 'electronics_wago_logo': 'Wago Logo', 1656 | 'electronics_wago_alt1': 'Wago Alt 1', 1657 | 'electronics_wago_alt2': 'Wago Alt 2', 1658 | 'electronics_wire_nut': 'Wire Nut', 1659 | 'electronics_generic': 'Generic Electrical', 1660 | 'heads_cross': 'Cross Head', 1661 | 'heads_hex_external': 'Hex External', 1662 | 'heads_hex_socket': 'Hex Socket', 1663 | 'heads_phillips': 'Phillips Head', 1664 | 'heads_pozidriv': 'Pozidriv', 1665 | 'heads_robertson': 'Robertson Head', 1666 | 'heads_slotted': 'Slotted Head', 1667 | 'heads_square_external': 'Square External', 1668 | 'heads_ta': 'TA Head', 1669 | 'heads_torx': 'Torx Head', 1670 | 'heads_torx_tamperproof': 'Torx Tamperproof', 1671 | 'inserts_heat': 'Heat Insert', 1672 | 'inserts_wood': 'Wood Insert', 1673 | 'nuts_nut_cap': 'Cap Nut', 1674 | 'nuts_nut_lock': 'Lock Nut', 1675 | 'nuts_nut_standard': 'Standard Nut', 1676 | 'fasteners_screw_round': 'Round Screw', 1677 | 'fasteners_screw_tbolt': 'T-Bolt', 1678 | 'fasteners_screw_truss': 'Truss Screw', 1679 | 'fasteners_screw_truss_modified': 'Truss Modified', 1680 | 'fasteners_screw_wafer': 'Wafer Screw', 1681 | 'fasteners_screw_bugle': 'Bugle Screw', 1682 | 'fasteners_screw_fillister': 'Fillister Screw', 1683 | 'fasteners_screw_flat': 'Flat Screw', 1684 | 'fasteners_screw_hex': 'Hex Screw', 1685 | 'fasteners_screw_oval': 'Oval Screw', 1686 | 'fasteners_screw_pan': 'Pan Screw', 1687 | 'fasteners_screw_pan_hex': 'Pan Hex Screw', 1688 | 'fasteners_screw_thumb_knurled': 'Thumb Knurled', 1689 | 'fasteners_screw_trim': 'Trim Screw', 1690 | 'fasteners_thumb_screw': 'Thumb Screw', 1691 | 'washers_fender': 'Fender Washer', 1692 | 'washers_flat': 'Flat Washer', 1693 | 'washers_split': 'Split Washer', 1694 | 'washers_star_exterior': 'Star Exterior', 1695 | 'washers_star_interior': 'Star Interior' 1696 | }; 1697 | 1698 | this.iconCategories = iconCategories; 1699 | this.iconNames = iconNames; 1700 | this.selectedIcon = 'heads_hex_socket'; 1701 | 1702 | // Populate the icon grid 1703 | this.populateIconGrid(); 1704 | 1705 | // Search functionality 1706 | const searchInput = document.getElementById('icon-search'); 1707 | if (searchInput) { 1708 | searchInput.addEventListener('input', (e) => { 1709 | this.filterIcons(e.target.value); 1710 | }); 1711 | } 1712 | } 1713 | 1714 | populateIconGrid() { 1715 | const grid = document.getElementById('icon-grid'); 1716 | grid.innerHTML = ''; 1717 | 1718 | // Add custom icons first if they exist 1719 | if (Object.keys(this.customIcons).length > 0) { 1720 | const customCategoryDiv = document.createElement('div'); 1721 | customCategoryDiv.className = 'icon-picker-category custom-icon-category'; 1722 | customCategoryDiv.textContent = 'Custom Icons'; 1723 | grid.appendChild(customCategoryDiv); 1724 | 1725 | Object.keys(this.customIcons).forEach(iconKey => { 1726 | const iconDiv = document.createElement('div'); 1727 | iconDiv.className = 'icon-picker-item'; 1728 | iconDiv.dataset.icon = iconKey; 1729 | iconDiv.innerHTML = ` 1730 | ${iconKey} 1731 | ${iconKey.replace('Custom_', '').replace(/_/g, ' ')} 1732 | 1733 | 1734 | `; 1735 | 1736 | iconDiv.addEventListener('click', () => { 1737 | this.selectIcon(iconKey); 1738 | }); 1739 | 1740 | if (iconKey === this.selectedIcon) { 1741 | iconDiv.classList.add('selected'); 1742 | } 1743 | 1744 | grid.appendChild(iconDiv); 1745 | }); 1746 | } 1747 | 1748 | // Add built-in icons 1749 | Object.entries(this.iconCategories).forEach(([category, icons]) => { 1750 | // Add category header 1751 | const categoryDiv = document.createElement('div'); 1752 | categoryDiv.className = 'icon-picker-category'; 1753 | categoryDiv.textContent = category; 1754 | grid.appendChild(categoryDiv); 1755 | 1756 | // Add icons 1757 | icons.forEach(iconKey => { 1758 | const iconDiv = document.createElement('div'); 1759 | iconDiv.className = 'icon-picker-item'; 1760 | iconDiv.dataset.icon = iconKey; 1761 | iconDiv.innerHTML = ` 1762 | ${iconKey} 1763 | ${this.iconNames[iconKey]} 1764 | `; 1765 | 1766 | iconDiv.addEventListener('click', () => { 1767 | this.selectIcon(iconKey); 1768 | }); 1769 | 1770 | if (iconKey === this.selectedIcon) { 1771 | iconDiv.classList.add('selected'); 1772 | } 1773 | 1774 | grid.appendChild(iconDiv); 1775 | }); 1776 | }); 1777 | } 1778 | 1779 | selectIcon(iconKey) { 1780 | this.selectedIcon = iconKey; 1781 | 1782 | // Get icon path and display name 1783 | const iconPath = this.icons[iconKey] || this.customIcons[iconKey]; 1784 | const displayName = this.iconNames[iconKey] || iconKey.replace('Custom_', '').replace(/_/g, ' '); 1785 | 1786 | // Update the selected icon display 1787 | const selectedIconDiv = document.querySelector('.selected-icon'); 1788 | selectedIconDiv.dataset.icon = iconKey; 1789 | selectedIconDiv.innerHTML = ` 1790 | ${iconKey} 1791 | ${displayName} 1792 | `; 1793 | 1794 | // Update the hidden select for compatibility 1795 | const selectElement = document.getElementById('icon-select'); 1796 | selectElement.value = iconKey; 1797 | selectElement.innerHTML = ``; 1798 | 1799 | // Update grid selection 1800 | document.querySelectorAll('.icon-picker-item').forEach(item => { 1801 | item.classList.toggle('selected', item.dataset.icon === iconKey); 1802 | }); 1803 | 1804 | // Close picker and update preview 1805 | this.closeIconPicker(); 1806 | this.updatePreview(); 1807 | this.syncSingleToYaml(); 1808 | } 1809 | 1810 | openIconPicker() { 1811 | const iconPicker = document.getElementById('icon-picker'); 1812 | const overlay = document.querySelector('.icon-picker-overlay'); 1813 | 1814 | if (iconPicker && overlay) { 1815 | iconPicker.style.display = 'block'; 1816 | overlay.classList.add('active'); 1817 | 1818 | // Focus search input 1819 | setTimeout(() => { 1820 | const searchInput = document.getElementById('icon-search'); 1821 | if (searchInput) { 1822 | searchInput.focus(); 1823 | } 1824 | }, 100); 1825 | } 1826 | } 1827 | 1828 | closeIconPicker() { 1829 | const iconPicker = document.getElementById('icon-picker'); 1830 | const overlay = document.querySelector('.icon-picker-overlay'); 1831 | 1832 | if (iconPicker && overlay) { 1833 | iconPicker.style.display = 'none'; 1834 | overlay.classList.remove('active'); 1835 | 1836 | // Clear search 1837 | const searchInput = document.getElementById('icon-search'); 1838 | if (searchInput) { 1839 | searchInput.value = ''; 1840 | this.filterIcons(''); 1841 | } 1842 | } 1843 | } 1844 | 1845 | filterIcons(searchTerm) { 1846 | const items = document.querySelectorAll('.icon-picker-item'); 1847 | const categories = document.querySelectorAll('.icon-picker-category'); 1848 | 1849 | searchTerm = searchTerm.toLowerCase(); 1850 | 1851 | items.forEach(item => { 1852 | const iconKey = item.dataset.icon; 1853 | const iconName = (this.iconNames[iconKey] || iconKey.replace('Custom_', '').replace(/_/g, ' ')).toLowerCase(); 1854 | const matches = iconName.includes(searchTerm) || iconKey.toLowerCase().includes(searchTerm); 1855 | 1856 | item.style.display = matches ? 'flex' : 'none'; 1857 | }); 1858 | 1859 | // Show/hide categories based on whether they have visible items 1860 | categories.forEach(category => { 1861 | let hasVisibleItems = false; 1862 | let sibling = category.nextElementSibling; 1863 | 1864 | while (sibling && !sibling.classList.contains('icon-picker-category')) { 1865 | if (sibling.style.display !== 'none') { 1866 | hasVisibleItems = true; 1867 | break; 1868 | } 1869 | sibling = sibling.nextElementSibling; 1870 | } 1871 | 1872 | category.style.display = hasVisibleItems ? 'block' : 'none'; 1873 | }); 1874 | } 1875 | 1876 | renameCustomIcon(iconKey) { 1877 | const currentName = iconKey.replace('Custom_', '').replace(/_/g, ' '); 1878 | const newName = prompt(`Rename custom icon:\n\nCurrent name: ${currentName}\n\nEnter new name (alphanumeric and spaces only):`, currentName); 1879 | 1880 | if (!newName || newName.trim() === '') { 1881 | return; // User cancelled or entered empty name 1882 | } 1883 | 1884 | // Sanitize the new name 1885 | const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); 1886 | 1887 | if (!sanitizedName) { 1888 | alert('Invalid name. Please use only letters, numbers, and spaces.'); 1889 | return; 1890 | } 1891 | 1892 | // Create new key with Custom_ prefix 1893 | const newKey = `Custom_${sanitizedName}`; 1894 | 1895 | // Check if the new name already exists 1896 | if (newKey !== iconKey && (this.icons[newKey] || this.customIcons[newKey])) { 1897 | alert(`An icon with the name "${sanitizedName}" already exists. Please choose a different name.`); 1898 | return; 1899 | } 1900 | 1901 | // If the name hasn't changed, do nothing 1902 | if (newKey === iconKey) { 1903 | return; 1904 | } 1905 | 1906 | // Copy the icon data to the new key 1907 | this.customIcons[newKey] = this.customIcons[iconKey]; 1908 | 1909 | // Delete the old key 1910 | delete this.customIcons[iconKey]; 1911 | 1912 | // Save changes 1913 | this.saveCustomIcons(); 1914 | this.loadAvailableIcons(); 1915 | this.populateIconGrid(); 1916 | 1917 | // If this was the selected icon, update the selection to use new key 1918 | if (this.selectedIcon === iconKey) { 1919 | this.selectIcon(newKey); 1920 | } 1921 | 1922 | // Sync to YAML to reflect the new name 1923 | this.syncSingleToYaml(); 1924 | } 1925 | 1926 | deleteCustomIcon(iconKey) { 1927 | if (confirm(`Are you sure you want to delete the custom icon "${iconKey}"?`)) { 1928 | delete this.customIcons[iconKey]; 1929 | this.saveCustomIcons(); 1930 | this.loadAvailableIcons(); 1931 | this.populateIconGrid(); 1932 | 1933 | // If this was the selected icon, switch to default 1934 | if (this.selectedIcon === iconKey) { 1935 | this.selectIcon('heads_hex_socket'); 1936 | } 1937 | } 1938 | } 1939 | 1940 | validateDPI(dpi) { 1941 | // Validate DPI is within acceptable range 1942 | if (isNaN(dpi) || dpi < 50 || dpi > 1200) { 1943 | return 96; // Default fallback 1944 | } 1945 | return dpi; 1946 | } 1947 | 1948 | rotateCanvas(canvas, degrees) { 1949 | const rotatedCanvas = document.createElement('canvas'); 1950 | const rotatedCtx = rotatedCanvas.getContext('2d'); 1951 | 1952 | // For 90 degree rotation, swap width and height 1953 | if (degrees === 90) { 1954 | rotatedCanvas.width = canvas.height; 1955 | rotatedCanvas.height = canvas.width; 1956 | 1957 | // Translate and rotate 1958 | rotatedCtx.translate(canvas.height, 0); 1959 | rotatedCtx.rotate(Math.PI / 2); 1960 | } 1961 | 1962 | // Draw the original canvas onto the rotated canvas 1963 | rotatedCtx.drawImage(canvas, 0, 0); 1964 | 1965 | return rotatedCanvas; 1966 | } 1967 | 1968 | loadDPISettings() { 1969 | try { 1970 | const settings = localStorage.getItem('dpiSettings'); 1971 | if (settings) { 1972 | const parsed = JSON.parse(settings); 1973 | if (parsed.pngDpi) { 1974 | document.getElementById('png-dpi').value = this.validateDPI(parsed.pngDpi); 1975 | } 1976 | // SVG DPI removed - using fixed 96 DPI 1977 | if (parsed.exportRotate !== undefined) { 1978 | document.getElementById('export-rotate').checked = parsed.exportRotate; 1979 | } 1980 | } 1981 | } catch (error) { 1982 | console.error('Error loading DPI settings:', error); 1983 | } 1984 | } 1985 | 1986 | saveDPISettings() { 1987 | try { 1988 | const settings = { 1989 | pngDpi: parseInt(document.getElementById('png-dpi').value), 1990 | exportRotate: document.getElementById('export-rotate').checked 1991 | }; 1992 | localStorage.setItem('dpiSettings', JSON.stringify(settings)); 1993 | } catch (error) { 1994 | console.error('Error saving DPI settings:', error); 1995 | } 1996 | } 1997 | } 1998 | 1999 | 2000 | let labelMaker; 2001 | 2002 | document.addEventListener('DOMContentLoaded', () => { 2003 | labelMaker = new LabelMaker(); 2004 | }); 2005 | --------------------------------------------------------------------------------