done the new blank functionlity

This commit is contained in:
saqib mir
2026-08-12 15:33:55 +05:30
parent 672e1594e1
commit adad76f146
83 changed files with 12553 additions and 102 deletions
+1 -1
View File
@@ -426,7 +426,7 @@ std::expected<std::string, EngineError> PdfiumPage::extractDisplayListJson() con
}
j_array.push_back(j_op);
}
return j_array.dump();
return j_array.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace);
#else
return std::unexpected(EngineError::Unknown);
#endif
+605 -3
View File
@@ -9,9 +9,16 @@
"version": "0.0.0",
"dependencies": {
"@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-dom": "^19.2.6",
"tailwindcss": "^4.3.0"
"tailwindcss": "^4.3.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -220,6 +227,15 @@
"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": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -538,6 +554,256 @@
"@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": {
"version": "1.1.4",
"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"
}
},
"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": {
"version": "1.0.1",
"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"
}
},
"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": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1162,6 +1471,13 @@
"@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": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
@@ -1481,6 +1797,16 @@
"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": {
"version": "2.10.30",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz",
@@ -1562,6 +1888,26 @@
],
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1569,6 +1915,21 @@
"dev": true,
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1584,11 +1945,21 @@
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -1625,6 +1996,16 @@
"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": {
"version": "1.5.357",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz",
@@ -1871,6 +2252,17 @@
"dev": true,
"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": {
"version": "6.5.0",
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2012,6 +2410,26 @@
"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": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2032,6 +2450,12 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2125,6 +2549,23 @@
"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": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2538,6 +2979,22 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2558,6 +3015,55 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2624,6 +3130,16 @@
"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": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -2645,6 +3161,23 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2726,6 +3259,26 @@
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -2745,6 +3298,16 @@
"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": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -2880,6 +3443,16 @@
"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": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
@@ -3025,6 +3598,35 @@
"peerDependencies": {
"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
}
}
}
}
}
+8 -1
View File
@@ -11,9 +11,16 @@
},
"dependencies": {
"@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-dom": "^19.2.6",
"tailwindcss": "^4.3.0"
"tailwindcss": "^4.3.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+195 -54
View File
@@ -12,6 +12,9 @@ import { VersionHistoryModal } from './components/VersionHistoryModal';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
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 { Annotation } from './viewer/AnnotationLayer';
import type { EditableRun, ReflowParagraphPayload } from './viewer/TextEditLayer';
@@ -21,7 +24,7 @@ import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontI
import { viewportRectToPdf } 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';
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 [currentPage, setCurrentPage] = useState(0);
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 [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
@@ -83,6 +110,13 @@ function App() {
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
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 () => {
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) =>
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
const undo = () => {
@@ -517,6 +555,8 @@ function App() {
setIsLoading(true);
const newDoc = await gatewayService.uploadDocument(file, password);
setDocuments((prev) => [newDoc, ...prev]);
setCreatePdfModalOpen(false);
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
openDocument(newDoc.id);
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 () => {
if (!activeDoc) return;
if (!can('canCopy')) { denyToast('Exporting'); return; }
@@ -616,37 +706,39 @@ function App() {
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
<TopBar
documentName={activeDoc?.filename}
documentName={(activeTool === 'create_pdf' || createPdfModalOpen) ? 'New Blank Document.pdf' : activeDoc?.filename}
backendHealthy={backendHealthy}
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}
onZoomChange={setZoom}
onFitWidth={fitWidth}
currentPage={currentPage}
totalPages={activeDoc?.totalPages || 1}
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">
<ToolRail
activeTool={activeTool}
onToolChange={setActiveTool}
onToolChange={handleToolChange}
hasSignature={!!pendingSignature}
onOpenSignature={() => setSignatureModalOpen(true)}
onOpenAbout={() => setAboutModalOpen(true)}
@@ -656,37 +748,66 @@ function App() {
/>
<div className="flex min-w-0 flex-1 flex-col">
<Toolbar
activeTool={activeTool}
settings={toolSettings}
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)}
hasSignature={!!pendingSignature}
activeStamp={activeStamp}
onSelectStamp={setActiveStamp}
redactionMode={redactionMode}
onRedactionModeChange={setRedactionMode}
pendingRedactionCount={pendingRedactions.length}
onApplyRedactions={handleApplyRedactions}
onClearRedactions={() => setPendingRedactions([])}
onRedactPages={() => setRedactPagesModalOpen(true)}
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
onUpdateAnnotation={(patch) => {
const a = annotations.find(x => x.id === selectedAnnotationId);
if (a) handleUpdateAnnotation({ ...a, ...patch });
}}
onDeleteAnnotation={() => {
const a = annotations.find(x => x.id === selectedAnnotationId);
if (a) {
handleDeleteAnnotation(a);
setSelectedAnnotationId(null);
}
}}
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
/>
{activeTool !== 'create_pdf' && !createPdfModalOpen && (
<Toolbar
activeTool={activeTool}
settings={toolSettings}
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)}
hasSignature={!!pendingSignature}
activeStamp={activeStamp}
onSelectStamp={setActiveStamp}
redactionMode={redactionMode}
onRedactionModeChange={setRedactionMode}
pendingRedactionCount={pendingRedactions.length}
onApplyRedactions={handleApplyRedactions}
onClearRedactions={() => setPendingRedactions([])}
onRedactPages={() => setRedactPagesModalOpen(true)}
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
onUpdateAnnotation={(patch) => {
const a = annotations.find(x => x.id === selectedAnnotationId);
if (a) handleUpdateAnnotation({ ...a, ...patch });
}}
onDeleteAnnotation={() => {
const a = annotations.find(x => x.id === selectedAnnotationId);
if (a) {
handleDeleteAnnotation(a);
setSelectedAnnotationId(null);
}
}}
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
/>
)}
<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="spinner" />
<p className="text-[12px] font-semibold uppercase tracking-wide text-[#98a1ad]">Loading</p>
@@ -804,13 +925,33 @@ function App() {
documents={documents}
selectedDocumentId={selectedDocId}
onSelectDocument={openDocument}
documentId={activeDoc?.id || ''}
totalPages={activeDoc?.totalPages || 0}
documentId={(activeTool === 'create_pdf' || createPdfModalOpen) ? '' : (activeDoc?.id || '')}
totalPages={(activeTool === 'create_pdf' || createPdfModalOpen) ? creatorPageCount : (activeDoc?.totalPages || 0)}
creatorPages={creatorPages}
currentPage={currentPage}
sizeBytes={activeDoc?.sizeBytes}
onNavigateToPage={(i) => viewerRef.current?.scrollToPage(i)}
onDeletePage={handleDeletePage}
onReorderPage={handleReorderPage}
onNavigateToPage={(i) => {
if (activeTool === 'create_pdf' || createPdfModalOpen) {
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}
onNavigateAnnotation={navigateToAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
+160 -14
View File
@@ -2,6 +2,8 @@ import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
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 { EmptyState } from './ui';
import {
@@ -25,6 +27,8 @@ interface InspectorPanelProps {
documentId: string;
totalPages: number;
currentPage: number;
creatorPages?: PageLayout[];
creatorPagePreviews?: string[][];
sizeBytes?: number;
onNavigateToPage: (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 [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 (
<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) => (
<div key={idx}
draggable
draggable={!!p.documentId}
onDragStart={(e) => {
setDraggedIdx(idx);
e.dataTransfer.effectAllowed = 'move';
@@ -171,23 +248,92 @@ const PagesTab: React.FC<InspectorPanelProps> = (p) => {
}}
onDrop={(e) => {
e.preventDefault();
if (draggedIdx !== null && draggedIdx !== idx) {
if (draggedIdx !== null && draggedIdx !== idx && p.documentId) {
p.onReorderPage(draggedIdx, idx);
}
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
documentId={p.documentId}
pageIndex={idx}
onClick={() => p.onNavigateToPage(idx)}
onDelete={() => p.onDeletePage(idx)}
onMoveUp={idx > 0 ? () => p.onReorderPage(idx, idx - 1) : undefined}
onMoveDown={idx < p.totalPages - 1 ? () => p.onReorderPage(idx, idx + 1) : undefined}
totalPages={p.totalPages}
/>
{p.documentId ? (
<Thumbnail
documentId={p.documentId}
pageIndex={idx}
onClick={() => p.onNavigateToPage(idx)}
onDelete={() => p.onDeletePage(idx)}
onMoveUp={idx > 0 ? () => p.onReorderPage(idx, idx - 1) : undefined}
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>
+19 -6
View File
@@ -20,6 +20,20 @@ const TOOLS: (ToolDef | 'divider')[] = [
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
{ 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: '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-disabled={disabled}
onClick={onClick}
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${
disabled
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${disabled
? 'cursor-not-allowed text-[#c5cad1]'
: active
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
: '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)' }} />}
{isLoading ? (
+25 -8
View File
@@ -59,7 +59,22 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</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,
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
}) => {
if (activeTool === 'whiteboard') return null;
const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact';
if (selectedAnnotation) {
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
return (
@@ -104,9 +121,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'ink', 'textbox'].includes(selectedAnnotation.type as string) && (
<>
<Label>Color</Label>
<ColorSwatches
value={selectedAnnotation.color || '#2563eb'}
onChange={(c) => onUpdateAnnotation?.({ color: c })}
<ColorSwatches
value={selectedAnnotation.color || '#2563eb'}
onChange={(c) => onUpdateAnnotation?.({ color: c })}
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']}
/>
<Divider />
@@ -114,8 +131,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
)}
<div className="flex items-center gap-2">
<CustomButton variant="outline" size="sm" onClick={onDeselectAnnotation}>
Done
</CustomButton>
Done
</CustomButton>
<CustomButton variant="outline" size="sm" onClick={onDeleteAnnotation}>
<span className="text-[#dc2626]">Delete</span>
</CustomButton>
@@ -200,7 +217,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<Hint>Click to place a text box.</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 === 'stream_edit' && <Hint>Beta · surgical byte-level edit (no reflow). Best for same-length fixes in simple fonts.</Hint>}
+6 -3
View File
@@ -26,12 +26,14 @@ interface TopBarProps {
onExport: () => void;
onPrint: () => void;
onUpload: (file: File) => void;
onNewBlankPDF?: () => void;
onShowVersionHistory?: () => void;
isInspectorOpen: boolean;
onToggleInspector: () => void;
canPrint?: boolean;
canExport?: boolean;
canAssemble?: boolean;
canVersionHistory?: boolean;
}
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> = ({
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory,
canPrint = true, canExport = true, canAssemble = true,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onNewBlankPDF, onShowVersionHistory,
canPrint = true, canExport = true, canAssemble = true, canVersionHistory = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -74,8 +76,9 @@ export const TopBar: React.FC<TopBarProps> = ({
)}
>
<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={<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={<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>
+1 -1
View File
@@ -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)] ${
align === 'right' ? 'right-0' : 'left-0'
}`}
onClick={(e) => e.stopPropagation()}
onClick={() => setOpen(false)}
>
{children}
</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,
},
};
+4 -1
View File
@@ -13,7 +13,9 @@ export type ToolId =
| 'underline'
| 'strikeout'
| 'squiggly'
| 'stream_edit';
| 'stream_edit'
| 'whiteboard'
| 'create_pdf';
export interface ToolSettings {
highlightColor: string;
@@ -44,6 +46,7 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
h: 'pan',
k: 'highlight',
d: 'draw',
b: 'whiteboard',
c: 'comment',
t: 'textbox',
e: 'edit_text',
+297
View File
@@ -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);
});
});
+38
View File
@@ -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));
}
+1
View File
@@ -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);
});
});
+22
View File
@@ -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);
});
});
+138
View File
@@ -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");
});
});
+69
View File
@@ -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";
}
}
+157
View File
@@ -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");
});
});
+85
View File
@@ -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"]);
});
});
+200
View File
@@ -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);
});
});
+51
View File
@@ -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,
});
});
});
+134
View File
@@ -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 };
}),
}));
+622
View File
@@ -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;
}
+8 -8
View File
@@ -469,13 +469,13 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
console.log('[Step 1 - Selected Block Metadata]');
console.table({
text: blockText || block.text,
fontFamily: block.textStyle?.fontName || block.fontFamily || 'Helvetica',
fontFace: block.textStyle?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
fontWeight: block.textStyle?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
isBold: block.textStyle?.isBold ?? (block.textStyle?.fontWeight ? block.textStyle.fontWeight >= 600 : false),
fontStyle: block.textStyle?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
internalFontId: block.textStyle?.fontId || block.internalFontId || '',
fontSize: block.textStyle?.fontSize || block.fontSize || 12,
fontFamily: block.textStyle?.fontName || (block as any).fontFamily || 'Helvetica',
fontFace: (block.textStyle as any)?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
fontWeight: (block.textStyle as any)?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
isBold: block.textStyle?.isBold ?? ((block.textStyle as any)?.fontWeight ? (block.textStyle as any).fontWeight >= 600 : false),
fontStyle: (block.textStyle as any)?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
internalFontId: block.textStyle?.fontId || (block as any).internalFontId || '',
fontSize: block.textStyle?.fontSize || (block as any).fontSize || 12,
});
if (isSelected && editingBlockId !== block.id) {
if (fontsReady) setEditingBlockId(block.id);
@@ -520,7 +520,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
console.log("[EDITOR_FINAL_FONT]", {
text: blockText || block.text,
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'),
fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isItalic ? 'italic' : 'normal'),
fontSize: `${fontSz}px`,
+2 -1
View File
@@ -22,5 +22,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
+5 -1
View File
@@ -1,3 +1,4 @@
import logging
from fastapi import APIRouter, HTTPException, Response, status
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.store import document_store
logger = logging.getLogger(__name__)
router = APIRouter(tags=["documents"])
@@ -142,7 +145,8 @@ def get_display_list(document_id: str, page_index: int):
json_str = page.extract_display_list()
return Response(content=json_str, media_type="application/json")
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}")