From 5b6e2e598c717c45d86b2bfcf88e7068707757ad Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 9 Jul 2026 00:55:10 +0200 Subject: [PATCH 1/3] pagination performance optimization --- package-lock.json | 98 +++++++------- .../extensions/node-id-dedup-extension.ts | 24 +++- .../extensions/pagination-extension.ts | 121 +++++++++++++++--- .../extensions/placeholder-extension.ts | 56 ++++++++ 4 files changed, 235 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5351a12d..e85667c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1175,7 +1175,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3042,8 +3041,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260501.1.tgz", "integrity": "sha512-B/VX2w3my/sCqxKyWOX7SxUpFC1uD8Gh7I2zbI1d3zA8p7Tx03AFsnuEx8lYLmcd8yONAA93YsAZb1wAaLK83w==", "dev": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -3074,8 +3072,7 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", @@ -3774,6 +3771,17 @@ "@floating-ui/utils": "^0.2.11" } }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", @@ -5390,7 +5398,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5783,7 +5790,6 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/client-runtime-utils": "7.7.0" }, @@ -6209,7 +6215,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.0", @@ -7492,7 +7499,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -8472,13 +8478,15 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -8488,7 +8496,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/node": { "version": "20.11.0", @@ -8586,7 +8595,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8597,7 +8605,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -8650,7 +8657,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -9323,7 +9329,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9855,7 +9860,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10099,7 +10103,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -10369,7 +10372,8 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -11279,7 +11283,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11478,7 +11481,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12742,7 +12744,6 @@ "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -13597,7 +13598,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13932,6 +13932,7 @@ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", + "peer": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -14163,6 +14164,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -14207,7 +14209,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mediaquery-text": { "version": "1.2.0", @@ -14504,7 +14507,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", @@ -14725,7 +14727,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -15003,7 +15004,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/own-keys": { "version": "1.0.1", @@ -15365,7 +15367,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -15641,7 +15642,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -15795,7 +15795,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -15862,7 +15861,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -15939,6 +15937,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-transform": "^1.0.0" } @@ -15948,6 +15947,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0" } @@ -15957,6 +15957,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -15968,6 +15969,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", @@ -15979,6 +15981,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", @@ -15991,6 +15994,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", @@ -16003,6 +16007,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" @@ -16013,6 +16018,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" @@ -16023,6 +16029,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", "license": "MIT", + "peer": true, "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", @@ -16034,6 +16041,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", "license": "MIT", + "peer": true, "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", @@ -16056,6 +16064,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.25.0" } @@ -16065,6 +16074,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -16088,6 +16098,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.4.tgz", "integrity": "sha512-CGr2BK5sLdZx+ARbeLO4HBZYa3qSG3FmwOVmzYs0Zp7n5SkrGqj+1CeNuubFNZEr64yMAQ20SanbFyIyHWZc8w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", @@ -16101,6 +16112,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", "license": "MIT", + "peer": true, "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" @@ -16116,6 +16128,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -16184,6 +16197,7 @@ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -16261,7 +16275,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16281,7 +16294,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16606,7 +16618,8 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/run-parallel": { "version": "1.2.0", @@ -16709,7 +16722,6 @@ "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -17617,7 +17629,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17763,7 +17774,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -17887,7 +17897,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17989,7 +17998,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -18058,7 +18066,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/uint8array-extras": { "version": "1.5.0", @@ -18113,7 +18122,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -18377,7 +18385,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -18997,7 +19004,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19040,7 +19046,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -19159,7 +19164,8 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/wait-on": { "version": "9.0.3", @@ -19342,7 +19348,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -19520,7 +19525,6 @@ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.85" }, @@ -19584,7 +19588,6 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz", "integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, @@ -19651,7 +19654,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/lib/screenplay/extensions/node-id-dedup-extension.ts b/src/lib/screenplay/extensions/node-id-dedup-extension.ts index 31bbaab5..1dbc74f2 100644 --- a/src/lib/screenplay/extensions/node-id-dedup-extension.ts +++ b/src/lib/screenplay/extensions/node-id-dedup-extension.ts @@ -1,5 +1,6 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { ReplaceAroundStep, ReplaceStep } from "@tiptap/pm/transform"; import { generateNodeId } from "@src/lib/screenplay/nodes"; import { ScreenplayElement } from "../../utils/enums"; @@ -20,6 +21,26 @@ type NodeIdDedupConfig = { * NOTE: production sceneLocks are intentionally NOT duplicated here — a pasted scene * should start unlocked/provisional, not inherit the source's frozen label. */ +/** + * Can this transaction have introduced a duplicate data-id? Only steps that + * insert BLOCK content can: paste/drop (copied blocks share the source's ids), + * Enter splits (ProseMirror copies attrs onto both halves), and node-wrapper + * replacements (setNodeMarkup). Plain text edits — the typing hot path — + * insert inline content only and can never duplicate an id, so the O(doc) + * dedup scan below is skipped for them. Unknown step types count as "maybe" + * to stay conservative. + */ +const mayIntroduceDuplicateIds = (tr: Transaction): boolean => { + for (const step of tr.steps) { + if (!(step instanceof ReplaceStep) && !(step instanceof ReplaceAroundStep)) return true; + const content = step.slice.content; + for (let i = 0; i < content.childCount; i++) { + if (content.child(i).isBlock) return true; + } + } + return false; +}; + export const createNodeIdDedupExtension = (config: NodeIdDedupConfig) => { return Extension.create({ name: "nodeIdDedup", @@ -34,6 +55,7 @@ export const createNodeIdDedupExtension = (config: NodeIdDedupConfig) => { if (!docChanged) return null; const hasPaste = transactions.some((tr) => tr.getMeta("uiEvent") === "paste"); + if (!hasPaste && !transactions.some(mayIntroduceDuplicateIds)) return null; const tr = newState.tr; let modified = false; diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index b877c52b..bb3d16f0 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -1,7 +1,7 @@ import { DOMSerializer } from "@node_modules/prosemirror-model/dist"; import { ScreenplayElement } from "@src/lib/utils/enums"; import { Editor, Extension } from "@tiptap/core"; -import { Node } from "@tiptap/pm/model"; +import { Node, Schema } from "@tiptap/pm/model"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { ySyncPluginKey } from "@tiptap/y-tiptap"; @@ -17,7 +17,7 @@ import { import { PAGE_COLLAPSE_META, PAGE_ONE_KEY, PersistentPageMap, SCENE_OMIT_META } from "@src/lib/screenplay/page-locking"; import { REVISION_COLORS, REVISION_STAMP_META } from "@src/lib/screenplay/revisions"; import { generateNodeId } from "@src/lib/screenplay/nodes"; -import { timeApply } from "./apply-timing"; +import { recordTiming, timeApply } from "./apply-timing"; // --------------------------------------------------------------------------- // Constants @@ -892,6 +892,40 @@ function buildDecorations( const heightCache = new Map(); +/** + * Bumped whenever previously measured geometry becomes invalid (page format / + * margin changes). Entries in `nodeHeightCache` stamped with an older + * generation are ignored. + */ +let measureGeneration = 0; + +/** + * First-level height cache keyed by node IDENTITY. ProseMirror shares the + * same immutable node objects for unchanged content across transactions, so a + * hit here means "this node is byte-identical to last pass" — skipping the + * `node.textContent` read and the full-text string key construction that + * `heightCache` needs. Without this, every keystroke re-walked the text of + * every top-level node in the document just to build cache keys (O(doc text) + * per keystroke on the typing hot path). + */ +const nodeHeightCache = new WeakMap(); + +/** + * DOMSerializer is derived from the (immutable) schema — build it once per + * schema instead of on every `apply` (fromSchema re-walks every node/mark + * spec, allocating a fresh serializer per keystroke). + */ +const serializerCache = new WeakMap(); + +function getSerializer(schema: Schema): DOMSerializer { + let s = serializerCache.get(schema); + if (!s) { + s = DOMSerializer.fromSchema(schema); + serializerCache.set(schema, s); + } + return s; +} + const getHTMLHeight = ( domNode: HTMLElement, editorDom: HTMLElement, @@ -907,11 +941,18 @@ const getHTMLHeight = ( return heightCache.get(cacheKey)!; } + const t0 = performance.now(); const testDiv = setupTestDiv(editorDom, options); - testDiv.innerHTML = domNode.outerHTML; + // Insert the (detached) element directly — serializing to an HTML string + // and re-parsing it via innerHTML did the same thing at ~3x the cost. + testDiv.replaceChildren(domNode); const rect = testDiv.getBoundingClientRect(); const height = Math.round(rect.height); + // Dev-only stat (compiled out of production): the getBoundingClientRect + // above is the typing hot path's only forced layout — it flushes any + // pending document layout, so it's the number to watch in the debug panel. + recordTiming("pagination:measure", performance.now() - t0); if (heightCache.size > 10000) heightCache.clear(); heightCache.set(cacheKey, height); @@ -948,10 +989,23 @@ const ZERO_TOP_MARGIN_TYPES = new Set([ */ const nodeTopMargin = (nodeType: ScreenplayElement): number => (ZERO_TOP_MARGIN_TYPES.has(nodeType) ? 0 : LINE_HEIGHT); +/** + * Memo for setupTestDiv: the last test div element and the editor signature + * (className + inline style attribute) it was synced against. The sync work — + * className assignment and copying every CSS variable — invalidates the test + * div's style and runs for EVERY height measurement otherwise (a straddling + * node's sentence split alone measures the node several times per keystroke). + * The signature covers everything the sync reads, so a memo hit is exact. + */ +let testDivSyncedTo: HTMLElement | null = null; +let testDivSyncKey: string | null = null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const setupTestDiv = (editorDom: HTMLElement, _: PaginationOptions): HTMLElement => { let testDiv = document.getElementById("pagination-test-div"); if (!testDiv) { + testDivSyncedTo = null; + testDivSyncKey = null; testDiv = document.createElement("div"); testDiv.id = "pagination-test-div"; testDiv.className = "ProseMirror pagination"; @@ -974,6 +1028,14 @@ const setupTestDiv = (editorDom: HTMLElement, _: PaginationOptions): HTMLElement document.body.appendChild(testDiv); } + // Skip the (invalidating) sync below when nothing it depends on changed + // since the last call — the common case for every measurement after the + // first in a pass, and for every pass while the page format is stable. + const syncKey = `${editorDom.className}|${editorDom.getAttribute("style") ?? ""}`; + if (testDiv === testDivSyncedTo && syncKey === testDivSyncKey) { + return testDiv; + } + // Sync classes and CSS variables that affect layout from editor to test div. // testDiv lives in , not inside the editor, so it doesn't inherit the editor's CSS vars. testDiv.className = editorDom.className; @@ -996,6 +1058,9 @@ const setupTestDiv = (editorDom: HTMLElement, _: PaginationOptions): HTMLElement testDiv.classList.remove("pagination"); testDiv.style.width = editorDom.style.getPropertyValue("--page-width"); + testDivSyncedTo = testDiv; + testDivSyncKey = syncKey; + return testDiv; }; @@ -1187,6 +1252,8 @@ const createPaginationPlugin = (extension: { // layout but not node heights — the cached measurements stay valid. if (formatUpdate) { heightCache.clear(); + // Invalidate the node-identity cache too — same geometry change. + measureGeneration++; } // Nothing pagination-related changed @@ -1272,7 +1339,7 @@ const createPaginationPlugin = (extension: { options.customFooter = f.customFooter; } - const serializer = DOMSerializer.fromSchema(newState.schema); + const serializer = getSerializer(newState.schema); // --- Page-lock setup --- // Hot-path discipline: when locking is off (the common case), @@ -1337,21 +1404,31 @@ const createPaginationPlugin = (extension: { const nodeType = node.type.name as ScreenplayElement; const logic = BREAK_LOGIC[nodeType]; - // Use the module-level heightCache (keyed by content) to avoid re-serializing - // unchanged nodes. Cache misses (new/edited content) trigger serialization. + // Two-level height cache. Level 1: node identity (WeakMap) — + // unchanged nodes are the same object across transactions, so a + // hit costs one map lookup and skips reading the node's text + // entirely. Level 2: the content-keyed string cache, which + // survives node-identity churn (undo/redo, remote sync, reload). + // Only a miss on both levels serializes + measures. // element is hoisted so the split block can reuse it without a second serialize. - const textContent = node.textContent || ""; - const cacheKey = `${node.type.name}:${options.pageWidth}:${options.marginLeft}:${options.marginRight}:${node.content.size}:${textContent}`; - let height = heightCache.get(cacheKey) ?? null; let element: HTMLElement | null = null; - - if (height === null) { - element = serializer.serializeNode(node) as HTMLElement; - height = getHTMLHeight(element, editorDOM, node.type.name, options, node.content.size); + let height: number; + const identityHit = nodeHeightCache.get(node); + if (identityHit !== undefined && identityHit.gen === measureGeneration) { + height = identityHit.height; + } else { + const textContent = node.textContent || ""; + const cacheKey = `${node.type.name}:${options.pageWidth}:${options.marginLeft}:${options.marginRight}:${node.content.size}:${textContent}`; + let measured = heightCache.get(cacheKey) ?? null; + + if (measured === null) { + element = serializer.serializeNode(node) as HTMLElement; + measured = getHTMLHeight(element, editorDOM, node.type.name, options, node.content.size); + } + height = measured; + nodeHeightCache.set(node, { gen: measureGeneration, height }); } - if (height == null) continue; - // Track the most recent Character name for CONT'D labels. if (nodeType === ScreenplayElement.Character) { lastCharName = node.textContent.trim(); @@ -1957,6 +2034,20 @@ const createPaginationPlugin = (extension: { const pageLocks = opts.getPageLocks?.(); if (!pageLocks) return true; + // Only a step that REMOVES content (or replaces a node wrapper — + // setNodeMarkup) can make a locked anchor's data-id disappear. + // Pure insertions — typing, Enter splits, mark changes — map every + // old range to itself (oldStart === oldEnd), so skip the two O(doc) + // before/after scans below on the common keystroke. + let removesContent = false; + for (const step of tr.steps) { + step.getMap().forEach((oldStart: number, oldEnd: number) => { + if (oldEnd > oldStart) removesContent = true; + }); + if (removesContent) break; + } + if (!removesContent) return true; + // PAGE_ONE_KEY has no node to defend — page 1 can't lose its // lock through doc edits. const lockedAnchors: string[] = []; diff --git a/src/lib/screenplay/extensions/placeholder-extension.ts b/src/lib/screenplay/extensions/placeholder-extension.ts index 882fa65b..f236b708 100644 --- a/src/lib/screenplay/extensions/placeholder-extension.ts +++ b/src/lib/screenplay/extensions/placeholder-extension.ts @@ -2,9 +2,52 @@ import type { Editor } from '@tiptap/core' import { Extension, isNodeEmpty } from '@tiptap/core' import type { Node as ProsemirrorNode } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' +import type { Transaction } from '@tiptap/pm/state' +import { ReplaceStep } from '@tiptap/pm/transform' import { Decoration, DecorationSet } from '@tiptap/pm/view' import { timeApply } from './apply-timing' +/** + * Could this transaction have changed which nodes are empty? + * + * The full recompute below walks every node in the document — an O(doc) cost + * that used to run on every keystroke. But a plain text edit that stays inside + * one non-empty textblock (typing, deleting a character mid-line) can never + * change the decoration set when the placeholder is static, so those + * transactions only need a cheap position remap. Anything structural (block + * content in a slice, edits crossing node boundaries) or any 0 ↔ non-0 + * content-size transition returns true. Unknown step types count as "maybe". + */ +function emptinessMayHaveChanged(tr: Transaction): boolean { + for (let i = 0; i < tr.steps.length; i++) { + const step = tr.steps[i] + if (!(step instanceof ReplaceStep)) return true + + const content = step.slice.content + for (let j = 0; j < content.childCount; j++) { + if (content.child(j).isBlock) return true + } + if (step.slice.openStart > 0 || step.slice.openEnd > 0) return true + + // tr.docs[i] is the doc this step applied to. + const doc = tr.docs[i] + let $from + let $to + try { + $from = doc.resolve(step.from) + $to = doc.resolve(step.to) + } catch { + return true + } + if ($from.parent !== $to.parent || !$from.parent.isTextblock) return true + + const sizeBefore = $from.parent.content.size + const sizeAfter = sizeBefore - (step.to - step.from) + step.slice.size + if ((sizeBefore === 0) !== (sizeAfter === 0)) return true + } + return false +} + export interface PlaceholderOptions { /** * **The class name for the empty editor** @@ -136,10 +179,23 @@ export const Placeholder = Extension.create({ const oldAnchor = oldState.selection.anchor const newAnchor = newState.selection.anchor + // With a static (string) placeholder and showOnlyCurrent off, the + // decoration set depends ONLY on which nodes are empty — never on + // the anchor (hasAnchor only feeds placeholder-as-function and the + // showOnlyCurrent filter). That covers the screenplay editor + // config, and lets both the per-keystroke full-doc walk and the + // anchor-move recompute collapse to cheap remaps. + const anchorIndependent = typeof options.placeholder !== 'function' && !options.showOnlyCurrent + if (tr.docChanged) { + if (anchorIndependent && !emptinessMayHaveChanged(tr)) { + return oldDecorations.map(tr.mapping, newState.doc) + } return computePlaceholderDecorations(newState.doc, newAnchor) } + if (anchorIndependent) return oldDecorations + // Selection-only change: recompute if anchor moved to different node // (showOnlyCurrent mode needs this, but also hasAnchor attribute changes) const anchorNodeChanged = oldState.doc.resolve(oldAnchor).parent !== newState.doc.resolve(newAnchor).parent From 1bdf3476dbfb84a91cf1d7af05b2d5840d10ae4c Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 9 Jul 2026 10:27:21 +0200 Subject: [PATCH 2/3] mobile ux experience improved --- components/editor/DocumentEditorPanel.tsx | 103 +++++++-- components/editor/EditorPanel.module.css | 119 +++++------ .../editor/MobileFormatToolbar.module.css | 68 ++++++ components/editor/MobileFormatToolbar.tsx | 202 ++++++++++++++++++ components/navbar/ProjectNavbar.module.css | 13 ++ components/navbar/ProjectNavbar.tsx | 4 +- .../project/ProjectWorkspace.module.css | 30 +++ components/project/ProjectWorkspace.tsx | 12 +- .../project/SplitPanelContainer.module.css | 13 ++ components/project/SplitPanelContainer.tsx | 3 +- src/context/ViewContext.tsx | 17 +- 11 files changed, 492 insertions(+), 92 deletions(-) create mode 100644 components/editor/MobileFormatToolbar.module.css create mode 100644 components/editor/MobileFormatToolbar.tsx diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index ee541878..1de68522 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -90,17 +90,10 @@ const DocumentEditorPanel = ({ repository, } = projectCtx; const { settings } = useSettings(); - const { isEndlessScroll } = useViewContext(); + const { isEndlessScroll, setChromeHidden } = useViewContext(); const { user } = useUser(); const isPhone = useIsPhone(); - // Phones always render continuous (no discrete page rectangles): the compact - // "reading margins" applied on phone (--display-margin-scale in the CSS) reflow - // text to a different width than the canonical page, so the fixed-height page - // spacers would misalign. Page COUNT and numbering stay canonical — they are - // measured off-screen at full page width — so this is purely a visual mode. - const effectiveEndless = isEndlessScroll || isPhone; - const [isEditorReady, setIsEditorReady] = useState(false); const [isScrolled, setIsScrolled] = useState(false); // Phone-only draggable scroll handle: a fixed-size grab handle (styled like @@ -113,6 +106,9 @@ const DocumentEditorPanel = ({ const [canScrollThumb, setCanScrollThumb] = useState(false); const scrollIdleTimer = useRef | null>(null); const isDraggingThumb = useRef(false); + // Last scrollTop, to derive scroll direction for hiding/showing the mobile + // editor chrome (navbar + sidebar edge handles). + const lastScrollTop = useRef(0); // Callback ref stored in state so the zoom effect re-runs when the scroll // container actually mounts (it may render after a Loading fallback). const [containerEl, setContainerEl] = useState(null); @@ -176,8 +172,8 @@ const DocumentEditorPanel = ({ useEffect(() => { const el = editor?.view?.dom; if (!el) return; - el.classList.toggle("endless-scroll", effectiveEndless); - }, [editor, effectiveEndless]); + el.classList.toggle("endless-scroll", isEndlessScroll); + }, [editor, isEndlessScroll]); // Ready state useEffect(() => { @@ -187,14 +183,41 @@ const DocumentEditorPanel = ({ } }, [editor, isYjsReady]); - // ---- Phone reading layout ---- - // Phones no longer scale the whole page down to fit (which shrank the text). - // Instead the CSS applies a compact --display-margin-scale to reflow text at - // full size into the viewport width (see the max-width:767px block in - // EditorPanel.module.css). Because that reflow uses a narrower measure than - // the canonical page, the discrete page rectangles can't line up, so phones - // render continuous (effectiveEndless above). Pagination itself is measured - // off-screen at full page width, so page count / numbering are unchanged. + // ---- Phone view modes ---- + // Endless (default on phone): the CSS reflows text into the viewport at full + // size via a compact --display-margin-scale — no page rectangles, so nothing + // shifts while writing. Paged (endless off): render the real fixed-size page — + // page breaks, headers, footers, exactly like desktop — and scale the whole + // page down with `zoom` so it fits the viewport width. A fixed page rectangle + // means its boundaries never move as you type, so there are no layout shifts. + // `zoom` is a uniform visual scale and pagination is measured off-screen, so + // page count / numbering are unaffected either way. + useEffect(() => { + const container = containerEl; + if (!container) return; + + const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS]; + // Only the phone paged view is zoomed. Endless reflows (no zoom); desktop + // shows the page at 1:1. + if (!isPhone || isEndlessScroll || !pageSize) { + container.style.removeProperty("--editor-zoom"); + return; + } + + const apply = () => { + const avail = container.clientWidth; + if (!avail) return; + // Fit the full canonical page width into the viewport; never upscale. + // Leaves a small gutter so the page edges aren't flush with the screen. + const ratio = Math.min(1, (avail - 8) / pageSize.pageWidth); + container.style.setProperty("--editor-zoom", `${ratio}`); + }; + + apply(); + const ro = new ResizeObserver(apply); + ro.observe(container); + return () => ro.disconnect(); + }, [containerEl, isPhone, isEndlessScroll, pageFormat]); // ---- Orphaned comment cleanup ---- // Comments anchor to a node's data-id. When that node is deleted the comment @@ -707,9 +730,13 @@ const DocumentEditorPanel = ({ }, [commentOps]); // Fixed handle height and inset of the track from the panel's top/bottom. - // (Keep HANDLE_HEIGHT in sync with .scroll_handle's height in the CSS.) + // (Keep HANDLE_HEIGHT in sync with .scroll_handle's height, and the insets + // in sync with .scroll_track's top/bottom, in the CSS.) The top inset clears + // the right sidebar edge toggle so the handle can't overlap it, and the + // bottom inset keeps it off the very bottom of the screen. const HANDLE_HEIGHT = 44; - const TRACK_PAD = 6; + const TRACK_INSET_TOP = 60; + const TRACK_INSET_BOTTOM = 24; // Recompute the handle's position from the container's scroll metrics: it's a // fixed-size grab handle whose offset mirrors how far down we're scrolled. @@ -723,7 +750,7 @@ const DocumentEditorPanel = ({ return; } setCanScrollThumb(true); - const travel = Math.max(0, clientHeight - 2 * TRACK_PAD - HANDLE_HEIGHT); + const travel = Math.max(0, clientHeight - TRACK_INSET_TOP - TRACK_INSET_BOTTOM - HANDLE_HEIGHT); setThumbTop((scrollTop / scrollable) * travel); }, [containerEl]); @@ -744,6 +771,20 @@ const DocumentEditorPanel = ({ if (isPhone) { updateThumb(); revealScrollThumb(); + + // Hide the floating chrome (navbar + sidebar edge handles) while + // scrolling down into the script so it doesn't cover the page; bring + // it back on scroll-up or when near the top. A small threshold keeps + // momentum jitter from flickering it. + const delta = scrollTop - lastScrollTop.current; + if (scrollTop <= 4) { + setChromeHidden(false); + } else if (delta > 6) { + setChromeHidden(true); + } else if (delta < -6) { + setChromeHidden(false); + } + lastScrollTop.current = scrollTop; } }; @@ -762,7 +803,7 @@ const DocumentEditorPanel = ({ const startY = e.clientY; const startScrollTop = el.scrollTop; const scrollable = el.scrollHeight - el.clientHeight; - const maxThumbTravel = el.clientHeight - 2 * TRACK_PAD - HANDLE_HEIGHT; + const maxThumbTravel = el.clientHeight - TRACK_INSET_TOP - TRACK_INSET_BOTTOM - HANDLE_HEIGHT; const onMove = (ev: PointerEvent) => { if (maxThumbTravel <= 0) return; @@ -791,6 +832,22 @@ const DocumentEditorPanel = ({ }; }, []); + // Reveal the chrome again the moment the user starts writing. The native + // `input` event on the contenteditable fires only for real user edits — not + // for programmatic/collaboration changes or the pagination height updates — + // so it won't fight the scroll-hide. Off phone this is a no-op. + useEffect(() => { + if (!isPhone) { + setChromeHidden(false); + return; + } + const dom = editor?.view?.dom; + if (!dom) return; + const onInput = () => setChromeHidden(false); + dom.addEventListener("input", onInput); + return () => dom.removeEventListener("input", onInput); + }, [editor, isPhone, setChromeHidden]); + const focusType = focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title"); @@ -824,7 +881,7 @@ const DocumentEditorPanel = ({ } >
) is not matched by the - * `.editor_wrapper` selector below, so it keeps --display-margin-scale: 1 and - * measures at the canonical page width. Page count, page numbers, scene - * numbering and PDF export are therefore unchanged from desktop. Phones also - * render continuous (endless-scroll) — see effectiveEndless in - * DocumentEditorPanel — because the reflowed text can't align to the fixed - * page rectangles. */ +/* Phone has two view modes, tied to the endless-scroll toggle (default endless + * on phone — see ViewContext): + * - Endless: reflow text into the viewport at full size (compact margins), no + * page rectangles, so nothing shifts while writing. + * - Paged: the real desktop page (breaks/headers/footers) scaled to fit with + * `zoom` — a fixed rectangle, so its boundaries never move while writing. + * Both are purely visual; pagination is measured off-screen at the canonical page + * width, so page count / numbering / PDF export match desktop in either mode. */ @media (max-width: 767px) { .container { -webkit-overflow-scrolling: touch; + /* Hide the native scrollbar on phone: the draggable scroll handle + * (.scroll_track) is the touch scroll affordance here, and the two + * rendered together look redundant and can visually collide at the right + * edge. Drop the reserved gutter too so the page uses the full width. */ + scrollbar-gutter: auto; + scrollbar-width: none; + } + + .container::-webkit-scrollbar { + display: none; } .editor_wrapper { @@ -204,44 +213,30 @@ padding-bottom: 40%; } - /* width:100% !important overrides the pagination extension's injected - * `.pagination { width: var(--page-width) !important }` (this two-class - * selector has higher specificity, so !important vs !important resolves in - * our favour). --display-margin-scale drives the compact reading margins. */ - .editor_wrapper :global(.ProseMirror) { + /* ENDLESS mode (default on phone): reflow the screenplay into the viewport at + * full size instead of shrinking the page. The editor fills the width and the + * wide screenplay margins compress via --display-margin-scale (consumed by + * every element padding in scriptio.css), so text wraps to a narrow measure at + * real font size. Continuous — inter-page breaks are hidden by the + * `.endless_scroll .pagination-page-break { display:none }` rule above — so + * there are no page rectangles to shift while writing. + * + * Purely visual: the offscreen pagination measurement div + * (#pagination-test-div, appended to ) isn't matched by this selector, + * so it keeps --display-margin-scale: 1 and measures at the canonical page + * width. Page count, numbering and PDF export are unchanged from desktop. + * + * width:100% !important overrides the pagination extension's injected + * `.pagination { width: var(--page-width) !important }` (this three-class + * selector wins on specificity). */ + .editor_wrapper.endless_scroll :global(.ProseMirror) { width: 100% !important; --display-margin-scale: 0.3; } - /* Render each page boundary as a footer + gap + header band that carries the - * configured headers/footers (page numbers, title, etc.) at full height, just - * like desktop but without the page-fill whitespace. Un-hide the widget (the - * base `.endless_scroll .pagination-page-break { display:none }` rule collapses - * it in endless mode) and reset content-visibility so an off-screen band - * doesn't reserve a full canonical page height. */ - .editor_wrapper.endless_scroll :global(.pagination-page-break) { - display: block !important; - height: auto !important; - content-visibility: visible !important; - contain-intrinsic-size: auto !important; - } - - /* Drop only the freespace: the spacer/overlay are `freespace + marginBottom + - * gap + marginTop`. Collapsing them to `marginBottom + gap + marginTop` keeps - * the footer of the ending page, the inter-page gap, and the header of the - * next page at full height — so headers/footers show — while removing the - * blank page fill. */ - .editor_wrapper.endless_scroll :global(.pagination-page-break > .pagination-spacer), - .editor_wrapper.endless_scroll :global(.pagination-page-break > .pagination-overlay) { - height: calc(var(--page-margin-bottom) + var(--page-gap) + var(--page-margin-top)) !important; - /* Clip anything taller than the collapsed band — e.g. the orphan-locked - * "empty page" area, whose full page-height block would otherwise spill. */ - overflow: hidden !important; - } - - /* The last-page widget's spacer is `freespace + marginBottom`; collapse the - * freespace so the script ends with just its bottom margin (symmetric with the - * first page's top margin, no text stuck to the page's bottom edge). */ + /* Endless shows only the first page's top margin and the last page's bottom + * margin (all inter-page breaks are hidden). Collapse the last page's + * freespace so the script ends on its margin, not a blank page tail. */ .editor_wrapper.endless_scroll :global(.pagination-last-page) { content-visibility: visible !important; contain-intrinsic-size: auto !important; @@ -252,23 +247,23 @@ height: var(--page-margin-bottom) !important; } - /* Header/footer text is inset by the page margins, which are set in the - * injected pagination stylesheet at their canonical (full-page) size. Scale - * that inset by --display-margin-scale so the header/footer fits the viewport - * exactly the way the screenplay content margins do. Applies to first-page, - * last-page and inter-page widgets alike. */ + /* Scale the first-page header / last-page footer inset by + * --display-margin-scale so those fit the viewport like the content does. */ .editor_wrapper.endless_scroll :global(.pagination-header-area), .editor_wrapper.endless_scroll :global(.pagination-footer-area) { padding-left: calc(var(--page-margin-left) * var(--display-margin-scale)) !important; padding-right: calc(var(--page-margin-right) * var(--display-margin-scale)) !important; } - /* (MORE) / (CONT'D) overlays are positioned against the canonical text column, - * so they'd sit at the wrong offset once the text reflows — hide them (the - * straddling dialogue reads continuously across the band instead). */ - .editor_wrapper.endless_scroll :global(.page-more-overlay), - .editor_wrapper.endless_scroll :global(.page-contd-overlay) { - display: none !important; + /* PAGED mode (endless off): render exactly like desktop — real fixed-size + * pages, page breaks, headers, footers — and scale the whole page down to fit + * the viewport with `zoom` (--editor-zoom is computed in DocumentEditorPanel). + * Because the page is a fixed rectangle, its boundaries never move while + * writing, so there are no layout shifts — the tradeoff being smaller text + * than endless mode's reflow. The page keeps its canonical width (from the + * injected `.pagination` rule) and stays centred as it scales. */ + .editor_wrapper:not(.endless_scroll) :global(.ProseMirror) { + zoom: var(--editor-zoom, 1); } .editor_shadow { diff --git a/components/editor/MobileFormatToolbar.module.css b/components/editor/MobileFormatToolbar.module.css new file mode 100644 index 00000000..a4aca354 --- /dev/null +++ b/components/editor/MobileFormatToolbar.module.css @@ -0,0 +1,68 @@ +/* Phone-only formatting bar pinned just above the on-screen keyboard. `bottom` + * is set inline from the tracked keyboard inset (see MobileFormatToolbar.tsx). */ +.toolbar { + position: fixed; + left: 0; + right: 0; + z-index: 60; + + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + + padding: 6px 12px; + padding-bottom: max(6px, env(safe-area-inset-bottom, 0px)); + + background-color: var(--secondary); + border-top: 1px solid var(--separator); + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12); + + user-select: none; + -webkit-user-select: none; + /* Belt-and-braces with the pointer-down preventDefault: never let a tap here + * steal focus / dismiss the keyboard. */ + -webkit-tap-highlight-color: transparent; +} + +.group { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--secondary-text); + cursor: pointer; + touch-action: manipulation; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.btn:active { + background-color: var(--secondary-hover); +} + +.btn.active { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +.separator { + width: 1px; + height: 22px; + background-color: var(--separator); + margin-inline: 4px; +} diff --git a/components/editor/MobileFormatToolbar.tsx b/components/editor/MobileFormatToolbar.tsx new file mode 100644 index 00000000..1b35ae7c --- /dev/null +++ b/components/editor/MobileFormatToolbar.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useCallback, useContext, useEffect, useState } from "react"; +import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, Underline } from "lucide-react"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { useIsPhone } from "@src/lib/utils/hooks"; +import { applyMarkToggle } from "@src/lib/screenplay/editor"; +import { applyTitlePageMarkToggle } from "@src/lib/titlepage/editor"; +import { Style } from "@src/lib/utils/enums"; +import { join } from "@src/lib/utils/misc"; + +import styles from "./MobileFormatToolbar.module.css"; + +// Below this the visualViewport shrink is just browser chrome jitter, not an +// open on-screen keyboard. +const KEYBOARD_THRESHOLD = 120; + +/** + * Distance in px the on-screen keyboard covers at the bottom of the layout + * viewport, tracked via the VisualViewport API. 0 when no keyboard is up. + */ +const useKeyboardInset = (enabled: boolean): number => { + const [inset, setInset] = useState(0); + + useEffect(() => { + // When disabled the consumer is hidden anyway, so leaving a stale inset is + // harmless — just don't subscribe. + if (!enabled || typeof window === "undefined" || !window.visualViewport) return; + const vv = window.visualViewport; + const update = () => { + // Layout-viewport height minus the visible visual viewport (and any + // offset from a scrolled visual viewport) is what the keyboard hides. + const covered = window.innerHeight - vv.height - vv.offsetTop; + setInset(covered > KEYBOARD_THRESHOLD ? covered : 0); + }; + update(); + vv.addEventListener("resize", update); + vv.addEventListener("scroll", update); + return () => { + vv.removeEventListener("resize", update); + vv.removeEventListener("scroll", update); + }; + }, [enabled]); + + return inset; +}; + +/** + * Phone-only formatting bar that rides just above the on-screen keyboard while a + * screenplay/title editor is focused. Surfaces the inline styling (bold, italic, + * underline) and alignment controls that are dropped from the narrow navbar + * (see the max-width:767px block in ScreenplayFormatDropdown.module.css). + */ +const MobileFormatToolbar = () => { + const isPhone = useIsPhone(); + const { + editor, + draftEditor, + titlePageEditor, + focusedEditorType, + selectedStyles, + setSelectedStyles, + isReadOnly, + } = useContext(ProjectContext); + + const keyboardInset = useKeyboardInset(isPhone); + + const isTitleContext = focusedEditorType === "title"; + const isDraftContext = focusedEditorType === "draft"; + const activeEditor = isTitleContext ? titlePageEditor : isDraftContext ? draftEditor : editor; + + const [selectedAlign, setSelectedAlign] = useState("left"); + + // Keep the alignment highlight in sync with the caret's block. + useEffect(() => { + if (!activeEditor) return; + const updateAlign = () => { + const align = activeEditor.state.selection.$anchor.parent.attrs.textAlign || "left"; + setSelectedAlign(align); + }; + activeEditor.on("selectionUpdate", updateAlign); + activeEditor.on("transaction", updateAlign); + updateAlign(); + return () => { + activeEditor.off("selectionUpdate", updateAlign); + activeEditor.off("transaction", updateAlign); + }; + }, [activeEditor]); + + const toggleStyle = useCallback( + (style: Style) => { + if (isReadOnly || !activeEditor) return; + setSelectedStyles((prev) => (prev ^ style) as Style); + if (isTitleContext) { + applyTitlePageMarkToggle(activeEditor, style); + } else { + applyMarkToggle(activeEditor, style); + } + }, + [activeEditor, isTitleContext, isReadOnly, setSelectedStyles], + ); + + const setAlignment = useCallback( + (align: string) => { + if (isReadOnly || !activeEditor) return; + setSelectedAlign(align); + if (isTitleContext) { + activeEditor.chain().focus().updateAttributes("tp-text", { textAlign: align }).run(); + } else { + const nodeType = activeEditor.state.selection.$anchor.parent.type.name; + activeEditor + .chain() + .focus() + .updateAttributes(nodeType, { textAlign: align === "left" ? null : align }) + .run(); + } + }, + [activeEditor, isTitleContext, isReadOnly], + ); + + // Only mount on phone, once a text editor is focused and the keyboard is up. + const isVisible = isPhone && keyboardInset > 0 && !!activeEditor && focusedEditorType !== null; + if (!isVisible) return null; + + // Fire on pointer-down and swallow the event so focus (and the on-screen + // keyboard) stays on the editor — a normal click would blur it first. + const action = (fn: () => void) => (e: React.PointerEvent) => { + e.preventDefault(); + fn(); + }; + + const styleBtn = (active: boolean) => join(styles.btn, active ? styles.active : ""); + + return ( +
+
+ + + +
+ +
+ +
+ + + +
+
+ ); +}; + +export default MobileFormatToolbar; diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index c9a25a41..66e6ba50 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -64,6 +64,19 @@ * already reserves this space, so the content box stays 56px tall. */ padding-block: 0; padding-top: var(--safe-top); + overflow: hidden; + transition: + height 0.25s ease, + opacity 0.25s ease; + } + + /* Collapsed while the reader scrolls down (see chromeHidden in ViewContext). + * Height animates to 0 so the editor below reclaims the space; the workspace + * is a flex column, so this reflows cleanly without a fixed overlay. */ + .container_hidden { + height: 0; + opacity: 0; + pointer-events: none; } } diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index dddde341..aed59a13 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -193,7 +193,7 @@ const ProjectNavbar = () => { const isLocalEdit = useRef(false); const isPhone = useIsPhone(); - const { setLeftSidebarOpen, setRightSidebarOpen } = useViewContext(); + const { setLeftSidebarOpen, setRightSidebarOpen, chromeHidden } = useViewContext(); // Return to the projects list by clearing the ?projectId query. Use the same // redirect() primitive that opens a project (ProjectItem → redirectScreenplay): @@ -286,7 +286,7 @@ const ProjectNavbar = () => { if (isPhone) { return ( -
- - {children.map((child) => ( - - ))} - - ); -}; - -export default OutlineItem; diff --git a/components/editor/outline/OutlinePanel.tsx b/components/editor/outline/OutlinePanel.tsx deleted file mode 100644 index fdd99f74..00000000 --- a/components/editor/outline/OutlinePanel.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { useContext } from "react"; -import { useTranslations } from "next-intl"; -import { ProjectContext } from "@src/context/ProjectContext"; -import OutlineView from "./OutlineView"; -import { ListTree } from "lucide-react"; - -import styles from "../EditorPanel.module.css"; - -const EmptyOutlineState = () => { - const t = useTranslations("outline"); - - return ( -
- -

{t("empty")}

-
- ); -}; - -/** - * The Outline view: a project-wide, ordered, nestable list of blocks that - * reference scene headings and board cards. Writers reorder/indent blocks to - * sequence their story beats. Shows an empty state until something is sent here. - */ -const OutlinePanel = ({ isVisible }: { isVisible: boolean }) => { - const { outline } = useContext(ProjectContext); - - if (Object.keys(outline).length === 0) { - return ; - } - - return ; -}; - -export default OutlinePanel; diff --git a/components/editor/outline/OutlineView.module.css b/components/editor/outline/OutlineView.module.css deleted file mode 100644 index 679f5e2e..00000000 --- a/components/editor/outline/OutlineView.module.css +++ /dev/null @@ -1,28 +0,0 @@ -.outline_panel { - position: relative; - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - background-color: var(--main-bg); -} - -.outline_scroll { - position: relative; - flex: 1; - min-height: 0; - overflow-y: auto; - overflow-x: clip; - scrollbar-gutter: stable; -} - -.outline_list { - display: flex; - flex-direction: column; - gap: 0; - width: 100%; - max-width: 580px; - margin: 0 auto; - padding: 16px; - box-sizing: border-box; -} diff --git a/components/editor/outline/OutlineView.tsx b/components/editor/outline/OutlineView.tsx deleted file mode 100644 index ea245791..00000000 --- a/components/editor/outline/OutlineView.tsx +++ /dev/null @@ -1,314 +0,0 @@ -"use client"; - -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { ProjectContext } from "@src/context/ProjectContext"; -import { SplitSide, useViewContext } from "@src/context/ViewContext"; -import { BoardCardData, MAIN_SCREENPLAY_REF, OutlineItem } from "@src/lib/project/project-state"; -import { TransientScene } from "@src/lib/screenplay/scenes"; -import { focusOnPosition } from "@src/lib/screenplay/editor"; -import OutlineBlock, { DropPosition } from "./OutlineItem"; - -import styles from "./OutlineView.module.css"; - -/** Live-resolved display data for an outline block. */ -export type ResolvedOutline = { - title: string; - preview: string; - color?: string; - /** True when the referenced source element no longer exists. */ - missing: boolean; - /** Editor position of a resolved scene (for navigation). */ - position?: number; -}; - -const PREVIEW_MAX = 80; - -const truncate = (text: string) => { - const clean = text.replace(/\s+/g, " ").trim(); - return clean.length > PREVIEW_MAX ? clean.slice(0, PREVIEW_MAX).trimEnd() + "…" : clean; -}; - -const parseCards = (raw: unknown): BoardCardData[] => { - if (!raw) return []; - try { - return typeof raw === "string" ? (JSON.parse(raw) as BoardCardData[]) : (raw as BoardCardData[]); - } catch { - return []; - } -}; - -const OutlineView = ({ isVisible }: { isVisible: boolean }) => { - const { outline, repository, scenes, editor, documentEditor, setBoardFocusCardId } = - useContext(ProjectContext); - const { isSplit, primaryPanel, setSidePanel, setFocusedSide, setSideDocument } = useViewContext(); - const projectState = repository?.getState(); - - const [draggingId, setDraggingId] = useState(null); - const [dropTarget, setDropTarget] = useState<{ id: string; pos: DropPosition } | null>(null); - - // Children of a parent (null = root), sorted by fractional `order`. - const childrenOf = useCallback( - (parentId: string | null): OutlineItem[] => - Object.values(outline) - .filter((n) => (n.parentId ?? null) === (parentId ?? null)) - .sort((a, b) => a.order - b.order), - [outline], - ); - - const roots = useMemo(() => childrenOf(null), [childrenOf]); - - const appendOrder = useCallback( - (parentId: string | null, excludeId?: string) => { - const kids = childrenOf(parentId).filter((n) => n.id !== excludeId); - return kids.length ? kids[kids.length - 1].order + 1 : 0; - }, - [childrenOf], - ); - - // ---- Live resolution of references ---- - - // Distinct source documents referenced by current blocks. - const cardBoardIds = useMemo( - () => [...new Set(Object.values(outline).filter((i) => i.source === "card").map((i) => i.refDocId))], - [outline], - ); - const editorDocIds = useMemo( - () => - [ - ...new Set( - Object.values(outline) - .filter((i) => i.source === "scene" && i.refDocId !== MAIN_SCREENPLAY_REF) - .map((i) => i.refDocId), - ), - ], - [outline], - ); - - // Bump a counter whenever a referenced board / editor-doc changes, so the - // resolver recomputes. Main-screenplay scenes come from `scenes` (reactive). - const [sourceVersion, setSourceVersion] = useState(0); - const cardKey = cardBoardIds.join(","); - const editorKey = editorDocIds.join(","); - useEffect(() => { - if (!projectState) return; - const bump = () => setSourceVersion((v) => v + 1); - const unsubs: (() => void)[] = []; - for (const id of cardBoardIds) { - const map = projectState.boardData(id); - map.observe(bump); - unsubs.push(() => map.unobserve(bump)); - } - for (const id of editorDocIds) { - const frag = projectState.documentFragment(id); - frag.observe(bump); - unsubs.push(() => frag.unobserve(bump)); - } - return () => unsubs.forEach((u) => u()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectState, cardKey, editorKey]); - - const resolved = useMemo>(() => { - const out: Record = {}; - - // Lookups for the main screenplay scenes (already live via context). - const mainScenes = new Map(scenes.filter((s) => s.id).map((s) => [s.id as string, s])); - - // Parse each referenced editor document's scenes once. - const editorScenes = new Map>(); - for (const id of editorDocIds) { - const list = repository?.getEditorDocumentScenes(id) ?? []; - editorScenes.set(id, new Map(list.filter((s) => s.id).map((s) => [s.id as string, s]))); - } - - // Parse each referenced board's cards once. - const boardCards = new Map>(); - for (const id of cardBoardIds) { - const cards = parseCards(projectState?.boardData(id).get("cards")); - boardCards.set(id, new Map(cards.map((c) => [c.id, c]))); - } - - for (const item of Object.values(outline)) { - if (item.source === "card") { - const card = boardCards.get(item.refDocId)?.get(item.refId); - out[item.id] = card - ? { title: card.title, preview: truncate(card.description), color: card.color, missing: false } - : { title: item.title, preview: item.preview, color: item.color, missing: true }; - } else { - const scene = - item.refDocId === MAIN_SCREENPLAY_REF - ? mainScenes.get(item.refId) - : editorScenes.get(item.refDocId)?.get(item.refId); - if (scene) { - const synopsis = "synopsis" in scene ? (scene.synopsis as string | undefined) : undefined; - const color = "color" in scene ? (scene.color as string | undefined) : undefined; - out[item.id] = { - title: scene.title, - preview: truncate(synopsis || scene.preview), - color, - missing: false, - position: scene.position, - }; - } else { - out[item.id] = { title: item.title, preview: item.preview, color: item.color, missing: true }; - } - } - } - return out; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [outline, scenes, sourceVersion, cardKey, editorKey, repository, projectState]); - - // Keep each block's cached snapshot fresh so the "unlinked" fallback shows - // the last-known title/preview/color. Only writes when a resolved value - // actually changed (and the source still exists), so it settles quickly. - // Gated on visibility to avoid background writes for an unseen panel. - useEffect(() => { - if (!repository || !isVisible) return; - for (const item of Object.values(outline)) { - const r = resolved[item.id]; - if (!r || r.missing) continue; - if (r.title !== item.title || r.preview !== item.preview || (r.color ?? undefined) !== (item.color ?? undefined)) { - repository.refreshOutlineSnapshot(item.id, { title: r.title, preview: r.preview, color: r.color }); - } - } - }, [repository, outline, resolved, isVisible]); - - // ---- Navigation ---- - - // Best-effort focus of a freshly opened document editor needs the latest - // instance, so track it in a ref. - const documentEditorRef = useRef(documentEditor); - documentEditorRef.current = documentEditor; - - const navigate = useCallback( - (item: OutlineItem) => { - const r = resolved[item.id]; - if (!r || r.missing) return; - - // Open in the panel next to the Outline, leaving the Outline in place. - // When unsplit there's only one panel, so the target takes it over. - const targetSide: SplitSide = isSplit ? (primaryPanel === "outline" ? "secondary" : "primary") : "primary"; - - if (item.source === "card") { - setSideDocument(targetSide, item.refDocId, "board"); - setBoardFocusCardId(item.refId); - return; - } - - // Scene - if (item.refDocId === MAIN_SCREENPLAY_REF) { - setSidePanel(targetSide, "screenplay"); - setFocusedSide(targetSide); - if (editor && r.position !== undefined) focusOnPosition(editor, r.position); - return; - } - - // Scene inside an editor document. - setSideDocument(targetSide, item.refDocId, "document"); - if (r.position !== undefined) { - const pos = r.position; - window.setTimeout(() => { - const ed = documentEditorRef.current; - if (ed) focusOnPosition(ed, pos); - }, 350); - } - }, - [resolved, editor, isSplit, primaryPanel, setSideDocument, setSidePanel, setFocusedSide, setBoardFocusCardId], - ); - - const remove = useCallback( - (id: string) => { - repository?.deleteOutlineItem(id); - }, - [repository], - ); - - // ---- Drag & drop (mirrors DocumentTreeSidebarView) ---- - - const onDragStart = useCallback((id: string) => setDraggingId(id), []); - - const onDragOverNode = useCallback((id: string, pos: DropPosition) => { - setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); - }, []); - - const resetDrag = useCallback(() => { - setDraggingId(null); - setDropTarget(null); - }, []); - - const onDropNode = useCallback( - (targetId: string) => { - const dragId = draggingId; - const target = outline[targetId]; - const pos = dropTarget?.pos; - resetDrag(); - if (!repository || !dragId || !target || !pos || dragId === targetId) return; - - if (pos === "into") { - repository.moveOutlineItem(dragId, target.id, appendOrder(target.id, dragId)); - return; - } - - const parentId = target.parentId ?? null; - const siblings = childrenOf(parentId).filter((n) => n.id !== dragId); - const idx = siblings.findIndex((n) => n.id === targetId); - let order: number; - if (pos === "before") { - const prev = siblings[idx - 1]; - order = prev ? (prev.order + target.order) / 2 : target.order - 1; - } else { - const next = siblings[idx + 1]; - order = next ? (target.order + next.order) / 2 : target.order + 1; - } - repository.moveOutlineItem(dragId, parentId, order); - }, - [draggingId, outline, dropTarget, repository, appendOrder, childrenOf, resetDrag], - ); - - const onDropRoot = useCallback(() => { - const dragId = draggingId; - resetDrag(); - if (!repository || !dragId) return; - repository.moveOutlineItem(dragId, null, appendOrder(null, dragId)); - }, [draggingId, repository, appendOrder, resetDrag]); - - return ( -
-
{ - if (!draggingId) return; - // Over empty space (not a block): clear the block indicator; - // dropping here still moves the item to the root level. - e.preventDefault(); - setDropTarget(null); - }} - onDrop={(e) => { - e.preventDefault(); - onDropRoot(); - }} - > -
- {roots.map((node) => ( - - ))} -
-
-
- ); -}; - -export default OutlineView; diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 6341d7d3..cfd95929 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -125,16 +125,15 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { unomitSceneByUuid(editor, repository, scene.id); }; - const handleSendToOutline = () => { + const handleSendToTimeline = () => { if (!repository || !scene.id) return; - repository.addOutlineItem({ + repository.appendTimelineClip({ source: "scene", refDocId: MAIN_SCREENPLAY_REF, refId: scene.id, title: scene.title, preview: scene.synopsis || scene.preview, color: scene.color, - parentId: null, }); }; @@ -154,9 +153,9 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { {scene.id && ( )} editScenePopup(scene, userCtx)} /> @@ -497,7 +496,7 @@ export type EditorContextMenuProps = { spellError?: { word: string; from: number; to: number }; nodePos?: number; nodeClass?: string; - /** Present when the caret sits on a scene heading that can be sent to the Outline. */ + /** Present when the caret sits on a scene heading that can be sent to the Timeline. */ outlineScene?: { refDocId: string; refId: string; title: string }; /** Present on paginated screenplay editors: the top-level block under the * caret (`pos`) and whether it already forces a manual page break. */ @@ -515,15 +514,14 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { const { editor, from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene, pageBreak } = props; const hasSelection = from !== to; - const handleSendToOutline = () => { + const handleSendToTimeline = () => { if (!repository || !outlineScene) return; - repository.addOutlineItem({ + repository.appendTimelineClip({ source: "scene", refDocId: outlineScene.refDocId, refId: outlineScene.refId, title: outlineScene.title, preview: "", - parentId: null, }); updateContextMenu(undefined); }; @@ -745,11 +743,11 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} - {/* Send a scene heading to the Outline */} + {/* Send a scene heading to the Timeline */} {outlineScene && !isReadOnly && ( <> - + )} diff --git a/components/editor/timeline/TimelinePanel.module.css b/components/editor/timeline/TimelinePanel.module.css new file mode 100644 index 00000000..d25aef85 --- /dev/null +++ b/components/editor/timeline/TimelinePanel.module.css @@ -0,0 +1,656 @@ +.timeline { + display: flex; + flex-direction: column; + width: 100%; + max-height: 45vh; + background-color: var(--main-bg); + border-bottom: 1px solid var(--separator); + box-sizing: border-box; + overflow: hidden; + flex-shrink: 0; + /* Chrome (ruler labels, clip titles + durations, etc.) is not selectable; + * the editable text inputs re-enable selection below. */ + user-select: none; + -webkit-user-select: none; +} + +.timeline input { + user-select: text; + -webkit-user-select: text; +} + +/* ---- Toolbar ---- */ + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + height: 44px; + flex-shrink: 0; + padding: 0 10px; + border-bottom: 1px solid var(--separator); +} + +.toolbar_title { + font-size: 13px; + font-weight: 600; + color: var(--primary-text); +} + +.toolbar_spacer { + flex: 1; +} + +.tool_btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 9px; + border: none; + border-radius: 6px; + background-color: var(--secondary); + color: var(--secondary-text); + font-size: 12px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.tool_btn:hover { + background-color: var(--secondary-hover); +} + +.tool_btn_label { + white-space: nowrap; +} + +.zoom { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.icon_btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px; + border: none; + border-radius: 6px; + background: none; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.12s ease; +} + +.icon_btn:hover:not(:disabled) { + background-color: var(--secondary-hover); +} + +.icon_btn:disabled { + opacity: 0.4; + cursor: default; +} + +/* Feature length */ +.length_anchor { + position: relative; + display: inline-flex; +} + +.length_popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--separator); + border-radius: 8px; + background-color: var(--main-bg); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16); +} + +.length_label { + font-size: 11px; + color: var(--secondary-text); +} + +.length_field { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.length_input { + width: 72px; + padding: 5px 8px; + border: 1px solid var(--separator); + border-radius: 6px; + background-color: var(--secondary); + color: var(--primary-text); + font-size: 13px; + outline: none; +} + +.length_input:focus { + border-color: var(--tertiary); +} + +.length_unit { + font-size: 12px; + color: var(--secondary-text); +} + +/* Scale slider */ +.zoom_icon { + color: var(--secondary-text); + flex-shrink: 0; +} + +.zoom_slider { + width: 96px; + cursor: pointer; + accent-color: var(--tertiary); +} + +/* ---- Custom horizontal scrollbar ---- */ + +/* A dedicated, always-present horizontal scrollbar sitting just above the + * tracks. Native macOS overlay scrollbars take no space and auto-hide, which + * left no reliable way to scroll a zoomed-in timeline — so we render our own. */ +.hscroll { + flex-shrink: 0; + height: 16px; + padding: 4px 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--separator); + background-color: var(--main-bg); +} + +.hscroll_track { + position: relative; + width: 100%; + height: 8px; + border-radius: 4px; + background-color: var(--secondary); +} + +.hscroll_thumb { + position: absolute; + top: 0; + height: 8px; + min-width: 28px; + border-radius: 4px; + background-color: var(--secondary-text); + cursor: grab; + touch-action: none; +} + +.hscroll_thumb:hover { + background-color: var(--primary-text); +} + +.hscroll_thumb:active { + cursor: grabbing; +} + +/* ---- Scroll area ---- */ + +/* Layer tracks — scrolls vertically (layers) and horizontally (time). The + * native horizontal scrollbar is hidden; the custom .hscroll above drives the + * horizontal position instead. */ +.tracks_scroll { + position: relative; + flex: 1; + min-height: 0; + overflow-x: scroll; + overflow-y: auto; +} + +.tracks_scroll::-webkit-scrollbar:horizontal { + height: 0; +} + +.grid { + position: relative; + min-width: 100%; +} + +.rows { + position: relative; + display: flex; + flex-direction: column; +} + +.row { + position: relative; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--separator); +} + +/* Drop indicator: highlight the whole hovered lane (track + its sticky label) so + * the drop target is obvious. A thin edge line additionally marks a reorder + * (before/after) vs a nest (into). Driven by the row's data-drop attribute + * (rendered from React state). Uses --secondary-text (a mid-tone in every + * theme) so the tint is clearly visible over the neutral background. */ +.row[data-drop], +.row[data-drop] .label { + background-color: color-mix(in srgb, var(--secondary-text) 45%, var(--main-bg)); +} + +.row[data-drop="before"]::before, +.row[data-drop="after"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 3px; + background-color: var(--secondary-text); + z-index: 4; + pointer-events: none; +} + +.row[data-drop="before"]::before { + top: -1px; +} + +.row[data-drop="after"]::after { + bottom: -1px; +} + +/* Left name column — pinned while the tracks scroll horizontally. Doubles as the + * drag handle: pressing and moving it reorders/nests the lane. */ +.label { + position: sticky; + left: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + padding: 0 6px; + background-color: var(--main-bg); + border-right: 1px solid var(--separator); + box-sizing: border-box; + cursor: grab; + touch-action: none; +} + +.label:active { + cursor: grabbing; +} + +/* While renaming, the label is a text field, not a drag handle. */ +.label_editing, +.label_editing:active { + cursor: default; + touch-action: auto; +} + +.label_dragging { + opacity: 0.4; +} + +/* Per-depth inset that nests child lanes under their parent. */ +.label_indent { + flex-shrink: 0; +} + +/* Fold chevron (only present when a layer has children). */ +.fold_btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 4px; + background: none; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.12s ease; +} + +.fold_btn:hover { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +/* Keeps leaf lanes' names aligned with the ones that have a chevron. */ +.fold_spacer { + flex-shrink: 0; + width: 18px; +} + +.label_input { + flex: 1; + min-width: 0; + padding: 4px 6px; + border: 1px solid transparent; + border-radius: 5px; + background: none; + color: var(--primary-text); + font-size: 12px; + font-weight: 500; + outline: none; + transition: border-color 0.12s ease, background-color 0.12s ease; +} + +.label_input:hover { + background-color: var(--secondary); +} + +.label_input:focus { + border-color: var(--tertiary); + background-color: var(--secondary); +} + +/* Read-only name display (swaps to .label_input on double-click / rename). */ +.label_name { + flex: 1; + min-width: 0; + padding: 4px 6px; + font-size: 12px; + font-weight: 500; + color: var(--primary-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Track — clip container within a row. */ +.track { + position: relative; + flex-shrink: 0; +} + +/* ---- Clip ---- */ + +.clip { + position: absolute; + top: 6px; + bottom: 6px; + display: flex; + align-items: stretch; + box-sizing: border-box; + border-radius: 6px; + border-left: 3px solid var(--card-color); + background-color: color-mix(in srgb, var(--card-color) 26%, var(--main-bg)); + cursor: grab; + overflow: hidden; + user-select: none; + touch-action: none; + transition: background-color 0.12s ease; +} + +.clip:hover { + background-color: color-mix(in srgb, var(--card-color) 38%, var(--main-bg)); +} + +.clip:active { + cursor: grabbing; +} + +.clip_missing { + opacity: 0.55; +} + +.clip_body { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 5px; + padding: 0 20px 0 10px; +} + +.clip_icon { + flex-shrink: 0; + color: var(--secondary-text); +} + +.clip_title { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--primary-text); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: none; +} + +.clip_unlinked { + flex-shrink: 0; + color: var(--secondary-text); +} + +.clip_duration { + flex-shrink: 0; + margin-left: auto; + padding-left: 6px; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--secondary-text); + pointer-events: none; +} + +/* Resize handles at each edge. */ +.handle { + position: absolute; + top: 0; + bottom: 0; + width: 8px; + z-index: 2; + cursor: ew-resize; +} + +.handle_l { + left: 0; +} + +.handle_r { + right: 0; +} + +.empty_hint { + position: absolute; + top: 14px; + z-index: 1; + font-size: 13px; + color: var(--secondary-text); + pointer-events: none; +} + +/* Playhead — vertical marker across every lane at the selected time. Sits above + * clips but below the sticky label (z-index 2), so it hides under the label when + * scrolled off the left edge. */ +.playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background-color: var(--tertiary); + z-index: 1; + pointer-events: none; +} + +/* ---- Scene overview line (read-only band above the tracks) ---- */ + +.scene_row { + display: flex; + flex-direction: row; + flex-shrink: 0; + z-index: 3; + background-color: var(--main-bg); + border-top: 1px solid var(--separator); +} + +.scene_corner { + flex-shrink: 0; + display: flex; + align-items: center; + padding-left: 10px; + background-color: var(--main-bg); + border-right: 1px solid var(--separator); +} + +.scene_unit { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--secondary-text); +} + +/* Clips the band and lets it slide horizontally with the tracks. */ +.scene_viewport { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.scene_line { + position: absolute; + top: 0; + bottom: 0; + left: 0; + will-change: transform; +} + +/* One scene block. Purely informative — no pointer affordances. */ +.scene_seg { + position: absolute; + top: 4px; + bottom: 4px; + display: flex; + align-items: center; + box-sizing: border-box; + padding: 0 6px; + border-radius: 4px; + border-left: 3px solid var(--scene-color); + background-color: color-mix(in srgb, var(--scene-color) 22%, var(--main-bg)); + overflow: hidden; +} + +.scene_seg_title { + font-size: 11px; + font-weight: 600; + color: var(--primary-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +/* Playhead marker across the scene band — extends the tracks' playhead over the + * scene overview so the cursor reads as one continuous line down the timeline. */ +.scene_playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background-color: var(--tertiary); + z-index: 2; + pointer-events: none; +} + +/* ---- Ruler (fixed row below the tracks) ---- */ + +.ruler_row { + display: flex; + flex-direction: row; + flex-shrink: 0; + z-index: 3; + background-color: var(--main-bg); + border-top: 1px solid var(--separator); +} + +.ruler_corner { + flex-shrink: 0; + display: flex; + align-items: center; + padding-left: 10px; + background-color: var(--main-bg); + border-right: 1px solid var(--separator); +} + +.ruler_unit { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--secondary-text); +} + +/* Clips the ruler track and lets it slide horizontally with the tracks. */ +.ruler_viewport { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; + cursor: pointer; +} + +/* Playhead marker on the ruler: a stem with a triangle head. */ +.ruler_playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background-color: var(--tertiary); + pointer-events: none; +} + +.ruler_playhead::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--tertiary); +} + +.ruler { + position: absolute; + top: 0; + bottom: 0; + left: 0; + will-change: transform; +} + +.tick { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px solid var(--separator); +} + +/* Faint eighth subdivisions between the labelled minute ticks. */ +.tick_eighth { + position: absolute; + bottom: 0; + height: 6px; + border-left: 1px solid color-mix(in srgb, var(--separator) 55%, transparent); +} + +.tick_label { + position: absolute; + left: 4px; + top: 5px; + font-size: 10px; + color: var(--secondary-text); + white-space: nowrap; +} diff --git a/components/editor/timeline/TimelinePanel.tsx b/components/editor/timeline/TimelinePanel.tsx new file mode 100644 index 00000000..9fe40e30 --- /dev/null +++ b/components/editor/timeline/TimelinePanel.tsx @@ -0,0 +1,1281 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; +import { useViewContext } from "@src/context/ViewContext"; +import { BoardCardData, MAIN_SCREENPLAY_REF, TimelineClip, TimelineLayer } from "@src/lib/project/project-state"; +import { TransientScene } from "@src/lib/screenplay/scenes"; +import { focusOnPosition } from "@src/lib/screenplay/editor"; +import { join } from "@src/lib/utils/misc"; +import { ContextMenuItem } from "@components/utils/ContextMenu"; +import { + ChevronDown, + ChevronRight, + Clock, + Film, + Pencil, + Plus, + Trash2, + Unlink, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; + +import styles from "./TimelinePanel.module.css"; + +/** Live-resolved display data for a clip. */ +type ResolvedClip = { + title: string; + preview: string; + color?: string; + /** True when the referenced source element no longer exists. */ + missing: boolean; + /** Editor position of a resolved scene (for navigation). */ + position?: number; +}; + +// Layout constants. +const TRACK_H = 46; // height of one layer lane, px +const LABEL_W = 150; // width of the left name column, px +const RULER_H = 26; // height of the bottom minute ruler, px +const SCENE_LINE_H = 26; // height of the read-only scene overview band, px +const TOOLBAR_H = 44; // height of the top toolbar, px +const SCROLLBAR_H = 16; // height of the custom horizontal scrollbar strip (.hscroll) +const INDENT = 14; // left inset per nesting level in the label column, px + +// Clip starts/widths snap to eighths of a minute (7.5s) for fine placement. +const EIGHTH = 1 / 8; // minutes per snap step +const MIN_DURATION = EIGHTH; // shortest clip +const SNAP = EIGHTH; // snap starts/widths to the eighth grid + +// Zoom levels expressed as minutes of runtime visible across the panel width. +// pixels-per-minute is derived from the measured panel width (below), so a given +// slider position shows the same span regardless of screen size. +const ZOOM_MINUTES = [120, 85, 60, 42, 30, 22, 16, 11, 7.5, 5]; +const DEFAULT_ZOOM_INDEX = 6; // ~16 minutes visible by default +// Assumed panel width until it's actually measured (avoids a 0-width first paint). +const FALLBACK_PANEL_WIDTH = 1000; + +const DEFAULT_FEATURE_LENGTH = 90; // minutes, mirrors the repository default + +const PREVIEW_MAX = 80; + +const truncate = (text: string) => { + const clean = text.replace(/\s+/g, " ").trim(); + return clean.length > PREVIEW_MAX ? clean.slice(0, PREVIEW_MAX).trimEnd() + "…" : clean; +}; + +const parseCards = (raw: unknown): BoardCardData[] => { + if (!raw) return []; + try { + return typeof raw === "string" ? (JSON.parse(raw) as BoardCardData[]) : (raw as BoardCardData[]); + } catch { + return []; + } +}; + +const snap = (v: number) => Math.round(v / SNAP) * SNAP; + +// Suppress text selection (e.g. layer names) for the duration of a pointer drag. +const lockSelection = () => { + document.body.style.userSelect = "none"; + document.body.style.setProperty("-webkit-user-select", "none"); +}; +const unlockSelection = () => { + document.body.style.userSelect = ""; + document.body.style.removeProperty("-webkit-user-select"); +}; + +// Magnetic snap tolerance to other clips' edges, in pixels. +const SNAP_PX = 7; + +/** Nearest candidate to `value` within `tol` minutes, or null if none is close. */ +const nearestCandidate = (value: number, candidates: number[], tol: number): number | null => { + let best: number | null = null; + let bestDist = tol; + for (const c of candidates) { + const dist = Math.abs(c - value); + if (dist <= bestDist) { + bestDist = dist; + best = c; + } + } + return best; +}; + +/** Ruler tick label in minutes, rolling up to hours past 60. */ +const formatTick = (m: number) => { + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + const r = m % 60; + return r ? `${h}h${r}` : `${h}h`; +}; + +/** Clip duration readout as `m:ss` of screen time. */ +const formatDuration = (minutes: number) => { + const secs = Math.round(minutes * 60); + const mm = Math.floor(secs / 60); + const ss = secs % 60; + return `${mm}:${ss.toString().padStart(2, "0")}`; +}; + +/** Where a dragged layer would land relative to the layer it's dropped on. */ +type LayerDropPos = "before" | "into" | "after"; + +/** In-flight drag: a live placement preview committed to Yjs on pointer up. */ +type DragState = { + clip: TimelineClip; + mode: "move" | "resize-l" | "resize-r"; + startX: number; + startY: number; + origStart: number; + origDuration: number; + moved: boolean; + /** Edges (starts + ends, minutes) of every other clip, for magnetic snapping. */ + snapCandidates: number[]; +}; + +/** Other clips sharing a layer, sorted by start — the obstacles a drag/resize + * must not overlap. */ +const sameLayerClips = (clips: Record, layerId: string, excludeId: string) => + Object.values(clips) + .filter((c) => c.layerId === layerId && c.id !== excludeId) + .sort((a, b) => a.start - b.start); + +/** Nearest start to `desired` for a clip of `dur` that fits a free gap between + * `others` (so two clips on one layer never overlap). */ +const fitStart = (others: TimelineClip[], dur: number, desired: number): number => { + const gaps: [number, number][] = []; + let cursor = 0; + for (const o of others) { + if (o.start > cursor) gaps.push([cursor, o.start]); + cursor = Math.max(cursor, o.start + o.duration); + } + gaps.push([cursor, Infinity]); // trailing gap always fits + let best = Math.max(0, desired); + let bestDist = Infinity; + for (const [lo, hi] of gaps) { + if (hi - lo < dur - 1e-9) continue; + const s = Math.max(lo, Math.min(desired, hi - dur)); + const dist = Math.abs(s - desired); + if (dist < bestDist) { + bestDist = dist; + best = s; + } + } + return best; +}; + +const TimelinePanel = () => { + const t = useTranslations("timeline"); + const { + repository, + timelineLayers, + timelineClips, + scenes, + editor, + } = useContext(ProjectContext); + const { focusedSide, setSidePanel, setFocusedSide, setTimelineOpen } = useViewContext(); + const { updateContextMenu } = useContext(UserContext); + const projectState = repository?.getState(); + + const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + // Visible width of the track area (panel minus the sticky label column). Each + // zoom level fits a fixed number of minutes into it. + const [trackViewportW, setTrackViewportW] = useState(0); + const pxPerMin = (trackViewportW > 0 ? trackViewportW : FALLBACK_PANEL_WIDTH) / ZOOM_MINUTES[zoomIndex]; + + // Playhead position in minutes (null until the user picks a spot on the ruler + // or an empty part of a track). + const [playhead, setPlayhead] = useState(null); + + // Feature length (minutes) lives in project metadata so it persists and syncs. + const [featureLength, setFeatureLength] = useState( + () => repository?.getFeatureLength() ?? DEFAULT_FEATURE_LENGTH, + ); + const [lengthEditorOpen, setLengthEditorOpen] = useState(false); + const lengthAnchorRef = useRef(null); + useEffect(() => { + if (!repository) return; + setFeatureLength(repository.getFeatureLength()); + return repository.observeMetadata((meta) => { + if (meta.featureLength !== undefined) setFeatureLength(meta.featureLength); + }); + }, [repository]); + useEffect(() => { + if (!lengthEditorOpen) return; + const onDown = (e: MouseEvent) => { + if (lengthAnchorRef.current && !lengthAnchorRef.current.contains(e.target as Node)) { + setLengthEditorOpen(false); + } + }; + window.addEventListener("mousedown", onDown); + return () => window.removeEventListener("mousedown", onDown); + }, [lengthEditorOpen]); + + // Folded layers (local, like the document tree's expand state). + const [collapsed, setCollapsed] = useState>(() => new Set()); + const toggleCollapse = useCallback((id: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // Children of a layer (null = roots), sorted by fractional `order`. + const childrenOf = useCallback( + (parentId: string | null) => + Object.values(timelineLayers) + .filter((l) => (l.parentId ?? null) === parentId) + .sort((a, b) => a.order - b.order), + [timelineLayers], + ); + + const appendOrder = useCallback( + (parentId: string | null, excludeId?: string) => { + const kids = childrenOf(parentId).filter((n) => n.id !== excludeId); + return kids.length ? kids[kids.length - 1].order + 1 : 0; + }, + [childrenOf], + ); + + /** Flattened, depth-annotated lanes in display order, skipping folded subtrees. */ + const visibleLayers = useMemo(() => { + const out: { layer: TimelineLayer; depth: number; hasChildren: boolean }[] = []; + const walk = (parentId: string | null, depth: number) => { + for (const layer of childrenOf(parentId)) { + const kids = childrenOf(layer.id); + out.push({ layer, depth, hasChildren: kids.length > 0 }); + if (kids.length && !collapsed.has(layer.id)) walk(layer.id, depth + 1); + } + }; + walk(null, 0); + return out; + }, [childrenOf, collapsed]); + + // ---- Layer drag & drop (pointer-based; see onLayerPointerDown below) ---- + // Pointer-based rather than native HTML5 drag so the hover indicator repaints + // reliably (WebKit does not repaint DOM changes made during a native drag). + // Dropping over a lane's top/bottom quarter reorders it as a sibling; over the + // middle nests it under the target. + const [draggingLayerId, setDraggingLayerId] = useState(null); + + // Inline rename: the layer whose name is being edited (via double-click or the + // right-click menu). Its swaps to an , which the effect focuses. + const [editingLayerId, setEditingLayerId] = useState(null); + const editInputRef = useRef(null); + useEffect(() => { + if (editingLayerId && editInputRef.current) { + editInputRef.current.focus(); + editInputRef.current.select(); + } + }, [editingLayerId]); + + const commitLayerMove = useCallback( + (dragId: string, targetId: string, pos: LayerDropPos) => { + const target = timelineLayers[targetId]; + if (!repository || !target || dragId === targetId) return; + + if (pos === "into") { + repository.moveTimelineLayer(dragId, target.id, appendOrder(target.id, dragId)); + setCollapsed((c) => { + if (!c.has(target.id)) return c; + const next = new Set(c); + next.delete(target.id); // reveal the newly nested lane + return next; + }); + return; + } + + const parentId = target.parentId ?? null; + const siblings = childrenOf(parentId).filter((n) => n.id !== dragId); + const idx = siblings.findIndex((n) => n.id === targetId); + let order: number; + if (pos === "before") { + const prev = siblings[idx - 1]; + order = prev ? (prev.order + target.order) / 2 : target.order - 1; + } else { + const next = siblings[idx + 1]; + order = next ? (target.order + next.order) / 2 : target.order + 1; + } + repository.moveTimelineLayer(dragId, parentId, order); + }, + [timelineLayers, childrenOf, appendOrder, repository], + ); + + // Seed the two default lanes the first time the timeline is opened empty. + const seededRef = useRef(false); + useEffect(() => { + if (seededRef.current || !repository) return; + if (Object.keys(timelineLayers).length === 0) { + seededRef.current = true; + repository.ensureTimelineLayers(2, (i) => `${t("layer")} ${i + 1}`); + } + }, [repository, timelineLayers, t]); + + // ---- Live resolution of clip references (mirrors the old Outline resolver) ---- + + const cardBoardIds = useMemo( + () => [...new Set(Object.values(timelineClips).filter((c) => c.source === "card").map((c) => c.refDocId))], + [timelineClips], + ); + const editorDocIds = useMemo( + () => [ + ...new Set( + Object.values(timelineClips) + .filter((c) => c.source === "scene" && c.refDocId !== MAIN_SCREENPLAY_REF) + .map((c) => c.refDocId), + ), + ], + [timelineClips], + ); + + const [sourceVersion, setSourceVersion] = useState(0); + const cardKey = cardBoardIds.join(","); + const editorKey = editorDocIds.join(","); + useEffect(() => { + if (!projectState) return; + const bump = () => setSourceVersion((v) => v + 1); + const unsubs: (() => void)[] = []; + for (const id of cardBoardIds) { + const map = projectState.boardData(id); + map.observe(bump); + unsubs.push(() => map.unobserve(bump)); + } + for (const id of editorDocIds) { + const frag = projectState.documentFragment(id); + frag.observe(bump); + unsubs.push(() => frag.unobserve(bump)); + } + return () => unsubs.forEach((u) => u()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectState, cardKey, editorKey]); + + const resolved = useMemo>(() => { + const out: Record = {}; + const mainScenes = new Map(scenes.filter((s) => s.id).map((s) => [s.id as string, s])); + + const editorScenes = new Map>(); + for (const id of editorDocIds) { + const list = repository?.getEditorDocumentScenes(id) ?? []; + editorScenes.set(id, new Map(list.filter((s) => s.id).map((s) => [s.id as string, s]))); + } + + const boardCards = new Map>(); + for (const id of cardBoardIds) { + const cards = parseCards(projectState?.boardData(id).get("cards")); + boardCards.set(id, new Map(cards.map((c) => [c.id, c]))); + } + + for (const clip of Object.values(timelineClips)) { + if (clip.source === "card") { + const card = boardCards.get(clip.refDocId)?.get(clip.refId); + out[clip.id] = card + ? { title: card.title, preview: truncate(card.description), color: card.color, missing: false } + : { title: clip.title, preview: clip.preview, color: clip.color, missing: true }; + } else { + const scene = + clip.refDocId === MAIN_SCREENPLAY_REF + ? mainScenes.get(clip.refId) + : editorScenes.get(clip.refDocId)?.get(clip.refId); + if (scene) { + const synopsis = "synopsis" in scene ? (scene.synopsis as string | undefined) : undefined; + const color = "color" in scene ? (scene.color as string | undefined) : undefined; + out[clip.id] = { + title: scene.title, + preview: truncate(synopsis || scene.preview), + color, + missing: false, + position: scene.position, + }; + } else { + out[clip.id] = { title: clip.title, preview: clip.preview, color: clip.color, missing: true }; + } + } + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timelineClips, scenes, sourceVersion, cardKey, editorKey, repository, projectState]); + + // Keep each clip's cached snapshot fresh so the "unlinked" fallback shows the + // last-known title/preview/color. + useEffect(() => { + if (!repository) return; + for (const clip of Object.values(timelineClips)) { + const r = resolved[clip.id]; + if (!r || r.missing) continue; + if ( + r.title !== clip.title || + r.preview !== clip.preview || + (r.color ?? undefined) !== (clip.color ?? undefined) + ) { + repository.refreshTimelineClipSnapshot(clip.id, { title: r.title, preview: r.preview, color: r.color }); + } + } + }, [repository, timelineClips, resolved]); + + // ---- Navigation ---- + + // Latest screenplay editor, read lazily so a deferred focus (after the + // screenplay panel mounts) never uses a stale/null instance. + const editorRef = useRef(editor); + editorRef.current = editor; + + // Clicking the timeline scrolls the screenplay editor to the page matching + // the clicked time — one minute of screen time ≈ one script page. It brings + // the screenplay into view first (but never opens boards). + const navigate = useCallback( + (minutes: number) => { + setSidePanel(focusedSide, "screenplay"); + setFocusedSide(focusedSide); + + const page = Math.max(1, Math.round(minutes)); // 1 min ≈ 1 page + + // The screenplay editor may still be mounting after setSidePanel; + // retry briefly until it's available. + const focus = (attempt: number) => { + const ed = editorRef.current; + if (!ed) { + if (attempt < 12) window.setTimeout(() => focus(attempt + 1), 40); + return; + } + // Each page's first node carries a pagination start class, one per + // page in document order — so the Nth marks the top of page N. + const starts = ed.view.dom.querySelectorAll( + ".pagination-doc-start, .pagination-break-start", + ); + if (starts.length > 0) { + const el = starts[Math.min(starts.length - 1, page - 1)]; + const pos = ed.view.posAtDOM(el, 0); + if (pos >= 0) { + focusOnPosition(ed, pos); + return; + } + } + // Fallback (pagination markers absent): proportional content scroll. + const size = ed.state.doc.content.size; + const fraction = featureLength > 0 ? Math.min(1, Math.max(0, minutes / featureLength)) : 0; + focusOnPosition(ed, Math.max(1, Math.min(size - 1, Math.round(fraction * size)))); + }; + focus(0); + }, + [focusedSide, setSidePanel, setFocusedSide, featureLength], + ); + + // ---- Timeline extent + ruler ---- + + const maxEnd = useMemo(() => { + let end = 0; + for (const c of Object.values(timelineClips)) end = Math.max(end, c.start + c.duration); + return end; + }, [timelineClips]); + + // Ticks spaced so labels never crowd (aim for >= 48px per major tick). + const tickStep = useMemo(() => { + const candidates = [1, 2, 5, 10, 15, 30, 60, 120]; + return candidates.find((s) => s * pxPerMin >= 48) ?? 120; + }, [pxPerMin]); + + // Span the whole feature, but always keep every clip in view. + const totalMinutes = useMemo(() => { + const min = Math.max(30, featureLength, Math.ceil(maxEnd) + 2); + return Math.ceil(min / tickStep) * tickStep; + }, [maxEnd, featureLength, tickStep]); + + const trackWidth = totalMinutes * pxPerMin; + + const ticks = useMemo(() => { + const out: number[] = []; + for (let m = 0; m <= totalMinutes; m += tickStep) out.push(m); + return out; + }, [totalMinutes, tickStep]); + + // Faint eighth-of-a-page gridlines, shown once an eighth is wide enough. + const eighthTicks = useMemo(() => { + if (EIGHTH * pxPerMin < 7) return []; + const out: number[] = []; + for (let m = 0; m <= totalMinutes + 1e-6; m += EIGHTH) out.push(m); + return out; + }, [totalMinutes, pxPerMin]); + + // ---- Scene overview line ---- + // A read-only band above the tracks showing every scene currently in the + // screenplay, laid out proportionally to its length across the feature + // runtime (1 page ≈ 1 minute). `scenes` comes from the ProjectContext, which + // recomputes them on the debounced screenplay callback — so this reflects the + // settled scene list, not every keystroke. Purely informative: no drag/edit. + const sceneSegments = useMemo(() => { + const items = scenes.filter((s) => s.nextPosition > s.position); + if (items.length === 0) return []; + const origin = items[0].position; + const total = items[items.length - 1].nextPosition - origin; + if (total <= 0) return []; + const spanWidth = featureLength * pxPerMin; + return items.map((s, i) => { + const left = ((s.position - origin) / total) * spanWidth; + const right = ((s.nextPosition - origin) / total) * spanWidth; + return { + key: s.id ?? `scene-${i}`, + title: s.title || t("untitled"), + color: s.color, + left, + // Leave a 2px seam between neighbours so adjacent scenes read as + // distinct blocks rather than one continuous bar. + width: Math.max(2, right - left - 2), + }; + }); + }, [scenes, featureLength, pxPerMin, t]); + + // ---- Ruler sync ---- + // The ruler lives outside the vertically-scrolling tracks area so it stays + // fixed while layers scroll. It follows the tracks' horizontal scroll via a + // transform so the two stay aligned. + const tracksScrollRef = useRef(null); + const rulerRef = useRef(null); + const sceneLineRef = useRef(null); + + // Custom horizontal scrollbar geometry (fraction of the content in view and + // the current scroll offset, both 0..1). Native macOS overlay scrollbars + // hide themselves and take no space, so we render our own bar instead. + const hTrackRef = useRef(null); + const [hbar, setHbar] = useState({ left: 0, ratio: 1 }); + const updateHbar = useCallback(() => { + const el = tracksScrollRef.current; + if (!el) return; + const { scrollLeft, clientWidth, scrollWidth } = el; + setHbar({ + left: scrollWidth > 0 ? scrollLeft / scrollWidth : 0, + ratio: scrollWidth > 0 ? Math.min(1, clientWidth / scrollWidth) : 1, + }); + }, []); + + const syncRuler = useCallback(() => { + if (tracksScrollRef.current) { + const offset = `translateX(${-tracksScrollRef.current.scrollLeft}px)`; + if (rulerRef.current) rulerRef.current.style.transform = offset; + if (sceneLineRef.current) sceneLineRef.current.style.transform = offset; + } + updateHbar(); + }, [updateHbar]); + // Re-align after zoom changes the track width. + useEffect(syncRuler, [syncRuler, trackWidth]); + // Track the panel's visible track width (drives pixels-per-minute) and keep + // the scrollbar sized as it resizes. + useEffect(() => { + const el = tracksScrollRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => { + setTrackViewportW(el.clientWidth - LABEL_W); + updateHbar(); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [updateHbar]); + + // Drag the custom scrollbar thumb to scroll the tracks horizontally. + const onHThumbPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + const el = tracksScrollRef.current; + const track = hTrackRef.current; + if (!el || !track) return; + const startX = e.clientX; + const startLeft = el.scrollLeft; + const range = el.scrollWidth - el.clientWidth; // scrollable px + const thumbTravel = track.clientWidth * (1 - Math.min(1, el.clientWidth / el.scrollWidth)); + const onMove = (ev: PointerEvent) => { + if (thumbTravel <= 0) return; + el.scrollLeft = startLeft + ((ev.clientX - startX) * range) / thumbTravel; + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, []); + + // Move the playhead to the time under a click on the ruler or an empty track, + // and scroll the screenplay to the approximate matching spot. + const setPlayheadFromClientX = useCallback( + (clientX: number) => { + const el = tracksScrollRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const x = clientX - rect.left - LABEL_W + el.scrollLeft; + const minutes = Math.max(0, Math.min(totalMinutes, x / pxPerMin)); + setPlayhead(minutes); + navigate(minutes); + }, + [totalMinutes, pxPerMin, navigate], + ); + + // ---- Drag & resize ---- + + const tracksRef = useRef(null); + const dragRef = useRef(null); + // Live placement of the clip being dragged; committed to Yjs on pointer up. + // Mirrored in a ref so the pointer-up commit never reads a stale (one move + // behind) value from React state. + // A drag previews one clip (move / plain resize) or two (a roll edit on a + // shared border, where both adjacent clips update together). + type Placement = Pick; + const [preview, setPreviewState] = useState(null); + const previewRef = useRef(null); + const setPreview = useCallback((p: Placement[] | null) => { + previewRef.current = p; + setPreviewState(p); + }, []); + + const layerAtY = useCallback( + (clientY: number): string => { + const el = tracksRef.current; + if (!el || visibleLayers.length === 0) return visibleLayers[0]?.layer.id ?? ""; + const rect = el.getBoundingClientRect(); + const i = Math.floor((clientY - rect.top) / TRACK_H); + const clamped = Math.max(0, Math.min(visibleLayers.length - 1, i)); + return visibleLayers[clamped].layer.id; + }, + [visibleLayers], + ); + + // Window listeners for the active clip drag. Stored so an unmount mid-drag + // can detach them. + const clipDragListenersRef = useRef<{ + move: (e: PointerEvent) => void; + up: (e: PointerEvent) => void; + cancel: (e: PointerEvent) => void; + } | null>(null); + + const onClipPointerDown = useCallback( + (e: React.PointerEvent, clip: TimelineClip, mode: DragState["mode"]) => { + if (e.button !== 0) return; + e.stopPropagation(); + // Snap targets: the origin plus every other clip's start and end, so + // edges line up with clips on any layer. + const candidates = new Set([0]); + for (const c of Object.values(timelineClips)) { + if (c.id === clip.id) continue; + candidates.add(c.start); + candidates.add(c.start + c.duration); + } + lockSelection(); + dragRef.current = { + clip, + mode, + startX: e.clientX, + startY: e.clientY, + origStart: clip.start, + origDuration: clip.duration, + moved: false, + snapCandidates: [...candidates], + }; + setPreview([{ id: clip.id, layerId: clip.layerId, start: clip.start, duration: clip.duration }]); + + // Drive the drag from window listeners rather than the clip element + + // pointer capture: when the clip re-parents to another layer's track + // mid-drag, an element-scoped capture is lost and pointerup never + // fires, leaving the clip "stuck" to the cursor. Window listeners are + // immune to that re-parenting. + const onMove = (ev: PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const dxMin = (ev.clientX - d.startX) / pxPerMin; + // Slightly forgiving threshold so a trackpad click (which can + // jitter a few px) still counts as a tap, not a drag. + if (!d.moved && (Math.abs(ev.clientX - d.startX) > 6 || Math.abs(ev.clientY - d.startY) > 6)) { + d.moved = true; + } + const tol = SNAP_PX / pxPerMin; + const cands = d.snapCandidates; + + if (d.mode === "move") { + const rawStart = Math.max(0, d.origStart + dxMin); + const rawEnd = rawStart + d.origDuration; + const snapStart = nearestCandidate(rawStart, cands, tol); + const snapEnd = nearestCandidate(rawEnd, cands, tol); + let start: number; + if (snapStart !== null && (snapEnd === null || Math.abs(snapStart - rawStart) <= Math.abs(snapEnd - rawEnd))) { + start = snapStart; + } else if (snapEnd !== null) { + start = snapEnd - d.origDuration; + } else { + start = snap(rawStart); + } + start = Math.max(0, start); + // Keep the clip inside a free gap on the target layer so two + // clips of the same layer never overlap. + const targetLayer = layerAtY(ev.clientY); + start = fitStart(sameLayerClips(timelineClips, targetLayer, d.clip.id), d.origDuration, start); + setPreview([{ id: d.clip.id, layerId: targetLayer, start, duration: d.origDuration }]); + } else if (d.mode === "resize-r") { + const others = sameLayerClips(timelineClips, d.clip.layerId, d.clip.id); + const origEnd = d.origStart + d.origDuration; + const rawEnd = d.origStart + Math.max(MIN_DURATION, d.origDuration + dxMin); + const snapEnd = nearestCandidate(rawEnd, cands, tol); + let border = snapEnd !== null ? snapEnd : d.origStart + snap(rawEnd - d.origStart); + // If a clip is flush against this border, roll it: move both edges. + const neighbor = others.find((o) => Math.abs(o.start - origEnd) < 1e-6); + if (neighbor) { + const nEnd = neighbor.start + neighbor.duration; + border = Math.max(d.origStart + MIN_DURATION, Math.min(nEnd - MIN_DURATION, border)); + setPreview([ + { id: d.clip.id, layerId: d.clip.layerId, start: d.origStart, duration: border - d.origStart }, + { id: neighbor.id, layerId: neighbor.layerId, start: border, duration: nEnd - border }, + ]); + } else { + const rightLimit = others.reduce((m, o) => (o.start >= d.origStart ? Math.min(m, o.start) : m), Infinity); + border = Math.min(border, rightLimit); + const duration = Math.max(MIN_DURATION, border - d.origStart); + setPreview([{ id: d.clip.id, layerId: d.clip.layerId, start: d.origStart, duration }]); + } + } else { + const others = sameLayerClips(timelineClips, d.clip.layerId, d.clip.id); + const origEnd = d.origStart + d.origDuration; + const maxStart = origEnd - MIN_DURATION; + const rawStart = Math.min(maxStart, Math.max(0, d.origStart + dxMin)); + const snapStart = nearestCandidate(rawStart, cands, tol); + let border = Math.min(maxStart, Math.max(0, snapStart !== null ? snapStart : snap(rawStart))); + // If a clip is flush against this border, roll it: move both edges. + const neighbor = others.find((o) => Math.abs(o.start + o.duration - d.origStart) < 1e-6); + if (neighbor) { + border = Math.max(neighbor.start + MIN_DURATION, Math.min(maxStart, border)); + setPreview([ + { id: neighbor.id, layerId: neighbor.layerId, start: neighbor.start, duration: border - neighbor.start }, + { id: d.clip.id, layerId: d.clip.layerId, start: border, duration: origEnd - border }, + ]); + } else { + const leftLimit = others.reduce((m, o) => { + const oe = o.start + o.duration; + return oe <= d.origStart ? Math.max(m, oe) : m; + }, 0); + border = Math.max(border, leftLimit); + setPreview([{ id: d.clip.id, layerId: d.clip.layerId, start: border, duration: origEnd - border }]); + } + } + }; + const detach = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + clipDragListenersRef.current = null; + unlockSelection(); + }; + const onUp = () => { + detach(); + const d = dragRef.current; + dragRef.current = null; + const p = previewRef.current; + setPreview(null); + if (!d) return; + // A tap with no movement drops the playhead on the clip and + // scrolls the screenplay to the matching spot. + if (!d.moved) { + setPlayhead(d.clip.start); + navigate(d.clip.start); + return; + } + if (!p || !repository) return; + for (const placement of p) { + const orig = timelineClips[placement.id]; + if (!orig) continue; + if ( + placement.layerId !== orig.layerId || + placement.start !== orig.start || + placement.duration !== orig.duration + ) { + repository.updateTimelineClip(placement.id, { + layerId: placement.layerId, + start: placement.start, + duration: placement.duration, + }); + } + } + }; + // A cancelled pointer stream (e.g. a trackpad gesture) aborts the drag + // without committing, so the clip never gets stuck to the cursor. + const onCancel = () => { + detach(); + dragRef.current = null; + setPreview(null); + }; + clipDragListenersRef.current = { move: onMove, up: onUp, cancel: onCancel }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + }, + [pxPerMin, layerAtY, setPreview, repository, navigate, timelineClips], + ); + + // Detach an in-progress clip drag if the panel unmounts. + useEffect(() => { + return () => { + const l = clipDragListenersRef.current; + if (l) { + window.removeEventListener("pointermove", l.move); + window.removeEventListener("pointerup", l.up); + window.removeEventListener("pointercancel", l.cancel); + unlockSelection(); + } + }; + }, []); + + // ---- Layer drag (pointer-based) ---- + + const layerDragIdRef = useRef(null); + const layerDropRef = useRef<{ id: string; pos: LayerDropPos } | null>(null); + const layerDragListenersRef = useRef<{ + move: (e: PointerEvent) => void; + up: (e: PointerEvent) => void; + cancel: (e: PointerEvent) => void; + } | null>(null); + + // The hovered drop target (which lane, and before/into/after) drives the + // highlight declaratively — each row renders its own data-drop from this + // state. A pointer-based drag (not native HTML5 DnD) repaints on state + // change just fine. + const [dropTarget, setDropTarget] = useState<{ id: string; pos: LayerDropPos } | null>(null); + + // Whole label acts as the drag handle: press and move past a small threshold + // to reorder/nest a lane. Below the threshold a press is a plain click (or + // double-click to rename), so no visible grip is needed. Skipped while the + // lane's name is being edited so the caret can be placed in the input. + const onLayerPointerDown = useCallback( + (e: React.PointerEvent, layer: TimelineLayer) => { + if (e.button !== 0 || editingLayerId === layer.id) return; + const startX = e.clientX; + const startY = e.clientY; + let activated = false; + layerDragIdRef.current = layer.id; + layerDropRef.current = null; + + // Which lane (and where within it) is under the pointer. + const targetAtY = (clientY: number): { id: string; pos: LayerDropPos } | null => { + const el = tracksRef.current; + if (!el || visibleLayers.length === 0) return null; + const rect = el.getBoundingClientRect(); + let i = Math.floor((clientY - rect.top) / TRACK_H); + i = Math.max(0, Math.min(visibleLayers.length - 1, i)); + const target = visibleLayers[i].layer; + if (target.id === layerDragIdRef.current) return null; + const within = clientY - rect.top - i * TRACK_H; + const pos: LayerDropPos = + within < TRACK_H * 0.25 ? "before" : within > TRACK_H * 0.75 ? "after" : "into"; + return { id: target.id, pos }; + }; + + const activate = () => { + activated = true; + lockSelection(); + setDraggingLayerId(layer.id); + setDropTarget(null); + }; + const detach = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + layerDragListenersRef.current = null; + if (activated) { + setDropTarget(null); + unlockSelection(); + } + }; + const onMove = (ev: PointerEvent) => { + if (!activated) { + // Wait for a deliberate drag before hijacking the press. + if (Math.abs(ev.clientX - startX) <= 6 && Math.abs(ev.clientY - startY) <= 6) return; + activate(); + } + const target = targetAtY(ev.clientY); + layerDropRef.current = target; + setDropTarget(target); + }; + const onUp = () => { + detach(); + const dragId = layerDragIdRef.current; + const drop = layerDropRef.current; + layerDragIdRef.current = null; + layerDropRef.current = null; + if (!activated) return; // a plain click: nothing to commit + setDraggingLayerId(null); + if (dragId && drop) commitLayerMove(dragId, drop.id, drop.pos); + }; + const onCancel = () => { + detach(); + layerDragIdRef.current = null; + layerDropRef.current = null; + if (activated) setDraggingLayerId(null); + }; + layerDragListenersRef.current = { move: onMove, up: onUp, cancel: onCancel }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + }, + [visibleLayers, commitLayerMove, editingLayerId], + ); + + useEffect(() => { + return () => { + const l = layerDragListenersRef.current; + if (l) { + window.removeEventListener("pointermove", l.move); + window.removeEventListener("pointerup", l.up); + window.removeEventListener("pointercancel", l.cancel); + unlockSelection(); + } + }; + }, []); + + // ---- Layer + clip mutations ---- + + const addLayer = useCallback(() => { + repository?.addTimelineLayer(`${t("layer")} ${Object.keys(timelineLayers).length + 1}`); + }, [repository, timelineLayers, t]); + + const renameLayer = useCallback( + (id: string, name: string) => repository?.renameTimelineLayer(id, name), + [repository], + ); + + const deleteLayer = useCallback((id: string) => repository?.deleteTimelineLayer(id), [repository]); + + const removeClip = useCallback((id: string) => repository?.deleteTimelineClip(id), [repository]); + + // ---- Right-click menus (shared context-menu host) ---- + + const openLayerMenu = useCallback( + (e: React.MouseEvent, layer: TimelineLayer) => { + e.preventDefault(); + e.stopPropagation(); + updateContextMenu({ + position: { + x: Math.min(e.clientX, window.innerWidth - 230), + y: Math.min(e.clientY, window.innerHeight - 160), + }, + content: ( + <> + setEditingLayerId(layer.id)} + /> + deleteLayer(layer.id)} /> + + ), + }); + }, + [updateContextMenu, t, deleteLayer], + ); + + const openClipMenu = useCallback( + (e: React.MouseEvent, clip: TimelineClip) => { + e.preventDefault(); + e.stopPropagation(); + updateContextMenu({ + position: { + x: Math.min(e.clientX, window.innerWidth - 230), + y: Math.min(e.clientY, window.innerHeight - 120), + }, + content: ( + removeClip(clip.id)} /> + ), + }); + }, + [updateContextMenu, t, removeClip], + ); + + const isEmpty = Object.keys(timelineClips).length === 0; + + return ( +
+ {/* Toolbar */} +
+ {t("title")} +
+ + {/* Feature length */} +
+ + {lengthEditorOpen && ( +
+ +
+ { + const v = Math.max(1, Math.min(600, Math.round(Number(e.target.value) || 0))); + setFeatureLength(v); + repository?.setFeatureLength(v); + }} + /> + {t("unitMinutesShort")} +
+
+ )} +
+ + {/* Scale slider */} +
+ + setZoomIndex(Number(e.target.value))} + aria-label={t("scale")} + /> + +
+ + + +
+ + {/* Custom, always-present horizontal scrollbar (native overlay bars + auto-hide and take no space, so the timeline could not be scrolled + once zoomed past the container width). */} +
+
+
+
+
+ + {/* Layer tracks: scroll vertically (layers) and horizontally (time). */} +
+
+
+ {visibleLayers.map(({ layer, depth, hasChildren }) => { + return ( +
+
onLayerPointerDown(e, layer)} + onContextMenu={(e) => openLayerMenu(e, layer)} + > +
+ {hasChildren ? ( + + ) : ( + + )} + {editingLayerId === layer.id ? ( + renameLayer(layer.id, e.target.value)} + onBlur={() => setEditingLayerId(null)} + onKeyDown={(e) => { + // Enter/Escape commit by dropping focus (→ onBlur). + if (e.key === "Enter" || e.key === "Escape") e.currentTarget.blur(); + }} + /> + ) : ( + setEditingLayerId(layer.id)} + > + {layer.name || t("layerPlaceholder")} + + )} +
+
setPlayheadFromClientX(e.clientX)} + > + {Object.values(timelineClips) + .filter((c) => { + const p = preview?.find((x) => x.id === c.id) ?? c; + return p.layerId === layer.id; + }) + .map((clip) => { + const p = preview?.find((x) => x.id === clip.id) ?? clip; + const r = resolved[clip.id] ?? { + title: clip.title, + preview: clip.preview, + color: clip.color, + missing: true, + }; + const isCard = clip.source === "card"; + return ( +
onClipPointerDown(e, clip, "move")} + onContextMenu={(e) => openClipMenu(e, clip)} + > +
onClipPointerDown(e, clip, "resize-l")} + /> +
+ {!isCard && } + + {r.title || t("untitled")} + + {r.missing && ( + + )} + {p.duration * pxPerMin >= 52 && ( + + {formatDuration(p.duration)} + + )} +
+
onClipPointerDown(e, clip, "resize-r")} + /> +
+ ); + })} +
+
+ ); + })} + {isEmpty && visibleLayers.length > 0 && ( +
+ {t("empty")} +
+ )} + {playhead !== null && ( +
+ )} +
+
+
+ + {/* Scene overview line — a read-only band listing every scene in the + screenplay, proportional to its length. Sits just above the minute + ruler, following the tracks' horizontal scroll (via syncRuler) but + never scrolling vertically with the layers. */} +
+
+ {t("scenes")} +
+
+
+ {sceneSegments.map((seg) => ( +
+ {seg.title} +
+ ))} + {playhead !== null && ( +
+ )} +
+
+
+ + {/* Fixed minute ruler — always visible; follows the tracks' horizontal + scroll but never scrolls vertically with the layers. */} +
+
+ {t("unitMinutes")} +
+
setPlayheadFromClientX(e.clientX)} + > +
+ {eighthTicks.map((m) => ( +
+ ))} + {ticks.map((m) => ( +
+ {formatTick(m)} +
+ ))} + {playhead !== null && ( +
+ )} +
+
+
+
+ ); +}; + +export default TimelinePanel; diff --git a/components/navbar/ViewOptionsDropdown.tsx b/components/navbar/ViewOptionsDropdown.tsx index 78e77d81..b3fd2f12 100644 --- a/components/navbar/ViewOptionsDropdown.tsx +++ b/components/navbar/ViewOptionsDropdown.tsx @@ -9,7 +9,7 @@ import { PanelRightClose, ArrowLeftRight, Eye, - ListTree, + GanttChartSquare, } from "lucide-react"; import styles from "./ViewOptionsDropdown.module.css"; @@ -21,8 +21,8 @@ const ViewOptionsDropdown = () => { primaryPanel, setSecondaryPanel, swapPanels, - focusedPanel, - setFocusedPanel, + timelineOpen, + setTimelineOpen, } = useViewContext(); const handleSplitToggle = useCallback(() => { @@ -66,14 +66,14 @@ const ViewOptionsDropdown = () => { {isOpen && (
{isOpen && (
+ {/* Timeline strip toggle — available on every platform. */} + +
{/* Split view is single-panel-only on phones. */} {!isPhone && ( <> diff --git a/messages/de.json b/messages/de.json index b0e10489..f59603b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "Gliederung", - "empty": "Sende Szenen und Karten hierher, um deine Geschichte zu gliedern.", + "timeline": { + "title": "Zeitleiste", + "layer": "Spur", + "addLayer": "Spur hinzufügen", + "deleteLayer": "Spur löschen", + "renameLayer": "Ebene umbenennen", + "layerPlaceholder": "Spurname", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern", + "close": "Schließen", + "empty": "Sende Szenen und Karten hierher, um sie auf der Zeitleiste zu platzieren.", "untitled": "Ohne Titel", - "unlinked": "Getrennt", - "unlinkedHint": "Die Quelle dieses Blocks existiert nicht mehr.", - "removeFromOutline": "Aus Gliederung entfernen" + "unlinkedHint": "Die Quelle dieses Clips existiert nicht mehr.", + "removeClip": "Aus Zeitleiste entfernen", + "featureLength": "Filmlänge", + "unitMinutes": "Minuten", + "unitPages": "Seiten", + "unitMinutesShort": "Min.", + "switchUnit": "Einheit wechseln (Minuten / Seiten)", + "scale": "Skala", + "collapseLayer": "Einklappen", + "expandLayer": "Ausklappen", + "indentLayer": "Unter vorheriger Spur einrücken", + "outdentLayer": "Spur ausrücken", + "dragLayer": "Ziehen zum Umsortieren oder Verschachteln", + "scenes": "Szenen" }, "navbar": { "menu": "Menü", @@ -16,7 +35,7 @@ "analytics": "Analysen", "settings": "Einstellungen", "back": "Zurück zu Projekten", - "outline": "Gliederung", + "timeline": "Zeitleiste", "screenplay": "Drehbuch", "board": "Board", "titlePage": "Titelseite", @@ -512,7 +531,7 @@ "dangerZoneTitle": "Gefahrenzone" }, "contextMenu": { - "sendToOutline": "An Gliederung senden", + "sendToTimeline": "An Zeitleiste senden", "goToScene": "Zur Szene gehen", "edit": "Bearbeiten", "copy": "Kopieren", @@ -580,7 +599,7 @@ } }, "board": { - "sendToOutline": "An Gliederung senden", + "sendToTimeline": "An Zeitleiste senden", "duplicate": "Duplizieren", "delete": "Löschen", "createCard": "Karte erstellen", @@ -681,4 +700,4 @@ "monthsAgo": "Vor {months, plural, one {# Monat} other {# Monaten}}", "moreThanYearAgo": "Vor über einem Jahr" } -} +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 855d0249..11a256bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "Outline", - "empty": "Send scenes and cards here to start outlining your story.", + "timeline": { + "title": "Timeline", + "layer": "Layer", + "addLayer": "Add layer", + "deleteLayer": "Delete layer", + "renameLayer": "Rename layer", + "layerPlaceholder": "Layer name", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "close": "Close", + "empty": "Send scenes and cards here to place them on the timeline.", "untitled": "Untitled", - "unlinked": "Unlinked", - "unlinkedHint": "The source of this block no longer exists.", - "removeFromOutline": "Remove from outline" + "unlinkedHint": "The source of this clip no longer exists.", + "removeClip": "Remove from timeline", + "featureLength": "Feature length", + "unitMinutes": "Minutes", + "unitPages": "Pages", + "unitMinutesShort": "min", + "switchUnit": "Switch units (minutes / pages)", + "scale": "Scale", + "collapseLayer": "Collapse", + "expandLayer": "Expand", + "indentLayer": "Indent under previous layer", + "outdentLayer": "Outdent layer", + "dragLayer": "Drag to reorder or nest", + "scenes": "Scenes" }, "navbar": { "menu": "Menu", @@ -16,7 +35,7 @@ "analytics": "Analytics", "settings": "Settings", "back": "Back to projects", - "outline": "Outline", + "timeline": "Timeline", "screenplay": "Screenplay", "board": "Board", "titlePage": "Title Page", @@ -511,7 +530,7 @@ "dangerZoneTitle": "Danger zone" }, "contextMenu": { - "sendToOutline": "Send to outline", + "sendToTimeline": "Send to timeline", "goToScene": "Go to scene", "edit": "Edit", "copy": "Copy", @@ -579,7 +598,7 @@ } }, "board": { - "sendToOutline": "Send to outline", + "sendToTimeline": "Send to timeline", "duplicate": "Duplicate", "delete": "Delete", "createCard": "Create card", diff --git a/messages/es.json b/messages/es.json index 7d93cea0..3a5b433a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "Esquema", - "empty": "Envía escenas y tarjetas aquí para empezar a esquematizar tu historia.", + "timeline": { + "title": "Línea de tiempo", + "layer": "Pista", + "addLayer": "Añadir pista", + "deleteLayer": "Eliminar pista", + "renameLayer": "Renombrar capa", + "layerPlaceholder": "Nombre de la pista", + "zoomIn": "Acercar", + "zoomOut": "Alejar", + "close": "Cerrar", + "empty": "Envía escenas y tarjetas aquí para colocarlas en la línea de tiempo.", "untitled": "Sin título", - "unlinked": "Desvinculado", - "unlinkedHint": "La fuente de este bloque ya no existe.", - "removeFromOutline": "Quitar del esquema" + "unlinkedHint": "La fuente de este clip ya no existe.", + "removeClip": "Quitar de la línea de tiempo", + "featureLength": "Duración", + "unitMinutes": "Minutos", + "unitPages": "Páginas", + "unitMinutesShort": "min", + "switchUnit": "Cambiar unidades (minutos / páginas)", + "scale": "Escala", + "collapseLayer": "Contraer", + "expandLayer": "Expandir", + "indentLayer": "Anidar bajo la pista anterior", + "outdentLayer": "Desanidar pista", + "dragLayer": "Arrastrar para reordenar o anidar", + "scenes": "Escenas" }, "navbar": { "menu": "Menú", @@ -16,7 +35,7 @@ "analytics": "Analíticas", "settings": "Ajustes", "back": "Volver a proyectos", - "outline": "Esquema", + "timeline": "Línea de tiempo", "screenplay": "Guión", "board": "Tablero", "titlePage": "Portada", @@ -511,7 +530,7 @@ "dangerZoneTitle": "Zona de peligro" }, "contextMenu": { - "sendToOutline": "Enviar al esquema", + "sendToTimeline": "Enviar a la línea de tiempo", "goToScene": "Ir a la escena", "edit": "Editar", "copy": "Copiar", @@ -579,7 +598,7 @@ } }, "board": { - "sendToOutline": "Enviar al esquema", + "sendToTimeline": "Enviar a la línea de tiempo", "duplicate": "Duplicar", "delete": "Eliminar", "createCard": "Crear tarjeta", @@ -680,4 +699,4 @@ "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", "moreThanYearAgo": "Hace más de 1 año" } -} +} \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json index da87b058..9d95c940 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "Séquencier", - "empty": "Envoyez des scènes et des cartes ici pour commencer à structurer votre histoire.", + "timeline": { + "title": "Chronologie", + "layer": "Piste", + "addLayer": "Ajouter une piste", + "deleteLayer": "Supprimer la piste", + "renameLayer": "Renommer le calque", + "layerPlaceholder": "Nom de la piste", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "close": "Fermer", + "empty": "Envoyez des scènes et des cartes ici pour les placer sur la chronologie.", "untitled": "Sans titre", - "unlinked": "Détaché", - "unlinkedHint": "La source de ce bloc n'existe plus.", - "removeFromOutline": "Retirer du séquencier" + "unlinkedHint": "La source de ce clip n'existe plus.", + "removeClip": "Retirer de la chronologie", + "featureLength": "Durée du film", + "unitMinutes": "Minutes", + "unitPages": "Pages", + "unitMinutesShort": "min", + "switchUnit": "Changer d'unité (minutes / pages)", + "scale": "Échelle", + "collapseLayer": "Réduire", + "expandLayer": "Développer", + "indentLayer": "Imbriquer sous la piste précédente", + "outdentLayer": "Désimbriquer la piste", + "dragLayer": "Glisser pour réordonner ou imbriquer", + "scenes": "Scènes" }, "navbar": { "menu": "Menu", @@ -16,7 +35,7 @@ "analytics": "Analyses", "settings": "Paramètres", "back": "Retour aux projets", - "outline": "Séquencier", + "timeline": "Chronologie", "screenplay": "Scénario", "board": "Tableau", "titlePage": "Page de titre", @@ -512,7 +531,7 @@ "dangerZoneTitle": "Zone dangereuse" }, "contextMenu": { - "sendToOutline": "Envoyer vers le séquencier", + "sendToTimeline": "Envoyer vers la chronologie", "goToScene": "Aller à la scène", "edit": "Modifier", "copy": "Copier", @@ -580,7 +599,7 @@ } }, "board": { - "sendToOutline": "Envoyer vers le séquencier", + "sendToTimeline": "Envoyer vers la chronologie", "duplicate": "Dupliquer", "delete": "Supprimer", "createCard": "Créer une carte", @@ -681,4 +700,4 @@ "monthsAgo": "Il y a {months, plural, one {# mois} other {# mois}}", "moreThanYearAgo": "Il y a plus d'un an" } -} +} \ No newline at end of file diff --git a/messages/ja.json b/messages/ja.json index 23a9301f..fdda161e 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "アウトライン", - "empty": "シーンやカードをここに送って、ストーリーの構成を始めましょう。", + "timeline": { + "title": "タイムライン", + "layer": "レイヤー", + "addLayer": "レイヤーを追加", + "deleteLayer": "レイヤーを削除", + "renameLayer": "レイヤー名を変更", + "layerPlaceholder": "レイヤー名", + "zoomIn": "拡大", + "zoomOut": "縮小", + "close": "閉じる", + "empty": "シーンやカードをここに送るとタイムラインに配置できます。", "untitled": "無題", - "unlinked": "リンク解除", - "unlinkedHint": "このブロックの参照元は存在しなくなりました。", - "removeFromOutline": "アウトラインから削除" + "unlinkedHint": "このクリップの元データは存在しません。", + "removeClip": "タイムラインから削除", + "featureLength": "作品の長さ", + "unitMinutes": "分", + "unitPages": "ページ", + "unitMinutesShort": "分", + "switchUnit": "単位を切り替え(分/ページ)", + "scale": "スケール", + "collapseLayer": "折りたたむ", + "expandLayer": "展開", + "indentLayer": "前のレイヤーの下に入れる", + "outdentLayer": "レイヤーを外に出す", + "dragLayer": "ドラッグで並べ替え・入れ子", + "scenes": "シーン" }, "navbar": { "menu": "メニュー", @@ -16,7 +35,7 @@ "analytics": "分析", "settings": "設定", "back": "プロジェクトに戻る", - "outline": "アウトライン", + "timeline": "タイムライン", "screenplay": "脚本", "board": "ボード", "titlePage": "タイトルページ", @@ -511,7 +530,7 @@ "dangerZoneTitle": "危険ゾーン" }, "contextMenu": { - "sendToOutline": "アウトラインに送る", + "sendToTimeline": "タイムラインに送る", "goToScene": "シーンへ移動", "edit": "編集", "copy": "コピー", @@ -579,7 +598,7 @@ } }, "board": { - "sendToOutline": "アウトラインに送る", + "sendToTimeline": "タイムラインに送る", "duplicate": "複製", "delete": "削除", "createCard": "カードを作成", @@ -680,4 +699,4 @@ "monthsAgo": "{months}ヶ月前", "moreThanYearAgo": "1年以上前" } -} +} \ No newline at end of file diff --git a/messages/ko.json b/messages/ko.json index 91e3d644..0914c10a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "아웃라인", - "empty": "장면과 카드를 여기로 보내 이야기 구성을 시작하세요.", + "timeline": { + "title": "타임라인", + "layer": "레이어", + "addLayer": "레이어 추가", + "deleteLayer": "레이어 삭제", + "renameLayer": "레이어 이름 변경", + "layerPlaceholder": "레이어 이름", + "zoomIn": "확대", + "zoomOut": "축소", + "close": "닫기", + "empty": "장면과 카드를 여기로 보내 타임라인에 배치하세요.", "untitled": "제목 없음", - "unlinked": "연결 해제됨", - "unlinkedHint": "이 블록의 원본이 더 이상 존재하지 않습니다.", - "removeFromOutline": "아웃라인에서 제거" + "unlinkedHint": "이 클립의 원본이 더 이상 존재하지 않습니다.", + "removeClip": "타임라인에서 제거", + "featureLength": "작품 길이", + "unitMinutes": "분", + "unitPages": "페이지", + "unitMinutesShort": "분", + "switchUnit": "단위 전환 (분 / 페이지)", + "scale": "배율", + "collapseLayer": "접기", + "expandLayer": "펼치기", + "indentLayer": "이전 레이어 아래로 들여쓰기", + "outdentLayer": "레이어 내어쓰기", + "dragLayer": "드래그하여 순서 변경 또는 중첩", + "scenes": "장면" }, "navbar": { "menu": "메뉴", @@ -16,7 +35,7 @@ "analytics": "분석", "settings": "설정", "back": "프로젝트로 돌아가기", - "outline": "아웃라인", + "timeline": "타임라인", "screenplay": "시나리오", "board": "보드", "titlePage": "타이틀 페이지", @@ -511,7 +530,7 @@ "dangerZoneTitle": "위험 구역" }, "contextMenu": { - "sendToOutline": "아웃라인으로 보내기", + "sendToTimeline": "타임라인으로 보내기", "goToScene": "장면으로 이동", "edit": "편집", "copy": "복사", @@ -579,7 +598,7 @@ } }, "board": { - "sendToOutline": "아웃라인으로 보내기", + "sendToTimeline": "타임라인으로 보내기", "duplicate": "복제", "delete": "삭제", "createCard": "카드 생성", @@ -680,4 +699,4 @@ "monthsAgo": "{months}달 전", "moreThanYearAgo": "1년 이상 전" } -} +} \ No newline at end of file diff --git a/messages/pl.json b/messages/pl.json index 0b1bccd4..023c81b3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "Konspekt", - "empty": "Wyślij tu sceny i karty, aby rozpocząć tworzenie konspektu historii.", + "timeline": { + "title": "Oś czasu", + "layer": "Ścieżka", + "addLayer": "Dodaj ścieżkę", + "deleteLayer": "Usuń ścieżkę", + "renameLayer": "Zmień nazwę warstwy", + "layerPlaceholder": "Nazwa ścieżki", + "zoomIn": "Powiększ", + "zoomOut": "Pomniejsz", + "close": "Zamknij", + "empty": "Wyślij tu sceny i karty, aby umieścić je na osi czasu.", "untitled": "Bez tytułu", - "unlinked": "Odłączony", - "unlinkedHint": "Źródło tego bloku już nie istnieje.", - "removeFromOutline": "Usuń z konspektu" + "unlinkedHint": "Źródło tego klipu już nie istnieje.", + "removeClip": "Usuń z osi czasu", + "featureLength": "Długość filmu", + "unitMinutes": "Minuty", + "unitPages": "Strony", + "unitMinutesShort": "min", + "switchUnit": "Zmień jednostki (minuty / strony)", + "scale": "Skala", + "collapseLayer": "Zwiń", + "expandLayer": "Rozwiń", + "indentLayer": "Zagnieźdź pod poprzednią ścieżką", + "outdentLayer": "Wysuń ścieżkę", + "dragLayer": "Przeciągnij, aby zmienić kolejność lub zagnieździć", + "scenes": "Sceny" }, "navbar": { "menu": "Menu", @@ -16,7 +35,7 @@ "analytics": "Analityka", "settings": "Ustawienia", "back": "Powrót do projektów", - "outline": "Konspekt", + "timeline": "Oś czasu", "screenplay": "Scenariusz", "board": "Tablica", "titlePage": "Strona tytułowa", @@ -511,7 +530,7 @@ "dangerZoneTitle": "Strefa niebezpieczeństwa" }, "contextMenu": { - "sendToOutline": "Wyślij do konspektu", + "sendToTimeline": "Wyślij do osi czasu", "goToScene": "Idź do sceny", "edit": "Edytuj", "copy": "Kopiuj", @@ -579,7 +598,7 @@ } }, "board": { - "sendToOutline": "Wyślij do konspektu", + "sendToTimeline": "Wyślij do osi czasu", "duplicate": "Powiel", "delete": "Usuń", "createCard": "Utwórz kartę", @@ -680,4 +699,4 @@ "monthsAgo": "{months, plural, one {# miesiąc} few {# miesiące} many {# miesięcy} other {# miesięcy}} temu", "moreThanYearAgo": "Ponad rok temu" } -} +} \ No newline at end of file diff --git a/messages/zh.json b/messages/zh.json index 366af009..a6a96a7a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1,11 +1,30 @@ { - "outline": { - "title": "大纲", - "empty": "将场景和卡片发送到这里,开始梳理你的故事大纲。", + "timeline": { + "title": "时间线", + "layer": "图层", + "addLayer": "添加图层", + "deleteLayer": "删除图层", + "renameLayer": "重命名图层", + "layerPlaceholder": "图层名称", + "zoomIn": "放大", + "zoomOut": "缩小", + "close": "关闭", + "empty": "将场景和卡片发送到此处即可放置在时间线上。", "untitled": "无标题", - "unlinked": "已断开", - "unlinkedHint": "此区块的来源已不存在。", - "removeFromOutline": "从大纲中移除" + "unlinkedHint": "该片段的来源已不存在。", + "removeClip": "从时间线移除", + "featureLength": "影片时长", + "unitMinutes": "分钟", + "unitPages": "页数", + "unitMinutesShort": "分", + "switchUnit": "切换单位(分钟/页数)", + "scale": "缩放", + "collapseLayer": "折叠", + "expandLayer": "展开", + "indentLayer": "嵌套到上一图层下", + "outdentLayer": "取消嵌套", + "dragLayer": "拖动以重新排序或嵌套", + "scenes": "场景" }, "navbar": { "menu": "菜单", @@ -16,7 +35,7 @@ "analytics": "分析", "settings": "设置", "back": "返回项目", - "outline": "大纲", + "timeline": "时间线", "screenplay": "剧本", "board": "剧本板", "titlePage": "扉页", @@ -511,7 +530,7 @@ "dangerZoneTitle": "危险区域" }, "contextMenu": { - "sendToOutline": "发送到大纲", + "sendToTimeline": "发送到时间线", "goToScene": "跳转到场景", "edit": "编辑", "copy": "复制", @@ -579,7 +598,7 @@ } }, "board": { - "sendToOutline": "发送到大纲", + "sendToTimeline": "发送到时间线", "duplicate": "复制", "delete": "删除", "createCard": "创建卡片", @@ -680,4 +699,4 @@ "monthsAgo": "{months} 个月前", "moreThanYearAgo": "1 年前" } -} +} \ No newline at end of file diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 9f90e2ff..13b70483 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -30,7 +30,8 @@ import { DEFAULT_SKIPPED_SCENE_LETTERS, ShelfEntry, DocumentNode, - OutlineItem, + TimelineLayer, + TimelineClip, ProjectStatus, } from "@src/lib/project/project-state"; import { Screenplay } from "@src/lib/utils/types"; @@ -197,10 +198,11 @@ export interface ProjectContextType { documentEditor: Editor | null; updateDocumentEditor: (editor: Editor | null) => void; - // Outline view (project-wide ordered list of scene/card references) - outline: Record; + // Timeline (horizontal, minute-scaled lanes of scene/card clips) + timelineLayers: Record; + timelineClips: Record; /** A board card to focus next time its board canvas mounts/becomes visible - * (set when navigating to a card from the Outline). Cleared by the canvas. */ + * (set when navigating to a card from the Timeline). Cleared by the canvas. */ boardFocusCardId: string | null; setBoardFocusCardId: (cardId: string | null) => void; } @@ -325,8 +327,9 @@ const defaultContextValue: ProjectContextType = { documents: {}, documentEditor: null, updateDocumentEditor: () => {}, - // Outline defaults - outline: {}, + // Timeline defaults + timelineLayers: {}, + timelineClips: {}, boardFocusCardId: null, setBoardFocusCardId: () => {}, }; @@ -476,8 +479,9 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [documentEditor, setDocumentEditor] = useState(null); const updateDocumentEditor = useCallback((editor: Editor | null) => setDocumentEditor(editor), []); - // Outline state - const [outline, setOutline] = useState>({}); + // Timeline state + const [timelineLayers, setTimelineLayers] = useState>({}); + const [timelineClips, setTimelineClips] = useState>({}); const [boardFocusCardId, setBoardFocusCardId] = useState(null); // Create repository instance when ydoc is available (dynamically imported) @@ -806,10 +810,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setDocuments(docs); }); - // Observe outline changes - setOutline(repository.outlineItems); - const unsubscribeOutline = repository.observeOutline((items) => { - setOutline(items); + // Observe timeline changes + setTimelineLayers(repository.timelineLayers); + setTimelineClips(repository.timelineClips); + const unsubscribeTimelineLayers = repository.observeTimelineLayers((layers) => { + setTimelineLayers(layers); + }); + const unsubscribeTimelineClips = repository.observeTimelineClips((clips) => { + setTimelineClips(clips); }); return () => { @@ -823,7 +831,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = unsubscribeMetadata(); unsubscribeShelf(); unsubscribeDocuments(); - unsubscribeOutline(); + unsubscribeTimelineLayers(); + unsubscribeTimelineClips(); }; }, [repository, updateCharacters, updateLocations, updateScenes, updateScreenplay]); @@ -1255,7 +1264,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = documents, documentEditor, updateDocumentEditor, - outline, + timelineLayers, + timelineClips, boardFocusCardId, setBoardFocusCardId, }), @@ -1359,7 +1369,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = documents, documentEditor, updateDocumentEditor, - outline, + timelineLayers, + timelineClips, boardFocusCardId, setBoardFocusCardId, ], diff --git a/src/context/ViewContext.tsx b/src/context/ViewContext.tsx index e0f0855e..e5a6c172 100644 --- a/src/context/ViewContext.tsx +++ b/src/context/ViewContext.tsx @@ -6,7 +6,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use const isPhoneViewport = () => typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches; -export type PanelType = "screenplay" | "board" | "statistics" | "title" | "draft" | "document" | "outline"; +export type PanelType = "screenplay" | "board" | "statistics" | "title" | "draft" | "document"; export type SplitSide = "primary" | "secondary"; /** Panel kinds that display a specific document, and so carry a docId per side. */ @@ -28,6 +28,9 @@ interface ViewContextType { showComments: boolean; leftSidebarOpen: boolean; rightSidebarOpen: boolean; + /** Whether the horizontal Timeline strip is open beneath the project navbar. */ + timelineOpen: boolean; + setTimelineOpen: (value: boolean | ((prev: boolean) => boolean)) => void; /** * Phone-only: hide the floating editor chrome (project navbar + sidebar edge * handles) while the reader scrolls down through the script, so it doesn't @@ -79,6 +82,7 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { const [showComments, setShowComments] = useState(true); const [leftSidebarOpen, setLeftSidebarOpenState] = useState(false); const [rightSidebarOpen, setRightSidebarOpenState] = useState(false); + const [timelineOpen, setTimelineOpen] = useState(false); const [chromeHidden, setChromeHidden] = useState(false); // Mirror the live open-state in refs so the wrapped setters can resolve a @@ -289,6 +293,8 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { showComments, leftSidebarOpen, rightSidebarOpen, + timelineOpen, + setTimelineOpen, chromeHidden, setChromeHidden, setPrimaryPanel, @@ -306,7 +312,7 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { setLeftSidebarOpen, setRightSidebarOpen, }), - [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, chromeHidden, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, splitWithDocument, closeDocument, swapPanels, setIsEndlessScroll, setShowComments, setLeftSidebarOpen, setRightSidebarOpen], + [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, timelineOpen, chromeHidden, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, splitWithDocument, closeDocument, swapPanels, setIsEndlessScroll, setShowComments, setLeftSidebarOpen, setRightSidebarOpen], ); return {children}; diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index c735a8db..5ef4a61a 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -48,6 +48,8 @@ export type ProjectMetadata = { title: string; author: string; titlepageInitialized?: boolean; + /** Target feature length in minutes, used to size the Timeline extent. */ + featureLength?: number; }; // -------------------------------- // @@ -240,15 +242,15 @@ export type DocumentNode = { }; // -------------------------------- // -// OUTLINE // +// TIMELINE // // -------------------------------- // /** - * Kind of source element an outline block references. Extensible — board cards + * Kind of source element a timeline clip references. Extensible — board cards * will later come in image/voice/link flavours, which become new source kinds * without a schema rewrite. */ -export type OutlineItemSource = "scene" | "card"; +export type TimelineClipSource = "scene" | "card"; /** * Sentinel `refDocId` for scenes that live in the project's main screenplay @@ -257,20 +259,38 @@ export type OutlineItemSource = "scene" | "card"; export const MAIN_SCREENPLAY_REF = "screenplay"; /** - * A block in the project's Outline view. Like the document tree, the outline is - * stored flat in the `outline` Y.Map keyed by `id`; hierarchy is reconstructed - * from `parentId`, siblings ordered by ascending `order` (fractional float so - * moves never rewrite neighbours). Any block can nest children. - * - * Each block references a live source element via (`source`, `refDocId`, - * `refId`); `title`/`preview`/`color` are a cached snapshot kept in sync by the - * Outline view's resolver and shown (greyed) when the source no longer exists. + * A horizontal lane in the project's Timeline. Layers stack top-to-bottom and + * are stored flat in the `timelineLayers` Y.Map keyed by `id`, ordered by + * ascending `order` (fractional float so inserts never rewrite neighbours). */ -export type OutlineItem = { +export type TimelineLayer = { id: string; - parentId: string | null; + name: string; order: number; - source: OutlineItemSource; + /** Parent layer id for nesting; null/undefined = a root lane. Siblings are + * ordered by ascending `order` (fractional float, like the document tree). */ + parentId?: string | null; +}; + +/** + * A clip placed on the Timeline: a colored box on a single layer spanning a + * range of the minute-based ruler. Stored flat in the `timelineClips` Y.Map + * keyed by `id`. + * + * `start`/`duration` are measured in minutes. Each clip references a live source + * element via (`source`, `refDocId`, `refId`); `title`/`preview`/`color` are a + * cached snapshot kept fresh by the Timeline's resolver and shown (greyed) when + * the source no longer exists. + */ +export type TimelineClip = { + id: string; + /** Owning layer id. */ + layerId: string; + /** Start offset from the timeline origin, in minutes. */ + start: number; + /** Length of the clip, in minutes. */ + duration: number; + source: TimelineClipSource; /** card: board docId · scene: MAIN_SCREENPLAY_REF or the editor doc id. */ refDocId: string; /** card: card id · scene: scene heading `data-id`. */ @@ -293,7 +313,8 @@ export type ProjectData = { locations: Record; metadata: ProjectMetadata; documents?: Record; - outline?: Record; + timelineLayers?: Record; + timelineClips?: Record; layout: LayoutData; production: ProductionData; comments?: Record; @@ -338,7 +359,8 @@ export class ProjectState extends Y.Doc { LOCATIONS: "locations", METADATA: "metadata", DOCUMENTS: "documents", - OUTLINE: "outline", + TIMELINE_LAYERS: "timelineLayers", + TIMELINE_CLIPS: "timelineClips", LAYOUT: "layout", PRODUCTION: "production", COMMENTS: "comments", @@ -389,9 +411,14 @@ export class ProjectState extends Y.Doc { return this.getMap(this.KEYS.DOCUMENTS); } - /** Outline blocks keyed by block id. */ - outline(): Y.Map { - return this.getMap(this.KEYS.OUTLINE); + /** Timeline layers (lanes) keyed by layer id. */ + timelineLayers(): Y.Map { + return this.getMap(this.KEYS.TIMELINE_LAYERS); + } + + /** Timeline clips (boxes) keyed by clip id. */ + timelineClips(): Y.Map { + return this.getMap(this.KEYS.TIMELINE_CLIPS); } /** Content fragment for an `editor` document node. */ diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 3c096069..44928d14 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -14,7 +14,8 @@ import { ShelfEntryType, ShelfVersionMeta, DocumentNode, - OutlineItem, + TimelineLayer, + TimelineClip, screenplayOf, } from "./project-state"; import { CharacterMap } from "../screenplay/characters"; @@ -148,6 +149,16 @@ export class ProjectRepository { this.ydoc.metadata().set("author", author); } + /** Target feature length in minutes (defaults to 90 when unset). */ + getFeatureLength(): number { + return this.ydoc.metadata().get("featureLength") ?? 90; + } + + setFeatureLength(minutes: number): void { + if (this.guardWrite("setFeatureLength")) return; + this.ydoc.metadata().set("featureLength", minutes); + } + observeMetadata(callback: (metadata: Partial) => void): () => void { const map = this.ydoc.metadata(); const observer = () => callback(map.toJSON() as Partial); @@ -905,59 +916,139 @@ export class ProjectRepository { } // -------------------------------- // - // OUTLINE // + // TIMELINE // // -------------------------------- // - /** All outline blocks keyed by block id. */ - get outlineItems(): Record { - return this.ydoc.outline().toJSON() as Record; + /** All timeline layers keyed by layer id. */ + get timelineLayers(): Record { + return this.ydoc.timelineLayers().toJSON() as Record; } - observeOutline(callback: (outline: Record) => void): () => void { - const map = this.ydoc.outline(); - const observer = () => callback(map.toJSON() as Record); + /** All timeline clips keyed by clip id. */ + get timelineClips(): Record { + return this.ydoc.timelineClips().toJSON() as Record; + } + + observeTimelineLayers(callback: (layers: Record) => void): () => void { + const map = this.ydoc.timelineLayers(); + const observer = () => callback(map.toJSON() as Record); map.observe(observer); return () => map.unobserve(observer); } - /** Append position = one past the greatest order among the parent's children. */ - private nextOutlineOrder(parentId: string | null): number { + observeTimelineClips(callback: (clips: Record) => void): () => void { + const map = this.ydoc.timelineClips(); + const observer = () => callback(map.toJSON() as Record); + map.observe(observer); + return () => map.unobserve(observer); + } + + /** Append order = one past the greatest order among the parent's children. */ + private nextLayerOrder(parentId: string | null): number { let max = -1; - this.ydoc.outline().forEach((item) => { - if (item.parentId === parentId && item.order > max) max = item.order; + this.ydoc.timelineLayers().forEach((layer) => { + if ((layer.parentId ?? null) === parentId && layer.order > max) max = layer.order; }); return max + 1; } - /** Is `ancestorId` an ancestor of `itemId`? Used to block cyclic moves. */ - private isOutlineAncestor(itemId: string, ancestorId: string): boolean { - const map = this.ydoc.outline(); + /** Is `ancestorId` an ancestor of `layerId`? Blocks cyclic nesting. */ + private isLayerAncestor(layerId: string, ancestorId: string): boolean { + const map = this.ydoc.timelineLayers(); const seen = new Set(); - let cur = map.get(itemId) as OutlineItem | undefined; + let cur = map.get(layerId) as TimelineLayer | undefined; while (cur && cur.parentId) { if (seen.has(cur.id)) break; seen.add(cur.id); if (cur.parentId === ancestorId) return true; - cur = map.get(cur.parentId) as OutlineItem | undefined; + cur = map.get(cur.parentId) as TimelineLayer | undefined; } return false; } /** - * Add a block to the outline at the end of the root (or a given parent). - * De-duplicates: if a block already references the same source element, the - * existing block's id is returned and nothing is added. + * Ensure the timeline has at least `count` layers, creating "Layer N" lanes + * as needed. Returns the layers ordered by `order`. Used to seed the two + * default lanes the first time the timeline is opened. + */ + ensureTimelineLayers(count: number, defaultName: (index: number) => string): TimelineLayer[] { + if (!this.guardWrite("ensureTimelineLayers")) { + const existing = Object.values(this.timelineLayers).length; + if (existing < count) { + this.ydoc.transact(() => { + for (let i = existing; i < count; i++) this.addTimelineLayer(defaultName(i)); + }); + } + } + return Object.values(this.timelineLayers).sort((a, b) => a.order - b.order); + } + + /** Append a new layer under `parentId` (root by default). Returns its id. */ + addTimelineLayer(name: string, parentId: string | null = null): string { + if (this.guardWrite("addTimelineLayer")) return ""; + const id = uuidv7(); + this.ydoc.timelineLayers().set(id, { id, name, parentId, order: this.nextLayerOrder(parentId) }); + return id; + } + + renameTimelineLayer(id: string, name: string): void { + if (this.guardWrite("renameTimelineLayer")) return; + const map = this.ydoc.timelineLayers(); + const layer = map.get(id) as TimelineLayer | undefined; + if (!layer) return; + map.set(id, { ...layer, name }); + } + + /** + * Re-nest a layer under a new parent at the given fractional order. No-ops on + * a move that would create a cycle (into itself or one of its descendants). + */ + moveTimelineLayer(id: string, newParentId: string | null, order: number): void { + if (this.guardWrite("moveTimelineLayer")) return; + const map = this.ydoc.timelineLayers(); + const layer = map.get(id) as TimelineLayer | undefined; + if (!layer) return; + if (newParentId !== null && (newParentId === id || this.isLayerAncestor(newParentId, id))) return; + map.set(id, { ...layer, parentId: newParentId, order }); + } + + /** + * Delete a layer and the clips that live on it. Child layers are promoted to + * the deleted layer's parent so nested lanes aren't destroyed with it. + */ + deleteTimelineLayer(id: string): void { + if (this.guardWrite("deleteTimelineLayer")) return; + const map = this.ydoc.timelineLayers(); + const target = map.get(id) as TimelineLayer | undefined; + if (!target) return; + this.ydoc.transact(() => { + map.forEach((child) => { + if ((child.parentId ?? null) === id) { + map.set(child.id, { ...child, parentId: target.parentId ?? null }); + } + }); + map.delete(id); + const clips = this.ydoc.timelineClips(); + clips.forEach((clip) => { + if (clip.layerId === id) clips.delete(clip.id); + }); + }); + } + + /** + * Add a clip to the timeline. De-duplicates: if a clip already references the + * same source element, the existing clip's id is returned and nothing added. */ - addOutlineItem(item: Omit): string { - if (this.guardWrite("addOutlineItem")) return ""; - const map = this.ydoc.outline(); + addTimelineClip(clip: Omit): string { + if (this.guardWrite("addTimelineClip")) return ""; + const map = this.ydoc.timelineClips(); let existingId = ""; map.forEach((existing) => { if ( - existing.source === item.source && - existing.refDocId === item.refDocId && - existing.refId === item.refId + existing.source === clip.source && + existing.refDocId === clip.refDocId && + existing.refId === clip.refId ) { existingId = existing.id; } @@ -965,57 +1056,61 @@ export class ProjectRepository { if (existingId) return existingId; const id = uuidv7(); - map.set(id, { ...item, id, order: this.nextOutlineOrder(item.parentId ?? null) }); + map.set(id, { ...clip, id }); return id; } /** - * Move a block under a new parent at the given fractional order. No-ops on a - * move that would create a cycle (into itself or one of its descendants). + * "Send to timeline" helper: ensure the default lanes exist, then append a + * clip to the first layer just after its last clip. De-duplicates via + * `addTimelineClip`, so sending the same source twice is a no-op. */ - moveOutlineItem(id: string, newParentId: string | null, order: number): void { - if (this.guardWrite("moveOutlineItem")) return; - const map = this.ydoc.outline(); - const item = map.get(id) as OutlineItem | undefined; - if (!item) return; - if (newParentId !== null && (newParentId === id || this.isOutlineAncestor(newParentId, id))) { - return; - } - map.set(id, { ...item, parentId: newParentId, order }); + appendTimelineClip( + fields: Pick, + durationMinutes = 2, + ): string { + if (this.guardWrite("appendTimelineClip")) return ""; + let id = ""; + this.ydoc.transact(() => { + const layers = this.ensureTimelineLayers(2, (i) => `Layer ${i + 1}`); + // Prefer the first root lane; fall back to the first layer overall. + const roots = layers.filter((l) => (l.parentId ?? null) === null); + const layerId = (roots[0] ?? layers[0]).id; + let end = 0; + this.ydoc.timelineClips().forEach((c) => { + if (c.layerId === layerId) end = Math.max(end, c.start + c.duration); + }); + id = this.addTimelineClip({ ...fields, layerId, start: end, duration: durationMinutes }); + }); + return id; } - /** Patch the cached display snapshot of a block (title/preview/color). */ - refreshOutlineSnapshot(id: string, snapshot: Pick): void { - if (this.guardWrite("refreshOutlineSnapshot")) return; - const map = this.ydoc.outline(); - const item = map.get(id) as OutlineItem | undefined; - if (!item) return; - map.set(id, { ...item, ...snapshot }); + /** Patch a clip's placement (layer / start / duration). */ + updateTimelineClip(id: string, patch: Partial>): void { + if (this.guardWrite("updateTimelineClip")) return; + const map = this.ydoc.timelineClips(); + const clip = map.get(id) as TimelineClip | undefined; + if (!clip) return; + map.set(id, { ...clip, ...patch }); } - /** - * Remove a block from the outline. Its children are promoted to the removed - * block's parent so other referenced beats are not destroyed. - */ - deleteOutlineItem(id: string): void { - if (this.guardWrite("deleteOutlineItem")) return; - const map = this.ydoc.outline(); - const target = map.get(id) as OutlineItem | undefined; - if (!target) return; + /** Patch the cached display snapshot of a clip (title/preview/color). */ + refreshTimelineClipSnapshot(id: string, snapshot: Pick): void { + if (this.guardWrite("refreshTimelineClipSnapshot")) return; + const map = this.ydoc.timelineClips(); + const clip = map.get(id) as TimelineClip | undefined; + if (!clip) return; + map.set(id, { ...clip, ...snapshot }); + } - this.ydoc.transact(() => { - map.forEach((child) => { - if (child.parentId === id) { - map.set(child.id, { ...child, parentId: target.parentId }); - } - }); - map.delete(id); - }); + deleteTimelineClip(id: string): void { + if (this.guardWrite("deleteTimelineClip")) return; + this.ydoc.timelineClips().delete(id); } /** * Parse an `editor` document's content into transient scenes (heading text + - * preview + position), so the outline can resolve scene references that live + * preview + position), so the timeline can resolve scene references that live * in document-tree editor docs rather than the main screenplay. */ getEditorDocumentScenes(docId: string): TransientScene[] { diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 365b8674..437bba11 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -42,8 +42,9 @@ export type { BoardData, DocumentNode, DocumentNodeType, - OutlineItem, - OutlineItemSource, + TimelineLayer, + TimelineClip, + TimelineClipSource, ProjectData, TypedMap, } from "./project-doc"; @@ -195,7 +196,8 @@ export const projectDataOf = (ydoc: ProjectState): ProjectData => ({ production: ydoc.production().toJSON(), comments: ydoc.comments().toJSON(), documents: ydoc.documents().toJSON(), - outline: ydoc.outline().toJSON(), + timelineLayers: ydoc.timelineLayers().toJSON(), + timelineClips: ydoc.timelineClips().toJSON(), shelf: ydoc.shelf().toJSON(), dictionary: ydoc.dictionary().toJSON(), documentContent: documentContentOf(ydoc), @@ -243,7 +245,8 @@ export const applyProjectData = (ydoc: ProjectState, data: Partial) fillMap(asMap(ydoc.production()), data.production); fillMap(asMap(ydoc.comments()), data.comments); fillMap(asMap(ydoc.documents()), data.documents); - fillMap(asMap(ydoc.outline()), data.outline); + fillMap(asMap(ydoc.timelineLayers()), data.timelineLayers); + fillMap(asMap(ydoc.timelineClips()), data.timelineClips); fillMap(asMap(ydoc.shelf()), data.shelf); fillMap(asMap(ydoc.dictionary()), data.dictionary); @@ -315,7 +318,8 @@ export const clearProjectData = (ydoc: ProjectState): void => { ydoc.production().clear(); ydoc.comments().clear(); ydoc.documents().clear(); - ydoc.outline().clear(); + ydoc.timelineLayers().clear(); + ydoc.timelineClips().clear(); ydoc.shelf().clear(); ydoc.dictionary().clear(); }); diff --git a/src/tests/project/scriptio-roundtrip.test.ts b/src/tests/project/scriptio-roundtrip.test.ts index 5a34f44e..350e3ed2 100644 --- a/src/tests/project/scriptio-roundtrip.test.ts +++ b/src/tests/project/scriptio-roundtrip.test.ts @@ -22,7 +22,7 @@ const action = (id: string, text: string) => ({ * Build a project that exercises every shared type the adapter must preserve: * screenplay + title-page fragments, all the metadata-ish maps, the document * tree (folder / editor / board), an editor doc's own content, a board's data, - * the outline, a shelf entry with version content, and the custom dictionary. + * the timeline, a shelf entry with version content, and the custom dictionary. */ function buildPopulatedProject(): ProjectState { const ydoc = new ProjectState(); @@ -66,9 +66,12 @@ function buildPopulatedProject(): ProjectState { ydoc.boardData(board).set("arrows", JSON.stringify([])); }); - // Outline references the main screenplay - repo.addOutlineItem({ - parentId: null, + // Timeline: a layer holding a clip that references the main screenplay + const layerId = repo.addTimelineLayer("Act 1"); + repo.addTimelineClip({ + layerId, + start: 0, + duration: 2, source: "scene", refDocId: "screenplay", refId: "s1", @@ -104,7 +107,8 @@ function snapshot(data: ReturnType) { production: data.production, comments: data.comments, documents: data.documents, - outline: data.outline, + timelineLayers: data.timelineLayers, + timelineClips: data.timelineClips, shelf: data.shelf, dictionary: data.dictionary, documentContent: data.documentContent, diff --git a/styles/globals.css b/styles/globals.css index 98a6b4f5..7dd3543b 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -128,6 +128,10 @@ svg { ::-webkit-scrollbar { width: 16px; + /* Also size the horizontal bar. Without an explicit height, WebKit collapses + * horizontal scrollbars to 0px once ANY ::-webkit-scrollbar rule exists, which + * hid them app-wide (e.g. the timeline could not be scrolled sideways). */ + height: 16px; border-radius: 8px; }