diff --git a/e2e/file-block.spec.ts b/e2e/file-block.spec.ts new file mode 100644 index 0000000..8f2a12c --- /dev/null +++ b/e2e/file-block.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; + +// Basic smoke test to ensure the editor page loads and the File tile exists +// Note: Full OS file picker automation is not performed here; this verifies UI wiring only. + +test.describe('File Block - Smoke', () => { + test('palette opens and shows File item', async ({ page }) => { + await page.goto('http://localhost:4200/tests/nimbus-editor'); + // Open palette via slash keyboard would be flaky; instead click menu button present in header + await page.keyboard.press('/'); + await page.waitForTimeout(300); + const fileItem = page.getByText('File', { exact: true }); + await expect(fileItem).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 2bc3945..ac7e693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,9 @@ "@codemirror/view": "^6.38.6", "@excalidraw/excalidraw": "^0.17.0", "@excalidraw/utils": "^0.1.0", + "@fortawesome/angular-fontawesome": "^3.0.0", + "@fortawesome/free-brands-svg-icons": "^7.1.0", + "@fortawesome/free-solid-svg-icons": "^7.1.0", "@lezer/highlight": "^1.2.2", "@types/markdown-it": "^14.0.1", "angular-calendar": "^0.32.0", @@ -85,7 +88,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@angular-devkit/build-angular": "20.3.2", + "@angular-devkit/build-angular": "^20.3.10", "@playwright/test": "^1.55.1", "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", @@ -346,17 +349,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-20.3.2.tgz", - "integrity": "sha512-DMNyW17Z4a7zyew9YJrNcNnKSbgFBc+EsFSa05dH5oLvcwtGQood35AzhncXpsUqO16NQBfWuUscuf2WrvG1iA==", + "version": "20.3.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-20.3.10.tgz", + "integrity": "sha512-SWGh1ASXEXtzFv/OSlmYGsYlIWHNeZRWkwkBe6mPfxZMX4JZ4HKbxmMtKV9hifvFdITU393IxPH5JXlFZJpZhQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2003.2", - "@angular-devkit/build-webpack": "0.2003.2", - "@angular-devkit/core": "20.3.2", - "@angular/build": "20.3.2", + "@angular-devkit/architect": "0.2003.10", + "@angular-devkit/build-webpack": "0.2003.10", + "@angular-devkit/core": "20.3.10", + "@angular/build": "20.3.10", "@babel/core": "7.28.3", "@babel/generator": "7.28.3", "@babel/helper-annotate-as-pure": "7.27.3", @@ -367,7 +370,7 @@ "@babel/preset-env": "7.28.3", "@babel/runtime": "7.28.3", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "20.3.2", + "@ngtools/webpack": "20.3.10", "ansi-colors": "4.1.3", "autoprefixer": "10.4.21", "babel-loader": "10.0.0", @@ -422,11 +425,11 @@ "@angular/platform-browser": "^20.0.0", "@angular/platform-server": "^20.0.0", "@angular/service-worker": "^20.0.0", - "@angular/ssr": "^20.3.2", + "@angular/ssr": "^20.3.10", "@web/test-runner": "^0.20.0", "browser-sync": "^3.0.2", - "jest": "^29.5.0", - "jest-environment-jsdom": "^29.5.0", + "jest": "^29.5.0 || ^30.2.0", + "jest-environment-jsdom": "^29.5.0 || ^30.2.0", "karma": "^6.3.0", "ng-packagr": "^20.0.0", "protractor": "^7.0.0", @@ -478,14 +481,614 @@ } } }, - "node_modules/@angular-devkit/build-webpack": { - "version": "0.2003.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2003.2.tgz", - "integrity": "sha512-cipoxofI4HdKk9lAqPloPrp/HEV3ME3fIKSmckUsmPcJzy62YdXkkFv6zE4EENUPNP5d8SoSpZ5FPW+wNMV+yg==", + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { + "version": "0.2003.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.10.tgz", + "integrity": "sha512-2SWetxJzS8gRX6OKQstkWx37VRvZVgcEBDLsDSaeTjpnwh81A+niZQjAVRdwL0NEt1Wixk/RxfeUuCmdyyHvhQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2003.2", + "@angular-devkit/core": "20.3.10", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core": { + "version": "20.3.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.10.tgz", + "integrity": "sha512-COOT2eVebDwHhwENk12VR6m0wjL8D7p0dncEHF15zaBt1IXEnVhGESjSrs5klnPnt5T55qCBKyCTaeK7i/cS8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.3", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular/build": { + "version": "20.3.10", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.10.tgz", + "integrity": "sha512-nQrj1nMNZygYDilThc7hPrD6/NIWF/BOSgMfE4VkXQp8d0QronP3HFJ/h77MeoughMRFRhix0pqQSlXJQ2SGTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2003.10", + "@babel/core": "7.28.3", + "@babel/helper-annotate-as-pure": "7.27.3", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "5.1.14", + "@vitejs/plugin-basic-ssl": "2.1.0", + "beasties": "0.3.5", + "browserslist": "^4.23.0", + "esbuild": "0.25.9", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "listr2": "9.0.1", + "magic-string": "0.30.17", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "8.0.0", + "picomatch": "4.0.3", + "piscina": "5.1.3", + "rollup": "4.52.3", + "sass": "1.90.0", + "semver": "7.7.2", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.14", + "vite": "7.1.11", + "watchpack": "2.4.4" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.4.2" + }, + "peerDependencies": { + "@angular/compiler": "^20.0.0", + "@angular/compiler-cli": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/localize": "^20.0.0", + "@angular/platform-browser": "^20.0.0", + "@angular/platform-server": "^20.0.0", + "@angular/service-worker": "^20.0.0", + "@angular/ssr": "^20.3.10", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^20.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=5.8 <6.0", + "vitest": "^3.1.1" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular/build/node_modules/vite": { + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular/build/node_modules/vite/node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular-devkit/build-angular/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/rollup": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/@angular-devkit/build-webpack": { + "version": "0.2003.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2003.10.tgz", + "integrity": "sha512-/e76O5MnoAplV+LW6XAWyd8e1KR1HqRTCSTngLMO+VMADbcQkD4i01ouridlxVLKkGDg83hvASUz2M6x0duZ9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2003.10", "rxjs": "7.8.2" }, "engines": { @@ -498,6 +1101,50 @@ "webpack-dev-server": "^5.0.2" } }, + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/architect": { + "version": "0.2003.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.10.tgz", + "integrity": "sha512-2SWetxJzS8gRX6OKQstkWx37VRvZVgcEBDLsDSaeTjpnwh81A+niZQjAVRdwL0NEt1Wixk/RxfeUuCmdyyHvhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "20.3.10", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/core": { + "version": "20.3.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.10.tgz", + "integrity": "sha512-COOT2eVebDwHhwENk12VR6m0wjL8D7p0dncEHF15zaBt1IXEnVhGESjSrs5klnPnt5T55qCBKyCTaeK7i/cS8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.3", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular-devkit/core": { "version": "20.3.2", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.2.tgz", @@ -3447,6 +4094,64 @@ "integrity": "sha512-hypi3np+Do3e8Eb0Y1ug52EyJP4JAP3RPQRfAgiMN0ftag7M49vahiWVXd9yX4wsviCKYacTbBDs2mRqt3nyUQ==", "license": "MIT" }, + "node_modules/@fortawesome/angular-fontawesome": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-3.0.0.tgz", + "integrity": "sha512-+8Dd6DoJnqArfrZ5NvjHyRL64IIkTigXclbOOcFdYQ8/WFERQUDaEU6SAV8Q0JBpJhMS1McED7YCOCAE6SIVyA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.0.0", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/core": "^20.0.0" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz", + "integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz", + "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz", + "integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz", + "integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -4078,9 +4783,9 @@ } }, "node_modules/@jsonjoy.com/buffers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz", - "integrity": "sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4112,19 +4817,20 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.14.0.tgz", - "integrity": "sha512-LpWbYgVnKzphN5S6uss4M25jJ/9+m6q6UJoeN6zTkK4xAGhKsiBRPVeF7OYMWonn5repMQbE5vieRXcMUrKDKw==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/base64": "^1.1.2", - "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/buffers": "^1.2.0", "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/json-pointer": "^1.0.1", + "@jsonjoy.com/json-pointer": "^1.0.2", "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0" + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" }, "engines": { "node": ">=10.0" @@ -4364,6 +5070,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4377,6 +5084,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4390,6 +5098,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4403,6 +5112,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4416,6 +5126,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4429,6 +5140,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4442,6 +5154,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4522,6 +5235,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4535,6 +5249,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4548,6 +5263,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4561,6 +5277,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4574,6 +5291,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4587,6 +5305,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5096,9 +5815,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.2.tgz", - "integrity": "sha512-i5sbPfhQI6suMF+02KV3PzLBITlXZhiEKPjnQHUK/kCRc+tV+WQidxaO/UTkgSzqhzWGVHkmtE1Sau08K5Wi+A==", + "version": "20.3.10", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.10.tgz", + "integrity": "sha512-W/+CGQFhmYEMJ/YgkC5p9khkxu2ocrvM0Pe0GxcUldrpBpdm1GCphEH1kTo7MeCupUK4/6rXGUt+GoA6PYchOg==", "dev": true, "license": "MIT", "engines": { @@ -7022,22 +7741,22 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", - "@types/serve-static": "*" + "@types/serve-static": "^1" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", "dev": true, "license": "MIT", "dependencies": { @@ -7234,13 +7953,12 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -7255,15 +7973,26 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", "@types/node": "*", - "@types/send": "*" + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" } }, "node_modules/@types/sockjs": { @@ -8387,10 +9116,10 @@ } }, "node_modules/cacache/node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", - "license": "ISC", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -9897,9 +10626,9 @@ } }, "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.3.0.tgz", + "integrity": "sha512-Qq68+VkJlc8tjnPV1i7HtbIn7ohmjZa88qUvHMIK0ZKUXMCuV45cT7cEXALPUmeXCe0q1DWQkQTemHVaLIFSrg==", "dev": true, "license": "MIT", "dependencies": { @@ -13217,9 +13946,9 @@ } }, "node_modules/launch-editor": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz", - "integrity": "sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", + "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -13878,9 +14607,9 @@ "license": "MIT" }, "node_modules/memfs": { - "version": "4.48.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.48.1.tgz", - "integrity": "sha512-vWO+1ROkhOALF1UnT9aNOOflq5oFDlqwTXaPg6duo07fBLxSH0+bcF0TY1lbA1zTNKyGgDxgaDdKx5MaewLX5A==", + "version": "4.51.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.51.0.tgz", + "integrity": "sha512-4zngfkVM/GpIhC8YazOsM6E8hoB33NP0BCESPOA6z7qaL6umPJNqkO8CNYaLV2FB2MV6H1O3x2luHHOSqppv+A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -14310,6 +15039,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -14514,10 +15244,10 @@ } }, "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", - "license": "ISC", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -18644,9 +19374,9 @@ } }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/package.json b/package.json index e6cd69c..6164d36 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,9 @@ "@codemirror/view": "^6.38.6", "@excalidraw/excalidraw": "^0.17.0", "@excalidraw/utils": "^0.1.0", + "@fortawesome/angular-fontawesome": "^3.0.0", + "@fortawesome/free-brands-svg-icons": "^7.1.0", + "@fortawesome/free-solid-svg-icons": "^7.1.0", "@lezer/highlight": "^1.2.2", "@types/markdown-it": "^14.0.1", "angular-calendar": "^0.32.0", @@ -103,7 +106,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@angular-devkit/build-angular": "20.3.2", + "@angular-devkit/build-angular": "^20.3.10", "@playwright/test": "^1.55.1", "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", diff --git a/src/app/blocks/file/directives/drag-drop-files.directive.ts b/src/app/blocks/file/directives/drag-drop-files.directive.ts new file mode 100644 index 0000000..072d3ec --- /dev/null +++ b/src/app/blocks/file/directives/drag-drop-files.directive.ts @@ -0,0 +1,159 @@ +import { Directive, ElementRef, EventEmitter, Inject, Input, NgZone, OnDestroy, OnInit, Output } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { BlockInsertionService } from '../services/block-insertion.service'; +import { DropTarget } from '../models/file-models'; +import { DragDropService } from '../../../editor/services/drag-drop.service'; + +@Directive({ + selector: '[appDragDropFiles]', + standalone: true +}) +export class DragDropFilesDirective implements OnInit, OnDestroy { + @Input('appDragDropFiles') context: 'root' | { type: 'columns'; columnsBlockId: string } = 'root'; + @Output() filesDropped = new EventEmitter(); + + private enterCounter = 0; + + constructor( + private el: ElementRef, + private zone: NgZone, + private inserter: BlockInsertionService, + private dragDrop: DragDropService, + @Inject(DOCUMENT) private document: Document + ) {} + + ngOnInit(): void { + this.zone.runOutsideAngular(() => { + const node = this.el.nativeElement; + node.addEventListener('dragover', this.onDragOver, { passive: false }); + node.addEventListener('dragleave', this.onDragLeave); + node.addEventListener('drop', this.onDrop, { passive: false }); + node.addEventListener('dragenter', this.onDragEnter); + }); + } + + ngOnDestroy(): void { + const node = this.el.nativeElement; + node.removeEventListener('dragover', this.onDragOver); + node.removeEventListener('dragleave', this.onDragLeave); + node.removeEventListener('drop', this.onDrop); + node.removeEventListener('dragenter', this.onDragEnter); + } + + private onDragEnter = (ev: DragEvent) => { + if (!ev.dataTransfer) return; + const hasFiles = Array.from(ev.dataTransfer.items || []).some(i => i.kind === 'file'); + if (!hasFiles) return; + this.enterCounter++; + try { (this.dragDrop as any).dragging.set(true); } catch {} + }; + + private onDragOver = (ev: DragEvent) => { + if (!ev.dataTransfer) return; + const hasFiles = Array.from(ev.dataTransfer.items || []).some(i => i.kind === 'file'); + if (!hasFiles) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + + const target = this.computeTarget(ev); + if (target) { + // Use the shared indicator for visuals + const ind = this.computeIndicator(ev, target); + this.dragDrop.setIndicator(ind); + } + }; + + private onDragLeave = (_ev: DragEvent) => { + this.enterCounter = Math.max(0, this.enterCounter - 1); + if (this.enterCounter === 0) { + this.dragDrop.setIndicator(null); + try { (this.dragDrop as any).dragging.set(false); } catch {} + } + }; + + private onDrop = async (ev: DragEvent) => { + try { + if (!ev.dataTransfer) return; + const files = Array.from(ev.dataTransfer.files || []); + if (!files.length) return; + ev.preventDefault(); + + const target = this.computeTarget(ev); + this.dragDrop.setIndicator(null); + this.enterCounter = 0; + if (!target) return; + + await this.inserter.createFromFiles(files, target); + this.zone.run(() => this.filesDropped.emit()); + try { (this.dragDrop as any).dragging.set(false); } catch {} + } catch {} + }; + + private computeTarget(ev: DragEvent): DropTarget | number | null { + const clientY = ev.clientY; + const clientX = ev.clientX; + const container = this.el.nativeElement; + const containerRect = container.getBoundingClientRect(); + + // Column context + if (this.context !== 'root' && this.context.type === 'columns') { + const el = this.document.elementFromPoint(clientX, clientY) as HTMLElement | null; + const colEl = el?.closest('[data-column-index]') as HTMLElement | null; + if (!colEl) return null; + const columnIndex = parseInt(colEl.getAttribute('data-column-index') || '0'); + const blocks = Array.from(colEl.querySelectorAll('[data-block-id]')); + + let index = blocks.length; + for (let i = 0; i < blocks.length; i++) { + const r = blocks[i].getBoundingClientRect(); + const zone = r.top + r.height / 2; + if (clientY <= zone) { index = i; break; } + } + + return { type: 'column', ownerColumnsId: this.context.columnsBlockId, columnId: colEl.getAttribute('data-column-id') || '', columnIndex, index }; + } + + // Root context: compute index among .block-wrapper siblings + const nodes = Array.from(container.querySelectorAll('.block-wrapper')); + if (!nodes.length) return 0; + + let index = nodes.length; + for (let i = 0; i < nodes.length; i++) { + const r = nodes[i].getBoundingClientRect(); + const zone = r.top + r.height / 2; + if (clientY <= zone) { index = i; break; } + } + return index; + } + + private computeIndicator(ev: DragEvent, target: DropTarget | number) { + const container = this.el.nativeElement; + const rect = container.getBoundingClientRect(); + + if (typeof target === 'number') { + const nodes = Array.from(container.querySelectorAll('.block-wrapper')); + const clampIndex = Math.max(0, Math.min(target, nodes.length)); + let top = rect.top; + if (nodes.length > 0) { + if (clampIndex === 0) top = nodes[0].getBoundingClientRect().top; + else if (clampIndex >= nodes.length) top = nodes[nodes.length - 1].getBoundingClientRect().bottom; + else top = nodes[clampIndex].getBoundingClientRect().top; + } + return { top: top - rect.top, left: 0, width: rect.width, mode: 'horizontal' as const }; + } + + // Column indicator: vertical line at column boundary is complex; draw horizontal between items inside column + const el = this.document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null; + const colEl = el?.closest('[data-column-index]') as HTMLElement | null; + const list = Array.from(colEl?.querySelectorAll('[data-block-id]') || []); + let top = rect.top; + if (list.length > 0) { + const idx = Math.max(0, Math.min(target.index, list.length)); + if (idx === 0) top = list[0].getBoundingClientRect().top; + else if (idx >= list.length) top = list[list.length - 1].getBoundingClientRect().bottom; + else top = list[idx].getBoundingClientRect().top; + } + const colRect = colEl?.getBoundingClientRect() || rect; + return { top: top - rect.top, left: (colRect.left - rect.left), width: colRect.width, mode: 'horizontal' as const }; + } +} diff --git a/src/app/blocks/file/file-preview.component.ts b/src/app/blocks/file/file-preview.component.ts new file mode 100644 index 0000000..0c11d4d --- /dev/null +++ b/src/app/blocks/file/file-preview.component.ts @@ -0,0 +1,84 @@ +import { Component, Input, ViewChild, ElementRef, AfterViewInit, OnDestroy, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FileMeta } from './models/file-models'; +import { PreviewImageComponent } from './viewers/preview-image.component'; +import { PreviewVideoComponent } from './viewers/preview-video.component'; +import { PreviewTextComponent } from './viewers/preview-text.component'; +import { PreviewCodeComponent } from './viewers/preview-code.component'; +import { PreviewDocxComponent } from './viewers/preview-docx.component'; +import { PdfViewerComponent } from '../../features/note-view/components/pdf-viewer/pdf-viewer.component'; + + +@Component({ + selector: 'app-file-preview', + standalone: true, + imports: [CommonModule, PreviewImageComponent, PreviewVideoComponent, PreviewTextComponent, PreviewCodeComponent, PreviewDocxComponent, PdfViewerComponent], + template: ` +
+ @if (shouldRender()) { + @switch (kind()) { + @case ('image') { } + @case ('video') { } + @case ('pdf') { } + @case ('text') { } + @case ('code') { } + @case ('docx') { } + @default { +
+ Preview non disponible pour ce type. Télécharger +
+ } + } + } +
+ ` +}) +export class FilePreviewComponent implements AfterViewInit, OnDestroy { + @Input({ required: true }) meta!: FileMeta; + @Input() set expanded(v: boolean) { this.expandedSig.set(!!v); } + + @ViewChild('container', { static: true }) containerRef!: ElementRef; + + width = signal(0); + private observer?: ResizeObserver; + // Intersection observer not required since we render based on expanded + private expandedSig = signal(false); + readonly shouldRender = computed(() => this.expandedSig()); + kind = signal<'image'|'video'|'pdf'|'text'|'code'|'docx'|'other'>('other'); + + ngAfterViewInit(): void { + try { + this.observer = new ResizeObserver((entries) => { + const r = entries[0].contentRect; + this.width.set(Math.floor(r.width)); + }); + this.observer.observe(this.containerRef.nativeElement); + } catch {} + + // No intersection observer: render is driven solely by expanded state + + // derive kind once on init (works for legacy blocks lacking meta.kind) + try { + const k = (this.meta as any)?.kind as string | undefined; + if (k) { + this.kind.set(k as any); + } else { + const ext = (this.meta as any)?.ext?.toLowerCase?.() || ''; + const map = (e: string): any => { + if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(e)) return 'image'; + if (['mp4','webm','mov','m4v','ogg'].includes(e)) return 'video'; + if (e === 'pdf') return 'pdf'; + if (['txt','md','log','csv'].includes(e)) return 'text'; + if (['js','ts','json','css','scss','html','xml','yml','yaml','py','java','cs','cpp','c','go','rs','rb','php','sh','bat','ps1'].includes(e)) return 'code'; + if (e === 'docx') return 'docx'; + return 'other'; + }; + this.kind.set(map(ext)); + } + } catch {} + } + + ngOnDestroy(): void { + try { this.observer?.disconnect(); } catch {} + } +} diff --git a/src/app/blocks/file/models/file-models.ts b/src/app/blocks/file/models/file-models.ts new file mode 100644 index 0000000..d6ab5de --- /dev/null +++ b/src/app/blocks/file/models/file-models.ts @@ -0,0 +1,39 @@ +export type FileKind = 'image' | 'video' | 'pdf' | 'text' | 'code' | 'docx' | 'other'; + +export interface FileMeta { + id: string; // uuid + name: string; + size: number; + mime: string; + ext: string; + kind: FileKind; + createdAt: number; + url: string; // blob URL for local preview or remote URL + hash?: string; +} + +export interface FileBlockUIState { + expanded: boolean; + layout: 'list' | 'grid'; + widthPx?: number; +} + +export interface FileBlockProps { + meta: FileMeta; + ui: FileBlockUIState; +} + +export interface DropTargetRoot { + type: 'root'; + index: number; // position in main block list +} + +export interface DropTargetColumn { + type: 'column'; + ownerColumnsId: string; // block id of the Columns block + columnId: string; // column id within ColumnsProps + columnIndex: number; // runtime index for convenience + index: number; // position inside the column blocks +} + +export type DropTarget = DropTargetRoot | DropTargetColumn; diff --git a/src/app/blocks/file/pipes/file-icon.pipe.ts b/src/app/blocks/file/pipes/file-icon.pipe.ts new file mode 100644 index 0000000..1eb4151 --- /dev/null +++ b/src/app/blocks/file/pipes/file-icon.pipe.ts @@ -0,0 +1,144 @@ +// file-icon.component.ts +import { Component, Input } from '@angular/core'; +import { FontAwesomeModule, IconDefinition } from '@fortawesome/angular-fontawesome'; +import { + faFilePdf, faFileWord, faFileExcel, faFilePowerpoint, + faFileImage, faFileVideo, faFileAudio, faFileArchive, + faFileCode, faFileLines, faFolder, faPaperclip, + faTerminal, faGem, faDatabase +} from '@fortawesome/free-solid-svg-icons'; +import { + faJs, faHtml5, faCss3Alt, faPython, faJava, + faDocker, faReact, faVuejs, faSass, faMarkdown, faPhp, + faWindows +} from '@fortawesome/free-brands-svg-icons'; + +@Component({ + selector: 'app-file-icon', + standalone: true, + imports: [FontAwesomeModule], + template: ``, + styles: [`:host { display: inline-flex; align-items: center; }`] +}) +export class FileIconComponent { + @Input() set ext(value: string | undefined) { + this.updateIcon(value || '', ''); + } + @Input() set kind(value: string | undefined) { + this.updateIcon('', value || ''); + } + + icon: IconDefinition = faPaperclip; + color: string = '#6c757d'; + + private updateIcon(ext: string, kind: string) { + const e = ext.toLowerCase(); + const iconMap: Record = { + // Extensions populaires + pdf: { icon: faFilePdf, color: '#dc3545' }, + doc: { icon: faFileWord, color: '#0d6efd' }, + docx: { icon: faFileWord, color: '#0d6efd' }, + xls: { icon: faFileExcel, color: '#198754' }, + xlsx: { icon: faFileExcel, color: '#198754' }, + ppt: { icon: faFilePowerpoint, color: '#fd7e14' }, + pptx: { icon: faFilePowerpoint, color: '#fd7e14' }, + txt: { icon: faFileLines, color: '#6c757d' }, + md: { icon: faMarkdown, color: '#000000' }, + csv: { icon: faFileExcel, color: '#198754' }, + + // Images + jpg: { icon: faFileImage, color: '#20c997' }, + jpeg: { icon: faFileImage, color: '#20c997' }, + png: { icon: faFileImage, color: '#20c997' }, + gif: { icon: faFileImage, color: '#20c997' }, + svg: { icon: faFileImage, color: '#20c997' }, + webp: { icon: faFileImage, color: '#20c997' }, + + // Vidéos + mp4: { icon: faFileVideo, color: '#6f42c1' }, + avi: { icon: faFileVideo, color: '#6f42c1' }, + mov: { icon: faFileVideo, color: '#6f42c1' }, + + // Code + js: { icon: faJs, color: '#f7df1e' }, + mjs: { icon: faJs, color: '#f7df1e' }, + ts: { icon: faJs, color: '#3178c6' }, + html: { icon: faHtml5, color: '#e34c26' }, + htm: { icon: faHtml5, color: '#e34c26' }, + css: { icon: faCss3Alt, color: '#1572b6' }, + scss: { icon: faSass, color: '#cc6699' }, + sass: { icon: faSass, color: '#cc6699' }, + less: { icon: faCss3Alt, color: '#1d365d' }, + php: { icon: faPhp, color: '#777bb4' }, + py: { icon: faPython, color: '#3776ab' }, + java: { icon: faJava, color: '#ed8b00' }, + class: { icon: faJava, color: '#ed8b00' }, + jar: { icon: faJava, color: '#ed8b00' }, + c: { icon: faFileCode, color: '#a8b9cc' }, + cpp: { icon: faFileCode, color: '#00599c' }, + cs: { icon: faFileCode, color: '#239120' }, + h: { icon: faFileCode, color: '#a8b9cc' }, + go: { icon: faFileCode, color: '#00add8' }, + rs: { icon: faFileCode, color: '#dea584' }, + rb: { icon: faGem, color: '#cc342d' }, + swift: { icon: faFileCode, color: '#fa7343' }, + kt: { icon: faFileCode, color: '#7f52ff' }, + vue: { icon: faVuejs, color: '#4fc08d' }, + jsx: { icon: faReact, color: '#61dafb' }, + tsx: { icon: faReact, color: '#61dafb' }, + sql: { icon: faDatabase, color: '#336791' }, + json: { icon: faFileCode, color: '#000000' }, + xml: { icon: faFileCode, color: '#000000' }, + yml: { icon: faFileCode, color: '#ff0000' }, + yaml: { icon: faFileCode, color: '#ff0000' }, + + // Scripts + sh: { icon: faTerminal, color: '#000000' }, + bash: { icon: faTerminal, color: '#000000' }, + zsh: { icon: faTerminal, color: '#000000' }, + ps1: { icon: faTerminal, color: '#000000' }, + bat: { icon: faWindows, color: '#0078d4' }, + + // Archives + zip: { icon: faFileArchive, color: '#ffc107' }, + rar: { icon: faFileArchive, color: '#ffc107' }, + '7z': { icon: faFileArchive, color: '#ffc107' }, + tar: { icon: faFileArchive, color: '#ffc107' }, + gz: { icon: faFileArchive, color: '#ffc107' }, + + // Config + ini: { icon: faFileLines, color: '#6c757d' }, + cfg: { icon: faFileLines, color: '#6c757d' }, + conf: { icon: faFileLines, color: '#6c757d' }, + env: { icon: faFileLines, color: '#6c757d' }, + lock: { icon: faFileLines, color: '#6c757d' }, + + // Docker + dockerfile: { icon: faDocker, color: '#2496ed' }, + }; + + // Priorité à l'extension + if (iconMap[e]) { + this.icon = iconMap[e].icon; + this.color = iconMap[e].color; + return; + } + + // Fallback sur le type + const kindMap: Record = { + image: { icon: faFileImage, color: '#20c997' }, + video: { icon: faFileVideo, color: '#6f42c1' }, + audio: { icon: faFileAudio, color: '#6f42c1' }, + pdf: { icon: faFilePdf, color: '#dc3545' }, + text: { icon: faFileLines, color: '#6c757d' }, + code: { icon: faFileCode, color: '#6c757d' }, + doc: { icon: faFileWord, color: '#0d6efd' }, + archive: { icon: faFileArchive, color: '#ffc107' }, + folder: { icon: faFolder, color: '#ffc107' }, + }; + + const kindIcon = kindMap[kind] || { icon: faPaperclip, color: '#6c757d' }; + this.icon = kindIcon.icon; + this.color = kindIcon.color; + } +} \ No newline at end of file diff --git a/src/app/blocks/file/pipes/file-size.pipe.ts b/src/app/blocks/file/pipes/file-size.pipe.ts new file mode 100644 index 0000000..394f448 --- /dev/null +++ b/src/app/blocks/file/pipes/file-size.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'fileSize', standalone: true }) +export class FileSizePipe implements PipeTransform { + transform(bytes?: number | null): string { + if (bytes == null || isNaN(bytes as any)) return ''; + const b = Number(bytes); + if (b < 1024) return `${b} B`; + const kb = b / 1024; + if (kb < 1024) return `${kb.toFixed(kb < 10 ? 1 : 0)} kB`; + const mb = kb / 1024; + if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`; + const gb = mb / 1024; + return `${gb.toFixed(gb < 10 ? 1 : 0)} GB`; + } +} diff --git a/src/app/blocks/file/services/block-insertion.service.ts b/src/app/blocks/file/services/block-insertion.service.ts new file mode 100644 index 0000000..e7ddbe0 --- /dev/null +++ b/src/app/blocks/file/services/block-insertion.service.ts @@ -0,0 +1,128 @@ +import { Injectable } from '@angular/core'; +import { DocumentService } from '../../../editor/services/document.service'; +import { Block } from '../../../editor/core/models/block.model'; +import { FileKind, FileMeta, DropTarget, DropTargetColumn } from '../models/file-models'; +import { FileMimeService } from './file-mime.service'; + +function uuid(): string { + return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); +} + +@Injectable({ providedIn: 'root' }) +export class BlockInsertionService { + private MAX_INLINE_PREVIEW_SIZE = 25 * 1024 * 1024; // 25MB default + + constructor(private docs: DocumentService, private mime: FileMimeService) {} + + async createFromFiles(files: File[], target: DropTarget | number): Promise { + // Preserve order as received + const metas: FileMeta[] = await Promise.all(files.map(async (f) => { + const meta: FileMeta = { + id: uuid(), + name: f.name, + size: f.size, + mime: f.type || this.guessMimeFromExt(f.name), + ext: this.mime.getExt(f.name), + kind: this.mime.kindFromFile(f), + createdAt: Date.now(), + url: URL.createObjectURL(f) + }; + return meta; + })); + + const createdIds: string[] = []; + + if (typeof target === 'number') { + // Root list absolute index + let insertAt = Math.max(0, Math.min(target, this.docs.blocks().length)); + for (const meta of metas) { + const block = this.buildFileBlock(meta); + // Insert using afterBlockId API + const afterId = insertAt === 0 ? null : this.docs.blocks()[insertAt - 1]?.id ?? null; + this.docs.insertBlock(afterId, block); + createdIds.push(block.id); + insertAt++; + } + return createdIds; + } + + if (target.type === 'root') { + return this.createFromFiles(files, target.index); + } + + // Column target + await this.insertIntoColumn(metas, target); + return createdIds; + } + + private buildFileBlock(meta: FileMeta): Block { + const ui = { expanded: false, layout: 'list' as const }; + const props = { meta, ui }; + return this.docs.createBlock('file', props); + } + + private async insertIntoColumn(metas: FileMeta[], t: DropTargetColumn): Promise { + const doc = this.docs.doc(); + const ownerIndex = doc.blocks.findIndex(b => b.id === t.ownerColumnsId && b.type === 'columns'); + if (ownerIndex < 0) return; + const owner = doc.blocks[ownerIndex]; + const props = JSON.parse(JSON.stringify(owner.props)); + const col = props.columns?.[t.columnIndex]; + if (!col) return; + + let insertAt = Math.max(0, Math.min(t.index, col.blocks.length)); + for (const meta of metas) { + const block = this.buildFileBlock(meta); + col.blocks.splice(insertAt, 0, block); + insertAt++; + } + + this.docs.updateBlockProps(owner.id, props); + } + + togglePreview(blockId: string, singleInColumn?: { columnsId: string; columnIndex: number } | null): void { + const block = this.docs.getBlock(blockId); + if (!block) return; + const current = block.props?.ui?.expanded === true; + + if (singleInColumn && singleInColumn.columnsId) { + // Collapse other file blocks in the same column + const doc = this.docs.doc(); + const columnsBlock = doc.blocks.find(b => b.id === singleInColumn.columnsId && b.type === 'columns'); + if (columnsBlock) { + const props = JSON.parse(JSON.stringify(columnsBlock.props)); + const col = props.columns?.[singleInColumn.columnIndex]; + if (col) { + col.blocks = col.blocks.map((b: Block) => { + if (b.id === blockId) return b; + if (b.type === 'file' && b.props?.ui) { + return { ...b, props: { ...b.props, ui: { ...b.props.ui, expanded: false } } }; + } + return b; + }); + this.docs.updateBlockProps(columnsBlock.id, props); + } + } + } + + // Toggle current block + this.docs.updateBlockProps(blockId, { ui: { ...(block as any).props?.ui, expanded: !current } }); + } + + private guessMimeFromExt(name: string): string { + const ext = this.mime.getExt(name); + switch (ext) { + case 'pdf': return 'application/pdf'; + case 'png': return 'image/png'; + case 'jpg': + case 'jpeg': return 'image/jpeg'; + case 'gif': return 'image/gif'; + case 'webp': return 'image/webp'; + case 'mp4': return 'video/mp4'; + case 'webm': return 'video/webm'; + case 'txt': return 'text/plain'; + case 'md': return 'text/markdown'; + default: return ''; + } + } +} diff --git a/src/app/blocks/file/services/file-mime.service.spec.ts b/src/app/blocks/file/services/file-mime.service.spec.ts new file mode 100644 index 0000000..ccd8e0a --- /dev/null +++ b/src/app/blocks/file/services/file-mime.service.spec.ts @@ -0,0 +1,35 @@ +import { FileMimeService } from './file-mime.service'; + +describe('FileMimeService', () => { + let service: FileMimeService; + + beforeEach(() => { + service = new FileMimeService(); + }); + + it('detects image by extension', () => { + expect(service.kindFromName('photo.PNG')).toBe('image'); + }); + + it('detects video by extension', () => { + expect(service.kindFromName('movie.mp4')).toBe('video'); + }); + + it('detects pdf by extension', () => { + expect(service.kindFromName('report.PDF')).toBe('pdf'); + }); + + it('detects text by extension', () => { + expect(service.kindFromName('readme.md')).toBe('text'); + expect(service.kindFromName('notes.txt')).toBe('text'); + }); + + it('detects code by extension', () => { + expect(service.kindFromName('main.ts')).toBe('code'); + expect(service.kindFromName('index.html')).toBe('code'); + }); + + it('defaults to other', () => { + expect(service.kindFromName('archive.zip')).toBe('other'); + }); +}); diff --git a/src/app/blocks/file/services/file-mime.service.ts b/src/app/blocks/file/services/file-mime.service.ts new file mode 100644 index 0000000..2c76d5f --- /dev/null +++ b/src/app/blocks/file/services/file-mime.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { FileKind } from '../models/file-models'; + +@Injectable({ providedIn: 'root' }) +export class FileMimeService { + kindFromName(name: string): FileKind { + const ext = this.getExt(name); + return this.kindFromExt(ext); + } + + kindFromExt(ext: string): FileKind { + const e = ext.toLowerCase(); + if (['png','jpg','jpeg','gif','webp','bmp','svg','heic','heif','tiff'].includes(e)) return 'image'; + if (['mp4','webm','ogg','mov','mkv','avi'].includes(e)) return 'video'; + if (['pdf'].includes(e)) return 'pdf'; + if (['md','markdown','txt','log','rtf','csv'].includes(e)) return 'text'; + if (['docx'].includes(e)) return 'docx'; + if (['js','ts','tsx','jsx','py','java','cs','c','cpp','h','hpp','rs','go','rb','php','sh','bash','ps1','psm1','json','yaml','yml','toml','ini','gradle','kt','swift','scala','sql','lua','pl','perl','r','dart','s','asm','bat','cmd','makefile','dockerfile','nginx','conf','html','css','scss','less'].includes(e)) return 'code'; + return 'other'; + } + + kindFromFile(file: File): FileKind { + const mime = (file.type || '').toLowerCase(); + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('video/')) return 'video'; + if (mime === 'application/pdf') return 'pdf'; + if (mime.startsWith('text/')) { + const ext = this.getExt(file.name); + if (['md','markdown'].includes(ext)) return 'text'; + if (this.kindFromExt(ext) === 'code') return 'code'; + return 'text'; + } + const ext = this.getExt(file.name); + return this.kindFromExt(ext); + } + + getExt(name: string): string { + const idx = name.lastIndexOf('.'); + if (idx < 0) return ''; + return name.substring(idx + 1).toLowerCase(); + } +} diff --git a/src/app/blocks/file/services/file-picker.service.ts b/src/app/blocks/file/services/file-picker.service.ts new file mode 100644 index 0000000..530189e --- /dev/null +++ b/src/app/blocks/file/services/file-picker.service.ts @@ -0,0 +1,37 @@ +import { Injectable, NgZone } from '@angular/core'; + +export interface PickOptions { + multiple?: boolean; + accept?: string; +} + +@Injectable({ providedIn: 'root' }) +export class FilePickerService { + private input?: HTMLInputElement; + + constructor(private zone: NgZone) {} + + pick(options: PickOptions = { multiple: true, accept: '*/*' }): Promise { + return new Promise((resolve) => { + if (!this.input) { + this.input = document.createElement('input'); + this.input.type = 'file'; + this.input.style.display = 'none'; + document.body.appendChild(this.input); + } + this.input.multiple = !!options.multiple; + this.input.accept = options.accept ?? '*/*'; + this.input.value = ''; + + const onChange = () => { + const files = Array.from(this.input!.files || []); + this.input!.removeEventListener('change', onChange); + // Zone reentry for Angular + this.zone.run(() => resolve(files)); + }; + + this.input.addEventListener('change', onChange, { once: true }); + this.input.click(); + }); + } +} diff --git a/src/app/blocks/file/viewers/preview-code.component.ts b/src/app/blocks/file/viewers/preview-code.component.ts new file mode 100644 index 0000000..686de81 --- /dev/null +++ b/src/app/blocks/file/viewers/preview-code.component.ts @@ -0,0 +1,97 @@ +import { Component, Input, OnInit, OnDestroy, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-preview-code', + standalone: true, + imports: [CommonModule], + template: ` +
+
+
+
+
+ `, + styles: [` + .hljs { display:block; white-space:pre; } + .hljs .line { display:block; position:relative; padding-left: 3.25rem; } + .hljs .line::before { + content: counter(line); + counter-increment: line; + position: absolute; left: 0; width: 2.5rem; text-align: right; color: #9ca3af; /* gray-400 */ + padding-right: 0.5rem; user-select: none; + } + pre { counter-reset: line; } + /* Minimal colors for highlight.js token classes (dark theme friendly) */ + .hljs-comment, .hljs-quote { color:#6b7280; } + .hljs-keyword, .hljs-selector-tag, .hljs-subst { color:#93c5fd; } + .hljs-literal, .hljs-number { color:#fca5a5; } + .hljs-string, .hljs-doctag, .hljs-regexp { color:#86efac; } + .hljs-title, .hljs-section { color:#fcd34d; } + .hljs-type, .hljs-class .hljs-title { color:#f9a8d4; } + .hljs-attribute, .hljs-name, .hljs-tag { color:#f9a8d4; } + .hljs-attr, .hljs-variable, .hljs-template-variable { color:#f472b6; } + `] +}) +export class PreviewCodeComponent implements OnInit, OnDestroy { + @Input({ required: true }) url!: string; + @Input() width = 0; + @Input() ext: string = ''; + + highlighted = signal('Loading...'); + private ctrl?: AbortController; + + async ngOnInit(): Promise { + try { + this.ctrl = new AbortController(); + const res = await fetch(this.url, { signal: this.ctrl.signal }); + const text = await res.text(); + const lang = this.mapExtToLanguage(this.ext); + const hljs = (await import('highlight.js')).default; + let html = ''; + try { + html = lang ? hljs.highlight(text, { language: lang }).value : hljs.highlightAuto(text).value; + } catch { + html = hljs.highlightAuto(text).value; + } + const withLines = html.split('\n').map(l => `${l || ' '}`).join('\n'); + this.highlighted.set(withLines); + } catch (e) { + this.highlighted.set('Failed to load code preview.'); + } + } + + ngOnDestroy(): void { + try { this.ctrl?.abort(); } catch {} + } + + private mapExtToLanguage(ext: string): string | '' { + const e = (ext || '').toLowerCase(); + switch (e) { + case 'ts': return 'typescript'; + case 'js': return 'javascript'; + case 'json': return 'json'; + case 'py': return 'python'; + case 'ps1': return 'powershell'; + case 'sh': return 'bash'; + case 'bash': return 'bash'; + case 'html': return 'xml'; + case 'xml': return 'xml'; + case 'css': return 'css'; + case 'scss': return 'scss'; + case 'java': return 'java'; + case 'cs': return 'csharp'; + case 'cpp': return 'cpp'; + case 'c': return 'c'; + case 'go': return 'go'; + case 'rs': return 'rust'; + case 'rb': return 'ruby'; + case 'php': return 'php'; + case 'sql': return 'sql'; + case 'yml': + case 'yaml': return 'yaml'; + case 'md': return 'markdown'; + default: return ''; + } + } +} diff --git a/src/app/blocks/file/viewers/preview-docx.component.ts b/src/app/blocks/file/viewers/preview-docx.component.ts new file mode 100644 index 0000000..ffb1247 --- /dev/null +++ b/src/app/blocks/file/viewers/preview-docx.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-preview-docx', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

Preview DOCX non supporté.

+ + Télécharger + +
+
+ ` +}) +export class PreviewDocxComponent { + @Input({ required: true }) url!: string; +} diff --git a/src/app/blocks/file/viewers/preview-image.component.ts b/src/app/blocks/file/viewers/preview-image.component.ts new file mode 100644 index 0000000..b6890a0 --- /dev/null +++ b/src/app/blocks/file/viewers/preview-image.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-preview-image', + standalone: true, + imports: [CommonModule], + template: ` +
+ +
+ ` +}) +export class PreviewImageComponent { + @Input({ required: true }) url!: string; + @Input() width = 0; + @Input() alt = ''; +} diff --git a/src/app/blocks/file/viewers/preview-pdf.component.ts b/src/app/blocks/file/viewers/preview-pdf.component.ts new file mode 100644 index 0000000..c5d7254 --- /dev/null +++ b/src/app/blocks/file/viewers/preview-pdf.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-preview-pdf', + standalone: true, + imports: [CommonModule], + template: ` +
+ +
+ ` +}) +export class PreviewPdfComponent { + @Input({ required: true }) set url(v: string) { this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(v); } + @Input() width = 720; + safeUrl!: SafeResourceUrl; + + constructor(private readonly sanitizer: DomSanitizer) {} + + pdfHeight(): number { + const h = Math.max(360, Math.min(920, Math.floor(this.width * 1.25))); + return h; + } +} diff --git a/src/app/blocks/file/viewers/preview-text.component.ts b/src/app/blocks/file/viewers/preview-text.component.ts new file mode 100644 index 0000000..8d1d6c6 --- /dev/null +++ b/src/app/blocks/file/viewers/preview-text.component.ts @@ -0,0 +1,64 @@ +import { Component, Input, OnInit, OnDestroy, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-preview-text', + standalone: true, + imports: [CommonModule], + template: ` +
+
+
+
+
+ `, + styles: [` + .hljs { display:block; white-space:pre-wrap; } + .hljs .line { display:block; position:relative; padding-left: 3.25rem; } + .hljs .line::before { content: counter(line); counter-increment: line; position:absolute; left:0; width:2.5rem; text-align:right; color:#9ca3af; padding-right:0.5rem; user-select:none; } + pre { counter-reset: line; } + .hljs-comment { color:#6b7280; } + .hljs-keyword { color:#93c5fd; } + .hljs-string { color:#86efac; } + .hljs-number { color:#fca5a5; } + .hljs-title { color:#fcd34d; } + .hljs-attr, .hljs-attribute, .hljs-built_in { color:#f9a8d4; } + `] +}) +export class PreviewTextComponent implements OnInit, OnDestroy { + @Input({ required: true }) url!: string; + @Input() width = 0; + @Input() ext: string = ''; + + highlighted = signal('Loading...'); + private ctrl?: AbortController; + + async ngOnInit(): Promise { + try { + this.ctrl = new AbortController(); + const res = await fetch(this.url, { signal: this.ctrl.signal }); + const text = await res.text(); + const hljs = (await import('highlight.js')).default; + let html = ''; + try { + if (this.ext === 'md' || this.ext === 'markdown') { + html = hljs.highlight(text, { language: 'markdown' }).value; + } else if (this.ext === 'csv') { + html = hljs.highlight(text, { language: 'plaintext' as any }).value; + } else { + html = hljs.highlightAuto(text).value; + } + } catch { + html = (text || '').replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c] as string)); + } + const withLines = html.split('\n').map(l => `${l || ' '}`).join('\n'); + this.highlighted.set(withLines); + } catch (e) { + this.highlighted.set('Failed to load preview.'); + } + } + + ngOnDestroy(): void { + try { this.ctrl?.abort(); } catch {} + } +} diff --git a/src/app/blocks/file/viewers/preview-video.component.ts b/src/app/blocks/file/viewers/preview-video.component.ts new file mode 100644 index 0000000..7d6c293 --- /dev/null +++ b/src/app/blocks/file/viewers/preview-video.component.ts @@ -0,0 +1,17 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-preview-video', + standalone: true, + imports: [CommonModule], + template: ` +
+ +
+ ` +}) +export class PreviewVideoComponent { + @Input({ required: true }) url!: string; + @Input() width = 0; +} diff --git a/src/app/editor/components/block/block-context-menu.component.ts b/src/app/editor/components/block/block-context-menu.component.ts index f57a128..11999ce 100644 --- a/src/app/editor/components/block/block-context-menu.component.ts +++ b/src/app/editor/components/block/block-context-menu.component.ts @@ -183,18 +183,20 @@ export interface MenuAction { - + @if (convertOptions.length) { + + }
+ @if (menuOpen) { +
+ @if (isImageKind()) { + +
+ } + + + +
+ +
+ } + + @if (hasPreview()) { + {{ expanded() ? 'Collapse' : 'Preview' }} + } + + + + @if (expanded()) { +
+ +
}
` }) -export class FileBlockComponent { - @Input({ required: true }) block!: Block; - @Output() update = new EventEmitter(); +export class FileBlockComponent implements OnDestroy { + @Input({ required: true }) block!: Block; + @Output() update = new EventEmitter(); + @Output() menuAction = new EventEmitter<{type: string; payload?: any}>(); - get props(): FileProps { - return this.block.props; + private readonly docs = inject(DocumentService); + private readonly picker = inject(FilePickerService); + private readonly hostEl = inject(ElementRef); + private readonly mimeService = inject(FileMimeService); + + expanded = signal(false); + menuOpen = false; + @ViewChild('moreBtn') moreBtn?: ElementRef; + menuPos = { left: 0, top: 0 }; + + ngOnInit(): void { + const p: any = this.block.props as any; + if ('meta' in p && p.meta) { + this.expanded.set(!!p.ui?.expanded); + } else { + // Backward compatibility: adapt legacy props to new structure on the fly + const legacy = p as LegacyFileProps; + const ext = (legacy.name?.split('.').pop() || '').toLowerCase(); + const meta: FileMeta = { + id: this.block.id, + name: legacy.name || 'Untitled file', + size: legacy.size ?? 0, + mime: legacy.mime || '', + ext, + kind: this.mimeService.kindFromExt(ext), + createdAt: Date.now(), + url: legacy.url || '' + }; + this.docs.updateBlockProps(this.block.id, { meta, ui: { expanded: false, layout: 'list' } }); + this.expanded.set(false); + } } - formatSize(bytes: number): string { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + meta() { return (this.block.props as any).meta as FileMeta; } + + isImageKind(): boolean { + const k = this.meta()?.kind; + if (k === 'image') return true; + const ext = (this.meta()?.ext || '').toLowerCase(); + return ['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext); + } + + hasPreview(): boolean { + const k = this.meta()?.kind; + return ['image','video','pdf','text','code','docx'].includes(k); + } + + toggle(): void { + const current = !this.expanded(); + this.expanded.set(current); + const ui = { ...((this.block.props as any).ui || {}), expanded: current }; + this.docs.updateBlockProps(this.block.id, { ui }); + } + + toggleMenu() { + this.menuOpen = !this.menuOpen; + if (this.menuOpen) this.positionMenu(); + } + + @HostListener('document:keydown.escape') + onEsc() { this.menuOpen = false; } + + @HostListener('document:click', ['$event']) + onDocClick(ev: MouseEvent) { + if (!this.menuOpen) return; + const el = this.hostEl.nativeElement as HTMLElement; + if (!el.contains(ev.target as Node)) this.menuOpen = false; + } + + @HostListener('window:resize') + @HostListener('window:scroll') + onWindowChange() { if (this.menuOpen) this.positionMenu(); } + + private positionMenu() { + const btn = this.moreBtn?.nativeElement; if (!btn) return; + const r = btn.getBoundingClientRect(); + const top = Math.round(r.bottom + 6); + const left = Math.round(r.right - 220); // align right edge roughly + this.menuPos = { left: Math.max(8, left), top: Math.max(8, top) }; + } + + async onRename(): Promise { + this.menuOpen = false; + const name = prompt('Rename file', this.meta().name); + if (name && name !== this.meta().name) { + this.docs.updateBlockProps(this.block.id, { meta: { ...this.meta(), name } }); + } + } + + async onReplace(): Promise { + this.menuOpen = false; + const files = await this.picker.pick({ multiple: false, accept: '*/*' }); + if (!files.length) return; + const f = files[0]; + try { if (this.meta().url?.startsWith('blob:')) URL.revokeObjectURL(this.meta().url); } catch {} + const url = URL.createObjectURL(f); + const updated: FileMeta = { ...this.meta(), name: f.name, size: f.size, url }; + this.docs.updateBlockProps(this.block.id, { meta: updated }); + } + + async onCopyLink(): Promise { + this.menuOpen = false; + try { await navigator.clipboard.writeText(this.meta().url); } catch {} + } + + onDelete(): void { + this.menuOpen = false; + this.docs.deleteBlock(this.block.id); + } + + onConvertToImage(): void { + this.menuOpen = false; + this.docs.convertBlock(this.block.id, 'image'); + } + + ngOnDestroy(): void { + // Do not revoke blob URL here to ensure conversions keep working } } diff --git a/src/app/editor/components/block/blocks/paragraph-block.component.ts b/src/app/editor/components/block/blocks/paragraph-block.component.ts index 69cce8b..dc3876b 100644 --- a/src/app/editor/components/block/blocks/paragraph-block.component.ts +++ b/src/app/editor/components/block/blocks/paragraph-block.component.ts @@ -7,6 +7,8 @@ import { DocumentService } from '../../../services/document.service'; import { SelectionService } from '../../../services/selection.service'; import { PaletteService } from '../../../services/palette.service'; import { PaletteCategory, PaletteItem, getPaletteItemsByCategory } from '../../../core/constants/palette-items'; +import { FilePickerService } from '../../../../blocks/file/services/file-picker.service'; +import { BlockInsertionService } from '../../../../blocks/file/services/block-insertion.service'; @Component({ selector: 'app-paragraph-block', @@ -123,6 +125,8 @@ export class ParagraphBlockComponent implements AfterViewInit { private documentService = inject(DocumentService); private selectionService = inject(SelectionService); private paletteService = inject(PaletteService); + private filePicker = inject(FilePickerService); + private blockInserter = inject(BlockInsertionService); @ViewChild('editable', { static: true }) editable?: ElementRef; isFocused = signal(false); @@ -163,8 +167,8 @@ export class ParagraphBlockComponent implements AfterViewInit { break; } case 'file': { - this.documentService.convertBlock(id, 'file'); - break; + this.handleFileInsertion(id); + return; } case 'heading-2': { this.documentService.convertBlock(id, 'heading'); @@ -218,8 +222,8 @@ export class ParagraphBlockComponent implements AfterViewInit { break; } case 'file': { - this.documentService.convertBlock(id, 'file'); - break; + this.handleFileInsertion(id); + return; } case 'paragraph': { this.documentService.convertBlock(id, 'paragraph'); @@ -243,7 +247,7 @@ export class ParagraphBlockComponent implements AfterViewInit { } } this.moreOpen.set(false); - // Keep focus on the same editable after conversion + // Keep focus on the same editable after conversion (if still present) setTimeout(() => this.editable?.nativeElement?.focus(), 0); } @@ -395,4 +399,19 @@ export class ParagraphBlockComponent implements AfterViewInit { nextEl?.focus(); }, 0); } + + private handleFileInsertion(blockId: string): void { + this.filePicker.pick({ multiple: true, accept: '*/*' }).then(async files => { + if (!files.length) return; + const blocks = this.documentService.blocks(); + const insertIndex = blocks.findIndex(b => b.id === blockId); + if (insertIndex < 0) return; + + this.documentService.deleteBlock(blockId); + const created = await this.blockInserter.createFromFiles(files, insertIndex); + if (created.length) { + this.selectionService.setActive(created[created.length - 1]); + } + }); + } } diff --git a/src/app/editor/components/editor-shell/editor-shell.component.ts b/src/app/editor/components/editor-shell/editor-shell.component.ts index d4da2f6..ce50dd1 100644 --- a/src/app/editor/components/editor-shell/editor-shell.component.ts +++ b/src/app/editor/components/editor-shell/editor-shell.component.ts @@ -13,12 +13,15 @@ import { TocButtonComponent } from '../toc/toc-button.component'; import { TocPanelComponent } from '../toc/toc-panel.component'; import { UnsplashPickerComponent } from '../unsplash/unsplash-picker.component'; import { DragDropService } from '../../services/drag-drop.service'; +import { DragDropFilesDirective } from '../../../blocks/file/directives/drag-drop-files.directive'; +import { FilePickerService } from '../../../blocks/file/services/file-picker.service'; +import { BlockInsertionService } from '../../../blocks/file/services/block-insertion.service'; import { PaletteItem } from '../../core/constants/palette-items'; @Component({ selector: 'app-editor-shell', standalone: true, - imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerComponent], + imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerComponent, DragDropFilesDirective], template: `
@@ -45,7 +48,7 @@ import { PaletteItem } from '../../core/constants/palette-items';
-
+
@for (block of documentService.blocks(); track block.id; let idx = $index) { ; @@ -259,13 +264,20 @@ export class EditorShellComponent implements AfterViewInit { 'bullet-list': { type: 'list-item' as any, props: { kind: 'bullet', text: '' } }, 'table': { type: 'table', props: this.documentService.getDefaultProps('table') }, 'image': { type: 'image', props: this.documentService.getDefaultProps('image') }, - 'file': { type: 'file', props: this.documentService.getDefaultProps('file') }, + 'file': null, 'heading-2': { type: 'heading', props: { level: 2, text: '' } }, 'new-page': { type: 'paragraph', props: { text: '' } }, // Placeholder 'use-ai': { type: 'paragraph', props: { text: '' } }, // Placeholder }; const config = typeMap[action]; + if (action === 'file') { + this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => { + if (!files.length) return; + this.insertFilesAtCursor(files); + }); + return; + } if (config) { const block = this.documentService.createBlock(config.type, config.props); this.documentService.appendBlock(block); @@ -275,32 +287,67 @@ export class EditorShellComponent implements AfterViewInit { } onPaletteItemSelected(item: PaletteItem): void { + // Special handling for File: open multi-picker and create N blocks + if (item.type === 'file' || item.id === 'file') { + this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => { + if (!files.length) return; + this.insertFilesAtCursor(files); + }); + return; + } + // Convert list types to list-item for independent lines let blockType = item.type; let props = this.documentService.getDefaultProps(blockType); - if (item.type === 'list') { - // Use list-item instead of list for independent drag & drop blockType = 'list-item' as any; props = this.documentService.getDefaultProps(blockType); - - // Set the correct kind based on palette item - if (item.id === 'checkbox-list') { - props.kind = 'check'; - props.checked = false; - } else if (item.id === 'numbered-list') { - props.kind = 'numbered'; - props.number = 1; - } else if (item.id === 'bullet-list') { - props.kind = 'bullet'; - } + if (item.id === 'checkbox-list') { props.kind = 'check'; props.checked = false; } + else if (item.id === 'numbered-list') { props.kind = 'numbered'; props.number = 1; } + else if (item.id === 'bullet-list') { props.kind = 'bullet'; } } - + const block = this.documentService.createBlock(blockType, props); this.documentService.appendBlock(block); this.selectionService.setActive(block.id); } + /** + * Insert selected files at the current cursor position, matching '/' menu behavior. + * - If an empty paragraph is active, replace it. + * - Else insert after the active block. + * - Else, if an inline menu placeholder exists, insert after it. + * - Else append at the end. + */ + private insertFilesAtCursor(files: File[]): void { + const blocks = this.documentService.blocks(); + const activeId = this.selectionService.getActive(); + let insertIndex = blocks.length; + let replaceBlockId: string | null = null; + + if (activeId) { + const i = blocks.findIndex(b => b.id === activeId); + if (i >= 0) { + const blk: any = blocks[i]; + if (blk.type === 'paragraph' && (!blk.props?.text || String(blk.props.text).trim() === '')) { + // Replace empty paragraph like initial '/' flow + replaceBlockId = blk.id; + insertIndex = i; + } else { + insertIndex = i + 1; + } + } + } else if (this.insertAfterBlockId()) { + const idx = blocks.findIndex(b => b.id === this.insertAfterBlockId()); + if (idx >= 0) insertIndex = idx + 1; + } + + if (replaceBlockId) { + this.documentService.deleteBlock(replaceBlockId); + } + this.inserter.createFromFiles(files, insertIndex); + } + getSaveStateClass(): string { const state = this.documentService.saveState(); switch (state) { @@ -447,9 +494,17 @@ export class EditorShellComponent implements AfterViewInit { props = this.documentService.getDefaultProps('image'); break; case 'file': - blockType = 'file'; - props = this.documentService.getDefaultProps('file'); - break; + // Open picker and replace the placeholder paragraph with N file blocks at the same index + const currentBlocks = this.documentService.blocks(); + const idx = currentBlocks.findIndex(b => b.id === blockId); + this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => { + if (!files.length) return; + // Delete the placeholder paragraph + this.documentService.deleteBlock(blockId); + // Insert at original index + this.inserter.createFromFiles(files, idx); + }); + return; // early exit; we handle asynchronously } // Convert the existing block diff --git a/src/app/editor/components/palette/block-menu.component.ts b/src/app/editor/components/palette/block-menu.component.ts index 39efd47..585e73a 100644 --- a/src/app/editor/components/palette/block-menu.component.ts +++ b/src/app/editor/components/palette/block-menu.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, Output, EventEmitter, signal, computed, ViewChild, ElementRef } from '@angular/core'; +import { Component, inject, Output, EventEmitter, signal, computed, ViewChild, ElementRef, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { PaletteService } from '../../services/palette.service'; @@ -163,6 +163,7 @@ export class BlockMenuComponent { readonly paletteService = inject(PaletteService); @Output() itemSelected = new EventEmitter(); @ViewChild('menuPanel') menuPanel?: ElementRef; + @ViewChild('searchInput') searchInput?: ElementRef; showSuggestions = signal(true); selectedItem = signal(null); @@ -179,8 +180,27 @@ export class BlockMenuComponent { newItems = ['steps', 'kanban', 'progress', 'dropdown', 'unsplash']; + // Ensure focus moves to the search input whenever the palette opens + // or when the suggestions section becomes visible + private _focusEffect = effect(() => { + const isOpen = this.paletteService.isOpen(); + const show = this.showSuggestions(); + if (isOpen && show) { + // Defer to next tick so the input exists in the DOM + setTimeout(() => { + try { this.searchInput?.nativeElement?.focus(); } catch {} + }, 0); + } + }); + toggleSuggestions(): void { this.showSuggestions.update(v => !v); + // If suggestions become visible while open, focus the input + if (this.paletteService.isOpen() && this.showSuggestions()) { + setTimeout(() => { + try { this.searchInput?.nativeElement?.focus(); } catch {} + }, 0); + } } getItemsByCategory(category: PaletteCategory): PaletteItem[] { diff --git a/src/app/editor/services/document.service.ts b/src/app/editor/services/document.service.ts index 743a86d..19f8c3c 100644 --- a/src/app/editor/services/document.service.ts +++ b/src/app/editor/services/document.service.ts @@ -304,56 +304,201 @@ export class DocumentService { * Convert props when changing block type */ private convertProps(fromType: BlockType, fromProps: any, toType: BlockType, preset?: any): any { - // If preset provided, use it - if (preset) return { ...preset }; - - // Paragraph -> Heading - if (fromType === 'paragraph' && toType === 'heading') { - return { level: preset?.level || 1, text: fromProps.text || '' }; + // File -> Image + if (fromType === 'file' && toType === 'image') { + const meta = (fromProps && fromProps.meta) || {}; + const url = meta.url || fromProps.url || ''; + const name = meta.name || ''; + return { src: url, alt: name }; } - // Paragraph -> List - if (fromType === 'paragraph' && toType === 'list') { - return { - kind: preset?.kind || 'bullet', - items: [{ id: generateId(), text: fromProps.text || '' }] + // Image -> File + if (fromType === 'image' && toType === 'file') { + const src: string = fromProps?.src || ''; + const nameGuess = ((): string => { + try { + const u = new URL(src, window.location.origin); + const last = u.pathname.split('/').pop() || 'image'; + return last; + } catch { return 'image'; } + })(); + const ext = (nameGuess.split('.').pop() || '').toLowerCase(); + const meta = { + id: generateId(), + name: nameGuess, + size: 0, + mime: ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : '', + ext, + kind: 'image', + createdAt: Date.now(), + url: src }; + const ui = { expanded: false, layout: 'list' as const }; + return { meta, ui }; } - // List conversions - if (fromType === 'list' && toType === 'list') { - return { ...fromProps, kind: preset?.kind || 'bullet' }; + const text = this.extractTextValue(fromType, fromProps); + const marks = this.extractTextMarks(fromProps); + + switch (toType) { + case 'paragraph': { + const result: any = { text }; + if (marks?.length) result.marks = marks; + return result; + } + case 'heading': { + const level = preset?.level ?? fromProps?.level ?? 1; + const result: any = { level, text }; + if (marks?.length) result.marks = marks; + return result; + } + case 'list-item': { + const kind = preset?.kind ?? fromProps?.kind ?? 'bullet'; + const result: any = { + kind, + text, + indent: typeof fromProps?.indent === 'number' ? fromProps.indent : 0, + align: fromProps?.align ?? 'left' + }; + if (kind === 'check') { + result.checked = preset?.checked ?? fromProps?.checked ?? false; + } else if (kind === 'numbered') { + result.number = preset?.number ?? fromProps?.number ?? 1; + } + return result; + } + case 'list': { + if (fromType === 'list') { + return { ...fromProps, kind: preset?.kind ?? fromProps?.kind ?? 'bullet' }; + } + const kind = preset?.kind ?? 'bullet'; + const item: any = { id: generateId(), text }; + if (kind === 'check') item.checked = preset?.checked ?? false; + return { kind, items: [item] }; + } + case 'code': { + const code = fromType === 'code' ? (fromProps?.code ?? '') : text; + return { + code, + lang: preset?.lang ?? fromProps?.lang ?? '', + theme: fromProps?.theme, + showLineNumbers: fromProps?.showLineNumbers ?? false, + enableWrap: fromProps?.enableWrap ?? false + }; + } + case 'quote': { + return { text, author: fromProps?.author }; + } + case 'hint': { + return { + text, + variant: preset?.variant ?? fromProps?.variant ?? 'info', + borderColor: fromProps?.borderColor, + lineColor: fromProps?.lineColor, + icon: fromProps?.icon + }; + } + case 'button': { + return { + label: text || preset?.label || 'Button', + url: fromProps?.url ?? '', + variant: preset?.variant ?? fromProps?.variant ?? 'primary' + }; + } + case 'toggle': { + const content = Array.isArray(fromProps?.content) + ? this.cloneBlocks(fromProps.content) + : []; + return { + title: text || preset?.title || 'Toggle', + content, + collapsed: fromProps?.collapsed ?? true + }; + } + case 'dropdown': { + const content = Array.isArray(fromProps?.content) + ? this.cloneBlocks(fromProps.content) + : []; + return { + title: text || preset?.title || 'Dropdown', + content, + collapsed: fromProps?.collapsed ?? true + }; + } + case 'steps': { + if (fromType === 'steps' && Array.isArray(fromProps?.steps)) { + return { + steps: fromProps.steps.map((step: any) => ({ + ...step, + id: step.id ?? generateId() + })) + }; + } + return { + steps: [ + { + id: generateId(), + title: text || 'Step 1', + description: '', + done: false + } + ] + }; + } } - // Paragraph -> Code - if (fromType === 'paragraph' && toType === 'code') { - return { code: fromProps.text || '', lang: preset?.lang || '' }; - } - - // Paragraph -> Quote - if (fromType === 'paragraph' && toType === 'quote') { - return { text: fromProps.text || '' }; - } - - // Paragraph -> Hint - if (fromType === 'paragraph' && toType === 'hint') { - return { text: fromProps.text || '', variant: preset?.variant || 'info' }; - } - - // Paragraph -> Button - if (fromType === 'paragraph' && toType === 'button') { - return { label: fromProps.text || 'Button', url: '', variant: 'primary' }; - } - - // Paragraph -> Toggle/Dropdown - if (fromType === 'paragraph' && (toType === 'toggle' || toType === 'dropdown')) { - return { title: fromProps.text || 'Toggle', content: [], collapsed: true }; + if (preset) { + return { ...preset }; } // Default: create empty props for target type return this.getDefaultProps(toType); } + private extractTextValue(fromType: BlockType, props: any): string { + if (!props) return ''; + switch (fromType) { + case 'paragraph': + case 'heading': + case 'quote': + case 'hint': + return props.text || ''; + case 'list-item': + return props.text || ''; + case 'code': + return props.code || ''; + case 'button': + return props.label || ''; + case 'toggle': + case 'dropdown': + return props.title || ''; + case 'steps': + return props.steps?.[0]?.title || ''; + case 'progress': + return props.label || ''; + default: + return ''; + } + } + + private extractTextMarks(props: any): any[] | undefined { + if (!props?.marks) return undefined; + try { + return Array.isArray(props.marks) ? props.marks.map((mark: any) => ({ ...mark })) : undefined; + } catch { + return undefined; + } + } + + private cloneBlocks(blocks: Block[] = []): Block[] { + return blocks.map(block => ({ + ...block, + id: block.id ?? generateId(), + props: block.props ? { ...block.props } : block.props, + children: block.children ? this.cloneBlocks(block.children) : undefined + })); + } + /** * Get default props for block type */ diff --git a/vault/attachments/nimbus/2025/1112/img-img-pasted-20251111-120357-ke3ao3-png-20251112-105533-wgrm0b.png b/vault/attachments/nimbus/2025/1112/img-img-pasted-20251111-120357-ke3ao3-png-20251112-105533-wgrm0b.png new file mode 100644 index 0000000..87c8951 Binary files /dev/null and b/vault/attachments/nimbus/2025/1112/img-img-pasted-20251111-120357-ke3ao3-png-20251112-105533-wgrm0b.png differ diff --git a/vault/attachments/nimbus/2025/1112/img-img-pasted-20251111-120357-ke3ao3-png-20251112-112049-yajpgt.png b/vault/attachments/nimbus/2025/1112/img-img-pasted-20251111-120357-ke3ao3-png-20251112-112049-yajpgt.png new file mode 100644 index 0000000..87c8951 Binary files /dev/null and b/vault/attachments/nimbus/2025/1112/img-img-pasted-20251111-120357-ke3ao3-png-20251112-112049-yajpgt.png differ