done the new blank functionlity
This commit is contained in:
@@ -426,7 +426,7 @@ std::expected<std::string, EngineError> PdfiumPage::extractDisplayListJson() con
|
|||||||
}
|
}
|
||||||
j_array.push_back(j_op);
|
j_array.push_back(j_op);
|
||||||
}
|
}
|
||||||
return j_array.dump();
|
return j_array.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace);
|
||||||
#else
|
#else
|
||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Generated
+605
-3
@@ -9,9 +9,16 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^6.2.108",
|
||||||
|
"perfect-freehand": "^1.2.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"tailwindcss": "^4.3.0"
|
"tailwindcss": "^4.3.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -220,6 +227,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -538,6 +554,256 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-GaPlicMtnvgPr5SowFRprkEJicDSrV3qCq17U4jiF5u0kNORZo3IbdN2Bk4SfcZJAMYFHbMVJ81O3w21CYxazg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "1.0.5",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "1.0.5",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "1.0.5",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.5",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "1.0.5",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "1.0.5",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.5",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "1.0.5",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "1.0.5",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "1.0.5",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-ZzDlpKQocwFfCwhMh17UWre6Qt5yZN3kNIJoUpGfRZqwDDZ164IKOsPOHsRd3d8Tuj5KM6bDjGuPmZxuPuG3NQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-Hr8v6CA/TBe+OJOePdV3sXWxzQHQKfQsTKPbc8wG7iPqVeAx6MMdzKGXlYID6SVvpfwV/zqkvGcdImYWSlhrZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-9BXlLHBXpYnK4jSae1MdFdyPq09Xi1I3PeCNpvRzqgmUUBhgJS7aC1z7SZEP8JUXDHtbfIrolCS3sFuT9IGP2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-zEW4fgvtYsOJ/N56Us4TQPfaFrUf0shGr9CgGSj3GACc+NHfUM3ci0YF9xFTjwJWGDmaEhYPL6KqCfFCxwm/qg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-HFprwLspelJxCEtZvdMcz95Mwvfs63GzFVedLFmC/wslHnaOXhjXxgYxPCM/VdM4Jhx3CV4Lk0vVmh6hJv2etQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FNMGFAx8DvtDwlLfWyBJ+oQjgPXoIAqCnNTJYqtJCFRwwzK3AyAe5B1Ll3NZ6hcOzqw7HylZsq4nQxVyPCQX1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-2vd5v8Lui+37Hh/spITKIvTT384ip4dnUc5XBn0E+sMNS4b7au7IswyT5YDG2udPTjSh/eLUs3sGuB5YHooFOA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-iQIPy+Uey0expZTOszLri5n8rY7x4WUpMaY82mcXNIgbil30iHvc01OsiizhZC1KQHTK90RFMFWSpwFU+ON3aA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-Npthji25t7FUqIAKsoEkFS0qY5CYVkHjvI3tuZjDTG92wX8g4dst+Lfb4hhubdqPazlDcIwalPzInsFCtf3FFg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-bi+JsdCdbfVJDoAQybTYmkLKwh1xpYpptg5j/BNr2BB56u4/R26jrVvtjqff+CxYMXV6Kz1/jeDVhZCuj6bDng==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-KQQwG9/sBmcGxqaLFIQf+k2OefREGoyEaIBmRuTM8bUuFKOEE9Xk5pel90hE6pmBwk/vo4RB0OdX+FxobJGMFw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
@@ -565,6 +831,36 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts/node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng/node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||||
@@ -1142,11 +1438,24 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pako": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/raf": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1162,6 +1471,13 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.3",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||||
@@ -1481,6 +1797,16 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.30",
|
"version": "2.10.30",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz",
|
||||||
@@ -1562,6 +1888,26 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/canvg": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/raf": "^3.4.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"raf": "^3.4.1",
|
||||||
|
"regenerator-runtime": "^0.13.7",
|
||||||
|
"rgbcolor": "^1.0.1",
|
||||||
|
"stackblur-canvas": "^2.0.0",
|
||||||
|
"svg-pathdata": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1569,6 +1915,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.50.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||||
|
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -1584,11 +1945,21 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -1625,6 +1996,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||||
|
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optional": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.357",
|
"version": "1.5.357",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz",
|
||||||
@@ -1871,6 +2252,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-png": {
|
||||||
|
"version": "6.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||||
|
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"iobuffer": "^5.3.2",
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1888,6 +2280,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -2012,6 +2410,26 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/idb": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -2032,6 +2450,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iobuffer": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -2125,6 +2549,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jspdf": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"fast-png": "^6.2.0",
|
||||||
|
"fflate": "^0.8.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"canvg": "^3.0.11",
|
||||||
|
"core-js": "^3.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
|
"html2canvas": "^1.0.0-rc.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -2538,6 +2979,22 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -2558,6 +3015,55 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "6.2.108",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
|
||||||
|
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/perfect-freehand": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/performance-now": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2624,6 +3130,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/raf": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"performance-now": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
@@ -2645,6 +3161,23 @@
|
|||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/rgbcolor": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||||
|
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||||
@@ -2726,6 +3259,26 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackblur-canvas": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svg-pathdata": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
@@ -2745,6 +3298,16 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -2880,6 +3443,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.13",
|
"version": "8.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||||
@@ -3025,6 +3598,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||||
|
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^6.2.108",
|
||||||
|
"perfect-freehand": "^1.2.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"tailwindcss": "^4.3.0"
|
"tailwindcss": "^4.3.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
+195
-54
@@ -12,6 +12,9 @@ import { VersionHistoryModal } from './components/VersionHistoryModal';
|
|||||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||||
import { PDFViewer } from './viewer/PDFViewer';
|
import { PDFViewer } from './viewer/PDFViewer';
|
||||||
|
import { WhiteboardView } from './simple_draw';
|
||||||
|
import { CreatePDFModal } from './features/document-creator/components/CreatePDFModal';
|
||||||
|
import type { PageLayout } from './features/document-creator/model/PaginationEngine';
|
||||||
import type { PDFViewerRef } from './viewer/PDFViewer';
|
import type { PDFViewerRef } from './viewer/PDFViewer';
|
||||||
import type { Annotation } from './viewer/AnnotationLayer';
|
import type { Annotation } from './viewer/AnnotationLayer';
|
||||||
import type { EditableRun, ReflowParagraphPayload } from './viewer/TextEditLayer';
|
import type { EditableRun, ReflowParagraphPayload } from './viewer/TextEditLayer';
|
||||||
@@ -21,7 +24,7 @@ import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontI
|
|||||||
import { viewportRectToPdf } from './lib/coordinateMapping';
|
import { viewportRectToPdf } from './lib/coordinateMapping';
|
||||||
import type { Rect } from './lib/coordinateMapping';
|
import type { Rect } from './lib/coordinateMapping';
|
||||||
|
|
||||||
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
|
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS, STAMP_PRESETS } from './lib/tools';
|
||||||
import type { ToolId, ToolSettings, StampPreset } from './lib/tools';
|
import type { ToolId, ToolSettings, StampPreset } from './lib/tools';
|
||||||
|
|
||||||
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
|
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
@@ -53,6 +56,30 @@ function App() {
|
|||||||
const [toolSettings, setToolSettings] = useState<ToolSettings>(DEFAULT_TOOL_SETTINGS);
|
const [toolSettings, setToolSettings] = useState<ToolSettings>(DEFAULT_TOOL_SETTINGS);
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
|
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
|
||||||
|
const [createPdfModalOpen, setCreatePdfModalOpen] = useState(false);
|
||||||
|
const [creatorActions, setCreatorActions] = useState<{
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
generate: () => void;
|
||||||
|
print: () => void;
|
||||||
|
generateBlob?: () => Promise<Blob>;
|
||||||
|
updateRunFormatting?: (updates: Partial<import('./features/document-creator/types/documentModel').TextRun>) => void;
|
||||||
|
getActiveRun?: () => import('./features/document-creator/types/documentModel').TextRun | null;
|
||||||
|
addParagraph?: (afterBlockId?: string) => void;
|
||||||
|
insertStamp?: (stampType?: string) => void;
|
||||||
|
insertComment?: (commentText?: string) => void;
|
||||||
|
deletePage?: (pageIndex: number) => void;
|
||||||
|
reorderPage?: (fromIndex: number, toIndex: number) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTool === 'create_pdf') {
|
||||||
|
setCreatePdfModalOpen(true);
|
||||||
|
setActiveTool('select');
|
||||||
|
}
|
||||||
|
}, [activeTool]);
|
||||||
|
|
||||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||||
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
|
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
|
||||||
@@ -83,6 +110,13 @@ function App() {
|
|||||||
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
|
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
|
||||||
|
|
||||||
const [isOCRLoading, setIsOCRLoading] = useState(false);
|
const [isOCRLoading, setIsOCRLoading] = useState(false);
|
||||||
|
const [creatorPageCount, setCreatorPageCount] = useState<number>(1);
|
||||||
|
const [creatorPages, setCreatorPages] = useState<PageLayout[]>([]);
|
||||||
|
|
||||||
|
const handleCreatorPageCountChange = useCallback((count: number, pages?: PageLayout[]) => {
|
||||||
|
setCreatorPageCount(count);
|
||||||
|
if (pages) setCreatorPages(pages);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRunOCR = async () => {
|
const handleRunOCR = async () => {
|
||||||
if (!activeDoc) return;
|
if (!activeDoc) return;
|
||||||
@@ -97,7 +131,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDocument = useCallback((id: string) => setHist({ stack: [id], index: 0 }), []);
|
const openDocument = useCallback((id: string) => {
|
||||||
|
setCreatePdfModalOpen(false);
|
||||||
|
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
||||||
|
setHist({ stack: [id], index: 0 });
|
||||||
|
}, []);
|
||||||
const pushHistory = (id: string) =>
|
const pushHistory = (id: string) =>
|
||||||
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
|
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
|
||||||
const undo = () => {
|
const undo = () => {
|
||||||
@@ -517,6 +555,8 @@ function App() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const newDoc = await gatewayService.uploadDocument(file, password);
|
const newDoc = await gatewayService.uploadDocument(file, password);
|
||||||
setDocuments((prev) => [newDoc, ...prev]);
|
setDocuments((prev) => [newDoc, ...prev]);
|
||||||
|
setCreatePdfModalOpen(false);
|
||||||
|
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
||||||
openDocument(newDoc.id);
|
openDocument(newDoc.id);
|
||||||
|
|
||||||
setPasswordPrompt(null);
|
setPasswordPrompt(null);
|
||||||
@@ -531,6 +571,56 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToolChange = async (tool: ToolId) => {
|
||||||
|
if (tool === 'create_pdf') {
|
||||||
|
setCreatePdfModalOpen(true);
|
||||||
|
setActiveTool('create_pdf');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
||||||
|
if (isCreatorActive) {
|
||||||
|
const activeRun = creatorActions?.getActiveRun?.();
|
||||||
|
|
||||||
|
if (tool === 'underline' || tool === 'squiggly') {
|
||||||
|
creatorActions?.updateRunFormatting?.({ underline: !activeRun?.underline });
|
||||||
|
setActiveTool('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tool === 'strikeout') {
|
||||||
|
creatorActions?.updateRunFormatting?.({ strikethrough: !activeRun?.strikethrough });
|
||||||
|
setActiveTool('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tool === 'highlight') {
|
||||||
|
const nextColor = activeRun?.highlightColor ? undefined : '#fef08a';
|
||||||
|
creatorActions?.updateRunFormatting?.({ highlightColor: nextColor });
|
||||||
|
setActiveTool('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tool === 'textbox') {
|
||||||
|
creatorActions?.addParagraph?.();
|
||||||
|
setActiveTool('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tool === 'stamp') {
|
||||||
|
setActiveTool('stamp');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tool === 'comment') {
|
||||||
|
creatorActions?.insertComment?.();
|
||||||
|
setActiveTool('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tool === 'draw') {
|
||||||
|
setActiveTool('draw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTool(tool);
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!activeDoc) return;
|
if (!activeDoc) return;
|
||||||
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
||||||
@@ -616,37 +706,39 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
|
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
|
||||||
<TopBar
|
<TopBar
|
||||||
documentName={activeDoc?.filename}
|
documentName={(activeTool === 'create_pdf' || createPdfModalOpen) ? 'New Blank Document.pdf' : activeDoc?.filename}
|
||||||
backendHealthy={backendHealthy}
|
backendHealthy={backendHealthy}
|
||||||
engineReady={engineReady}
|
engineReady={engineReady}
|
||||||
|
canUndo={activeTool === 'create_pdf' || createPdfModalOpen ? (creatorActions?.canUndo ?? false) : canUndo}
|
||||||
|
canRedo={activeTool === 'create_pdf' || createPdfModalOpen ? (creatorActions?.canRedo ?? false) : canRedo}
|
||||||
|
onUndo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.undo()) : undo}
|
||||||
|
onRedo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.redo()) : redo}
|
||||||
|
isSaving={isSaving}
|
||||||
|
isDirtySaved={hist.stack.length > 1}
|
||||||
|
onRotate={handleRotate}
|
||||||
|
onExport={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.generate()) : handleExport}
|
||||||
|
onPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? (() => creatorActions?.print?.()) : handlePrint}
|
||||||
|
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
|
||||||
|
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
|
||||||
|
canAssemble={can('canAssemble')}
|
||||||
|
canVersionHistory={!(activeTool === 'create_pdf' || createPdfModalOpen)}
|
||||||
|
onUpload={handleUpload}
|
||||||
|
onNewBlankPDF={() => { setCreatePdfModalOpen(true); setActiveTool('create_pdf'); }}
|
||||||
|
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
|
||||||
|
isInspectorOpen={isInspectorOpen}
|
||||||
|
onToggleInspector={toggleInspector}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
onFitWidth={fitWidth}
|
onFitWidth={fitWidth}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={activeDoc?.totalPages || 1}
|
totalPages={activeDoc?.totalPages || 1}
|
||||||
onGoToPage={(p) => viewerRef.current?.scrollToPage(p)}
|
onGoToPage={(p) => viewerRef.current?.scrollToPage(p)}
|
||||||
canUndo={canUndo}
|
|
||||||
canRedo={canRedo}
|
|
||||||
onUndo={undo}
|
|
||||||
onRedo={redo}
|
|
||||||
isSaving={isSaving}
|
|
||||||
isDirtySaved={hist.stack.length > 1}
|
|
||||||
onRotate={handleRotate}
|
|
||||||
onExport={handleExport}
|
|
||||||
onPrint={handlePrint}
|
|
||||||
canPrint={can('canPrint')}
|
|
||||||
canExport={can('canCopy')}
|
|
||||||
canAssemble={can('canAssemble')}
|
|
||||||
onUpload={handleUpload}
|
|
||||||
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
|
|
||||||
isInspectorOpen={isInspectorOpen}
|
|
||||||
onToggleInspector={toggleInspector}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<ToolRail
|
<ToolRail
|
||||||
activeTool={activeTool}
|
activeTool={activeTool}
|
||||||
onToolChange={setActiveTool}
|
onToolChange={handleToolChange}
|
||||||
hasSignature={!!pendingSignature}
|
hasSignature={!!pendingSignature}
|
||||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||||
onOpenAbout={() => setAboutModalOpen(true)}
|
onOpenAbout={() => setAboutModalOpen(true)}
|
||||||
@@ -656,37 +748,66 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<Toolbar
|
{activeTool !== 'create_pdf' && !createPdfModalOpen && (
|
||||||
activeTool={activeTool}
|
<Toolbar
|
||||||
settings={toolSettings}
|
activeTool={activeTool}
|
||||||
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
settings={toolSettings}
|
||||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
||||||
hasSignature={!!pendingSignature}
|
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||||
activeStamp={activeStamp}
|
hasSignature={!!pendingSignature}
|
||||||
onSelectStamp={setActiveStamp}
|
activeStamp={activeStamp}
|
||||||
redactionMode={redactionMode}
|
onSelectStamp={setActiveStamp}
|
||||||
onRedactionModeChange={setRedactionMode}
|
redactionMode={redactionMode}
|
||||||
pendingRedactionCount={pendingRedactions.length}
|
onRedactionModeChange={setRedactionMode}
|
||||||
onApplyRedactions={handleApplyRedactions}
|
pendingRedactionCount={pendingRedactions.length}
|
||||||
onClearRedactions={() => setPendingRedactions([])}
|
onApplyRedactions={handleApplyRedactions}
|
||||||
onRedactPages={() => setRedactPagesModalOpen(true)}
|
onClearRedactions={() => setPendingRedactions([])}
|
||||||
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
|
onRedactPages={() => setRedactPagesModalOpen(true)}
|
||||||
onUpdateAnnotation={(patch) => {
|
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
|
||||||
const a = annotations.find(x => x.id === selectedAnnotationId);
|
onUpdateAnnotation={(patch) => {
|
||||||
if (a) handleUpdateAnnotation({ ...a, ...patch });
|
const a = annotations.find(x => x.id === selectedAnnotationId);
|
||||||
}}
|
if (a) handleUpdateAnnotation({ ...a, ...patch });
|
||||||
onDeleteAnnotation={() => {
|
}}
|
||||||
const a = annotations.find(x => x.id === selectedAnnotationId);
|
onDeleteAnnotation={() => {
|
||||||
if (a) {
|
const a = annotations.find(x => x.id === selectedAnnotationId);
|
||||||
handleDeleteAnnotation(a);
|
if (a) {
|
||||||
setSelectedAnnotationId(null);
|
handleDeleteAnnotation(a);
|
||||||
}
|
setSelectedAnnotationId(null);
|
||||||
}}
|
}
|
||||||
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
|
}}
|
||||||
/>
|
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
{isLoading && !activeDoc ? (
|
{activeTool === 'whiteboard' ? (
|
||||||
|
<WhiteboardView />
|
||||||
|
) : activeTool === 'create_pdf' || createPdfModalOpen ? (
|
||||||
|
<CreatePDFModal
|
||||||
|
isOpen={true}
|
||||||
|
activeTool={activeTool}
|
||||||
|
drawColor={toolSettings.inkColor}
|
||||||
|
drawWidth={toolSettings.inkThickness}
|
||||||
|
activeStamp={activeStamp?.label || 'APPROVED'}
|
||||||
|
onUpdateDrawColor={(color) => setToolSettings((s) => ({ ...s, inkColor: color }))}
|
||||||
|
onUpdateDrawWidth={(width) => setToolSettings((s) => ({ ...s, inkThickness: width }))}
|
||||||
|
onSelectStamp={(label) => {
|
||||||
|
const preset = STAMP_PRESETS.find((s) => s.label === label);
|
||||||
|
if (preset) setActiveStamp(preset);
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setCreatePdfModalOpen(false);
|
||||||
|
if (activeTool === 'create_pdf') setActiveTool('select');
|
||||||
|
}}
|
||||||
|
onCreatePDF={async (file) => {
|
||||||
|
await handleUpload(file);
|
||||||
|
setCreatePdfModalOpen(false);
|
||||||
|
setActiveTool('select');
|
||||||
|
}}
|
||||||
|
onPageCountChange={handleCreatorPageCountChange}
|
||||||
|
onRegisterActions={setCreatorActions}
|
||||||
|
/>
|
||||||
|
) : isLoading && !activeDoc ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
<p className="text-[12px] font-semibold uppercase tracking-wide text-[#98a1ad]">Loading…</p>
|
<p className="text-[12px] font-semibold uppercase tracking-wide text-[#98a1ad]">Loading…</p>
|
||||||
@@ -804,13 +925,33 @@ function App() {
|
|||||||
documents={documents}
|
documents={documents}
|
||||||
selectedDocumentId={selectedDocId}
|
selectedDocumentId={selectedDocId}
|
||||||
onSelectDocument={openDocument}
|
onSelectDocument={openDocument}
|
||||||
documentId={activeDoc?.id || ''}
|
documentId={(activeTool === 'create_pdf' || createPdfModalOpen) ? '' : (activeDoc?.id || '')}
|
||||||
totalPages={activeDoc?.totalPages || 0}
|
totalPages={(activeTool === 'create_pdf' || createPdfModalOpen) ? creatorPageCount : (activeDoc?.totalPages || 0)}
|
||||||
|
creatorPages={creatorPages}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
sizeBytes={activeDoc?.sizeBytes}
|
sizeBytes={activeDoc?.sizeBytes}
|
||||||
onNavigateToPage={(i) => viewerRef.current?.scrollToPage(i)}
|
onNavigateToPage={(i) => {
|
||||||
onDeletePage={handleDeletePage}
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
||||||
onReorderPage={handleReorderPage}
|
const cards = document.querySelectorAll('.bg-white.shadow-xl');
|
||||||
|
cards[i]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else {
|
||||||
|
viewerRef.current?.scrollToPage(i);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDeletePage={(i) => {
|
||||||
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
||||||
|
creatorActions?.deletePage?.(i);
|
||||||
|
} else {
|
||||||
|
handleDeletePage(i);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReorderPage={(fromIdx, toIdx) => {
|
||||||
|
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
||||||
|
creatorActions?.reorderPage?.(fromIdx, toIdx);
|
||||||
|
} else {
|
||||||
|
handleReorderPage(fromIdx, toIdx);
|
||||||
|
}
|
||||||
|
}}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
onNavigateAnnotation={navigateToAnnotation}
|
onNavigateAnnotation={navigateToAnnotation}
|
||||||
onDeleteAnnotation={handleDeleteAnnotation}
|
onDeleteAnnotation={handleDeleteAnnotation}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { CustomButton } from './custom/CustomButton';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
|
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
|
||||||
import type { Annotation } from '../viewer/AnnotationLayer';
|
import type { Annotation } from '../viewer/AnnotationLayer';
|
||||||
|
import type { PageLayout } from '../features/document-creator/model/PaginationEngine';
|
||||||
|
import type { ParagraphBlock, TableBlock, ImageBlock } from '../features/document-creator/types/documentModel';
|
||||||
import { Thumbnail } from './Thumbnail';
|
import { Thumbnail } from './Thumbnail';
|
||||||
import { EmptyState } from './ui';
|
import { EmptyState } from './ui';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +27,8 @@ interface InspectorPanelProps {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
|
creatorPages?: PageLayout[];
|
||||||
|
creatorPagePreviews?: string[][];
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
onNavigateToPage: (i: number) => void;
|
onNavigateToPage: (i: number) => void;
|
||||||
onDeletePage: (i: number) => void;
|
onDeletePage: (i: number) => void;
|
||||||
@@ -152,15 +156,88 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MiniPageVisualPreview: React.FC<{ page: PageLayout; pageNumber: number }> = ({ page, pageNumber: _pageNumber }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full aspect-[3/4] bg-white border border-slate-200 rounded flex flex-col justify-between p-2 overflow-hidden shadow-sm select-none relative">
|
||||||
|
<div className="flex flex-col gap-1 w-full overflow-hidden flex-1 pointer-events-none">
|
||||||
|
{page.blocks.map((block) => {
|
||||||
|
if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list-item' || block.type === 'quote') {
|
||||||
|
const p = block as ParagraphBlock;
|
||||||
|
const firstRun = p.runs?.[0] || {};
|
||||||
|
const fullText = p.runs ? p.runs.map((r) => r.text || '').join('') : '';
|
||||||
|
if (!fullText.trim()) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={block.id}
|
||||||
|
className="line-clamp-2 break-all text-[7.5px] leading-tight"
|
||||||
|
style={{
|
||||||
|
textAlign: p.alignment || 'left',
|
||||||
|
color: firstRun.color || '#0f172a',
|
||||||
|
fontWeight: firstRun.bold || p.styleName?.includes('Heading') ? 'bold' : 'normal',
|
||||||
|
fontStyle: firstRun.italic ? 'italic' : 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fullText}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'table') {
|
||||||
|
const tbl = block as TableBlock;
|
||||||
|
return (
|
||||||
|
<div key={block.id} className="my-0.5 w-full overflow-hidden border border-slate-300 rounded text-[6px]">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{tbl.rows.slice(0, 4).map((row, rIdx) => (
|
||||||
|
<tr key={rIdx} className="border-b border-slate-200 last:border-b-0">
|
||||||
|
{row.cells.map((cell, cIdx) => {
|
||||||
|
const cellText = (cell.runs || []).map((r) => r.text || '').join('');
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cIdx}
|
||||||
|
className="p-0.5 border-r border-slate-200 last:border-r-0 truncate text-[6px] text-slate-700 font-sans leading-none"
|
||||||
|
style={{ backgroundColor: cell.backgroundColor || 'transparent', textAlign: cell.alignment || 'left' }}
|
||||||
|
>
|
||||||
|
{cellText}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'image') {
|
||||||
|
const img = block as ImageBlock;
|
||||||
|
const alignClass = img.alignment === 'right' ? 'self-end' : img.alignment === 'center' ? 'self-center' : 'self-start';
|
||||||
|
return (
|
||||||
|
<div key={block.id} className={`my-0.5 max-w-[85%] max-h-[40px] overflow-hidden rounded border border-slate-200 shadow-2xs ${alignClass}`}>
|
||||||
|
<img src={img.src} alt="" className="w-full h-full object-cover max-h-[40px]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
||||||
const [draggedIdx, setDraggedIdx] = React.useState<number | null>(null);
|
const [draggedIdx, setDraggedIdx] = React.useState<number | null>(null);
|
||||||
|
|
||||||
if (!p.totalPages) return <EmptyState icon={<PagesIcon size={30} />} title="No pages" hint="Open a PDF to see its pages." />;
|
if (!p.totalPages) return <EmptyState icon={<PagesIcon size={30} />} title="No pages" hint="Open a PDF or create a document to see its pages." />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 p-3">
|
<div className="grid grid-cols-2 gap-3 p-3 select-none">
|
||||||
{Array.from({ length: p.totalPages }).map((_, idx) => (
|
{Array.from({ length: p.totalPages }).map((_, idx) => (
|
||||||
<div key={idx}
|
<div key={idx}
|
||||||
draggable
|
draggable={!!p.documentId}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
setDraggedIdx(idx);
|
setDraggedIdx(idx);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -171,23 +248,92 @@ const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
}}
|
}}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedIdx !== null && draggedIdx !== idx) {
|
if (draggedIdx !== null && draggedIdx !== idx && p.documentId) {
|
||||||
p.onReorderPage(draggedIdx, idx);
|
p.onReorderPage(draggedIdx, idx);
|
||||||
}
|
}
|
||||||
setDraggedIdx(null);
|
setDraggedIdx(null);
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => setDraggedIdx(null)}
|
onDragEnd={() => setDraggedIdx(null)}
|
||||||
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-brand-primary [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-brand-secondary' : ''}`}
|
className={`cursor-pointer ${draggedIdx === idx ? 'opacity-50' : ''}`}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
{p.documentId ? (
|
||||||
documentId={p.documentId}
|
<Thumbnail
|
||||||
pageIndex={idx}
|
documentId={p.documentId}
|
||||||
onClick={() => p.onNavigateToPage(idx)}
|
pageIndex={idx}
|
||||||
onDelete={() => p.onDeletePage(idx)}
|
onClick={() => p.onNavigateToPage(idx)}
|
||||||
onMoveUp={idx > 0 ? () => p.onReorderPage(idx, idx - 1) : undefined}
|
onDelete={() => p.onDeletePage(idx)}
|
||||||
onMoveDown={idx < p.totalPages - 1 ? () => p.onReorderPage(idx, idx + 1) : undefined}
|
onMoveUp={idx > 0 ? () => p.onReorderPage(idx, idx - 1) : undefined}
|
||||||
totalPages={p.totalPages}
|
onMoveDown={idx < p.totalPages - 1 ? () => p.onReorderPage(idx, idx + 1) : undefined}
|
||||||
/>
|
totalPages={p.totalPages}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => p.onNavigateToPage(idx)}
|
||||||
|
className={`group relative flex flex-col items-center gap-1.5 p-2 rounded-lg border bg-white shadow-sm hover:shadow-md transition-all ${
|
||||||
|
idx === p.currentPage ? 'border-brand-primary ring-2 ring-brand-secondary/40' : 'border-slate-200 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.totalPages > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Delete Page"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
p.onDeletePage(idx);
|
||||||
|
}}
|
||||||
|
className="absolute top-1 right-1 hidden group-hover:flex items-center justify-center h-5 w-5 rounded bg-red-600 text-white shadow-md hover:bg-red-700 z-10"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M3 6h18m-2 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.creatorPages?.[idx] ? (
|
||||||
|
<MiniPageVisualPreview page={p.creatorPages[idx]} pageNumber={idx + 1} />
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-[3/4] bg-white border border-slate-200 rounded flex flex-col items-center justify-center p-2 overflow-hidden shadow-sm select-none">
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 gap-1 text-slate-300">
|
||||||
|
<div className="w-3/4 h-1 bg-slate-200 rounded-full" />
|
||||||
|
<div className="w-1/2 h-1 bg-slate-200 rounded-full" />
|
||||||
|
<span className="text-[9px] text-slate-400 font-medium mt-1">Empty Page</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between w-full px-1">
|
||||||
|
{idx > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Move Page Left"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
p.onReorderPage(idx, idx - 1);
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-slate-700 p-0.5 text-xs font-bold"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
) : <span className="w-3" />}
|
||||||
|
|
||||||
|
<span className="text-[11px] font-semibold text-slate-600">Page {idx + 1}</span>
|
||||||
|
|
||||||
|
{idx < p.totalPages - 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Move Page Right"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
p.onReorderPage(idx, idx + 1);
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-slate-700 p-0.5 text-xs font-bold"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
) : <span className="w-3" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
|||||||
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
||||||
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
|
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
|
||||||
{ id: 'draw', label: 'Draw (ink)', shortLabel: 'Draw', shortcut: 'D', icon: <DrawIcon /> },
|
{ id: 'draw', label: 'Draw (ink)', shortLabel: 'Draw', shortcut: 'D', icon: <DrawIcon /> },
|
||||||
|
{
|
||||||
|
id: 'create_pdf',
|
||||||
|
label: 'Create PDF',
|
||||||
|
shortLabel: 'Create PDF',
|
||||||
|
shortcut: 'N',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12" />
|
||||||
|
<line x1="9" y1="15" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||||
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
|
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||||
{
|
{
|
||||||
@@ -82,14 +96,13 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; is
|
|||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${
|
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${disabled
|
||||||
disabled
|
|
||||||
? 'cursor-not-allowed text-[#c5cad1]'
|
? 'cursor-not-allowed text-[#c5cad1]'
|
||||||
: active
|
: active
|
||||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
|
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
|
||||||
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
|
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -59,7 +59,22 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
|||||||
<polyline points="16 18 22 12 16 6" />
|
<polyline points="16 18 22 12 16 6" />
|
||||||
<polyline points="8 6 2 12 8 18" />
|
<polyline points="8 6 2 12 8 18" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
create_pdf: {
|
||||||
|
label: 'Create PDF',
|
||||||
|
icon: (
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12" />
|
||||||
|
<line x1="9" y1="15" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
whiteboard: {
|
||||||
|
label: 'Whiteboard',
|
||||||
|
icon: <DrawIcon size={17} />
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,9 +92,11 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
|
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
|
||||||
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
|
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
|
||||||
}) => {
|
}) => {
|
||||||
|
if (activeTool === 'whiteboard') return null;
|
||||||
|
|
||||||
const meta = TOOL_META[activeTool];
|
const meta = TOOL_META[activeTool];
|
||||||
const isRedact = activeTool === 'redact';
|
const isRedact = activeTool === 'redact';
|
||||||
|
|
||||||
if (selectedAnnotation) {
|
if (selectedAnnotation) {
|
||||||
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
|
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
|
||||||
return (
|
return (
|
||||||
@@ -104,9 +121,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
{['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'ink', 'textbox'].includes(selectedAnnotation.type as string) && (
|
{['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'ink', 'textbox'].includes(selectedAnnotation.type as string) && (
|
||||||
<>
|
<>
|
||||||
<Label>Color</Label>
|
<Label>Color</Label>
|
||||||
<ColorSwatches
|
<ColorSwatches
|
||||||
value={selectedAnnotation.color || '#2563eb'}
|
value={selectedAnnotation.color || '#2563eb'}
|
||||||
onChange={(c) => onUpdateAnnotation?.({ color: c })}
|
onChange={(c) => onUpdateAnnotation?.({ color: c })}
|
||||||
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']}
|
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -114,8 +131,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CustomButton variant="outline" size="sm" onClick={onDeselectAnnotation}>
|
<CustomButton variant="outline" size="sm" onClick={onDeselectAnnotation}>
|
||||||
Done
|
Done
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
<CustomButton variant="outline" size="sm" onClick={onDeleteAnnotation}>
|
<CustomButton variant="outline" size="sm" onClick={onDeleteAnnotation}>
|
||||||
<span className="text-[#dc2626]">Delete</span>
|
<span className="text-[#dc2626]">Delete</span>
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
@@ -200,7 +217,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<Hint>Click to place a text box.</Hint>
|
<Hint>Click to place a text box.</Hint>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTool === 'edit_text' && <Hint>Click text to seamlessly re-write paragraphs with automatic reflow.</Hint>}
|
{activeTool === 'edit_text' && <Hint>Click text to seamlessly re-write paragraphs with automatic reflow.</Hint>}
|
||||||
{activeTool === 'ocr' && <Hint>Recognize text on page using RapidOCR.</Hint>}
|
{activeTool === 'ocr' && <Hint>Recognize text on page using RapidOCR.</Hint>}
|
||||||
{activeTool === 'stream_edit' && <Hint>Beta · surgical byte-level edit (no reflow). Best for same-length fixes in simple fonts.</Hint>}
|
{activeTool === 'stream_edit' && <Hint>Beta · surgical byte-level edit (no reflow). Best for same-length fixes in simple fonts.</Hint>}
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ interface TopBarProps {
|
|||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
onUpload: (file: File) => void;
|
onUpload: (file: File) => void;
|
||||||
|
onNewBlankPDF?: () => void;
|
||||||
onShowVersionHistory?: () => void;
|
onShowVersionHistory?: () => void;
|
||||||
isInspectorOpen: boolean;
|
isInspectorOpen: boolean;
|
||||||
onToggleInspector: () => void;
|
onToggleInspector: () => void;
|
||||||
canPrint?: boolean;
|
canPrint?: boolean;
|
||||||
canExport?: boolean;
|
canExport?: boolean;
|
||||||
canAssemble?: boolean;
|
canAssemble?: boolean;
|
||||||
|
canVersionHistory?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||||
@@ -39,8 +41,8 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
|||||||
export const TopBar: React.FC<TopBarProps> = ({
|
export const TopBar: React.FC<TopBarProps> = ({
|
||||||
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
||||||
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
||||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory,
|
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onNewBlankPDF, onShowVersionHistory,
|
||||||
canPrint = true, canExport = true, canAssemble = true,
|
canPrint = true, canExport = true, canAssemble = true, canVersionHistory = true,
|
||||||
}) => {
|
}) => {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -74,8 +76,9 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col text-[13px]">
|
<div className="flex flex-col text-[13px]">
|
||||||
|
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="12" y1="18" x2="12" y2="12" /><line x1="9" y1="15" x2="15" y2="15" /></svg>} onClick={() => onNewBlankPDF?.()}>New Blank PDF…</MenuItem>
|
||||||
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF…</MenuItem>
|
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF…</MenuItem>
|
||||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>} onClick={() => onShowVersionHistory?.()} disabled={!documentName}>Version History</MenuItem>
|
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>} onClick={() => onShowVersionHistory?.()} disabled={!documentName || !canVersionHistory}>Version History</MenuItem>
|
||||||
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
|
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
|
||||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
|
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'le
|
|||||||
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-border-primary bg-bg-primary p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
|
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-border-primary bg-bg-primary p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
|
||||||
align === 'right' ? 'right-0' : 'left-0'
|
align === 'right' ? 'right-0' : 'left-0'
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,787 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import type { DocumentModel, DocumentBlock, ParagraphBlock, TextRun, TableBlock, ImageBlock } from '../types/documentModel';
|
||||||
|
import { DEFAULT_STYLES } from '../types/documentModel';
|
||||||
|
import { HistoryManager } from '../model/HistoryManager';
|
||||||
|
import { PaginationEngine } from '../model/PaginationEngine';
|
||||||
|
import type { PageLayout } from '../model/PaginationEngine';
|
||||||
|
import { PdfDocumentRenderer } from '../renderer/PdfDocumentRenderer';
|
||||||
|
import { DocumentToolbar } from './DocumentToolbar';
|
||||||
|
import { DocumentEditor } from './DocumentEditor';
|
||||||
|
|
||||||
|
interface CreatePDFModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
activeTool?: string;
|
||||||
|
drawColor?: string;
|
||||||
|
drawWidth?: number;
|
||||||
|
activeStamp?: string;
|
||||||
|
onUpdateDrawColor?: (color: string) => void;
|
||||||
|
onUpdateDrawWidth?: (width: number) => void;
|
||||||
|
onSelectStamp?: (stamp: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreatePDF: (file: File) => Promise<void>;
|
||||||
|
onPageCountChange?: (count: number, pages?: PageLayout[]) => void;
|
||||||
|
onRegisterActions?: (actions: {
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
generate: () => void;
|
||||||
|
print: () => void;
|
||||||
|
deletePage?: (pageIndex: number) => void;
|
||||||
|
reorderPage?: (fromIndex: number, toIndex: number) => void;
|
||||||
|
generateBlob?: () => Promise<Blob>;
|
||||||
|
updateRunFormatting?: (updates: Partial<TextRun>) => void;
|
||||||
|
getActiveRun?: () => TextRun | null;
|
||||||
|
addParagraph?: (afterBlockId?: string) => void;
|
||||||
|
insertStamp?: (stampType?: string) => void;
|
||||||
|
insertComment?: (commentText?: string) => void;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DOC: DocumentModel = {
|
||||||
|
id: 'doc-creator-1',
|
||||||
|
title: 'Untitled Document',
|
||||||
|
settings: {
|
||||||
|
paperSize: 'A4',
|
||||||
|
orientation: 'portrait',
|
||||||
|
widthPt: 595.28,
|
||||||
|
heightPt: 841.89,
|
||||||
|
marginTopPt: 54,
|
||||||
|
marginBottomPt: 54,
|
||||||
|
marginLeftPt: 54,
|
||||||
|
marginRightPt: 54,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
enabled: false,
|
||||||
|
centerText: '',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
enabled: true,
|
||||||
|
showPageNumbers: true,
|
||||||
|
},
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'blk-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
styleName: 'Normal',
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
id: 'run-1',
|
||||||
|
text: '',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#0f172a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 0,
|
||||||
|
spaceAfter: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
activeTool = 'select',
|
||||||
|
drawColor = '#2563eb',
|
||||||
|
drawWidth = 3,
|
||||||
|
activeStamp = 'APPROVED',
|
||||||
|
onUpdateDrawColor,
|
||||||
|
onUpdateDrawWidth,
|
||||||
|
onSelectStamp,
|
||||||
|
onClose,
|
||||||
|
onCreatePDF,
|
||||||
|
onPageCountChange,
|
||||||
|
onRegisterActions,
|
||||||
|
}) => {
|
||||||
|
const historyRef = useRef(new HistoryManager(INITIAL_DOC));
|
||||||
|
const [doc, setDoc] = useState<DocumentModel>(INITIAL_DOC);
|
||||||
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>('blk-1');
|
||||||
|
const [activeRunId, setActiveRunId] = useState<string | null>('run-1');
|
||||||
|
const [zoomScale, setZoomScale] = useState<number>(1.0);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
const calculatedPages = React.useMemo(() => {
|
||||||
|
return PaginationEngine.calculatePages(doc);
|
||||||
|
}, [doc]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (onPageCountChange && isOpen) {
|
||||||
|
onPageCountChange(calculatedPages.length, calculatedPages);
|
||||||
|
}
|
||||||
|
}, [calculatedPages, isOpen, onPageCountChange]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const freshDoc: DocumentModel = {
|
||||||
|
...INITIAL_DOC,
|
||||||
|
id: `doc-creator-${Date.now()}`,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'blk-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
styleName: 'Normal',
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
id: 'run-1',
|
||||||
|
text: '',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#0f172a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 0,
|
||||||
|
spaceAfter: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
historyRef.current = new HistoryManager(freshDoc);
|
||||||
|
setDoc(freshDoc);
|
||||||
|
setSelectedBlockId('blk-1');
|
||||||
|
setActiveRunId('run-1');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.querySelector<HTMLDivElement>('[data-block-id="blk-1"]');
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleDeletePage = (pageIndex: number) => {
|
||||||
|
const pages = PaginationEngine.calculatePages(doc);
|
||||||
|
if (pages.length <= 1 || pageIndex < 0 || pageIndex >= pages.length) return;
|
||||||
|
|
||||||
|
const pageToRemove = pages[pageIndex];
|
||||||
|
if (!pageToRemove) return;
|
||||||
|
|
||||||
|
const blockIdsToRemove = new Set(pageToRemove.blocks.map((b) => b.id));
|
||||||
|
let nextBlocks = doc.blocks.filter((b) => !blockIdsToRemove.has(b.id));
|
||||||
|
|
||||||
|
if (nextBlocks.length === 0) {
|
||||||
|
nextBlocks = [
|
||||||
|
{
|
||||||
|
id: `blk-${Math.random().toString(36).substring(2, 8)}`,
|
||||||
|
type: 'paragraph',
|
||||||
|
styleName: 'Normal',
|
||||||
|
runs: [{ id: `run-${Math.random().toString(36).substring(2, 8)}`, text: '', fontFamily: 'Helvetica', fontSize: 12, color: '#0f172a' }],
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 0,
|
||||||
|
spaceAfter: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorderPage = (fromIndex: number, toIndex: number) => {
|
||||||
|
const pages = PaginationEngine.calculatePages(doc);
|
||||||
|
if (fromIndex < 0 || fromIndex >= pages.length || toIndex < 0 || toIndex >= pages.length || fromIndex === toIndex) return;
|
||||||
|
|
||||||
|
const pageBlocksList = pages.map((p) => p.blocks);
|
||||||
|
const temp = pageBlocksList[fromIndex];
|
||||||
|
pageBlocksList[fromIndex] = pageBlocksList[toIndex];
|
||||||
|
pageBlocksList[toIndex] = temp;
|
||||||
|
|
||||||
|
const reorderedBlocks: DocumentBlock[] = [];
|
||||||
|
pageBlocksList.forEach((pBlocks, idx) => {
|
||||||
|
if (idx > 0) {
|
||||||
|
reorderedBlocks.push({
|
||||||
|
id: `pb-${Math.random().toString(36).substring(2, 8)}`,
|
||||||
|
type: 'page-break',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reorderedBlocks.push(...pBlocks);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: reorderedBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (onRegisterActions && isOpen) {
|
||||||
|
onRegisterActions({
|
||||||
|
canUndo: historyRef.current.canUndo(),
|
||||||
|
canRedo: historyRef.current.canRedo(),
|
||||||
|
undo: () => {
|
||||||
|
const prev = historyRef.current.undo();
|
||||||
|
if (prev) setDoc(prev);
|
||||||
|
},
|
||||||
|
redo: () => {
|
||||||
|
const next = historyRef.current.redo();
|
||||||
|
if (next) setDoc(next);
|
||||||
|
},
|
||||||
|
generate: handleGenerateAndOpen,
|
||||||
|
print: handlePrintCreator,
|
||||||
|
deletePage: handleDeletePage,
|
||||||
|
reorderPage: handleReorderPage,
|
||||||
|
generateBlob: async () => PdfDocumentRenderer.generatePdfBlob(doc),
|
||||||
|
updateRunFormatting: handleUpdateRunFormatting,
|
||||||
|
getActiveRun: () => activeRun,
|
||||||
|
addParagraph: handleAddParagraph,
|
||||||
|
insertStamp: handleInsertStamp,
|
||||||
|
insertComment: handleInsertComment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [doc, isOpen, onRegisterActions]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const updateDocumentState = (nextDoc: DocumentModel) => {
|
||||||
|
historyRef.current.push(nextDoc);
|
||||||
|
setDoc(nextDoc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeBlock = doc.blocks.find((b) => b.id === selectedBlockId);
|
||||||
|
const activeParagraph = activeBlock?.type === 'paragraph' || activeBlock?.type === 'heading' ? (activeBlock as ParagraphBlock) : null;
|
||||||
|
const activeRun = activeParagraph?.runs.find((r) => r.id === activeRunId) || activeParagraph?.runs[0] || null;
|
||||||
|
|
||||||
|
const handleUpdateBlockStyle = (styleName: string) => {
|
||||||
|
const styleDef = DEFAULT_STYLES[styleName];
|
||||||
|
if (!styleDef || !selectedBlockId) return;
|
||||||
|
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== selectedBlockId || (b.type !== 'paragraph' && b.type !== 'heading' && b.type !== 'list-item' && b.type !== 'quote')) return b;
|
||||||
|
const p = b as ParagraphBlock;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
styleName: styleName as any,
|
||||||
|
alignment: styleDef.alignment,
|
||||||
|
spaceBefore: styleDef.spaceBefore,
|
||||||
|
spaceAfter: styleDef.spaceAfter,
|
||||||
|
runs: p.runs.map((r) => ({
|
||||||
|
...r,
|
||||||
|
fontFamily: styleDef.fontFamily,
|
||||||
|
fontSize: styleDef.fontSize,
|
||||||
|
bold: styleDef.bold,
|
||||||
|
italic: styleDef.italic,
|
||||||
|
color: styleDef.color,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateRunFormatting = (updates: Partial<TextRun>) => {
|
||||||
|
if (!selectedBlockId) return;
|
||||||
|
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== selectedBlockId || (b.type !== 'paragraph' && b.type !== 'heading' && b.type !== 'list-item' && b.type !== 'quote')) return b;
|
||||||
|
const p = b as ParagraphBlock;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
runs: p.runs.map((r) => (r.id === activeRunId || !activeRunId ? { ...r, ...updates } : r)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateParagraphFormatting = (updates: Partial<ParagraphBlock>) => {
|
||||||
|
if (!selectedBlockId) return;
|
||||||
|
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== selectedBlockId || (b.type !== 'paragraph' && b.type !== 'heading' && b.type !== 'list-item' && b.type !== 'quote')) return b;
|
||||||
|
return { ...b, ...updates };
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateParagraphText = (blockId: string, text: string) => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId) return b;
|
||||||
|
const p = b as ParagraphBlock;
|
||||||
|
const firstRun = p.runs[0] || {
|
||||||
|
id: `run-${Math.random().toString(36).substring(2, 7)}`,
|
||||||
|
text: '',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#0f172a',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
runs: [{ ...firstRun, text }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddParagraph = (afterBlockId?: string) => {
|
||||||
|
const newId = `blk-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newRunId = `run-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
|
||||||
|
const newParagraph: ParagraphBlock = {
|
||||||
|
id: newId,
|
||||||
|
type: 'paragraph',
|
||||||
|
styleName: 'Normal',
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
id: newRunId,
|
||||||
|
text: '',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#0f172a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 0,
|
||||||
|
spaceAfter: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
let nextBlocks = [...doc.blocks];
|
||||||
|
if (afterBlockId) {
|
||||||
|
const idx = nextBlocks.findIndex((b) => b.id === afterBlockId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
nextBlocks.splice(idx + 1, 0, newParagraph);
|
||||||
|
} else {
|
||||||
|
nextBlocks.push(newParagraph);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextBlocks.push(newParagraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
setSelectedBlockId(newId);
|
||||||
|
setActiveRunId(newRunId);
|
||||||
|
|
||||||
|
// Auto-focus the new block's contentEditable so the user can type immediately
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.querySelector<HTMLDivElement>(`[data-block-id="${newId}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
// Move caret to the end
|
||||||
|
const range = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
}
|
||||||
|
}, 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveBlock = (blockId: string) => {
|
||||||
|
if (doc.blocks.length <= 1) return;
|
||||||
|
const nextBlocks = doc.blocks.filter((b) => b.id !== blockId);
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
setSelectedBlockId(nextBlocks[0]?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertTable = (rowsCount = 3, colsCount = 3) => {
|
||||||
|
const tblId = `tbl-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const rows = Array.from({ length: rowsCount }, (_, rIdx) => ({
|
||||||
|
id: `row-${rIdx}`,
|
||||||
|
cells: Array.from({ length: colsCount }, (_, cIdx) => ({
|
||||||
|
id: `cell-${rIdx}-${cIdx}`,
|
||||||
|
runs: [{ id: `crun-${rIdx}-${cIdx}`, text: '', fontSize: 10, color: '#1e293b' }],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tblBlock: TableBlock = {
|
||||||
|
id: tblId,
|
||||||
|
type: 'table',
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDocumentState({ ...doc, blocks: [...doc.blocks, tblBlock] });
|
||||||
|
setSelectedBlockId(tblId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertImage = (file: File) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const src = e.target?.result as string;
|
||||||
|
if (!src) return;
|
||||||
|
const imgId = `img-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const imgBlock: ImageBlock = {
|
||||||
|
id: imgId,
|
||||||
|
type: 'image',
|
||||||
|
src,
|
||||||
|
width: 320,
|
||||||
|
height: 200,
|
||||||
|
alignment: 'center',
|
||||||
|
};
|
||||||
|
updateDocumentState({ ...doc, blocks: [...doc.blocks, imgBlock] });
|
||||||
|
setSelectedBlockId(imgId);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateImageBlock = (blockId: string, updates: Partial<ImageBlock>) => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'image') return b;
|
||||||
|
return { ...b, ...updates };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTableCell = (blockId: string, rIdx: number, cIdx: number, text: string) => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
const tbl = b as TableBlock;
|
||||||
|
const nextRows = tbl.rows.map((row, r) => {
|
||||||
|
if (r !== rIdx) return row;
|
||||||
|
const nextCells = row.cells.map((cell, c) => {
|
||||||
|
if (c !== cIdx) return cell;
|
||||||
|
const firstRun = cell.runs[0] || { id: `crun-${r}-${c}`, text: '', fontSize: 10, color: '#1e293b' };
|
||||||
|
return { ...cell, runs: [{ ...firstRun, text }] };
|
||||||
|
});
|
||||||
|
return { ...row, cells: nextCells };
|
||||||
|
});
|
||||||
|
return { ...tbl, rows: nextRows };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTableRow = (blockId: string, rIdx: number, position: 'above' | 'below') => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
const tbl = b as TableBlock;
|
||||||
|
const colCount = tbl.rows[0]?.cells.length || 3;
|
||||||
|
const targetIdx = position === 'above' ? rIdx : rIdx + 1;
|
||||||
|
const newRow = {
|
||||||
|
id: `row-${Math.random().toString(36).substring(2, 7)}`,
|
||||||
|
cells: Array.from({ length: colCount }, () => ({
|
||||||
|
id: `cell-${Math.random().toString(36).substring(2, 7)}`,
|
||||||
|
runs: [{ id: `crun-${Math.random().toString(36).substring(2, 7)}`, text: '', fontSize: 10, color: '#1e293b' }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const nextRows = [...tbl.rows];
|
||||||
|
nextRows.splice(targetIdx, 0, newRow);
|
||||||
|
return { ...tbl, rows: nextRows };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTableColumn = (blockId: string, cIdx: number, position: 'left' | 'right') => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
const tbl = b as TableBlock;
|
||||||
|
const insertIdx = position === 'left' ? cIdx : cIdx + 1;
|
||||||
|
const nextRows = tbl.rows.map((row) => {
|
||||||
|
const newCell = {
|
||||||
|
id: `cell-${Math.random().toString(36).substring(2, 7)}`,
|
||||||
|
runs: [{ id: `crun-${Math.random().toString(36).substring(2, 7)}`, text: '', fontSize: 10, color: '#1e293b' }],
|
||||||
|
};
|
||||||
|
const nextCells = [...row.cells];
|
||||||
|
nextCells.splice(insertIdx, 0, newCell);
|
||||||
|
return { ...row, cells: nextCells };
|
||||||
|
});
|
||||||
|
return { ...tbl, rows: nextRows };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTableRow = (blockId: string, rIdx: number) => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
const tbl = b as TableBlock;
|
||||||
|
if (tbl.rows.length <= 1) return b;
|
||||||
|
const nextRows = tbl.rows.filter((_, r) => r !== rIdx);
|
||||||
|
return { ...tbl, rows: nextRows };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTableColumn = (blockId: string, cIdx: number) => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
const tbl = b as TableBlock;
|
||||||
|
if (tbl.rows[0]?.cells.length <= 1) return b;
|
||||||
|
const nextRows = tbl.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
cells: row.cells.filter((_, c) => c !== cIdx),
|
||||||
|
}));
|
||||||
|
return { ...tbl, rows: nextRows };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTableCellBackground = (blockId: string, rIdx: number, cIdx: number, color: string) => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
const tbl = b as TableBlock;
|
||||||
|
const nextRows = tbl.rows.map((row, r) => {
|
||||||
|
if (r !== rIdx) return row;
|
||||||
|
const nextCells = row.cells.map((cell, c) => {
|
||||||
|
if (c !== cIdx) return cell;
|
||||||
|
return { ...cell, backgroundColor: color };
|
||||||
|
});
|
||||||
|
return { ...row, cells: nextCells };
|
||||||
|
});
|
||||||
|
return { ...tbl, rows: nextRows };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTableBlock = (blockId: string, updates: Partial<TableBlock>) => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
return { ...b, ...updates };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTableCellAlignment = (blockId: string, rIdx: number, cIdx: number, alignment: 'left' | 'center' | 'right') => {
|
||||||
|
const nextBlocks = doc.blocks.map((b) => {
|
||||||
|
if (b.id !== blockId || b.type !== 'table') return b;
|
||||||
|
const tbl = b as TableBlock;
|
||||||
|
const nextRows = tbl.rows.map((row, r) => {
|
||||||
|
if (r !== rIdx) return row;
|
||||||
|
const nextCells = row.cells.map((cell, c) => {
|
||||||
|
if (c !== cIdx) return cell;
|
||||||
|
return { ...cell, alignment };
|
||||||
|
});
|
||||||
|
return { ...row, cells: nextCells };
|
||||||
|
});
|
||||||
|
return { ...tbl, rows: nextRows };
|
||||||
|
});
|
||||||
|
updateDocumentState({ ...doc, blocks: nextBlocks });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertStamp = (stampType = 'APPROVED') => {
|
||||||
|
const newId = `blk-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newRunId = `run-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const label = stampType.toUpperCase();
|
||||||
|
const stampColor = label === 'APPROVED' ? '#16a34a' : label === 'CONFIDENTIAL' || label === 'REJECTED' ? '#dc2626' : '#d97706';
|
||||||
|
|
||||||
|
const stampParagraph: ParagraphBlock = {
|
||||||
|
id: newId,
|
||||||
|
type: 'paragraph',
|
||||||
|
styleName: 'Quote',
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
id: newRunId,
|
||||||
|
text: ` [ ${label} ] `,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 16,
|
||||||
|
bold: true,
|
||||||
|
color: stampColor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alignment: 'center',
|
||||||
|
spaceBefore: 12,
|
||||||
|
spaceAfter: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDocumentState({
|
||||||
|
...doc,
|
||||||
|
blocks: [...doc.blocks, stampParagraph],
|
||||||
|
});
|
||||||
|
setSelectedBlockId(newId);
|
||||||
|
setActiveRunId(newRunId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertComment = (text = '💬 Comment: Add your note or feedback here...') => {
|
||||||
|
const newId = `blk-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newRunId = `run-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
|
||||||
|
const commentParagraph: ParagraphBlock = {
|
||||||
|
id: newId,
|
||||||
|
type: 'quote',
|
||||||
|
styleName: 'Quote',
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
id: newRunId,
|
||||||
|
text: text,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 11,
|
||||||
|
italic: true,
|
||||||
|
color: '#854d0e',
|
||||||
|
highlightColor: '#fef9c3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 8,
|
||||||
|
spaceAfter: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDocumentState({
|
||||||
|
...doc,
|
||||||
|
blocks: [...doc.blocks, commentParagraph],
|
||||||
|
});
|
||||||
|
setSelectedBlockId(newId);
|
||||||
|
setActiveRunId(newRunId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertLink = (url: string) => {
|
||||||
|
if (!selectedBlockId) return;
|
||||||
|
handleUpdateRunFormatting({ linkUrl: url, color: '#2563eb', underline: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertPageBreak = () => {
|
||||||
|
const pbId = `pb-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newParaId = `blk-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newRunId = `run-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
|
||||||
|
const newParagraph: ParagraphBlock = {
|
||||||
|
id: newParaId,
|
||||||
|
type: 'paragraph',
|
||||||
|
styleName: 'Normal',
|
||||||
|
runs: [{ id: newRunId, text: '', fontFamily: 'Helvetica', fontSize: 12, color: '#0f172a' }],
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 0,
|
||||||
|
spaceAfter: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
setDoc((prevDoc) => {
|
||||||
|
const nextDoc = {
|
||||||
|
...prevDoc,
|
||||||
|
blocks: [...prevDoc.blocks, { id: pbId, type: 'page-break' as const }, newParagraph],
|
||||||
|
};
|
||||||
|
historyRef.current.push(nextDoc);
|
||||||
|
return nextDoc;
|
||||||
|
});
|
||||||
|
setSelectedBlockId(newParaId);
|
||||||
|
setActiveRunId(newRunId);
|
||||||
|
|
||||||
|
// Auto-focus the new paragraph on the fresh page
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.querySelector<HTMLDivElement>(`[data-block-id="${newParaId}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
const range = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormattingShortcut = (type: 'bold' | 'italic' | 'underline' | 'undo' | 'redo' | 'pageBreak') => {
|
||||||
|
if (type === 'undo') {
|
||||||
|
const prev = historyRef.current.undo();
|
||||||
|
if (prev) setDoc(prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'redo') {
|
||||||
|
const next = historyRef.current.redo();
|
||||||
|
if (next) setDoc(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'pageBreak') {
|
||||||
|
handleInsertPageBreak();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeRun) return;
|
||||||
|
if (type === 'bold') handleUpdateRunFormatting({ bold: !activeRun.bold });
|
||||||
|
if (type === 'italic') handleUpdateRunFormatting({ italic: !activeRun.italic });
|
||||||
|
if (type === 'underline') handleUpdateRunFormatting({ underline: !activeRun.underline });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateAndOpen = async () => {
|
||||||
|
try {
|
||||||
|
setIsGenerating(true);
|
||||||
|
const pdfBlob = await PdfDocumentRenderer.generatePdfBlob(doc);
|
||||||
|
const filename = `${doc.title.replace(/[^a-zA-Z0-9_-]/g, '_') || 'Custom_Document'}.pdf`;
|
||||||
|
const file = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||||
|
await onCreatePDF(file);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF Generation failed', err);
|
||||||
|
alert('Failed to generate PDF document.');
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrintCreator = async () => {
|
||||||
|
try {
|
||||||
|
setIsGenerating(true);
|
||||||
|
const pdfBlob = await PdfDocumentRenderer.generatePdfBlob(doc);
|
||||||
|
const url = URL.createObjectURL(pdfBlob);
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.style.display = 'none';
|
||||||
|
iframe.src = url;
|
||||||
|
iframe.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
iframe.contentWindow?.focus();
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(url); document.body.removeChild(iframe); }, 2000);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Print failed', err);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col h-full w-full overflow-hidden bg-bg-canvas relative z-10">
|
||||||
|
{/* Formatting Toolbar */}
|
||||||
|
<DocumentToolbar
|
||||||
|
doc={doc}
|
||||||
|
selectedBlockId={selectedBlockId}
|
||||||
|
activeRun={activeRun}
|
||||||
|
activeTool={activeTool}
|
||||||
|
drawColor={drawColor}
|
||||||
|
drawWidth={drawWidth}
|
||||||
|
activeStamp={activeStamp}
|
||||||
|
onUpdateDrawColor={onUpdateDrawColor}
|
||||||
|
onUpdateDrawWidth={onUpdateDrawWidth}
|
||||||
|
onSelectStamp={onSelectStamp}
|
||||||
|
onInsertStamp={handleInsertStamp}
|
||||||
|
onInsertComment={handleInsertComment}
|
||||||
|
zoomScale={zoomScale}
|
||||||
|
onUpdateZoomScale={setZoomScale}
|
||||||
|
onUpdateBlockStyle={handleUpdateBlockStyle}
|
||||||
|
onUpdateRunFormatting={handleUpdateRunFormatting}
|
||||||
|
onUpdateParagraphFormatting={handleUpdateParagraphFormatting}
|
||||||
|
onInsertTable={handleInsertTable}
|
||||||
|
onInsertImage={handleInsertImage}
|
||||||
|
onInsertLink={handleInsertLink}
|
||||||
|
onInsertPageBreak={handleInsertPageBreak}
|
||||||
|
onAddPage={handleInsertPageBreak}
|
||||||
|
onToggleHeaderFooter={(enabled) =>
|
||||||
|
setDoc({
|
||||||
|
...doc,
|
||||||
|
header: { ...doc.header, enabled },
|
||||||
|
footer: { ...doc.footer, enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onGeneratePDF={handleGenerateAndOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
isGenerating={isGenerating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Document Workspace */}
|
||||||
|
<DocumentEditor
|
||||||
|
doc={doc}
|
||||||
|
activeTool={activeTool}
|
||||||
|
drawColor={drawColor}
|
||||||
|
drawWidth={drawWidth}
|
||||||
|
selectedBlockId={selectedBlockId}
|
||||||
|
activeRunId={activeRunId}
|
||||||
|
zoomScale={zoomScale}
|
||||||
|
onSelectBlock={(bId, rId) => {
|
||||||
|
setSelectedBlockId(bId);
|
||||||
|
if (rId) setActiveRunId(rId);
|
||||||
|
}}
|
||||||
|
onUpdateParagraphText={handleUpdateParagraphText}
|
||||||
|
onAddParagraph={handleAddParagraph}
|
||||||
|
onAddPage={handleInsertPageBreak}
|
||||||
|
onRemoveBlock={handleRemoveBlock}
|
||||||
|
onFormattingShortcut={handleFormattingShortcut}
|
||||||
|
onUpdateImageBlock={handleUpdateImageBlock}
|
||||||
|
onUpdateTableCell={handleUpdateTableCell}
|
||||||
|
onAddTableRow={handleAddTableRow}
|
||||||
|
onAddTableColumn={handleAddTableColumn}
|
||||||
|
onDeleteTableRow={handleDeleteTableRow}
|
||||||
|
onDeleteTableColumn={handleDeleteTableColumn}
|
||||||
|
onUpdateTableCellBackground={handleUpdateTableCellBackground}
|
||||||
|
onUpdateTableBlock={handleUpdateTableBlock}
|
||||||
|
onUpdateTableCellAlignment={handleUpdateTableCellAlignment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,938 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import type { DocumentModel, ParagraphBlock, TableBlock, ImageBlock } from '../types/documentModel';
|
||||||
|
import { PaginationEngine, type PageLayout } from '../model/PaginationEngine';
|
||||||
|
import { getCssFontStack } from '../model/FontRegistry';
|
||||||
|
|
||||||
|
interface DocumentEditorProps {
|
||||||
|
doc: DocumentModel;
|
||||||
|
selectedBlockId: string | null;
|
||||||
|
activeRunId: string | null;
|
||||||
|
zoomScale?: number;
|
||||||
|
onSelectBlock: (blockId: string, runId?: string) => void;
|
||||||
|
onUpdateParagraphText: (blockId: string, text: string) => void;
|
||||||
|
onAddParagraph: (afterBlockId?: string) => void;
|
||||||
|
onAddPage?: () => void;
|
||||||
|
onRemoveBlock: (blockId: string) => void;
|
||||||
|
onFormattingShortcut: (type: 'bold' | 'italic' | 'underline' | 'undo' | 'redo' | 'pageBreak') => void;
|
||||||
|
onUpdateImageBlock?: (blockId: string, updates: Partial<ImageBlock>) => void;
|
||||||
|
onUpdateTableCell?: (blockId: string, rIdx: number, cIdx: number, text: string) => void;
|
||||||
|
onAddTableRow?: (blockId: string, rIdx: number, position: 'above' | 'below') => void;
|
||||||
|
onAddTableColumn?: (blockId: string, cIdx: number, position: 'left' | 'right') => void;
|
||||||
|
onDeleteTableRow?: (blockId: string, rIdx: number) => void;
|
||||||
|
onDeleteTableColumn?: (blockId: string, cIdx: number) => void;
|
||||||
|
onUpdateTableCellBackground?: (blockId: string, rIdx: number, cIdx: number, color: string) => void;
|
||||||
|
onUpdateTableBlock?: (blockId: string, updates: Partial<TableBlock>) => void;
|
||||||
|
onUpdateTableCellAlignment?: (blockId: string, rIdx: number, cIdx: number, alignment: 'left' | 'center' | 'right') => void;
|
||||||
|
activeTool?: string;
|
||||||
|
drawColor?: string;
|
||||||
|
drawWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caret-Preserving Editable Paragraph Block
|
||||||
|
*/
|
||||||
|
const EditableParagraphBlock: React.FC<{
|
||||||
|
block: ParagraphBlock;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelectBlock: (blockId: string, runId?: string) => void;
|
||||||
|
onUpdateParagraphText: (blockId: string, text: string) => void;
|
||||||
|
onAddParagraph: (afterBlockId?: string) => void;
|
||||||
|
onRemoveBlock: (blockId: string) => void;
|
||||||
|
onFormattingShortcut: (type: 'bold' | 'italic' | 'underline' | 'undo' | 'redo' | 'pageBreak') => void;
|
||||||
|
}> = ({
|
||||||
|
block,
|
||||||
|
isSelected,
|
||||||
|
onSelectBlock,
|
||||||
|
onUpdateParagraphText,
|
||||||
|
onAddParagraph,
|
||||||
|
onRemoveBlock,
|
||||||
|
onFormattingShortcut,
|
||||||
|
}) => {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fullText = block.runs.map((r) => r.text).join('');
|
||||||
|
const firstRun = block.runs[0] || {};
|
||||||
|
const fontStack = getCssFontStack(firstRun.fontFamily);
|
||||||
|
|
||||||
|
// Sync state to DOM only when un-focused
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current && document.activeElement !== contentRef.current) {
|
||||||
|
if (contentRef.current.innerText !== fullText) {
|
||||||
|
contentRef.current.innerText = fullText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fullText]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const isMod = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
if (isMod && e.key.toLowerCase() === 'b') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFormattingShortcut('bold');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMod && e.key.toLowerCase() === 'i') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFormattingShortcut('italic');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMod && e.key.toLowerCase() === 'u') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFormattingShortcut('underline');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMod && e.key.toLowerCase() === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFormattingShortcut('undo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMod && e.key.toLowerCase() === 'y') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFormattingShortcut('redo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMod && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onFormattingShortcut('pageBreak');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddParagraph(block.id);
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && (contentRef.current?.innerText || '') === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
onRemoveBlock(block.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onSelectBlock(block.id, firstRun.id)}
|
||||||
|
className={`relative rounded px-2 py-1 transition-all ${isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
textAlign: block.alignment || 'left',
|
||||||
|
marginTop: `${block.spaceBefore || 0}px`,
|
||||||
|
marginBottom: `${block.spaceAfter || 4}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
data-block-id={block.id}
|
||||||
|
onFocus={() => onSelectBlock(block.id, firstRun.id)}
|
||||||
|
onInput={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)}
|
||||||
|
onBlur={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none"
|
||||||
|
data-placeholder="Start typing paragraph text..."
|
||||||
|
style={{
|
||||||
|
fontFamily: fontStack,
|
||||||
|
fontSize: `${firstRun.fontSize || 12}pt`,
|
||||||
|
fontWeight: firstRun.bold ? 'bold' : 'normal',
|
||||||
|
fontStyle: firstRun.italic ? 'italic' : 'normal',
|
||||||
|
textDecoration: [
|
||||||
|
firstRun.underline ? 'underline' : '',
|
||||||
|
firstRun.strikethrough ? 'line-through' : '',
|
||||||
|
].filter(Boolean).join(' ') || 'none',
|
||||||
|
color: firstRun.color || '#0f172a',
|
||||||
|
backgroundColor: firstRun.highlightColor || 'transparent',
|
||||||
|
lineHeight: block.lineSpacing || 1.25,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Delete Paragraph"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveBlock(block.id);
|
||||||
|
}}
|
||||||
|
className="absolute -right-6 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200 text-[10px]"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive Resizable & Alignable Image Component
|
||||||
|
*/
|
||||||
|
const EditableImageBlock: React.FC<{
|
||||||
|
block: ImageBlock;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelectBlock: (blockId: string) => void;
|
||||||
|
onRemoveBlock: (blockId: string) => void;
|
||||||
|
onUpdateImageBlock?: (blockId: string, updates: Partial<ImageBlock>) => void;
|
||||||
|
}> = ({ block, isSelected, onSelectBlock, onRemoveBlock, onUpdateImageBlock }) => {
|
||||||
|
const [, setIsResizing] = useState(false);
|
||||||
|
const startCoords = useRef({ x: 0, y: 0, width: block.width, height: block.height });
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsResizing(true);
|
||||||
|
startCoords.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
width: block.width,
|
||||||
|
height: block.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (me: MouseEvent) => {
|
||||||
|
const deltaX = me.clientX - startCoords.current.x;
|
||||||
|
const newWidth = Math.max(80, Math.min(680, startCoords.current.width + deltaX));
|
||||||
|
const aspectRatio = startCoords.current.height / startCoords.current.width;
|
||||||
|
const newHeight = Math.round(newWidth * aspectRatio);
|
||||||
|
|
||||||
|
if (onUpdateImageBlock) {
|
||||||
|
onUpdateImageBlock(block.id, { width: newWidth, height: newHeight });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const alignClass =
|
||||||
|
block.alignment === 'right'
|
||||||
|
? 'justify-end'
|
||||||
|
: block.alignment === 'left'
|
||||||
|
? 'justify-start'
|
||||||
|
: 'justify-center';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onSelectBlock(block.id)}
|
||||||
|
className={`my-3 flex w-full ${alignClass}`}
|
||||||
|
>
|
||||||
|
<div className="relative inline-flex flex-col items-center group">
|
||||||
|
<div
|
||||||
|
className={`relative inline-block rounded transition-all ${isSelected ? 'ring-2 ring-brand-primary' : 'hover:ring-1 hover:ring-slate-300'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${block.width}px` }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={block.src}
|
||||||
|
alt={block.caption || 'Inserted Image'}
|
||||||
|
style={{ width: `${block.width}px`, height: `${block.height}px` }}
|
||||||
|
className="rounded object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Quick Action Toolbar */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute -top-10 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-md bg-slate-900 px-2 py-1 text-white shadow-lg z-20 text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateImageBlock?.(block.id, { alignment: 'left' });
|
||||||
|
}}
|
||||||
|
className={`rounded px-1.5 py-0.5 hover:bg-slate-700 ${block.alignment === 'left' ? 'bg-slate-700' : ''}`}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateImageBlock?.(block.id, { alignment: 'center' });
|
||||||
|
}}
|
||||||
|
className={`rounded px-1.5 py-0.5 hover:bg-slate-700 ${block.alignment === 'center' || !block.alignment ? 'bg-slate-700' : ''}`}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
Center
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateImageBlock?.(block.id, { alignment: 'right' });
|
||||||
|
}}
|
||||||
|
className={`rounded px-1.5 py-0.5 hover:bg-slate-700 ${block.alignment === 'right' ? 'bg-slate-700' : ''}`}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
Right
|
||||||
|
</button>
|
||||||
|
<div className="h-3 w-[1px] bg-slate-700 mx-0.5" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateImageBlock?.(block.id, { width: 220, height: 140 });
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Small
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateImageBlock?.(block.id, { width: 420, height: 260 });
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Medium
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateImageBlock?.(block.id, { width: 620, height: 380 });
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Large
|
||||||
|
</button>
|
||||||
|
<div className="h-3 w-[1px] bg-slate-700 mx-0.5" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveBlock(block.id);
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-red-600 hover:bg-red-700 text-white font-medium"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag-to-Resize Handle */}
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
className="absolute bottom-0 right-0 h-4 w-4 bg-brand-primary cursor-se-resize rounded-tl shadow border border-white z-20 hover:scale-125 transition-transform"
|
||||||
|
title="Drag to resize"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional Caption */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add image caption..."
|
||||||
|
value={block.caption || ''}
|
||||||
|
onChange={(e) => onUpdateImageBlock?.(block.id, { caption: e.target.value })}
|
||||||
|
className="mt-1 text-center text-[11px] text-slate-500 bg-transparent outline-none border-b border-transparent focus:border-slate-300 w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Caret-Preserving Editable Table Cell
|
||||||
|
*/
|
||||||
|
const EditableTableCellDiv: React.FC<{
|
||||||
|
cellText: string;
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
|
isHeader?: boolean;
|
||||||
|
onUpdateText: (text: string) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
}> = ({ cellText, alignment = 'left', isHeader, onUpdateText, onFocus }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current && document.activeElement !== ref.current) {
|
||||||
|
if (ref.current.innerText !== cellText) {
|
||||||
|
ref.current.innerText = cellText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [cellText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onFocus={onFocus}
|
||||||
|
onInput={(e) => onUpdateText(e.currentTarget.innerText)}
|
||||||
|
onBlur={(e) => onUpdateText(e.currentTarget.innerText)}
|
||||||
|
className={`outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none ${
|
||||||
|
isHeader ? 'font-bold' : ''
|
||||||
|
}`}
|
||||||
|
style={{ textAlign: alignment }}
|
||||||
|
data-placeholder="Cell..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive Table Component with Cell Editing & Structure Manipulation
|
||||||
|
*/
|
||||||
|
const EditableTableBlock: React.FC<{
|
||||||
|
block: TableBlock;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelectBlock: (blockId: string) => void;
|
||||||
|
onRemoveBlock: (blockId: string) => void;
|
||||||
|
onUpdateTableCell?: (blockId: string, rIdx: number, cIdx: number, text: string) => void;
|
||||||
|
onAddTableRow?: (blockId: string, rIdx: number, position: 'above' | 'below') => void;
|
||||||
|
onAddTableColumn?: (blockId: string, cIdx: number, position: 'left' | 'right') => void;
|
||||||
|
onDeleteTableRow?: (blockId: string, rIdx: number) => void;
|
||||||
|
onDeleteTableColumn?: (blockId: string, cIdx: number) => void;
|
||||||
|
onUpdateTableCellBackground?: (blockId: string, rIdx: number, cIdx: number, color: string) => void;
|
||||||
|
onUpdateTableBlock?: (blockId: string, updates: Partial<TableBlock>) => void;
|
||||||
|
onUpdateTableCellAlignment?: (blockId: string, rIdx: number, cIdx: number, alignment: 'left' | 'center' | 'right') => void;
|
||||||
|
}> = ({
|
||||||
|
block,
|
||||||
|
isSelected,
|
||||||
|
onSelectBlock,
|
||||||
|
onRemoveBlock,
|
||||||
|
onUpdateTableCell,
|
||||||
|
onAddTableRow,
|
||||||
|
onAddTableColumn,
|
||||||
|
onDeleteTableRow,
|
||||||
|
onDeleteTableColumn,
|
||||||
|
onUpdateTableCellBackground,
|
||||||
|
onUpdateTableBlock,
|
||||||
|
onUpdateTableCellAlignment,
|
||||||
|
}) => {
|
||||||
|
const [activeCell, setActiveCell] = useState<{ r: number; c: number } | null>(null);
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
{ label: 'Clear', value: 'transparent' },
|
||||||
|
{ label: 'Gray', value: '#f1f5f9' },
|
||||||
|
{ label: 'Blue', value: '#dbeafe' },
|
||||||
|
{ label: 'Green', value: '#dcfce7' },
|
||||||
|
{ label: 'Yellow', value: '#fef9c3' },
|
||||||
|
{ label: 'Purple', value: '#f3e8ff' },
|
||||||
|
{ label: 'Red', value: '#fee2e2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tableAlignClass =
|
||||||
|
block.alignment === 'right'
|
||||||
|
? 'justify-end'
|
||||||
|
: block.alignment === 'center'
|
||||||
|
? 'justify-center'
|
||||||
|
: 'justify-start';
|
||||||
|
|
||||||
|
const themeName = block.themeName || 'default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onSelectBlock(block.id)}
|
||||||
|
className={`my-3 flex w-full ${tableAlignClass}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative rounded p-1 transition-all inline-block max-w-full ${
|
||||||
|
isSelected ? 'ring-2 ring-brand-primary bg-blue-50/20' : 'hover:ring-1 hover:ring-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Advanced Floating Table Action Bar */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-md bg-slate-900 px-3 py-1.5 text-white shadow-lg text-[11px] z-20 select-none">
|
||||||
|
<span className="font-semibold text-slate-300 mr-1">Table:</span>
|
||||||
|
|
||||||
|
{/* Header Row Toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateTableBlock?.(block.id, { hasHeaderRow: !block.hasHeaderRow });
|
||||||
|
}}
|
||||||
|
className={`rounded px-2 py-0.5 border ${
|
||||||
|
block.hasHeaderRow ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Header Row
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Striped Rows Toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateTableBlock?.(block.id, { stripedRows: !block.stripedRows });
|
||||||
|
}}
|
||||||
|
className={`rounded px-2 py-0.5 border ${
|
||||||
|
block.stripedRows ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Striped Rows
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||||
|
|
||||||
|
{/* Theme Preset Selector */}
|
||||||
|
<span className="text-slate-400">Theme:</span>
|
||||||
|
<select
|
||||||
|
value={themeName}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateTableBlock?.(block.id, { themeName: e.target.value as any });
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="bg-slate-800 text-white rounded px-1.5 py-0.5 outline-none border border-slate-700 text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="modern">Modern Navy</option>
|
||||||
|
<option value="minimal">Minimalist</option>
|
||||||
|
<option value="bordered">Grid Bordered</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||||
|
|
||||||
|
{/* Row / Col Manipulations */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddTableRow?.(block.id, activeCell?.r ?? 0, 'above');
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
+ Row Above
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddTableRow?.(block.id, activeCell?.r ?? block.rows.length - 1, 'below');
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
+ Row Below
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddTableColumn?.(block.id, activeCell?.c ?? 0, 'left');
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
+ Col Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddTableColumn?.(block.id, activeCell?.c ?? (block.rows[0]?.cells.length || 1) - 1, 'right');
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
+ Col Right
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeCell && (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||||
|
|
||||||
|
{/* Cell Alignment */}
|
||||||
|
<span className="text-slate-400">Cell Align:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'left');
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||||
|
title="Align Cell Left"
|
||||||
|
>
|
||||||
|
Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'center');
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||||
|
title="Align Cell Center"
|
||||||
|
>
|
||||||
|
Center
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'right');
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||||
|
title="Align Cell Right"
|
||||||
|
>
|
||||||
|
Right
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteTableRow?.(block.id, activeCell.r);
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200 ml-1"
|
||||||
|
>
|
||||||
|
- Row
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteTableColumn?.(block.id, activeCell.c);
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200"
|
||||||
|
>
|
||||||
|
- Col
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||||
|
|
||||||
|
<span className="text-slate-400">Fill:</span>
|
||||||
|
{colors.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.label}
|
||||||
|
type="button"
|
||||||
|
title={`Fill Cell ${c.label}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (activeCell) {
|
||||||
|
onUpdateTableCellBackground?.(block.id, activeCell.r, activeCell.c, c.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 rounded border border-slate-600 hover:scale-110 transition-transform"
|
||||||
|
style={{ backgroundColor: c.value === 'transparent' ? '#ffffff' : c.value }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveBlock(block.id);
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-0.5 bg-red-600 hover:bg-red-700 text-white font-medium"
|
||||||
|
>
|
||||||
|
Delete Table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table
|
||||||
|
className={`w-full border-collapse bg-white ${
|
||||||
|
themeName === 'minimal'
|
||||||
|
? 'border-b border-slate-200'
|
||||||
|
: themeName === 'bordered'
|
||||||
|
? 'border-2 border-slate-400'
|
||||||
|
: 'border border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row, rIdx) => {
|
||||||
|
const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader;
|
||||||
|
const isStriped = block.stripedRows && rIdx % 2 === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={
|
||||||
|
isHeaderRow
|
||||||
|
? themeName === 'modern'
|
||||||
|
? 'bg-slate-900 text-white'
|
||||||
|
: 'bg-slate-100 font-bold'
|
||||||
|
: isStriped
|
||||||
|
? 'bg-slate-50/80'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.cells.map((cell, cIdx) => {
|
||||||
|
const cellText = cell.runs.map((r) => r.text).join('');
|
||||||
|
const isCellFocused = activeCell?.r === rIdx && activeCell?.c === cIdx;
|
||||||
|
|
||||||
|
const cellBg =
|
||||||
|
cell.backgroundColor ||
|
||||||
|
(isHeaderRow
|
||||||
|
? themeName === 'modern'
|
||||||
|
? '#0f172a'
|
||||||
|
: '#f1f5f9'
|
||||||
|
: isStriped
|
||||||
|
? '#f8fafc'
|
||||||
|
: 'transparent');
|
||||||
|
|
||||||
|
const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
onClick={() => setActiveCell({ r: rIdx, c: cIdx })}
|
||||||
|
className={`p-2.5 text-[12px] relative transition-colors ${
|
||||||
|
themeName === 'minimal'
|
||||||
|
? 'border-b border-slate-200'
|
||||||
|
: themeName === 'bordered'
|
||||||
|
? 'border border-slate-400'
|
||||||
|
: 'border border-slate-300'
|
||||||
|
} ${isCellFocused ? 'ring-2 ring-brand-primary z-10' : ''}`}
|
||||||
|
style={{ backgroundColor: cellBg, color: cellTextColor }}
|
||||||
|
>
|
||||||
|
<EditableTableCellDiv
|
||||||
|
cellText={cellText}
|
||||||
|
alignment={cell.alignment}
|
||||||
|
isHeader={isHeaderRow}
|
||||||
|
onUpdateText={(text) => onUpdateTableCell?.(block.id, rIdx, cIdx, text)}
|
||||||
|
onFocus={() => setActiveCell({ r: rIdx, c: cIdx })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||||
|
doc,
|
||||||
|
selectedBlockId,
|
||||||
|
onSelectBlock,
|
||||||
|
onUpdateParagraphText,
|
||||||
|
onAddParagraph,
|
||||||
|
onRemoveBlock,
|
||||||
|
onFormattingShortcut,
|
||||||
|
onUpdateImageBlock,
|
||||||
|
onUpdateTableCell,
|
||||||
|
onAddTableRow,
|
||||||
|
onAddTableColumn,
|
||||||
|
onDeleteTableRow,
|
||||||
|
onDeleteTableColumn,
|
||||||
|
onUpdateTableCellBackground,
|
||||||
|
onUpdateTableBlock,
|
||||||
|
onUpdateTableCellAlignment,
|
||||||
|
activeTool = 'select',
|
||||||
|
drawColor = '#2563eb',
|
||||||
|
drawWidth = 3,
|
||||||
|
zoomScale = 1.0,
|
||||||
|
}) => {
|
||||||
|
const pages: PageLayout[] = React.useMemo(() => PaginationEngine.calculatePages(doc), [doc]);
|
||||||
|
const PT_TO_PX = 96 / 72;
|
||||||
|
|
||||||
|
const [pageStrokes, setPageStrokes] = useState<Record<number, Array<{ id: string; d: string; color: string; width: number }>>>({});
|
||||||
|
const [activeDrawing, setActiveDrawing] = useState<{ pageIdx: number; points: Array<{ x: number; y: number }> } | null>(null);
|
||||||
|
|
||||||
|
const isDrawTool = activeTool === 'draw';
|
||||||
|
|
||||||
|
const pageW = Math.round(doc.settings.widthPt * PT_TO_PX * zoomScale);
|
||||||
|
const pageH = Math.round(doc.settings.heightPt * PT_TO_PX * zoomScale);
|
||||||
|
const padTop = Math.round(doc.settings.marginTopPt * PT_TO_PX * zoomScale);
|
||||||
|
const padBottom = Math.round(doc.settings.marginBottomPt * PT_TO_PX * zoomScale);
|
||||||
|
const padLeft = Math.round(doc.settings.marginLeftPt * PT_TO_PX * zoomScale);
|
||||||
|
const padRight = Math.round(doc.settings.marginRightPt * PT_TO_PX * zoomScale);
|
||||||
|
|
||||||
|
const handleMouseDownPage = (e: React.MouseEvent<HTMLDivElement>, pageIdx: number) => {
|
||||||
|
if (!isDrawTool) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
setActiveDrawing({ pageIdx, points: [{ x, y }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMovePage = (e: React.MouseEvent<HTMLDivElement>, pageIdx: number) => {
|
||||||
|
if (!isDrawTool || !activeDrawing || activeDrawing.pageIdx !== pageIdx) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
setActiveDrawing((prev) => (prev ? { ...prev, points: [...prev.points, { x, y }] } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUpPage = () => {
|
||||||
|
if (!isDrawTool || !activeDrawing || activeDrawing.points.length < 2) {
|
||||||
|
setActiveDrawing(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageIdx, points } = activeDrawing;
|
||||||
|
const pathD = points.reduce((acc, pt, i) => (i === 0 ? `M ${pt.x} ${pt.y}` : `${acc} L ${pt.x} ${pt.y}`), '');
|
||||||
|
const newStroke = { id: `stroke-${Date.now()}-${Math.random()}`, d: pathD, color: drawColor, width: drawWidth };
|
||||||
|
|
||||||
|
setPageStrokes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pageIdx]: [...(prev[pageIdx] || []), newStroke],
|
||||||
|
}));
|
||||||
|
setActiveDrawing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseUp={handleMouseUpPage}
|
||||||
|
className="flex-1 overflow-auto bg-slate-200/75 py-8 flex flex-col items-center gap-8 outline-none select-text"
|
||||||
|
>
|
||||||
|
{pages.map((page, pageIdx) => {
|
||||||
|
const { header, footer, title } = doc;
|
||||||
|
const lastBlockOnPage = page.blocks[page.blocks.length - 1];
|
||||||
|
|
||||||
|
// Determine which block to insert after when clicking empty space on this page
|
||||||
|
let targetAfterBlockId: string | undefined = lastBlockOnPage?.id;
|
||||||
|
if (!targetAfterBlockId && pageIdx > 0) {
|
||||||
|
const prevPage = pages[pageIdx - 1];
|
||||||
|
if (prevPage && prevPage.blocks.length > 0) {
|
||||||
|
targetAfterBlockId = prevPage.blocks[prevPage.blocks.length - 1].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const strokes = pageStrokes[pageIdx] || [];
|
||||||
|
const activePoints = activeDrawing?.pageIdx === pageIdx ? activeDrawing.points : [];
|
||||||
|
const activeD = activePoints.reduce((acc, pt, i) => (i === 0 ? `M ${pt.x} ${pt.y}` : `${acc} L ${pt.x} ${pt.y}`), '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`page-${page.pageNumber}`}
|
||||||
|
onMouseDown={(e) => handleMouseDownPage(e, pageIdx)}
|
||||||
|
onMouseMove={(e) => handleMouseMovePage(e, pageIdx)}
|
||||||
|
className={`relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col ${
|
||||||
|
isDrawTool ? 'cursor-crosshair' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${pageW}px`,
|
||||||
|
minHeight: `${pageH}px`,
|
||||||
|
paddingTop: `${padTop}px`,
|
||||||
|
paddingBottom: `${padBottom}px`,
|
||||||
|
paddingLeft: `${padLeft}px`,
|
||||||
|
paddingRight: `${padRight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Ink Drawing Overlay */}
|
||||||
|
<svg className="absolute inset-0 z-20 pointer-events-none w-full h-full">
|
||||||
|
{strokes.map((s) => (
|
||||||
|
<path key={s.id} d={s.d} stroke={s.color} strokeWidth={s.width} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
))}
|
||||||
|
{activeD && (
|
||||||
|
<path d={activeD} stroke={drawColor} strokeWidth={drawWidth} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{/* Header Region */}
|
||||||
|
{header.enabled && (
|
||||||
|
<div
|
||||||
|
className="absolute top-4 left-0 right-0 flex justify-between px-12 text-[11px] text-slate-400 border-b border-dashed border-slate-200 pb-1 select-none"
|
||||||
|
style={{ left: `${padLeft}px`, right: `${padRight}px` }}
|
||||||
|
>
|
||||||
|
<span>{header.leftText || ''}</span>
|
||||||
|
<span className="font-medium">{header.centerText || title || 'Document Title'}</span>
|
||||||
|
<span>{header.rightText || ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page Content Blocks */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 flex-1 overflow-hidden"
|
||||||
|
onClick={(e) => {
|
||||||
|
// Only trigger if the click landed directly on this container (the empty area below blocks)
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onAddParagraph(targetAfterBlockId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page.blocks.map((block) => {
|
||||||
|
const isSelected = selectedBlockId === block.id;
|
||||||
|
|
||||||
|
if (block.type === 'table') {
|
||||||
|
return (
|
||||||
|
<EditableTableBlock
|
||||||
|
key={block.id}
|
||||||
|
block={block as TableBlock}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelectBlock={onSelectBlock}
|
||||||
|
onRemoveBlock={onRemoveBlock}
|
||||||
|
onUpdateTableCell={onUpdateTableCell}
|
||||||
|
onAddTableRow={onAddTableRow}
|
||||||
|
onAddTableColumn={onAddTableColumn}
|
||||||
|
onDeleteTableRow={onDeleteTableRow}
|
||||||
|
onDeleteTableColumn={onDeleteTableColumn}
|
||||||
|
onUpdateTableCellBackground={onUpdateTableCellBackground}
|
||||||
|
onUpdateTableBlock={onUpdateTableBlock}
|
||||||
|
onUpdateTableCellAlignment={onUpdateTableCellAlignment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'image') {
|
||||||
|
return (
|
||||||
|
<EditableImageBlock
|
||||||
|
key={block.id}
|
||||||
|
block={block as ImageBlock}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelectBlock={onSelectBlock}
|
||||||
|
onRemoveBlock={onRemoveBlock}
|
||||||
|
onUpdateImageBlock={onUpdateImageBlock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph / Heading / List-item / Quote
|
||||||
|
return (
|
||||||
|
<EditableParagraphBlock
|
||||||
|
key={block.id}
|
||||||
|
block={block as ParagraphBlock}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelectBlock={onSelectBlock}
|
||||||
|
onUpdateParagraphText={onUpdateParagraphText}
|
||||||
|
onAddParagraph={onAddParagraph}
|
||||||
|
onRemoveBlock={onRemoveBlock}
|
||||||
|
onFormattingShortcut={onFormattingShortcut}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Clickable empty area at the bottom – clicking here adds a new paragraph to THIS page */}
|
||||||
|
<div
|
||||||
|
className="flex-1 cursor-text min-h-[80px] group"
|
||||||
|
onClick={() => onAddParagraph(targetAfterBlockId)}
|
||||||
|
>
|
||||||
|
{page.blocks.length === 0 && (
|
||||||
|
<p className="text-slate-300 text-sm select-none mt-4 ml-1 pointer-events-none">
|
||||||
|
Click anywhere to start writing…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Region */}
|
||||||
|
{footer.enabled && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-0 right-0 flex justify-between px-12 text-[11px] text-slate-400 border-t border-dashed border-slate-200 pt-1 select-none"
|
||||||
|
style={{ left: `${padLeft}px`, right: `${padRight}px` }}
|
||||||
|
>
|
||||||
|
<span>{footer.leftText || ''}</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
Page {page.pageNumber} of {pages.length}
|
||||||
|
</span>
|
||||||
|
<span>{footer.rightText || ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Bottom Floating Add Page Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFormattingShortcut('pageBreak')}
|
||||||
|
className="my-4 flex items-center gap-2 rounded-full border border-blue-300 bg-white px-6 py-2.5 text-sm font-semibold text-blue-600 shadow-md hover:bg-blue-50 hover:shadow-lg hover:scale-105 transition-all select-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add New Blank Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { DocumentModel, ParagraphBlock, TextRun } from '../types/documentModel';
|
||||||
|
import { AVAILABLE_FONTS } from '../model/FontRegistry';
|
||||||
|
import { DEFAULT_STYLES } from '../types/documentModel';
|
||||||
|
import { STAMP_PRESETS } from '../../../lib/tools';
|
||||||
|
|
||||||
|
interface DocumentToolbarProps {
|
||||||
|
doc: DocumentModel;
|
||||||
|
selectedBlockId: string | null;
|
||||||
|
activeRun: TextRun | null;
|
||||||
|
activeTool?: string;
|
||||||
|
drawColor?: string;
|
||||||
|
drawWidth?: number;
|
||||||
|
activeStamp?: string;
|
||||||
|
onUpdateDrawColor?: (color: string) => void;
|
||||||
|
onUpdateDrawWidth?: (width: number) => void;
|
||||||
|
onSelectStamp?: (stamp: string) => void;
|
||||||
|
onInsertStamp?: (stampType?: string) => void;
|
||||||
|
onInsertComment?: (commentText?: string) => void;
|
||||||
|
onGeneratePDF?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
isGenerating?: boolean;
|
||||||
|
zoomScale?: number;
|
||||||
|
onUpdateZoomScale?: (scale: number) => void;
|
||||||
|
onUpdateBlockStyle: (styleName: string) => void;
|
||||||
|
onUpdateRunFormatting: (updates: Partial<TextRun>) => void;
|
||||||
|
onUpdateParagraphFormatting: (updates: Partial<ParagraphBlock>) => void;
|
||||||
|
onInsertTable: (rows: number, cols: number) => void;
|
||||||
|
onInsertImage: (file: File) => void;
|
||||||
|
onInsertLink: (url: string) => void;
|
||||||
|
onInsertPageBreak: () => void;
|
||||||
|
onAddPage?: () => void;
|
||||||
|
onToggleHeaderFooter: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 64, 72];
|
||||||
|
|
||||||
|
export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||||
|
selectedBlockId,
|
||||||
|
activeRun,
|
||||||
|
activeTool = 'select',
|
||||||
|
drawColor = '#2563eb',
|
||||||
|
drawWidth = 3,
|
||||||
|
activeStamp = 'APPROVED',
|
||||||
|
onUpdateDrawColor,
|
||||||
|
onUpdateDrawWidth,
|
||||||
|
onSelectStamp,
|
||||||
|
onInsertStamp,
|
||||||
|
onInsertComment,
|
||||||
|
zoomScale = 1.0,
|
||||||
|
onUpdateZoomScale,
|
||||||
|
onUpdateBlockStyle,
|
||||||
|
onUpdateRunFormatting,
|
||||||
|
onUpdateParagraphFormatting,
|
||||||
|
onInsertTable,
|
||||||
|
onInsertImage,
|
||||||
|
onInsertLink,
|
||||||
|
onInsertPageBreak,
|
||||||
|
onAddPage,
|
||||||
|
onToggleHeaderFooter,
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onInsertImage(file);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkPrompt = () => {
|
||||||
|
const url = prompt('Enter Web Link URL (e.g. https://example.com):');
|
||||||
|
if (url) onInsertLink(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-border-primary bg-bg-secondary px-4 py-2 text-[13px] select-none">
|
||||||
|
{/* Contextual Tool Options Bar for Draw / Stamp / Comment */}
|
||||||
|
{activeTool === 'draw' && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-blue-50/80 border border-blue-200 px-3 py-1 text-[12px] shadow-2xs">
|
||||||
|
<span className="font-bold text-blue-700 flex items-center gap-1">
|
||||||
|
✏️ Ink Pen Color:
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{['#2563eb', '#dc2626', '#0f172a', '#16a34a', '#ca8a04', '#9333ea'].map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdateDrawColor?.(c)}
|
||||||
|
className={`h-5 w-5 rounded-full border border-white shadow-2xs transition-transform ${
|
||||||
|
drawColor === c ? 'scale-125 ring-2 ring-blue-600' : 'hover:scale-110'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<label className="flex h-5 w-5 cursor-pointer items-center justify-center rounded-full border border-slate-300 bg-white" title="Custom Pen Color">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="sr-only"
|
||||||
|
value={drawColor}
|
||||||
|
onChange={(e) => onUpdateDrawColor?.(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px]">🎨</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-blue-200 mx-1" />
|
||||||
|
|
||||||
|
<span className="font-bold text-blue-700">Stroke:</span>
|
||||||
|
<select
|
||||||
|
className="h-6.5 rounded border border-blue-300 bg-white px-1.5 text-[11px] font-semibold text-blue-900 outline-none"
|
||||||
|
value={drawWidth}
|
||||||
|
onChange={(e) => onUpdateDrawWidth?.(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>1px Fine</option>
|
||||||
|
<option value={2}>2px Normal</option>
|
||||||
|
<option value={3}>3px Medium</option>
|
||||||
|
<option value={5}>5px Thick</option>
|
||||||
|
<option value={8}>8px Bold</option>
|
||||||
|
<option value={12}>12px Heavy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTool === 'stamp' && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-emerald-50/80 border border-emerald-200 px-3 py-1 text-[12px] shadow-2xs">
|
||||||
|
<span className="font-bold text-emerald-800 flex items-center gap-1">
|
||||||
|
🏵️ Choose Stamp:
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto py-0.5">
|
||||||
|
{STAMP_PRESETS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectStamp?.(s.label);
|
||||||
|
onInsertStamp?.(s.label);
|
||||||
|
}}
|
||||||
|
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-all hover:scale-105 active:scale-95 shadow-2xs cursor-pointer ${
|
||||||
|
activeStamp === s.label ? 'ring-2 ring-emerald-600 ring-offset-1' : ''
|
||||||
|
}`}
|
||||||
|
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}
|
||||||
|
title={`Click to insert ${s.label} stamp`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTool === 'comment' && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-amber-50/80 border border-amber-200 px-3 py-1 text-[12px] shadow-2xs">
|
||||||
|
<span className="font-bold text-amber-800 flex items-center gap-1">
|
||||||
|
💬 Comment Note:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInsertComment?.()}
|
||||||
|
className="h-6.5 rounded-md bg-amber-600 px-2.5 text-[11px] font-bold text-white shadow-xs hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Sticky Comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Styles Dropdown */}
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2.5 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary"
|
||||||
|
onChange={(e) => onUpdateBlockStyle(e.target.value)}
|
||||||
|
defaultValue="Normal"
|
||||||
|
>
|
||||||
|
{Object.keys(DEFAULT_STYLES).map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||||
|
|
||||||
|
{/* Font Family */}
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2.5 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary"
|
||||||
|
value={activeRun?.fontFamily || 'Helvetica'}
|
||||||
|
onChange={(e) => onUpdateRunFormatting({ fontFamily: e.target.value })}
|
||||||
|
>
|
||||||
|
{AVAILABLE_FONTS.map((f) => (
|
||||||
|
<option key={f} value={f}>
|
||||||
|
{f}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Font Size */}
|
||||||
|
<select
|
||||||
|
className="h-8 w-16 rounded-md border border-border-primary bg-bg-primary px-2 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary"
|
||||||
|
value={activeRun?.fontSize || 12}
|
||||||
|
onChange={(e) => onUpdateRunFormatting({ fontSize: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
{FONT_SIZES.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||||
|
|
||||||
|
{/* Bold / Italic / Underline / Strikethrough */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
onClick={() => onUpdateRunFormatting({ bold: !activeRun?.bold })}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-md font-bold transition-colors ${
|
||||||
|
activeRun?.bold ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
onClick={() => onUpdateRunFormatting({ italic: !activeRun?.italic })}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-md italic font-serif transition-colors ${
|
||||||
|
activeRun?.italic ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Underline (Ctrl+U)"
|
||||||
|
onClick={() => onUpdateRunFormatting({ underline: !activeRun?.underline })}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-md underline transition-colors ${
|
||||||
|
activeRun?.underline ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Strikethrough"
|
||||||
|
onClick={() => onUpdateRunFormatting({ strikethrough: !activeRun?.strikethrough })}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-md line-through transition-colors ${
|
||||||
|
activeRun?.strikethrough ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||||
|
|
||||||
|
{/* Text Color Picker */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-border-primary bg-bg-primary hover:bg-bg-tertiary" title="Text Color">
|
||||||
|
<span className="text-[12px] font-bold" style={{ color: activeRun?.color || '#0f172a' }}>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="sr-only"
|
||||||
|
value={activeRun?.color || '#0f172a'}
|
||||||
|
onChange={(e) => onUpdateRunFormatting({ color: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlight Color */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-border-primary bg-bg-primary hover:bg-bg-tertiary" title="Highlight Color">
|
||||||
|
<div className="h-4 w-4 rounded-sm border border-slate-300" style={{ backgroundColor: activeRun?.highlightColor || '#ffff00' }} />
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="sr-only"
|
||||||
|
value={activeRun?.highlightColor || '#ffff00'}
|
||||||
|
onChange={(e) => onUpdateRunFormatting({ highlightColor: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||||
|
|
||||||
|
{/* Alignment */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Align Left"
|
||||||
|
onClick={() => onUpdateParagraphFormatting({ alignment: 'left' })}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<line x1="17" y1="10" x2="3" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="17" y1="18" x2="3" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Align Center"
|
||||||
|
onClick={() => onUpdateParagraphFormatting({ alignment: 'center' })}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<line x1="18" y1="10" x2="6" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="18" y1="18" x2="6" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Align Right"
|
||||||
|
onClick={() => onUpdateParagraphFormatting({ alignment: 'right' })}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<line x1="21" y1="10" x2="7" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="21" y1="18" x2="7" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||||
|
|
||||||
|
{/* Bullet / Numbered Lists */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Bullet List"
|
||||||
|
onClick={() => onUpdateParagraphFormatting({ listType: 'bullet' })}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Numbered List"
|
||||||
|
onClick={() => onUpdateParagraphFormatting({ listType: 'number' })}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<line x1="10" y1="6" x2="21" y2="6" /><line x1="10" y1="12" x2="21" y2="12" /><line x1="10" y1="18" x2="21" y2="18" />
|
||||||
|
<path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||||
|
|
||||||
|
{/* Insert Items */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Insert Table"
|
||||||
|
onClick={() => onInsertTable(3, 3)}
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-md border border-border-primary bg-bg-primary px-2 font-medium text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" /><path d="M15 3v18" />
|
||||||
|
</svg>
|
||||||
|
Table
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Insert Image"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-md border border-border-primary bg-bg-primary px-2 font-medium text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
Image
|
||||||
|
</button>
|
||||||
|
<input type="file" ref={fileInputRef} accept="image/*" className="hidden" onChange={handleImageFileChange} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Insert Hyperlink"
|
||||||
|
onClick={handleLinkPrompt}
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-md border border-border-primary bg-bg-primary px-2 font-medium text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Add New Blank Page"
|
||||||
|
onClick={onAddPage || onInsertPageBreak}
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-md border border-blue-200 bg-blue-50 px-2.5 font-semibold text-blue-600 hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add Page
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Insert Page Break (Ctrl+Enter)"
|
||||||
|
onClick={onInsertPageBreak}
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-md border border-border-primary bg-bg-primary px-2 font-medium text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="4" y1="12" x2="20" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Page Break
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Toggle Header & Footer"
|
||||||
|
onClick={() => onToggleHeaderFooter(!selectedBlockId)}
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-md border border-border-primary bg-bg-primary px-2 font-medium text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /><line x1="3" y1="8" x2="21" y2="8" /><line x1="3" y1="16" x2="21" y2="16" />
|
||||||
|
</svg>
|
||||||
|
Header / Footer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||||
|
|
||||||
|
{/* Zoom Scale Dropdown */}
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary"
|
||||||
|
value={zoomScale}
|
||||||
|
onChange={(e) => onUpdateZoomScale?.(Number(e.target.value))}
|
||||||
|
title="Page Zoom Scale"
|
||||||
|
>
|
||||||
|
<option value={0.75}>75%</option>
|
||||||
|
<option value={1.0}>100% (Full A4)</option>
|
||||||
|
<option value={1.25}>125%</option>
|
||||||
|
<option value={1.5}>150%</option>
|
||||||
|
<option value={2.0}>200%</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export interface FontMapping {
|
||||||
|
name: string;
|
||||||
|
pdfFont: 'helvetica' | 'times' | 'courier';
|
||||||
|
cssStack: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FONT_REGISTRY: Record<string, FontMapping> = {
|
||||||
|
Helvetica: {
|
||||||
|
name: 'Helvetica',
|
||||||
|
pdfFont: 'helvetica',
|
||||||
|
cssStack: 'Helvetica, Arial, sans-serif',
|
||||||
|
},
|
||||||
|
Arial: {
|
||||||
|
name: 'Arial',
|
||||||
|
pdfFont: 'helvetica',
|
||||||
|
cssStack: 'Arial, Helvetica, sans-serif',
|
||||||
|
},
|
||||||
|
'Times New Roman': {
|
||||||
|
name: 'Times New Roman',
|
||||||
|
pdfFont: 'times',
|
||||||
|
cssStack: '"Times New Roman", Times, serif',
|
||||||
|
},
|
||||||
|
Times: {
|
||||||
|
name: 'Times',
|
||||||
|
pdfFont: 'times',
|
||||||
|
cssStack: 'Times, "Times New Roman", serif',
|
||||||
|
},
|
||||||
|
Courier: {
|
||||||
|
name: 'Courier',
|
||||||
|
pdfFont: 'courier',
|
||||||
|
cssStack: 'Courier, "Courier New", monospace',
|
||||||
|
},
|
||||||
|
Georgia: {
|
||||||
|
name: 'Georgia',
|
||||||
|
pdfFont: 'times',
|
||||||
|
cssStack: 'Georgia, serif',
|
||||||
|
},
|
||||||
|
Verdana: {
|
||||||
|
name: 'Verdana',
|
||||||
|
pdfFont: 'helvetica',
|
||||||
|
cssStack: 'Verdana, Geneva, sans-serif',
|
||||||
|
},
|
||||||
|
'Trebuchet MS': {
|
||||||
|
name: 'Trebuchet MS',
|
||||||
|
pdfFont: 'helvetica',
|
||||||
|
cssStack: '"Trebuchet MS", sans-serif',
|
||||||
|
},
|
||||||
|
Impact: {
|
||||||
|
name: 'Impact',
|
||||||
|
pdfFont: 'helvetica',
|
||||||
|
cssStack: 'Impact, Charcoal, sans-serif',
|
||||||
|
},
|
||||||
|
Garamond: {
|
||||||
|
name: 'Garamond',
|
||||||
|
pdfFont: 'times',
|
||||||
|
cssStack: 'Garamond, Georgia, serif',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AVAILABLE_FONTS = Object.keys(FONT_REGISTRY);
|
||||||
|
|
||||||
|
export function getPdfFont(fontFamily?: string): 'helvetica' | 'times' | 'courier' {
|
||||||
|
if (!fontFamily) return 'helvetica';
|
||||||
|
const entry = FONT_REGISTRY[fontFamily];
|
||||||
|
return entry ? entry.pdfFont : 'helvetica';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCssFontStack(fontFamily?: string): string {
|
||||||
|
if (!fontFamily) return 'Helvetica, Arial, sans-serif';
|
||||||
|
const entry = FONT_REGISTRY[fontFamily];
|
||||||
|
return entry ? entry.cssStack : 'Helvetica, Arial, sans-serif';
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { DocumentModel } from '../types/documentModel';
|
||||||
|
|
||||||
|
export class HistoryManager {
|
||||||
|
private past: DocumentModel[] = [];
|
||||||
|
private future: DocumentModel[] = [];
|
||||||
|
private current: DocumentModel;
|
||||||
|
|
||||||
|
constructor(initialState: DocumentModel) {
|
||||||
|
this.current = JSON.parse(JSON.stringify(initialState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public push(state: DocumentModel): void {
|
||||||
|
this.past.push(JSON.parse(JSON.stringify(this.current)));
|
||||||
|
this.current = JSON.parse(JSON.stringify(state));
|
||||||
|
this.future = []; // Clear redo stack on new action
|
||||||
|
}
|
||||||
|
|
||||||
|
public undo(): DocumentModel | null {
|
||||||
|
if (this.past.length === 0) return null;
|
||||||
|
const previous = this.past.pop()!;
|
||||||
|
this.future.unshift(JSON.parse(JSON.stringify(this.current)));
|
||||||
|
this.current = previous;
|
||||||
|
return JSON.parse(JSON.stringify(this.current));
|
||||||
|
}
|
||||||
|
|
||||||
|
public redo(): DocumentModel | null {
|
||||||
|
if (this.future.length === 0) return null;
|
||||||
|
const next = this.future.shift()!;
|
||||||
|
this.past.push(JSON.parse(JSON.stringify(this.current)));
|
||||||
|
this.current = next;
|
||||||
|
return JSON.parse(JSON.stringify(this.current));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCurrent(): DocumentModel {
|
||||||
|
return JSON.parse(JSON.stringify(this.current));
|
||||||
|
}
|
||||||
|
|
||||||
|
public canUndo(): boolean {
|
||||||
|
return this.past.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public canRedo(): boolean {
|
||||||
|
return this.future.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { DocumentBlock, DocumentModel, PageSettings } from '../types/documentModel';
|
||||||
|
|
||||||
|
export interface PageLayout {
|
||||||
|
pageNumber: number;
|
||||||
|
blocks: DocumentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard Screen DPI conversion factor: 96 DPI (screen px) / 72 DPI (PDF pt) = 1.3333.
|
||||||
|
* Converting PDF points to screen pixels ensures the page card renders at full 100% A4 size
|
||||||
|
* (794px x 1123px on screen) rather than a small 72 DPI box.
|
||||||
|
*/
|
||||||
|
export const PT_TO_PX = 96 / 72;
|
||||||
|
|
||||||
|
export class PaginationEngine {
|
||||||
|
public static calculatePages(doc: DocumentModel): PageLayout[] {
|
||||||
|
const { settings, blocks } = doc;
|
||||||
|
const pages: PageLayout[] = [];
|
||||||
|
|
||||||
|
// Usable height per page in screen pixels (96 DPI)
|
||||||
|
const headerFooterHeightPt = (doc.header.enabled ? 36 : 0) + (doc.footer.enabled ? 36 : 0);
|
||||||
|
const usableHeightPx = (settings.heightPt - settings.marginTopPt - settings.marginBottomPt - headerFooterHeightPt) * PT_TO_PX;
|
||||||
|
|
||||||
|
let currentPageBlocks: DocumentBlock[] = [];
|
||||||
|
let currentHeightPx = 0;
|
||||||
|
let pageNum = 1;
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'page-break') {
|
||||||
|
// Explicit manual page break — always start a new page
|
||||||
|
pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks });
|
||||||
|
currentPageBlocks = [];
|
||||||
|
currentHeightPx = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockHeightPx = this.estimateBlockHeight(block, settings);
|
||||||
|
|
||||||
|
if (currentHeightPx + blockHeightPx > usableHeightPx && currentPageBlocks.length > 0) {
|
||||||
|
// Page overflow -> push current page and start next page
|
||||||
|
pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks });
|
||||||
|
currentPageBlocks = [block];
|
||||||
|
currentHeightPx = blockHeightPx;
|
||||||
|
} else {
|
||||||
|
currentPageBlocks.push(block);
|
||||||
|
currentHeightPx += blockHeightPx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPageBlocks.length > 0 || pages.length === 0) {
|
||||||
|
pages.push({ pageNumber: pageNum, blocks: currentPageBlocks });
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static estimateBlockHeight(block: DocumentBlock, settings: PageSettings): number {
|
||||||
|
const usableWidthPx = (settings.widthPt - settings.marginLeftPt - settings.marginRightPt) * PT_TO_PX;
|
||||||
|
|
||||||
|
if (block.type === 'table') {
|
||||||
|
const rowCount = block.rows.length;
|
||||||
|
return Math.max(50, rowCount * (30 * PT_TO_PX) + (12 * PT_TO_PX));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'image') {
|
||||||
|
return Math.min(block.height + (20 * PT_TO_PX), 500 * PT_TO_PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list-item' || block.type === 'quote') {
|
||||||
|
const fullText = block.runs.map((r) => r.text).join('');
|
||||||
|
if (!fullText) return 24 * PT_TO_PX; // Empty paragraph height
|
||||||
|
|
||||||
|
const fontSizePt = block.runs[0]?.fontSize || 12;
|
||||||
|
const fontSizePx = fontSizePt * PT_TO_PX;
|
||||||
|
const lineSpacing = block.lineSpacing || 1.15;
|
||||||
|
const avgCharWidthPx = fontSizePx * 0.55;
|
||||||
|
const charsPerLine = Math.max(1, Math.floor(usableWidthPx / avgCharWidthPx));
|
||||||
|
|
||||||
|
const lineCount = Math.ceil(fullText.length / charsPerLine);
|
||||||
|
const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX;
|
||||||
|
const spaceAfterPx = (block.spaceAfter || 6) * PT_TO_PX;
|
||||||
|
|
||||||
|
return Math.max(20 * PT_TO_PX, lineCount * (fontSizePx * lineSpacing) + spaceBeforePx + spaceAfterPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 24 * PT_TO_PX;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { jsPDF } from 'jspdf';
|
||||||
|
import type { DocumentModel, ParagraphBlock, TableBlock, ImageBlock } from '../types/documentModel';
|
||||||
|
import { getPdfFont } from '../model/FontRegistry';
|
||||||
|
|
||||||
|
export class PdfDocumentRenderer {
|
||||||
|
public static async generatePdfBlob(doc: DocumentModel): Promise<Blob> {
|
||||||
|
const { settings, header, footer, blocks, title } = doc;
|
||||||
|
|
||||||
|
const isLandscape = settings.orientation === 'landscape';
|
||||||
|
const pdf = new jsPDF({
|
||||||
|
orientation: isLandscape ? 'landscape' : 'portrait',
|
||||||
|
unit: 'pt',
|
||||||
|
format: [settings.widthPt, settings.heightPt],
|
||||||
|
});
|
||||||
|
|
||||||
|
const marginLeft = settings.marginLeftPt;
|
||||||
|
const marginTop = settings.marginTopPt;
|
||||||
|
const marginRight = settings.marginRightPt;
|
||||||
|
const marginBottom = settings.marginBottomPt;
|
||||||
|
const usableWidth = settings.widthPt - marginLeft - marginRight;
|
||||||
|
const headerFooterSpace = (header.enabled ? 36 : 0) + (footer.enabled ? 36 : 0);
|
||||||
|
const usableHeight = settings.heightPt - marginTop - marginBottom - headerFooterSpace;
|
||||||
|
|
||||||
|
let currentY = marginTop + (header.enabled ? 36 : 0);
|
||||||
|
let currentPageNum = 1;
|
||||||
|
|
||||||
|
const addNewPage = () => {
|
||||||
|
pdf.addPage([settings.widthPt, settings.heightPt], isLandscape ? 'landscape' : 'portrait');
|
||||||
|
currentPageNum++;
|
||||||
|
currentY = marginTop + (header.enabled ? 36 : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let bIdx = 0; bIdx < blocks.length; bIdx++) {
|
||||||
|
const block = blocks[bIdx];
|
||||||
|
|
||||||
|
if (block.type === 'page-break') {
|
||||||
|
addNewPage();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'image') {
|
||||||
|
const imgBlock = block as ImageBlock;
|
||||||
|
if (currentY + imgBlock.height > marginTop + usableHeight) {
|
||||||
|
addNewPage();
|
||||||
|
}
|
||||||
|
let xPos = marginLeft;
|
||||||
|
if (imgBlock.alignment === 'center') {
|
||||||
|
xPos = marginLeft + (usableWidth - imgBlock.width) / 2;
|
||||||
|
} else if (imgBlock.alignment === 'right') {
|
||||||
|
xPos = marginLeft + usableWidth - imgBlock.width;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pdf.addImage(imgBlock.src, 'PNG', xPos, currentY, imgBlock.width, imgBlock.height);
|
||||||
|
} catch {
|
||||||
|
// Fallback if image format requires alternative
|
||||||
|
}
|
||||||
|
currentY += imgBlock.height + 14;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'table') {
|
||||||
|
const tblBlock = block as TableBlock;
|
||||||
|
const rowHeight = 24;
|
||||||
|
const tableHeight = tblBlock.rows.length * rowHeight;
|
||||||
|
|
||||||
|
if (currentY + tableHeight > marginTop + usableHeight) {
|
||||||
|
addNewPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const colCount = tblBlock.rows[0]?.cells.length || 1;
|
||||||
|
const colWidth = usableWidth / colCount;
|
||||||
|
|
||||||
|
tblBlock.rows.forEach((row) => {
|
||||||
|
row.cells.forEach((cell, cIdx) => {
|
||||||
|
const cellX = marginLeft + cIdx * colWidth;
|
||||||
|
const cellY = currentY;
|
||||||
|
|
||||||
|
if (cell.backgroundColor) {
|
||||||
|
pdf.setFillColor(cell.backgroundColor);
|
||||||
|
pdf.rect(cellX, cellY, colWidth, rowHeight, 'F');
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.setDrawColor(200, 200, 200);
|
||||||
|
pdf.rect(cellX, cellY, colWidth, rowHeight, 'S');
|
||||||
|
|
||||||
|
const cellText = cell.runs.map((r) => r.text).join('') || '';
|
||||||
|
if (cellText) {
|
||||||
|
pdf.setFont(getPdfFont(cell.runs[0]?.fontFamily), 'normal');
|
||||||
|
pdf.setFontSize(cell.runs[0]?.fontSize || 10);
|
||||||
|
pdf.setTextColor(cell.runs[0]?.color || '#000000');
|
||||||
|
pdf.text(cellText, cellX + 6, cellY + 16, { maxWidth: colWidth - 12 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentY += rowHeight;
|
||||||
|
});
|
||||||
|
currentY += 14;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph / Heading / List-item / Quote
|
||||||
|
const pBlock = block as ParagraphBlock;
|
||||||
|
const fullText = pBlock.runs.map((r) => r.text).join('');
|
||||||
|
if (!fullText.trim()) {
|
||||||
|
currentY += (pBlock.spaceAfter || 6) + 12;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontName = getPdfFont(pBlock.runs[0]?.fontFamily);
|
||||||
|
const isBold = pBlock.runs.some((r) => r.bold);
|
||||||
|
const isItalic = pBlock.runs.some((r) => r.italic);
|
||||||
|
const styleStr = isBold && isItalic ? 'bolditalic' : isBold ? 'bold' : isItalic ? 'italic' : 'normal';
|
||||||
|
|
||||||
|
const fontSize = pBlock.runs[0]?.fontSize || (pBlock.type === 'heading' ? 20 : 12);
|
||||||
|
pdf.setFont(fontName, styleStr);
|
||||||
|
pdf.setFontSize(fontSize);
|
||||||
|
|
||||||
|
const mainColor = pBlock.runs[0]?.color || '#0f172a';
|
||||||
|
pdf.setTextColor(mainColor);
|
||||||
|
|
||||||
|
let textToRender = fullText;
|
||||||
|
let indentOffset = 0;
|
||||||
|
if (pBlock.listType === 'bullet') {
|
||||||
|
textToRender = `• ${fullText}`;
|
||||||
|
indentOffset = 14;
|
||||||
|
} else if (pBlock.listType === 'number') {
|
||||||
|
textToRender = `1. ${fullText}`;
|
||||||
|
indentOffset = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineSpacing = pBlock.lineSpacing || 1.15;
|
||||||
|
const lines = pdf.splitTextToSize(textToRender, usableWidth - indentOffset);
|
||||||
|
const blockHeight = lines.length * (fontSize * lineSpacing);
|
||||||
|
|
||||||
|
if (currentY + blockHeight > marginTop + usableHeight) {
|
||||||
|
addNewPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += pBlock.spaceBefore || 0;
|
||||||
|
|
||||||
|
let alignOption: 'left' | 'center' | 'right' | 'justify' = pBlock.alignment || 'left';
|
||||||
|
let xPos = marginLeft + indentOffset;
|
||||||
|
if (alignOption === 'center') {
|
||||||
|
xPos = marginLeft + usableWidth / 2;
|
||||||
|
} else if (alignOption === 'right') {
|
||||||
|
xPos = marginLeft + usableWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render text runs / lines
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
pdf.text(line, xPos, currentY + fontSize, { align: alignOption });
|
||||||
|
currentY += fontSize * lineSpacing;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hyperlinks handling
|
||||||
|
pBlock.runs.forEach((run) => {
|
||||||
|
if (run.linkUrl) {
|
||||||
|
pdf.textWithLink(run.text, xPos, currentY, { url: run.linkUrl });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentY += pBlock.spaceAfter || 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Header & Footer with Page Numbers across all pages
|
||||||
|
const totalPages = pdf.getNumberOfPages();
|
||||||
|
for (let pageI = 1; pageI <= totalPages; pageI++) {
|
||||||
|
pdf.setPage(pageI);
|
||||||
|
pdf.setFont('helvetica', 'normal');
|
||||||
|
pdf.setFontSize(9);
|
||||||
|
pdf.setTextColor('#64748b');
|
||||||
|
|
||||||
|
if (header.enabled) {
|
||||||
|
const headerY = marginTop - 18;
|
||||||
|
if (header.leftText) pdf.text(header.leftText, marginLeft, headerY, { align: 'left' });
|
||||||
|
if (header.centerText || title) pdf.text(header.centerText || title, settings.widthPt / 2, headerY, { align: 'center' });
|
||||||
|
if (header.rightText) pdf.text(header.rightText, settings.widthPt - marginRight, headerY, { align: 'right' });
|
||||||
|
pdf.setDrawColor(226, 232, 240);
|
||||||
|
pdf.line(marginLeft, headerY + 8, settings.widthPt - marginRight, headerY + 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer.enabled) {
|
||||||
|
const footerY = settings.heightPt - marginBottom + 20;
|
||||||
|
pdf.setDrawColor(226, 232, 240);
|
||||||
|
pdf.line(marginLeft, footerY - 12, settings.widthPt - marginRight, footerY - 12);
|
||||||
|
if (footer.leftText) pdf.text(footer.leftText, marginLeft, footerY, { align: 'left' });
|
||||||
|
|
||||||
|
const pageStr = `Page ${pageI} of ${totalPages}`;
|
||||||
|
pdf.text(pageStr, settings.widthPt / 2, footerY, { align: 'center' });
|
||||||
|
|
||||||
|
if (footer.rightText) pdf.text(footer.rightText, settings.widthPt - marginRight, footerY, { align: 'right' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdf.output('blob');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
export interface TextRun {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strikethrough?: boolean;
|
||||||
|
color?: string;
|
||||||
|
highlightColor?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlockType = 'paragraph' | 'heading' | 'list-item' | 'table' | 'image' | 'quote' | 'page-break';
|
||||||
|
|
||||||
|
export interface ParagraphBlock {
|
||||||
|
id: string;
|
||||||
|
type: 'paragraph' | 'heading' | 'list-item' | 'quote';
|
||||||
|
styleName?: 'Normal' | 'Title' | 'Subtitle' | 'Heading 1' | 'Heading 2' | 'Heading 3' | 'Quote' | 'Caption' | 'Code';
|
||||||
|
headingLevel?: 1 | 2 | 3 | 4;
|
||||||
|
runs: TextRun[];
|
||||||
|
alignment?: 'left' | 'center' | 'right' | 'justify';
|
||||||
|
lineSpacing?: number; // 1.0, 1.15, 1.5, 2.0
|
||||||
|
spaceBefore?: number;
|
||||||
|
spaceAfter?: number;
|
||||||
|
indentLeft?: number;
|
||||||
|
listType?: 'bullet' | 'number';
|
||||||
|
listLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableCell {
|
||||||
|
id: string;
|
||||||
|
runs: TextRun[];
|
||||||
|
backgroundColor?: string;
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
|
colSpan?: number;
|
||||||
|
rowSpan?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableRow {
|
||||||
|
id: string;
|
||||||
|
cells: TableCell[];
|
||||||
|
isHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableBlock {
|
||||||
|
id: string;
|
||||||
|
type: 'table';
|
||||||
|
rows: TableRow[];
|
||||||
|
colWidths?: number[];
|
||||||
|
borderWidth?: number;
|
||||||
|
borderColor?: string;
|
||||||
|
hasHeaderRow?: boolean;
|
||||||
|
stripedRows?: boolean;
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
|
themeName?: 'default' | 'modern' | 'minimal' | 'bordered';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageBlock {
|
||||||
|
id: string;
|
||||||
|
type: 'image';
|
||||||
|
src: string; // Data URL or Image URL
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageBreakBlock {
|
||||||
|
id: string;
|
||||||
|
type: 'page-break';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocumentBlock = ParagraphBlock | TableBlock | ImageBlock | PageBreakBlock;
|
||||||
|
|
||||||
|
export interface HeaderFooterContent {
|
||||||
|
enabled: boolean;
|
||||||
|
leftText?: string;
|
||||||
|
centerText?: string;
|
||||||
|
rightText?: string;
|
||||||
|
showPageNumbers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageSettings {
|
||||||
|
paperSize: 'A4' | 'Letter' | 'Legal' | 'A3';
|
||||||
|
orientation: 'portrait' | 'landscape';
|
||||||
|
widthPt: number;
|
||||||
|
heightPt: number;
|
||||||
|
marginTopPt: number;
|
||||||
|
marginBottomPt: number;
|
||||||
|
marginLeftPt: number;
|
||||||
|
marginRightPt: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentModel {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
settings: PageSettings;
|
||||||
|
header: HeaderFooterContent;
|
||||||
|
footer: HeaderFooterContent;
|
||||||
|
blocks: DocumentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyleDefinition {
|
||||||
|
name: string;
|
||||||
|
fontFamily: string;
|
||||||
|
fontSize: number;
|
||||||
|
bold: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
color: string;
|
||||||
|
alignment: 'left' | 'center' | 'right' | 'justify';
|
||||||
|
spaceBefore: number;
|
||||||
|
spaceAfter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_STYLES: Record<string, StyleDefinition> = {
|
||||||
|
Normal: {
|
||||||
|
name: 'Normal',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 12,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
color: '#0f172a',
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 0,
|
||||||
|
spaceAfter: 6,
|
||||||
|
},
|
||||||
|
Title: {
|
||||||
|
name: 'Title',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 28,
|
||||||
|
bold: true,
|
||||||
|
italic: false,
|
||||||
|
color: '#0f172a',
|
||||||
|
alignment: 'center',
|
||||||
|
spaceBefore: 12,
|
||||||
|
spaceAfter: 12,
|
||||||
|
},
|
||||||
|
Subtitle: {
|
||||||
|
name: 'Subtitle',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 18,
|
||||||
|
bold: false,
|
||||||
|
italic: true,
|
||||||
|
color: '#475569',
|
||||||
|
alignment: 'center',
|
||||||
|
spaceBefore: 0,
|
||||||
|
spaceAfter: 12,
|
||||||
|
},
|
||||||
|
'Heading 1': {
|
||||||
|
name: 'Heading 1',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 22,
|
||||||
|
bold: true,
|
||||||
|
italic: false,
|
||||||
|
color: '#1e293b',
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 12,
|
||||||
|
spaceAfter: 8,
|
||||||
|
},
|
||||||
|
'Heading 2': {
|
||||||
|
name: 'Heading 2',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 17,
|
||||||
|
bold: true,
|
||||||
|
italic: false,
|
||||||
|
color: '#334155',
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 10,
|
||||||
|
spaceAfter: 6,
|
||||||
|
},
|
||||||
|
'Heading 3': {
|
||||||
|
name: 'Heading 3',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 14,
|
||||||
|
bold: true,
|
||||||
|
italic: false,
|
||||||
|
color: '#475569',
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 8,
|
||||||
|
spaceAfter: 4,
|
||||||
|
},
|
||||||
|
Quote: {
|
||||||
|
name: 'Quote',
|
||||||
|
fontFamily: 'Georgia',
|
||||||
|
fontSize: 13,
|
||||||
|
bold: false,
|
||||||
|
italic: true,
|
||||||
|
color: '#475569',
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 10,
|
||||||
|
spaceAfter: 10,
|
||||||
|
},
|
||||||
|
Code: {
|
||||||
|
name: 'Code',
|
||||||
|
fontFamily: 'Courier',
|
||||||
|
fontSize: 11,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
color: '#0f172a',
|
||||||
|
alignment: 'left',
|
||||||
|
spaceBefore: 6,
|
||||||
|
spaceAfter: 6,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,7 +13,9 @@ export type ToolId =
|
|||||||
| 'underline'
|
| 'underline'
|
||||||
| 'strikeout'
|
| 'strikeout'
|
||||||
| 'squiggly'
|
| 'squiggly'
|
||||||
| 'stream_edit';
|
| 'stream_edit'
|
||||||
|
| 'whiteboard'
|
||||||
|
| 'create_pdf';
|
||||||
|
|
||||||
export interface ToolSettings {
|
export interface ToolSettings {
|
||||||
highlightColor: string;
|
highlightColor: string;
|
||||||
@@ -44,6 +46,7 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
|||||||
h: 'pan',
|
h: 'pan',
|
||||||
k: 'highlight',
|
k: 'highlight',
|
||||||
d: 'draw',
|
d: 'draw',
|
||||||
|
b: 'whiteboard',
|
||||||
c: 'comment',
|
c: 'comment',
|
||||||
t: 'textbox',
|
t: 'textbox',
|
||||||
e: 'edit_text',
|
e: 'edit_text',
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { BoardCanvas } from "./components/BoardCanvas";
|
||||||
|
import { PageIndicator } from "./components/PageIndicator";
|
||||||
|
import { PageSidebar } from "./components/PageSidebar";
|
||||||
|
import { SelectionBar } from "./components/SelectionBar";
|
||||||
|
import { Toolbar } from "./components/Toolbar";
|
||||||
|
import { insertImageFile } from "./persistence/insertImage";
|
||||||
|
import { createNotebook, listNotebooks } from "./persistence/notebooks";
|
||||||
|
import { loadToolPrefs, startPrefsSync } from "./persistence/prefs";
|
||||||
|
import { flushViewStateSave, openNotebook, readLastNotebookId } from "./persistence/session";
|
||||||
|
import { exportPagePng } from "./persistence/exportImage";
|
||||||
|
import { exportNotebookPdf } from "./persistence/exportPdf";
|
||||||
|
import { useBoardStore } from "./store/useBoardStore";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
let initStarted = false;
|
||||||
|
|
||||||
|
interface WhiteboardTab {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhiteboardView() {
|
||||||
|
const notebookId = useBoardStore((state) => state.notebookId);
|
||||||
|
const presentation = useBoardStore((state) => state.presentation);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [tabs, setTabs] = useState<WhiteboardTab[]>([
|
||||||
|
{ id: 'tab-1', title: 'Whiteboard 1' }
|
||||||
|
]);
|
||||||
|
const [activeTabId, setActiveTabId] = useState('tab-1');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const viewPageIndex = useBoardStore((state) => state.viewPageIndex);
|
||||||
|
const currentPageId = useBoardStore((state) => state.pages[state.viewPageIndex]?.id);
|
||||||
|
|
||||||
|
const {
|
||||||
|
addPage,
|
||||||
|
clearPage,
|
||||||
|
} = useBoardStore.getState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initStarted) {
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initStarted = true;
|
||||||
|
startPrefsSync();
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
useBoardStore.setState(loadToolPrefs());
|
||||||
|
const notebooks = await listNotebooks();
|
||||||
|
if (notebooks.length === 0) {
|
||||||
|
const meta = await createNotebook("Whiteboard 1");
|
||||||
|
await openNotebook(meta.id);
|
||||||
|
} else {
|
||||||
|
const last = notebooks.find((n) => n.id === readLastNotebookId());
|
||||||
|
if (last) await openNotebook(last.id);
|
||||||
|
else await openNotebook(notebooks[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize whiteboard storage", error);
|
||||||
|
} finally {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
const typing =
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
if (state.presentation) {
|
||||||
|
state.setPresentation(false);
|
||||||
|
} else if (!typing && state.selection) {
|
||||||
|
state.setSelection(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typing) return;
|
||||||
|
if (event.key === "Delete") {
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
if (state.selection) {
|
||||||
|
event.preventDefault();
|
||||||
|
state.deleteSelection();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.metaKey || event.ctrlKey)) return;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
if (key === "z" && event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
state.redo();
|
||||||
|
} else if (key === "z") {
|
||||||
|
event.preventDefault();
|
||||||
|
state.undo();
|
||||||
|
} else if (key === "y") {
|
||||||
|
event.preventDefault();
|
||||||
|
state.redo();
|
||||||
|
} else if (key === "x" && state.selection) {
|
||||||
|
event.preventDefault();
|
||||||
|
state.cutSelection();
|
||||||
|
} else if (key === "c" && state.selection) {
|
||||||
|
event.preventDefault();
|
||||||
|
state.copySelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPaste = (event: ClipboardEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
if (!state.notebookId) return;
|
||||||
|
const file = [...(event.clipboardData?.files ?? [])].find((f) => f.type.startsWith("image/"));
|
||||||
|
if (file) {
|
||||||
|
event.preventDefault();
|
||||||
|
void insertImageFile(file).catch((error: unknown) => {
|
||||||
|
console.error("Failed to paste image", error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.clipboard.strokes.length > 0 || state.clipboard.images.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
state.pasteClipboard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("paste", onPaste);
|
||||||
|
return () => window.removeEventListener("paste", onPaste);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPageHide = () => void flushViewStateSave();
|
||||||
|
window.addEventListener("pagehide", onPageHide);
|
||||||
|
return () => window.removeEventListener("pagehide", onPageHide);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddTab = () => {
|
||||||
|
const newId = `tab-${Date.now()}`;
|
||||||
|
const newTitle = `Whiteboard ${tabs.length + 1}`;
|
||||||
|
setTabs((prev) => [...prev, { id: newId, title: newTitle }]);
|
||||||
|
setActiveTabId(newId);
|
||||||
|
addPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseTab = (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (tabs.length <= 1) return;
|
||||||
|
const nextTabs = tabs.filter((t) => t.id !== id);
|
||||||
|
setTabs(nextTabs);
|
||||||
|
if (activeTabId === id) {
|
||||||
|
setActiveTabId(nextTabs[nextTabs.length - 1].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPng = async () => {
|
||||||
|
const page = useBoardStore.getState().pages[viewPageIndex];
|
||||||
|
if (!page) return;
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportPagePng("Whiteboard", viewPageIndex, page);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to export PNG", err);
|
||||||
|
alert("Failed to export PNG");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPdf = async () => {
|
||||||
|
const { notebookTitle, pages } = useBoardStore.getState();
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportNotebookPdf(notebookTitle || "Whiteboard", pages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to export PDF", err);
|
||||||
|
alert("Failed to export PDF");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready) return <div className="flex h-full w-full items-center justify-center bg-bg-primary text-text-tertiary">Loading Whiteboard...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8fafc] dark:bg-[#0f172a]">
|
||||||
|
{/* Top Whiteboard Tabs Bar */}
|
||||||
|
<div className="flex h-10 shrink-0 items-center border-b border-border-primary bg-bg-secondary px-3">
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTabId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTabId(tab.id)}
|
||||||
|
className={`group flex h-7 cursor-pointer items-center gap-2 rounded-t-lg border-t border-x px-3 text-xs font-semibold transition-all ${isActive
|
||||||
|
? "border-border-primary bg-bg-primary text-text-primary shadow-xs"
|
||||||
|
: "border-transparent text-text-tertiary hover:bg-bg-tertiary hover:text-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{tab.title}</span>
|
||||||
|
{tabs.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleCloseTab(tab.id, e)}
|
||||||
|
className="flex h-4 w-4 items-center justify-center rounded-full text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddTab}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary"
|
||||||
|
title="New Whiteboard Tab"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportPng}
|
||||||
|
disabled={exporting}
|
||||||
|
className="flex h-7 items-center gap-1 rounded-md border border-border-primary bg-bg-primary px-2 text-[11px] font-semibold text-text-secondary hover:bg-bg-tertiary hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Export PNG
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
disabled={exporting}
|
||||||
|
className="flex h-7 items-center gap-1 rounded-md bg-brand-primary px-2.5 text-[11px] font-semibold text-white hover:bg-brand-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Canvas Workspace */}
|
||||||
|
<div className="relative flex flex-1 overflow-hidden">
|
||||||
|
{notebookId && (
|
||||||
|
<>
|
||||||
|
<BoardCanvas />
|
||||||
|
{!presentation && <PageSidebar />}
|
||||||
|
{presentation ? null : <Toolbar />}
|
||||||
|
{!presentation && <SelectionBar />}
|
||||||
|
{!presentation && <PageIndicator />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom Left Canvas Actions */}
|
||||||
|
<div className="absolute bottom-4 left-4 z-20 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPage}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-border-primary bg-bg-primary/95 px-3 py-2 text-xs font-semibold text-text-primary shadow-lg backdrop-blur-md transition-all hover:bg-bg-tertiary"
|
||||||
|
>
|
||||||
|
<span className="text-base font-bold">+</span>
|
||||||
|
<span>Add page</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentPageId && window.confirm("Clear board?")) {
|
||||||
|
clearPage(currentPageId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-border-primary bg-bg-primary/95 px-3 py-2 text-xs font-semibold text-red-600 shadow-lg backdrop-blur-md transition-all hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
<span>Clear board</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Board } from "../engine/board";
|
||||||
|
import type { ToolKind } from "../model/stroke";
|
||||||
|
import { startAutosave } from "../persistence/autosave";
|
||||||
|
import { scheduleViewStateSave } from "../persistence/session";
|
||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
|
||||||
|
function cursorForTool(tool: ToolKind): string {
|
||||||
|
if (tool === "eraser") return "cell";
|
||||||
|
if (tool === "laser") return "none";
|
||||||
|
if (tool === "select") return "default";
|
||||||
|
return "crosshair";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardCanvas() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sidebarOpen = useBoardStore((state) => state.sidebarOpen);
|
||||||
|
const presentation = useBoardStore((state) => state.presentation);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const board = new Board(container, {
|
||||||
|
getTool: () => {
|
||||||
|
const { tool, color, size } = useBoardStore.getState();
|
||||||
|
return { tool, color, size };
|
||||||
|
},
|
||||||
|
onCommitStroke: (pageId, stroke) => useBoardStore.getState().addStroke(pageId, stroke),
|
||||||
|
onEraseStroke: (pageId, strokeId) => useBoardStore.getState().removeStroke(pageId, strokeId),
|
||||||
|
onViewChange: (index) => {
|
||||||
|
if (useBoardStore.getState().viewPageIndex !== index) {
|
||||||
|
useBoardStore.getState().setViewPageIndex(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelectionChange: (selection) => useBoardStore.getState().setSelection(selection),
|
||||||
|
onSelectionAnchor: (anchor) => useBoardStore.getState().setSelectionAnchor(anchor),
|
||||||
|
onTransformSelection: (before, after) =>
|
||||||
|
useBoardStore.getState().transformSelection(before, after),
|
||||||
|
onViewportChange: (viewState) => {
|
||||||
|
const { notebookId } = useBoardStore.getState();
|
||||||
|
if (notebookId) scheduleViewStateSave(notebookId, viewState);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
board.syncPages(useBoardStore.getState().pages);
|
||||||
|
const viewState = useBoardStore.getState().viewState;
|
||||||
|
if (viewState) board.restoreViewState(viewState);
|
||||||
|
container.style.cursor = cursorForTool(useBoardStore.getState().tool);
|
||||||
|
const stopAutosave = startAutosave();
|
||||||
|
const unsubscribe = useBoardStore.subscribe((state, prev) => {
|
||||||
|
if (state.pages !== prev.pages) board.syncPages(state.pages);
|
||||||
|
if (state.selection !== prev.selection) board.syncSelection(state.selection);
|
||||||
|
if (state.tool !== prev.tool) {
|
||||||
|
container.style.cursor = cursorForTool(state.tool);
|
||||||
|
if (state.tool !== "select" && state.selection) {
|
||||||
|
useBoardStore.getState().setSelection(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.pendingScrollToPage !== null &&
|
||||||
|
state.pendingScrollToPage !== prev.pendingScrollToPage
|
||||||
|
) {
|
||||||
|
board.scrollToPage(state.pendingScrollToPage);
|
||||||
|
useBoardStore.getState().clearPendingScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stopAutosave();
|
||||||
|
unsubscribe();
|
||||||
|
board.destroy();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shifted = sidebarOpen && !presentation;
|
||||||
|
return <div ref={containerRef} className={shifted ? "board board-shifted" : "board"} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { type CSSProperties, useEffect, useState } from "react";
|
||||||
|
import { normalizeHex } from "../model/color";
|
||||||
|
|
||||||
|
export function ColorField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="color-field">
|
||||||
|
<label
|
||||||
|
className="color-picker"
|
||||||
|
style={{ "--value": value } as CSSProperties}
|
||||||
|
title="Custom color"
|
||||||
|
>
|
||||||
|
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<HexInput value={value} onCommit={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HexInput({ value, onCommit }: { value: string; onCommit: (color: string) => void }) {
|
||||||
|
const [text, setText] = useState(value);
|
||||||
|
useEffect(() => setText(value), [value]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const normalized = normalizeHex(text);
|
||||||
|
if (normalized && normalized !== value) onCommit(normalized);
|
||||||
|
setText(normalized ?? value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="hex-input"
|
||||||
|
value={text}
|
||||||
|
aria-label="Hex color value"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { NotebookRecord } from "../persistence/db";
|
||||||
|
import { importPdfFile } from "../persistence/importPdf";
|
||||||
|
import {
|
||||||
|
createNotebook,
|
||||||
|
deleteNotebook,
|
||||||
|
listNotebooks,
|
||||||
|
mergeNotebooks,
|
||||||
|
renameNotebook,
|
||||||
|
} from "../persistence/notebooks";
|
||||||
|
import { downloadNotebook, importNotebookFile } from "../persistence/transfer";
|
||||||
|
import { formatRelativeTime } from "./formatTime";
|
||||||
|
import { ExportIcon, RenameIcon, TrashIcon } from "./Icons";
|
||||||
|
|
||||||
|
interface HomeProps {
|
||||||
|
onOpen: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Home({ onOpen }: HomeProps) {
|
||||||
|
const [notebooks, setNotebooks] = useState<NotebookRecord[] | null>(null);
|
||||||
|
const [pdfProgress, setPdfProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await listNotebooks();
|
||||||
|
setNotebooks(list);
|
||||||
|
setSelected((prev) => prev.filter((id) => list.some((n) => n.id === id)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load notebooks", error);
|
||||||
|
window.alert("Failed to load notebooks. Local storage may be unavailable.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const createAndOpen = async () => {
|
||||||
|
try {
|
||||||
|
const meta = await createNotebook(`Notebook ${(notebooks?.length ?? 0) + 1}`);
|
||||||
|
onOpen(meta.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create notebook", error);
|
||||||
|
window.alert("Failed to create a notebook.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rename = async (notebook: NotebookRecord) => {
|
||||||
|
const title = window.prompt("Notebook name", notebook.title)?.trim();
|
||||||
|
if (!title || title === notebook.title) return;
|
||||||
|
try {
|
||||||
|
await renameNotebook(notebook.id, title);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to rename notebook", error);
|
||||||
|
window.alert("Failed to rename the notebook.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (notebook: NotebookRecord) => {
|
||||||
|
if (!window.confirm(`Delete "${notebook.title}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await deleteNotebook(notebook.id);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete notebook", error);
|
||||||
|
window.alert("Failed to delete the notebook.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importFile = async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
await importNotebookFile(file);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "Import failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPdf = async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
setPdfProgress({ done: 0, total: 0 });
|
||||||
|
try {
|
||||||
|
const id = await importPdfFile(file, (done, total) => setPdfProgress({ done, total }));
|
||||||
|
await refresh();
|
||||||
|
onOpen(id);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "PDF import failed");
|
||||||
|
} finally {
|
||||||
|
setPdfProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const merge = async () => {
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
const first = notebooks?.find((n) => n.id === selected[0]);
|
||||||
|
const fallback = selected.length === 1 && first ? `${first.title} copy` : "Merged notebook";
|
||||||
|
const title = window.prompt("Name for the merged notebook", fallback)?.trim();
|
||||||
|
if (!title) return;
|
||||||
|
try {
|
||||||
|
const id = await mergeNotebooks(selected, title);
|
||||||
|
setSelected([]);
|
||||||
|
await refresh();
|
||||||
|
onOpen(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Merge failed", error);
|
||||||
|
window.alert("Merge failed.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home">
|
||||||
|
<header className="home-header">
|
||||||
|
<h1>vas</h1>
|
||||||
|
<div className="home-actions">
|
||||||
|
<button type="button" className="primary" onClick={() => void createAndOpen()}>
|
||||||
|
New notebook
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,.zip,application/json,application/zip"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
void importFile(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pdfProgress !== null}
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{pdfProgress
|
||||||
|
? pdfProgress.total > 0
|
||||||
|
? `Importing PDF… ${pdfProgress.done}/${pdfProgress.total}`
|
||||||
|
: "Importing PDF…"
|
||||||
|
: "Import PDF"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
void importPdf(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
title="Merge selected notebooks"
|
||||||
|
onClick={() => void merge()}
|
||||||
|
>
|
||||||
|
{selected.length > 0 ? `Merge (${selected.length})` : "Merge"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{notebooks !== null && notebooks.length === 0 && (
|
||||||
|
<p className="home-empty">No notebooks yet. Create one to start writing.</p>
|
||||||
|
)}
|
||||||
|
{notebooks !== null && notebooks.length > 0 && (
|
||||||
|
<div className="notebook-grid">
|
||||||
|
{notebooks.map((notebook) => (
|
||||||
|
<div
|
||||||
|
key={notebook.id}
|
||||||
|
className={
|
||||||
|
selected.includes(notebook.id) ? "notebook-card selected" : "notebook-card"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="notebook-checkbox"
|
||||||
|
checked={selected.includes(notebook.id)}
|
||||||
|
onChange={() => toggleSelect(notebook.id)}
|
||||||
|
aria-label={`Select ${notebook.title}`}
|
||||||
|
/>
|
||||||
|
<button type="button" className="notebook-open" onClick={() => onOpen(notebook.id)}>
|
||||||
|
<span className="notebook-title">{notebook.title}</span>
|
||||||
|
<span className="notebook-meta">
|
||||||
|
{notebook.pageCount} {notebook.pageCount === 1 ? "page" : "pages"} ·{" "}
|
||||||
|
{formatRelativeTime(notebook.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="notebook-actions">
|
||||||
|
<button type="button" title="Rename" onClick={() => void rename(notebook)}>
|
||||||
|
<RenameIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Export"
|
||||||
|
onClick={() => {
|
||||||
|
void downloadNotebook(notebook.id).catch((error: unknown) => {
|
||||||
|
console.error("Export failed", error);
|
||||||
|
window.alert("Export failed.");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExportIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Delete" onClick={() => void remove(notebook)}>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
function Icon({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<title>{title}</title>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PenIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Pen">
|
||||||
|
<path d="M4 20l1.5-4.5L16 5l3 3L8.5 18.5 4 20z" />
|
||||||
|
<path d="M13.5 7.5l3 3" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HighlighterIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Highlighter">
|
||||||
|
<path d="M5 15L14 6l4 4-9 9H5v-4z" />
|
||||||
|
<path d="M13 20h7" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EraserIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Eraser">
|
||||||
|
<path d="M7 21l-4-4L13 7l4 4L7 21z" />
|
||||||
|
<path d="M11 9l4 4" />
|
||||||
|
<path d="M13 21h8" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Settings">
|
||||||
|
<path d="M4 8h16" />
|
||||||
|
<path d="M4 16h16" />
|
||||||
|
<circle cx="9" cy="8" r="2" />
|
||||||
|
<circle cx="15" cy="16" r="2" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Back to notebooks">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UndoIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Undo">
|
||||||
|
<path d="M9 14L4 9l5-5" />
|
||||||
|
<path d="M4 9h10a6 6 0 0 1 0 12h-3" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedoIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Redo">
|
||||||
|
<path d="M15 14l5-5-5-5" />
|
||||||
|
<path d="M20 9H10a6 6 0 0 0 0 12h3" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrashIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Delete">
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M9 7V4h6v3" />
|
||||||
|
<path d="M6 7l1 13h10l1-13" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddPageIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Add page">
|
||||||
|
<rect x="5" y="3" width="14" height="18" rx="2" />
|
||||||
|
<path d="M12 8v8" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeletePageIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Delete page">
|
||||||
|
<rect x="5" y="3" width="14" height="18" rx="2" />
|
||||||
|
<path d="M9.5 9.5l5 5" />
|
||||||
|
<path d="M14.5 9.5l-5 5" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Pages panel">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
|
<path d="M9 4v16" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresentIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Present">
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
|
<path d="M12 16v4" />
|
||||||
|
<path d="M9 20h6" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasteIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Paste">
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||||
|
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Insert image">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Rename">
|
||||||
|
<path d="M4 20l1.5-4.5L16 5l3 3L8.5 18.5 4 20z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Export">
|
||||||
|
<path d="M12 4v12" />
|
||||||
|
<path d="M7 11l5 5 5-5" />
|
||||||
|
<path d="M4 20h16" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExitIcon() {
|
||||||
|
return (
|
||||||
|
<Icon title="Exit presentation">
|
||||||
|
<path d="M6 6l12 12" />
|
||||||
|
<path d="M18 6L6 18" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
|
||||||
|
export function PageIndicator() {
|
||||||
|
const index = useBoardStore((state) => state.viewPageIndex);
|
||||||
|
const count = useBoardStore((state) => state.pages.length);
|
||||||
|
return (
|
||||||
|
<div className="page-indicator">
|
||||||
|
{index + 1} / {count}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import { onImageLoaded } from "../engine/imageCache";
|
||||||
|
import { paintPage } from "../engine/renderPage";
|
||||||
|
import { PAGE_WIDTH, type Page } from "../model/page";
|
||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
|
||||||
|
const THUMB_WIDTH = 336;
|
||||||
|
const LONG_PRESS_MS = 500;
|
||||||
|
const MOVE_CANCEL_PX = 10;
|
||||||
|
const EDGE_SCROLL_PX = 48;
|
||||||
|
const EDGE_SCROLL_STEP = 14;
|
||||||
|
|
||||||
|
interface Interaction {
|
||||||
|
phase: "pressing" | "dragging";
|
||||||
|
pointerId: number;
|
||||||
|
fromIndex: number;
|
||||||
|
startY: number;
|
||||||
|
offsetY: number;
|
||||||
|
dropPos: number;
|
||||||
|
element: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragRender {
|
||||||
|
fromIndex: number;
|
||||||
|
offsetY: number;
|
||||||
|
dropPos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSidebar() {
|
||||||
|
const open = useBoardStore((state) => state.sidebarOpen);
|
||||||
|
const pages = useBoardStore((state) => state.pages);
|
||||||
|
const viewPageIndex = useBoardStore((state) => state.viewPageIndex);
|
||||||
|
const [drag, setDrag] = useState<DragRender | null>(null);
|
||||||
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const interactionRef = useRef<Interaction | null>(null);
|
||||||
|
const timerRef = useRef<number | undefined>(undefined);
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cancelTimer = () => {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = undefined;
|
||||||
|
};
|
||||||
|
const endInteraction = (commit: boolean) => {
|
||||||
|
const it = interactionRef.current;
|
||||||
|
interactionRef.current = null;
|
||||||
|
cancelTimer();
|
||||||
|
setDrag(null);
|
||||||
|
if (!it) return;
|
||||||
|
it.element.style.touchAction = "";
|
||||||
|
if (commit && it.phase === "dragging") {
|
||||||
|
if (it.dropPos !== it.fromIndex) {
|
||||||
|
useBoardStore.getState().movePage(it.fromIndex, it.dropPos);
|
||||||
|
}
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
const it = interactionRef.current;
|
||||||
|
if (!it || event.pointerId !== it.pointerId) return;
|
||||||
|
if (event.pointerType === "mouse" && event.buttons === 0) {
|
||||||
|
endInteraction(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (it.phase === "pressing") {
|
||||||
|
if (Math.abs(event.clientY - it.startY) > MOVE_CANCEL_PX) endInteraction(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
it.offsetY = event.clientY - it.startY;
|
||||||
|
const items = [...(listRef.current?.querySelectorAll(".thumbnail") ?? [])];
|
||||||
|
const others = items.filter((_, i) => i !== it.fromIndex);
|
||||||
|
let dropPos = 0;
|
||||||
|
for (const item of others) {
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
if (event.clientY > rect.top + rect.height / 2) dropPos++;
|
||||||
|
}
|
||||||
|
it.dropPos = dropPos;
|
||||||
|
const aside = asideRef.current;
|
||||||
|
if (aside) {
|
||||||
|
const rect = aside.getBoundingClientRect();
|
||||||
|
if (event.clientY < rect.top + EDGE_SCROLL_PX) aside.scrollTop -= EDGE_SCROLL_STEP;
|
||||||
|
else if (event.clientY > rect.bottom - EDGE_SCROLL_PX) aside.scrollTop += EDGE_SCROLL_STEP;
|
||||||
|
}
|
||||||
|
setDrag({ fromIndex: it.fromIndex, offsetY: it.offsetY, dropPos: it.dropPos });
|
||||||
|
};
|
||||||
|
const onEnd = (event: PointerEvent) => {
|
||||||
|
const it = interactionRef.current;
|
||||||
|
if (!it || event.pointerId !== it.pointerId) return;
|
||||||
|
endInteraction(event.type !== "pointercancel");
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
window.addEventListener("pointerup", onEnd);
|
||||||
|
window.addEventListener("pointercancel", onEnd);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onEnd);
|
||||||
|
window.removeEventListener("pointercancel", onEnd);
|
||||||
|
cancelTimer();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>, index: number) => {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
if (interactionRef.current) return;
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||||
|
interactionRef.current = {
|
||||||
|
phase: "pressing",
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
fromIndex: index,
|
||||||
|
startY: event.clientY,
|
||||||
|
offsetY: 0,
|
||||||
|
dropPos: index,
|
||||||
|
element: event.currentTarget,
|
||||||
|
};
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
const it = interactionRef.current;
|
||||||
|
if (it?.phase !== "pressing") return;
|
||||||
|
it.phase = "dragging";
|
||||||
|
it.element.style.touchAction = "none";
|
||||||
|
setDrag({ fromIndex: it.fromIndex, offsetY: 0, dropPos: it.dropPos });
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const othersCount = pages.length - 1;
|
||||||
|
const indicatorBeforeIndex =
|
||||||
|
drag && drag.dropPos !== drag.fromIndex && drag.dropPos < othersCount
|
||||||
|
? drag.dropPos + (drag.dropPos >= drag.fromIndex ? 1 : 0)
|
||||||
|
: null;
|
||||||
|
const indicatorAfterIndex =
|
||||||
|
drag && drag.dropPos !== drag.fromIndex && drag.dropPos === othersCount
|
||||||
|
? othersCount - 1 >= drag.fromIndex
|
||||||
|
? othersCount
|
||||||
|
: othersCount - 1
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside ref={asideRef} className="sidebar">
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<span>Pages</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Close pages panel"
|
||||||
|
onClick={() => useBoardStore.getState().toggleSidebar()}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={listRef} className="sidebar-list">
|
||||||
|
{pages.map((page, index) => (
|
||||||
|
<PageThumbnail
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
index={index}
|
||||||
|
active={index === viewPageIndex}
|
||||||
|
dragging={drag?.fromIndex === index}
|
||||||
|
dropBefore={index === indicatorBeforeIndex}
|
||||||
|
dropAfter={index === indicatorAfterIndex}
|
||||||
|
dragOffset={drag?.fromIndex === index ? drag.offsetY : 0}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, index)}
|
||||||
|
onOpen={() => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useBoardStore.getState().requestScrollToPage(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageThumbnail({
|
||||||
|
page,
|
||||||
|
index,
|
||||||
|
active,
|
||||||
|
dragging,
|
||||||
|
dropBefore,
|
||||||
|
dropAfter,
|
||||||
|
dragOffset,
|
||||||
|
onPointerDown,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
page: Page;
|
||||||
|
index: number;
|
||||||
|
active: boolean;
|
||||||
|
dragging: boolean;
|
||||||
|
dropBefore: boolean;
|
||||||
|
dropAfter: boolean;
|
||||||
|
dragOffset: number;
|
||||||
|
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const scale = THUMB_WIDTH / PAGE_WIDTH;
|
||||||
|
paintPage(canvas, page, scale);
|
||||||
|
if (page.images.length === 0) return;
|
||||||
|
return onImageLoaded(() => paintPage(canvas, page, scale));
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
"thumbnail",
|
||||||
|
active ? "active" : "",
|
||||||
|
dragging ? "dragging" : "",
|
||||||
|
dropBefore ? "drop-before" : "",
|
||||||
|
dropAfter ? "drop-after" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
style={dragging ? { transform: `translateY(${dragOffset}px)` } : undefined}
|
||||||
|
title={`Go to page ${index + 1}`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
onClick={onOpen}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { COLORS, useBoardStore } from "../store/useBoardStore";
|
||||||
|
import { ColorField } from "./ColorField";
|
||||||
|
|
||||||
|
export function SelectionBar() {
|
||||||
|
const selection = useBoardStore((state) => state.selection);
|
||||||
|
const anchor = useBoardStore((state) => state.selectionAnchor);
|
||||||
|
const tool = useBoardStore((state) => state.tool);
|
||||||
|
const inkColor = useBoardStore((state) => {
|
||||||
|
const sel = state.selection;
|
||||||
|
if (!sel) return null;
|
||||||
|
const page = state.pages.find((p) => p.id === sel.pageId);
|
||||||
|
return page?.strokes.find((s) => s.id === sel.strokeIds[0])?.color ?? null;
|
||||||
|
});
|
||||||
|
if (!selection || !anchor || tool !== "select") return null;
|
||||||
|
|
||||||
|
const { recolorSelection, cutSelection, copySelection, deleteSelection } =
|
||||||
|
useBoardStore.getState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="selection-bar"
|
||||||
|
style={{
|
||||||
|
left: `clamp(${Math.min(190, (window.innerWidth - 12) / 2)}px, ${anchor.x}px, calc(100vw - ${Math.min(190, (window.innerWidth - 12) / 2)}px))`,
|
||||||
|
top: Math.max(anchor.y, 52),
|
||||||
|
}}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Selection actions"
|
||||||
|
>
|
||||||
|
{selection.strokeIds.length > 0 && (
|
||||||
|
<>
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
title={`Color ${c}`}
|
||||||
|
className="swatch"
|
||||||
|
style={{ "--swatch": c } as CSSProperties}
|
||||||
|
onClick={() => recolorSelection(c)}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<ColorField value={inkColor ?? COLORS[0]} onChange={recolorSelection} />
|
||||||
|
<div className="selection-divider" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" className="text-btn" onClick={cutSelection}>
|
||||||
|
Cut
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-btn" onClick={copySelection}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-btn" onClick={deleteSelection}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { type CSSProperties, useRef, useState } from "react";
|
||||||
|
import { PAGE_PATTERNS, type PagePattern } from "../model/page";
|
||||||
|
import { SHAPE_KINDS, type ShapeKind } from "../model/stroke";
|
||||||
|
import { exportPagePng } from "../persistence/exportImage";
|
||||||
|
import { exportNotebookPdf } from "../persistence/exportPdf";
|
||||||
|
import { rasterizePdf, saveRasterizedImages } from "../persistence/importPdf";
|
||||||
|
import { insertImageFile } from "../persistence/insertImage";
|
||||||
|
import { COLORS, PAPER_COLORS, SIZES, useBoardStore } from "../store/useBoardStore";
|
||||||
|
import { ColorField } from "./ColorField";
|
||||||
|
import {
|
||||||
|
AddPageIcon,
|
||||||
|
DeletePageIcon,
|
||||||
|
ImageIcon,
|
||||||
|
PasteIcon,
|
||||||
|
PresentIcon,
|
||||||
|
RedoIcon,
|
||||||
|
SidebarIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UndoIcon,
|
||||||
|
} from "./Icons";
|
||||||
|
|
||||||
|
const PATTERN_LABELS: Record<PagePattern, string> = {
|
||||||
|
blank: "Blank",
|
||||||
|
lined: "Lines",
|
||||||
|
grid: "Grid",
|
||||||
|
dots: "Dots",
|
||||||
|
rice: "Rice",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHAPE_LABELS: Record<ShapeKind, string> = {
|
||||||
|
line: "Line",
|
||||||
|
arrow: "Arrow",
|
||||||
|
rect: "Rect",
|
||||||
|
ellipse: "Ellipse",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsPanel() {
|
||||||
|
const tool = useBoardStore((state) => state.tool);
|
||||||
|
const inkColor = useBoardStore((state) => state.color);
|
||||||
|
const size = useBoardStore((state) => state.size);
|
||||||
|
const paperColor = useBoardStore(
|
||||||
|
(state) => state.pages[state.viewPageIndex]?.paperColor ?? state.paperColor,
|
||||||
|
);
|
||||||
|
const pattern = useBoardStore(
|
||||||
|
(state) => state.pages[state.viewPageIndex]?.pattern ?? state.pattern,
|
||||||
|
);
|
||||||
|
const canUndo = useBoardStore((state) => state.past.length > 0);
|
||||||
|
const canRedo = useBoardStore((state) => state.future.length > 0);
|
||||||
|
const currentPageId = useBoardStore((state) => state.pages[state.viewPageIndex]?.id);
|
||||||
|
const canClear = useBoardStore((state) => {
|
||||||
|
const page = state.pages[state.viewPageIndex];
|
||||||
|
return (page?.strokes.length ?? 0) > 0 || (page?.images.some((i) => !i.locked) ?? false);
|
||||||
|
});
|
||||||
|
const canDeletePage = useBoardStore((state) => state.pages.length > 1);
|
||||||
|
const canPaste = useBoardStore(
|
||||||
|
(state) => state.clipboard.strokes.length > 0 || state.clipboard.images.length > 0,
|
||||||
|
);
|
||||||
|
const sidebarOpen = useBoardStore((state) => state.sidebarOpen);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [pdfImporting, setPdfImporting] = useState<{ done: number; total: number } | null>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const {
|
||||||
|
setTool,
|
||||||
|
setColor,
|
||||||
|
setSize,
|
||||||
|
setPaperColor,
|
||||||
|
setPattern,
|
||||||
|
setPresentation,
|
||||||
|
toggleSidebar,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
clearPage,
|
||||||
|
addPage,
|
||||||
|
deletePage,
|
||||||
|
pasteClipboard,
|
||||||
|
} = useBoardStore.getState();
|
||||||
|
|
||||||
|
const confirmClear = () => {
|
||||||
|
if (currentPageId && window.confirm("Clear this page?")) clearPage(currentPageId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (currentPageId && window.confirm("Delete this page and everything on it?")) {
|
||||||
|
deletePage(currentPageId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportPdf = async () => {
|
||||||
|
const { notebookTitle: title, pages } = useBoardStore.getState();
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportNotebookPdf(title || "vas notebook", pages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("PDF export failed", error);
|
||||||
|
window.alert("PDF export failed.");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportPng = async () => {
|
||||||
|
const { notebookTitle: title, pages, viewPageIndex } = useBoardStore.getState();
|
||||||
|
const page = pages[viewPageIndex];
|
||||||
|
if (!page) return;
|
||||||
|
try {
|
||||||
|
await exportPagePng(title || "vas notebook", viewPageIndex, page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("PNG export failed", error);
|
||||||
|
window.alert("PNG export failed.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickImage = async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
await insertImageFile(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to insert image", error);
|
||||||
|
window.alert("Failed to insert image.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPdf = async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
setPdfImporting({ done: 0, total: 0 });
|
||||||
|
try {
|
||||||
|
const rasterized = await rasterizePdf(file, (done, total) =>
|
||||||
|
setPdfImporting({ done, total }),
|
||||||
|
);
|
||||||
|
await saveRasterizedImages(rasterized);
|
||||||
|
useBoardStore.getState().insertPdfPages(rasterized);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("PDF import failed", error);
|
||||||
|
window.alert(error instanceof Error ? error.message : "PDF import failed");
|
||||||
|
} finally {
|
||||||
|
setPdfImporting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">Tool</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tool === "pen"}
|
||||||
|
className={tool === "pen" ? "text-option active" : "text-option"}
|
||||||
|
onClick={() => setTool("pen")}
|
||||||
|
>
|
||||||
|
Pen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tool === "highlighter"}
|
||||||
|
className={tool === "highlighter" ? "text-option active" : "text-option"}
|
||||||
|
onClick={() => setTool("highlighter")}
|
||||||
|
>
|
||||||
|
Highlighter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tool === "laser"}
|
||||||
|
className={tool === "laser" ? "text-option active" : "text-option"}
|
||||||
|
onClick={() => setTool("laser")}
|
||||||
|
>
|
||||||
|
Laser
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tool === "select"}
|
||||||
|
className={tool === "select" ? "text-option active" : "text-option"}
|
||||||
|
onClick={() => setTool("select")}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">Shape</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
{SHAPE_KINDS.map((kind) => (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tool === kind}
|
||||||
|
className={tool === kind ? "text-option active" : "text-option"}
|
||||||
|
onClick={() => setTool(kind)}
|
||||||
|
>
|
||||||
|
{SHAPE_LABELS[kind]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">Ink</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
title={`Color ${c}`}
|
||||||
|
aria-pressed={inkColor === c}
|
||||||
|
className={inkColor === c ? "swatch active" : "swatch"}
|
||||||
|
style={{ "--swatch": c } as CSSProperties}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<ColorField value={inkColor} onChange={setColor} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">Size</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
{SIZES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
title={`Size ${s}`}
|
||||||
|
aria-pressed={size === s}
|
||||||
|
className={size === s ? "size-option active" : "size-option"}
|
||||||
|
onClick={() => setSize(s)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: Math.round(3 + s * 1.6),
|
||||||
|
height: Math.round(3 + s * 1.6),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="size-slider"
|
||||||
|
min={0.5}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
value={size}
|
||||||
|
aria-label="Stroke size"
|
||||||
|
onChange={(e) => setSize(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="size-value">{size}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">Paper</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
{PAPER_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
title={`Paper ${c}`}
|
||||||
|
aria-pressed={paperColor === c}
|
||||||
|
className={paperColor === c ? "swatch active" : "swatch"}
|
||||||
|
style={{ "--swatch": c } as CSSProperties}
|
||||||
|
onClick={() => setPaperColor(c)}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<ColorField value={paperColor} onChange={setPaperColor} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">Template</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
{PAGE_PATTERNS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={pattern === p}
|
||||||
|
className={pattern === p ? "text-option active" : "text-option"}
|
||||||
|
onClick={() => setPattern(p)}
|
||||||
|
>
|
||||||
|
{PATTERN_LABELS[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">Actions</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<button type="button" title="Undo" disabled={!canUndo} onClick={undo}>
|
||||||
|
<UndoIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Redo" disabled={!canRedo} onClick={redo}>
|
||||||
|
<RedoIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Clear page" disabled={!canClear} onClick={confirmClear}>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Add page" onClick={addPage}>
|
||||||
|
<AddPageIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Delete page"
|
||||||
|
disabled={!canDeletePage}
|
||||||
|
onClick={confirmDelete}
|
||||||
|
>
|
||||||
|
<DeletePageIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Paste" disabled={!canPaste} onClick={pasteClipboard}>
|
||||||
|
<PasteIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Insert image" onClick={() => imageInputRef.current?.click()}>
|
||||||
|
<ImageIcon />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
void pickImage(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" title="Present" onClick={() => setPresentation(true)}>
|
||||||
|
<PresentIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Pages panel"
|
||||||
|
aria-pressed={sidebarOpen}
|
||||||
|
className={sidebarOpen ? "active" : ""}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
>
|
||||||
|
<SidebarIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-label">File</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-option"
|
||||||
|
disabled={pdfImporting !== null}
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{pdfImporting
|
||||||
|
? pdfImporting.total > 0
|
||||||
|
? `Importing… ${pdfImporting.done}/${pdfImporting.total}`
|
||||||
|
: "Importing…"
|
||||||
|
: "Import PDF"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
void importPdf(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-option"
|
||||||
|
disabled={exporting}
|
||||||
|
onClick={() => void exportPdf()}
|
||||||
|
>
|
||||||
|
{exporting ? "Exporting…" : "PDF (vector)"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-option" onClick={() => void exportPng()}>
|
||||||
|
PNG (this page)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { closeNotebook } from "../persistence/session";
|
||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
import { BackIcon, EraserIcon, HighlighterIcon, PenIcon, SettingsIcon } from "./Icons";
|
||||||
|
import { SettingsPanel } from "./SettingsPanel";
|
||||||
|
|
||||||
|
export function Toolbar() {
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const tool = useBoardStore((state) => state.tool);
|
||||||
|
const lastPenKind = useBoardStore((state) => state.lastPenKind);
|
||||||
|
const { setTool } = useBoardStore.getState();
|
||||||
|
|
||||||
|
const penActive = tool === "pen" || tool === "highlighter";
|
||||||
|
const shownKind = penActive ? tool : lastPenKind;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="toolbar" role="toolbar" aria-label="Drawing tools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={shownKind === "highlighter" ? "Highlighter" : "Pen"}
|
||||||
|
aria-pressed={penActive}
|
||||||
|
className={penActive ? "active" : ""}
|
||||||
|
onClick={() => setTool(penActive ? tool : lastPenKind)}
|
||||||
|
>
|
||||||
|
{shownKind === "highlighter" ? <HighlighterIcon /> : <PenIcon />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Eraser"
|
||||||
|
aria-pressed={tool === "eraser"}
|
||||||
|
className={tool === "eraser" ? "active" : ""}
|
||||||
|
onClick={() => setTool("eraser")}
|
||||||
|
>
|
||||||
|
<EraserIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Settings"
|
||||||
|
aria-expanded={panelOpen}
|
||||||
|
className={panelOpen ? "active" : ""}
|
||||||
|
onClick={() => setPanelOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Back to notebooks" onClick={() => void closeNotebook()}>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{panelOpen && <SettingsPanel />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatRelativeTime } from "./formatTime";
|
||||||
|
|
||||||
|
const now = new Date("2026-07-23T12:00:00").getTime();
|
||||||
|
|
||||||
|
describe("formatRelativeTime", () => {
|
||||||
|
it("reports just now for under a minute", () => {
|
||||||
|
expect(formatRelativeTime(now - 30_000, now)).toBe("just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports minutes", () => {
|
||||||
|
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe("5 min ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports hours", () => {
|
||||||
|
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3 h ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports days", () => {
|
||||||
|
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2 d ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a date for older timestamps", () => {
|
||||||
|
const result = formatRelativeTime(now - 30 * 86_400_000, now);
|
||||||
|
expect(result).not.toContain("ago");
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats future timestamps as just now", () => {
|
||||||
|
expect(formatRelativeTime(now + 60_000, now)).toBe("just now");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function formatRelativeTime(timestamp: number, now = Date.now()): string {
|
||||||
|
const diff = Math.max(0, now - timestamp);
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return `${days} d ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
export function get2dContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Canvas 2D context is unavailable");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { getImage } from "../persistence/images";
|
||||||
|
|
||||||
|
const cache = new Map<string, HTMLImageElement>();
|
||||||
|
const failed = new Set<string>();
|
||||||
|
const pending = new Map<string, Promise<HTMLImageElement | null>>();
|
||||||
|
const listeners = new Set<(imageId: string) => void>();
|
||||||
|
|
||||||
|
export function getImageBitmap(imageId: string): HTMLImageElement | null {
|
||||||
|
const hit = cache.get(imageId);
|
||||||
|
if (hit) return hit;
|
||||||
|
if (!failed.has(imageId)) void ensureImageLoaded(imageId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureImageLoaded(imageId: string): Promise<HTMLImageElement | null> {
|
||||||
|
const hit = cache.get(imageId);
|
||||||
|
if (hit) return Promise.resolve(hit);
|
||||||
|
if (failed.has(imageId)) return Promise.resolve(null);
|
||||||
|
const existing = pending.get(imageId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const task = load(imageId);
|
||||||
|
pending.set(imageId, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primeImage(imageId: string, image: HTMLImageElement): void {
|
||||||
|
cache.set(imageId, image);
|
||||||
|
for (const listener of listeners) listener(imageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onImageLoaded(listener: (imageId: string) => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBlob(blob: Blob): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error("Failed to decode image"));
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(imageId: string): Promise<HTMLImageElement | null> {
|
||||||
|
let record: Awaited<ReturnType<typeof getImage>>;
|
||||||
|
try {
|
||||||
|
record = await getImage(imageId);
|
||||||
|
} catch {
|
||||||
|
pending.delete(imageId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!record) {
|
||||||
|
failed.add(imageId);
|
||||||
|
pending.delete(imageId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const image = await decodeBlob(record.blob);
|
||||||
|
cache.set(imageId, image);
|
||||||
|
for (const listener of listeners) listener(imageId);
|
||||||
|
return image;
|
||||||
|
} catch {
|
||||||
|
failed.add(imageId);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
pending.delete(imageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createPage, type Page } from "../model/page";
|
||||||
|
import type { Stroke } from "../model/stroke";
|
||||||
|
import { canAppendToCache } from "./pageCache";
|
||||||
|
|
||||||
|
function stroke(id: string): Stroke {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
pen: "pen",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size: 2,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: [{ x: 1, y: 1, pressure: 0.5 }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageWith(strokes: Stroke[], images: Page["images"] = []): Page {
|
||||||
|
return { ...createPage("#ffffff"), strokes, images };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("canAppendToCache", () => {
|
||||||
|
it("allows appending new strokes to an unchanged prefix", () => {
|
||||||
|
const s1 = stroke("s1");
|
||||||
|
const cached = { page: pageWith([s1]), renderedCount: 1 };
|
||||||
|
expect(canAppendToCache(cached, pageWith([s1, stroke("s2")]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when strokes were removed or replaced", () => {
|
||||||
|
const s1 = stroke("s1");
|
||||||
|
const s2 = stroke("s2");
|
||||||
|
const cached = { page: pageWith([s1, s2]), renderedCount: 2 };
|
||||||
|
expect(canAppendToCache(cached, pageWith([s1]))).toBe(false);
|
||||||
|
expect(canAppendToCache(cached, pageWith([s1, stroke("s3")]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when images changed even if strokes are identical", () => {
|
||||||
|
const s1 = stroke("s1");
|
||||||
|
const cached = { page: pageWith([s1]), renderedCount: 1 };
|
||||||
|
const withImage = pageWith(
|
||||||
|
[s1],
|
||||||
|
[{ id: "i1", imageId: "blob", x: 0, y: 0, width: 10, height: 10 }],
|
||||||
|
);
|
||||||
|
expect(canAppendToCache(cached, withImage)).toBe(false);
|
||||||
|
expect(canAppendToCache({ page: withImage, renderedCount: 1 }, pageWith([s1]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts identical image references", () => {
|
||||||
|
const s1 = stroke("s1");
|
||||||
|
const s2 = stroke("s2");
|
||||||
|
const images = [{ id: "i1", imageId: "blob", x: 0, y: 0, width: 10, height: 10 }];
|
||||||
|
const cached = { page: pageWith([s1], images), renderedCount: 1 };
|
||||||
|
expect(canAppendToCache(cached, pageWith([s1, s2], images))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when an image item was replaced by a new object", () => {
|
||||||
|
const cached = {
|
||||||
|
page: pageWith([], [{ id: "i1", imageId: "blob", x: 0, y: 0, width: 10, height: 10 }]),
|
||||||
|
renderedCount: 0,
|
||||||
|
};
|
||||||
|
const moved = pageWith([], [{ id: "i1", imageId: "blob", x: 5, y: 5, width: 10, height: 10 }]);
|
||||||
|
expect(canAppendToCache(cached, moved)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ImageItem } from "../model/image";
|
||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH, type Page } from "../model/page";
|
||||||
|
import { get2dContext } from "./canvas";
|
||||||
|
import { paintPage } from "./renderPage";
|
||||||
|
import { drawStroke } from "./renderStroke";
|
||||||
|
|
||||||
|
const MAX_CACHE_PIXELS = 16_000_000;
|
||||||
|
export const MAX_CACHE_RENDER_SCALE = Math.sqrt(MAX_CACHE_PIXELS / (PAGE_WIDTH * PAGE_HEIGHT));
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
renderScale: number;
|
||||||
|
page: Page;
|
||||||
|
renderedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PageCache {
|
||||||
|
private entries = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
sync(page: Page, renderScale: number): HTMLCanvasElement {
|
||||||
|
renderScale = Math.min(renderScale, MAX_CACHE_RENDER_SCALE);
|
||||||
|
const entry = this.entries.get(page.id);
|
||||||
|
if (
|
||||||
|
entry &&
|
||||||
|
entry.renderScale === renderScale &&
|
||||||
|
entry.page.paperColor === page.paperColor &&
|
||||||
|
entry.page.pattern === page.pattern
|
||||||
|
) {
|
||||||
|
if (entry.page === page) return entry.canvas;
|
||||||
|
if (canAppendToCache(entry, page)) {
|
||||||
|
const ctx = get2dContext(entry.canvas);
|
||||||
|
for (const stroke of page.strokes.slice(entry.renderedCount)) drawStroke(ctx, stroke);
|
||||||
|
entry.renderedCount = page.strokes.length;
|
||||||
|
entry.page = page;
|
||||||
|
return entry.canvas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.render(page, renderScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
peek(pageId: string): HTMLCanvasElement | undefined {
|
||||||
|
return this.entries.get(pageId)?.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(pageId: string): void {
|
||||||
|
this.entries.delete(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
prune(keepIds: Set<string>): void {
|
||||||
|
for (const id of this.entries.keys()) {
|
||||||
|
if (!keepIds.has(id)) this.entries.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(page: Page, renderScale: number): HTMLCanvasElement {
|
||||||
|
let entry = this.entries.get(page.id);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { canvas: document.createElement("canvas"), renderScale, page, renderedCount: 0 };
|
||||||
|
this.entries.set(page.id, entry);
|
||||||
|
}
|
||||||
|
paintPage(entry.canvas, page, renderScale);
|
||||||
|
entry.renderScale = renderScale;
|
||||||
|
entry.page = page;
|
||||||
|
entry.renderedCount = page.strokes.length;
|
||||||
|
return entry.canvas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAppendToCache(
|
||||||
|
cached: { page: Page; renderedCount: number },
|
||||||
|
next: Page,
|
||||||
|
): boolean {
|
||||||
|
if (cached.renderedCount > next.strokes.length) return false;
|
||||||
|
if (!sameImages(cached.page.images, next.images)) return false;
|
||||||
|
for (let i = 0; i < cached.renderedCount; i++) {
|
||||||
|
if (cached.page.strokes[i] !== next.strokes[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameImages(a: ImageItem[], b: ImageItem[]): boolean {
|
||||||
|
return a.length === b.length && a.every((image, index) => image === b[index]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { isDarkColor } from "../model/color";
|
||||||
|
import type { PagePattern } from "../model/page";
|
||||||
|
import { PATTERN_DASH, patternLayout } from "../model/patternLayout";
|
||||||
|
|
||||||
|
export function drawPagePattern(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
pattern: PagePattern,
|
||||||
|
paperColor: string,
|
||||||
|
): void {
|
||||||
|
if (pattern === "blank") return;
|
||||||
|
const { lines, dots } = patternLayout(pattern);
|
||||||
|
const dark = isDarkColor(paperColor);
|
||||||
|
const color = dark ? "rgba(255, 255, 255, 0.22)" : "rgba(0, 0, 0, 0.16)";
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.dashed) continue;
|
||||||
|
ctx.moveTo(line.x1, line.y1);
|
||||||
|
ctx.lineTo(line.x2, line.y2);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.setLineDash([...PATTERN_DASH]);
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.dashed) continue;
|
||||||
|
ctx.moveTo(line.x1, line.y1);
|
||||||
|
ctx.lineTo(line.x2, line.y2);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
for (const dot of dots) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dot.x, dot.y, 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH, type Page } from "../model/page";
|
||||||
|
import { get2dContext } from "./canvas";
|
||||||
|
import { getImageBitmap } from "./imageCache";
|
||||||
|
import { drawPagePattern } from "./patterns";
|
||||||
|
import { drawStroke } from "./renderStroke";
|
||||||
|
|
||||||
|
export function paintPage(canvas: HTMLCanvasElement, page: Page, renderScale: number): void {
|
||||||
|
canvas.width = Math.max(1, Math.round(PAGE_WIDTH * renderScale));
|
||||||
|
canvas.height = Math.max(1, Math.round(PAGE_HEIGHT * renderScale));
|
||||||
|
const ctx = get2dContext(canvas);
|
||||||
|
ctx.setTransform(renderScale, 0, 0, renderScale, 0, 0);
|
||||||
|
ctx.fillStyle = page.paperColor;
|
||||||
|
ctx.fillRect(0, 0, PAGE_WIDTH, PAGE_HEIGHT);
|
||||||
|
drawPagePattern(ctx, page.pattern, page.paperColor);
|
||||||
|
for (const image of page.images) {
|
||||||
|
const bitmap = getImageBitmap(image.imageId);
|
||||||
|
if (bitmap) ctx.drawImage(bitmap, image.x, image.y, image.width, image.height);
|
||||||
|
}
|
||||||
|
for (const stroke of page.strokes) drawStroke(ctx, stroke);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Stroke } from "../model/stroke";
|
||||||
|
import { getOutlinePoints } from "./renderStroke";
|
||||||
|
|
||||||
|
function makeStroke(overrides: Partial<Stroke> = {}): Stroke {
|
||||||
|
return {
|
||||||
|
id: "s1",
|
||||||
|
pen: "pen",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size: 6,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0, pressure: 0.4 },
|
||||||
|
{ x: 20, y: 6, pressure: 0.6 },
|
||||||
|
{ x: 40, y: 2, pressure: 0.5 },
|
||||||
|
{ x: 60, y: 12, pressure: 0.7 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getOutlinePoints", () => {
|
||||||
|
it("produces a finite outline for a multi-point stroke", () => {
|
||||||
|
const outline = getOutlinePoints(makeStroke());
|
||||||
|
expect(outline.length).toBeGreaterThan(4);
|
||||||
|
for (const [x, y] of outline) {
|
||||||
|
expect(Number.isFinite(x)).toBe(true);
|
||||||
|
expect(Number.isFinite(y)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a dot for a single-point stroke", () => {
|
||||||
|
const outline = getOutlinePoints(makeStroke({ points: [{ x: 5, y: 5, pressure: 0.5 }] }));
|
||||||
|
expect(outline.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports highlighter strokes", () => {
|
||||||
|
expect(getOutlinePoints(makeStroke({ pen: "highlighter" })).length).toBeGreaterThan(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the highlighter wider than the pen at the same size", () => {
|
||||||
|
const yExtent = (outline: number[][]) => {
|
||||||
|
const ys = outline.map(([, y]) => y);
|
||||||
|
return Math.max(...ys) - Math.min(...ys);
|
||||||
|
};
|
||||||
|
const penExtent = yExtent(getOutlinePoints(makeStroke({ pen: "pen" })));
|
||||||
|
const highlighterExtent = yExtent(getOutlinePoints(makeStroke({ pen: "highlighter" })));
|
||||||
|
expect(highlighterExtent).toBeGreaterThan(penExtent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports simulated pressure for mouse input", () => {
|
||||||
|
expect(getOutlinePoints(makeStroke({ simulatePressure: true })).length).toBeGreaterThan(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the tail open for an in-progress stroke", () => {
|
||||||
|
const stroke = makeStroke();
|
||||||
|
const inProgress = getOutlinePoints(stroke, false);
|
||||||
|
const completed = getOutlinePoints(stroke, true);
|
||||||
|
expect(inProgress.length).toBeGreaterThan(0);
|
||||||
|
expect(completed.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getStroke } from "perfect-freehand";
|
||||||
|
import { effectiveStrokeSize, type Stroke } from "../model/stroke";
|
||||||
|
import { shapePath } from "./shapes";
|
||||||
|
|
||||||
|
const PEN_OPTIONS = { thinning: 0.75, smoothing: 0.5, streamline: 0.5 };
|
||||||
|
const HIGHLIGHTER_OPTIONS = { thinning: 0.35, smoothing: 0.6, streamline: 0.5 };
|
||||||
|
export const HIGHLIGHTER_ALPHA = 0.35;
|
||||||
|
|
||||||
|
export function getOutlinePoints(stroke: Stroke, complete = true): number[][] {
|
||||||
|
const base = stroke.pen === "highlighter" ? HIGHLIGHTER_OPTIONS : PEN_OPTIONS;
|
||||||
|
return getStroke(stroke.points, {
|
||||||
|
...base,
|
||||||
|
size: effectiveStrokeSize(stroke),
|
||||||
|
simulatePressure: stroke.simulatePressure,
|
||||||
|
last: complete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke, complete = true): void {
|
||||||
|
if (stroke.shape) {
|
||||||
|
const path = shapePath(stroke);
|
||||||
|
if (!path) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = stroke.color;
|
||||||
|
ctx.lineWidth = stroke.size;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.stroke(path);
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const outline = getOutlinePoints(stroke, complete);
|
||||||
|
if (outline.length === 0) return;
|
||||||
|
const path = new Path2D();
|
||||||
|
path.moveTo(outline[0][0], outline[0][1]);
|
||||||
|
for (let i = 1; i < outline.length; i++) {
|
||||||
|
path.lineTo(outline[i][0], outline[i][1]);
|
||||||
|
}
|
||||||
|
path.closePath();
|
||||||
|
ctx.save();
|
||||||
|
if (stroke.pen === "highlighter") ctx.globalAlpha = HIGHLIGHTER_ALPHA;
|
||||||
|
ctx.fillStyle = stroke.color;
|
||||||
|
ctx.fill(path);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { arrowHead } from "../model/shapeGeometry";
|
||||||
|
|
||||||
|
describe("arrowHead", () => {
|
||||||
|
it("returns two points at the head length from the tip", () => {
|
||||||
|
const [left, right] = arrowHead({ x: 0, y: 0 }, { x: 100, y: 0 }, 5);
|
||||||
|
for (const point of [left, right]) {
|
||||||
|
expect(Math.hypot(100 - point.x, point.y)).toBeCloseTo(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is symmetric about the shaft direction", () => {
|
||||||
|
const [left, right] = arrowHead({ x: 0, y: 0 }, { x: 100, y: 0 }, 5);
|
||||||
|
expect(left.y).toBeCloseTo(-right.y);
|
||||||
|
expect(left.x).toBeCloseTo(right.x);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces a minimum head length", () => {
|
||||||
|
const [left] = arrowHead({ x: 0, y: 0 }, { x: 0, y: 50 }, 1);
|
||||||
|
expect(Math.hypot(left.x, 50 - left.y)).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { arrowHead } from "../model/shapeGeometry";
|
||||||
|
import type { Stroke } from "../model/stroke";
|
||||||
|
|
||||||
|
export function shapePath(stroke: Stroke): Path2D | null {
|
||||||
|
const [a, b] = stroke.points;
|
||||||
|
if (!stroke.shape || !a || !b) return null;
|
||||||
|
const path = new Path2D();
|
||||||
|
switch (stroke.shape) {
|
||||||
|
case "line":
|
||||||
|
path.moveTo(a.x, a.y);
|
||||||
|
path.lineTo(b.x, b.y);
|
||||||
|
break;
|
||||||
|
case "arrow": {
|
||||||
|
const [left, right] = arrowHead(a, b, stroke.size);
|
||||||
|
path.moveTo(a.x, a.y);
|
||||||
|
path.lineTo(b.x, b.y);
|
||||||
|
path.moveTo(left.x, left.y);
|
||||||
|
path.lineTo(b.x, b.y);
|
||||||
|
path.lineTo(right.x, right.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "rect":
|
||||||
|
path.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y));
|
||||||
|
break;
|
||||||
|
case "ellipse":
|
||||||
|
path.ellipse(
|
||||||
|
(a.x + b.x) / 2,
|
||||||
|
(a.y + b.y) / 2,
|
||||||
|
Math.abs(b.x - a.x) / 2,
|
||||||
|
Math.abs(b.y - a.y) / 2,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { contentHeight, PAGE_WIDTH } from "../model/page";
|
||||||
|
import {
|
||||||
|
clampScale,
|
||||||
|
createViewport,
|
||||||
|
fitScale,
|
||||||
|
panBy,
|
||||||
|
type ScreenSize,
|
||||||
|
screenToWorld,
|
||||||
|
type Viewport,
|
||||||
|
visiblePageRange,
|
||||||
|
zoomAt,
|
||||||
|
} from "./viewport";
|
||||||
|
|
||||||
|
const screen: ScreenSize = { width: 818, height: 1000 };
|
||||||
|
|
||||||
|
describe("viewport", () => {
|
||||||
|
it("fits the page width with margins", () => {
|
||||||
|
const scale = fitScale(screen.width);
|
||||||
|
expect(scale * (PAGE_WIDTH + 24)).toBeCloseTo(screen.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centers the page horizontally at fit scale", () => {
|
||||||
|
const vp = createViewport(screen, 1);
|
||||||
|
expect(vp.x).toBeCloseTo((PAGE_WIDTH - screen.width / vp.scale) / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("top-aligns content taller than the screen", () => {
|
||||||
|
const vp = createViewport(screen, 5);
|
||||||
|
expect(vp.y).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the focal point stable while zooming", () => {
|
||||||
|
const vp = createViewport(screen, 3);
|
||||||
|
const focal = { x: 400, y: 500 };
|
||||||
|
const before = screenToWorld(vp, focal.x, focal.y);
|
||||||
|
const zoomed = zoomAt(vp, focal, vp.scale * 2, screen, 3);
|
||||||
|
const after = screenToWorld(zoomed, focal.x, focal.y);
|
||||||
|
expect(after.x).toBeCloseTo(before.x);
|
||||||
|
expect(after.y).toBeCloseTo(before.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps zoom to the allowed range", () => {
|
||||||
|
const fit = fitScale(screen.width);
|
||||||
|
expect(clampScale(fit * 0.5, screen.width)).toBe(fit);
|
||||||
|
expect(clampScale(fit * 99, screen.width)).toBe(fit * 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps panning to the content bounds", () => {
|
||||||
|
const vp = createViewport(screen, 3);
|
||||||
|
expect(panBy(vp, 0, 100, screen, 3).y).toBe(0);
|
||||||
|
const bottom = panBy(vp, 0, -100000, screen, 3);
|
||||||
|
expect(bottom.y).toBeCloseTo(contentHeight(3) - screen.height / bottom.scale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the visible page range", () => {
|
||||||
|
const vp: Viewport = { x: 0, y: 0, scale: fitScale(screen.width) };
|
||||||
|
const range = visiblePageRange(vp, screen, 10);
|
||||||
|
expect(range.first).toBe(0);
|
||||||
|
expect(range.last).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(range.last).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { contentHeight, PAGE_GAP, PAGE_HEIGHT, PAGE_TOP_MARGIN, PAGE_WIDTH } from "../model/page";
|
||||||
|
|
||||||
|
export interface Viewport {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIT_MARGIN = 12;
|
||||||
|
const MAX_ZOOM = 20;
|
||||||
|
|
||||||
|
export function fitScale(screenWidth: number): number {
|
||||||
|
return screenWidth / (PAGE_WIDTH + FIT_MARGIN * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createViewport(screen: ScreenSize, pageCount: number): Viewport {
|
||||||
|
return clampViewport({ x: 0, y: 0, scale: fitScale(screen.width) }, screen, pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampScale(scale: number, screenWidth: number): number {
|
||||||
|
const fit = fitScale(screenWidth);
|
||||||
|
return Math.min(fit * MAX_ZOOM, Math.max(fit, scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampViewport(vp: Viewport, screen: ScreenSize, pageCount: number): Viewport {
|
||||||
|
const worldScreenW = screen.width / vp.scale;
|
||||||
|
const x =
|
||||||
|
worldScreenW >= PAGE_WIDTH
|
||||||
|
? (PAGE_WIDTH - worldScreenW) / 2
|
||||||
|
: clamp(vp.x, 0, PAGE_WIDTH - worldScreenW);
|
||||||
|
const height = contentHeight(pageCount);
|
||||||
|
const worldScreenH = screen.height / vp.scale;
|
||||||
|
const y =
|
||||||
|
worldScreenH >= height ? (height - worldScreenH) / 2 : clamp(vp.y, 0, height - worldScreenH);
|
||||||
|
return { ...vp, x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panBy(
|
||||||
|
vp: Viewport,
|
||||||
|
dxScreen: number,
|
||||||
|
dyScreen: number,
|
||||||
|
screen: ScreenSize,
|
||||||
|
pageCount: number,
|
||||||
|
): Viewport {
|
||||||
|
return clampViewport(
|
||||||
|
{ ...vp, x: vp.x - dxScreen / vp.scale, y: vp.y - dyScreen / vp.scale },
|
||||||
|
screen,
|
||||||
|
pageCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoomAt(
|
||||||
|
vp: Viewport,
|
||||||
|
focal: Point,
|
||||||
|
nextScale: number,
|
||||||
|
screen: ScreenSize,
|
||||||
|
pageCount: number,
|
||||||
|
): Viewport {
|
||||||
|
const scale = clampScale(nextScale, screen.width);
|
||||||
|
const worldFx = vp.x + focal.x / vp.scale;
|
||||||
|
const worldFy = vp.y + focal.y / vp.scale;
|
||||||
|
return clampViewport(
|
||||||
|
{ scale, x: worldFx - focal.x / scale, y: worldFy - focal.y / scale },
|
||||||
|
screen,
|
||||||
|
pageCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function screenToWorld(vp: Viewport, screenX: number, screenY: number): Point {
|
||||||
|
return { x: vp.x + screenX / vp.scale, y: vp.y + screenY / vp.scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visiblePageRange(
|
||||||
|
vp: Viewport,
|
||||||
|
screen: ScreenSize,
|
||||||
|
pageCount: number,
|
||||||
|
): { first: number; last: number } {
|
||||||
|
const span = PAGE_HEIGHT + PAGE_GAP;
|
||||||
|
const first = Math.max(0, Math.floor((vp.y - PAGE_TOP_MARGIN) / span));
|
||||||
|
const last = Math.min(
|
||||||
|
pageCount - 1,
|
||||||
|
Math.floor((vp.y + screen.height / vp.scale - PAGE_TOP_MARGIN) / span),
|
||||||
|
);
|
||||||
|
return { first, last: Math.max(first, last) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { WhiteboardView } from './WhiteboardView';
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hexToRgb, isDarkColor, normalizeHex } from "./color";
|
||||||
|
|
||||||
|
describe("normalizeHex", () => {
|
||||||
|
it("accepts a six-digit hex with hash", () => {
|
||||||
|
expect(normalizeHex("#2f6fdd")).toBe("#2f6fdd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a six-digit hex without hash and lowercases it", () => {
|
||||||
|
expect(normalizeHex("D64541")).toBe("#d64541");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a three-digit hex", () => {
|
||||||
|
expect(normalizeHex("#f2b")).toBe("#ff22bb");
|
||||||
|
expect(normalizeHex("abc")).toBe("#aabbcc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims surrounding whitespace", () => {
|
||||||
|
expect(normalizeHex(" #1a1a1a ")).toBe("#1a1a1a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid input", () => {
|
||||||
|
expect(normalizeHex("")).toBeNull();
|
||||||
|
expect(normalizeHex("#12345")).toBeNull();
|
||||||
|
expect(normalizeHex("#1234567")).toBeNull();
|
||||||
|
expect(normalizeHex("red")).toBeNull();
|
||||||
|
expect(normalizeHex("#gg0000")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hexToRgb", () => {
|
||||||
|
it("parses channels in the 0-1 range", () => {
|
||||||
|
expect(hexToRgb("#ffffff")).toEqual({ r: 1, g: 1, b: 1 });
|
||||||
|
expect(hexToRgb("#000000")).toEqual({ r: 0, g: 0, b: 0 });
|
||||||
|
const { r, g, b } = hexToRgb("#2f6fdd") ?? { r: 0, g: 0, b: 0 };
|
||||||
|
expect(r).toBeCloseTo(47 / 255);
|
||||||
|
expect(g).toBeCloseTo(111 / 255);
|
||||||
|
expect(b).toBeCloseTo(221 / 255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid input", () => {
|
||||||
|
expect(hexToRgb("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isDarkColor", () => {
|
||||||
|
it("treats black and the blackboard green as dark", () => {
|
||||||
|
expect(isDarkColor("#1a1a1a")).toBe(true);
|
||||||
|
expect(isDarkColor("#003423")).toBe(true);
|
||||||
|
expect(isDarkColor("#26262a")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats white and light tones as light", () => {
|
||||||
|
expect(isDarkColor("#ffffff")).toBe(false);
|
||||||
|
expect(isDarkColor("#fbf3db")).toBe(false);
|
||||||
|
expect(isDarkColor("#eef1f4")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to light for invalid input", () => {
|
||||||
|
expect(isDarkColor("invalid")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function normalizeHex(input: string): string | null {
|
||||||
|
const match = input.trim().match(/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const hex = match[1].toLowerCase();
|
||||||
|
return `#${hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
|
const normalized = normalizeHex(hex);
|
||||||
|
if (!normalized) return null;
|
||||||
|
return {
|
||||||
|
r: parseInt(normalized.slice(1, 3), 16) / 255,
|
||||||
|
g: parseInt(normalized.slice(3, 5), 16) / 255,
|
||||||
|
b: parseInt(normalized.slice(5, 7), 16) / 255,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDarkColor(hex: string): boolean {
|
||||||
|
const rgb = hexToRgb(hex);
|
||||||
|
if (!rgb) return false;
|
||||||
|
return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b < 0.5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hitTestStroke, pointToSegmentDistance, strokeBounds } from "./hitTest";
|
||||||
|
import { arrowHead } from "./shapeGeometry";
|
||||||
|
import type { Stroke } from "./stroke";
|
||||||
|
|
||||||
|
function horizontalStroke(): Stroke {
|
||||||
|
return {
|
||||||
|
id: "s1",
|
||||||
|
pen: "pen",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size: 6,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: [
|
||||||
|
{ x: 10, y: 50, pressure: 0.5 },
|
||||||
|
{ x: 60, y: 50, pressure: 0.5 },
|
||||||
|
{ x: 110, y: 50, pressure: 0.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pointToSegmentDistance", () => {
|
||||||
|
it("measures the perpendicular distance", () => {
|
||||||
|
expect(pointToSegmentDistance(5, 4, 0, 0, 10, 0)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to the segment endpoints", () => {
|
||||||
|
expect(pointToSegmentDistance(-3, 4, 0, 0, 10, 0)).toBe(5);
|
||||||
|
expect(pointToSegmentDistance(14, 0, 0, 0, 10, 0)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a zero-length segment", () => {
|
||||||
|
expect(pointToSegmentDistance(3, 4, 0, 0, 0, 0)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("strokeBounds", () => {
|
||||||
|
it("expands the point bounds by the padding", () => {
|
||||||
|
expect(strokeBounds(horizontalStroke(), 5)).toEqual({
|
||||||
|
minX: 5,
|
||||||
|
minY: 45,
|
||||||
|
maxX: 115,
|
||||||
|
maxY: 55,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hitTestStroke", () => {
|
||||||
|
it("hits a point on the stroke", () => {
|
||||||
|
expect(hitTestStroke({ x: 60, y: 51 }, horizontalStroke(), 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits within the stroke width plus tolerance", () => {
|
||||||
|
expect(hitTestStroke({ x: 60, y: 57 }, horizontalStroke(), 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("misses a point beyond the radius", () => {
|
||||||
|
expect(hitTestStroke({ x: 60, y: 62 }, horizontalStroke(), 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects quickly via bounds", () => {
|
||||||
|
expect(hitTestStroke({ x: 300, y: 300 }, horizontalStroke(), 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits a single-point dot", () => {
|
||||||
|
const dot: Stroke = { ...horizontalStroke(), points: [{ x: 20, y: 20, pressure: 0.5 }] };
|
||||||
|
expect(hitTestStroke({ x: 22, y: 22 }, dot, 5)).toBe(true);
|
||||||
|
expect(hitTestStroke({ x: 40, y: 40 }, dot, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function shapeStroke(kind: "line" | "arrow" | "rect" | "ellipse"): Stroke {
|
||||||
|
return {
|
||||||
|
id: "shape-1",
|
||||||
|
pen: "pen",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size: 4,
|
||||||
|
simulatePressure: false,
|
||||||
|
shape: kind,
|
||||||
|
points: [
|
||||||
|
{ x: 20, y: 20, pressure: 0.5 },
|
||||||
|
{ x: 120, y: 100, pressure: 0.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("hitTestStroke with shapes", () => {
|
||||||
|
it("hits a line on the segment and misses off it", () => {
|
||||||
|
expect(hitTestStroke({ x: 70, y: 61 }, shapeStroke("line"), 5)).toBe(true);
|
||||||
|
expect(hitTestStroke({ x: 70, y: 90 }, shapeStroke("line"), 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits a rect only near its border", () => {
|
||||||
|
const rect = shapeStroke("rect");
|
||||||
|
expect(hitTestStroke({ x: 20, y: 60 }, rect, 5)).toBe(true);
|
||||||
|
expect(hitTestStroke({ x: 70, y: 60 }, rect, 5)).toBe(false);
|
||||||
|
expect(hitTestStroke({ x: 5, y: 60 }, rect, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits an ellipse only near its border", () => {
|
||||||
|
const ellipse = shapeStroke("ellipse");
|
||||||
|
expect(hitTestStroke({ x: 120, y: 60 }, ellipse, 5)).toBe(true);
|
||||||
|
expect(hitTestStroke({ x: 70, y: 60 }, ellipse, 5)).toBe(false);
|
||||||
|
expect(hitTestStroke({ x: 160, y: 60 }, ellipse, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits a tiny ellipse on its rim and interior", () => {
|
||||||
|
const tiny: Stroke = {
|
||||||
|
...shapeStroke("ellipse"),
|
||||||
|
points: [
|
||||||
|
{ x: 100, y: 100, pressure: 0.5 },
|
||||||
|
{ x: 105, y: 105, pressure: 0.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(hitTestStroke({ x: 105, y: 102.5 }, tiny, 5)).toBe(true);
|
||||||
|
expect(hitTestStroke({ x: 102.5, y: 102.5 }, tiny, 5)).toBe(true);
|
||||||
|
expect(hitTestStroke({ x: 120, y: 120 }, tiny, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hit deep inside or far outside an eccentric ellipse", () => {
|
||||||
|
const flat: Stroke = {
|
||||||
|
...shapeStroke("ellipse"),
|
||||||
|
points: [
|
||||||
|
{ x: 100, y: 500, pressure: 0.5 },
|
||||||
|
{ x: 700, y: 540, pressure: 0.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(hitTestStroke({ x: 600, y: 520 }, flat, 5)).toBe(false);
|
||||||
|
expect(hitTestStroke({ x: 745, y: 520 }, flat, 5)).toBe(false);
|
||||||
|
expect(hitTestStroke({ x: 700, y: 520 }, flat, 5)).toBe(true);
|
||||||
|
expect(hitTestStroke({ x: 705, y: 520 }, flat, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands arrow bounds to include the head wings", () => {
|
||||||
|
const arrow: Stroke = {
|
||||||
|
...shapeStroke("arrow"),
|
||||||
|
points: [
|
||||||
|
{ x: 100, y: 20, pressure: 0.5 },
|
||||||
|
{ x: 100, y: 100, pressure: 0.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const [left, right] = arrowHead(arrow.points[0], arrow.points[1], arrow.size);
|
||||||
|
const bounds = strokeBounds(arrow, 0);
|
||||||
|
expect(bounds.minX).toBeCloseTo(Math.min(100, left.x, right.x));
|
||||||
|
expect(bounds.maxX).toBeCloseTo(Math.max(100, left.x, right.x));
|
||||||
|
expect(bounds.maxX - bounds.minX).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { arrowHead, ellipseOutline } from "./shapeGeometry";
|
||||||
|
import { effectiveStrokeSize, type Stroke } from "./stroke";
|
||||||
|
|
||||||
|
export const ERASER_TOLERANCE = 5;
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointToSegmentDistance(
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
ax: number,
|
||||||
|
ay: number,
|
||||||
|
bx: number,
|
||||||
|
by: number,
|
||||||
|
): number {
|
||||||
|
const dx = bx - ax;
|
||||||
|
const dy = by - ay;
|
||||||
|
const lengthSq = dx * dx + dy * dy;
|
||||||
|
if (lengthSq === 0) return Math.hypot(px - ax, py - ay);
|
||||||
|
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSq));
|
||||||
|
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strokeBounds(stroke: Stroke, padding: number): Bounds {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const point of stroke.points) {
|
||||||
|
minX = Math.min(minX, point.x);
|
||||||
|
minY = Math.min(minY, point.y);
|
||||||
|
maxX = Math.max(maxX, point.x);
|
||||||
|
maxY = Math.max(maxY, point.y);
|
||||||
|
}
|
||||||
|
if (stroke.shape === "arrow") {
|
||||||
|
const [a, b] = stroke.points;
|
||||||
|
if (a && b) {
|
||||||
|
for (const wing of arrowHead(a, b, stroke.size)) {
|
||||||
|
minX = Math.min(minX, wing.x);
|
||||||
|
minY = Math.min(minY, wing.y);
|
||||||
|
maxX = Math.max(maxX, wing.x);
|
||||||
|
maxY = Math.max(maxY, wing.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
minX: minX - padding,
|
||||||
|
minY: minY - padding,
|
||||||
|
maxX: maxX + padding,
|
||||||
|
maxY: maxY + padding,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hitTestStroke(
|
||||||
|
point: { x: number; y: number },
|
||||||
|
stroke: Stroke,
|
||||||
|
tolerance: number,
|
||||||
|
): boolean {
|
||||||
|
if (stroke.shape) return hitTestShape(point, stroke, tolerance);
|
||||||
|
const radius = effectiveStrokeSize(stroke) / 2 + tolerance;
|
||||||
|
const bounds = strokeBounds(stroke, radius);
|
||||||
|
if (
|
||||||
|
point.x < bounds.minX ||
|
||||||
|
point.x > bounds.maxX ||
|
||||||
|
point.y < bounds.minY ||
|
||||||
|
point.y > bounds.maxY
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const points = stroke.points;
|
||||||
|
if (points.length === 1) {
|
||||||
|
return Math.hypot(point.x - points[0].x, point.y - points[0].y) <= radius;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const a = points[i];
|
||||||
|
const b = points[i + 1];
|
||||||
|
if (pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestShape(point: { x: number; y: number }, stroke: Stroke, tolerance: number): boolean {
|
||||||
|
const radius = stroke.size / 2 + tolerance;
|
||||||
|
const [a, b] = stroke.points;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
switch (stroke.shape) {
|
||||||
|
case "line":
|
||||||
|
return pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius;
|
||||||
|
case "arrow": {
|
||||||
|
if (pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius) return true;
|
||||||
|
const [left, right] = arrowHead(a, b, stroke.size);
|
||||||
|
return (
|
||||||
|
pointToSegmentDistance(point.x, point.y, b.x, b.y, left.x, left.y) <= radius ||
|
||||||
|
pointToSegmentDistance(point.x, point.y, b.x, b.y, right.x, right.y) <= radius
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "rect": {
|
||||||
|
const x1 = Math.min(a.x, b.x);
|
||||||
|
const x2 = Math.max(a.x, b.x);
|
||||||
|
const y1 = Math.min(a.y, b.y);
|
||||||
|
const y2 = Math.max(a.y, b.y);
|
||||||
|
const insideExpanded =
|
||||||
|
point.x >= x1 - radius &&
|
||||||
|
point.x <= x2 + radius &&
|
||||||
|
point.y >= y1 - radius &&
|
||||||
|
point.y <= y2 + radius;
|
||||||
|
const insideShrunk =
|
||||||
|
point.x > x1 + radius &&
|
||||||
|
point.x < x2 - radius &&
|
||||||
|
point.y > y1 + radius &&
|
||||||
|
point.y < y2 - radius;
|
||||||
|
return insideExpanded && !insideShrunk;
|
||||||
|
}
|
||||||
|
case "ellipse": {
|
||||||
|
const rx = Math.abs(b.x - a.x) / 2;
|
||||||
|
const ry = Math.abs(b.y - a.y) / 2;
|
||||||
|
if (rx < 1 || ry < 1) {
|
||||||
|
return pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius;
|
||||||
|
}
|
||||||
|
const outline = ellipseOutline(a, b);
|
||||||
|
for (let i = 0; i < outline.length; i++) {
|
||||||
|
const p1 = outline[i];
|
||||||
|
const p2 = outline[(i + 1) % outline.length];
|
||||||
|
if (pointToSegmentDistance(point.x, point.y, p1.x, p1.y, p2.x, p2.y) <= radius) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createImageItem, imageExtension, placeImageCentered, placeImageSize } from "./image";
|
||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH, PLACEMENT_MARGIN } from "./page";
|
||||||
|
|
||||||
|
describe("placeImageSize", () => {
|
||||||
|
it("keeps small images at their natural size", () => {
|
||||||
|
expect(placeImageSize(200, 100)).toEqual({ width: 200, height: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrinks oversized images to fit the page keeping aspect ratio", () => {
|
||||||
|
const maxWidth = PAGE_WIDTH - PLACEMENT_MARGIN * 2;
|
||||||
|
const { width, height } = placeImageSize(maxWidth * 2, 100);
|
||||||
|
expect(width).toBeCloseTo(maxWidth);
|
||||||
|
expect(height).toBeCloseTo(100 / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits very tall images by height", () => {
|
||||||
|
const maxHeight = PAGE_HEIGHT - PLACEMENT_MARGIN * 2;
|
||||||
|
const { width, height } = placeImageSize(100, maxHeight * 4);
|
||||||
|
expect(height).toBeCloseTo(maxHeight);
|
||||||
|
expect(width).toBeCloseTo(100 / 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a default size for degenerate input", () => {
|
||||||
|
expect(placeImageSize(0, 0)).toEqual({ width: 300, height: 150 });
|
||||||
|
expect(placeImageSize(Number.NaN, Number.POSITIVE_INFINITY)).toEqual({
|
||||||
|
width: 300,
|
||||||
|
height: 150,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createImageItem", () => {
|
||||||
|
it("places the image at the top-left placement margin", () => {
|
||||||
|
const item = createImageItem("img-1", 200, 100);
|
||||||
|
expect(item.imageId).toBe("img-1");
|
||||||
|
expect(item.id).not.toBe("img-1");
|
||||||
|
expect(item.x).toBe(PLACEMENT_MARGIN);
|
||||||
|
expect(item.y).toBe(PLACEMENT_MARGIN);
|
||||||
|
expect(item.width).toBe(200);
|
||||||
|
expect(item.height).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("placeImageCentered", () => {
|
||||||
|
it("fills the page width with a wide image and centers it vertically", () => {
|
||||||
|
const placed = placeImageCentered(200, 100);
|
||||||
|
expect(placed.width).toBeCloseTo(PAGE_WIDTH);
|
||||||
|
expect(placed.height).toBeCloseTo((100 * PAGE_WIDTH) / 200);
|
||||||
|
expect(placed.x).toBeCloseTo(0);
|
||||||
|
expect(placed.y).toBeCloseTo((PAGE_HEIGHT - placed.height) / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills the page height with a tall image and centers it horizontally", () => {
|
||||||
|
const placed = placeImageCentered(100, 200);
|
||||||
|
expect(placed.height).toBeCloseTo(PAGE_HEIGHT);
|
||||||
|
expect(placed.width).toBeCloseTo((100 * PAGE_HEIGHT) / 200);
|
||||||
|
expect(placed.y).toBeCloseTo(0);
|
||||||
|
expect(placed.x).toBeCloseTo((PAGE_WIDTH - placed.width) / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upscales small images to fill the page", () => {
|
||||||
|
const placed = placeImageCentered(50, 50);
|
||||||
|
expect(placed.width).toBeGreaterThan(50);
|
||||||
|
expect(placed.height).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the page aspect with at most one blank side", () => {
|
||||||
|
for (const [w, h] of [
|
||||||
|
[400, 300],
|
||||||
|
[300, 400],
|
||||||
|
[1000, 100],
|
||||||
|
]) {
|
||||||
|
const placed = placeImageCentered(w, h);
|
||||||
|
const marginX = Math.min(placed.x, PAGE_WIDTH - placed.x - placed.width);
|
||||||
|
const marginY = Math.min(placed.y, PAGE_HEIGHT - placed.y - placed.height);
|
||||||
|
expect(Math.min(marginX, marginY)).toBeCloseTo(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("imageExtension", () => {
|
||||||
|
it("maps known mime types to file extensions", () => {
|
||||||
|
expect(imageExtension("image/jpeg")).toBe("jpg");
|
||||||
|
expect(imageExtension("image/png")).toBe("png");
|
||||||
|
expect(imageExtension("image/svg+xml")).toBe("svg");
|
||||||
|
expect(imageExtension("image/webp")).toBe("webp");
|
||||||
|
expect(imageExtension("image/gif")).toBe("gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to bin for unknown types", () => {
|
||||||
|
expect(imageExtension("image/x-unknown")).toBe("bin");
|
||||||
|
expect(imageExtension("")).toBe("bin");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH, PLACEMENT_MARGIN } from "./page";
|
||||||
|
import { newId } from "./stroke";
|
||||||
|
|
||||||
|
export interface ImageItem {
|
||||||
|
id: string;
|
||||||
|
imageId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
locked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeImageSize(
|
||||||
|
naturalWidth: number,
|
||||||
|
naturalHeight: number,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const safeWidth = naturalWidth > 0 && Number.isFinite(naturalWidth) ? naturalWidth : 300;
|
||||||
|
const safeHeight = naturalHeight > 0 && Number.isFinite(naturalHeight) ? naturalHeight : 150;
|
||||||
|
const maxWidth = PAGE_WIDTH - PLACEMENT_MARGIN * 2;
|
||||||
|
const maxHeight = PAGE_HEIGHT - PLACEMENT_MARGIN * 2;
|
||||||
|
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight);
|
||||||
|
return { width: safeWidth * scale, height: safeHeight * scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeImageCentered(
|
||||||
|
naturalWidth: number,
|
||||||
|
naturalHeight: number,
|
||||||
|
): { x: number; y: number; width: number; height: number } {
|
||||||
|
const safeWidth = naturalWidth > 0 && Number.isFinite(naturalWidth) ? naturalWidth : 300;
|
||||||
|
const safeHeight = naturalHeight > 0 && Number.isFinite(naturalHeight) ? naturalHeight : 150;
|
||||||
|
const scale = Math.min(PAGE_WIDTH / safeWidth, PAGE_HEIGHT / safeHeight);
|
||||||
|
const width = safeWidth * scale;
|
||||||
|
const height = safeHeight * scale;
|
||||||
|
return {
|
||||||
|
x: (PAGE_WIDTH - width) / 2,
|
||||||
|
y: (PAGE_HEIGHT - height) / 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImageItem(
|
||||||
|
imageId: string,
|
||||||
|
naturalWidth: number,
|
||||||
|
naturalHeight: number,
|
||||||
|
): ImageItem {
|
||||||
|
const { width, height } = placeImageSize(naturalWidth, naturalHeight);
|
||||||
|
return { id: newId(), imageId, x: PLACEMENT_MARGIN, y: PLACEMENT_MARGIN, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageExtension(mimeType: string): string {
|
||||||
|
switch (mimeType) {
|
||||||
|
case "image/jpeg":
|
||||||
|
return "jpg";
|
||||||
|
case "image/png":
|
||||||
|
return "png";
|
||||||
|
case "image/svg+xml":
|
||||||
|
return "svg";
|
||||||
|
case "image/webp":
|
||||||
|
return "webp";
|
||||||
|
case "image/gif":
|
||||||
|
return "gif";
|
||||||
|
case "image/avif":
|
||||||
|
return "avif";
|
||||||
|
default:
|
||||||
|
return "bin";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
clampToPage,
|
||||||
|
clonePageWithNewIds,
|
||||||
|
contentHeight,
|
||||||
|
PAGE_GAP,
|
||||||
|
PAGE_HEIGHT,
|
||||||
|
PAGE_TOP_MARGIN,
|
||||||
|
PAGE_WIDTH,
|
||||||
|
pageAt,
|
||||||
|
pageIndexAtY,
|
||||||
|
pageTopY,
|
||||||
|
trimTrailingBlankPages,
|
||||||
|
} from "./page";
|
||||||
|
|
||||||
|
describe("page geometry", () => {
|
||||||
|
it("computes page top offsets", () => {
|
||||||
|
expect(pageTopY(0)).toBe(PAGE_TOP_MARGIN);
|
||||||
|
expect(pageTopY(1)).toBe(PAGE_TOP_MARGIN + PAGE_HEIGHT + PAGE_GAP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes content height with margins and gaps", () => {
|
||||||
|
expect(contentHeight(1)).toBe(PAGE_TOP_MARGIN + PAGE_HEIGHT + PAGE_GAP);
|
||||||
|
expect(contentHeight(2)).toBe(PAGE_TOP_MARGIN + 2 * PAGE_HEIGHT + 2 * PAGE_GAP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits a point inside the first page", () => {
|
||||||
|
const hit = pageAt(100, PAGE_TOP_MARGIN + 50, 3);
|
||||||
|
expect(hit).toEqual({ index: 0, x: 100, y: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits a point inside a later page", () => {
|
||||||
|
const hit = pageAt(10, pageTopY(2) + 20, 3);
|
||||||
|
expect(hit).toEqual({ index: 2, x: 10, y: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("misses the gap between pages", () => {
|
||||||
|
expect(pageAt(100, PAGE_TOP_MARGIN + PAGE_HEIGHT + 5, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("misses outside the page horizontally", () => {
|
||||||
|
expect(pageAt(-1, 100, 3)).toBeNull();
|
||||||
|
expect(pageAt(PAGE_WIDTH + 1, 100, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("misses beyond the last page", () => {
|
||||||
|
expect(pageAt(100, pageTopY(5), 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a world y to the nearest page index", () => {
|
||||||
|
expect(pageIndexAtY(pageTopY(1) + 5, 3)).toBe(1);
|
||||||
|
expect(pageIndexAtY(-100, 3)).toBe(0);
|
||||||
|
expect(pageIndexAtY(99999, 3)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps points to the page bounds", () => {
|
||||||
|
expect(clampToPage(-5, 2000)).toEqual({ x: 0, y: PAGE_HEIGHT });
|
||||||
|
expect(clampToPage(100, 200)).toEqual({ x: 100, y: 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trimTrailingBlankPages", () => {
|
||||||
|
const blank = (id: string) => ({
|
||||||
|
id,
|
||||||
|
strokes: [],
|
||||||
|
images: [],
|
||||||
|
paperColor: "#ffffff",
|
||||||
|
pattern: "blank" as const,
|
||||||
|
});
|
||||||
|
const written = (id: string) => ({
|
||||||
|
...blank(id),
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
id: `s-${id}`,
|
||||||
|
pen: "pen" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size: 2,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: [{ x: 1, y: 1, pressure: 0.5 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const imaged = (id: string) => ({
|
||||||
|
...blank(id),
|
||||||
|
images: [{ id: `i-${id}`, imageId: "blob", x: 0, y: 0, width: 10, height: 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops trailing blank pages", () => {
|
||||||
|
const pages = [written("a"), blank("b"), blank("c")];
|
||||||
|
expect(trimTrailingBlankPages(pages).map((p) => p.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps blank pages in the middle", () => {
|
||||||
|
const pages = [written("a"), blank("b"), written("c"), blank("d")];
|
||||||
|
expect(trimTrailingBlankPages(pages).map((p) => p.id)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps at least one page", () => {
|
||||||
|
const pages = [blank("a"), blank("b")];
|
||||||
|
expect(trimTrailingBlankPages(pages).map((p) => p.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps everything when the last page has content", () => {
|
||||||
|
const pages = [written("a"), written("b")];
|
||||||
|
expect(trimTrailingBlankPages(pages)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a page with only images as content", () => {
|
||||||
|
const pages = [written("a"), imaged("b")];
|
||||||
|
expect(trimTrailingBlankPages(pages)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clonePageWithNewIds", () => {
|
||||||
|
it("clones a page with fresh ids while preserving content and image references", () => {
|
||||||
|
const source = {
|
||||||
|
id: "page-1",
|
||||||
|
paperColor: "#003423",
|
||||||
|
pattern: "grid" as const,
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
pen: "pen" as const,
|
||||||
|
color: "#d64541",
|
||||||
|
size: 3,
|
||||||
|
simulatePressure: true,
|
||||||
|
shape: "arrow" as const,
|
||||||
|
points: [
|
||||||
|
{ x: 1, y: 2, pressure: 0.4 },
|
||||||
|
{ x: 30, y: 40, pressure: 0.9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
images: [{ id: "i1", imageId: "blob-1", x: 40, y: 40, width: 100, height: 50, locked: true }],
|
||||||
|
};
|
||||||
|
const clone = clonePageWithNewIds(source);
|
||||||
|
expect(clone.id).not.toBe(source.id);
|
||||||
|
expect(clone.strokes[0].id).not.toBe("s1");
|
||||||
|
expect(clone.images[0].id).not.toBe("i1");
|
||||||
|
expect(clone.images[0].imageId).toBe("blob-1");
|
||||||
|
expect(clone.paperColor).toBe("#003423");
|
||||||
|
expect(clone.pattern).toBe("grid");
|
||||||
|
expect(clone.strokes[0]).toMatchObject({
|
||||||
|
pen: "pen",
|
||||||
|
color: "#d64541",
|
||||||
|
size: 3,
|
||||||
|
simulatePressure: true,
|
||||||
|
shape: "arrow",
|
||||||
|
});
|
||||||
|
expect(clone.strokes[0].points).toEqual(source.strokes[0].points);
|
||||||
|
expect(clone.strokes[0].points).not.toBe(source.strokes[0].points);
|
||||||
|
expect(clone.strokes[0].points[0]).not.toBe(source.strokes[0].points[0]);
|
||||||
|
expect(clone.images[0].locked).toBe(true);
|
||||||
|
expect(source.strokes[0].id).toBe("s1");
|
||||||
|
expect(source.images[0].id).toBe("i1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ImageItem } from "./image";
|
||||||
|
import type { Stroke } from "./stroke";
|
||||||
|
import { newId } from "./stroke";
|
||||||
|
|
||||||
|
export const PAGE_WIDTH = 794;
|
||||||
|
export const PAGE_HEIGHT = 1123;
|
||||||
|
export const PAGE_GAP = 24;
|
||||||
|
export const PAGE_TOP_MARGIN = 24;
|
||||||
|
export const PLACEMENT_MARGIN = 40;
|
||||||
|
|
||||||
|
export const PAGE_PATTERNS = ["blank", "lined", "grid", "dots", "rice"] as const;
|
||||||
|
|
||||||
|
export type PagePattern = (typeof PAGE_PATTERNS)[number];
|
||||||
|
|
||||||
|
export interface Page {
|
||||||
|
id: string;
|
||||||
|
strokes: Stroke[];
|
||||||
|
images: ImageItem[];
|
||||||
|
paperColor: string;
|
||||||
|
pattern: PagePattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageHit {
|
||||||
|
index: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPage(paperColor: string, pattern: PagePattern = "blank"): Page {
|
||||||
|
return { id: newId(), strokes: [], images: [], paperColor, pattern };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageTopY(index: number): number {
|
||||||
|
return PAGE_TOP_MARGIN + index * (PAGE_HEIGHT + PAGE_GAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentHeight(pageCount: number): number {
|
||||||
|
if (pageCount <= 0) return PAGE_TOP_MARGIN;
|
||||||
|
return PAGE_TOP_MARGIN + pageCount * PAGE_HEIGHT + (pageCount - 1) * PAGE_GAP + PAGE_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageIndexAtY(worldY: number, pageCount: number): number {
|
||||||
|
const raw = Math.floor((worldY - PAGE_TOP_MARGIN) / (PAGE_HEIGHT + PAGE_GAP));
|
||||||
|
return Math.min(pageCount - 1, Math.max(0, raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageAt(worldX: number, worldY: number, pageCount: number): PageHit | null {
|
||||||
|
if (worldX < 0 || worldX > PAGE_WIDTH) return null;
|
||||||
|
const rel = worldY - PAGE_TOP_MARGIN;
|
||||||
|
if (rel < 0) return null;
|
||||||
|
const index = Math.floor(rel / (PAGE_HEIGHT + PAGE_GAP));
|
||||||
|
if (index >= pageCount) return null;
|
||||||
|
const y = rel - index * (PAGE_HEIGHT + PAGE_GAP);
|
||||||
|
if (y > PAGE_HEIGHT) return null;
|
||||||
|
return { index, x: worldX, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampToPage(x: number, y: number): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: Math.min(PAGE_WIDTH, Math.max(0, x)),
|
||||||
|
y: Math.min(PAGE_HEIGHT, Math.max(0, y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimTrailingBlankPages(pages: Page[]): Page[] {
|
||||||
|
let end = pages.length;
|
||||||
|
while (end > 1 && pages[end - 1].strokes.length === 0 && pages[end - 1].images.length === 0) {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
return pages.slice(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clonePageWithNewIds(page: Page): Page {
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
paperColor: page.paperColor,
|
||||||
|
pattern: page.pattern,
|
||||||
|
strokes: page.strokes.map((stroke) => ({
|
||||||
|
...stroke,
|
||||||
|
id: newId(),
|
||||||
|
points: stroke.points.map((point) => ({ ...point })),
|
||||||
|
})),
|
||||||
|
images: page.images.map((image) => ({ ...image, id: newId() })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH } from "./page";
|
||||||
|
import { PATTERN_MARGIN, PATTERN_SPACING, patternLayout, RICE_CELL } from "./patternLayout";
|
||||||
|
|
||||||
|
describe("patternLayout", () => {
|
||||||
|
it("blank has no geometry", () => {
|
||||||
|
expect(patternLayout("blank")).toEqual({ lines: [], dots: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lined lines stay within margins and keep a larger top gap", () => {
|
||||||
|
const { lines, dots } = patternLayout("lined");
|
||||||
|
expect(dots).toHaveLength(0);
|
||||||
|
expect(lines.length).toBeGreaterThan(10);
|
||||||
|
for (const line of lines) {
|
||||||
|
expect(line.y1).toBe(line.y2);
|
||||||
|
expect(line.dashed).toBe(false);
|
||||||
|
expect(line.x1).toBe(PATTERN_MARGIN);
|
||||||
|
expect(line.x2).toBe(PAGE_WIDTH - PATTERN_MARGIN);
|
||||||
|
}
|
||||||
|
expect(lines[1].y1 - lines[0].y1).toBe(PATTERN_SPACING);
|
||||||
|
const topGap = lines[0].y1;
|
||||||
|
const bottomGap = PAGE_HEIGHT - lines[lines.length - 1].y1;
|
||||||
|
expect(topGap).toBeGreaterThan(bottomGap);
|
||||||
|
expect(topGap).toBe(PATTERN_MARGIN + PATTERN_SPACING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grid draws only complete cells, centered on the page", () => {
|
||||||
|
const { lines } = patternLayout("grid");
|
||||||
|
const vertical = lines.filter((l) => l.x1 === l.x2);
|
||||||
|
const horizontal = lines.filter((l) => l.y1 === l.y2);
|
||||||
|
const cols = vertical.length - 1;
|
||||||
|
const rows = horizontal.length - 1;
|
||||||
|
const minX = Math.min(...vertical.map((l) => l.x1));
|
||||||
|
const maxX = Math.max(...vertical.map((l) => l.x1));
|
||||||
|
const minY = Math.min(...horizontal.map((l) => l.y1));
|
||||||
|
const maxY = Math.max(...horizontal.map((l) => l.y1));
|
||||||
|
expect((maxX - minX) % PATTERN_SPACING).toBe(0);
|
||||||
|
expect((maxY - minY) % PATTERN_SPACING).toBe(0);
|
||||||
|
expect(maxX - minX).toBe(cols * PATTERN_SPACING);
|
||||||
|
expect(maxY - minY).toBe(rows * PATTERN_SPACING);
|
||||||
|
expect(minX).toBeCloseTo(PAGE_WIDTH - maxX, 5);
|
||||||
|
expect(minY).toBeCloseTo(PAGE_HEIGHT - maxY, 5);
|
||||||
|
expect(minX).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||||
|
expect(minY).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dots form a centered lattice within margins", () => {
|
||||||
|
const { lines, dots } = patternLayout("dots");
|
||||||
|
expect(lines).toHaveLength(0);
|
||||||
|
expect(dots.length).toBeGreaterThan(50);
|
||||||
|
const minX = Math.min(...dots.map((d) => d.x));
|
||||||
|
const maxX = Math.max(...dots.map((d) => d.x));
|
||||||
|
const minY = Math.min(...dots.map((d) => d.y));
|
||||||
|
const maxY = Math.max(...dots.map((d) => d.y));
|
||||||
|
expect(minX).toBeCloseTo(PAGE_WIDTH - maxX, 5);
|
||||||
|
expect(minY).toBeCloseTo(PAGE_HEIGHT - maxY, 5);
|
||||||
|
expect(minX).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||||
|
expect(minY).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||||
|
expect(maxX).toBeLessThanOrEqual(PAGE_WIDTH - PATTERN_MARGIN);
|
||||||
|
expect(maxY).toBeLessThanOrEqual(PAGE_HEIGHT - PATTERN_MARGIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rice produces a solid grid with dashed cell guides", () => {
|
||||||
|
const { lines } = patternLayout("rice");
|
||||||
|
const cols = Math.floor((PAGE_WIDTH - 2 * PATTERN_MARGIN) / RICE_CELL);
|
||||||
|
const rows = Math.floor((PAGE_HEIGHT - 2 * PATTERN_MARGIN) / RICE_CELL);
|
||||||
|
const solid = lines.filter((l) => !l.dashed);
|
||||||
|
const dashed = lines.filter((l) => l.dashed);
|
||||||
|
expect(solid).toHaveLength(cols + 1 + rows + 1);
|
||||||
|
expect(dashed).toHaveLength(cols * rows * 4);
|
||||||
|
const xs = solid.map((l) => l.x1);
|
||||||
|
const ys = solid.map((l) => l.y1);
|
||||||
|
expect(Math.min(...xs)).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||||
|
expect(Math.min(...ys)).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centers the rice grid on the page", () => {
|
||||||
|
const { lines } = patternLayout("rice");
|
||||||
|
const solid = lines.filter((l) => !l.dashed);
|
||||||
|
const minX = Math.min(...solid.map((l) => l.x1));
|
||||||
|
const maxX = Math.max(...solid.map((l) => l.x2));
|
||||||
|
expect(minX - 0).toBeCloseTo(PAGE_WIDTH - maxX, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH, type PagePattern } from "./page";
|
||||||
|
|
||||||
|
export const PATTERN_MARGIN = 48;
|
||||||
|
export const PATTERN_SPACING = 56;
|
||||||
|
export const RICE_CELL = 96;
|
||||||
|
export const PATTERN_DASH = [6, 4] as const;
|
||||||
|
|
||||||
|
export interface PatternLine {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
dashed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternDot {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternLayout {
|
||||||
|
lines: PatternLine[];
|
||||||
|
dots: PatternDot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternLayout(pattern: PagePattern): PatternLayout {
|
||||||
|
switch (pattern) {
|
||||||
|
case "blank":
|
||||||
|
return { lines: [], dots: [] };
|
||||||
|
case "lined":
|
||||||
|
return { lines: linedLines(), dots: [] };
|
||||||
|
case "grid":
|
||||||
|
return { lines: gridLines(), dots: [] };
|
||||||
|
case "dots":
|
||||||
|
return { lines: [], dots: dotLattice() };
|
||||||
|
case "rice":
|
||||||
|
return { lines: riceLines(), dots: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function centeredSpan(total: number, spacing: number): { start: number; cells: number } {
|
||||||
|
const usable = total - 2 * PATTERN_MARGIN;
|
||||||
|
const cells = Math.max(1, Math.floor(usable / spacing));
|
||||||
|
return { start: (total - cells * spacing) / 2, cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
function linedLines(): PatternLine[] {
|
||||||
|
const lines: PatternLine[] = [];
|
||||||
|
for (
|
||||||
|
let y = PATTERN_MARGIN + PATTERN_SPACING;
|
||||||
|
y <= PAGE_HEIGHT - PATTERN_MARGIN;
|
||||||
|
y += PATTERN_SPACING
|
||||||
|
) {
|
||||||
|
lines.push({
|
||||||
|
x1: PATTERN_MARGIN,
|
||||||
|
y1: y,
|
||||||
|
x2: PAGE_WIDTH - PATTERN_MARGIN,
|
||||||
|
y2: y,
|
||||||
|
dashed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridLines(): PatternLine[] {
|
||||||
|
const { start: startX, cells: cols } = centeredSpan(PAGE_WIDTH, PATTERN_SPACING);
|
||||||
|
const { start: startY, cells: rows } = centeredSpan(PAGE_HEIGHT, PATTERN_SPACING);
|
||||||
|
const endX = startX + cols * PATTERN_SPACING;
|
||||||
|
const endY = startY + rows * PATTERN_SPACING;
|
||||||
|
const lines: PatternLine[] = [];
|
||||||
|
for (let k = 0; k <= cols; k++) {
|
||||||
|
const x = startX + k * PATTERN_SPACING;
|
||||||
|
lines.push({ x1: x, y1: startY, x2: x, y2: endY, dashed: false });
|
||||||
|
}
|
||||||
|
for (let k = 0; k <= rows; k++) {
|
||||||
|
const y = startY + k * PATTERN_SPACING;
|
||||||
|
lines.push({ x1: startX, y1: y, x2: endX, y2: y, dashed: false });
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotLattice(): PatternDot[] {
|
||||||
|
const { start: startX, cells: cols } = centeredSpan(PAGE_WIDTH, PATTERN_SPACING);
|
||||||
|
const { start: startY, cells: rows } = centeredSpan(PAGE_HEIGHT, PATTERN_SPACING);
|
||||||
|
const dots: PatternDot[] = [];
|
||||||
|
for (let row = 0; row <= rows; row++) {
|
||||||
|
for (let col = 0; col <= cols; col++) {
|
||||||
|
dots.push({
|
||||||
|
x: startX + col * PATTERN_SPACING,
|
||||||
|
y: startY + row * PATTERN_SPACING,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function riceLines(): PatternLine[] {
|
||||||
|
const { start: startX, cells: cols } = centeredSpan(PAGE_WIDTH, RICE_CELL);
|
||||||
|
const { start: startY, cells: rows } = centeredSpan(PAGE_HEIGHT, RICE_CELL);
|
||||||
|
const endX = startX + cols * RICE_CELL;
|
||||||
|
const endY = startY + rows * RICE_CELL;
|
||||||
|
const lines: PatternLine[] = [];
|
||||||
|
|
||||||
|
for (let k = 0; k <= cols; k++) {
|
||||||
|
const x = startX + k * RICE_CELL;
|
||||||
|
lines.push({ x1: x, y1: startY, x2: x, y2: endY, dashed: false });
|
||||||
|
}
|
||||||
|
for (let k = 0; k <= rows; k++) {
|
||||||
|
const y = startY + k * RICE_CELL;
|
||||||
|
lines.push({ x1: startX, y1: y, x2: endX, y2: y, dashed: false });
|
||||||
|
}
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const x = startX + col * RICE_CELL;
|
||||||
|
const y = startY + row * RICE_CELL;
|
||||||
|
const midX = x + RICE_CELL / 2;
|
||||||
|
const midY = y + RICE_CELL / 2;
|
||||||
|
lines.push({ x1: x, y1: midY, x2: x + RICE_CELL, y2: midY, dashed: true });
|
||||||
|
lines.push({ x1: midX, y1: y, x2: midX, y2: y + RICE_CELL, dashed: true });
|
||||||
|
lines.push({ x1: x, y1: y, x2: x + RICE_CELL, y2: y + RICE_CELL, dashed: true });
|
||||||
|
lines.push({ x1: x, y1: y + RICE_CELL, x2: x + RICE_CELL, y2: y, dashed: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
imageInLasso,
|
||||||
|
imagesInLasso,
|
||||||
|
pointInPolygon,
|
||||||
|
segmentsIntersect,
|
||||||
|
strokeInLasso,
|
||||||
|
strokesInLasso,
|
||||||
|
} from "./selection";
|
||||||
|
import type { Stroke } from "./stroke";
|
||||||
|
|
||||||
|
function penStroke(id: string, points: { x: number; y: number }[], size = 4): Stroke {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
pen: "pen",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: points.map((p) => ({ ...p, pressure: 0.5 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeStroke(
|
||||||
|
id: string,
|
||||||
|
shape: Stroke["shape"],
|
||||||
|
a: { x: number; y: number },
|
||||||
|
b: { x: number; y: number },
|
||||||
|
): Stroke {
|
||||||
|
return { ...penStroke(id, [a, b]), shape };
|
||||||
|
}
|
||||||
|
|
||||||
|
const square = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 100, y: 0 },
|
||||||
|
{ x: 100, y: 100 },
|
||||||
|
{ x: 0, y: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("pointInPolygon", () => {
|
||||||
|
it("detects points inside and outside a square", () => {
|
||||||
|
expect(pointInPolygon({ x: 50, y: 50 }, square)).toBe(true);
|
||||||
|
expect(pointInPolygon({ x: 150, y: 50 }, square)).toBe(false);
|
||||||
|
expect(pointInPolygon({ x: -10, y: 50 }, square)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for degenerate polygons", () => {
|
||||||
|
expect(pointInPolygon({ x: 0, y: 0 }, [])).toBe(false);
|
||||||
|
expect(pointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }])).toBe(false);
|
||||||
|
expect(pointInPolygon({ x: 5, y: 5 }, square.slice(0, 2))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles concave polygons", () => {
|
||||||
|
const l = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 100, y: 0 },
|
||||||
|
{ x: 100, y: 40 },
|
||||||
|
{ x: 40, y: 40 },
|
||||||
|
{ x: 40, y: 100 },
|
||||||
|
{ x: 0, y: 100 },
|
||||||
|
];
|
||||||
|
expect(pointInPolygon({ x: 20, y: 20 }, l)).toBe(true);
|
||||||
|
expect(pointInPolygon({ x: 80, y: 80 }, l)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("segmentsIntersect", () => {
|
||||||
|
it("detects crossing segments", () => {
|
||||||
|
expect(
|
||||||
|
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects parallel segments", () => {
|
||||||
|
expect(
|
||||||
|
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 5 }, { x: 10, y: 5 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects collinear points off the segment", () => {
|
||||||
|
expect(
|
||||||
|
segmentsIntersect({ x: 20, y: 0 }, { x: 25, y: 5 }, { x: 0, y: 0 }, { x: 10, y: 0 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects touching endpoints", () => {
|
||||||
|
expect(
|
||||||
|
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("strokeInLasso", () => {
|
||||||
|
it("selects a stroke fully inside the lasso without touching its boundary", () => {
|
||||||
|
const stroke = penStroke("s1", [
|
||||||
|
{ x: 40, y: 40 },
|
||||||
|
{ x: 60, y: 60 },
|
||||||
|
]);
|
||||||
|
expect(strokeInLasso(stroke, square)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a stroke that merely crosses the lasso boundary", () => {
|
||||||
|
const stroke = penStroke("s1", [
|
||||||
|
{ x: -50, y: 50 },
|
||||||
|
{ x: 50, y: 50 },
|
||||||
|
]);
|
||||||
|
expect(strokeInLasso(stroke, square)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an open lasso as closed by linking the last point to the first", () => {
|
||||||
|
const openSquare = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 100, y: 0 },
|
||||||
|
{ x: 100, y: 100 },
|
||||||
|
];
|
||||||
|
const stroke = penStroke("s1", [
|
||||||
|
{ x: -10, y: -10 },
|
||||||
|
{ x: 20, y: 5 },
|
||||||
|
]);
|
||||||
|
expect(strokeInLasso(stroke, openSquare)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects strokes outside the lasso", () => {
|
||||||
|
const stroke = penStroke("s1", [
|
||||||
|
{ x: 200, y: 200 },
|
||||||
|
{ x: 300, y: 300 },
|
||||||
|
]);
|
||||||
|
expect(strokeInLasso(stroke, square)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a thick stroke when the lasso lies entirely within its ink", () => {
|
||||||
|
const stroke = penStroke(
|
||||||
|
"s1",
|
||||||
|
[
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 300, y: 0 },
|
||||||
|
],
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
const tiny = [
|
||||||
|
{ x: 140, y: -5 },
|
||||||
|
{ x: 160, y: -5 },
|
||||||
|
{ x: 160, y: 5 },
|
||||||
|
{ x: 140, y: 5 },
|
||||||
|
];
|
||||||
|
expect(strokeInLasso(stroke, tiny)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a single-point dot stroke inside the lasso", () => {
|
||||||
|
const stroke = penStroke("s1", [{ x: 50, y: 50 }]);
|
||||||
|
expect(strokeInLasso(stroke, square)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects shape strokes by their outline geometry", () => {
|
||||||
|
const rect = shapeStroke("r", "rect", { x: 20, y: 20 }, { x: 80, y: 80 });
|
||||||
|
expect(strokeInLasso(rect, square)).toBe(true);
|
||||||
|
|
||||||
|
const line = shapeStroke("l", "line", { x: -50, y: 50 }, { x: 50, y: 50 });
|
||||||
|
expect(strokeInLasso(line, square)).toBe(true);
|
||||||
|
|
||||||
|
const ellipse = shapeStroke("e", "ellipse", { x: 200, y: 200 }, { x: 260, y: 260 });
|
||||||
|
expect(strokeInLasso(ellipse, square)).toBe(false);
|
||||||
|
|
||||||
|
const ellipseOverlap = shapeStroke("e2", "ellipse", { x: 80, y: 80 }, { x: 160, y: 160 });
|
||||||
|
expect(strokeInLasso(ellipseOverlap, square)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects an arrow whose head wing crosses the lasso", () => {
|
||||||
|
const arrow = shapeStroke("a", "arrow", { x: 104, y: 10 }, { x: 104, y: 80 });
|
||||||
|
expect(strokeInLasso(arrow, square)).toBe(true);
|
||||||
|
const outside = shapeStroke("a2", "arrow", { x: 140, y: 10 }, { x: 140, y: 80 });
|
||||||
|
expect(strokeInLasso(outside, square)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for degenerate lassos", () => {
|
||||||
|
const stroke = penStroke("s1", [
|
||||||
|
{ x: 50, y: 50 },
|
||||||
|
{ x: 60, y: 60 },
|
||||||
|
]);
|
||||||
|
expect(strokeInLasso(stroke, [])).toBe(false);
|
||||||
|
expect(strokeInLasso(stroke, [{ x: 50, y: 50 }])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("strokesInLasso", () => {
|
||||||
|
it("filters strokes preserving order", () => {
|
||||||
|
const inside = penStroke("in", [
|
||||||
|
{ x: 40, y: 40 },
|
||||||
|
{ x: 60, y: 60 },
|
||||||
|
]);
|
||||||
|
const outside = penStroke("out", [
|
||||||
|
{ x: 300, y: 300 },
|
||||||
|
{ x: 320, y: 320 },
|
||||||
|
]);
|
||||||
|
const crossing = penStroke("cross", [
|
||||||
|
{ x: -10, y: 10 },
|
||||||
|
{ x: 10, y: 10 },
|
||||||
|
]);
|
||||||
|
expect(strokesInLasso([inside, outside, crossing], square).map((s) => s.id)).toEqual([
|
||||||
|
"in",
|
||||||
|
"cross",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list for a degenerate lasso", () => {
|
||||||
|
const stroke = penStroke("s1", [{ x: 50, y: 50 }]);
|
||||||
|
expect(strokesInLasso([stroke], [{ x: 50, y: 50 }])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("imageInLasso", () => {
|
||||||
|
const image = { id: "i1", imageId: "blob-1", x: 20, y: 20, width: 60, height: 40 };
|
||||||
|
|
||||||
|
it("selects an image fully inside the lasso", () => {
|
||||||
|
expect(imageInLasso(image, square)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects an image the lasso merely touches", () => {
|
||||||
|
const overlapping = { ...image, x: 90, y: 90 };
|
||||||
|
expect(imageInLasso(overlapping, square)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects an image that fully contains the lasso", () => {
|
||||||
|
const huge = { ...image, x: -50, y: -50, width: 500, height: 500 };
|
||||||
|
const tiny = [
|
||||||
|
{ x: 200, y: 200 },
|
||||||
|
{ x: 220, y: 200 },
|
||||||
|
{ x: 220, y: 220 },
|
||||||
|
{ x: 200, y: 220 },
|
||||||
|
];
|
||||||
|
expect(imageInLasso(huge, tiny)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects images outside the lasso", () => {
|
||||||
|
const outside = { ...image, id: "i2", x: 200, y: 200 };
|
||||||
|
expect(imageInLasso(outside, square)).toBe(false);
|
||||||
|
expect(imagesInLasso([image, outside], square).map((i) => i.id)).toEqual(["i1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a degenerate lasso", () => {
|
||||||
|
expect(imageInLasso(image, [])).toBe(false);
|
||||||
|
expect(imageInLasso(image, [{ x: 50, y: 50 }])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips locked images", () => {
|
||||||
|
const locked = { ...image, id: "i3", locked: true };
|
||||||
|
expect(imageInLasso(locked, square)).toBe(true);
|
||||||
|
expect(imagesInLasso([image, locked], square).map((i) => i.id)).toEqual(["i1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { ERASER_TOLERANCE, hitTestStroke } from "./hitTest";
|
||||||
|
import type { ImageItem } from "./image";
|
||||||
|
import { arrowHead, ellipseOutline, type Point } from "./shapeGeometry";
|
||||||
|
import { effectiveStrokeSize, type Stroke } from "./stroke";
|
||||||
|
|
||||||
|
export function pointInPolygon(point: Point, polygon: Point[]): boolean {
|
||||||
|
if (polygon.length < 3) return false;
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const a = polygon[i];
|
||||||
|
const b = polygon[j];
|
||||||
|
if (a.y > point.y !== b.y > point.y) {
|
||||||
|
const x = ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x;
|
||||||
|
if (point.x < x) inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentsIntersect(a1: Point, a2: Point, b1: Point, b2: Point): boolean {
|
||||||
|
const d1 = cross(b1, b2, a1);
|
||||||
|
const d2 = cross(b1, b2, a2);
|
||||||
|
const d3 = cross(a1, a2, b1);
|
||||||
|
const d4 = cross(a1, a2, b2);
|
||||||
|
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (d1 === 0 && onSegment(b1, b2, a1)) return true;
|
||||||
|
if (d2 === 0 && onSegment(b1, b2, a2)) return true;
|
||||||
|
if (d3 === 0 && onSegment(a1, a2, b1)) return true;
|
||||||
|
if (d4 === 0 && onSegment(a1, a2, b2)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strokeInLasso(stroke: Stroke, lasso: Point[]): boolean {
|
||||||
|
if (lasso.length < 2) return false;
|
||||||
|
const segments = strokeSegments(stroke);
|
||||||
|
const vertices = stroke.shape ? segments.flat() : (stroke.points as Point[]);
|
||||||
|
const radius = effectiveStrokeSize(stroke) / 2 + ERASER_TOLERANCE;
|
||||||
|
const region = lassoBounds(lasso);
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const v of vertices) {
|
||||||
|
minX = Math.min(minX, v.x);
|
||||||
|
minY = Math.min(minY, v.y);
|
||||||
|
maxX = Math.max(maxX, v.x);
|
||||||
|
maxY = Math.max(maxY, v.y);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
maxX + radius < region.minX ||
|
||||||
|
minX - radius > region.maxX ||
|
||||||
|
maxY + radius < region.minY ||
|
||||||
|
minY - radius > region.maxY
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const vertex of vertices) {
|
||||||
|
if (pointInPolygon(vertex, lasso)) return true;
|
||||||
|
}
|
||||||
|
const edges = polygonEdges(lasso);
|
||||||
|
for (const [p1, p2] of segments) {
|
||||||
|
for (const [q1, q2] of edges) {
|
||||||
|
if (segmentsIntersect(p1, p2, q1, q2)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const vertex of lasso) {
|
||||||
|
if (hitTestStroke(vertex, stroke, ERASER_TOLERANCE)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strokesInLasso(strokes: Stroke[], lasso: Point[]): Stroke[] {
|
||||||
|
if (lasso.length < 2) return [];
|
||||||
|
return strokes.filter((stroke) => strokeInLasso(stroke, lasso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageInLasso(image: ImageItem, lasso: Point[]): boolean {
|
||||||
|
if (lasso.length < 2) return false;
|
||||||
|
const region = lassoBounds(lasso);
|
||||||
|
if (
|
||||||
|
image.x > region.maxX ||
|
||||||
|
image.x + image.width < region.minX ||
|
||||||
|
image.y > region.maxY ||
|
||||||
|
image.y + image.height < region.minY
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const corners = [
|
||||||
|
{ x: image.x, y: image.y },
|
||||||
|
{ x: image.x + image.width, y: image.y },
|
||||||
|
{ x: image.x + image.width, y: image.y + image.height },
|
||||||
|
{ x: image.x, y: image.y + image.height },
|
||||||
|
];
|
||||||
|
for (const corner of corners) {
|
||||||
|
if (pointInPolygon(corner, lasso)) return true;
|
||||||
|
}
|
||||||
|
const lassoEdges = polygonEdges(lasso);
|
||||||
|
for (let i = 0; i < corners.length; i++) {
|
||||||
|
const p1 = corners[i];
|
||||||
|
const p2 = corners[(i + 1) % corners.length];
|
||||||
|
for (const [q1, q2] of lassoEdges) {
|
||||||
|
if (segmentsIntersect(p1, p2, q1, q2)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const vertex of lasso) {
|
||||||
|
if (
|
||||||
|
vertex.x >= image.x &&
|
||||||
|
vertex.x <= image.x + image.width &&
|
||||||
|
vertex.y >= image.y &&
|
||||||
|
vertex.y <= image.y + image.height
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imagesInLasso(images: ImageItem[], lasso: Point[]): ImageItem[] {
|
||||||
|
if (lasso.length < 2) return [];
|
||||||
|
return images.filter((image) => !image.locked && imageInLasso(image, lasso));
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonEdges(polygon: Point[]): [Point, Point][] {
|
||||||
|
const edges: [Point, Point][] = [];
|
||||||
|
for (let i = 0; i < polygon.length; i++) {
|
||||||
|
edges.push([polygon[i], polygon[(i + 1) % polygon.length]]);
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokeSegments(stroke: Stroke): [Point, Point][] {
|
||||||
|
if (!stroke.shape) {
|
||||||
|
const segments: [Point, Point][] = [];
|
||||||
|
const points = stroke.points;
|
||||||
|
for (let i = 0; i < points.length - 1; i++) segments.push([points[i], points[i + 1]]);
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
const [a, b] = stroke.points;
|
||||||
|
if (!a || !b) return [];
|
||||||
|
switch (stroke.shape) {
|
||||||
|
case "line":
|
||||||
|
return [[a, b]];
|
||||||
|
case "arrow": {
|
||||||
|
const [left, right] = arrowHead(a, b, stroke.size);
|
||||||
|
return [
|
||||||
|
[a, b],
|
||||||
|
[b, left],
|
||||||
|
[b, right],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "rect": {
|
||||||
|
const tl = { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) };
|
||||||
|
const br = { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) };
|
||||||
|
const tr = { x: br.x, y: tl.y };
|
||||||
|
const bl = { x: tl.x, y: br.y };
|
||||||
|
return [
|
||||||
|
[tl, tr],
|
||||||
|
[tr, br],
|
||||||
|
[br, bl],
|
||||||
|
[bl, tl],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "ellipse": {
|
||||||
|
const rx = Math.abs(b.x - a.x) / 2;
|
||||||
|
const ry = Math.abs(b.y - a.y) / 2;
|
||||||
|
if (rx < 1 || ry < 1) return [[a, b]];
|
||||||
|
const outline = ellipseOutline(a, b);
|
||||||
|
return outline.map((point, i) => [point, outline[(i + 1) % outline.length]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lassoBounds(lasso: Point[]): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const p of lasso) {
|
||||||
|
minX = Math.min(minX, p.x);
|
||||||
|
minY = Math.min(minY, p.y);
|
||||||
|
maxX = Math.max(maxX, p.x);
|
||||||
|
maxY = Math.max(maxY, p.y);
|
||||||
|
}
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(o: Point, a: Point, b: Point): number {
|
||||||
|
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSegment(a: Point, b: Point, p: Point): boolean {
|
||||||
|
return (
|
||||||
|
Math.min(a.x, b.x) <= p.x &&
|
||||||
|
p.x <= Math.max(a.x, b.x) &&
|
||||||
|
Math.min(a.y, b.y) <= p.y &&
|
||||||
|
p.y <= Math.max(a.y, b.y)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ELLIPSE_OUTLINE_SEGMENTS = 32;
|
||||||
|
|
||||||
|
export function arrowHead(a: Point, b: Point, size: number): [Point, Point] {
|
||||||
|
const angle = Math.atan2(b.y - a.y, b.x - a.x);
|
||||||
|
const length = Math.max(10, size * 4);
|
||||||
|
const spread = Math.PI / 7;
|
||||||
|
return [
|
||||||
|
{ x: b.x - length * Math.cos(angle - spread), y: b.y - length * Math.sin(angle - spread) },
|
||||||
|
{ x: b.x - length * Math.cos(angle + spread), y: b.y - length * Math.sin(angle + spread) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ellipseOutline(a: Point, b: Point, segments = ELLIPSE_OUTLINE_SEGMENTS): Point[] {
|
||||||
|
const cx = (a.x + b.x) / 2;
|
||||||
|
const cy = (a.y + b.y) / 2;
|
||||||
|
const rx = Math.abs(b.x - a.x) / 2;
|
||||||
|
const ry = Math.abs(b.y - a.y) / 2;
|
||||||
|
const points: Point[] = [];
|
||||||
|
for (let i = 0; i < segments; i++) {
|
||||||
|
const angle = (i / segments) * Math.PI * 2;
|
||||||
|
points.push({ x: cx + rx * Math.cos(angle), y: cy + ry * Math.sin(angle) });
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createStroke } from "./stroke";
|
||||||
|
|
||||||
|
const baseInput = {
|
||||||
|
pen: "pen" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size: 5,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: [{ x: 0, y: 0, pressure: 0.5 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createStroke", () => {
|
||||||
|
it("assigns a unique non-empty id to each stroke", () => {
|
||||||
|
const a = createStroke(baseInput);
|
||||||
|
const b = createStroke(baseInput);
|
||||||
|
expect(a.id).toBeTruthy();
|
||||||
|
expect(b.id).toBeTruthy();
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the input fields untouched", () => {
|
||||||
|
const stroke = createStroke(baseInput);
|
||||||
|
expect(stroke.pen).toBe("pen");
|
||||||
|
expect(stroke.color).toBe("#1a1a1a");
|
||||||
|
expect(stroke.size).toBe(5);
|
||||||
|
expect(stroke.points).toEqual(baseInput.points);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type PenKind = "pen" | "highlighter";
|
||||||
|
|
||||||
|
export const HIGHLIGHTER_SIZE_FACTOR = 2.2;
|
||||||
|
|
||||||
|
export const SHAPE_KINDS = ["line", "arrow", "rect", "ellipse"] as const;
|
||||||
|
|
||||||
|
export type ShapeKind = (typeof SHAPE_KINDS)[number];
|
||||||
|
|
||||||
|
export const TOOL_KINDS = [
|
||||||
|
"pen",
|
||||||
|
"highlighter",
|
||||||
|
"eraser",
|
||||||
|
"laser",
|
||||||
|
"select",
|
||||||
|
...SHAPE_KINDS,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ToolKind = (typeof TOOL_KINDS)[number];
|
||||||
|
|
||||||
|
export interface StrokePoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
pressure: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stroke {
|
||||||
|
id: string;
|
||||||
|
pen: PenKind;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
simulatePressure: boolean;
|
||||||
|
points: StrokePoint[];
|
||||||
|
shape?: ShapeKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStroke(input: Omit<Stroke, "id">): Stroke {
|
||||||
|
return { ...input, id: newId() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveStrokeSize(stroke: Stroke): number {
|
||||||
|
return stroke.pen === "highlighter" ? stroke.size * HIGHLIGHTER_SIZE_FACTOR : stroke.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
export function newId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${Date.now().toString(36)}-${(idCounter++).toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Stroke } from "./stroke";
|
||||||
|
import {
|
||||||
|
clampMoveDelta,
|
||||||
|
clampScaleToPage,
|
||||||
|
imagesBounds,
|
||||||
|
scaleBounds,
|
||||||
|
scaleImage,
|
||||||
|
scaleStroke,
|
||||||
|
strokesBounds,
|
||||||
|
translateBounds,
|
||||||
|
translateImage,
|
||||||
|
translateStroke,
|
||||||
|
unionBounds,
|
||||||
|
} from "./transform";
|
||||||
|
|
||||||
|
function penStroke(id: string, points: { x: number; y: number }[], size = 4): Stroke {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
pen: "pen",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: points.map((p) => ({ ...p, pressure: 0.5 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("strokesBounds", () => {
|
||||||
|
it("unions stroke bounds including the ink margin", () => {
|
||||||
|
const a = penStroke("a", [
|
||||||
|
{ x: 10, y: 10 },
|
||||||
|
{ x: 20, y: 20 },
|
||||||
|
]);
|
||||||
|
const b = penStroke(
|
||||||
|
"b",
|
||||||
|
[
|
||||||
|
{ x: 100, y: 50 },
|
||||||
|
{ x: 120, y: 80 },
|
||||||
|
],
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(strokesBounds([a, b])).toEqual({ minX: 8, minY: 8, maxX: 124, maxY: 84 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an empty selection", () => {
|
||||||
|
expect(strokesBounds([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the widened highlighter size", () => {
|
||||||
|
const stroke: Stroke = { ...penStroke("h", [{ x: 10, y: 10 }], 4), pen: "highlighter" };
|
||||||
|
const bounds = strokesBounds([stroke]);
|
||||||
|
expect(bounds?.minX).toBeCloseTo(10 - (4 * 2.2) / 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("translateStroke", () => {
|
||||||
|
it("moves every point and preserves the rest of the stroke", () => {
|
||||||
|
const stroke = penStroke("s", [
|
||||||
|
{ x: 1, y: 2 },
|
||||||
|
{ x: 3, y: 4 },
|
||||||
|
]);
|
||||||
|
const moved = translateStroke(stroke, 10, -5);
|
||||||
|
expect(moved.points.map((p) => [p.x, p.y])).toEqual([
|
||||||
|
[11, -3],
|
||||||
|
[13, -1],
|
||||||
|
]);
|
||||||
|
expect(moved.id).toBe("s");
|
||||||
|
expect(moved.size).toBe(stroke.size);
|
||||||
|
expect(moved.color).toBe(stroke.color);
|
||||||
|
expect(stroke.points[0].x).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scaleStroke", () => {
|
||||||
|
it("scales points about the anchor", () => {
|
||||||
|
const stroke = penStroke("s", [
|
||||||
|
{ x: 10, y: 10 },
|
||||||
|
{ x: 20, y: 30 },
|
||||||
|
]);
|
||||||
|
const scaled = scaleStroke(stroke, { x: 10, y: 10 }, 2, 3);
|
||||||
|
expect(scaled.points.map((p) => [p.x, p.y])).toEqual([
|
||||||
|
[10, 10],
|
||||||
|
[30, 70],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales the ink size by the geometric mean of the factors", () => {
|
||||||
|
const stroke = penStroke("s", [{ x: 0, y: 0 }], 6);
|
||||||
|
expect(scaleStroke(stroke, { x: 0, y: 0 }, 2, 2).size).toBeCloseTo(12);
|
||||||
|
expect(scaleStroke(stroke, { x: 0, y: 0 }, 2, 8).size).toBeCloseTo(24);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bounds helpers", () => {
|
||||||
|
const bounds = { minX: 10, minY: 20, maxX: 30, maxY: 40 };
|
||||||
|
|
||||||
|
it("translates bounds", () => {
|
||||||
|
expect(translateBounds(bounds, 5, -10)).toEqual({ minX: 15, minY: 10, maxX: 35, maxY: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales bounds about the anchor", () => {
|
||||||
|
expect(scaleBounds(bounds, { x: 10, y: 20 }, 2, 2)).toEqual({
|
||||||
|
minX: 10,
|
||||||
|
minY: 20,
|
||||||
|
maxX: 50,
|
||||||
|
maxY: 60,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clampMoveDelta", () => {
|
||||||
|
const bounds = { minX: 10, minY: 10, maxX: 110, maxY: 110 };
|
||||||
|
|
||||||
|
it("keeps deltas that fit on the page", () => {
|
||||||
|
expect(clampMoveDelta(bounds, 50, -5)).toEqual({ dx: 50, dy: -5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps movement beyond the page edges", () => {
|
||||||
|
expect(clampMoveDelta(bounds, -500, 0).dx).toBe(-10);
|
||||||
|
expect(clampMoveDelta(bounds, 0, 99999).dy).toBe(1123 - 110);
|
||||||
|
expect(clampMoveDelta(bounds, 99999, 0).dx).toBe(794 - 110);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clampScaleToPage", () => {
|
||||||
|
it("allows scaling that stays on the page", () => {
|
||||||
|
const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 };
|
||||||
|
expect(clampScaleToPage(bounds, { x: 100, y: 100 }, 2, 2)).toEqual({ sx: 2, sy: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps scaling that would overflow the page", () => {
|
||||||
|
const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 };
|
||||||
|
const clamped = clampScaleToPage(bounds, { x: 100, y: 100 }, 100, 100);
|
||||||
|
expect(clamped.sx).toBeCloseTo((794 - 100) / 100);
|
||||||
|
expect(clamped.sy).toBeCloseTo((1123 - 100) / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports shrinking without limits", () => {
|
||||||
|
const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 };
|
||||||
|
expect(clampScaleToPage(bounds, { x: 100, y: 100 }, 0.5, 0.25)).toEqual({
|
||||||
|
sx: 0.5,
|
||||||
|
sy: 0.25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("image transforms", () => {
|
||||||
|
const image = { id: "i1", imageId: "blob-1", x: 10, y: 20, width: 100, height: 50 };
|
||||||
|
|
||||||
|
it("translates an image", () => {
|
||||||
|
expect(translateImage(image, 5, -10)).toMatchObject({ x: 15, y: 10, width: 100 });
|
||||||
|
expect(image.x).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales an image about the anchor including its size", () => {
|
||||||
|
const scaled = scaleImage(image, { x: 10, y: 20 }, 2, 3);
|
||||||
|
expect(scaled).toMatchObject({ x: 10, y: 20, width: 200, height: 150 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the union bounds of images", () => {
|
||||||
|
const other = { ...image, id: "i2", x: 50, y: 0, width: 20, height: 20 };
|
||||||
|
expect(imagesBounds([image, other])).toEqual({ minX: 10, minY: 0, maxX: 110, maxY: 70 });
|
||||||
|
expect(imagesBounds([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unions stroke and image bounds", () => {
|
||||||
|
const stroke = penStroke("s", [{ x: 0, y: 0 }], 4);
|
||||||
|
const bounds = unionBounds(strokesBounds([stroke]), imagesBounds([image]));
|
||||||
|
expect(bounds).toEqual({ minX: -2, minY: -2, maxX: 110, maxY: 70 });
|
||||||
|
expect(unionBounds(null, null)).toBeNull();
|
||||||
|
expect(unionBounds(imagesBounds([image]), null)).toEqual({
|
||||||
|
minX: 10,
|
||||||
|
minY: 20,
|
||||||
|
maxX: 110,
|
||||||
|
maxY: 70,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { type Bounds, strokeBounds } from "./hitTest";
|
||||||
|
import type { ImageItem } from "./image";
|
||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH } from "./page";
|
||||||
|
import type { Point } from "./shapeGeometry";
|
||||||
|
import { effectiveStrokeSize, type Stroke } from "./stroke";
|
||||||
|
|
||||||
|
export function strokesBounds(strokes: Stroke[]): Bounds | null {
|
||||||
|
if (strokes.length === 0) return null;
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const stroke of strokes) {
|
||||||
|
const bounds = strokeBounds(stroke, inkMargin(stroke));
|
||||||
|
minX = Math.min(minX, bounds.minX);
|
||||||
|
minY = Math.min(minY, bounds.minY);
|
||||||
|
maxX = Math.max(maxX, bounds.maxX);
|
||||||
|
maxY = Math.max(maxY, bounds.maxY);
|
||||||
|
}
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateStroke(stroke: Stroke, dx: number, dy: number): Stroke {
|
||||||
|
return {
|
||||||
|
...stroke,
|
||||||
|
points: stroke.points.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateImage(image: ImageItem, dx: number, dy: number): ImageItem {
|
||||||
|
return { ...image, x: image.x + dx, y: image.y + dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleImage(image: ImageItem, anchor: Point, sx: number, sy: number): ImageItem {
|
||||||
|
return {
|
||||||
|
...image,
|
||||||
|
x: anchor.x + (image.x - anchor.x) * sx,
|
||||||
|
y: anchor.y + (image.y - anchor.y) * sy,
|
||||||
|
width: image.width * sx,
|
||||||
|
height: image.height * sy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imagesBounds(images: ImageItem[]): Bounds | null {
|
||||||
|
if (images.length === 0) return null;
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const image of images) {
|
||||||
|
minX = Math.min(minX, image.x);
|
||||||
|
minY = Math.min(minY, image.y);
|
||||||
|
maxX = Math.max(maxX, image.x + image.width);
|
||||||
|
maxY = Math.max(maxY, image.y + image.height);
|
||||||
|
}
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unionBounds(a: Bounds | null, b: Bounds | null): Bounds | null {
|
||||||
|
if (!a) return b;
|
||||||
|
if (!b) return a;
|
||||||
|
return {
|
||||||
|
minX: Math.min(a.minX, b.minX),
|
||||||
|
minY: Math.min(a.minY, b.minY),
|
||||||
|
maxX: Math.max(a.maxX, b.maxX),
|
||||||
|
maxY: Math.max(a.maxY, b.maxY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleStroke(stroke: Stroke, anchor: Point, sx: number, sy: number): Stroke {
|
||||||
|
return {
|
||||||
|
...stroke,
|
||||||
|
size: stroke.size * Math.sqrt(Math.abs(sx * sy)),
|
||||||
|
points: stroke.points.map((p) => ({
|
||||||
|
...p,
|
||||||
|
x: anchor.x + (p.x - anchor.x) * sx,
|
||||||
|
y: anchor.y + (p.y - anchor.y) * sy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateBounds(bounds: Bounds, dx: number, dy: number): Bounds {
|
||||||
|
return {
|
||||||
|
minX: bounds.minX + dx,
|
||||||
|
minY: bounds.minY + dy,
|
||||||
|
maxX: bounds.maxX + dx,
|
||||||
|
maxY: bounds.maxY + dy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleBounds(bounds: Bounds, anchor: Point, sx: number, sy: number): Bounds {
|
||||||
|
const xs = [bounds.minX, bounds.maxX].map((x) => anchor.x + (x - anchor.x) * sx);
|
||||||
|
const ys = [bounds.minY, bounds.maxY].map((y) => anchor.y + (y - anchor.y) * sy);
|
||||||
|
return {
|
||||||
|
minX: Math.min(...xs),
|
||||||
|
minY: Math.min(...ys),
|
||||||
|
maxX: Math.max(...xs),
|
||||||
|
maxY: Math.max(...ys),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampMoveDelta(bounds: Bounds, dx: number, dy: number): { dx: number; dy: number } {
|
||||||
|
return {
|
||||||
|
dx: clamp(dx, -bounds.minX, PAGE_WIDTH - bounds.maxX),
|
||||||
|
dy: clamp(dy, -bounds.minY, PAGE_HEIGHT - bounds.maxY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampScaleToPage(
|
||||||
|
bounds: Bounds,
|
||||||
|
anchor: Point,
|
||||||
|
sx: number,
|
||||||
|
sy: number,
|
||||||
|
): { sx: number; sy: number } {
|
||||||
|
return {
|
||||||
|
sx: Math.min(sx, maxScale(bounds.minX, bounds.maxX, anchor.x, PAGE_WIDTH)),
|
||||||
|
sy: Math.min(sy, maxScale(bounds.minY, bounds.maxY, anchor.y, PAGE_HEIGHT)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxScale(min: number, max: number, anchor: number, extent: number): number {
|
||||||
|
let limit = Infinity;
|
||||||
|
if (min < anchor) limit = Math.min(limit, anchor / (anchor - min));
|
||||||
|
if (max > anchor) limit = Math.min(limit, (extent - anchor) / (max - anchor));
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inkMargin(stroke: Stroke): number {
|
||||||
|
return effectiveStrokeSize(stroke) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), Math.max(min, max));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ViewState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Page } from "../model/page";
|
||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
import { replacePages, savePage } from "./notebooks";
|
||||||
|
|
||||||
|
let saveErrorReported = false;
|
||||||
|
|
||||||
|
export function startAutosave(): () => void {
|
||||||
|
return useBoardStore.subscribe((state, prev) => {
|
||||||
|
const notebookId = state.notebookId;
|
||||||
|
if (!notebookId || state.pages === prev.pages) return;
|
||||||
|
if (state.pages.length < prev.pages.length) {
|
||||||
|
void replacePages(notebookId, state.pages).catch(reportSaveError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { index, page } of changedPages(prev.pages, state.pages)) {
|
||||||
|
void savePage(notebookId, index, page).catch(reportSaveError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportSaveError(error: unknown): void {
|
||||||
|
console.error("Failed to save page", error);
|
||||||
|
if (saveErrorReported) return;
|
||||||
|
saveErrorReported = true;
|
||||||
|
window.alert("Saving failed. Your latest changes may not be stored.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedPages(prev: Page[], next: Page[]): { index: number; page: Page }[] {
|
||||||
|
const changed: { index: number; page: Page }[] = [];
|
||||||
|
for (const [index, page] of next.entries()) {
|
||||||
|
if (prev[index] !== page) changed.push({ index, page });
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
|
||||||
|
import type { ImageItem } from "../model/image";
|
||||||
|
import type { PagePattern } from "../model/page";
|
||||||
|
import type { Stroke } from "../model/stroke";
|
||||||
|
import type { ViewState } from "../model/viewState";
|
||||||
|
|
||||||
|
export interface NotebookRecord {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
pageCount: number;
|
||||||
|
viewState?: ViewState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageRecord {
|
||||||
|
id: string;
|
||||||
|
notebookId: string;
|
||||||
|
index: number;
|
||||||
|
paperColor: string;
|
||||||
|
pattern: PagePattern;
|
||||||
|
strokes: Stroke[];
|
||||||
|
images?: ImageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageRecord {
|
||||||
|
id: string;
|
||||||
|
mimeType: string;
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VasDB extends DBSchema {
|
||||||
|
notebooks: { key: string; value: NotebookRecord };
|
||||||
|
pages: { key: string; value: PageRecord; indexes: { "by-notebook": string } };
|
||||||
|
images: { key: string; value: ImageRecord };
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase<VasDB>> | null = null;
|
||||||
|
|
||||||
|
export function db(): Promise<IDBPDatabase<VasDB>> {
|
||||||
|
dbPromise ??= openDB<VasDB>("vas", 2, {
|
||||||
|
upgrade(database, oldVersion) {
|
||||||
|
if (oldVersion < 1) {
|
||||||
|
database.createObjectStore("notebooks", { keyPath: "id" });
|
||||||
|
const pages = database.createObjectStore("pages", { keyPath: "id" });
|
||||||
|
pages.createIndex("by-notebook", "notebookId");
|
||||||
|
}
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
database.createObjectStore("images", { keyPath: "id" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ensureImageLoaded } from "../engine/imageCache";
|
||||||
|
import { paintPage } from "../engine/renderPage";
|
||||||
|
import type { Page } from "../model/page";
|
||||||
|
import { downloadBlob } from "./transfer";
|
||||||
|
|
||||||
|
const PNG_SCALE = 2;
|
||||||
|
|
||||||
|
export async function exportPagePng(title: string, pageIndex: number, page: Page): Promise<void> {
|
||||||
|
await Promise.all(page.images.map((image) => ensureImageLoaded(image.imageId)));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
paintPage(canvas, page, PNG_SCALE);
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
downloadBlob(blob, `${title}-page-${pageIndex + 1}.png`);
|
||||||
|
} else {
|
||||||
|
window.alert("PNG export failed.");
|
||||||
|
}
|
||||||
|
}, "image/png");
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { PDFDocument, type PDFImage, type PDFPage, type RGB, rgb } from "pdf-lib";
|
||||||
|
import { getOutlinePoints, HIGHLIGHTER_ALPHA } from "../engine/renderStroke";
|
||||||
|
import { hexToRgb, isDarkColor } from "../model/color";
|
||||||
|
import type { ImageItem } from "../model/image";
|
||||||
|
import type { PagePattern } from "../model/page";
|
||||||
|
import { PAGE_HEIGHT, PAGE_WIDTH, type Page, trimTrailingBlankPages } from "../model/page";
|
||||||
|
import { PATTERN_DASH, patternLayout } from "../model/patternLayout";
|
||||||
|
import { arrowHead } from "../model/shapeGeometry";
|
||||||
|
import type { Stroke } from "../model/stroke";
|
||||||
|
import { getImage } from "./images";
|
||||||
|
import { rasterizeToPng } from "./rasterize";
|
||||||
|
import { downloadBlob } from "./transfer";
|
||||||
|
|
||||||
|
const PT_PER_UNIT = 72 / 96;
|
||||||
|
|
||||||
|
export async function exportNotebookPdf(title: string, pages: Page[]): Promise<void> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
doc.setTitle(title);
|
||||||
|
const width = PAGE_WIDTH * PT_PER_UNIT;
|
||||||
|
const height = PAGE_HEIGHT * PT_PER_UNIT;
|
||||||
|
const embedded = new Map<string, PDFImage | null>();
|
||||||
|
|
||||||
|
for (const page of trimTrailingBlankPages(pages)) {
|
||||||
|
const pdfPage = doc.addPage([width, height]);
|
||||||
|
pdfPage.drawRectangle({ x: 0, y: 0, width, height, color: toPdfRgb(page.paperColor, rgb) });
|
||||||
|
drawPattern(pdfPage, page.pattern, page.paperColor, rgb);
|
||||||
|
for (const image of page.images) {
|
||||||
|
await drawPdfImage(doc, pdfPage, image, embedded);
|
||||||
|
}
|
||||||
|
for (const stroke of page.strokes) {
|
||||||
|
if (stroke.shape) {
|
||||||
|
drawPdfShape(pdfPage, stroke, rgb);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const outline = getOutlinePoints(stroke, true);
|
||||||
|
if (outline.length < 3) continue;
|
||||||
|
pdfPage.drawSvgPath(outlineToSvgPath(outline), {
|
||||||
|
y: height,
|
||||||
|
color: toPdfRgb(stroke.color, rgb),
|
||||||
|
opacity: stroke.pen === "highlighter" ? HIGHLIGHTER_ALPHA : 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await doc.save();
|
||||||
|
downloadBlob(
|
||||||
|
new Blob([bytes.buffer as ArrayBuffer], { type: "application/pdf" }),
|
||||||
|
`${title}.pdf`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawPdfImage(
|
||||||
|
doc: PDFDocument,
|
||||||
|
pdfPage: PDFPage,
|
||||||
|
image: ImageItem,
|
||||||
|
embedded: Map<string, PDFImage | null>,
|
||||||
|
): Promise<void> {
|
||||||
|
let pdfImage = embedded.get(image.imageId);
|
||||||
|
if (pdfImage === undefined) {
|
||||||
|
pdfImage = await embedImage(doc, image.imageId);
|
||||||
|
embedded.set(image.imageId, pdfImage);
|
||||||
|
}
|
||||||
|
if (!pdfImage) return;
|
||||||
|
pdfPage.drawImage(pdfImage, {
|
||||||
|
x: image.x * PT_PER_UNIT,
|
||||||
|
y: toY(image.y + image.height),
|
||||||
|
width: image.width * PT_PER_UNIT,
|
||||||
|
height: image.height * PT_PER_UNIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedImage(doc: PDFDocument, imageId: string): Promise<PDFImage | null> {
|
||||||
|
try {
|
||||||
|
const record = await getImage(imageId);
|
||||||
|
if (!record) return null;
|
||||||
|
const bytes = await record.blob.arrayBuffer();
|
||||||
|
if (record.mimeType === "image/jpeg") return await doc.embedJpg(bytes);
|
||||||
|
if (record.mimeType === "image/png") return await doc.embedPng(bytes);
|
||||||
|
const png = await rasterizeToPng(record.blob);
|
||||||
|
return png ? await doc.embedPng(png) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPdfRgb(hex: string, rgb: (r: number, g: number, b: number) => RGB): RGB {
|
||||||
|
const parsed = hexToRgb(hex);
|
||||||
|
return parsed ? rgb(parsed.r, parsed.g, parsed.b) : rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outlineToSvgPath(outline: number[][]): string {
|
||||||
|
const segments = outline.map(([x, y], index) => {
|
||||||
|
const command = index === 0 ? "M" : "L";
|
||||||
|
return `${command}${(x * PT_PER_UNIT).toFixed(2)} ${(y * PT_PER_UNIT).toFixed(2)}`;
|
||||||
|
});
|
||||||
|
return `${segments.join(" ")} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toY(y: number): number {
|
||||||
|
return (PAGE_HEIGHT - y) * PT_PER_UNIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPdfShape(
|
||||||
|
pdfPage: PDFPage,
|
||||||
|
stroke: Stroke,
|
||||||
|
rgb: (r: number, g: number, b: number) => RGB,
|
||||||
|
): void {
|
||||||
|
const [a, b] = stroke.points;
|
||||||
|
if (!stroke.shape || !a || !b) return;
|
||||||
|
const color = toPdfRgb(stroke.color, rgb);
|
||||||
|
const thickness = stroke.size * PT_PER_UNIT;
|
||||||
|
switch (stroke.shape) {
|
||||||
|
case "line":
|
||||||
|
pdfPage.drawLine({
|
||||||
|
start: { x: a.x * PT_PER_UNIT, y: toY(a.y) },
|
||||||
|
end: { x: b.x * PT_PER_UNIT, y: toY(b.y) },
|
||||||
|
thickness,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "arrow": {
|
||||||
|
const [left, right] = arrowHead(a, b, stroke.size);
|
||||||
|
for (const point of [b, left, right]) {
|
||||||
|
const from = point === b ? a : b;
|
||||||
|
pdfPage.drawLine({
|
||||||
|
start: { x: from.x * PT_PER_UNIT, y: toY(from.y) },
|
||||||
|
end: { x: point.x * PT_PER_UNIT, y: toY(point.y) },
|
||||||
|
thickness,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "rect":
|
||||||
|
pdfPage.drawRectangle({
|
||||||
|
x: Math.min(a.x, b.x) * PT_PER_UNIT,
|
||||||
|
y: toY(Math.max(a.y, b.y)),
|
||||||
|
width: Math.abs(b.x - a.x) * PT_PER_UNIT,
|
||||||
|
height: Math.abs(b.y - a.y) * PT_PER_UNIT,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: thickness,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "ellipse":
|
||||||
|
pdfPage.drawEllipse({
|
||||||
|
x: ((a.x + b.x) / 2) * PT_PER_UNIT,
|
||||||
|
y: toY((a.y + b.y) / 2),
|
||||||
|
xScale: (Math.abs(b.x - a.x) / 2) * PT_PER_UNIT,
|
||||||
|
yScale: (Math.abs(b.y - a.y) / 2) * PT_PER_UNIT,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: thickness,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPattern(
|
||||||
|
pdfPage: PDFPage,
|
||||||
|
pattern: PagePattern,
|
||||||
|
paperColor: string,
|
||||||
|
rgb: (r: number, g: number, b: number) => RGB,
|
||||||
|
): void {
|
||||||
|
if (pattern === "blank") return;
|
||||||
|
const { lines, dots } = patternLayout(pattern);
|
||||||
|
const dark = isDarkColor(paperColor);
|
||||||
|
const color = dark ? rgb(1, 1, 1) : rgb(0, 0, 0);
|
||||||
|
const opacity = dark ? 0.22 : 0.16;
|
||||||
|
for (const line of lines) {
|
||||||
|
pdfPage.drawLine({
|
||||||
|
start: { x: line.x1 * PT_PER_UNIT, y: toY(line.y1) },
|
||||||
|
end: { x: line.x2 * PT_PER_UNIT, y: toY(line.y2) },
|
||||||
|
thickness: 0.75,
|
||||||
|
color,
|
||||||
|
opacity,
|
||||||
|
...(line.dashed ? { dashArray: PATTERN_DASH.map((v) => v * PT_PER_UNIT) } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const dot of dots) {
|
||||||
|
pdfPage.drawCircle({
|
||||||
|
x: dot.x * PT_PER_UNIT,
|
||||||
|
y: toY(dot.y),
|
||||||
|
size: 0.9,
|
||||||
|
color,
|
||||||
|
opacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
import { db, type ImageRecord } from "./db";
|
||||||
|
|
||||||
|
export async function saveImage(record: ImageRecord): Promise<void> {
|
||||||
|
await (await db()).put("images", record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveImages(records: ImageRecord[]): Promise<void> {
|
||||||
|
if (records.length === 0) return;
|
||||||
|
const tx = (await db()).transaction("images", "readwrite");
|
||||||
|
for (const record of records) await tx.store.put(record);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImage(id: string): Promise<ImageRecord | undefined> {
|
||||||
|
return (await db()).get("images", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteImages(ids: string[]): Promise<void> {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const tx = (await db()).transaction("images", "readwrite");
|
||||||
|
for (const id of ids) await tx.store.delete(id);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gcUnreferencedImages(): Promise<void> {
|
||||||
|
const database = await db();
|
||||||
|
const keep = new Set<string>();
|
||||||
|
for (const page of await database.getAll("pages")) {
|
||||||
|
for (const image of page.images ?? []) keep.add(image.imageId);
|
||||||
|
}
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
for (const page of state.pages) {
|
||||||
|
for (const image of page.images) keep.add(image.imageId);
|
||||||
|
}
|
||||||
|
for (const image of state.clipboard.images) keep.add(image.imageId);
|
||||||
|
const tx = database.transaction("images", "readwrite");
|
||||||
|
const keys = await tx.store.getAllKeys();
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!keep.has(key)) await tx.store.delete(key);
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { placeImageCentered } from "../model/image";
|
||||||
|
import { createPage, type Page } from "../model/page";
|
||||||
|
import { newId } from "../model/stroke";
|
||||||
|
import { deleteImages, saveImages } from "./images";
|
||||||
|
import { createNotebook, deleteNotebook, replacePages } from "./notebooks";
|
||||||
|
|
||||||
|
const PDF_RENDER_SCALE = 3;
|
||||||
|
const JPEG_QUALITY = 0.9;
|
||||||
|
|
||||||
|
export interface RasterizedPdfPage {
|
||||||
|
imageId: string;
|
||||||
|
mimeType: string;
|
||||||
|
blob: Blob;
|
||||||
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PdfJs = typeof import("pdfjs-dist");
|
||||||
|
|
||||||
|
let pdfjsPromise: Promise<PdfJs> | null = null;
|
||||||
|
|
||||||
|
function loadPdfJs(): Promise<PdfJs> {
|
||||||
|
pdfjsPromise ??= (async () => {
|
||||||
|
const [pdfjs, worker] = await Promise.all([
|
||||||
|
import("pdfjs-dist"),
|
||||||
|
import("pdfjs-dist/build/pdf.worker.min.mjs?url"),
|
||||||
|
]);
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = worker.default;
|
||||||
|
return pdfjs;
|
||||||
|
})();
|
||||||
|
pdfjsPromise.catch(() => {
|
||||||
|
pdfjsPromise = null;
|
||||||
|
});
|
||||||
|
return pdfjsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRasterizedImages(rasterized: RasterizedPdfPage[]): Promise<void> {
|
||||||
|
await saveImages(rasterized.map((raster) => ({ ...raster, id: raster.imageId })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rasterizePdf(
|
||||||
|
file: File,
|
||||||
|
onProgress?: (done: number, total: number) => void,
|
||||||
|
): Promise<RasterizedPdfPage[]> {
|
||||||
|
const pdfjs = await loadPdfJs();
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
let cancelled = false;
|
||||||
|
const task = pdfjs.getDocument({ data });
|
||||||
|
task.onPassword = (updatePassword: (password: string) => void, reason: number) => {
|
||||||
|
const message =
|
||||||
|
reason === pdfjs.PasswordResponses.INCORRECT_PASSWORD
|
||||||
|
? "Incorrect password. Please try again:"
|
||||||
|
: "This PDF is password protected. Enter its password:";
|
||||||
|
const password = window.prompt(message);
|
||||||
|
if (password === null) {
|
||||||
|
cancelled = true;
|
||||||
|
void task.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatePassword(password);
|
||||||
|
};
|
||||||
|
let doc: import("pdfjs-dist").PDFDocumentProxy;
|
||||||
|
try {
|
||||||
|
doc = await task.promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) throw new Error("Import cancelled: password required");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (doc.numPages < 1) throw new Error("This PDF contains no pages");
|
||||||
|
const pages: RasterizedPdfPage[] = [];
|
||||||
|
for (let index = 1; index <= doc.numPages; index++) {
|
||||||
|
pages.push(await rasterizePage(doc, index));
|
||||||
|
onProgress?.(index, doc.numPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
} finally {
|
||||||
|
void task.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importPdfFile(
|
||||||
|
file: File,
|
||||||
|
onProgress?: (done: number, total: number) => void,
|
||||||
|
): Promise<string> {
|
||||||
|
const rasterized = await rasterizePdf(file, onProgress);
|
||||||
|
const pages: Page[] = rasterized.map((raster) => {
|
||||||
|
const page = createPage("#ffffff", "blank");
|
||||||
|
page.images = [
|
||||||
|
{
|
||||||
|
id: newId(),
|
||||||
|
imageId: raster.imageId,
|
||||||
|
...placeImageCentered(raster.naturalWidth, raster.naturalHeight),
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return page;
|
||||||
|
});
|
||||||
|
const title = file.name.replace(/\.pdf$/i, "").trim() || "Imported PDF";
|
||||||
|
const meta = await createNotebook(title);
|
||||||
|
try {
|
||||||
|
await saveRasterizedImages(rasterized);
|
||||||
|
await replacePages(meta.id, pages);
|
||||||
|
} catch (error) {
|
||||||
|
await deleteNotebook(meta.id);
|
||||||
|
await deleteImages(rasterized.map((raster) => raster.imageId));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return meta.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rasterizePage(
|
||||||
|
doc: import("pdfjs-dist").PDFDocumentProxy,
|
||||||
|
index: number,
|
||||||
|
): Promise<RasterizedPdfPage> {
|
||||||
|
const pdfPage = await doc.getPage(index);
|
||||||
|
const base = pdfPage.getViewport({ scale: 1 });
|
||||||
|
const displayWidth = placeImageCentered(base.width, base.height).width;
|
||||||
|
const viewport = pdfPage.getViewport({ scale: (displayWidth * PDF_RENDER_SCALE) / base.width });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.ceil(viewport.width));
|
||||||
|
canvas.height = Math.max(1, Math.ceil(viewport.height));
|
||||||
|
await pdfPage.render({ canvas, viewport }).promise;
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY),
|
||||||
|
);
|
||||||
|
if (!blob) throw new Error(`Failed to rasterize page ${index}`);
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
return {
|
||||||
|
imageId: newId(),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
blob,
|
||||||
|
naturalWidth: base.width,
|
||||||
|
naturalHeight: base.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { decodeBlob, primeImage } from "../engine/imageCache";
|
||||||
|
import { newId } from "../model/stroke";
|
||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
import { saveImage } from "./images";
|
||||||
|
|
||||||
|
export async function insertImageFile(file: File): Promise<void> {
|
||||||
|
if (!file.type.startsWith("image/")) throw new Error("Not an image file");
|
||||||
|
const imageId = newId();
|
||||||
|
const decoded = await decodeBlob(file);
|
||||||
|
await saveImage({
|
||||||
|
id: imageId,
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
blob: file,
|
||||||
|
});
|
||||||
|
primeImage(imageId, decoded);
|
||||||
|
useBoardStore.getState().insertImage(imageId, decoded.naturalWidth, decoded.naturalHeight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { clonePageWithNewIds, createPage, type Page } from "../model/page";
|
||||||
|
import { newId } from "../model/stroke";
|
||||||
|
import type { ViewState } from "../model/viewState";
|
||||||
|
import { db, type NotebookRecord } from "./db";
|
||||||
|
|
||||||
|
const DEFAULT_PAPER_COLOR = "#ffffff";
|
||||||
|
|
||||||
|
export async function listNotebooks(): Promise<NotebookRecord[]> {
|
||||||
|
const all = await (await db()).getAll("notebooks");
|
||||||
|
return all.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNotebook(title: string): Promise<NotebookRecord> {
|
||||||
|
const now = Date.now();
|
||||||
|
const meta: NotebookRecord = { id: newId(), title, createdAt: now, updatedAt: now, pageCount: 1 };
|
||||||
|
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||||
|
await tx.objectStore("notebooks").put(meta);
|
||||||
|
await tx.objectStore("pages").put(toPageRecord(meta.id, 0, createPage(DEFAULT_PAPER_COLOR)));
|
||||||
|
await tx.done;
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPageRecord(notebookId: string, index: number, page: Page) {
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
notebookId,
|
||||||
|
index,
|
||||||
|
paperColor: page.paperColor,
|
||||||
|
pattern: page.pattern,
|
||||||
|
strokes: page.strokes,
|
||||||
|
images: page.images,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadNotebook(id: string): Promise<{ meta: NotebookRecord; pages: Page[] }> {
|
||||||
|
const database = await db();
|
||||||
|
const meta = await database.get("notebooks", id);
|
||||||
|
if (!meta) throw new Error("Notebook not found");
|
||||||
|
const records = await database.getAllFromIndex("pages", "by-notebook", id);
|
||||||
|
records.sort((a, b) => a.index - b.index);
|
||||||
|
const pages = records.map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
paperColor: record.paperColor,
|
||||||
|
pattern: record.pattern ?? "blank",
|
||||||
|
strokes: record.strokes,
|
||||||
|
images: record.images ?? [],
|
||||||
|
}));
|
||||||
|
if (pages.length === 0) pages.push(createPage(DEFAULT_PAPER_COLOR));
|
||||||
|
return { meta, pages };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePage(notebookId: string, index: number, page: Page): Promise<void> {
|
||||||
|
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||||
|
await tx.objectStore("pages").put(toPageRecord(notebookId, index, page));
|
||||||
|
const meta = await tx.objectStore("notebooks").get(notebookId);
|
||||||
|
if (meta) {
|
||||||
|
await tx.objectStore("notebooks").put({
|
||||||
|
...meta,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
pageCount: Math.max(meta.pageCount, index + 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replacePages(notebookId: string, pages: Page[]): Promise<void> {
|
||||||
|
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||||
|
const store = tx.objectStore("pages");
|
||||||
|
const keys = await store.index("by-notebook").getAllKeys(notebookId);
|
||||||
|
for (const key of keys) await store.delete(key);
|
||||||
|
for (const [index, page] of pages.entries()) {
|
||||||
|
await store.put(toPageRecord(notebookId, index, page));
|
||||||
|
}
|
||||||
|
const meta = await tx.objectStore("notebooks").get(notebookId);
|
||||||
|
if (meta) {
|
||||||
|
await tx
|
||||||
|
.objectStore("notebooks")
|
||||||
|
.put({ ...meta, updatedAt: Date.now(), pageCount: pages.length });
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameNotebook(id: string, title: string): Promise<void> {
|
||||||
|
const tx = (await db()).transaction("notebooks", "readwrite");
|
||||||
|
const meta = await tx.store.get(id);
|
||||||
|
if (meta) await tx.store.put({ ...meta, title, updatedAt: Date.now() });
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveViewState(id: string, viewState: ViewState): Promise<void> {
|
||||||
|
const tx = (await db()).transaction("notebooks", "readwrite");
|
||||||
|
const meta = await tx.store.get(id);
|
||||||
|
if (meta) await tx.store.put({ ...meta, viewState });
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeNotebooks(ids: string[], title: string): Promise<string> {
|
||||||
|
if (ids.length === 0) throw new Error("No notebooks selected");
|
||||||
|
const sources: Page[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const { pages } = await loadNotebook(id);
|
||||||
|
sources.push(...pages);
|
||||||
|
}
|
||||||
|
const meta = await createNotebook(title);
|
||||||
|
try {
|
||||||
|
await replacePages(meta.id, sources.map(clonePageWithNewIds));
|
||||||
|
} catch (error) {
|
||||||
|
await deleteNotebook(meta.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return meta.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotebook(id: string): Promise<void> {
|
||||||
|
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||||
|
await tx.objectStore("notebooks").delete(id);
|
||||||
|
const keys = await tx.objectStore("pages").index("by-notebook").getAllKeys(id);
|
||||||
|
for (const key of keys) await tx.objectStore("pages").delete(key);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseToolPrefs } from "./prefs";
|
||||||
|
|
||||||
|
describe("parseToolPrefs", () => {
|
||||||
|
it("returns an empty object for missing or invalid input", () => {
|
||||||
|
expect(parseToolPrefs(null)).toEqual({});
|
||||||
|
expect(parseToolPrefs(undefined)).toEqual({});
|
||||||
|
expect(parseToolPrefs("garbage")).toEqual({});
|
||||||
|
expect(parseToolPrefs(42)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits keys that are absent instead of setting them to undefined", () => {
|
||||||
|
const parsed = parseToolPrefs({});
|
||||||
|
expect(parsed).toEqual({});
|
||||||
|
expect("color" in parsed).toBe(false);
|
||||||
|
expect("size" in parsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the valid entries from a partial record", () => {
|
||||||
|
const parsed = parseToolPrefs({ color: "#ff0000", size: "big", tool: "fountain" });
|
||||||
|
expect(parsed).toEqual({ color: "#ff0000" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a full valid record", () => {
|
||||||
|
const parsed = parseToolPrefs({
|
||||||
|
tool: "highlighter",
|
||||||
|
color: "#2F6FDD",
|
||||||
|
size: 5,
|
||||||
|
paperColor: "#003423",
|
||||||
|
pattern: "grid",
|
||||||
|
sidebarOpen: true,
|
||||||
|
});
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
tool: "highlighter",
|
||||||
|
color: "#2f6fdd",
|
||||||
|
size: 5,
|
||||||
|
paperColor: "#003423",
|
||||||
|
pattern: "grid",
|
||||||
|
sidebarOpen: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates the legacy pen field", () => {
|
||||||
|
expect(parseToolPrefs({ pen: "highlighter" })).toEqual({ tool: "highlighter" });
|
||||||
|
expect(parseToolPrefs({ pen: "eraser" })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects absurd or non-finite sizes", () => {
|
||||||
|
expect(parseToolPrefs({ size: 500 })).toEqual({});
|
||||||
|
expect(parseToolPrefs({ size: Infinity })).toEqual({});
|
||||||
|
expect(parseToolPrefs({ size: -2 })).toEqual({});
|
||||||
|
expect(parseToolPrefs({ size: 2.5 })).toEqual({ size: 2.5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { normalizeHex } from "../model/color";
|
||||||
|
import { PAGE_PATTERNS, type PagePattern } from "../model/page";
|
||||||
|
import { TOOL_KINDS, type ToolKind } from "../model/stroke";
|
||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
|
||||||
|
const PREFS_KEY = "vas.toolPrefs";
|
||||||
|
|
||||||
|
interface ToolPrefs {
|
||||||
|
tool?: ToolKind;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
paperColor?: string;
|
||||||
|
pattern?: PagePattern;
|
||||||
|
sidebarOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadToolPrefs(): ToolPrefs {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(localStorage.getItem(PREFS_KEY) ?? "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parseToolPrefs(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolPrefs(raw: unknown): ToolPrefs {
|
||||||
|
if (typeof raw !== "object" || raw === null) return {};
|
||||||
|
const prefs = raw as Record<string, unknown>;
|
||||||
|
const out: ToolPrefs = {};
|
||||||
|
if (TOOL_KINDS.includes(prefs.tool as ToolKind)) out.tool = prefs.tool as ToolKind;
|
||||||
|
if (prefs.tool === undefined && (prefs.pen === "pen" || prefs.pen === "highlighter")) {
|
||||||
|
out.tool = prefs.pen;
|
||||||
|
}
|
||||||
|
const color = typeof prefs.color === "string" ? normalizeHex(prefs.color) : null;
|
||||||
|
if (color) out.color = color;
|
||||||
|
if (
|
||||||
|
typeof prefs.size === "number" &&
|
||||||
|
Number.isFinite(prefs.size) &&
|
||||||
|
prefs.size > 0 &&
|
||||||
|
prefs.size <= 48
|
||||||
|
) {
|
||||||
|
out.size = prefs.size;
|
||||||
|
}
|
||||||
|
const paperColor = typeof prefs.paperColor === "string" ? normalizeHex(prefs.paperColor) : null;
|
||||||
|
if (paperColor) out.paperColor = paperColor;
|
||||||
|
if (PAGE_PATTERNS.includes(prefs.pattern as PagePattern)) {
|
||||||
|
out.pattern = prefs.pattern as PagePattern;
|
||||||
|
}
|
||||||
|
if (typeof prefs.sidebarOpen === "boolean") out.sidebarOpen = prefs.sidebarOpen;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPrefsSync(): () => void {
|
||||||
|
let timer: number | undefined;
|
||||||
|
const unsubscribe = useBoardStore.subscribe((state, prev) => {
|
||||||
|
if (
|
||||||
|
state.tool === prev.tool &&
|
||||||
|
state.color === prev.color &&
|
||||||
|
state.size === prev.size &&
|
||||||
|
state.paperColor === prev.paperColor &&
|
||||||
|
state.pattern === prev.pattern &&
|
||||||
|
state.sidebarOpen === prev.sidebarOpen
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
const { tool, color, size, paperColor, pattern, sidebarOpen } = useBoardStore.getState();
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
PREFS_KEY,
|
||||||
|
JSON.stringify({ tool, color, size, paperColor, pattern, sidebarOpen }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// storage may be unavailable
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { decodeBlob } from "../engine/imageCache";
|
||||||
|
|
||||||
|
const RASTER_SCALE = 3;
|
||||||
|
|
||||||
|
export async function rasterizeToPng(blob: Blob): Promise<Uint8Array | null> {
|
||||||
|
try {
|
||||||
|
const image = await decodeBlob(blob);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round((image.naturalWidth || 300) * RASTER_SCALE));
|
||||||
|
canvas.height = Math.max(1, Math.round((image.naturalHeight || 150) * RASTER_SCALE));
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
const png = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||||
|
if (!png) return null;
|
||||||
|
return new Uint8Array(await png.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ViewState } from "../model/viewState";
|
||||||
|
import { useBoardStore } from "../store/useBoardStore";
|
||||||
|
import { gcUnreferencedImages } from "./images";
|
||||||
|
import { loadNotebook, saveViewState } from "./notebooks";
|
||||||
|
|
||||||
|
const LAST_NOTEBOOK_KEY = "vas.lastNotebookId";
|
||||||
|
const VIEW_STATE_SAVE_DELAY_MS = 400;
|
||||||
|
|
||||||
|
let pendingViewState: { id: string; viewState: ViewState } | null = null;
|
||||||
|
let viewStateTimer: number | undefined;
|
||||||
|
|
||||||
|
export function scheduleViewStateSave(id: string, viewState: ViewState): void {
|
||||||
|
pendingViewState = { id, viewState };
|
||||||
|
window.clearTimeout(viewStateTimer);
|
||||||
|
viewStateTimer = window.setTimeout(() => void flushViewStateSave(), VIEW_STATE_SAVE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushViewStateSave(): Promise<void> {
|
||||||
|
window.clearTimeout(viewStateTimer);
|
||||||
|
viewStateTimer = undefined;
|
||||||
|
const pending = pendingViewState;
|
||||||
|
pendingViewState = null;
|
||||||
|
if (!pending) return;
|
||||||
|
try {
|
||||||
|
await saveViewState(pending.id, pending.viewState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save view state", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openNotebook(id: string): Promise<void> {
|
||||||
|
await flushViewStateSave();
|
||||||
|
const { meta, pages } = await loadNotebook(id);
|
||||||
|
useBoardStore
|
||||||
|
.getState()
|
||||||
|
.loadDocument({ id: meta.id, title: meta.title, pages, viewState: meta.viewState });
|
||||||
|
void gcUnreferencedImages().catch((error) => console.error("Image GC failed", error));
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_NOTEBOOK_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// storage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeNotebook(): Promise<void> {
|
||||||
|
await flushViewStateSave();
|
||||||
|
useBoardStore.getState().unloadDocument();
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(LAST_NOTEBOOK_KEY);
|
||||||
|
} catch {
|
||||||
|
// storage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLastNotebookId(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LAST_NOTEBOOK_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { strFromU8, unzipSync } from "fflate";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Page } from "../model/page";
|
||||||
|
import {
|
||||||
|
buildNotebookZip,
|
||||||
|
imageEntryPath,
|
||||||
|
NOTEBOOK_JSON_ENTRY,
|
||||||
|
parseNotebookFile,
|
||||||
|
resolveImageEntries,
|
||||||
|
serializeNotebook,
|
||||||
|
} from "./transfer";
|
||||||
|
|
||||||
|
function samplePage(): Page {
|
||||||
|
return {
|
||||||
|
id: "page-1",
|
||||||
|
paperColor: "#003423",
|
||||||
|
pattern: "grid",
|
||||||
|
images: [],
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
id: "stroke-1",
|
||||||
|
pen: "highlighter",
|
||||||
|
color: "#f2b134",
|
||||||
|
size: 9,
|
||||||
|
simulatePressure: true,
|
||||||
|
points: [
|
||||||
|
{ x: 1, y: 2, pressure: 0.4 },
|
||||||
|
{ x: 30, y: 40, pressure: 0.8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stroke-2",
|
||||||
|
pen: "pen",
|
||||||
|
color: "#2f6fdd",
|
||||||
|
size: 3,
|
||||||
|
simulatePressure: false,
|
||||||
|
shape: "arrow",
|
||||||
|
points: [
|
||||||
|
{ x: 10, y: 10, pressure: 0.5 },
|
||||||
|
{ x: 100, y: 80, pressure: 0.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("serializeNotebook / parseNotebookFile", () => {
|
||||||
|
it("round-trips a notebook preserving content", () => {
|
||||||
|
const text = serializeNotebook("My notes", [samplePage()]);
|
||||||
|
const parsed = parseNotebookFile(text);
|
||||||
|
expect(parsed.title).toBe("My notes");
|
||||||
|
expect(parsed.pages).toHaveLength(1);
|
||||||
|
const page = parsed.pages[0];
|
||||||
|
expect(page.paperColor).toBe("#003423");
|
||||||
|
expect(page.pattern).toBe("grid");
|
||||||
|
expect(page.strokes).toHaveLength(2);
|
||||||
|
const stroke = page.strokes[0];
|
||||||
|
expect(stroke.pen).toBe("highlighter");
|
||||||
|
expect(stroke.color).toBe("#f2b134");
|
||||||
|
expect(stroke.size).toBe(9);
|
||||||
|
expect(stroke.simulatePressure).toBe(true);
|
||||||
|
expect(stroke.points).toEqual([
|
||||||
|
{ x: 1, y: 2, pressure: 0.4 },
|
||||||
|
{ x: 30, y: 40, pressure: 0.8 },
|
||||||
|
]);
|
||||||
|
const shape = page.strokes[1];
|
||||||
|
expect(shape.shape).toBe("arrow");
|
||||||
|
expect(shape.points).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("regenerates ids on import", () => {
|
||||||
|
const text = serializeNotebook("My notes", [samplePage()]);
|
||||||
|
const parsed = parseNotebookFile(text);
|
||||||
|
expect(parsed.pages[0].id).not.toBe("page-1");
|
||||||
|
expect(parsed.pages[0].strokes[0].id).not.toBe("stroke-1");
|
||||||
|
const second = parseNotebookFile(text);
|
||||||
|
expect(second.pages[0].id).not.toBe(parsed.pages[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-JSON input", () => {
|
||||||
|
expect(() => parseNotebookFile("not json")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a foreign file format", () => {
|
||||||
|
expect(() => parseNotebookFile(JSON.stringify({ format: "other", version: 1 }))).toThrow(
|
||||||
|
"Not a vas notebook file",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsupported version", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseNotebookFile(
|
||||||
|
JSON.stringify({ format: "vas-notebook", version: 99, pages: [{ strokes: [] }] }),
|
||||||
|
),
|
||||||
|
).toThrow("Unsupported file version");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a file without pages", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseNotebookFile(JSON.stringify({ format: "vas-notebook", version: 1, pages: [] })),
|
||||||
|
).toThrow("no pages");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies defaults for missing optional fields", () => {
|
||||||
|
const text = JSON.stringify({
|
||||||
|
format: "vas-notebook",
|
||||||
|
version: 1,
|
||||||
|
pages: [{ strokes: [{ points: [{ x: 1, y: 2 }] }] }],
|
||||||
|
});
|
||||||
|
const parsed = parseNotebookFile(text);
|
||||||
|
expect(parsed.title).toBe("Imported notebook");
|
||||||
|
const stroke = parsed.pages[0].strokes[0];
|
||||||
|
expect(parsed.pages[0].paperColor).toBe("#ffffff");
|
||||||
|
expect(parsed.pages[0].pattern).toBe("blank");
|
||||||
|
expect(stroke.pen).toBe("pen");
|
||||||
|
expect(stroke.color).toBe("#1a1a1a");
|
||||||
|
expect(stroke.size).toBe(5);
|
||||||
|
expect(stroke.simulatePressure).toBe(false);
|
||||||
|
expect(stroke.points[0].pressure).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stroke with invalid points", () => {
|
||||||
|
const text = JSON.stringify({
|
||||||
|
format: "vas-notebook",
|
||||||
|
version: 1,
|
||||||
|
pages: [{ strokes: [{ points: [{ x: "1", y: 2 }] }] }],
|
||||||
|
});
|
||||||
|
expect(() => parseNotebookFile(text)).toThrow("Invalid point");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-finite point coordinates", () => {
|
||||||
|
const text = `{
|
||||||
|
"format": "vas-notebook",
|
||||||
|
"version": 1,
|
||||||
|
"pages": [{ "strokes": [{ "points": [{ "x": 1e999, "y": 2 }] }] }]
|
||||||
|
}`;
|
||||||
|
expect(() => parseNotebookFile(text)).toThrow("Invalid point");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default size for non-finite stroke size", () => {
|
||||||
|
const text = `{
|
||||||
|
"format": "vas-notebook",
|
||||||
|
"version": 1,
|
||||||
|
"pages": [{ "strokes": [{ "size": 1e999, "points": [{ "x": 1, "y": 2 }] }] }]
|
||||||
|
}`;
|
||||||
|
const parsed = parseNotebookFile(text);
|
||||||
|
expect(parsed.pages[0].strokes[0].size).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips the view state when present", () => {
|
||||||
|
const text = serializeNotebook("Views", [samplePage()], [], { x: 12, y: 400, zoom: 2.5 });
|
||||||
|
expect(parseNotebookFile(text).viewState).toEqual({ x: 12, y: 400, zoom: 2.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the view state when absent", () => {
|
||||||
|
expect(parseNotebookFile(serializeNotebook("Views", [samplePage()])).viewState).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid view state", () => {
|
||||||
|
const text = JSON.stringify({
|
||||||
|
format: "vas-notebook",
|
||||||
|
version: 2,
|
||||||
|
viewState: { x: 0, y: 0, zoom: -1 },
|
||||||
|
pages: [{ strokes: [] }],
|
||||||
|
});
|
||||||
|
expect(() => parseNotebookFile(text)).toThrow("Invalid view state");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notebook images and zip packaging", () => {
|
||||||
|
function imagedPage(): Page {
|
||||||
|
return {
|
||||||
|
...samplePage(),
|
||||||
|
images: [
|
||||||
|
{ id: "item-1", imageId: "blob-1", x: 40, y: 40, width: 200, height: 100, locked: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("round-trips pages with images and remaps the image ids", () => {
|
||||||
|
const text = serializeNotebook(
|
||||||
|
"With images",
|
||||||
|
[imagedPage()],
|
||||||
|
[{ imageId: "blob-1", mimeType: "image/png" }],
|
||||||
|
);
|
||||||
|
const parsed = parseNotebookFile(text);
|
||||||
|
expect(parsed.images).toHaveLength(1);
|
||||||
|
const entry = parsed.images[0];
|
||||||
|
expect(entry.sourceId).toBe("blob-1");
|
||||||
|
expect(entry.imageId).not.toBe("blob-1");
|
||||||
|
expect(entry.mimeType).toBe("image/png");
|
||||||
|
const item = parsed.pages[0].images[0];
|
||||||
|
expect(item.imageId).toBe(entry.imageId);
|
||||||
|
expect(item.id).not.toBe("item-1");
|
||||||
|
expect(item).toMatchObject({ x: 40, y: 40, width: 200, height: 100, locked: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a page referencing an image missing from the manifest", () => {
|
||||||
|
const text = JSON.stringify({
|
||||||
|
format: "vas-notebook",
|
||||||
|
version: 2,
|
||||||
|
pages: [{ strokes: [], images: [{ imageId: "ghost", x: 0, y: 0, width: 10, height: 10 }] }],
|
||||||
|
});
|
||||||
|
expect(() => parseNotebookFile(text)).toThrow("unknown image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid image geometry", () => {
|
||||||
|
const text = JSON.stringify({
|
||||||
|
format: "vas-notebook",
|
||||||
|
version: 2,
|
||||||
|
images: [{ imageId: "blob-1", mimeType: "image/png" }],
|
||||||
|
pages: [{ strokes: [], images: [{ imageId: "blob-1", x: 0, y: 0, width: -5, height: 10 }] }],
|
||||||
|
});
|
||||||
|
expect(() => parseNotebookFile(text)).toThrow("Invalid image width");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a zip that parses back with image bytes resolved", () => {
|
||||||
|
const json = serializeNotebook(
|
||||||
|
"Zip",
|
||||||
|
[imagedPage()],
|
||||||
|
[{ imageId: "blob-1", mimeType: "image/png" }],
|
||||||
|
);
|
||||||
|
const pngBytes = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
const zip = buildNotebookZip(json, [
|
||||||
|
{ path: imageEntryPath("blob-1", "image/png"), data: pngBytes },
|
||||||
|
]);
|
||||||
|
expect(zip[0]).toBe(0x50);
|
||||||
|
expect(zip[1]).toBe(0x4b);
|
||||||
|
const entries = unzipSync(zip);
|
||||||
|
const parsed = parseNotebookFile(strFromU8(entries[NOTEBOOK_JSON_ENTRY]));
|
||||||
|
const resolved = resolveImageEntries(entries, parsed.images);
|
||||||
|
expect([...resolved[0]]).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveImageEntries throws when an image file is missing", () => {
|
||||||
|
const json = serializeNotebook(
|
||||||
|
"Zip",
|
||||||
|
[imagedPage()],
|
||||||
|
[{ imageId: "blob-1", mimeType: "image/png" }],
|
||||||
|
);
|
||||||
|
const zip = buildNotebookZip(json, []);
|
||||||
|
const entries = unzipSync(zip);
|
||||||
|
const parsed = parseNotebookFile(strFromU8(entries[NOTEBOOK_JSON_ENTRY]));
|
||||||
|
expect(() => resolveImageEntries(entries, parsed.images)).toThrow("Missing image data");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
||||||
|
import { normalizeHex } from "../model/color";
|
||||||
|
import { type ImageItem, imageExtension } from "../model/image";
|
||||||
|
import { PAGE_PATTERNS, type Page, type PagePattern } from "../model/page";
|
||||||
|
import {
|
||||||
|
newId,
|
||||||
|
type PenKind,
|
||||||
|
SHAPE_KINDS,
|
||||||
|
type ShapeKind,
|
||||||
|
type Stroke,
|
||||||
|
type StrokePoint,
|
||||||
|
} from "../model/stroke";
|
||||||
|
import type { ViewState } from "../model/viewState";
|
||||||
|
import type { ImageRecord } from "./db";
|
||||||
|
import { deleteImages, getImage, saveImages } from "./images";
|
||||||
|
import {
|
||||||
|
createNotebook,
|
||||||
|
deleteNotebook,
|
||||||
|
loadNotebook,
|
||||||
|
replacePages,
|
||||||
|
saveViewState,
|
||||||
|
} from "./notebooks";
|
||||||
|
|
||||||
|
export const FILE_FORMAT = "vas-notebook";
|
||||||
|
export const FILE_VERSION = 2;
|
||||||
|
export const NOTEBOOK_JSON_ENTRY = "notebook.json";
|
||||||
|
|
||||||
|
const FALLBACK_INK = "#1a1a1a";
|
||||||
|
const FALLBACK_PAPER = "#ffffff";
|
||||||
|
const FALLBACK_SIZE = 5;
|
||||||
|
|
||||||
|
export interface ImageManifestEntry {
|
||||||
|
imageId: string;
|
||||||
|
mimeType: string;
|
||||||
|
sourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNotebook(
|
||||||
|
title: string,
|
||||||
|
pages: Page[],
|
||||||
|
images: ImageManifestEntry[] = [],
|
||||||
|
viewState?: ViewState,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
format: FILE_FORMAT,
|
||||||
|
version: FILE_VERSION,
|
||||||
|
title,
|
||||||
|
...(viewState ? { viewState } : {}),
|
||||||
|
pages: pages.map((page) => ({
|
||||||
|
paperColor: page.paperColor,
|
||||||
|
pattern: page.pattern,
|
||||||
|
strokes: page.strokes.map((stroke) => ({
|
||||||
|
pen: stroke.pen,
|
||||||
|
color: stroke.color,
|
||||||
|
size: stroke.size,
|
||||||
|
simulatePressure: stroke.simulatePressure,
|
||||||
|
shape: stroke.shape,
|
||||||
|
points: stroke.points,
|
||||||
|
})),
|
||||||
|
images: page.images.map((image) => ({
|
||||||
|
imageId: image.imageId,
|
||||||
|
x: image.x,
|
||||||
|
y: image.y,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
...(image.locked ? { locked: true } : {}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
...(images.length > 0
|
||||||
|
? { images: images.map((e) => ({ imageId: e.imageId, mimeType: e.mimeType })) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNotebookFile(text: string): {
|
||||||
|
title: string;
|
||||||
|
pages: Page[];
|
||||||
|
images: Required<ImageManifestEntry>[];
|
||||||
|
viewState?: ViewState;
|
||||||
|
} {
|
||||||
|
const data: unknown = JSON.parse(text);
|
||||||
|
if (!isRecord(data) || data.format !== FILE_FORMAT) {
|
||||||
|
throw new Error("Not a vas notebook file");
|
||||||
|
}
|
||||||
|
if (data.version !== 1 && data.version !== FILE_VERSION) {
|
||||||
|
throw new Error(`Unsupported file version: ${String(data.version)}`);
|
||||||
|
}
|
||||||
|
const title =
|
||||||
|
typeof data.title === "string" && data.title.trim().length > 0
|
||||||
|
? data.title.trim()
|
||||||
|
: "Imported notebook";
|
||||||
|
if (!Array.isArray(data.pages) || data.pages.length === 0) {
|
||||||
|
throw new Error("File contains no pages");
|
||||||
|
}
|
||||||
|
const images = parseImageManifest(data);
|
||||||
|
const remap = new Map(images.map((entry) => [entry.sourceId, entry.imageId]));
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
pages: data.pages.map((page) => parsePage(page, remap)),
|
||||||
|
images,
|
||||||
|
viewState: parseViewState(data.viewState),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseViewState(raw: unknown): ViewState | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
if (!isRecord(raw)) throw new Error("Invalid view state");
|
||||||
|
if (
|
||||||
|
typeof raw.x !== "number" ||
|
||||||
|
!Number.isFinite(raw.x) ||
|
||||||
|
typeof raw.y !== "number" ||
|
||||||
|
!Number.isFinite(raw.y) ||
|
||||||
|
typeof raw.zoom !== "number" ||
|
||||||
|
!Number.isFinite(raw.zoom) ||
|
||||||
|
raw.zoom <= 0
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid view state");
|
||||||
|
}
|
||||||
|
return { x: raw.x, y: raw.y, zoom: raw.zoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNotebookZip(
|
||||||
|
json: string,
|
||||||
|
files: { path: string; data: Uint8Array }[],
|
||||||
|
): Uint8Array {
|
||||||
|
const entries: Record<string, Uint8Array> = { [NOTEBOOK_JSON_ENTRY]: strToU8(json) };
|
||||||
|
for (const file of files) entries[file.path] = file.data;
|
||||||
|
return zipSync(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageEntryPath(imageId: string, mimeType: string): string {
|
||||||
|
return `images/${imageId}.${imageExtension(mimeType)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveImageEntries(
|
||||||
|
entries: Record<string, Uint8Array>,
|
||||||
|
manifest: Required<ImageManifestEntry>[],
|
||||||
|
): Uint8Array[] {
|
||||||
|
return manifest.map((entry) => {
|
||||||
|
const data = entries[imageEntryPath(entry.sourceId, entry.mimeType)];
|
||||||
|
if (!data) throw new Error(`Missing image data for ${entry.sourceId}`);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadNotebook(id: string): Promise<void> {
|
||||||
|
const { meta, pages } = await loadNotebook(id);
|
||||||
|
const referenced = new Set<string>();
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const image of page.images) referenced.add(image.imageId);
|
||||||
|
}
|
||||||
|
if (referenced.size === 0) {
|
||||||
|
const blob = new Blob([serializeNotebook(meta.title, pages, [], meta.viewState)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
downloadBlob(blob, `${meta.title}.vas.json`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const records = new Map<string, ImageRecord>();
|
||||||
|
for (const imageId of referenced) {
|
||||||
|
const record = await getImage(imageId);
|
||||||
|
if (record) records.set(imageId, record);
|
||||||
|
}
|
||||||
|
const cleanPages = pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
images: page.images.filter((image) => records.has(image.imageId)),
|
||||||
|
}));
|
||||||
|
const manifest: ImageManifestEntry[] = [...records.values()].map((record) => ({
|
||||||
|
imageId: record.id,
|
||||||
|
mimeType: record.mimeType,
|
||||||
|
}));
|
||||||
|
const json = serializeNotebook(meta.title, cleanPages, manifest, meta.viewState);
|
||||||
|
const files: { path: string; data: Uint8Array }[] = [];
|
||||||
|
for (const record of records.values()) {
|
||||||
|
files.push({
|
||||||
|
path: imageEntryPath(record.id, record.mimeType),
|
||||||
|
data: new Uint8Array(await record.blob.arrayBuffer()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const zip = buildNotebookZip(json, files);
|
||||||
|
downloadBlob(
|
||||||
|
new Blob([zip.buffer as ArrayBuffer], { type: "application/zip" }),
|
||||||
|
`${meta.title}.vas.zip`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename.replace(/[\\/:*?"<>|]/g, "_");
|
||||||
|
anchor.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importNotebookFile(file: File): Promise<string> {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
if (bytes[0] === 0x50 && bytes[1] === 0x4b) {
|
||||||
|
return importNotebookZip(bytes);
|
||||||
|
}
|
||||||
|
const parsed = parseNotebookFile(strFromU8(bytes));
|
||||||
|
if (parsed.images.length > 0) {
|
||||||
|
throw new Error("This file references images; import the original .vas.zip archive instead");
|
||||||
|
}
|
||||||
|
const meta = await createNotebook(parsed.title);
|
||||||
|
try {
|
||||||
|
await replacePages(meta.id, parsed.pages);
|
||||||
|
if (parsed.viewState) await saveViewState(meta.id, parsed.viewState);
|
||||||
|
} catch (error) {
|
||||||
|
await deleteNotebook(meta.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return meta.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importNotebookZip(bytes: Uint8Array): Promise<string> {
|
||||||
|
let entries: Record<string, Uint8Array>;
|
||||||
|
try {
|
||||||
|
entries = unzipSync(bytes);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid zip archive");
|
||||||
|
}
|
||||||
|
const jsonEntry = entries[NOTEBOOK_JSON_ENTRY];
|
||||||
|
if (!jsonEntry) throw new Error("Archive contains no notebook.json");
|
||||||
|
const parsed = parseNotebookFile(strFromU8(jsonEntry));
|
||||||
|
const imageData = resolveImageEntries(entries, parsed.images);
|
||||||
|
const records: ImageRecord[] = parsed.images.map((entry, index) => ({
|
||||||
|
id: entry.imageId,
|
||||||
|
mimeType: entry.mimeType,
|
||||||
|
blob: new Blob([imageData[index].buffer as ArrayBuffer], { type: entry.mimeType }),
|
||||||
|
}));
|
||||||
|
const meta = await createNotebook(parsed.title);
|
||||||
|
try {
|
||||||
|
await saveImages(records);
|
||||||
|
await replacePages(meta.id, parsed.pages);
|
||||||
|
if (parsed.viewState) await saveViewState(meta.id, parsed.viewState);
|
||||||
|
} catch (error) {
|
||||||
|
await deleteNotebook(meta.id);
|
||||||
|
await deleteImages(records.map((record) => record.id));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return meta.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImageManifest(data: Record<string, unknown>): Required<ImageManifestEntry>[] {
|
||||||
|
if (data.images === undefined) return [];
|
||||||
|
if (!Array.isArray(data.images)) throw new Error("Invalid images manifest");
|
||||||
|
return data.images.map((raw) => {
|
||||||
|
if (!isRecord(raw) || typeof raw.imageId !== "string" || raw.imageId.length === 0) {
|
||||||
|
throw new Error("Invalid image entry");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
imageId: newId(),
|
||||||
|
mimeType:
|
||||||
|
typeof raw.mimeType === "string" && raw.mimeType.length > 0
|
||||||
|
? raw.mimeType
|
||||||
|
: "application/octet-stream",
|
||||||
|
sourceId: raw.imageId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePage(raw: unknown, remap: Map<string, string>): Page {
|
||||||
|
if (!isRecord(raw)) throw new Error("Invalid page");
|
||||||
|
if (!Array.isArray(raw.strokes)) throw new Error("Invalid page strokes");
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
paperColor: parseColor(raw.paperColor, FALLBACK_PAPER),
|
||||||
|
pattern: PAGE_PATTERNS.includes(raw.pattern as PagePattern)
|
||||||
|
? (raw.pattern as PagePattern)
|
||||||
|
: "blank",
|
||||||
|
strokes: raw.strokes.map(parseStroke),
|
||||||
|
images: parsePageImages(raw.images, remap),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePageImages(raw: unknown, remap: Map<string, string>): ImageItem[] {
|
||||||
|
if (raw === undefined) return [];
|
||||||
|
if (!Array.isArray(raw)) throw new Error("Invalid page images");
|
||||||
|
return raw.map((entry) => {
|
||||||
|
if (!isRecord(entry) || typeof entry.imageId !== "string") throw new Error("Invalid image");
|
||||||
|
const imageId = remap.get(entry.imageId);
|
||||||
|
if (!imageId) throw new Error("Page references an unknown image");
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
imageId,
|
||||||
|
x: parseFiniteNumber(entry.x, "image x"),
|
||||||
|
y: parseFiniteNumber(entry.y, "image y"),
|
||||||
|
width: parsePositiveNumber(entry.width, "image width"),
|
||||||
|
height: parsePositiveNumber(entry.height, "image height"),
|
||||||
|
...(entry.locked === true ? { locked: true } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFiniteNumber(raw: unknown, label: string): number {
|
||||||
|
if (typeof raw !== "number" || !Number.isFinite(raw)) throw new Error(`Invalid ${label}`);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveNumber(raw: unknown, label: string): number {
|
||||||
|
const value = parseFiniteNumber(raw, label);
|
||||||
|
if (value <= 0) throw new Error(`Invalid ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStroke(raw: unknown): Stroke {
|
||||||
|
if (!isRecord(raw)) throw new Error("Invalid stroke");
|
||||||
|
if (!Array.isArray(raw.points)) throw new Error("Invalid stroke points");
|
||||||
|
const points = raw.points.map(parsePoint);
|
||||||
|
if (points.length === 0) throw new Error("Stroke has no points");
|
||||||
|
const pen: PenKind = raw.pen === "highlighter" ? "highlighter" : "pen";
|
||||||
|
const shape = SHAPE_KINDS.includes(raw.shape as ShapeKind) ? (raw.shape as ShapeKind) : undefined;
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
pen,
|
||||||
|
color: parseColor(raw.color, FALLBACK_INK),
|
||||||
|
size:
|
||||||
|
typeof raw.size === "number" && Number.isFinite(raw.size) && raw.size > 0
|
||||||
|
? raw.size
|
||||||
|
: FALLBACK_SIZE,
|
||||||
|
simulatePressure: raw.simulatePressure === true,
|
||||||
|
shape,
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePoint(raw: unknown): StrokePoint {
|
||||||
|
if (
|
||||||
|
!isRecord(raw) ||
|
||||||
|
typeof raw.x !== "number" ||
|
||||||
|
!Number.isFinite(raw.x) ||
|
||||||
|
typeof raw.y !== "number" ||
|
||||||
|
!Number.isFinite(raw.y)
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid point");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: raw.x,
|
||||||
|
y: raw.y,
|
||||||
|
pressure:
|
||||||
|
typeof raw.pressure === "number" && Number.isFinite(raw.pressure) ? raw.pressure : 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColor(raw: unknown, fallback: string): string {
|
||||||
|
return typeof raw === "string" ? (normalizeHex(raw) ?? fallback) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
|
export function registerServiceWorker(): void {
|
||||||
|
registerSW({ immediate: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { ImageItem } from "../model/image";
|
||||||
|
import { createPage } from "../model/page";
|
||||||
|
import type { Stroke } from "../model/stroke";
|
||||||
|
import { useBoardStore } from "./useBoardStore";
|
||||||
|
|
||||||
|
function sampleStroke(id: string): Stroke {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
pen: "pen",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
size: 5,
|
||||||
|
simulatePressure: false,
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0, pressure: 0.5 },
|
||||||
|
{ x: 12, y: 8, pressure: 0.6 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleImage(id: string): ImageItem {
|
||||||
|
return { id, imageId: "blob-1", x: 100, y: 100, width: 200, height: 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [createPage("#ffffff")],
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
viewPageIndex: 0,
|
||||||
|
pendingScrollToPage: null,
|
||||||
|
paperColor: "#ffffff",
|
||||||
|
selection: null,
|
||||||
|
selectionAnchor: null,
|
||||||
|
clipboard: { strokes: [], images: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(reset);
|
||||||
|
|
||||||
|
describe("board store", () => {
|
||||||
|
it("starts with a single blank page", () => {
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages).toHaveLength(1);
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a stroke to the target page", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-appends a blank page when writing on the last page", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||||
|
expect(useBoardStore.getState().pages[1].strokes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-append when writing on an earlier page", () => {
|
||||||
|
const firstId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().addStroke(firstId, sampleStroke("s2"));
|
||||||
|
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addPage inserts right after the current page", () => {
|
||||||
|
const firstId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().setViewPageIndex(0);
|
||||||
|
useBoardStore.getState().addPage();
|
||||||
|
const pages = useBoardStore.getState().pages;
|
||||||
|
expect(pages).toHaveLength(3);
|
||||||
|
expect(pages[1].strokes).toHaveLength(0);
|
||||||
|
expect(pages[1].paperColor).toBe(useBoardStore.getState().paperColor);
|
||||||
|
expect(useBoardStore.getState().pendingScrollToPage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undo removes the last stroke and redo restores it", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(0);
|
||||||
|
useBoardStore.getState().redo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearPage empties the page and undo restores it", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().clearPage(pageId);
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(0);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearPage on an empty page is a no-op", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().clearPage(pageId);
|
||||||
|
expect(useBoardStore.getState().past).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a new edit clears the redo stack", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||||
|
expect(useBoardStore.getState().future).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undo and redo on empty stacks are no-ops", () => {
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
useBoardStore.getState().redo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPaperColor recolors only the current page", () => {
|
||||||
|
useBoardStore.getState().addPage();
|
||||||
|
useBoardStore.getState().setPaperColor("#003423");
|
||||||
|
const pages = useBoardStore.getState().pages;
|
||||||
|
expect(pages[0].paperColor).toBe("#003423");
|
||||||
|
expect(pages[1].paperColor).toBe("#ffffff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPaperColor on another page keeps earlier pages untouched", () => {
|
||||||
|
useBoardStore.getState().addPage();
|
||||||
|
useBoardStore.getState().setViewPageIndex(1);
|
||||||
|
useBoardStore.getState().setPaperColor("#003423");
|
||||||
|
const pages = useBoardStore.getState().pages;
|
||||||
|
expect(pages[0].paperColor).toBe("#ffffff");
|
||||||
|
expect(pages[1].paperColor).toBe("#003423");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a manually added page copies the current page's paper color", () => {
|
||||||
|
useBoardStore.setState({ pages: [createPage("#003423")], paperColor: "#ffffff" });
|
||||||
|
useBoardStore.getState().addPage();
|
||||||
|
expect(useBoardStore.getState().pages[1].paperColor).toBe("#003423");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an auto-appended page copies the last page's paper color", () => {
|
||||||
|
useBoardStore.setState({ pages: [createPage("#b98a5f")], paperColor: "#ffffff" });
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
const pages = useBoardStore.getState().pages;
|
||||||
|
expect(pages).toHaveLength(2);
|
||||||
|
expect(pages[1].paperColor).toBe("#b98a5f");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadDocument hydrates the notebook and clears history", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().loadDocument({
|
||||||
|
id: "nb-1",
|
||||||
|
title: "Loaded",
|
||||||
|
pages: [createPage("#fbf3db")],
|
||||||
|
});
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.notebookId).toBe("nb-1");
|
||||||
|
expect(state.notebookTitle).toBe("Loaded");
|
||||||
|
expect(state.pages[0].paperColor).toBe("#fbf3db");
|
||||||
|
expect(state.past).toHaveLength(0);
|
||||||
|
expect(state.viewPageIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unloadDocument returns to a blank notebook", () => {
|
||||||
|
useBoardStore.getState().loadDocument({
|
||||||
|
id: "nb-1",
|
||||||
|
title: "Loaded",
|
||||||
|
pages: [createPage("#fbf3db")],
|
||||||
|
});
|
||||||
|
useBoardStore.getState().unloadDocument();
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.notebookId).toBeNull();
|
||||||
|
expect(state.pages).toHaveLength(1);
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearPage leaves other pages untouched", () => {
|
||||||
|
const firstId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||||
|
const secondId = useBoardStore.getState().pages[1].id;
|
||||||
|
useBoardStore.getState().addStroke(secondId, sampleStroke("s2"));
|
||||||
|
useBoardStore.getState().clearPage(firstId);
|
||||||
|
const pages = useBoardStore.getState().pages;
|
||||||
|
expect(pages[0].strokes).toHaveLength(0);
|
||||||
|
expect(pages[1].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearPage keeps locked images and undo restores the removed content", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...useBoardStore.getState().pages[0],
|
||||||
|
strokes: [sampleStroke("s1")],
|
||||||
|
images: [sampleImage("i1"), { ...sampleImage("i2"), locked: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useBoardStore.getState().clearPage(pageId);
|
||||||
|
let state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(0);
|
||||||
|
expect(state.pages[0].images.map((i) => i.id)).toEqual(["i2"]);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||||
|
expect(state.pages[0].images.map((i) => i.id)).toEqual(["i1", "i2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearPage is a no-op on a page with only locked images", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...useBoardStore.getState().pages[0],
|
||||||
|
images: [{ ...sampleImage("i1"), locked: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useBoardStore.getState().clearPage(pageId);
|
||||||
|
expect(useBoardStore.getState().pages[0].images).toHaveLength(1);
|
||||||
|
expect(useBoardStore.getState().past).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletePage removes the page and resets history", () => {
|
||||||
|
const firstId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||||
|
const secondId = useBoardStore.getState().pages[1].id;
|
||||||
|
useBoardStore.getState().deletePage(firstId);
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages).toHaveLength(1);
|
||||||
|
expect(state.pages[0].id).toBe(secondId);
|
||||||
|
expect(state.past).toHaveLength(0);
|
||||||
|
expect(state.future).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletePage is a no-op on the last remaining page", () => {
|
||||||
|
const onlyId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().deletePage(onlyId);
|
||||||
|
expect(useBoardStore.getState().pages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletePage clamps the view page index", () => {
|
||||||
|
useBoardStore.getState().addPage();
|
||||||
|
useBoardStore.getState().setViewPageIndex(1);
|
||||||
|
const secondId = useBoardStore.getState().pages[1].id;
|
||||||
|
useBoardStore.getState().deletePage(secondId);
|
||||||
|
expect(useBoardStore.getState().viewPageIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeStroke erases the stroke and undo restores it at its original index", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||||
|
useBoardStore.getState().removeStroke(pageId, "s1");
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1", "s2"]);
|
||||||
|
useBoardStore.getState().redo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeStroke with an unknown id is a no-op", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().removeStroke(pageId, "missing");
|
||||||
|
expect(useBoardStore.getState().past).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPattern applies to the current page only", () => {
|
||||||
|
useBoardStore.getState().addPage();
|
||||||
|
useBoardStore.getState().setPattern("grid");
|
||||||
|
const pages = useBoardStore.getState().pages;
|
||||||
|
expect(pages[0].pattern).toBe("grid");
|
||||||
|
expect(pages[1].pattern).toBe("blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a manually added page copies the current page's pattern", () => {
|
||||||
|
useBoardStore.getState().setPattern("lined");
|
||||||
|
useBoardStore.getState().addPage();
|
||||||
|
expect(useBoardStore.getState().pages[1].pattern).toBe("lined");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an auto-appended page copies the last page's pattern", () => {
|
||||||
|
useBoardStore.getState().setPattern("dots");
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
expect(useBoardStore.getState().pages[1].pattern).toBe("dots");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTool remembers the last pen kind across the eraser", () => {
|
||||||
|
useBoardStore.getState().setTool("highlighter");
|
||||||
|
useBoardStore.getState().setTool("eraser");
|
||||||
|
expect(useBoardStore.getState().lastPenKind).toBe("highlighter");
|
||||||
|
useBoardStore.getState().setTool(useBoardStore.getState().lastPenKind);
|
||||||
|
expect(useBoardStore.getState().tool).toBe("highlighter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTool keeps the pen kind when switching to the laser", () => {
|
||||||
|
useBoardStore.getState().setTool("highlighter");
|
||||||
|
useBoardStore.getState().setTool("laser");
|
||||||
|
expect(useBoardStore.getState().lastPenKind).toBe("highlighter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addStroke with an unknown page id is a no-op", () => {
|
||||||
|
useBoardStore.getState().addStroke("missing-page", sampleStroke("s1"));
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(0);
|
||||||
|
expect(state.past).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selection and clipboard", () => {
|
||||||
|
function setupSelection(ids: string[] = ["s1", "s2"]): string {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||||
|
useBoardStore.getState().setSelection({ pageId, strokeIds: ids, imageIds: [] });
|
||||||
|
return pageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("transformSelection replaces the strokes as a single undoable edit", () => {
|
||||||
|
setupSelection();
|
||||||
|
const before = useBoardStore.getState().pages[0].strokes;
|
||||||
|
const after = before.map((s) => ({
|
||||||
|
...s,
|
||||||
|
points: s.points.map((p) => ({ ...p, x: p.x + 100 })),
|
||||||
|
}));
|
||||||
|
useBoardStore
|
||||||
|
.getState()
|
||||||
|
.transformSelection({ strokes: [...before], images: [] }, { strokes: after, images: [] });
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(100);
|
||||||
|
expect(useBoardStore.getState().past).toHaveLength(3);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(0);
|
||||||
|
useBoardStore.getState().redo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transformSelection without a selection is a no-op", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
const before = useBoardStore.getState().past.length;
|
||||||
|
useBoardStore
|
||||||
|
.getState()
|
||||||
|
.transformSelection({ strokes: [], images: [] }, { strokes: [], images: [] });
|
||||||
|
expect(useBoardStore.getState().past).toHaveLength(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recolorSelection recolors only the selected strokes", () => {
|
||||||
|
setupSelection(["s1"]);
|
||||||
|
useBoardStore.getState().recolorSelection("#d64541");
|
||||||
|
const strokes = useBoardStore.getState().pages[0].strokes;
|
||||||
|
expect(strokes[0].color).toBe("#d64541");
|
||||||
|
expect(strokes[1].color).toBe("#1a1a1a");
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes[0].color).toBe("#1a1a1a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recolorSelection with the same color is a no-op", () => {
|
||||||
|
setupSelection(["s1"]);
|
||||||
|
const past = useBoardStore.getState().past.length;
|
||||||
|
useBoardStore.getState().recolorSelection("#1a1a1a");
|
||||||
|
expect(useBoardStore.getState().past).toHaveLength(past);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteSelection removes the strokes and undo restores their order", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s3"));
|
||||||
|
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1", "s3"], imageIds: [] });
|
||||||
|
useBoardStore.getState().deleteSelection();
|
||||||
|
let state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||||
|
expect(state.selection).toBeNull();
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1", "s2", "s3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copySelection keeps the strokes and snapshots the clipboard", () => {
|
||||||
|
setupSelection(["s1"]);
|
||||||
|
useBoardStore.getState().copySelection();
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(2);
|
||||||
|
expect(state.clipboard.strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||||
|
state.clipboard.strokes[0].points[0].x = 9999;
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cutSelection removes the strokes and fills the clipboard", () => {
|
||||||
|
setupSelection(["s2"]);
|
||||||
|
useBoardStore.getState().cutSelection();
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||||
|
expect(state.clipboard.strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||||
|
expect(state.selection).toBeNull();
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pasteClipboard pastes at the top-left margin with fresh ids and selects the result", () => {
|
||||||
|
setupSelection(["s1"]);
|
||||||
|
useBoardStore.getState().copySelection();
|
||||||
|
useBoardStore.getState().setSelection(null);
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(3);
|
||||||
|
const pasted = state.pages[0].strokes[2];
|
||||||
|
expect(pasted.id).not.toBe("s1");
|
||||||
|
expect(pasted.points[0].x).toBe(42.5);
|
||||||
|
expect(pasted.points[0].y).toBe(42.5);
|
||||||
|
expect(state.selection?.strokeIds).toEqual([pasted.id]);
|
||||||
|
expect(state.tool).toBe("select");
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(2);
|
||||||
|
useBoardStore.getState().redo();
|
||||||
|
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pasting twice yields distinct stroke ids", () => {
|
||||||
|
setupSelection(["s1"]);
|
||||||
|
useBoardStore.getState().copySelection();
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
const ids = useBoardStore.getState().pages[0].strokes.map((s) => s.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pasteClipboard with an empty clipboard is a no-op", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
const past = useBoardStore.getState().past.length;
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
expect(useBoardStore.getState().past).toHaveLength(past);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cut then paste on another page moves the content across pages", () => {
|
||||||
|
const firstId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().setSelection({ pageId: firstId, strokeIds: ["s1"], imageIds: [] });
|
||||||
|
useBoardStore.getState().cutSelection();
|
||||||
|
useBoardStore.getState().setViewPageIndex(1);
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(0);
|
||||||
|
expect(state.pages[1].strokes).toHaveLength(1);
|
||||||
|
expect(state.selection?.pageId).toBe(state.pages[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setSelection(null) clears the anchor as well", () => {
|
||||||
|
setupSelection(["s1"]);
|
||||||
|
useBoardStore.getState().setSelectionAnchor({ x: 10, y: 20 });
|
||||||
|
useBoardStore.getState().setSelection(null);
|
||||||
|
expect(useBoardStore.getState().selectionAnchor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entering presentation mode clears the selection", () => {
|
||||||
|
setupSelection(["s1"]);
|
||||||
|
useBoardStore.getState().setPresentation(true);
|
||||||
|
expect(useBoardStore.getState().selection).toBeNull();
|
||||||
|
useBoardStore.getState().setPresentation(false);
|
||||||
|
expect(useBoardStore.getState().presentation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pasteClipboard on the last page auto-appends a blank page", () => {
|
||||||
|
useBoardStore.setState({ pages: [createPage("#b98a5f")], viewPageIndex: 0 });
|
||||||
|
const firstId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().setSelection({ pageId: firstId, strokeIds: ["s1"], imageIds: [] });
|
||||||
|
useBoardStore.getState().copySelection();
|
||||||
|
useBoardStore.getState().setViewPageIndex(1);
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
const pages = useBoardStore.getState().pages;
|
||||||
|
expect(pages).toHaveLength(3);
|
||||||
|
expect(pages[2].strokes).toHaveLength(0);
|
||||||
|
expect(pages[2].paperColor).toBe("#b98a5f");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pasteClipboard on an earlier page does not append a page", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1"], imageIds: [] });
|
||||||
|
useBoardStore.getState().copySelection();
|
||||||
|
useBoardStore.getState().setViewPageIndex(0);
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insertImage places the image, selects it, and undo removes it", () => {
|
||||||
|
useBoardStore.getState().insertImage("blob-1", 200, 100);
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].images).toHaveLength(1);
|
||||||
|
expect(state.pages[0].images[0].imageId).toBe("blob-1");
|
||||||
|
expect(state.pages[0].images[0].x).toBe(40);
|
||||||
|
expect(state.pages[0].images[0].y).toBe(40);
|
||||||
|
expect(state.selection?.imageIds).toEqual([state.pages[0].images[0].id]);
|
||||||
|
expect(state.tool).toBe("select");
|
||||||
|
expect(state.pages).toHaveLength(2);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].images).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insertImage on an earlier page does not append a page", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||||
|
useBoardStore.getState().setViewPageIndex(0);
|
||||||
|
useBoardStore.getState().insertImage("blob-1", 100, 100);
|
||||||
|
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearPage also clears images and undo restores them", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [{ ...useBoardStore.getState().pages[0], images: [sampleImage("i1")] }],
|
||||||
|
});
|
||||||
|
useBoardStore.getState().clearPage(pageId);
|
||||||
|
expect(useBoardStore.getState().pages[0].images).toHaveLength(0);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].images.map((i) => i.id)).toEqual(["i1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteSelection removes strokes and images as a single edit", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...useBoardStore.getState().pages[0],
|
||||||
|
strokes: [sampleStroke("s1")],
|
||||||
|
images: [sampleImage("i1")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1"], imageIds: ["i1"] });
|
||||||
|
useBoardStore.getState().deleteSelection();
|
||||||
|
let state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes).toHaveLength(0);
|
||||||
|
expect(state.pages[0].images).toHaveLength(0);
|
||||||
|
expect(state.past).toHaveLength(1);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||||
|
expect(state.pages[0].images.map((i) => i.id)).toEqual(["i1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy and paste duplicates images with fresh item ids sharing the blob", () => {
|
||||||
|
const pageId = useBoardStore.getState().pages[0].id;
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
...useBoardStore.getState().pages[0],
|
||||||
|
strokes: [sampleStroke("s1")],
|
||||||
|
images: [sampleImage("i1")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1"], imageIds: ["i1"] });
|
||||||
|
useBoardStore.getState().copySelection();
|
||||||
|
expect(useBoardStore.getState().clipboard.images[0].imageId).toBe("blob-1");
|
||||||
|
useBoardStore.getState().pasteClipboard();
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages[0].images).toHaveLength(2);
|
||||||
|
const pasted = state.pages[0].images[1];
|
||||||
|
expect(pasted.id).not.toBe("i1");
|
||||||
|
expect(pasted.imageId).toBe("blob-1");
|
||||||
|
expect(pasted.x).toBe(142.5);
|
||||||
|
expect(pasted.y).toBe(142.5);
|
||||||
|
expect(state.selection?.imageIds).toEqual([pasted.id]);
|
||||||
|
useBoardStore.getState().undo();
|
||||||
|
expect(useBoardStore.getState().pages[0].images).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("insertPdfPages", () => {
|
||||||
|
it("inserts locked pages after the current page inheriting its style", () => {
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [createPage("#003423", "grid"), createPage("#ffffff")],
|
||||||
|
viewPageIndex: 0,
|
||||||
|
});
|
||||||
|
useBoardStore.getState().insertPdfPages([
|
||||||
|
{ imageId: "blob-1", naturalWidth: 200, naturalHeight: 100 },
|
||||||
|
{ imageId: "blob-2", naturalWidth: 100, naturalHeight: 100 },
|
||||||
|
]);
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages).toHaveLength(4);
|
||||||
|
const inserted = state.pages[1];
|
||||||
|
expect(inserted.paperColor).toBe("#003423");
|
||||||
|
expect(inserted.pattern).toBe("grid");
|
||||||
|
expect(inserted.images).toHaveLength(1);
|
||||||
|
expect(inserted.images[0].locked).toBe(true);
|
||||||
|
expect(inserted.images[0].imageId).toBe("blob-1");
|
||||||
|
expect(inserted.images[0].x).toBeCloseTo(0);
|
||||||
|
expect(inserted.images[0].width).toBeCloseTo(794);
|
||||||
|
expect(state.pages[2].images[0].imageId).toBe("blob-2");
|
||||||
|
expect(state.pendingScrollToPage).toBe(1);
|
||||||
|
expect(state.past).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insertPdfPages with an empty list is a no-op", () => {
|
||||||
|
useBoardStore.getState().insertPdfPages([]);
|
||||||
|
expect(useBoardStore.getState().pages).toHaveLength(1);
|
||||||
|
expect(useBoardStore.getState().pendingScrollToPage).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("movePage", () => {
|
||||||
|
it("reorders pages and keeps the view on the same page", () => {
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [createPage("#ffffff"), createPage("#ffffff"), createPage("#ffffff")],
|
||||||
|
viewPageIndex: 2,
|
||||||
|
});
|
||||||
|
const [a, b, c] = useBoardStore.getState().pages.map((p) => p.id);
|
||||||
|
useBoardStore.getState().movePage(2, 0);
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages.map((p) => p.id)).toEqual([c, a, b]);
|
||||||
|
expect(state.viewPageIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the view page when an earlier page moves after it", () => {
|
||||||
|
useBoardStore.setState({
|
||||||
|
pages: [createPage("#ffffff"), createPage("#ffffff"), createPage("#ffffff")],
|
||||||
|
viewPageIndex: 2,
|
||||||
|
});
|
||||||
|
const [a, b, c] = useBoardStore.getState().pages.map((p) => p.id);
|
||||||
|
useBoardStore.getState().movePage(0, 2);
|
||||||
|
const state = useBoardStore.getState();
|
||||||
|
expect(state.pages.map((p) => p.id)).toEqual([b, c, a]);
|
||||||
|
expect(state.viewPageIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for invalid indices", () => {
|
||||||
|
const before = useBoardStore.getState().pages;
|
||||||
|
useBoardStore.getState().movePage(0, 0);
|
||||||
|
useBoardStore.getState().movePage(-1, 0);
|
||||||
|
useBoardStore.getState().movePage(0, 5);
|
||||||
|
expect(useBoardStore.getState().pages).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { createImageItem, type ImageItem, placeImageCentered } from "../model/image";
|
||||||
|
import { createPage, type Page, type PagePattern, PLACEMENT_MARGIN } from "../model/page";
|
||||||
|
import { newId, type PenKind, type Stroke, type ToolKind } from "../model/stroke";
|
||||||
|
import {
|
||||||
|
imagesBounds,
|
||||||
|
strokesBounds,
|
||||||
|
translateImage,
|
||||||
|
translateStroke,
|
||||||
|
unionBounds,
|
||||||
|
} from "../model/transform";
|
||||||
|
import type { ViewState } from "../model/viewState";
|
||||||
|
|
||||||
|
export const COLORS = ["#1a1a1a", "#d64541", "#2f6fdd", "#2e9e5b", "#f2b134", "#ffffff"] as const;
|
||||||
|
export const PAPER_COLORS = [
|
||||||
|
"#ffffff",
|
||||||
|
"#fbf3db",
|
||||||
|
"#eef1f4",
|
||||||
|
"#26262a",
|
||||||
|
"#003423",
|
||||||
|
"#b98a5f",
|
||||||
|
] as const;
|
||||||
|
export const SIZES = [1.5, 2.5, 4.5] as const;
|
||||||
|
|
||||||
|
export type Edit =
|
||||||
|
| { kind: "add-stroke"; pageId: string; stroke: Stroke }
|
||||||
|
| { kind: "remove-stroke"; pageId: string; index: number; stroke: Stroke }
|
||||||
|
| { kind: "clear-page"; pageId: string; strokes: Stroke[]; images: ImageItem[] }
|
||||||
|
| { kind: "add-elements"; pageId: string; strokes: Stroke[]; images: ImageItem[] }
|
||||||
|
| {
|
||||||
|
kind: "remove-elements";
|
||||||
|
pageId: string;
|
||||||
|
strokes: { index: number; stroke: Stroke }[];
|
||||||
|
images: { index: number; image: ImageItem }[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "replace-elements";
|
||||||
|
pageId: string;
|
||||||
|
strokesBefore: Stroke[];
|
||||||
|
strokesAfter: Stroke[];
|
||||||
|
imagesBefore: ImageItem[];
|
||||||
|
imagesAfter: ImageItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SelectionTarget {
|
||||||
|
pageId: string;
|
||||||
|
strokeIds: string[];
|
||||||
|
imageIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClipboardContent {
|
||||||
|
strokes: Stroke[];
|
||||||
|
images: ImageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementEntries {
|
||||||
|
pageId: string;
|
||||||
|
strokes: { index: number; stroke: Stroke }[];
|
||||||
|
images: { index: number; image: ImageItem }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardState {
|
||||||
|
notebookId: string | null;
|
||||||
|
notebookTitle: string;
|
||||||
|
pages: Page[];
|
||||||
|
past: Edit[];
|
||||||
|
future: Edit[];
|
||||||
|
viewPageIndex: number;
|
||||||
|
pendingScrollToPage: number | null;
|
||||||
|
tool: ToolKind;
|
||||||
|
lastPenKind: PenKind;
|
||||||
|
presentation: boolean;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
paperColor: string;
|
||||||
|
pattern: PagePattern;
|
||||||
|
selection: SelectionTarget | null;
|
||||||
|
selectionAnchor: { x: number; y: number } | null;
|
||||||
|
clipboard: ClipboardContent;
|
||||||
|
viewState: ViewState | null;
|
||||||
|
loadDocument: (doc: { id: string; title: string; pages: Page[]; viewState?: ViewState }) => void;
|
||||||
|
unloadDocument: () => void;
|
||||||
|
addStroke: (pageId: string, stroke: Stroke) => void;
|
||||||
|
removeStroke: (pageId: string, strokeId: string) => void;
|
||||||
|
addPage: () => void;
|
||||||
|
deletePage: (pageId: string) => void;
|
||||||
|
clearPage: (pageId: string) => void;
|
||||||
|
clearPendingScroll: () => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
setViewPageIndex: (index: number) => void;
|
||||||
|
setTool: (tool: ToolKind) => void;
|
||||||
|
setPresentation: (on: boolean) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
requestScrollToPage: (index: number) => void;
|
||||||
|
setColor: (color: string) => void;
|
||||||
|
setSize: (size: number) => void;
|
||||||
|
setPaperColor: (color: string) => void;
|
||||||
|
setPattern: (pattern: PagePattern) => void;
|
||||||
|
movePage: (from: number, to: number) => void;
|
||||||
|
setSelection: (selection: SelectionTarget | null) => void;
|
||||||
|
setSelectionAnchor: (anchor: { x: number; y: number } | null) => void;
|
||||||
|
transformSelection: (
|
||||||
|
before: { strokes: Stroke[]; images: ImageItem[] },
|
||||||
|
after: { strokes: Stroke[]; images: ImageItem[] },
|
||||||
|
) => void;
|
||||||
|
recolorSelection: (color: string) => void;
|
||||||
|
deleteSelection: () => void;
|
||||||
|
copySelection: () => void;
|
||||||
|
cutSelection: () => void;
|
||||||
|
pasteClipboard: () => void;
|
||||||
|
insertImage: (imageId: string, naturalWidth: number, naturalHeight: number) => void;
|
||||||
|
insertPdfPages: (
|
||||||
|
pdfPages: { imageId: string; naturalWidth: number; naturalHeight: number }[],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStroke(pages: Page[], pageId: string, stroke: Stroke): Page[] {
|
||||||
|
return pages.map((p) => (p.id === pageId ? { ...p, strokes: [...p.strokes, stroke] } : p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutStroke(pages: Page[], pageId: string, strokeId: string): Page[] {
|
||||||
|
return pages.map((p) =>
|
||||||
|
p.id === pageId ? { ...p, strokes: p.strokes.filter((s) => s.id !== strokeId) } : p,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutElements(
|
||||||
|
pages: Page[],
|
||||||
|
pageId: string,
|
||||||
|
strokeIds: Set<string>,
|
||||||
|
imageIds: Set<string>,
|
||||||
|
): Page[] {
|
||||||
|
return pages.map((p) =>
|
||||||
|
p.id === pageId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
strokes: p.strokes.filter((s) => !strokeIds.has(s.id)),
|
||||||
|
images: p.images.filter((i) => !imageIds.has(i.id)),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withInsertedStroke(pages: Page[], pageId: string, index: number, stroke: Stroke): Page[] {
|
||||||
|
return pages.map((p) => {
|
||||||
|
if (p.id !== pageId) return p;
|
||||||
|
const strokes = [...p.strokes];
|
||||||
|
strokes.splice(Math.min(index, strokes.length), 0, stroke);
|
||||||
|
return { ...p, strokes };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceById<T extends { id: string }>(items: T[], before: T[], after: T[]): T[] {
|
||||||
|
const byId = new Map(before.map((item, i) => [item.id, after[i]]));
|
||||||
|
return items.map((item) => byId.get(item.id) ?? item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEdit(pages: Page[], edit: Edit, direction: "do" | "undo"): Page[] {
|
||||||
|
switch (edit.kind) {
|
||||||
|
case "add-stroke":
|
||||||
|
return direction === "do"
|
||||||
|
? withStroke(pages, edit.pageId, edit.stroke)
|
||||||
|
: withoutStroke(pages, edit.pageId, edit.stroke.id);
|
||||||
|
case "remove-stroke":
|
||||||
|
return direction === "do"
|
||||||
|
? withoutStroke(pages, edit.pageId, edit.stroke.id)
|
||||||
|
: withInsertedStroke(pages, edit.pageId, edit.index, edit.stroke);
|
||||||
|
case "clear-page":
|
||||||
|
return pages.map((p) =>
|
||||||
|
p.id === edit.pageId
|
||||||
|
? direction === "do"
|
||||||
|
? { ...p, strokes: [], images: p.images.filter((i) => i.locked) }
|
||||||
|
: { ...p, strokes: edit.strokes, images: edit.images }
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
case "add-elements": {
|
||||||
|
if (direction === "do") {
|
||||||
|
return pages.map((p) =>
|
||||||
|
p.id === edit.pageId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
strokes: [...p.strokes, ...edit.strokes],
|
||||||
|
images: [...p.images, ...edit.images],
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return withoutElements(
|
||||||
|
pages,
|
||||||
|
edit.pageId,
|
||||||
|
new Set(edit.strokes.map((s) => s.id)),
|
||||||
|
new Set(edit.images.map((i) => i.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "remove-elements": {
|
||||||
|
if (direction === "do") {
|
||||||
|
return withoutElements(
|
||||||
|
pages,
|
||||||
|
edit.pageId,
|
||||||
|
new Set(edit.strokes.map((e) => e.stroke.id)),
|
||||||
|
new Set(edit.images.map((e) => e.image.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return pages.map((p) => {
|
||||||
|
if (p.id !== edit.pageId) return p;
|
||||||
|
const strokes = [...p.strokes];
|
||||||
|
for (const entry of [...edit.strokes].sort((a, b) => a.index - b.index)) {
|
||||||
|
strokes.splice(Math.min(entry.index, strokes.length), 0, entry.stroke);
|
||||||
|
}
|
||||||
|
const images = [...p.images];
|
||||||
|
for (const entry of [...edit.images].sort((a, b) => a.index - b.index)) {
|
||||||
|
images.splice(Math.min(entry.index, images.length), 0, entry.image);
|
||||||
|
}
|
||||||
|
return { ...p, strokes, images };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "replace-elements": {
|
||||||
|
const strokesBefore = direction === "do" ? edit.strokesBefore : edit.strokesAfter;
|
||||||
|
const strokesAfter = direction === "do" ? edit.strokesAfter : edit.strokesBefore;
|
||||||
|
const imagesBefore = direction === "do" ? edit.imagesBefore : edit.imagesAfter;
|
||||||
|
const imagesAfter = direction === "do" ? edit.imagesAfter : edit.imagesBefore;
|
||||||
|
return pages.map((p) =>
|
||||||
|
p.id === edit.pageId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
strokes: replaceById(p.strokes, strokesBefore, strokesAfter),
|
||||||
|
images: replaceById(p.images, imagesBefore, imagesAfter),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedElements(state: BoardState): ElementEntries | null {
|
||||||
|
const selection = state.selection;
|
||||||
|
if (!selection) return null;
|
||||||
|
const page = state.pages.find((p) => p.id === selection.pageId);
|
||||||
|
if (!page) return null;
|
||||||
|
const strokeIds = new Set(selection.strokeIds);
|
||||||
|
const imageIds = new Set(selection.imageIds);
|
||||||
|
const strokes: { index: number; stroke: Stroke }[] = [];
|
||||||
|
const images: { index: number; image: ImageItem }[] = [];
|
||||||
|
page.strokes.forEach((stroke, index) => {
|
||||||
|
if (strokeIds.has(stroke.id)) strokes.push({ index, stroke });
|
||||||
|
});
|
||||||
|
page.images.forEach((image, index) => {
|
||||||
|
if (imageIds.has(image.id)) images.push({ index, image });
|
||||||
|
});
|
||||||
|
if (strokes.length === 0 && images.length === 0) return null;
|
||||||
|
return { pageId: page.id, strokes, images };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBoardStore = create<BoardState>()((set) => ({
|
||||||
|
notebookId: null,
|
||||||
|
notebookTitle: "",
|
||||||
|
pages: [createPage(PAPER_COLORS[0])],
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
viewPageIndex: 0,
|
||||||
|
pendingScrollToPage: null,
|
||||||
|
tool: "pen",
|
||||||
|
lastPenKind: "pen",
|
||||||
|
presentation: false,
|
||||||
|
sidebarOpen: false,
|
||||||
|
color: COLORS[0],
|
||||||
|
size: SIZES[1],
|
||||||
|
paperColor: PAPER_COLORS[0],
|
||||||
|
pattern: "blank",
|
||||||
|
selection: null,
|
||||||
|
selectionAnchor: null,
|
||||||
|
clipboard: { strokes: [], images: [] },
|
||||||
|
viewState: null,
|
||||||
|
loadDocument: (doc) =>
|
||||||
|
set({
|
||||||
|
notebookId: doc.id,
|
||||||
|
notebookTitle: doc.title,
|
||||||
|
pages: doc.pages,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
viewPageIndex: 0,
|
||||||
|
pendingScrollToPage: null,
|
||||||
|
selection: null,
|
||||||
|
selectionAnchor: null,
|
||||||
|
viewState: doc.viewState ?? null,
|
||||||
|
}),
|
||||||
|
unloadDocument: () =>
|
||||||
|
set({
|
||||||
|
notebookId: null,
|
||||||
|
notebookTitle: "",
|
||||||
|
pages: [createPage(PAPER_COLORS[0])],
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
viewPageIndex: 0,
|
||||||
|
pendingScrollToPage: null,
|
||||||
|
selection: null,
|
||||||
|
selectionAnchor: null,
|
||||||
|
viewState: null,
|
||||||
|
}),
|
||||||
|
addStroke: (pageId, stroke) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!state.pages.some((p) => p.id === pageId)) return state;
|
||||||
|
let pages = withStroke(state.pages, pageId, stroke);
|
||||||
|
const lastPage = state.pages[state.pages.length - 1];
|
||||||
|
if (lastPage && lastPage.id === pageId) {
|
||||||
|
pages = [...pages, createPage(lastPage.paperColor, lastPage.pattern)];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
past: [...state.past, { kind: "add-stroke", pageId, stroke }],
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
removeStroke: (pageId, strokeId) =>
|
||||||
|
set((state) => {
|
||||||
|
const page = state.pages.find((p) => p.id === pageId);
|
||||||
|
if (!page) return state;
|
||||||
|
const index = page.strokes.findIndex((s) => s.id === strokeId);
|
||||||
|
if (index < 0) return state;
|
||||||
|
return {
|
||||||
|
pages: withoutStroke(state.pages, pageId, strokeId),
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{ kind: "remove-stroke", pageId, index, stroke: page.strokes[index] },
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
addPage: () =>
|
||||||
|
set((state) => {
|
||||||
|
const current = state.pages[state.viewPageIndex];
|
||||||
|
const insertIndex = state.viewPageIndex + 1;
|
||||||
|
const pages = [...state.pages];
|
||||||
|
pages.splice(
|
||||||
|
insertIndex,
|
||||||
|
0,
|
||||||
|
createPage(current?.paperColor ?? state.paperColor, current?.pattern ?? state.pattern),
|
||||||
|
);
|
||||||
|
return { pages, pendingScrollToPage: insertIndex };
|
||||||
|
}),
|
||||||
|
deletePage: (pageId) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.pages.length <= 1) return state;
|
||||||
|
const pages = state.pages.filter((p) => p.id !== pageId);
|
||||||
|
if (pages.length === state.pages.length) return state;
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
viewPageIndex: Math.min(state.viewPageIndex, pages.length - 1),
|
||||||
|
...(state.selection?.pageId === pageId ? { selection: null, selectionAnchor: null } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
clearPage: (pageId) =>
|
||||||
|
set((state) => {
|
||||||
|
const page = state.pages.find((p) => p.id === pageId);
|
||||||
|
const hasUnlockedContent =
|
||||||
|
page && (page.strokes.length > 0 || page.images.some((i) => !i.locked));
|
||||||
|
if (!page || !hasUnlockedContent) return state;
|
||||||
|
return {
|
||||||
|
pages: state.pages.map((p) =>
|
||||||
|
p.id === pageId ? { ...p, strokes: [], images: p.images.filter((i) => i.locked) } : p,
|
||||||
|
),
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{ kind: "clear-page", pageId, strokes: page.strokes, images: page.images },
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
...(state.selection?.pageId === pageId ? { selection: null, selectionAnchor: null } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
clearPendingScroll: () => set({ pendingScrollToPage: null }),
|
||||||
|
undo: () =>
|
||||||
|
set((state) => {
|
||||||
|
const edit = state.past[state.past.length - 1];
|
||||||
|
if (!edit) return state;
|
||||||
|
return {
|
||||||
|
pages: applyEdit(state.pages, edit, "undo"),
|
||||||
|
past: state.past.slice(0, -1),
|
||||||
|
future: [...state.future, edit],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
redo: () =>
|
||||||
|
set((state) => {
|
||||||
|
const edit = state.future[state.future.length - 1];
|
||||||
|
if (!edit) return state;
|
||||||
|
return {
|
||||||
|
pages: applyEdit(state.pages, edit, "do"),
|
||||||
|
past: [...state.past, edit],
|
||||||
|
future: state.future.slice(0, -1),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
setViewPageIndex: (index) => set({ viewPageIndex: index }),
|
||||||
|
setTool: (tool) =>
|
||||||
|
set((state) => ({
|
||||||
|
tool,
|
||||||
|
lastPenKind: tool === "pen" || tool === "highlighter" ? tool : state.lastPenKind,
|
||||||
|
})),
|
||||||
|
setPresentation: (on) =>
|
||||||
|
set(
|
||||||
|
on ? { presentation: true, selection: null, selectionAnchor: null } : { presentation: false },
|
||||||
|
),
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
|
requestScrollToPage: (index) => set({ pendingScrollToPage: index }),
|
||||||
|
setColor: (color) => set({ color }),
|
||||||
|
setSize: (size) => set({ size }),
|
||||||
|
setPaperColor: (paperColor) =>
|
||||||
|
set((state) => ({
|
||||||
|
paperColor,
|
||||||
|
pages: state.pages.map((p, i) => (i === state.viewPageIndex ? { ...p, paperColor } : p)),
|
||||||
|
})),
|
||||||
|
setPattern: (pattern) =>
|
||||||
|
set((state) => ({
|
||||||
|
pattern,
|
||||||
|
pages: state.pages.map((p, i) => (i === state.viewPageIndex ? { ...p, pattern } : p)),
|
||||||
|
})),
|
||||||
|
movePage: (from, to) =>
|
||||||
|
set((state) => {
|
||||||
|
const count = state.pages.length;
|
||||||
|
if (from === to || from < 0 || to < 0 || from >= count || to >= count) return state;
|
||||||
|
const viewId = state.pages[state.viewPageIndex]?.id;
|
||||||
|
const pages = [...state.pages];
|
||||||
|
const [moved] = pages.splice(from, 1);
|
||||||
|
pages.splice(to, 0, moved);
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
viewPageIndex: Math.max(
|
||||||
|
0,
|
||||||
|
pages.findIndex((p) => p.id === viewId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
setSelection: (selection) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!selection && !state.selection) return state;
|
||||||
|
return selection ? { selection } : { selection: null, selectionAnchor: null };
|
||||||
|
}),
|
||||||
|
setSelectionAnchor: (anchor) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!anchor && !state.selectionAnchor) return state;
|
||||||
|
if (
|
||||||
|
anchor &&
|
||||||
|
state.selectionAnchor &&
|
||||||
|
Math.round(anchor.x) === Math.round(state.selectionAnchor.x) &&
|
||||||
|
Math.round(anchor.y) === Math.round(state.selectionAnchor.y)
|
||||||
|
) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { selectionAnchor: anchor };
|
||||||
|
}),
|
||||||
|
transformSelection: (before, after) =>
|
||||||
|
set((state) => {
|
||||||
|
const selection = state.selection;
|
||||||
|
if (!selection) return state;
|
||||||
|
if (
|
||||||
|
before.strokes.length !== after.strokes.length ||
|
||||||
|
before.images.length !== after.images.length ||
|
||||||
|
(before.strokes.length === 0 && before.images.length === 0)
|
||||||
|
) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
if (!state.pages.some((p) => p.id === selection.pageId)) return state;
|
||||||
|
return {
|
||||||
|
pages: state.pages.map((p) =>
|
||||||
|
p.id === selection.pageId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
strokes: replaceById(p.strokes, before.strokes, after.strokes),
|
||||||
|
images: replaceById(p.images, before.images, after.images),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{
|
||||||
|
kind: "replace-elements",
|
||||||
|
pageId: selection.pageId,
|
||||||
|
strokesBefore: before.strokes,
|
||||||
|
strokesAfter: after.strokes,
|
||||||
|
imagesBefore: before.images,
|
||||||
|
imagesAfter: after.images,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
recolorSelection: (color) =>
|
||||||
|
set((state) => {
|
||||||
|
const selected = selectedElements(state);
|
||||||
|
if (!selected || selected.strokes.length === 0) return state;
|
||||||
|
const before = selected.strokes.map((e) => e.stroke);
|
||||||
|
if (before.every((s) => s.color === color)) return state;
|
||||||
|
const after = before.map((s) => ({ ...s, color }));
|
||||||
|
return {
|
||||||
|
pages: state.pages.map((p) =>
|
||||||
|
p.id === selected.pageId ? { ...p, strokes: replaceById(p.strokes, before, after) } : p,
|
||||||
|
),
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{
|
||||||
|
kind: "replace-elements",
|
||||||
|
pageId: selected.pageId,
|
||||||
|
strokesBefore: before,
|
||||||
|
strokesAfter: after,
|
||||||
|
imagesBefore: [],
|
||||||
|
imagesAfter: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
deleteSelection: () =>
|
||||||
|
set((state) => {
|
||||||
|
const selected = selectedElements(state);
|
||||||
|
if (!selected) return state;
|
||||||
|
return {
|
||||||
|
pages: withoutElements(
|
||||||
|
state.pages,
|
||||||
|
selected.pageId,
|
||||||
|
new Set(selected.strokes.map((e) => e.stroke.id)),
|
||||||
|
new Set(selected.images.map((e) => e.image.id)),
|
||||||
|
),
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{
|
||||||
|
kind: "remove-elements",
|
||||||
|
pageId: selected.pageId,
|
||||||
|
strokes: selected.strokes,
|
||||||
|
images: selected.images,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
selection: null,
|
||||||
|
selectionAnchor: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
copySelection: () =>
|
||||||
|
set((state) => {
|
||||||
|
const selected = selectedElements(state);
|
||||||
|
if (!selected) return state;
|
||||||
|
return {
|
||||||
|
clipboard: {
|
||||||
|
strokes: structuredClone(selected.strokes.map((e) => e.stroke)),
|
||||||
|
images: structuredClone(selected.images.map((e) => e.image)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
cutSelection: () =>
|
||||||
|
set((state) => {
|
||||||
|
const selected = selectedElements(state);
|
||||||
|
if (!selected) return state;
|
||||||
|
return {
|
||||||
|
clipboard: {
|
||||||
|
strokes: structuredClone(selected.strokes.map((e) => e.stroke)),
|
||||||
|
images: structuredClone(selected.images.map((e) => e.image)),
|
||||||
|
},
|
||||||
|
pages: withoutElements(
|
||||||
|
state.pages,
|
||||||
|
selected.pageId,
|
||||||
|
new Set(selected.strokes.map((e) => e.stroke.id)),
|
||||||
|
new Set(selected.images.map((e) => e.image.id)),
|
||||||
|
),
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{
|
||||||
|
kind: "remove-elements",
|
||||||
|
pageId: selected.pageId,
|
||||||
|
strokes: selected.strokes,
|
||||||
|
images: selected.images,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
selection: null,
|
||||||
|
selectionAnchor: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
pasteClipboard: () =>
|
||||||
|
set((state) => {
|
||||||
|
const clip = state.clipboard;
|
||||||
|
if (clip.strokes.length === 0 && clip.images.length === 0) return state;
|
||||||
|
const page = state.pages[state.viewPageIndex] ?? state.pages[0];
|
||||||
|
if (!page) return state;
|
||||||
|
const strokes = clip.strokes.map((s) => ({ ...structuredClone(s), id: newId() }));
|
||||||
|
const images = clip.images.map((i) => ({ ...structuredClone(i), id: newId() }));
|
||||||
|
const bounds = unionBounds(strokesBounds(strokes), imagesBounds(images));
|
||||||
|
if (!bounds) return state;
|
||||||
|
const dx = PLACEMENT_MARGIN - bounds.minX;
|
||||||
|
const dy = PLACEMENT_MARGIN - bounds.minY;
|
||||||
|
const placedStrokes = strokes.map((s) => translateStroke(s, dx, dy));
|
||||||
|
const placedImages = images.map((i) => translateImage(i, dx, dy));
|
||||||
|
let pages = state.pages.map((p) =>
|
||||||
|
p.id === page.id
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
strokes: [...p.strokes, ...placedStrokes],
|
||||||
|
images: [...p.images, ...placedImages],
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
const lastPage = state.pages[state.pages.length - 1];
|
||||||
|
if (lastPage && lastPage.id === page.id) {
|
||||||
|
pages = [...pages, createPage(lastPage.paperColor, lastPage.pattern)];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{ kind: "add-elements", pageId: page.id, strokes: placedStrokes, images: placedImages },
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
selection: {
|
||||||
|
pageId: page.id,
|
||||||
|
strokeIds: placedStrokes.map((s) => s.id),
|
||||||
|
imageIds: placedImages.map((i) => i.id),
|
||||||
|
},
|
||||||
|
tool: "select",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
insertImage: (imageId, naturalWidth, naturalHeight) =>
|
||||||
|
set((state) => {
|
||||||
|
const page = state.pages[state.viewPageIndex] ?? state.pages[0];
|
||||||
|
if (!page) return state;
|
||||||
|
const image = createImageItem(imageId, naturalWidth, naturalHeight);
|
||||||
|
let pages = state.pages.map((p) =>
|
||||||
|
p.id === page.id ? { ...p, images: [...p.images, image] } : p,
|
||||||
|
);
|
||||||
|
const lastPage = state.pages[state.pages.length - 1];
|
||||||
|
if (lastPage && lastPage.id === page.id) {
|
||||||
|
pages = [...pages, createPage(lastPage.paperColor, lastPage.pattern)];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
past: [
|
||||||
|
...state.past,
|
||||||
|
{ kind: "add-elements", pageId: page.id, strokes: [], images: [image] },
|
||||||
|
],
|
||||||
|
future: [],
|
||||||
|
selection: { pageId: page.id, strokeIds: [], imageIds: [image.id] },
|
||||||
|
tool: "select",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
insertPdfPages: (pdfPages) =>
|
||||||
|
set((state) => {
|
||||||
|
if (pdfPages.length === 0) return state;
|
||||||
|
const current = state.pages[state.viewPageIndex];
|
||||||
|
if (!current) return state;
|
||||||
|
const insertIndex = state.viewPageIndex + 1;
|
||||||
|
const inserted: Page[] = pdfPages.map((pdfPage) => ({
|
||||||
|
id: newId(),
|
||||||
|
strokes: [],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: newId(),
|
||||||
|
imageId: pdfPage.imageId,
|
||||||
|
...placeImageCentered(pdfPage.naturalWidth, pdfPage.naturalHeight),
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paperColor: current.paperColor,
|
||||||
|
pattern: current.pattern,
|
||||||
|
}));
|
||||||
|
const pages = [...state.pages];
|
||||||
|
pages.splice(insertIndex, 0, ...inserted);
|
||||||
|
return { pages, pendingScrollToPage: insertIndex };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: #e8e8e6;
|
||||||
|
color: #1a1a1a;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: #e8e8e6;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-indicator {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: max(16px, env(safe-area-inset-bottom));
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #555555;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: max(10px, env(safe-area-inset-top));
|
||||||
|
right: max(10px, env(safe-area-inset-right));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.active {
|
||||||
|
background: rgba(47, 111, 221, 0.14);
|
||||||
|
color: #2f6fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
max-width: calc(100vw - 12px);
|
||||||
|
overflow-x: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar button.text-btn {
|
||||||
|
width: auto;
|
||||||
|
padding: 0 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar .selection-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button.active {
|
||||||
|
background: rgba(47, 111, 221, 0.14);
|
||||||
|
color: #2f6fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch span {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--swatch);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch.active span {
|
||||||
|
outline: 2px solid #2f6fdd;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-option span {
|
||||||
|
display: block;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-slider {
|
||||||
|
width: 110px;
|
||||||
|
accent-color: #2f6fdd;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-value {
|
||||||
|
min-width: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888888;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(max(10px, env(safe-area-inset-top)) + 46px);
|
||||||
|
right: max(10px, env(safe-area-inset-right));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: calc(100vw - 20px);
|
||||||
|
max-height: calc(100vh - 56px - env(safe-area-inset-bottom));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
width: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--value);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker input[type="color"] {
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
transform: translate(-25%, -25%);
|
||||||
|
opacity: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hex-input {
|
||||||
|
width: 72px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333333;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar .color-field {
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar .color-picker {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-bar .hex-input {
|
||||||
|
width: 60px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button.text-option {
|
||||||
|
width: auto;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button.text-option.active {
|
||||||
|
border-color: #2f6fdd;
|
||||||
|
background: rgba(47, 111, 221, 0.1);
|
||||||
|
color: #2f6fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel button.text-option:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 48px 24px calc(48px + env(safe-area-inset-bottom));
|
||||||
|
background: #f4f4f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-actions button {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-actions button.primary {
|
||||||
|
background: #2f6fdd;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-actions button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-empty {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 80px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-grid {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-card.selected {
|
||||||
|
border-color: #2f6fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-checkbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #2f6fdd;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-open {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-actions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: #666666;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-actions button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-actions button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-presentation {
|
||||||
|
position: fixed;
|
||||||
|
top: max(10px, env(safe-area-inset-top));
|
||||||
|
right: max(10px, env(safe-area-inset-right));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
color: #555555;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.35;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-presentation:hover,
|
||||||
|
.exit-presentation:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.exit-presentation svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.board.board-shifted {
|
||||||
|
left: 216px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 184px;
|
||||||
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||||
|
background: #f4f4f2;
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #999999;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
touch-action: pan-y;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail.active {
|
||||||
|
border-color: #2f6fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail.dragging {
|
||||||
|
z-index: 5;
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail.drop-before {
|
||||||
|
box-shadow: 0 -3px 0 #2f6fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail.drop-after {
|
||||||
|
box-shadow: 0 3px 0 #2f6fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail span {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
@@ -469,13 +469,13 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
|||||||
console.log('[Step 1 - Selected Block Metadata]');
|
console.log('[Step 1 - Selected Block Metadata]');
|
||||||
console.table({
|
console.table({
|
||||||
text: blockText || block.text,
|
text: blockText || block.text,
|
||||||
fontFamily: block.textStyle?.fontName || block.fontFamily || 'Helvetica',
|
fontFamily: block.textStyle?.fontName || (block as any).fontFamily || 'Helvetica',
|
||||||
fontFace: block.textStyle?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
|
fontFace: (block.textStyle as any)?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
|
||||||
fontWeight: block.textStyle?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
|
fontWeight: (block.textStyle as any)?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
|
||||||
isBold: block.textStyle?.isBold ?? (block.textStyle?.fontWeight ? block.textStyle.fontWeight >= 600 : false),
|
isBold: block.textStyle?.isBold ?? ((block.textStyle as any)?.fontWeight ? (block.textStyle as any).fontWeight >= 600 : false),
|
||||||
fontStyle: block.textStyle?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
fontStyle: (block.textStyle as any)?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
||||||
internalFontId: block.textStyle?.fontId || block.internalFontId || '',
|
internalFontId: block.textStyle?.fontId || (block as any).internalFontId || '',
|
||||||
fontSize: block.textStyle?.fontSize || block.fontSize || 12,
|
fontSize: block.textStyle?.fontSize || (block as any).fontSize || 12,
|
||||||
});
|
});
|
||||||
if (isSelected && editingBlockId !== block.id) {
|
if (isSelected && editingBlockId !== block.id) {
|
||||||
if (fontsReady) setEditingBlockId(block.id);
|
if (fontsReady) setEditingBlockId(block.id);
|
||||||
@@ -520,7 +520,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
|||||||
console.log("[EDITOR_FINAL_FONT]", {
|
console.log("[EDITOR_FINAL_FONT]", {
|
||||||
text: blockText || block.text,
|
text: blockText || block.text,
|
||||||
fontFamily: resolvedFontFamily,
|
fontFamily: resolvedFontFamily,
|
||||||
fontFace: block.textStyle?.fontFace || (block.textStyle?.isBold ? `${resolvedFontFamily}-Bold` : resolvedFontFamily),
|
fontFace: (block.textStyle as any)?.fontFace || (block.textStyle?.isBold ? `${resolvedFontFamily}-Bold` : resolvedFontFamily),
|
||||||
fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isBold ? 'bold' : 'normal'),
|
fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isBold ? 'bold' : 'normal'),
|
||||||
fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
||||||
fontSize: `${fontSz}px`,
|
fontSize: `${fontSz}px`,
|
||||||
|
|||||||
@@ -22,5 +22,6 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Response, status
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
from app.schemas.annotation import AnnotationResponse
|
from app.schemas.annotation import AnnotationResponse
|
||||||
@@ -8,6 +9,8 @@ from app.services.page_model import build_page_model_response
|
|||||||
from app.services.search import compute_search_matches
|
from app.services.search import compute_search_matches
|
||||||
from app.services.store import document_store
|
from app.services.store import document_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["documents"])
|
router = APIRouter(tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +145,8 @@ def get_display_list(document_id: str, page_index: int):
|
|||||||
json_str = page.extract_display_list()
|
json_str = page.extract_display_list()
|
||||||
return Response(content=json_str, media_type="application/json")
|
return Response(content=json_str, media_type="application/json")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
logger.warning(f"Display list extraction failed for doc {document_id} page {page_index}: {e}")
|
||||||
|
return Response(content="[]", media_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{document_id}/pages/{page_index}/xobjects/{name}")
|
@router.get("/{document_id}/pages/{page_index}/xobjects/{name}")
|
||||||
|
|||||||
Reference in New Issue
Block a user