Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: add context menus and print layout for Docs and Sheets' (#27) from feat/context-print into main

scott edf4426d 38cca2d2

+2122
+526
package-lock.json
··· 44 44 }, 45 45 "devDependencies": { 46 46 "concurrently": "^9.1.0", 47 + "jsdom": "^29.0.0", 47 48 "jszip": "^3.10.1", 48 49 "vite": "^6.0.0", 49 50 "vitest": "^4.1.0" 50 51 } 51 52 }, 53 + "node_modules/@asamuzakjp/css-color": { 54 + "version": "5.0.1", 55 + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", 56 + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", 57 + "dev": true, 58 + "license": "MIT", 59 + "dependencies": { 60 + "@csstools/css-calc": "^3.1.1", 61 + "@csstools/css-color-parser": "^4.0.2", 62 + "@csstools/css-parser-algorithms": "^4.0.0", 63 + "@csstools/css-tokenizer": "^4.0.0", 64 + "lru-cache": "^11.2.6" 65 + }, 66 + "engines": { 67 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 68 + } 69 + }, 70 + "node_modules/@asamuzakjp/dom-selector": { 71 + "version": "7.0.3", 72 + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", 73 + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", 74 + "dev": true, 75 + "license": "MIT", 76 + "dependencies": { 77 + "@asamuzakjp/nwsapi": "^2.3.9", 78 + "bidi-js": "^1.0.3", 79 + "css-tree": "^3.2.1", 80 + "is-potential-custom-element-name": "^1.0.1", 81 + "lru-cache": "^11.2.7" 82 + }, 83 + "engines": { 84 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 85 + } 86 + }, 87 + "node_modules/@asamuzakjp/nwsapi": { 88 + "version": "2.3.9", 89 + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", 90 + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", 91 + "dev": true, 92 + "license": "MIT" 93 + }, 52 94 "node_modules/@babel/runtime": { 53 95 "version": "7.28.6", 54 96 "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", ··· 58 100 "node": ">=6.9.0" 59 101 } 60 102 }, 103 + "node_modules/@bramus/specificity": { 104 + "version": "2.4.2", 105 + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", 106 + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", 107 + "dev": true, 108 + "license": "MIT", 109 + "dependencies": { 110 + "css-tree": "^3.0.0" 111 + }, 112 + "bin": { 113 + "specificity": "bin/cli.js" 114 + } 115 + }, 116 + "node_modules/@csstools/color-helpers": { 117 + "version": "6.0.2", 118 + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", 119 + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", 120 + "dev": true, 121 + "funding": [ 122 + { 123 + "type": "github", 124 + "url": "https://github.com/sponsors/csstools" 125 + }, 126 + { 127 + "type": "opencollective", 128 + "url": "https://opencollective.com/csstools" 129 + } 130 + ], 131 + "license": "MIT-0", 132 + "engines": { 133 + "node": ">=20.19.0" 134 + } 135 + }, 136 + "node_modules/@csstools/css-calc": { 137 + "version": "3.1.1", 138 + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", 139 + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", 140 + "dev": true, 141 + "funding": [ 142 + { 143 + "type": "github", 144 + "url": "https://github.com/sponsors/csstools" 145 + }, 146 + { 147 + "type": "opencollective", 148 + "url": "https://opencollective.com/csstools" 149 + } 150 + ], 151 + "license": "MIT", 152 + "engines": { 153 + "node": ">=20.19.0" 154 + }, 155 + "peerDependencies": { 156 + "@csstools/css-parser-algorithms": "^4.0.0", 157 + "@csstools/css-tokenizer": "^4.0.0" 158 + } 159 + }, 160 + "node_modules/@csstools/css-color-parser": { 161 + "version": "4.0.2", 162 + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", 163 + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", 164 + "dev": true, 165 + "funding": [ 166 + { 167 + "type": "github", 168 + "url": "https://github.com/sponsors/csstools" 169 + }, 170 + { 171 + "type": "opencollective", 172 + "url": "https://opencollective.com/csstools" 173 + } 174 + ], 175 + "license": "MIT", 176 + "dependencies": { 177 + "@csstools/color-helpers": "^6.0.2", 178 + "@csstools/css-calc": "^3.1.1" 179 + }, 180 + "engines": { 181 + "node": ">=20.19.0" 182 + }, 183 + "peerDependencies": { 184 + "@csstools/css-parser-algorithms": "^4.0.0", 185 + "@csstools/css-tokenizer": "^4.0.0" 186 + } 187 + }, 188 + "node_modules/@csstools/css-parser-algorithms": { 189 + "version": "4.0.0", 190 + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", 191 + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", 192 + "dev": true, 193 + "funding": [ 194 + { 195 + "type": "github", 196 + "url": "https://github.com/sponsors/csstools" 197 + }, 198 + { 199 + "type": "opencollective", 200 + "url": "https://opencollective.com/csstools" 201 + } 202 + ], 203 + "license": "MIT", 204 + "peer": true, 205 + "engines": { 206 + "node": ">=20.19.0" 207 + }, 208 + "peerDependencies": { 209 + "@csstools/css-tokenizer": "^4.0.0" 210 + } 211 + }, 212 + "node_modules/@csstools/css-syntax-patches-for-csstree": { 213 + "version": "1.1.1", 214 + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", 215 + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", 216 + "dev": true, 217 + "funding": [ 218 + { 219 + "type": "github", 220 + "url": "https://github.com/sponsors/csstools" 221 + }, 222 + { 223 + "type": "opencollective", 224 + "url": "https://opencollective.com/csstools" 225 + } 226 + ], 227 + "license": "MIT-0", 228 + "peerDependencies": { 229 + "css-tree": "^3.2.1" 230 + }, 231 + "peerDependenciesMeta": { 232 + "css-tree": { 233 + "optional": true 234 + } 235 + } 236 + }, 237 + "node_modules/@csstools/css-tokenizer": { 238 + "version": "4.0.0", 239 + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", 240 + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", 241 + "dev": true, 242 + "funding": [ 243 + { 244 + "type": "github", 245 + "url": "https://github.com/sponsors/csstools" 246 + }, 247 + { 248 + "type": "opencollective", 249 + "url": "https://opencollective.com/csstools" 250 + } 251 + ], 252 + "license": "MIT", 253 + "peer": true, 254 + "engines": { 255 + "node": ">=20.19.0" 256 + } 257 + }, 61 258 "node_modules/@esbuild/aix-ppc64": { 62 259 "version": "0.25.12", 63 260 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", ··· 500 697 "node": ">=18" 501 698 } 502 699 }, 700 + "node_modules/@exodus/bytes": { 701 + "version": "1.15.0", 702 + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", 703 + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", 704 + "dev": true, 705 + "license": "MIT", 706 + "engines": { 707 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 708 + }, 709 + "peerDependencies": { 710 + "@noble/hashes": "^1.8.0 || ^2.0.0" 711 + }, 712 + "peerDependenciesMeta": { 713 + "@noble/hashes": { 714 + "optional": true 715 + } 716 + } 717 + }, 503 718 "node_modules/@jridgewell/sourcemap-codec": { 504 719 "version": "1.5.5", 505 720 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", ··· 1761 1976 "prebuild-install": "^7.1.1" 1762 1977 } 1763 1978 }, 1979 + "node_modules/bidi-js": { 1980 + "version": "1.0.3", 1981 + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", 1982 + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", 1983 + "dev": true, 1984 + "license": "MIT", 1985 + "dependencies": { 1986 + "require-from-string": "^2.0.2" 1987 + } 1988 + }, 1764 1989 "node_modules/bindings": { 1765 1990 "version": "1.5.0", 1766 1991 "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", ··· 2151 2376 "utrie": "^1.0.2" 2152 2377 } 2153 2378 }, 2379 + "node_modules/css-tree": { 2380 + "version": "3.2.1", 2381 + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", 2382 + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", 2383 + "dev": true, 2384 + "license": "MIT", 2385 + "dependencies": { 2386 + "mdn-data": "2.27.1", 2387 + "source-map-js": "^1.2.1" 2388 + }, 2389 + "engines": { 2390 + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" 2391 + } 2392 + }, 2393 + "node_modules/data-urls": { 2394 + "version": "7.0.0", 2395 + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", 2396 + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", 2397 + "dev": true, 2398 + "license": "MIT", 2399 + "dependencies": { 2400 + "whatwg-mimetype": "^5.0.0", 2401 + "whatwg-url": "^16.0.0" 2402 + }, 2403 + "engines": { 2404 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 2405 + } 2406 + }, 2154 2407 "node_modules/debug": { 2155 2408 "version": "2.6.9", 2156 2409 "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", ··· 2159 2412 "dependencies": { 2160 2413 "ms": "2.0.0" 2161 2414 } 2415 + }, 2416 + "node_modules/decimal.js": { 2417 + "version": "10.6.0", 2418 + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", 2419 + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", 2420 + "dev": true, 2421 + "license": "MIT" 2162 2422 }, 2163 2423 "node_modules/decompress-response": { 2164 2424 "version": "6.0.0", ··· 2699 2959 "node": ">= 0.4" 2700 2960 } 2701 2961 }, 2962 + "node_modules/html-encoding-sniffer": { 2963 + "version": "6.0.0", 2964 + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", 2965 + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", 2966 + "dev": true, 2967 + "license": "MIT", 2968 + "dependencies": { 2969 + "@exodus/bytes": "^1.6.0" 2970 + }, 2971 + "engines": { 2972 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 2973 + } 2974 + }, 2702 2975 "node_modules/html2canvas": { 2703 2976 "version": "1.4.1", 2704 2977 "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", ··· 2818 3091 "node": ">=8" 2819 3092 } 2820 3093 }, 3094 + "node_modules/is-potential-custom-element-name": { 3095 + "version": "1.0.1", 3096 + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", 3097 + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", 3098 + "dev": true, 3099 + "license": "MIT" 3100 + }, 2821 3101 "node_modules/isarray": { 2822 3102 "version": "1.0.0", 2823 3103 "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", ··· 2834 3114 "url": "https://github.com/sponsors/dmonad" 2835 3115 } 2836 3116 }, 3117 + "node_modules/jsdom": { 3118 + "version": "29.0.0", 3119 + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", 3120 + "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", 3121 + "dev": true, 3122 + "license": "MIT", 3123 + "peer": true, 3124 + "dependencies": { 3125 + "@asamuzakjp/css-color": "^5.0.1", 3126 + "@asamuzakjp/dom-selector": "^7.0.2", 3127 + "@bramus/specificity": "^2.4.2", 3128 + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", 3129 + "@exodus/bytes": "^1.15.0", 3130 + "css-tree": "^3.2.1", 3131 + "data-urls": "^7.0.0", 3132 + "decimal.js": "^10.6.0", 3133 + "html-encoding-sniffer": "^6.0.0", 3134 + "is-potential-custom-element-name": "^1.0.1", 3135 + "lru-cache": "^11.2.7", 3136 + "parse5": "^8.0.0", 3137 + "saxes": "^6.0.0", 3138 + "symbol-tree": "^3.2.4", 3139 + "tough-cookie": "^6.0.1", 3140 + "undici": "^7.24.3", 3141 + "w3c-xmlserializer": "^5.0.0", 3142 + "webidl-conversions": "^8.0.1", 3143 + "whatwg-mimetype": "^5.0.0", 3144 + "whatwg-url": "^16.0.1", 3145 + "xml-name-validator": "^5.0.0" 3146 + }, 3147 + "engines": { 3148 + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" 3149 + }, 3150 + "peerDependencies": { 3151 + "canvas": "^3.0.0" 3152 + }, 3153 + "peerDependenciesMeta": { 3154 + "canvas": { 3155 + "optional": true 3156 + } 3157 + } 3158 + }, 2837 3159 "node_modules/jspdf": { 2838 3160 "version": "4.2.0", 2839 3161 "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", ··· 2955 3277 "underscore": "^1.13.1" 2956 3278 } 2957 3279 }, 3280 + "node_modules/lru-cache": { 3281 + "version": "11.2.7", 3282 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", 3283 + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", 3284 + "dev": true, 3285 + "license": "BlueOak-1.0.0", 3286 + "engines": { 3287 + "node": "20 || >=22" 3288 + } 3289 + }, 2958 3290 "node_modules/magic-string": { 2959 3291 "version": "0.30.21", 2960 3292 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", ··· 3023 3355 "engines": { 3024 3356 "node": ">= 0.4" 3025 3357 } 3358 + }, 3359 + "node_modules/mdn-data": { 3360 + "version": "2.27.1", 3361 + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", 3362 + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", 3363 + "dev": true, 3364 + "license": "CC0-1.0" 3026 3365 }, 3027 3366 "node_modules/mdurl": { 3028 3367 "version": "2.0.0", ··· 3249 3588 "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", 3250 3589 "license": "(MIT AND Zlib)" 3251 3590 }, 3591 + "node_modules/parse5": { 3592 + "version": "8.0.0", 3593 + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", 3594 + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", 3595 + "dev": true, 3596 + "license": "MIT", 3597 + "dependencies": { 3598 + "entities": "^6.0.0" 3599 + }, 3600 + "funding": { 3601 + "url": "https://github.com/inikulin/parse5?sponsor=1" 3602 + } 3603 + }, 3604 + "node_modules/parse5/node_modules/entities": { 3605 + "version": "6.0.1", 3606 + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", 3607 + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", 3608 + "dev": true, 3609 + "license": "BSD-2-Clause", 3610 + "engines": { 3611 + "node": ">=0.12" 3612 + }, 3613 + "funding": { 3614 + "url": "https://github.com/fb55/entities?sponsor=1" 3615 + } 3616 + }, 3252 3617 "node_modules/parseurl": { 3253 3618 "version": "1.3.3", 3254 3619 "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", ··· 3591 3956 "once": "^1.3.1" 3592 3957 } 3593 3958 }, 3959 + "node_modules/punycode": { 3960 + "version": "2.3.1", 3961 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 3962 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 3963 + "dev": true, 3964 + "license": "MIT", 3965 + "engines": { 3966 + "node": ">=6" 3967 + } 3968 + }, 3594 3969 "node_modules/punycode.js": { 3595 3970 "version": "2.3.1", 3596 3971 "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", ··· 3695 4070 "node": ">=0.10.0" 3696 4071 } 3697 4072 }, 4073 + "node_modules/require-from-string": { 4074 + "version": "2.0.2", 4075 + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 4076 + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", 4077 + "dev": true, 4078 + "license": "MIT", 4079 + "engines": { 4080 + "node": ">=0.10.0" 4081 + } 4082 + }, 3698 4083 "node_modules/rgbcolor": { 3699 4084 "version": "1.0.1", 3700 4085 "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", ··· 3792 4177 "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 3793 4178 "license": "MIT" 3794 4179 }, 4180 + "node_modules/saxes": { 4181 + "version": "6.0.0", 4182 + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", 4183 + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", 4184 + "dev": true, 4185 + "license": "ISC", 4186 + "dependencies": { 4187 + "xmlchars": "^2.2.0" 4188 + }, 4189 + "engines": { 4190 + "node": ">=v12.22.7" 4191 + } 4192 + }, 3795 4193 "node_modules/semver": { 3796 4194 "version": "7.7.4", 3797 4195 "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", ··· 4131 4529 "node": ">=12.0.0" 4132 4530 } 4133 4531 }, 4532 + "node_modules/symbol-tree": { 4533 + "version": "3.2.4", 4534 + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", 4535 + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", 4536 + "dev": true, 4537 + "license": "MIT" 4538 + }, 4134 4539 "node_modules/tar-fs": { 4135 4540 "version": "2.1.4", 4136 4541 "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", ··· 4212 4617 "node": ">=14.0.0" 4213 4618 } 4214 4619 }, 4620 + "node_modules/tldts": { 4621 + "version": "7.0.26", 4622 + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", 4623 + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", 4624 + "dev": true, 4625 + "license": "MIT", 4626 + "dependencies": { 4627 + "tldts-core": "^7.0.26" 4628 + }, 4629 + "bin": { 4630 + "tldts": "bin/cli.js" 4631 + } 4632 + }, 4633 + "node_modules/tldts-core": { 4634 + "version": "7.0.26", 4635 + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", 4636 + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", 4637 + "dev": true, 4638 + "license": "MIT" 4639 + }, 4215 4640 "node_modules/toidentifier": { 4216 4641 "version": "1.0.1", 4217 4642 "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", ··· 4221 4646 "node": ">=0.6" 4222 4647 } 4223 4648 }, 4649 + "node_modules/tough-cookie": { 4650 + "version": "6.0.1", 4651 + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", 4652 + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", 4653 + "dev": true, 4654 + "license": "BSD-3-Clause", 4655 + "dependencies": { 4656 + "tldts": "^7.0.5" 4657 + }, 4658 + "engines": { 4659 + "node": ">=16" 4660 + } 4661 + }, 4662 + "node_modules/tr46": { 4663 + "version": "6.0.0", 4664 + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", 4665 + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", 4666 + "dev": true, 4667 + "license": "MIT", 4668 + "dependencies": { 4669 + "punycode": "^2.3.1" 4670 + }, 4671 + "engines": { 4672 + "node": ">=20" 4673 + } 4674 + }, 4224 4675 "node_modules/tree-kill": { 4225 4676 "version": "1.2.2", 4226 4677 "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", ··· 4274 4725 "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", 4275 4726 "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", 4276 4727 "license": "MIT" 4728 + }, 4729 + "node_modules/undici": { 4730 + "version": "7.24.4", 4731 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", 4732 + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", 4733 + "dev": true, 4734 + "license": "MIT", 4735 + "engines": { 4736 + "node": ">=20.18.1" 4737 + } 4277 4738 }, 4278 4739 "node_modules/unpipe": { 4279 4740 "version": "1.0.0", ··· 4481 4942 "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", 4482 4943 "license": "MIT" 4483 4944 }, 4945 + "node_modules/w3c-xmlserializer": { 4946 + "version": "5.0.0", 4947 + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", 4948 + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", 4949 + "dev": true, 4950 + "license": "MIT", 4951 + "dependencies": { 4952 + "xml-name-validator": "^5.0.0" 4953 + }, 4954 + "engines": { 4955 + "node": ">=18" 4956 + } 4957 + }, 4958 + "node_modules/webidl-conversions": { 4959 + "version": "8.0.1", 4960 + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", 4961 + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", 4962 + "dev": true, 4963 + "license": "BSD-2-Clause", 4964 + "engines": { 4965 + "node": ">=20" 4966 + } 4967 + }, 4968 + "node_modules/whatwg-mimetype": { 4969 + "version": "5.0.0", 4970 + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", 4971 + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", 4972 + "dev": true, 4973 + "license": "MIT", 4974 + "engines": { 4975 + "node": ">=20" 4976 + } 4977 + }, 4978 + "node_modules/whatwg-url": { 4979 + "version": "16.0.1", 4980 + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", 4981 + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", 4982 + "dev": true, 4983 + "license": "MIT", 4984 + "dependencies": { 4985 + "@exodus/bytes": "^1.11.0", 4986 + "tr46": "^6.0.0", 4987 + "webidl-conversions": "^8.0.1" 4988 + }, 4989 + "engines": { 4990 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 4991 + } 4992 + }, 4484 4993 "node_modules/why-is-node-running": { 4485 4994 "version": "2.3.0", 4486 4995 "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", ··· 4582 5091 "node": ">=0.8" 4583 5092 } 4584 5093 }, 5094 + "node_modules/xml-name-validator": { 5095 + "version": "5.0.0", 5096 + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", 5097 + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", 5098 + "dev": true, 5099 + "license": "Apache-2.0", 5100 + "engines": { 5101 + "node": ">=18" 5102 + } 5103 + }, 4585 5104 "node_modules/xmlbuilder": { 4586 5105 "version": "10.1.1", 4587 5106 "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", ··· 4590 5109 "engines": { 4591 5110 "node": ">=4.0" 4592 5111 } 5112 + }, 5113 + "node_modules/xmlchars": { 5114 + "version": "2.2.0", 5115 + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", 5116 + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", 5117 + "dev": true, 5118 + "license": "MIT" 4593 5119 }, 4594 5120 "node_modules/y-prosemirror": { 4595 5121 "version": "1.3.7",
+1
package.json
··· 47 47 }, 48 48 "devDependencies": { 49 49 "concurrently": "^9.1.0", 50 + "jsdom": "^29.0.0", 50 51 "jszip": "^3.10.1", 51 52 "vite": "^6.0.0", 52 53 "vitest": "^4.1.0"
+190
src/css/app.css
··· 2418 2418 display: none; 2419 2419 } 2420 2420 } 2421 + 2422 + /* ======================================================== 2423 + Context Menu 2424 + ======================================================== */ 2425 + 2426 + .context-menu { 2427 + position: fixed; 2428 + z-index: 9999; 2429 + min-width: 12rem; 2430 + padding: var(--space-xs) 0; 2431 + background: var(--color-bg); 2432 + border: 1px solid var(--color-border); 2433 + border-radius: var(--radius-md); 2434 + box-shadow: var(--shadow-lg); 2435 + font-family: var(--font-body); 2436 + font-size: 0.8rem; 2437 + color: var(--color-text); 2438 + outline: none; 2439 + } 2440 + 2441 + .context-menu-item { 2442 + display: flex; 2443 + align-items: center; 2444 + gap: var(--space-sm); 2445 + width: 100%; 2446 + padding: 0.35rem 0.75rem; 2447 + border: none; 2448 + background: transparent; 2449 + color: var(--color-text); 2450 + font-family: var(--font-body); 2451 + font-size: 0.8rem; 2452 + cursor: pointer; 2453 + text-align: left; 2454 + transition: background var(--transition-fast); 2455 + white-space: nowrap; 2456 + outline: none; 2457 + } 2458 + 2459 + .context-menu-item:hover, 2460 + .context-menu-item:focus-visible { 2461 + background: var(--color-hover); 2462 + } 2463 + 2464 + .context-menu-item.disabled { 2465 + color: var(--color-text-faint); 2466 + cursor: default; 2467 + } 2468 + 2469 + .context-menu-item.disabled:hover, 2470 + .context-menu-item.disabled:focus-visible { 2471 + background: transparent; 2472 + } 2473 + 2474 + .context-menu-icon { 2475 + width: 1.2rem; 2476 + text-align: center; 2477 + flex-shrink: 0; 2478 + display: inline-flex; 2479 + align-items: center; 2480 + justify-content: center; 2481 + font-size: 0.85rem; 2482 + } 2483 + 2484 + .context-menu-label { 2485 + flex: 1; 2486 + } 2487 + 2488 + .context-menu-shortcut { 2489 + font-family: var(--font-mono); 2490 + font-size: 0.65rem; 2491 + color: var(--color-text-faint); 2492 + margin-left: var(--space-md); 2493 + } 2494 + 2495 + .context-menu-separator { 2496 + height: 1px; 2497 + background: var(--color-border); 2498 + margin: var(--space-xs) 0; 2499 + } 2500 + 2501 + /* ======================================================== 2502 + Print Preview Modal 2503 + ======================================================== */ 2504 + 2505 + .print-preview-overlay { 2506 + position: fixed; 2507 + inset: 0; 2508 + z-index: 1000; 2509 + background: var(--color-modal-backdrop); 2510 + display: flex; 2511 + align-items: flex-start; 2512 + justify-content: center; 2513 + padding: var(--space-xl); 2514 + overflow-y: auto; 2515 + } 2516 + 2517 + .print-preview-container { 2518 + background: var(--color-bg); 2519 + border-radius: var(--radius-lg); 2520 + box-shadow: var(--shadow-lg); 2521 + max-width: 900px; 2522 + width: 100%; 2523 + max-height: 90vh; 2524 + display: flex; 2525 + flex-direction: column; 2526 + } 2527 + 2528 + .print-preview-header { 2529 + display: flex; 2530 + align-items: center; 2531 + justify-content: space-between; 2532 + padding: var(--space-md) var(--space-lg); 2533 + border-bottom: 1px solid var(--color-border); 2534 + } 2535 + 2536 + .print-preview-header h2 { 2537 + font-family: var(--font-display); 2538 + font-size: 1.1rem; 2539 + font-weight: 600; 2540 + margin: 0; 2541 + } 2542 + 2543 + .print-preview-options { 2544 + display: flex; 2545 + gap: var(--space-md); 2546 + padding: var(--space-sm) var(--space-lg); 2547 + border-bottom: 1px solid var(--color-border); 2548 + flex-wrap: wrap; 2549 + } 2550 + 2551 + .print-preview-options label { 2552 + display: flex; 2553 + align-items: center; 2554 + gap: var(--space-xs); 2555 + font-size: 0.8rem; 2556 + color: var(--color-text-muted); 2557 + } 2558 + 2559 + .print-preview-options select { 2560 + font-size: 0.8rem; 2561 + padding: 2px 6px; 2562 + border: 1px solid var(--color-border); 2563 + border-radius: var(--radius-sm); 2564 + background: var(--color-bg); 2565 + color: var(--color-text); 2566 + } 2567 + 2568 + .print-preview-body { 2569 + flex: 1; 2570 + overflow-y: auto; 2571 + padding: var(--space-lg); 2572 + background: var(--color-surface-alt); 2573 + } 2574 + 2575 + .print-preview-body iframe { 2576 + width: 100%; 2577 + min-height: 600px; 2578 + border: 1px solid var(--color-border); 2579 + border-radius: var(--radius-sm); 2580 + background: #fff; 2581 + } 2582 + 2583 + .print-preview-footer { 2584 + display: flex; 2585 + justify-content: flex-end; 2586 + gap: var(--space-sm); 2587 + padding: var(--space-md) var(--space-lg); 2588 + border-top: 1px solid var(--color-border); 2589 + } 2590 + 2591 + /* Enhanced print styles */ 2592 + @media print { 2593 + .context-menu, 2594 + .print-preview-overlay { 2595 + display: none !important; 2596 + } 2597 + 2598 + /* Sheets: hide UI chrome */ 2599 + .sheets-toolbar, 2600 + .tab-bar, 2601 + .formula-bar, 2602 + .sheets-footer { 2603 + display: none !important; 2604 + } 2605 + 2606 + .spreadsheet-container { 2607 + overflow: visible !important; 2608 + } 2609 + } 2610 + 2421 2611 /* ======================================================== 2422 2612 Charts Section 2423 2613 ======================================================== */
+292
src/lib/context-menu.js
··· 1 + /** 2 + * Context Menu — shared component for Docs and Sheets. 3 + * 4 + * Renders a custom right-click context menu with: 5 + * - Icon, label, shortcut hint per item 6 + * - Separator support 7 + * - Disabled state 8 + * - Keyboard navigation (arrow keys, Enter, Escape) 9 + * - Auto-positioning near screen edges 10 + */ 11 + 12 + /** Sentinel value used as a separator between item groups. */ 13 + export const SEPARATOR = Object.freeze({ type: 'separator' }); 14 + 15 + /** 16 + * Position a context menu so it stays within the viewport. 17 + * 18 + * @param {number} mouseX - Click X coordinate 19 + * @param {number} mouseY - Click Y coordinate 20 + * @param {number} menuW - Menu element width 21 + * @param {number} menuH - Menu element height 22 + * @param {number} vpW - Viewport width 23 + * @param {number} vpH - Viewport height 24 + * @returns {{ x: number, y: number }} 25 + */ 26 + export function positionMenu(mouseX, mouseY, menuW, menuH, vpW, vpH) { 27 + let x = mouseX; 28 + let y = mouseY; 29 + 30 + if (x + menuW > vpW) x = vpW - menuW; 31 + if (y + menuH > vpH) y = vpH - menuH; 32 + if (x < 0) x = 0; 33 + if (y < 0) y = 0; 34 + 35 + return { x, y }; 36 + } 37 + 38 + /** 39 + * Create a context menu DOM element from a list of menu items. 40 + * 41 + * Each item is either SEPARATOR or an object: 42 + * { label: string, action: () => void, icon?: string, shortcut?: string, disabled?: boolean } 43 + * 44 + * @param {Array} items 45 + * @returns {{ el: HTMLElement, show: (x: number, y: number) => void, hide: () => void, destroy: () => void }} 46 + */ 47 + export function createContextMenu(items) { 48 + const menu = document.createElement('div'); 49 + menu.className = 'context-menu'; 50 + menu.setAttribute('role', 'menu'); 51 + menu.style.display = 'none'; 52 + menu.style.position = 'fixed'; 53 + menu.style.zIndex = '9999'; 54 + 55 + for (const item of items) { 56 + if (item === SEPARATOR || (item && item.type === 'separator')) { 57 + const sep = document.createElement('div'); 58 + sep.className = 'context-menu-separator'; 59 + sep.setAttribute('role', 'separator'); 60 + menu.appendChild(sep); 61 + continue; 62 + } 63 + 64 + const btn = document.createElement('button'); 65 + btn.className = 'context-menu-item'; 66 + btn.setAttribute('role', 'menuitem'); 67 + btn.setAttribute('tabindex', '0'); 68 + 69 + if (item.disabled) { 70 + btn.classList.add('disabled'); 71 + btn.setAttribute('aria-disabled', 'true'); 72 + } 73 + 74 + // Icon 75 + if (item.icon) { 76 + const iconSpan = document.createElement('span'); 77 + iconSpan.className = 'context-menu-icon'; 78 + iconSpan.textContent = item.icon; 79 + btn.appendChild(iconSpan); 80 + } 81 + 82 + // Label 83 + const labelSpan = document.createElement('span'); 84 + labelSpan.className = 'context-menu-label'; 85 + labelSpan.textContent = item.label; 86 + btn.appendChild(labelSpan); 87 + 88 + // Shortcut hint 89 + if (item.shortcut) { 90 + const shortcutSpan = document.createElement('span'); 91 + shortcutSpan.className = 'context-menu-shortcut'; 92 + shortcutSpan.textContent = item.shortcut; 93 + btn.appendChild(shortcutSpan); 94 + } 95 + 96 + // Click handler 97 + btn.addEventListener('click', (e) => { 98 + e.stopPropagation(); 99 + if (item.disabled) return; 100 + item.action(); 101 + hide(); 102 + }); 103 + 104 + // Keyboard: Enter to activate 105 + btn.addEventListener('keydown', (e) => { 106 + if (e.key === 'Enter') { 107 + e.preventDefault(); 108 + if (!item.disabled) { 109 + item.action(); 110 + hide(); 111 + } 112 + } 113 + }); 114 + 115 + menu.appendChild(btn); 116 + } 117 + 118 + // Keyboard navigation on the menu container 119 + menu.addEventListener('keydown', (e) => { 120 + const focusable = Array.from(menu.querySelectorAll('.context-menu-item:not(.disabled)')); 121 + const current = document.activeElement; 122 + const idx = focusable.indexOf(current); 123 + 124 + if (e.key === 'ArrowDown') { 125 + e.preventDefault(); 126 + const next = idx < focusable.length - 1 ? idx + 1 : 0; 127 + focusable[next]?.focus(); 128 + } else if (e.key === 'ArrowUp') { 129 + e.preventDefault(); 130 + const prev = idx > 0 ? idx - 1 : focusable.length - 1; 131 + focusable[prev]?.focus(); 132 + } else if (e.key === 'Escape') { 133 + e.preventDefault(); 134 + hide(); 135 + } 136 + }); 137 + 138 + function show(x, y) { 139 + menu.style.display = 'block'; 140 + menu.style.left = x + 'px'; 141 + menu.style.top = y + 'px'; 142 + 143 + // Focus first item for keyboard navigation 144 + const first = menu.querySelector('.context-menu-item:not(.disabled)'); 145 + if (first) first.focus(); 146 + } 147 + 148 + function hide() { 149 + menu.style.display = 'none'; 150 + } 151 + 152 + function destroy() { 153 + if (menu.parentNode) { 154 + menu.parentNode.removeChild(menu); 155 + } 156 + } 157 + 158 + return { el: menu, show, hide, destroy }; 159 + } 160 + 161 + // ---- Docs context menu item builders ---- 162 + 163 + /** 164 + * Build menu items for right-clicking on text in Docs. 165 + * Actions are no-ops here; the consumer wires them to the editor. 166 + */ 167 + export function buildDocsTextItems() { 168 + return [ 169 + { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 170 + { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 171 + { label: 'Paste', icon: '📋', shortcut: '⌘V', action: () => {} }, 172 + { label: 'Select All', shortcut: '⌘A', action: () => {} }, 173 + SEPARATOR, 174 + { label: 'Bold', icon: 'B', shortcut: '⌘B', action: () => {} }, 175 + { label: 'Italic', icon: 'I', shortcut: '⌘I', action: () => {} }, 176 + { label: 'Underline', icon: 'U', shortcut: '⌘U', action: () => {} }, 177 + SEPARATOR, 178 + { label: 'Link', icon: '🔗', shortcut: '⌘K', action: () => {} }, 179 + { label: 'Comment', icon: '💬', action: () => {} }, 180 + ]; 181 + } 182 + 183 + /** 184 + * Build menu items for right-clicking on a link in Docs. 185 + */ 186 + export function buildDocsLinkItems() { 187 + return [ 188 + { label: 'Open Link', icon: '↗', action: () => {} }, 189 + { label: 'Edit Link', icon: '✏', action: () => {} }, 190 + { label: 'Remove Link', icon: '✕', action: () => {} }, 191 + SEPARATOR, 192 + { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 193 + ]; 194 + } 195 + 196 + /** 197 + * Build menu items for right-clicking on an image in Docs. 198 + */ 199 + export function buildDocsImageItems() { 200 + return [ 201 + { label: 'Image Properties', icon: '⚙', action: () => {} }, 202 + SEPARATOR, 203 + { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 204 + { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 205 + ]; 206 + } 207 + 208 + /** 209 + * Build menu items for right-clicking on a table in Docs. 210 + */ 211 + export function buildDocsTableItems() { 212 + return [ 213 + { label: 'Insert Row Above', action: () => {} }, 214 + { label: 'Insert Row Below', action: () => {} }, 215 + { label: 'Insert Column Left', action: () => {} }, 216 + { label: 'Insert Column Right', action: () => {} }, 217 + SEPARATOR, 218 + { label: 'Delete Row', action: () => {} }, 219 + { label: 'Delete Column', action: () => {} }, 220 + SEPARATOR, 221 + { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 222 + { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 223 + { label: 'Paste', icon: '📋', shortcut: '⌘V', action: () => {} }, 224 + ]; 225 + } 226 + 227 + // ---- Sheets context menu item builders ---- 228 + 229 + /** 230 + * Build menu items for right-clicking on a cell in Sheets. 231 + */ 232 + export function buildSheetsCellItems() { 233 + return [ 234 + { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 235 + { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 236 + { label: 'Paste', icon: '📋', shortcut: '⌘V', action: () => {} }, 237 + { label: 'Paste Special', icon: '📋', action: () => {} }, 238 + SEPARATOR, 239 + { label: 'Insert Row Above', action: () => {} }, 240 + { label: 'Insert Row Below', action: () => {} }, 241 + { label: 'Insert Column Left', action: () => {} }, 242 + { label: 'Insert Column Right', action: () => {} }, 243 + SEPARATOR, 244 + { label: 'Delete Row', action: () => {} }, 245 + { label: 'Delete Column', action: () => {} }, 246 + SEPARATOR, 247 + { label: 'Cell Format', icon: '⚙', action: () => {} }, 248 + { label: 'Add Note', icon: '📝', action: () => {} }, 249 + ]; 250 + } 251 + 252 + /** 253 + * Build menu items for right-clicking on a column header in Sheets. 254 + */ 255 + export function buildSheetsColumnHeaderItems() { 256 + return [ 257 + { label: 'Sort A \u2192 Z', icon: '↑', action: () => {} }, 258 + { label: 'Sort Z \u2192 A', icon: '↓', action: () => {} }, 259 + SEPARATOR, 260 + { label: 'Insert Column', action: () => {} }, 261 + { label: 'Delete Column', action: () => {} }, 262 + SEPARATOR, 263 + { label: 'Resize Column', action: () => {} }, 264 + ]; 265 + } 266 + 267 + /** 268 + * Build menu items for right-clicking on a row header in Sheets. 269 + */ 270 + export function buildSheetsRowHeaderItems() { 271 + return [ 272 + { label: 'Insert Row', action: () => {} }, 273 + { label: 'Delete Row', action: () => {} }, 274 + SEPARATOR, 275 + { label: 'Resize Row', action: () => {} }, 276 + ]; 277 + } 278 + 279 + /** 280 + * Dispatcher: return the appropriate menu items for a given sheets target. 281 + * 282 + * @param {'cell' | 'colHeader' | 'rowHeader'} target 283 + * @returns {Array} 284 + */ 285 + export function buildSheetsContextItems(target) { 286 + switch (target) { 287 + case 'cell': return buildSheetsCellItems(); 288 + case 'colHeader': return buildSheetsColumnHeaderItems(); 289 + case 'rowHeader': return buildSheetsRowHeaderItems(); 290 + default: return []; 291 + } 292 + }
+333
src/lib/print-layout.js
··· 1 + /** 2 + * Print Layout — shared print utilities for Docs and Sheets. 3 + * 4 + * Provides: 5 + * - Page size and margin presets 6 + * - Print HTML generation with headers/footers 7 + * - Content pagination at page break markers 8 + * - Sheets-specific: grid lines, header repeat, scaling 9 + */ 10 + 11 + /** Standard page sizes. */ 12 + export const PAGE_SIZES = Object.freeze({ 13 + letter: { width: '8.5in', height: '11in' }, 14 + a4: { width: '210mm', height: '297mm' }, 15 + }); 16 + 17 + /** Margin presets (all in inches for consistency). */ 18 + export const MARGIN_PRESETS = Object.freeze({ 19 + normal: { top: '1in', right: '1in', bottom: '1in', left: '1in' }, 20 + narrow: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }, 21 + wide: { top: '1.5in', right: '1.5in', bottom: '1.5in', left: '1.5in' }, 22 + }); 23 + 24 + /** Available scaling modes for sheets printing. */ 25 + export const scalingModes = Object.freeze(['fit-to-width', 'fit-to-page', 'actual-size']); 26 + 27 + /** 28 + * Format a date for print headers. 29 + * @param {Date} [date] - Date to format; defaults to now. 30 + * @returns {string} 31 + */ 32 + export function formatPrintDate(date) { 33 + const d = date || new Date(); 34 + return d.toLocaleDateString('en-US', { 35 + year: 'numeric', 36 + month: 'long', 37 + day: 'numeric', 38 + }); 39 + } 40 + 41 + /** 42 + * Get page dimensions for a given size key. 43 + * @param {string} size - 'letter' or 'a4' 44 + * @returns {{ width: string, height: string }} 45 + */ 46 + export function getPageDimensions(size) { 47 + return PAGE_SIZES[size] || PAGE_SIZES.letter; 48 + } 49 + 50 + /** 51 + * Build a print header HTML string. 52 + * Layout: title on the left, date on the right. 53 + * 54 + * @param {string} title 55 + * @param {Date} [date] 56 + * @returns {string} 57 + */ 58 + export function buildPrintHeader(title, date) { 59 + const dateStr = formatPrintDate(date); 60 + return `<div class="print-header" style="display:flex;justify-content:space-between;align-items:center;padding:0 0 0.25in 0;border-bottom:1px solid #ccc;margin-bottom:0.25in;font-family:system-ui,-apple-system,sans-serif;font-size:10pt;color:#666;"> 61 + <span class="print-header-title">${escapeHtml(title)}</span> 62 + <span class="print-header-date">${dateStr}</span> 63 + </div>`; 64 + } 65 + 66 + /** 67 + * Build a print footer HTML string. 68 + * Layout: centered "Page X of Y". 69 + * 70 + * @param {number} page 71 + * @param {number} totalPages 72 + * @returns {string} 73 + */ 74 + export function buildPrintFooter(page, totalPages) { 75 + return `<div class="print-footer" style="text-align:center;padding:0.25in 0 0 0;border-top:1px solid #ccc;margin-top:0.25in;font-family:system-ui,-apple-system,sans-serif;font-size:9pt;color:#999;"> 76 + Page ${page} of ${totalPages} 77 + </div>`; 78 + } 79 + 80 + /** 81 + * Split HTML content at page-break markers. 82 + * 83 + * @param {string} html 84 + * @returns {string[]} Array of page content strings. 85 + */ 86 + export function paginateContent(html) { 87 + if (!html) return ['']; 88 + 89 + // Split on page-break divs 90 + const parts = html.split(/<div[^>]*class="page-break"[^>]*data-type="page-break"[^>]*>\s*<\/div>/i); 91 + 92 + // Also try the reverse attribute order 93 + if (parts.length === 1) { 94 + const parts2 = html.split(/<div[^>]*data-type="page-break"[^>]*class="page-break"[^>]*>\s*<\/div>/i); 95 + if (parts2.length > 1) return parts2.map(p => p.trim()); 96 + } 97 + 98 + return parts.map(p => p.trim()); 99 + } 100 + 101 + /** 102 + * Build a full HTML document for printing Docs content. 103 + * 104 + * @param {string} editorHtml - The editor's inner HTML 105 + * @param {object} opts 106 + * @param {string} opts.title - Document title 107 + * @param {string} [opts.pageSize='letter'] - Page size key 108 + * @param {string} [opts.margins='normal'] - Margin preset key 109 + * @returns {string} Full HTML document 110 + */ 111 + export function buildDocsPrintHtml(editorHtml, opts = {}) { 112 + const title = opts.title || 'Untitled Document'; 113 + const pageSize = opts.pageSize || 'letter'; 114 + const margins = opts.margins || 'normal'; 115 + 116 + const dims = getPageDimensions(pageSize); 117 + const margin = MARGIN_PRESETS[margins] || MARGIN_PRESETS.normal; 118 + 119 + const pages = paginateContent(editorHtml); 120 + const totalPages = pages.length; 121 + 122 + const pagesHtml = pages.map((pageContent, idx) => { 123 + const header = buildPrintHeader(title, new Date()); 124 + const footer = buildPrintFooter(idx + 1, totalPages); 125 + const breakBefore = idx > 0 ? 'page-break-before: always;' : ''; 126 + return `<div class="print-page" style="${breakBefore}"> 127 + ${header} 128 + <div class="print-page-content">${pageContent}</div> 129 + ${footer} 130 + </div>`; 131 + }).join('\n'); 132 + 133 + return `<!DOCTYPE html> 134 + <html lang="en"> 135 + <head> 136 + <meta charset="UTF-8"> 137 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 138 + <title>${escapeHtml(title)} — Print</title> 139 + <style> 140 + @page { 141 + size: ${dims.width} ${dims.height}; 142 + margin: ${margin.top} ${margin.right} ${margin.bottom} ${margin.left}; 143 + } 144 + 145 + * { box-sizing: border-box; } 146 + 147 + body { 148 + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; 149 + color: #1a1815; 150 + background: #fff; 151 + margin: 0; 152 + padding: 0; 153 + line-height: 1.6; 154 + } 155 + 156 + .print-page { 157 + max-width: ${dims.width}; 158 + margin: 0 auto; 159 + } 160 + 161 + /* Content styles */ 162 + h1, h2, h3, h4, h5, h6 { margin-top: 1em; margin-bottom: 0.5em; } 163 + blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; color: #555; } 164 + pre { background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto; } 165 + code { background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; } 166 + pre code { background: none; padding: 0; } 167 + table { border-collapse: collapse; width: 100%; } 168 + th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; } 169 + th { background: #f5f5f5; } 170 + img { max-width: 100%; } 171 + hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; } 172 + ul[data-type="taskList"] { list-style: none; padding-left: 0; } 173 + ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5em; } 174 + 175 + /* Page break handling */ 176 + .page-break { 177 + page-break-before: always; 178 + break-before: page; 179 + margin: 0; 180 + visibility: hidden; 181 + } 182 + 183 + @media print { 184 + body { background: none; } 185 + .print-page { max-width: none; } 186 + } 187 + 188 + /* Preview styles (screen only) */ 189 + @media screen { 190 + body { 191 + background: #e8e8e8; 192 + padding: 2rem; 193 + } 194 + .print-page { 195 + background: #fff; 196 + padding: ${margin.top} ${margin.right} ${margin.bottom} ${margin.left}; 197 + margin-bottom: 1.5rem; 198 + box-shadow: 0 2px 8px rgba(0,0,0,0.1); 199 + border-radius: 2px; 200 + } 201 + } 202 + </style> 203 + </head> 204 + <body> 205 + ${pagesHtml} 206 + </body> 207 + </html>`; 208 + } 209 + 210 + /** 211 + * Build a full HTML document for printing Sheets content. 212 + * 213 + * @param {object} data 214 + * @param {string[]} data.headers - Column headers 215 + * @param {string[][]} data.rows - Row data arrays 216 + * @param {object} opts 217 + * @param {string} [opts.pageSize='letter'] 218 + * @param {boolean} [opts.gridLines=true] 219 + * @param {boolean} [opts.repeatHeaders=false] 220 + * @param {string} [opts.scaling='actual-size'] - 'fit-to-width' | 'fit-to-page' | 'actual-size' 221 + * @param {string} [opts.title='Spreadsheet'] 222 + * @returns {string} Full HTML document 223 + */ 224 + export function buildSheetsPrintHtml(data, opts = {}) { 225 + const pageSize = opts.pageSize || 'letter'; 226 + const gridLines = opts.gridLines !== false; // default true 227 + const repeatHeaders = opts.repeatHeaders || false; 228 + const scaling = opts.scaling || 'actual-size'; 229 + const title = opts.title || 'Spreadsheet'; 230 + 231 + const dims = getPageDimensions(pageSize); 232 + 233 + const borderStyle = gridLines 234 + ? 'border: 1px solid #ccc;' 235 + : 'border: none;'; 236 + 237 + const tableWidth = scaling === 'fit-to-width' 238 + ? 'width: 100%;' 239 + : scaling === 'fit-to-page' 240 + ? 'max-width: 100%; width: auto;' 241 + : ''; 242 + 243 + // Build header row 244 + const headerTag = repeatHeaders ? 'thead' : 'tbody'; 245 + const headerCells = data.headers.map(h => 246 + `<th style="${borderStyle} padding: 4px 8px; background: #f5f5f5; font-weight: 600; font-size: 0.85rem; text-align: left;">${escapeHtml(String(h))}</th>` 247 + ).join(''); 248 + const headerRow = data.headers.length > 0 249 + ? (repeatHeaders 250 + ? `<thead><tr>${headerCells}</tr></thead>` 251 + : `<tr>${headerCells}</tr>`) 252 + : ''; 253 + 254 + // Build data rows 255 + const bodyRows = data.rows.map(row => { 256 + const cells = row.map(cell => 257 + `<td style="${borderStyle} padding: 4px 8px; font-size: 0.85rem;">${escapeHtml(String(cell))}</td>` 258 + ).join(''); 259 + return `<tr>${cells}</tr>`; 260 + }).join('\n'); 261 + 262 + const bodyContent = repeatHeaders 263 + ? `<tbody>${bodyRows}</tbody>` 264 + : `<tbody>${headerRow}${bodyRows}</tbody>`; 265 + 266 + const tableHtml = repeatHeaders 267 + ? `<table style="border-collapse: collapse; ${tableWidth} ${borderStyle}">${headerRow}${bodyContent}</table>` 268 + : `<table style="border-collapse: collapse; ${tableWidth} ${borderStyle}">${bodyContent}</table>`; 269 + 270 + return `<!DOCTYPE html> 271 + <html lang="en"> 272 + <head> 273 + <meta charset="UTF-8"> 274 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 275 + <title>${escapeHtml(title)} — Print</title> 276 + <style> 277 + @page { 278 + size: ${dims.width} ${dims.height}; 279 + margin: 0.5in; 280 + } 281 + 282 + * { box-sizing: border-box; } 283 + 284 + body { 285 + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; 286 + color: #1a1815; 287 + background: #fff; 288 + margin: 0; 289 + padding: 1rem; 290 + line-height: 1.4; 291 + } 292 + 293 + table { 294 + border-collapse: collapse; 295 + ${tableWidth} 296 + } 297 + 298 + ${repeatHeaders ? ` 299 + thead { display: table-header-group; } 300 + ` : ''} 301 + 302 + @media print { 303 + body { background: none; padding: 0; } 304 + ${repeatHeaders ? 'thead { display: table-header-group; }' : ''} 305 + } 306 + 307 + @media screen { 308 + body { 309 + background: #e8e8e8; 310 + padding: 2rem; 311 + } 312 + } 313 + </style> 314 + </head> 315 + <body> 316 + ${tableHtml} 317 + </body> 318 + </html>`; 319 + } 320 + 321 + /** 322 + * Escape HTML special characters. 323 + * @param {string} str 324 + * @returns {string} 325 + */ 326 + function escapeHtml(str) { 327 + if (!str) return ''; 328 + return str 329 + .replace(/&/g, '&amp;') 330 + .replace(/</g, '&lt;') 331 + .replace(/>/g, '&gt;') 332 + .replace(/"/g, '&quot;'); 333 + }
+384
tests/context-menu.test.js
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect } from 'vitest'; 3 + import { 4 + createContextMenu, 5 + positionMenu, 6 + buildDocsTextItems, 7 + buildDocsLinkItems, 8 + buildDocsImageItems, 9 + buildDocsTableItems, 10 + buildSheetsContextItems, 11 + buildSheetsCellItems, 12 + buildSheetsColumnHeaderItems, 13 + buildSheetsRowHeaderItems, 14 + SEPARATOR, 15 + } from '../src/lib/context-menu.js'; 16 + 17 + // ---- Constants ---- 18 + 19 + describe('SEPARATOR', () => { 20 + it('is a special marker object', () => { 21 + expect(SEPARATOR).toBeDefined(); 22 + expect(SEPARATOR.type).toBe('separator'); 23 + }); 24 + }); 25 + 26 + // ---- createContextMenu ---- 27 + 28 + describe('createContextMenu', () => { 29 + it('returns an object with menu element and control methods', () => { 30 + const menu = createContextMenu([]); 31 + expect(menu).toHaveProperty('el'); 32 + expect(menu).toHaveProperty('show'); 33 + expect(menu).toHaveProperty('hide'); 34 + expect(menu).toHaveProperty('destroy'); 35 + expect(typeof menu.show).toBe('function'); 36 + expect(typeof menu.hide).toBe('function'); 37 + expect(typeof menu.destroy).toBe('function'); 38 + }); 39 + 40 + it('creates a DOM element with context-menu class', () => { 41 + const menu = createContextMenu([]); 42 + expect(menu.el.classList.contains('context-menu')).toBe(true); 43 + }); 44 + 45 + it('renders menu items with label', () => { 46 + const items = [ 47 + { label: 'Cut', action: () => {} }, 48 + { label: 'Copy', action: () => {} }, 49 + ]; 50 + const menu = createContextMenu(items); 51 + const buttons = menu.el.querySelectorAll('.context-menu-item'); 52 + expect(buttons.length).toBe(2); 53 + expect(buttons[0].textContent).toContain('Cut'); 54 + expect(buttons[1].textContent).toContain('Copy'); 55 + }); 56 + 57 + it('renders separator elements', () => { 58 + const items = [ 59 + { label: 'Cut', action: () => {} }, 60 + SEPARATOR, 61 + { label: 'Bold', action: () => {} }, 62 + ]; 63 + const menu = createContextMenu(items); 64 + const separators = menu.el.querySelectorAll('.context-menu-separator'); 65 + expect(separators.length).toBe(1); 66 + }); 67 + 68 + it('renders shortcut hints when provided', () => { 69 + const items = [ 70 + { label: 'Cut', shortcut: '⌘X', action: () => {} }, 71 + ]; 72 + const menu = createContextMenu(items); 73 + const shortcut = menu.el.querySelector('.context-menu-shortcut'); 74 + expect(shortcut).not.toBeNull(); 75 + expect(shortcut.textContent).toBe('⌘X'); 76 + }); 77 + 78 + it('renders icon when provided', () => { 79 + const items = [ 80 + { label: 'Bold', icon: 'B', action: () => {} }, 81 + ]; 82 + const menu = createContextMenu(items); 83 + const icon = menu.el.querySelector('.context-menu-icon'); 84 + expect(icon).not.toBeNull(); 85 + expect(icon.textContent).toBe('B'); 86 + }); 87 + 88 + it('marks disabled items', () => { 89 + const items = [ 90 + { label: 'Paste', action: () => {}, disabled: true }, 91 + ]; 92 + const menu = createContextMenu(items); 93 + const item = menu.el.querySelector('.context-menu-item'); 94 + expect(item.classList.contains('disabled')).toBe(true); 95 + expect(item.getAttribute('aria-disabled')).toBe('true'); 96 + }); 97 + 98 + it('calls action when item is clicked', () => { 99 + let called = false; 100 + const items = [ 101 + { label: 'Cut', action: () => { called = true; } }, 102 + ]; 103 + const menu = createContextMenu(items); 104 + const item = menu.el.querySelector('.context-menu-item'); 105 + item.click(); 106 + expect(called).toBe(true); 107 + }); 108 + 109 + it('does not call action on disabled item click', () => { 110 + let called = false; 111 + const items = [ 112 + { label: 'Paste', action: () => { called = true; }, disabled: true }, 113 + ]; 114 + const menu = createContextMenu(items); 115 + const item = menu.el.querySelector('.context-menu-item'); 116 + item.click(); 117 + expect(called).toBe(false); 118 + }); 119 + 120 + it('show makes menu visible', () => { 121 + const menu = createContextMenu([{ label: 'Test', action: () => {} }]); 122 + document.body.appendChild(menu.el); 123 + menu.show(100, 200); 124 + expect(menu.el.style.display).not.toBe('none'); 125 + expect(menu.el.style.left).toBe('100px'); 126 + expect(menu.el.style.top).toBe('200px'); 127 + menu.destroy(); 128 + }); 129 + 130 + it('hide makes menu invisible', () => { 131 + const menu = createContextMenu([{ label: 'Test', action: () => {} }]); 132 + document.body.appendChild(menu.el); 133 + menu.show(100, 200); 134 + menu.hide(); 135 + expect(menu.el.style.display).toBe('none'); 136 + menu.destroy(); 137 + }); 138 + 139 + it('destroy removes element from DOM', () => { 140 + const menu = createContextMenu([{ label: 'Test', action: () => {} }]); 141 + document.body.appendChild(menu.el); 142 + menu.destroy(); 143 + expect(document.body.contains(menu.el)).toBe(false); 144 + }); 145 + }); 146 + 147 + // ---- positionMenu ---- 148 + 149 + describe('positionMenu', () => { 150 + it('returns position unchanged if menu fits in viewport', () => { 151 + const pos = positionMenu(100, 100, 200, 300, 1024, 768); 152 + expect(pos.x).toBe(100); 153 + expect(pos.y).toBe(100); 154 + }); 155 + 156 + it('adjusts x when menu would overflow right edge', () => { 157 + const pos = positionMenu(900, 100, 200, 300, 1024, 768); 158 + expect(pos.x).toBeLessThanOrEqual(1024 - 200); 159 + }); 160 + 161 + it('adjusts y when menu would overflow bottom edge', () => { 162 + const pos = positionMenu(100, 600, 200, 300, 1024, 768); 163 + expect(pos.y).toBeLessThanOrEqual(768 - 300); 164 + }); 165 + 166 + it('adjusts both when overflowing both edges', () => { 167 + const pos = positionMenu(900, 600, 200, 300, 1024, 768); 168 + expect(pos.x).toBeLessThanOrEqual(1024 - 200); 169 + expect(pos.y).toBeLessThanOrEqual(768 - 300); 170 + }); 171 + 172 + it('clamps to zero if negative', () => { 173 + const pos = positionMenu(-50, -50, 200, 300, 1024, 768); 174 + expect(pos.x).toBeGreaterThanOrEqual(0); 175 + expect(pos.y).toBeGreaterThanOrEqual(0); 176 + }); 177 + }); 178 + 179 + // ---- Docs menu item builders ---- 180 + 181 + describe('buildDocsTextItems', () => { 182 + it('returns array of items for text context', () => { 183 + const items = buildDocsTextItems(); 184 + expect(Array.isArray(items)).toBe(true); 185 + expect(items.length).toBeGreaterThan(0); 186 + }); 187 + 188 + it('includes Cut, Copy, Paste, Select All', () => { 189 + const items = buildDocsTextItems(); 190 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 191 + expect(labels).toContain('Cut'); 192 + expect(labels).toContain('Copy'); 193 + expect(labels).toContain('Paste'); 194 + expect(labels).toContain('Select All'); 195 + }); 196 + 197 + it('includes Bold, Italic, Underline', () => { 198 + const items = buildDocsTextItems(); 199 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 200 + expect(labels).toContain('Bold'); 201 + expect(labels).toContain('Italic'); 202 + expect(labels).toContain('Underline'); 203 + }); 204 + 205 + it('includes Link and Comment', () => { 206 + const items = buildDocsTextItems(); 207 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 208 + expect(labels).toContain('Link'); 209 + expect(labels).toContain('Comment'); 210 + }); 211 + 212 + it('has separators between groups', () => { 213 + const items = buildDocsTextItems(); 214 + const sepCount = items.filter(i => i === SEPARATOR).length; 215 + expect(sepCount).toBeGreaterThanOrEqual(2); 216 + }); 217 + 218 + it('every non-separator item has label and action', () => { 219 + const items = buildDocsTextItems(); 220 + items.filter(i => i !== SEPARATOR).forEach(item => { 221 + expect(item.label).toBeTruthy(); 222 + expect(typeof item.action).toBe('function'); 223 + }); 224 + }); 225 + }); 226 + 227 + describe('buildDocsLinkItems', () => { 228 + it('returns array with link-specific items', () => { 229 + const items = buildDocsLinkItems(); 230 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 231 + expect(labels).toContain('Open Link'); 232 + expect(labels).toContain('Edit Link'); 233 + expect(labels).toContain('Remove Link'); 234 + }); 235 + }); 236 + 237 + describe('buildDocsImageItems', () => { 238 + it('returns array with image property item', () => { 239 + const items = buildDocsImageItems(); 240 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 241 + expect(labels).toContain('Image Properties'); 242 + }); 243 + }); 244 + 245 + describe('buildDocsTableItems', () => { 246 + it('returns array with table manipulation items', () => { 247 + const items = buildDocsTableItems(); 248 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 249 + expect(labels).toContain('Insert Row Above'); 250 + expect(labels).toContain('Insert Row Below'); 251 + expect(labels).toContain('Insert Column Left'); 252 + expect(labels).toContain('Insert Column Right'); 253 + expect(labels).toContain('Delete Row'); 254 + expect(labels).toContain('Delete Column'); 255 + }); 256 + }); 257 + 258 + // ---- Sheets menu item builders ---- 259 + 260 + describe('buildSheetsCellItems', () => { 261 + it('returns array of items for cell context', () => { 262 + const items = buildSheetsCellItems(); 263 + expect(Array.isArray(items)).toBe(true); 264 + expect(items.length).toBeGreaterThan(0); 265 + }); 266 + 267 + it('includes Cut, Copy, Paste, Paste Special', () => { 268 + const items = buildSheetsCellItems(); 269 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 270 + expect(labels).toContain('Cut'); 271 + expect(labels).toContain('Copy'); 272 + expect(labels).toContain('Paste'); 273 + expect(labels).toContain('Paste Special'); 274 + }); 275 + 276 + it('includes row/column insert options', () => { 277 + const items = buildSheetsCellItems(); 278 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 279 + expect(labels).toContain('Insert Row Above'); 280 + expect(labels).toContain('Insert Row Below'); 281 + expect(labels).toContain('Insert Column Left'); 282 + expect(labels).toContain('Insert Column Right'); 283 + }); 284 + 285 + it('includes delete row/column options', () => { 286 + const items = buildSheetsCellItems(); 287 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 288 + expect(labels).toContain('Delete Row'); 289 + expect(labels).toContain('Delete Column'); 290 + }); 291 + 292 + it('includes Cell Format and Add Note', () => { 293 + const items = buildSheetsCellItems(); 294 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 295 + expect(labels).toContain('Cell Format'); 296 + expect(labels).toContain('Add Note'); 297 + }); 298 + 299 + it('has separators between groups', () => { 300 + const items = buildSheetsCellItems(); 301 + const sepCount = items.filter(i => i === SEPARATOR).length; 302 + expect(sepCount).toBeGreaterThanOrEqual(2); 303 + }); 304 + }); 305 + 306 + describe('buildSheetsColumnHeaderItems', () => { 307 + it('returns array with column-specific items', () => { 308 + const items = buildSheetsColumnHeaderItems(); 309 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 310 + expect(labels).toContain('Sort A → Z'); 311 + expect(labels).toContain('Sort Z → A'); 312 + expect(labels).toContain('Insert Column'); 313 + expect(labels).toContain('Delete Column'); 314 + expect(labels).toContain('Resize Column'); 315 + }); 316 + }); 317 + 318 + describe('buildSheetsRowHeaderItems', () => { 319 + it('returns array with row-specific items', () => { 320 + const items = buildSheetsRowHeaderItems(); 321 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 322 + expect(labels).toContain('Insert Row'); 323 + expect(labels).toContain('Delete Row'); 324 + expect(labels).toContain('Resize Row'); 325 + }); 326 + }); 327 + 328 + // ---- buildSheetsContextItems (dispatcher) ---- 329 + 330 + describe('buildSheetsContextItems', () => { 331 + it('returns cell items for "cell" target', () => { 332 + const items = buildSheetsContextItems('cell'); 333 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 334 + expect(labels).toContain('Cut'); 335 + expect(labels).toContain('Cell Format'); 336 + }); 337 + 338 + it('returns column header items for "colHeader" target', () => { 339 + const items = buildSheetsContextItems('colHeader'); 340 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 341 + expect(labels).toContain('Sort A → Z'); 342 + }); 343 + 344 + it('returns row header items for "rowHeader" target', () => { 345 + const items = buildSheetsContextItems('rowHeader'); 346 + const labels = items.filter(i => i !== SEPARATOR).map(i => i.label); 347 + expect(labels).toContain('Insert Row'); 348 + }); 349 + 350 + it('returns empty array for unknown target', () => { 351 + const items = buildSheetsContextItems('unknown'); 352 + expect(items).toEqual([]); 353 + }); 354 + }); 355 + 356 + // ---- Keyboard navigation contract ---- 357 + 358 + describe('context menu keyboard navigation', () => { 359 + it('menu element has role="menu" attribute', () => { 360 + const menu = createContextMenu([ 361 + { label: 'Cut', action: () => {} }, 362 + ]); 363 + expect(menu.el.getAttribute('role')).toBe('menu'); 364 + }); 365 + 366 + it('menu items have role="menuitem" attribute', () => { 367 + const menu = createContextMenu([ 368 + { label: 'Cut', action: () => {} }, 369 + { label: 'Copy', action: () => {} }, 370 + ]); 371 + const items = menu.el.querySelectorAll('.context-menu-item'); 372 + items.forEach(item => { 373 + expect(item.getAttribute('role')).toBe('menuitem'); 374 + }); 375 + }); 376 + 377 + it('menu items have tabindex for keyboard focus', () => { 378 + const menu = createContextMenu([ 379 + { label: 'Cut', action: () => {} }, 380 + ]); 381 + const item = menu.el.querySelector('.context-menu-item'); 382 + expect(item.getAttribute('tabindex')).toBe('0'); 383 + }); 384 + });
+396
tests/print-layout.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + PAGE_SIZES, 4 + MARGIN_PRESETS, 5 + buildDocsPrintHtml, 6 + buildSheetsPrintHtml, 7 + getPageDimensions, 8 + paginateContent, 9 + buildPrintHeader, 10 + buildPrintFooter, 11 + formatPrintDate, 12 + scalingModes, 13 + } from '../src/lib/print-layout.js'; 14 + 15 + // ---- Constants ---- 16 + 17 + describe('PAGE_SIZES', () => { 18 + it('includes Letter size', () => { 19 + expect(PAGE_SIZES.letter).toBeDefined(); 20 + expect(PAGE_SIZES.letter.width).toBe('8.5in'); 21 + expect(PAGE_SIZES.letter.height).toBe('11in'); 22 + }); 23 + 24 + it('includes A4 size', () => { 25 + expect(PAGE_SIZES.a4).toBeDefined(); 26 + expect(PAGE_SIZES.a4.width).toBe('210mm'); 27 + expect(PAGE_SIZES.a4.height).toBe('297mm'); 28 + }); 29 + }); 30 + 31 + describe('MARGIN_PRESETS', () => { 32 + it('includes Normal margins', () => { 33 + expect(MARGIN_PRESETS.normal).toBeDefined(); 34 + expect(MARGIN_PRESETS.normal.top).toBeTruthy(); 35 + expect(MARGIN_PRESETS.normal.right).toBeTruthy(); 36 + expect(MARGIN_PRESETS.normal.bottom).toBeTruthy(); 37 + expect(MARGIN_PRESETS.normal.left).toBeTruthy(); 38 + }); 39 + 40 + it('includes Narrow margins', () => { 41 + expect(MARGIN_PRESETS.narrow).toBeDefined(); 42 + }); 43 + 44 + it('includes Wide margins', () => { 45 + expect(MARGIN_PRESETS.wide).toBeDefined(); 46 + }); 47 + 48 + it('narrow margins are smaller than normal', () => { 49 + const normalTop = parseFloat(MARGIN_PRESETS.normal.top); 50 + const narrowTop = parseFloat(MARGIN_PRESETS.narrow.top); 51 + expect(narrowTop).toBeLessThan(normalTop); 52 + }); 53 + 54 + it('wide margins are larger than normal', () => { 55 + const normalTop = parseFloat(MARGIN_PRESETS.normal.top); 56 + const wideTop = parseFloat(MARGIN_PRESETS.wide.top); 57 + expect(wideTop).toBeGreaterThan(normalTop); 58 + }); 59 + }); 60 + 61 + // ---- scalingModes ---- 62 + 63 + describe('scalingModes', () => { 64 + it('includes fit-to-width', () => { 65 + expect(scalingModes).toContain('fit-to-width'); 66 + }); 67 + 68 + it('includes fit-to-page', () => { 69 + expect(scalingModes).toContain('fit-to-page'); 70 + }); 71 + 72 + it('includes actual-size', () => { 73 + expect(scalingModes).toContain('actual-size'); 74 + }); 75 + }); 76 + 77 + // ---- formatPrintDate ---- 78 + 79 + describe('formatPrintDate', () => { 80 + it('formats a date as a readable string', () => { 81 + const date = new Date(2026, 2, 16); // March 16, 2026 82 + const result = formatPrintDate(date); 83 + expect(typeof result).toBe('string'); 84 + expect(result).toContain('2026'); 85 + // Should contain month in some form 86 + expect(result.length).toBeGreaterThan(4); 87 + }); 88 + 89 + it('uses current date when none provided', () => { 90 + const result = formatPrintDate(); 91 + expect(typeof result).toBe('string'); 92 + expect(result.length).toBeGreaterThan(4); 93 + }); 94 + }); 95 + 96 + // ---- getPageDimensions ---- 97 + 98 + describe('getPageDimensions', () => { 99 + it('returns dimensions for letter size', () => { 100 + const dims = getPageDimensions('letter'); 101 + expect(dims.width).toBe('8.5in'); 102 + expect(dims.height).toBe('11in'); 103 + }); 104 + 105 + it('returns dimensions for a4 size', () => { 106 + const dims = getPageDimensions('a4'); 107 + expect(dims.width).toBe('210mm'); 108 + expect(dims.height).toBe('297mm'); 109 + }); 110 + 111 + it('defaults to letter when unknown size', () => { 112 + const dims = getPageDimensions('unknown'); 113 + expect(dims.width).toBe('8.5in'); 114 + expect(dims.height).toBe('11in'); 115 + }); 116 + }); 117 + 118 + // ---- buildPrintHeader ---- 119 + 120 + describe('buildPrintHeader', () => { 121 + it('includes document title on the left', () => { 122 + const html = buildPrintHeader('My Document', new Date(2026, 2, 16)); 123 + expect(html).toContain('My Document'); 124 + }); 125 + 126 + it('includes formatted date on the right', () => { 127 + const html = buildPrintHeader('Test', new Date(2026, 2, 16)); 128 + expect(html).toContain('2026'); 129 + }); 130 + 131 + it('returns HTML with print-header class', () => { 132 + const html = buildPrintHeader('Title', new Date()); 133 + expect(html).toContain('print-header'); 134 + }); 135 + 136 + it('handles empty title gracefully', () => { 137 + const html = buildPrintHeader('', new Date()); 138 + expect(html).toContain('print-header'); 139 + }); 140 + }); 141 + 142 + // ---- buildPrintFooter ---- 143 + 144 + describe('buildPrintFooter', () => { 145 + it('includes page number', () => { 146 + const html = buildPrintFooter(1, 5); 147 + expect(html).toContain('1'); 148 + }); 149 + 150 + it('includes total pages', () => { 151 + const html = buildPrintFooter(3, 10); 152 + expect(html).toContain('10'); 153 + }); 154 + 155 + it('returns HTML with print-footer class', () => { 156 + const html = buildPrintFooter(1, 1); 157 + expect(html).toContain('print-footer'); 158 + }); 159 + 160 + it('formats as "Page X of Y"', () => { 161 + const html = buildPrintFooter(2, 5); 162 + expect(html).toContain('Page 2 of 5'); 163 + }); 164 + }); 165 + 166 + // ---- paginateContent ---- 167 + 168 + describe('paginateContent', () => { 169 + it('returns at least one page for any content', () => { 170 + const pages = paginateContent('<p>Hello world</p>'); 171 + expect(pages.length).toBeGreaterThanOrEqual(1); 172 + }); 173 + 174 + it('returns single page for short content', () => { 175 + const pages = paginateContent('<p>Short</p>'); 176 + expect(pages.length).toBe(1); 177 + expect(pages[0]).toContain('Short'); 178 + }); 179 + 180 + it('splits content at page break markers', () => { 181 + const content = '<p>Page 1</p><div class="page-break" data-type="page-break"></div><p>Page 2</p>'; 182 + const pages = paginateContent(content); 183 + expect(pages.length).toBe(2); 184 + expect(pages[0]).toContain('Page 1'); 185 + expect(pages[1]).toContain('Page 2'); 186 + }); 187 + 188 + it('handles empty content', () => { 189 + const pages = paginateContent(''); 190 + expect(pages.length).toBe(1); 191 + expect(pages[0]).toBe(''); 192 + }); 193 + 194 + it('handles content with multiple page breaks', () => { 195 + const content = '<p>A</p><div class="page-break" data-type="page-break"></div><p>B</p><div class="page-break" data-type="page-break"></div><p>C</p>'; 196 + const pages = paginateContent(content); 197 + expect(pages.length).toBe(3); 198 + }); 199 + }); 200 + 201 + // ---- buildDocsPrintHtml ---- 202 + 203 + describe('buildDocsPrintHtml', () => { 204 + it('wraps content in a full HTML document', () => { 205 + const html = buildDocsPrintHtml('<p>Hello</p>', { 206 + title: 'Test Doc', 207 + }); 208 + expect(html).toContain('<!DOCTYPE html>'); 209 + expect(html).toContain('<html'); 210 + expect(html).toContain('<body>'); 211 + expect(html).toContain('Hello'); 212 + expect(html).toContain('</body>'); 213 + }); 214 + 215 + it('includes @media print CSS', () => { 216 + const html = buildDocsPrintHtml('<p>Test</p>', { title: 'Test' }); 217 + expect(html).toContain('@media print'); 218 + }); 219 + 220 + it('includes page size in @page rule', () => { 221 + const html = buildDocsPrintHtml('<p>Test</p>', { 222 + title: 'Test', 223 + pageSize: 'letter', 224 + }); 225 + expect(html).toContain('@page'); 226 + expect(html).toContain('8.5in'); 227 + }); 228 + 229 + it('includes margin configuration', () => { 230 + const html = buildDocsPrintHtml('<p>Test</p>', { 231 + title: 'Test', 232 + margins: 'normal', 233 + }); 234 + expect(html).toContain('margin'); 235 + }); 236 + 237 + it('includes header with title and date', () => { 238 + const html = buildDocsPrintHtml('<p>Content</p>', { 239 + title: 'My Report', 240 + }); 241 + expect(html).toContain('My Report'); 242 + expect(html).toContain('print-header'); 243 + }); 244 + 245 + it('includes footer with page number', () => { 246 + const html = buildDocsPrintHtml('<p>Content</p>', { 247 + title: 'Test', 248 + }); 249 + expect(html).toContain('print-footer'); 250 + expect(html).toContain('Page'); 251 + }); 252 + 253 + it('defaults to letter page size', () => { 254 + const html = buildDocsPrintHtml('<p>Test</p>', { title: 'Test' }); 255 + expect(html).toContain('8.5in'); 256 + }); 257 + 258 + it('supports A4 page size', () => { 259 + const html = buildDocsPrintHtml('<p>Test</p>', { 260 + title: 'Test', 261 + pageSize: 'a4', 262 + }); 263 + expect(html).toContain('210mm'); 264 + }); 265 + 266 + it('defaults to normal margins', () => { 267 + const html = buildDocsPrintHtml('<p>Test</p>', { title: 'Test' }); 268 + expect(html).toContain(MARGIN_PRESETS.normal.top); 269 + }); 270 + 271 + it('handles empty content', () => { 272 + const html = buildDocsPrintHtml('', { title: 'Empty' }); 273 + expect(html).toContain('<!DOCTYPE html>'); 274 + expect(html).toContain('<body>'); 275 + }); 276 + 277 + it('respects page break markers for pagination', () => { 278 + const content = '<p>Page 1</p><div class="page-break" data-type="page-break"></div><p>Page 2</p>'; 279 + const html = buildDocsPrintHtml(content, { title: 'Test' }); 280 + expect(html).toContain('Page 1'); 281 + expect(html).toContain('Page 2'); 282 + expect(html).toContain('page-break'); 283 + }); 284 + }); 285 + 286 + // ---- buildSheetsPrintHtml ---- 287 + 288 + describe('buildSheetsPrintHtml', () => { 289 + const sampleData = { 290 + headers: ['A', 'B', 'C'], 291 + rows: [ 292 + ['1', 'Alice', '100'], 293 + ['2', 'Bob', '200'], 294 + ], 295 + }; 296 + 297 + it('wraps content in a full HTML document', () => { 298 + const html = buildSheetsPrintHtml(sampleData, {}); 299 + expect(html).toContain('<!DOCTYPE html>'); 300 + expect(html).toContain('<html'); 301 + expect(html).toContain('<table'); 302 + expect(html).toContain('</table>'); 303 + }); 304 + 305 + it('includes @media print CSS', () => { 306 + const html = buildSheetsPrintHtml(sampleData, {}); 307 + expect(html).toContain('@media print'); 308 + }); 309 + 310 + it('renders headers in table', () => { 311 + const html = buildSheetsPrintHtml(sampleData, {}); 312 + expect(html).toContain('A'); 313 + expect(html).toContain('B'); 314 + expect(html).toContain('C'); 315 + }); 316 + 317 + it('renders row data in table', () => { 318 + const html = buildSheetsPrintHtml(sampleData, {}); 319 + expect(html).toContain('Alice'); 320 + expect(html).toContain('Bob'); 321 + expect(html).toContain('100'); 322 + expect(html).toContain('200'); 323 + }); 324 + 325 + it('includes grid lines by default', () => { 326 + const html = buildSheetsPrintHtml(sampleData, {}); 327 + expect(html).toContain('border'); 328 + }); 329 + 330 + it('can disable grid lines', () => { 331 + const html = buildSheetsPrintHtml(sampleData, { gridLines: false }); 332 + expect(html).toContain('border: none') ; 333 + }); 334 + 335 + it('supports repeat header rows', () => { 336 + const html = buildSheetsPrintHtml(sampleData, { repeatHeaders: true }); 337 + expect(html).toContain('<thead'); 338 + }); 339 + 340 + it('supports fit-to-width scaling', () => { 341 + const html = buildSheetsPrintHtml(sampleData, { scaling: 'fit-to-width' }); 342 + expect(html).toContain('width: 100%'); 343 + }); 344 + 345 + it('supports fit-to-page scaling', () => { 346 + const html = buildSheetsPrintHtml(sampleData, { scaling: 'fit-to-page' }); 347 + // Should have sizing constraints 348 + expect(html).toContain('max-width'); 349 + }); 350 + 351 + it('supports actual-size scaling (no transform)', () => { 352 + const html = buildSheetsPrintHtml(sampleData, { scaling: 'actual-size' }); 353 + // Should not force width 354 + expect(html).not.toContain('width: 100%'); 355 + }); 356 + 357 + it('handles empty data', () => { 358 + const html = buildSheetsPrintHtml({ headers: [], rows: [] }, {}); 359 + expect(html).toContain('<!DOCTYPE html>'); 360 + expect(html).toContain('<table'); 361 + }); 362 + 363 + it('handles single cell', () => { 364 + const html = buildSheetsPrintHtml({ 365 + headers: ['A'], 366 + rows: [['Hello']], 367 + }, {}); 368 + expect(html).toContain('Hello'); 369 + }); 370 + }); 371 + 372 + // ---- CSS contract tests ---- 373 + 374 + describe('print CSS contract', () => { 375 + it('buildDocsPrintHtml includes @page rule with size', () => { 376 + const html = buildDocsPrintHtml('<p>Test</p>', { 377 + title: 'Test', 378 + pageSize: 'letter', 379 + }); 380 + expect(html).toContain('@page'); 381 + }); 382 + 383 + it('buildDocsPrintHtml hides non-content elements in print', () => { 384 + const html = buildDocsPrintHtml('<p>Test</p>', { title: 'Test' }); 385 + // Should set up print-only styles 386 + expect(html).toContain('@media print'); 387 + }); 388 + 389 + it('buildSheetsPrintHtml includes @page rule', () => { 390 + const html = buildSheetsPrintHtml( 391 + { headers: ['A'], rows: [['1']] }, 392 + { pageSize: 'a4' }, 393 + ); 394 + expect(html).toContain('@page'); 395 + }); 396 + });