From 6ea86b1b56aebbae7edeb37b01d7bf5cd145bf60 Mon Sep 17 00:00:00 2001 From: Josh Kingsley Date: Thu, 6 Nov 2025 22:31:12 +0200 Subject: feat(web): change subvisions --- package.json | 2 +- pnpm-lock.yaml | 319 +++++++++++++++++++++++++++++++---- turbo.json | 4 + web/package.json | 13 +- web/src/components/app/index.ts | 47 +++++- web/src/components/grid/index.ts | 11 +- web/src/components/grid/selection.ts | 5 + web/src/components/toolbar/index.css | 2 + web/src/components/toolbar/index.ts | 62 +++++-- web/src/defaultDoc.ts | 4 +- web/src/grid.test.ts | 45 +++++ web/src/grid.ts | 109 ++++++++++++ web/src/math/Ratio.test.ts | 27 +++ web/src/math/Ratio.ts | 28 ++- web/src/math/index.ts | 3 + web/src/types.ts | 50 ++---- 16 files changed, 639 insertions(+), 92 deletions(-) create mode 100644 web/src/grid.test.ts create mode 100644 web/src/grid.ts create mode 100644 web/src/math/Ratio.test.ts create mode 100644 web/src/math/index.ts diff --git a/package.json b/package.json index d5a1cb5..3097c19 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "prettier": "^3.6.2", "prettier-plugin-css-order": "^2.1.2", "prettier-plugin-packagejson": "^2.5.19", - "turbo": "^2.5.8", + "turbo": "^2.6.0", "typescript": "^5.9.3" }, "packageManager": "pnpm@10.18.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1db75b4..c86f65a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,14 +18,17 @@ importers: specifier: ^2.5.19 version: 2.5.19(prettier@3.6.2) turbo: - specifier: ^2.5.8 - version: 2.5.8 + specifier: ^2.6.0 + version: 2.6.0 typescript: specifier: ^5.9.3 version: 5.9.3 web: dependencies: + immer: + specifier: ^10.2.0 + version: 10.2.0 tailwindcss: specifier: ^4.1.16 version: 4.1.16 @@ -39,6 +42,9 @@ importers: vite: specifier: ^7.1.12 version: 7.1.12(jiti@2.6.1)(lightningcss@1.30.2) + vitest: + specifier: ^4.0.6 + version: 4.0.6(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -332,6 +338,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@tailwindcss/node@4.1.16': resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} @@ -422,19 +431,71 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@vitest/expect@4.0.6': + resolution: {integrity: sha512-5j8UUlBVhOjhj4lR2Nt9sEV8b4WtbcYh8vnfhTNA2Kn5+smtevzjNq+xlBuVhnFGXiyPPNzGrOVvmyHWkS5QGg==} + + '@vitest/mocker@4.0.6': + resolution: {integrity: sha512-3COEIew5HqdzBFEYN9+u0dT3i/NCwppLnO1HkjGfAP1Vs3vti1Hxm/MvcbC4DAn3Szo1M7M3otiAaT83jvqIjA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.6': + resolution: {integrity: sha512-4vptgNkLIA1W1Nn5X4x8rLJBzPiJwnPc+awKtfBE5hNMVsoAl/JCCPPzNrbf+L4NKgklsis5Yp2gYa+XAS442g==} + + '@vitest/runner@4.0.6': + resolution: {integrity: sha512-trPk5qpd7Jj+AiLZbV/e+KiiaGXZ8ECsRxtnPnCrJr9OW2mLB72Cb824IXgxVz/mVU3Aj4VebY+tDTPn++j1Og==} + + '@vitest/snapshot@4.0.6': + resolution: {integrity: sha512-PaYLt7n2YzuvxhulDDu6c9EosiRuIE+FI2ECKs6yvHyhoga+2TBWI8dwBjs+IeuQaMtZTfioa9tj3uZb7nev1g==} + + '@vitest/spy@4.0.6': + resolution: {integrity: sha512-g9jTUYPV1LtRPRCQfhbMintW7BTQz1n6WXYQYRQ25qkyffA4bjVXjkROokZnv7t07OqfaFKw1lPzqKGk1hmNuQ==} + + '@vitest/utils@4.0.6': + resolution: {integrity: sha512-bG43VS3iYKrMIZXBo+y8Pti0O7uNju3KvNn6DrQWhQQKcLavMB+0NZfO1/QBAEbq0MaQ3QjNsnnXlGQvsh0Z6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + automation-events@7.1.13: resolution: {integrity: sha512-1Hay5TQPzxsskSqPTH3YXyzE9Iirz82zZDse2vr3+kOR7Sc7om17qIEPsESchlNX0EgKxANwR40i2g/O3GM1Tw==} engines: {node: '>=18.2.0'} + chai@6.2.0: + resolution: {integrity: sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==} + engines: {node: '>=18'} + css-declaration-sorter@7.3.0: resolution: {integrity: sha512-LQF6N/3vkAMYF4xoHLJfG718HRJh34Z8BnNhd6bosOMIVjMlhuZK5++oZa3uYAgrI5+7x2o27gUqTR2U/KjUOQ==} engines: {node: ^14 || ^16 || >=18} peerDependencies: postcss: ^8.0.9 + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + detect-indent@7.0.2: resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} engines: {node: '>=12.20'} @@ -451,11 +512,21 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -476,6 +547,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -557,11 +631,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -614,6 +694,9 @@ packages: engines: {node: '>=10'} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sort-object-keys@1.1.3: resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==} @@ -626,9 +709,15 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardized-audio-context@25.3.77: resolution: {integrity: sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -640,48 +729,58 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tone@15.1.22: resolution: {integrity: sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo-darwin-64@2.5.8: - resolution: {integrity: sha512-Dh5bCACiHO8rUXZLpKw+m3FiHtAp2CkanSyJre+SInEvEr5kIxjGvCK/8MFX8SFRjQuhjtvpIvYYZJB4AGCxNQ==} + turbo-darwin-64@2.6.0: + resolution: {integrity: sha512-6vHnLAubHj8Ib45Knu+oY0ZVCLO7WcibzAvt5b1E72YHqAs4y8meMAGMZM0jLqWPh/9maHDc16/qBCMxtW4pXg==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.5.8: - resolution: {integrity: sha512-f1H/tQC9px7+hmXn6Kx/w8Jd/FneIUnvLlcI/7RGHunxfOkKJKvsoiNzySkoHQ8uq1pJnhJ0xNGTlYM48ZaJOQ==} + turbo-darwin-arm64@2.6.0: + resolution: {integrity: sha512-IU+gWMEXNBw8H0pxvE7nPEa5p6yahxbN8g/Q4Bf0AHymsAFqsScgV0peeNbWybdmY9jk1LPbALOsF2kY1I7ZiQ==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.5.8: - resolution: {integrity: sha512-hMyvc7w7yadBlZBGl/bnR6O+dJTx3XkTeyTTH4zEjERO6ChEs0SrN8jTFj1lueNXKIHh1SnALmy6VctKMGnWfw==} + turbo-linux-64@2.6.0: + resolution: {integrity: sha512-CKoiJ2ZFJLCDsWdRlZg+ew1BkGn8iCEGdePhISVpjsGwkJwSVhVu49z2zKdBeL1IhcSKS2YALwp9ellNZANJxw==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.5.8: - resolution: {integrity: sha512-LQELGa7bAqV2f+3rTMRPnj5G/OHAe2U+0N9BwsZvfMvHSUbsQ3bBMWdSQaYNicok7wOZcHjz2TkESn1hYK6xIQ==} + turbo-linux-arm64@2.6.0: + resolution: {integrity: sha512-WroVCdCvJbrhNxNdw7XB7wHAfPPJPV+IXY+ZKNed+9VdfBu/2mQNfKnvqTuFTH7n+Pdpv8to9qwhXRTJe26upg==} cpu: [arm64] os: [linux] - turbo-windows-64@2.5.8: - resolution: {integrity: sha512-3YdcaW34TrN1AWwqgYL9gUqmZsMT4T7g8Y5Azz+uwwEJW+4sgcJkIi9pYFyU4ZBSjBvkfuPZkGgfStir5BBDJQ==} + turbo-windows-64@2.6.0: + resolution: {integrity: sha512-7pZo5aGQPR+A7RMtWCZHusarJ6y15LQ+o3jOmpMxTic/W6Bad+jSeqo07TWNIseIWjCVzrSv27+0odiYRYtQdA==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.5.8: - resolution: {integrity: sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ==} + turbo-windows-arm64@2.6.0: + resolution: {integrity: sha512-1Ty+NwIksQY7AtFUCPrTpcKQE7zmd/f7aRjdT+qkqGFQjIjFYctEtN7qo4vpQPBgCfS1U3ka83A2u/9CfJQ3wQ==} cpu: [arm64] os: [win32] - turbo@2.5.8: - resolution: {integrity: sha512-5c9Fdsr9qfpT3hA0EyYSFRZj1dVVsb6KIWubA9JBYZ/9ZEAijgUEae0BBR/Xl/wekt4w65/lYLTFaP3JmwSO8w==} + turbo@2.6.0: + resolution: {integrity: sha512-kC5VJqOXo50k0/0jnJDDjibLAXalqT9j7PQ56so0pN+81VR4Fwb2QgIE9dTzT3phqOTQuEXkPh3sCpnv5Isz2g==} hasBin: true typescript@5.9.3: @@ -729,6 +828,45 @@ packages: yaml: optional: true + vitest@4.0.6: + resolution: {integrity: sha512-gR7INfiVRwnEOkCk47faros/9McCZMp5LM+OMNWGLaDBSvJxIzwjgNFufkuePBNaesGRnLmNfW+ddbUJRZn0nQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.6 + '@vitest/browser-preview': 4.0.6 + '@vitest/browser-webdriverio': 4.0.6 + '@vitest/ui': 4.0.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + snapshots: '@babel/runtime@7.28.4': {} @@ -898,6 +1036,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true + '@standard-schema/spec@1.0.0': {} + '@tailwindcss/node@4.1.16': dependencies: '@jridgewell/remapping': 2.3.5 @@ -966,17 +1106,71 @@ snapshots: tailwindcss: 4.1.16 vite: 7.1.12(jiti@2.6.1)(lightningcss@1.30.2) + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} + '@vitest/expect@4.0.6': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.6 + '@vitest/utils': 4.0.6 + chai: 6.2.0 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.6(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 4.0.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(jiti@2.6.1)(lightningcss@1.30.2) + + '@vitest/pretty-format@4.0.6': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.6': + dependencies: + '@vitest/utils': 4.0.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.6': + dependencies: + '@vitest/pretty-format': 4.0.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.6': {} + + '@vitest/utils@4.0.6': + dependencies: + '@vitest/pretty-format': 4.0.6 + tinyrainbow: 3.0.3 + + assertion-error@2.0.1: {} + automation-events@7.1.13: dependencies: '@babel/runtime': 7.28.4 tslib: 2.8.1 + chai@6.2.0: {} + css-declaration-sorter@7.3.0(postcss@8.5.6): dependencies: postcss: 8.5.6 + debug@4.4.3: + dependencies: + ms: 2.1.3 + detect-indent@7.0.2: {} detect-libc@2.1.2: {} @@ -988,6 +1182,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + es-module-lexer@1.7.0: {} + esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -1017,6 +1213,12 @@ snapshots: '@esbuild/win32-ia32': 0.25.11 '@esbuild/win32-x64': 0.25.11 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.2.2: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1028,6 +1230,8 @@ snapshots: graceful-fs@4.2.11: {} + immer@10.2.0: {} + is-plain-obj@4.1.0: {} jiti@2.6.1: {} @@ -1085,8 +1289,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + ms@2.1.3: {} + nanoid@3.3.11: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -1153,6 +1361,8 @@ snapshots: semver@7.7.3: {} + siginfo@2.0.0: {} + sort-object-keys@1.1.3: {} sort-package-json@3.4.0: @@ -1167,12 +1377,16 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + standardized-audio-context@25.3.77: dependencies: '@babel/runtime': 7.28.4 automation-events: 7.1.13 tslib: 2.8.1 + std-env@3.10.0: {} + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -1181,11 +1395,17 @@ snapshots: tapable@2.3.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + tone@15.1.22: dependencies: standardized-audio-context: 25.3.77 @@ -1193,32 +1413,32 @@ snapshots: tslib@2.8.1: {} - turbo-darwin-64@2.5.8: + turbo-darwin-64@2.6.0: optional: true - turbo-darwin-arm64@2.5.8: + turbo-darwin-arm64@2.6.0: optional: true - turbo-linux-64@2.5.8: + turbo-linux-64@2.6.0: optional: true - turbo-linux-arm64@2.5.8: + turbo-linux-arm64@2.6.0: optional: true - turbo-windows-64@2.5.8: + turbo-windows-64@2.6.0: optional: true - turbo-windows-arm64@2.5.8: + turbo-windows-arm64@2.6.0: optional: true - turbo@2.5.8: + turbo@2.6.0: optionalDependencies: - turbo-darwin-64: 2.5.8 - turbo-darwin-arm64: 2.5.8 - turbo-linux-64: 2.5.8 - turbo-linux-arm64: 2.5.8 - turbo-windows-64: 2.5.8 - turbo-windows-arm64: 2.5.8 + turbo-darwin-64: 2.6.0 + turbo-darwin-arm64: 2.6.0 + turbo-linux-64: 2.6.0 + turbo-linux-arm64: 2.6.0 + turbo-windows-64: 2.6.0 + turbo-windows-arm64: 2.6.0 typescript@5.9.3: {} @@ -1234,3 +1454,44 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + + vitest@4.0.6(jiti@2.6.1)(lightningcss@1.30.2): + dependencies: + '@vitest/expect': 4.0.6 + '@vitest/mocker': 4.0.6(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/pretty-format': 4.0.6 + '@vitest/runner': 4.0.6 + '@vitest/snapshot': 4.0.6 + '@vitest/spy': 4.0.6 + '@vitest/utils': 4.0.6 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.1.12(jiti@2.6.1)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/turbo.json b/turbo.json index b2299dd..48b01b8 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,10 @@ "dev": { "cache": false, "persistent": true + }, + "test": { + "cache": false, + "persistent": true } } } diff --git a/web/package.json b/web/package.json index 282a138..e06cb6c 100644 --- a/web/package.json +++ b/web/package.json @@ -2,14 +2,17 @@ "name": "@notive/web", "private": true, "scripts": { - "dev": "vite --clearScreen false" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.1.16", - "vite": "^7.1.12" + "dev": "vite --clearScreen false", + "test": "vitest" }, "dependencies": { + "immer": "^10.2.0", "tailwindcss": "^4.1.16", "tone": "^15.1.22" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.16", + "vite": "^7.1.12", + "vitest": "^4.0.6" } } diff --git a/web/src/components/app/index.ts b/web/src/components/app/index.ts index aa7c738..a2c0c9d 100644 --- a/web/src/components/app/index.ts +++ b/web/src/components/app/index.ts @@ -1,5 +1,10 @@ +import { produce } from "immer"; import defaultDoc from "../../defaultDoc"; import NotiveElement, { customElement } from "../../element"; +import { + changeSelectedSubdivisions, + getSelectedSubdivisionsCount, +} from "../../grid"; import { Doc } from "../../types"; import ntvGrid, { NotiveGridElement } from "../grid"; import renderGrid from "../grid/renderGrid"; @@ -15,9 +20,21 @@ export class NotiveAppElement extends NotiveElement { #selection?: GridSelection; setSelection(gridId: string, selection: GridSelection) { + const grid = this.doc.grids.find((grid) => grid.id === gridId); + if (!grid) throw new Error("Invalid grid ID"); + this.#selectedGridId = gridId; this.#selection = selection; this.#updateGridSelections(); + + this.#toolbar.subdivisions = getSelectedSubdivisionsCount(grid, selection); + } + + clearSelection() { + this.#selectedGridId = undefined; + this.#selection = undefined; + this.#updateGridSelections(); + this.#toolbar.subdivisions = undefined; } #updateGridSelections() { @@ -27,10 +44,36 @@ export class NotiveAppElement extends NotiveElement { }); } + #toolbar = ntvToolbar({ + onsubdivisionschange: ({ subdivisions }) => { + if (!subdivisions) return; + + const gridId = this.#selectedGridId; + const selection = this.#selection; + + if (!gridId || !selection) return; + + const gridIndex = this.doc.grids.findIndex((grid) => grid.id === gridId); + + this.doc = produce(this.doc, (doc) => { + doc.grids[gridIndex] = changeSelectedSubdivisions( + this.doc.grids[gridIndex], + selection, + subdivisions, + ); + }); + + this.querySelector( + `ntv-grid[data-grid-id="${gridId}"]`, + )!.grid = renderGrid(this.doc.grids[gridIndex]); + + this.clearSelection(); + }, + }); + connectedCallback() { this.append( - ntvToolbar(), - + this.#toolbar, ...this.doc.grids.map((grid) => ntvGrid({ grid: renderGrid(grid), diff --git a/web/src/components/grid/index.ts b/web/src/components/grid/index.ts index 78bb14e..3189409 100644 --- a/web/src/components/grid/index.ts +++ b/web/src/components/grid/index.ts @@ -12,7 +12,16 @@ import { extendSelection, GridSelection } from "./selection"; export class NotiveGridElement extends NotiveElement { #internals: ElementInternals = this.attachInternals(); - grid?: RenderedGrid; + #grid?: RenderedGrid; + + get grid(): RenderedGrid | undefined { + return this.#grid; + } + + set grid(grid: RenderedGrid | undefined) { + this.#grid = grid; + this.draw(); + } #selection?: GridSelection; diff --git a/web/src/components/grid/selection.ts b/web/src/components/grid/selection.ts index a24bbf5..517f8ae 100644 --- a/web/src/components/grid/selection.ts +++ b/web/src/components/grid/selection.ts @@ -1,4 +1,5 @@ import { CellRef, cellRefEquals } from "../../types"; +import { RenderedGrid } from "./renderGrid"; export type CellRange = [start: CellRef, end: CellRef]; @@ -21,3 +22,7 @@ export function extendSelection( return { ...selection, range: [selection.activeCellRef, cellRef] }; } + +export function getSelectionRange(selection: GridSelection): CellRange { + return selection.range ?? [selection.activeCellRef, selection.activeCellRef]; +} diff --git a/web/src/components/toolbar/index.css b/web/src/components/toolbar/index.css index e082f7d..653c326 100644 --- a/web/src/components/toolbar/index.css +++ b/web/src/components/toolbar/index.css @@ -18,11 +18,13 @@ padding: 0 0.5rem; height: 1.25rem; color: white; + font-weight: 600; font-size: 0.75rem; } ntv-toolbar button:hover { background: var(--color-green-400); + color: var(--color-neutral-900); } ntv-toolbar button[data-icon] { diff --git a/web/src/components/toolbar/index.ts b/web/src/components/toolbar/index.ts index da4b69d..b8a383d 100644 --- a/web/src/components/toolbar/index.ts +++ b/web/src/components/toolbar/index.ts @@ -1,24 +1,66 @@ -import NotiveElement, { customElement } from "../../element"; -import h, { fragment } from "../../html"; +import NotiveElement, { customElement, eventHandler } from "../../element"; +import h from "../../html"; +import { minus16Icon, plus16Icon } from "../icons"; import "./index.css"; -@customElement("ntv-toolbar") -class NotiveToolbarElement extends NotiveElement { - connectedCallback() { - this.append(this.#view()); +export class SubdivisionsChangeEvent extends Event { + static readonly TYPE = "ntv:toolbar:subdivisionschange"; + + constructor(public subdivisions: number | undefined) { + super(SubdivisionsChangeEvent.TYPE); } +} +@customElement("ntv-toolbar") +class NotiveToolbarElement extends NotiveElement { #subdivisionsInputEl: HTMLInputElement = h.input({ title: "Subdivisions", disabled: true, }); - #view() { - return fragment( + get subdivisions(): number | undefined { + if (this.#subdivisionsInputEl.value === "") return; + return parseInt(this.#subdivisionsInputEl.value); + } + + set subdivisions(n: number | undefined) { + const m = n && Math.max(n, 1); + this.#subdivisionsInputEl.value = m === undefined ? "" : m.toString(); + } + + @eventHandler(SubdivisionsChangeEvent.TYPE) + onsubdivisionschange?: (event: SubdivisionsChangeEvent) => any; + + connectedCallback() { + this.append( h.section( - h.button({ dataset: { icon: "" } }, "-"), + h.button( + { + dataset: { icon: "" }, + onclick: () => { + if (!this.subdivisions) return; + this.subdivisions = this.subdivisions - 1; + this.dispatchEvent( + new SubdivisionsChangeEvent(this.subdivisions), + ); + }, + }, + h.span(minus16Icon()), + ), this.#subdivisionsInputEl, - h.button({ dataset: { icon: "" } }, "+"), + h.button( + { + dataset: { icon: "" }, + onclick: () => { + if (!this.subdivisions) return; + this.subdivisions = this.subdivisions + 1; + this.dispatchEvent( + new SubdivisionsChangeEvent(this.subdivisions), + ); + }, + }, + h.span(plus16Icon()), + ), ), h.section(h.button("Play")), ); diff --git a/web/src/defaultDoc.ts b/web/src/defaultDoc.ts index 7409c1a..0a3fbfb 100644 --- a/web/src/defaultDoc.ts +++ b/web/src/defaultDoc.ts @@ -9,7 +9,7 @@ export default function defaultDoc(): Doc { return { grids: [ { - id: window.crypto.randomUUID(), + id: globalThis.crypto.randomUUID(), baseCellSize: 42, baseCellWidthRatio: new Ratio(1, 16), parts: [ @@ -21,7 +21,7 @@ export default function defaultDoc(): Doc { ], }, { - id: window.crypto.randomUUID(), + id: globalThis.crypto.randomUUID(), baseCellSize: 42, baseCellWidthRatio: new Ratio(1, 16), parts: [ diff --git a/web/src/grid.test.ts b/web/src/grid.test.ts new file mode 100644 index 0000000..50c0626 --- /dev/null +++ b/web/src/grid.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "vitest"; +import defaultDoc from "./defaultDoc"; +import renderGrid from "./components/grid/renderGrid"; +import { changeSelectedSubdivisions } from "./grid"; +import { GridSelection } from "./components/grid/selection"; + +test("foo", () => { + const doc = defaultDoc(); + const grid = doc.grids[1]; + + const selection: GridSelection = { + activeCellRef: { partIndex: 0, rowIndex: 0, cellIndex: 0 }, + range: [ + { partIndex: 0, rowIndex: 0, cellIndex: 0 }, + { partIndex: 0, rowIndex: 0, cellIndex: 3 }, + ], + }; + + const newGrid = changeSelectedSubdivisions(grid, selection, 3); + const renderedGrid = renderGrid(newGrid); + + expect( + renderedGrid.renderedRows.map((row) => row.renderedCells.length), + ).toStrictEqual([15, 16, 16, 16]); + + expect( + newGrid.parts[0].rows[0].cells.map((cell) => cell.widthRatio.toData()), + ).toStrictEqual([ + [1, 12], + [1, 12], + [1, 12], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + [1, 16], + ]); +}); diff --git a/web/src/grid.ts b/web/src/grid.ts new file mode 100644 index 0000000..e849803 --- /dev/null +++ b/web/src/grid.ts @@ -0,0 +1,109 @@ +import { produce } from "immer"; +import renderGrid, { getRenderedCell } from "./components/grid/renderGrid"; +import { getSelectionRange, GridSelection } from "./components/grid/selection"; +import Ratio from "./math/Ratio"; +import { Cell, Grid, renderedRowIndexToRef } from "./types"; + +export function getSelectedSubdivisionsCount( + grid: Grid, + selection: GridSelection, +): number | undefined { + const renderedGrid = renderGrid(grid); + + const [startCellRef, endCellRef] = getSelectionRange(selection); + const startCell = getRenderedCell(renderedGrid, startCellRef); + const endCell = getRenderedCell(renderedGrid, endCellRef); + + if (!startCell || !endCell) throw new Error("Invalid cell refs"); + + const startRenderedRowIndex = Math.min( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const endRenderedRowIndex = Math.max( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio); + const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio); + + return Math.min( + ...renderedGrid.renderedRows + .slice(startRenderedRowIndex, endRenderedRowIndex + 1) + .map((row) => { + const startCellIndex = row.renderedCells.findIndex((cell) => + cell.startRatio.equals(startRatio), + ); + + const endCellIndex = row.renderedCells.findLastIndex((cell) => + cell.endRatio.equals(endRatio), + ); + + return endCellIndex - startCellIndex + 1; + }), + ); +} + +export function changeSelectedSubdivisions( + grid: Grid, + selection: GridSelection, + subdivisions: number, +): Grid { + const renderedGrid = renderGrid(grid); + const [startCellRef, endCellRef] = getSelectionRange(selection); + const startCell = getRenderedCell(renderedGrid, startCellRef); + const endCell = getRenderedCell(renderedGrid, endCellRef); + if (!startCell || !endCell) throw new Error("Invalid cell refs"); + + const startRenderedRowIndex = Math.min( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const endRenderedRowIndex = Math.max( + startCell.renderedRowIndex, + endCell.renderedRowIndex, + ); + + const startRatio = Ratio.min(startCell.startRatio, endCell.startRatio); + const endRatio = Ratio.max(startCell.endRatio, endCell.endRatio); + const selectedWidthRatio = endRatio.subtract(startRatio); + const widthRatio = selectedWidthRatio.divideRatio( + Ratio.fromInteger(subdivisions), + ); + + return produce(grid, (draft) => { + for ( + let renderedRowIndex = startRenderedRowIndex; + renderedRowIndex <= endRenderedRowIndex; + renderedRowIndex++ + ) { + const renderedRow = renderedGrid.renderedRows[renderedRowIndex]; + + const startCellIndex = renderedRow.renderedCells.findIndex((cell) => + cell.startRatio.equals(startRatio), + ); + + const endCellIndex = renderedRow.renderedCells.findLastIndex((cell) => + cell.endRatio.equals(endRatio), + ); + + const { partIndex, rowIndex } = renderedRowIndexToRef( + grid, + renderedRowIndex, + ); + + const row = draft.parts[partIndex].rows[rowIndex]; + const previousCells = row.cells.slice(0, startCellIndex); + const nextCells = row.cells.slice(endCellIndex + 1); + + const newCells: Cell[] = Array.from({ length: subdivisions }, () => ({ + widthRatio, + })); + + row.cells = [...previousCells, ...newCells, ...nextCells]; + } + }); +} diff --git a/web/src/math/Ratio.test.ts b/web/src/math/Ratio.test.ts new file mode 100644 index 0000000..da6fef2 --- /dev/null +++ b/web/src/math/Ratio.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import Ratio from "./Ratio"; + +describe(Ratio, () => { + describe(Ratio.prototype.add, () => { + test("returns fractions in simplest form", () => { + const a = Ratio.fromInteger(0); + const b = new Ratio(1, 4); + + const c = a.add(b); + expect(c.numerator).toBe(1); + expect(c.denominator).toBe(4); + + const d = c.add(b); + expect(d.numerator).toBe(1); + expect(d.denominator).toBe(2); + + const e = d.add(b); + expect(e.numerator).toBe(3); + expect(e.denominator).toBe(4); + + const f = e.add(b); + expect(f.numerator).toBe(1); + expect(f.denominator).toBe(1); + }); + }); +}); diff --git a/web/src/math/Ratio.ts b/web/src/math/Ratio.ts index 0cca966..e2a1fbf 100644 --- a/web/src/math/Ratio.ts +++ b/web/src/math/Ratio.ts @@ -1,3 +1,5 @@ +import { gcd } from "."; + /** Serializable representation of a ratio. */ export type RatioData = [numerator: number, denominator: number]; @@ -25,8 +27,10 @@ export default class Ratio { throw new RangeError("Ratio demnominator cannot be zero"); } - this.#numerator = numerator; - this.#denominator = denominator; + const divisor = gcd(numerator, denominator); + + this.#numerator = numerator / divisor; + this.#denominator = denominator / divisor; } multiplyRatio(other: Ratio): Ratio { @@ -63,10 +67,22 @@ export default class Ratio { return left < right ? -1 : left > right ? 1 : 0; } + equals(other: Ratio): boolean { + return this.compare(other) === 0; + } + toNumber(): number { return this.numerator / this.denominator; } + toString(): string { + return `${this.numerator}/${this.denominator}`; + } + + [Symbol.for("nodejs.util.inspect.custom")](): string { + return `Ratio { ${this.numerator}/${this.denominator} }`; + } + static fromInteger(n: number): Ratio { return new Ratio(n, 1); } @@ -78,4 +94,12 @@ export default class Ratio { static fromData(ratio: RatioData): Ratio { return new Ratio(ratio[0], ratio[1]); } + + static min(...ratios: Ratio[]): Ratio { + return ratios.reduce((a, b) => (a.compare(b) <= 0 ? a : b)); + } + + static max(...ratios: Ratio[]): Ratio { + return ratios.reduce((a, b) => (a.compare(b) >= 0 ? a : b)); + } } diff --git a/web/src/math/index.ts b/web/src/math/index.ts new file mode 100644 index 0000000..70dbb67 --- /dev/null +++ b/web/src/math/index.ts @@ -0,0 +1,3 @@ +export function gcd(a: number, b: number): number { + return b === 0 ? Math.abs(a) : gcd(b, a % b); +} diff --git a/web/src/types.ts b/web/src/types.ts index b41bb9a..dc26c89 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,9 +1,10 @@ +import { Immutable } from "immer"; import Ratio from "./math/Ratio"; -export interface Cell { +export type Cell = Immutable<{ value?: string; widthRatio: Ratio; -} +}>; export interface Row { cells: Cell[]; @@ -44,42 +45,11 @@ export function cellRefEquals(a: CellRef, b: CellRef): boolean { ); } -export function mapRowsInRange( - doc: Doc, - gridId: string, - startRef: CellRef, - endRef: CellRef, - mapFn: (row: Row, ref: RowRef) => Row, -): Doc { - const firstPartIndex = Math.min(startRef.partIndex, endRef.partIndex); - const lastPartIndex = Math.max(startRef.partIndex, endRef.partIndex); - const firstRowIndex = Math.min(startRef.rowIndex, endRef.rowIndex); - const lastRowIndex = Math.max(startRef.rowIndex, endRef.rowIndex); - - return { - ...doc, - grids: doc.grids.map((grid) => { - if (grid.id !== gridId) return grid; - - return { - ...grid, - parts: grid.parts.map((part, partIndex) => { - if (partIndex < firstPartIndex || partIndex > lastPartIndex) { - return part; - } - - return { - ...part, - rows: part.rows.map((row, rowIndex) => { - if (rowIndex < firstRowIndex || rowIndex > lastRowIndex) { - return row; - } - - return mapFn(row, { partIndex, rowIndex }); - }), - }; - }), - }; - }), - }; +export function renderedRowIndexToRef( + grid: Grid, + renderedRowIndex: number, +): RowRef { + const partIndex = renderedRowIndex % grid.parts.length; + const rowIndex = Math.floor(renderedRowIndex / grid.parts.length); + return { partIndex, rowIndex }; } -- cgit v1.2.3