Compare commits
No commits in common. "main" and "main" have entirely different histories.
13 changed files with 27 additions and 1726 deletions
50
bun.lock
50
bun.lock
|
|
@ -6,11 +6,9 @@
|
||||||
"name": "psslides",
|
"name": "psslides",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emmet-monaco-es": "^5.7.0",
|
"emmet-monaco-es": "^5.7.0",
|
||||||
"less": "^4.9.1",
|
|
||||||
"monaco-editor": "0.55.1",
|
"monaco-editor": "0.55.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/less": "^3.0.8",
|
|
||||||
"oxlint": "^1.85.0",
|
"oxlint": "^1.85.0",
|
||||||
"prettier": "^3.9.9",
|
"prettier": "^3.9.9",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
|
@ -97,14 +95,8 @@
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||||
|
|
||||||
"@types/less": ["@types/less@3.0.8", "", {}, "sha512-Gjm4+H9noDJgu5EdT3rUw5MhPBag46fiOy27BefvWkNL8mlZnKnCaVVVTLKj6RYXed9b62CPKnPav9govyQDzA=="],
|
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="],
|
|
||||||
|
|
||||||
"debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
||||||
|
|
@ -113,20 +105,10 @@
|
||||||
|
|
||||||
"emmet-monaco-es": ["emmet-monaco-es@5.7.0", "", { "dependencies": { "emmet": "^2.4.11" }, "peerDependencies": { "monaco-editor": ">=0.22.0" } }, "sha512-q8jtgsEWWaXHnAJ6HAcwMB7WHraRtf032TWpeOpN6SGLtwxr7hd6RgF3Kbj2rPxb6Q19fYVVafAFODYRYUS8NQ=="],
|
"emmet-monaco-es": ["emmet-monaco-es@5.7.0", "", { "dependencies": { "emmet": "^2.4.11" }, "peerDependencies": { "monaco-editor": ">=0.22.0" } }, "sha512-q8jtgsEWWaXHnAJ6HAcwMB7WHraRtf032TWpeOpN6SGLtwxr7hd6RgF3Kbj2rPxb6Q19fYVVafAFODYRYUS8NQ=="],
|
||||||
|
|
||||||
"errno": ["errno@0.1.8", "", { "dependencies": { "prr": "~1.0.1" }, "bin": { "errno": "cli.js" } }, "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A=="],
|
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
|
||||||
|
|
||||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
|
||||||
|
|
||||||
"less": ["less@4.9.1", "", { "dependencies": { "copy-anything": "^3.0.5", "parse-node-version": "^1.0.1" }, "optionalDependencies": { "errno": "^0.1.1", "graceful-fs": "^4.1.2", "make-dir": "^5.1.0", "mime": "^1.4.1", "needle": "^3.1.0", "probe-image-size": "^7.2.3", "source-map": "~0.6.0" }, "bin": { "lessc": "bin/lessc" } }, "sha512-orp15PfJvvNDIqJdVWzMI9Sjpjp3VTiw3sfvbB+67LlISTEn8uVT2EdYSuyl02BLvaftv6sdk9Umnxmm5rckmg=="],
|
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||||
|
|
@ -151,26 +133,14 @@
|
||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||||
|
|
||||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
|
||||||
|
|
||||||
"make-dir": ["make-dir@5.1.0", "", {}, "sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw=="],
|
|
||||||
|
|
||||||
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||||
|
|
||||||
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
|
||||||
|
|
||||||
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||||
|
|
||||||
"needle": ["needle@3.5.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, "bin": { "needle": "bin/needle" } }, "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w=="],
|
|
||||||
|
|
||||||
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
|
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
|
||||||
|
|
||||||
"parse-node-version": ["parse-node-version@1.0.1", "", {}, "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA=="],
|
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
@ -179,34 +149,14 @@
|
||||||
|
|
||||||
"prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="],
|
"prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="],
|
||||||
|
|
||||||
"probe-image-size": ["probe-image-size@7.4.0", "", { "dependencies": { "lodash.merge": "^4.6.2", "needle": "^2.5.2", "stream-parser": "~0.3.1" } }, "sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ=="],
|
|
||||||
|
|
||||||
"prr": ["prr@1.0.1", "", {}, "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw=="],
|
|
||||||
|
|
||||||
"rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="],
|
"rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
|
||||||
|
|
||||||
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
|
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"stream-parser": ["stream-parser@0.3.1", "", { "dependencies": { "debug": "2" } }, "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
|
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
|
||||||
|
|
||||||
"probe-image-size/needle": ["needle@2.9.1", "", { "dependencies": { "debug": "^3.2.6", "iconv-lite": "^0.4.4", "sax": "^1.2.4" }, "bin": { "needle": "./bin/needle" } }, "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ=="],
|
|
||||||
|
|
||||||
"stream-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
|
||||||
|
|
||||||
"probe-image-size/needle/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
|
||||||
|
|
||||||
"stream-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
index.html
13
index.html
|
|
@ -9,12 +9,7 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="menubar">
|
<div class="menubar"><b>p/slides</b></div>
|
||||||
<b>p/slides</b>
|
|
||||||
<div class="menubar-actions">
|
|
||||||
<button class="present-button" type="button">Present</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="slidelist">
|
<div class="slidelist">
|
||||||
<div class="slide-list-header">
|
<div class="slide-list-header">
|
||||||
<span>Slides</span>
|
<span>Slides</span>
|
||||||
|
|
@ -24,12 +19,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="code"></div>
|
<div class="code"></div>
|
||||||
<div class="preview"></div>
|
<div class="preview"></div>
|
||||||
<div class="preview">
|
|
||||||
<div class="compile-error" role="alert" hidden></div>
|
|
||||||
<div class="slide">
|
|
||||||
<div class="stage" tabindex="-1"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/less": "^3.0.8",
|
|
||||||
"oxlint": "^1.85.0",
|
"oxlint": "^1.85.0",
|
||||||
"prettier": "^3.9.9",
|
"prettier": "^3.9.9",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
|
@ -21,7 +20,6 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emmet-monaco-es": "^5.7.0",
|
"emmet-monaco-es": "^5.7.0",
|
||||||
"less": "^4.9.1",
|
|
||||||
"monaco-editor": "0.55.1"
|
"monaco-editor": "0.55.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { domToSvgDataUrl } from './dom-to-image'
|
||||||
export interface CodeToElementOptions {
|
export interface CodeToElementOptions {
|
||||||
title?: string
|
title?: string
|
||||||
allowScripts?: boolean
|
allowScripts?: boolean
|
||||||
/** Plain CSS strings — each becomes a `<style>` block in the iframe head. */
|
|
||||||
styles?: string[]
|
styles?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,8 +18,7 @@ export function codeToElement(code: string, options: CodeToElementOptions = {}):
|
||||||
|
|
||||||
iframe.title = options.title ?? 'Rendered code'
|
iframe.title = options.title ?? 'Rendered code'
|
||||||
iframe.setAttribute('sandbox', Array.from(sandbox).join(' '))
|
iframe.setAttribute('sandbox', Array.from(sandbox).join(' '))
|
||||||
const headStyles = (options.styles ?? []).map((css) => `<style>${css}</style>`).join('\n')
|
iframe.srcdoc = `<!doctype html><html><head><meta charset="UTF-8"><style>html,body{margin:0;min-height:100%;}</style>${(options.styles ?? []).join('\n')}</head><body>${code}</body></html>`
|
||||||
iframe.srcdoc = `<!doctype html><html><head><meta charset="UTF-8"><style>html,body{margin:0;min-height:100%;}</style>${headStyles}</head><body>${code}</body></html>`
|
|
||||||
|
|
||||||
return iframe
|
return iframe
|
||||||
}
|
}
|
||||||
|
|
|
||||||
212
src/editor.ts
212
src/editor.ts
|
|
@ -1,207 +1,10 @@
|
||||||
import * as monaco from 'monaco-editor'
|
import * as monaco from 'monaco-editor'
|
||||||
import { emmetCSS, expandAbbreviation } from 'emmet-monaco-es'
|
import { emmetCSS, emmetHTML } from 'emmet-monaco-es'
|
||||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
||||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
|
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
|
||||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
|
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
|
||||||
|
|
||||||
/**
|
emmetHTML(monaco, ['html'])
|
||||||
* Emmet, wired by hand — see the note on the provider below.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Characters Emmet accepts in an abbreviation. `<` is excluded: it opens a tag
|
|
||||||
* or a placeholder, never an abbreviation. `>` is included — it is Emmet's
|
|
||||||
* child combinator (`ul>li*3`).
|
|
||||||
*/
|
|
||||||
const ABBREVIATION_CHAR = /[\w!@#$%^&*()[\]{}:;'"~=+./>\\-]/
|
|
||||||
|
|
||||||
const CLOSERS: Record<string, string> = { '{': '}', '(': ')', '[': ']' }
|
|
||||||
|
|
||||||
/** Matches a `<style>`/`</style>` open or close tag, for depth counting. */
|
|
||||||
const STYLE_TAG = /<\/?style\b[^>]*>/gi
|
|
||||||
|
|
||||||
/** Same trigger set the library used, so the widget appears at the same moments. */
|
|
||||||
const TRIGGER_CHARACTERS = [
|
|
||||||
'!',
|
|
||||||
'.',
|
|
||||||
'}',
|
|
||||||
':',
|
|
||||||
'*',
|
|
||||||
'$',
|
|
||||||
']',
|
|
||||||
'/',
|
|
||||||
'>',
|
|
||||||
'0',
|
|
||||||
'1',
|
|
||||||
'2',
|
|
||||||
'3',
|
|
||||||
'4',
|
|
||||||
'5',
|
|
||||||
'6',
|
|
||||||
'7',
|
|
||||||
'8',
|
|
||||||
'9'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the cursor sits inside a tag, an attribute, or a placeholder.
|
|
||||||
*
|
|
||||||
* Read off the line rather than the tokenizer — that decision is the one the
|
|
||||||
* library got wrong. Scanned only as far as the cursor, so a last `<` beating
|
|
||||||
* the last `>` means "inside": an unfinished tag, or a `<?= html ?>`.
|
|
||||||
*/
|
|
||||||
function insideTag(line: string, column: number): boolean {
|
|
||||||
const before = line.slice(0, column - 1)
|
|
||||||
return before.lastIndexOf('<') > before.lastIndexOf('>')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the cursor is inside a `<style>` element, where the text is Less and
|
|
||||||
* an HTML abbreviation would only produce noise — `margin` would become
|
|
||||||
* `<margin></margin>`, and `10px;` makes Emmet throw.
|
|
||||||
*/
|
|
||||||
function insideStyleElement(model: monaco.editor.ITextModel, position: monaco.Position): boolean {
|
|
||||||
const before = model.getValueInRange({
|
|
||||||
startLineNumber: 1,
|
|
||||||
startColumn: 1,
|
|
||||||
endLineNumber: position.lineNumber,
|
|
||||||
endColumn: position.column
|
|
||||||
})
|
|
||||||
let depth = 0
|
|
||||||
for (const match of before.matchAll(STYLE_TAG)) {
|
|
||||||
depth += match[0][1] === '/' ? -1 : 1
|
|
||||||
}
|
|
||||||
return depth > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The abbreviation ending at the cursor, and the column it starts in.
|
|
||||||
*
|
|
||||||
* `column` is 1-based, as Monaco counts: the characters before the cursor are
|
|
||||||
* `line[0 .. column - 2]`, hence the scan starting a character back from it.
|
|
||||||
* Bracketed runs are kept whole so `p{hello world}` and `a[href="x"]` survive,
|
|
||||||
* the way the library's own scanner handled them.
|
|
||||||
*/
|
|
||||||
function extractAbbreviation(
|
|
||||||
line: string,
|
|
||||||
column: number
|
|
||||||
): { text: string; startColumn: number } | null {
|
|
||||||
if (insideTag(line, column)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const closers: string[] = []
|
|
||||||
let index = column - 2
|
|
||||||
|
|
||||||
while (index >= 0) {
|
|
||||||
const character = line[index]
|
|
||||||
|
|
||||||
if (character === ' ' || character === '\t') {
|
|
||||||
// Legal inside `{…}`, a separator anywhere else.
|
|
||||||
if (closers.length === 0) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else if (character === '}' || character === ')' || character === ']') {
|
|
||||||
closers.push(character)
|
|
||||||
} else if (character === '{' || character === '(' || character === '[') {
|
|
||||||
if (closers.length === 0) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (closers[closers.length - 1] === CLOSERS[character]) {
|
|
||||||
closers.pop()
|
|
||||||
}
|
|
||||||
} else if (closers.length === 0 && !ABBREVIATION_CHAR.test(character)) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
index--
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stray `}` with no opener behind it lets the scan walk back over the
|
|
||||||
// indentation, so leading whitespace is dropped rather than matched.
|
|
||||||
let start = index + 1
|
|
||||||
while (start < column - 1 && (line[start] === ' ' || line[start] === '\t')) {
|
|
||||||
start++
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cursor may sit in text while the scan has walked back into a tag —
|
|
||||||
// `class="parent">` is reachable from the end of `<div class="parent">`.
|
|
||||||
// The candidate has to be outside a tag at *both* ends.
|
|
||||||
if (insideTag(line, start + 1)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = line.slice(start, column - 1)
|
|
||||||
return text.length > 0 ? { text, startColumn: start + 1 } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emmet, wired by hand.
|
|
||||||
*
|
|
||||||
* `emmetHTML` decides where Emmet may expand by asking the tokenizer for the
|
|
||||||
* state of the line *before* the cursor — `getBeginState(lineNumber - 1)`
|
|
||||||
* against a 1-based API. That is wrong twice over. It returns `null` on line 1
|
|
||||||
* and the library clones the result unconditionally, so the first expansion
|
|
||||||
* throws `Cannot read properties of null (reading 'clone')`; and below a
|
|
||||||
* `<style>` block it hands the HTML tokenizer the CSS state from inside the
|
|
||||||
* block, so nothing expands anywhere after it — which is why the global slide
|
|
||||||
* went dead while ordinary slides worked. Its `tokenizer: 'standard'` escape
|
|
||||||
* hatch is no help on this Monaco: it calls `model.tokenization.getLineTokens`,
|
|
||||||
* minified to `.n` in 0.55, so the library's feature-detect fails and quietly
|
|
||||||
* falls back to the same broken path.
|
|
||||||
*
|
|
||||||
* Expansion itself is sound and exported, so only the location check moves here:
|
|
||||||
* read the abbreviation back off the line, refuse inside a tag, expand. The
|
|
||||||
* result is inserted as plain text — `expandAbbreviation` normalises its own
|
|
||||||
* `${…}` tabstops away, so snippet mode would buy nothing and would mangle
|
|
||||||
* author text containing `$1`.
|
|
||||||
*/
|
|
||||||
monaco.languages.registerCompletionItemProvider('html', {
|
|
||||||
triggerCharacters: TRIGGER_CHARACTERS,
|
|
||||||
provideCompletionItems(model, position) {
|
|
||||||
if (insideStyleElement(model, position)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = extractAbbreviation(
|
|
||||||
model.getLineContent(position.lineNumber),
|
|
||||||
position.column
|
|
||||||
)
|
|
||||||
if (!found) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let expanded: string
|
|
||||||
try {
|
|
||||||
expanded = expandAbbreviation(found.text, { type: 'markup', syntax: 'html' })
|
|
||||||
} catch {
|
|
||||||
// A bad abbreviation must never take completion — or the editor —
|
|
||||||
// down with it.
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
// Expanding to the abbreviation itself, or to nothing, is just noise.
|
|
||||||
if (!expanded || expanded === found.text) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
label: found.text,
|
|
||||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
|
||||||
insertText: expanded,
|
|
||||||
range: new monaco.Range(
|
|
||||||
position.lineNumber,
|
|
||||||
found.startColumn,
|
|
||||||
position.lineNumber,
|
|
||||||
position.column
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
emmetCSS(monaco, ['css', 'scss', 'less'])
|
emmetCSS(monaco, ['css', 'scss', 'less'])
|
||||||
|
|
||||||
self.MonacoEnvironment = {
|
self.MonacoEnvironment = {
|
||||||
|
|
@ -218,18 +21,11 @@ self.MonacoEnvironment = {
|
||||||
|
|
||||||
const MONACO_PARENT = '.code'
|
const MONACO_PARENT = '.code'
|
||||||
|
|
||||||
/**
|
export function createEditor() {
|
||||||
* One buffer, one model. A slide is HTML with an optional `<style>` tag and
|
|
||||||
* the Less lives inside that tag, so there is nothing to switch between. The
|
|
||||||
* CSS/LESS workers above still highlight, validate and complete that Less.
|
|
||||||
*/
|
|
||||||
function createEditor(): monaco.editor.IStandaloneCodeEditor {
|
|
||||||
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
|
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
|
||||||
value: '',
|
value: 'test',
|
||||||
language: 'html',
|
language: 'html',
|
||||||
theme: 'vs-dark',
|
theme: 'vs-dark',
|
||||||
automaticLayout: true
|
automaticLayout: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const editor = createEditor()
|
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
import { initialize } from 'monaco-editor/esm/vs/common/initialize.js'
|
|
||||||
import {
|
|
||||||
getLanguageService,
|
|
||||||
newHTMLDataProvider
|
|
||||||
} from 'monaco-editor/esm/external/vscode-html-languageservice/lib/esm/htmlLanguageService.js'
|
|
||||||
import { getLESSLanguageService } from 'monaco-editor/esm/external/vscode-css-languageservice/lib/esm/cssLanguageService.js'
|
|
||||||
import { TextDocument } from 'monaco-editor/esm/external/vscode-languageserver-textdocument/lib/esm/main.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The HTML worker, with Less wired into `<style>`.
|
|
||||||
*
|
|
||||||
* Monaco's own HTML worker never gives you Less completions. Upstream's
|
|
||||||
* `embeddedSupport` — the `getDefaultEmbeddedSupport` that VS Code's
|
|
||||||
* `vscode-html-language-features` extension uses — is absent from the copy
|
|
||||||
* monaco vendors, and so is the `document.stylesheets` model it walks; the
|
|
||||||
* `setCompletionParticipants` hook survives but nothing behind it does. So this
|
|
||||||
* does the dispatch itself: a position inside a `<style>` element goes to the
|
|
||||||
* Less language service, everything else to HTML as before.
|
|
||||||
*
|
|
||||||
* Always Less, never CSS, whatever the tag says. That matches the app: every
|
|
||||||
* `<style>` block is compiled by the Less compiler regardless of its attributes
|
|
||||||
* (`extractStyles` only ever hands the tag's text to Less), so a `lang="css"`
|
|
||||||
* hint would only be a lie.
|
|
||||||
*
|
|
||||||
* The virtual document holds just the stylesheet's text, and every coordinate
|
|
||||||
* crosses between the two documents through `offsetAt`/`positionAt` rather than
|
|
||||||
* hand-rolled line arithmetic — a `<style>` that opens mid-line is the case that
|
|
||||||
* breaks naive line maths.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Position {
|
|
||||||
line: number
|
|
||||||
character: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Range {
|
|
||||||
start: Position
|
|
||||||
end: Position
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The text between a `<style>` element's tags, as offsets into the document. */
|
|
||||||
interface StyleBlock {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const STYLE_OPEN = /<style\b[^>]*>/gi
|
|
||||||
const STYLE_CLOSE = /<\/style\s*>/gi
|
|
||||||
|
|
||||||
/** Every `<style>` element's content range, in the order they appear. */
|
|
||||||
function findStyleBlocks(text: string): StyleBlock[] {
|
|
||||||
const blocks: StyleBlock[] = []
|
|
||||||
STYLE_OPEN.lastIndex = 0
|
|
||||||
for (let open = STYLE_OPEN.exec(text); open; open = STYLE_OPEN.exec(text)) {
|
|
||||||
const contentStart = open.index + open[0].length
|
|
||||||
STYLE_CLOSE.lastIndex = contentStart
|
|
||||||
const close = STYLE_CLOSE.exec(text)
|
|
||||||
if (!close) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
blocks.push({ start: contentStart, end: close.index })
|
|
||||||
// Continue past the close tag so a following block is still found.
|
|
||||||
STYLE_OPEN.lastIndex = close.index + close[0].length
|
|
||||||
}
|
|
||||||
return blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The stylesheet the position is in, or null when it is in ordinary markup. */
|
|
||||||
function styleBlockAt(blocks: StyleBlock[], offset: number): StyleBlock | null {
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (offset >= block.start && offset <= block.end) {
|
|
||||||
return block
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A range from the stylesheet, in the HTML document's coordinates.
|
|
||||||
*
|
|
||||||
* Both documents do their own arithmetic: `lessDocument.offsetAt` gives the
|
|
||||||
* offset within the stylesheet text, `document.positionAt` turns the shifted
|
|
||||||
* offset back into a line/character in the HTML buffer.
|
|
||||||
*/
|
|
||||||
function toDocumentRange(document, lessDocument, block: StyleBlock, range: Range): Range {
|
|
||||||
return {
|
|
||||||
start: document.positionAt(block.start + lessDocument.offsetAt(range.start)),
|
|
||||||
end: document.positionAt(block.start + lessDocument.offsetAt(range.end))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PsSlidesHtmlWorker {
|
|
||||||
private readonly ctx
|
|
||||||
private readonly languageSettings
|
|
||||||
private readonly languageId
|
|
||||||
private readonly htmlLanguageService
|
|
||||||
private readonly lessLanguageService
|
|
||||||
|
|
||||||
constructor(ctx, createData) {
|
|
||||||
this.ctx = ctx
|
|
||||||
this.languageSettings = createData.languageSettings
|
|
||||||
this.languageId = createData.languageId
|
|
||||||
const data = this.languageSettings.data
|
|
||||||
const customDataProviders = []
|
|
||||||
if (data?.dataProviders) {
|
|
||||||
for (const id in data.dataProviders) {
|
|
||||||
customDataProviders.push(newHTMLDataProvider(id, data.dataProviders[id]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.htmlLanguageService = getLanguageService({
|
|
||||||
useDefaultDataProvider: data?.useDefaultDataProvider,
|
|
||||||
customDataProviders
|
|
||||||
})
|
|
||||||
this.lessLanguageService = getLESSLanguageService()
|
|
||||||
}
|
|
||||||
|
|
||||||
async doComplete(uri, position) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const block = styleBlockAt(findStyleBlocks(document.getText()), document.offsetAt(position))
|
|
||||||
if (block) {
|
|
||||||
return this.lessComplete(document, position, block)
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
this.htmlLanguageService.doComplete(
|
|
||||||
document,
|
|
||||||
position,
|
|
||||||
this.htmlLanguageService.parseHTMLDocument(document),
|
|
||||||
this.languageSettings?.suggest
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Completions from the Less service, with every edit translated back into
|
|
||||||
* the HTML document's coordinates.
|
|
||||||
*/
|
|
||||||
lessComplete(document, position, block) {
|
|
||||||
const lessDocument = this.lessDocument(document, block)
|
|
||||||
const result = this.lessLanguageService.doComplete(
|
|
||||||
lessDocument,
|
|
||||||
lessDocument.positionAt(document.offsetAt(position) - block.start),
|
|
||||||
null,
|
|
||||||
this.languageSettings?.suggest
|
|
||||||
)
|
|
||||||
if (!result) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
isIncomplete: result.isIncomplete,
|
|
||||||
items: result.items.map((item) => ({
|
|
||||||
...item,
|
|
||||||
textEdit: item.textEdit
|
|
||||||
? {
|
|
||||||
range: toDocumentRange(document, lessDocument, block, item.textEdit.range),
|
|
||||||
newText: item.textEdit.newText
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async doHover(uri, position) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const block = styleBlockAt(findStyleBlocks(document.getText()), document.offsetAt(position))
|
|
||||||
if (block) {
|
|
||||||
const lessDocument = this.lessDocument(document, block)
|
|
||||||
const hover = this.lessLanguageService.doHover(
|
|
||||||
lessDocument,
|
|
||||||
lessDocument.positionAt(document.offsetAt(position) - block.start),
|
|
||||||
null,
|
|
||||||
this.languageSettings?.suggest
|
|
||||||
)
|
|
||||||
if (hover) {
|
|
||||||
return { ...hover, range: toDocumentRange(document, lessDocument, block, hover.range) }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
this.htmlLanguageService.doHover(
|
|
||||||
document,
|
|
||||||
position,
|
|
||||||
this.htmlLanguageService.parseHTMLDocument(document)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The stylesheet's own text, as a document the Less service can read. */
|
|
||||||
private lessDocument(document, block: StyleBlock) {
|
|
||||||
return TextDocument.create(
|
|
||||||
document.uri,
|
|
||||||
'less',
|
|
||||||
document.version,
|
|
||||||
document.getText().slice(block.start, block.end)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async format(uri, range, options) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
this.htmlLanguageService.format(document, range, {
|
|
||||||
...this.languageSettings.format,
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async findDocumentHighlights(uri, position) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
this.htmlLanguageService.findDocumentHighlights(
|
|
||||||
document,
|
|
||||||
position,
|
|
||||||
this.htmlLanguageService.parseHTMLDocument(document)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async findDocumentLinks(uri) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Promise.resolve(this.htmlLanguageService.findDocumentLinks(document, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
async findDocumentSymbols(uri) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
this.htmlLanguageService.findDocumentSymbols(
|
|
||||||
document,
|
|
||||||
this.htmlLanguageService.parseHTMLDocument(document)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFoldingRanges(uri, context) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Promise.resolve(this.htmlLanguageService.getFoldingRanges(document, context))
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSelectionRanges(uri, positions) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
this.htmlLanguageService.getSelectionRanges(
|
|
||||||
document,
|
|
||||||
positions,
|
|
||||||
this.htmlLanguageService.parseHTMLDocument(document)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async doRename(uri, position, newName) {
|
|
||||||
const document = this.getTextDocument(uri)
|
|
||||||
if (!document) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
this.htmlLanguageService.doRename(
|
|
||||||
document,
|
|
||||||
position,
|
|
||||||
newName,
|
|
||||||
this.htmlLanguageService.parseHTMLDocument(document)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTextDocument(uri) {
|
|
||||||
const models = this.ctx.getMirrorModels()
|
|
||||||
for (const model of models) {
|
|
||||||
if (model.uri.toString() === uri) {
|
|
||||||
return TextDocument.create(uri, this.languageId, model.version, model.getValue())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.onmessage = () => {
|
|
||||||
initialize((ctx, createData) => new PsSlidesHtmlWorker(ctx, createData))
|
|
||||||
}
|
|
||||||
289
src/main.ts
289
src/main.ts
|
|
@ -1,70 +1,28 @@
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import { editor } from './editor'
|
import { createEditor } from './editor'
|
||||||
import { createCodeThumbnail } from './code-to-element'
|
import { createCodeThumbnail } from './code-to-element'
|
||||||
import {
|
|
||||||
compilePresentation,
|
|
||||||
extractStyles,
|
|
||||||
slideId,
|
|
||||||
type CompileInput,
|
|
||||||
type CompiledPresentation
|
|
||||||
} from './slides/compile'
|
|
||||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './slides/placeholders'
|
|
||||||
import { createRuntime, type Runtime } from './slides/runtime'
|
|
||||||
|
|
||||||
interface Slide {
|
interface Slide {
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slides start empty: the author writes the markup and the Less themselves.
|
|
||||||
// Less written inside a slide's `<style>` tag is scoped to that slide, the
|
|
||||||
// global slide's styles are shared by every slide, and `:thisSlide` is the
|
|
||||||
// slide's own section — no extra CSS syntax beyond that.
|
|
||||||
|
|
||||||
// Sample markup standing in for a real slide. The global-slide tile and the
|
|
||||||
// preview while the global slide is selected both show it, so the author can
|
|
||||||
// see what the global slide wraps instead of a blank frame.
|
|
||||||
const EXAMPLE_CONTENT = '<h1>p/slides</h1><p>This is a paragraph.</p>'
|
|
||||||
|
|
||||||
const EXAMPLE_SECTION = `<section data-ps-slide="${slideId(0)}" data-ps-state="current">${EXAMPLE_CONTENT}</section>`
|
|
||||||
|
|
||||||
const EXAMPLE_FRAME = `${EXAMPLE_SECTION}`
|
|
||||||
|
|
||||||
let globalSlide = ''
|
let globalSlide = ''
|
||||||
|
|
||||||
const slides: Slide[] = [
|
const slides: Slide[] = [
|
||||||
{
|
{
|
||||||
code: ''
|
code: '<h1>test</h1>'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const editor = createEditor()
|
||||||
const slideList = document.querySelector<HTMLElement>('.slide-list-items')!
|
const slideList = document.querySelector<HTMLElement>('.slide-list-items')!
|
||||||
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
|
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
|
||||||
const stage = document.querySelector<HTMLElement>('.stage')!
|
|
||||||
const slide = document.querySelector<HTMLElement>('.slide')!
|
|
||||||
const compileError = document.querySelector<HTMLElement>('.compile-error')!
|
|
||||||
const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
|
|
||||||
let currentSlide = 0
|
let currentSlide = 0
|
||||||
let draggedSlide: number | null = null
|
let draggedSlide: number | null = null
|
||||||
let globalSlideElement: HTMLElement | null = null
|
let globalSlideElement: HTMLElement | null = null
|
||||||
const slideElements = new Map<Slide, HTMLElement>()
|
const slideElements = new Map<Slide, HTMLElement>()
|
||||||
const thumbnailRequests = new Map<number, number>()
|
const thumbnailRequests = new Map<number, number>()
|
||||||
let thumbnailDebounceTimer: number | undefined
|
let thumbnailDebounceTimer: number | undefined
|
||||||
// The last good compile of the deck: the preview shows it, and thumbnails read
|
|
||||||
// the stylesheet from here, so a tile is rendered exactly as the presentation
|
|
||||||
// renders it.
|
|
||||||
let lastResult: CompiledPresentation | null = null
|
|
||||||
// Bumped by every full list rebuild; async thumbnail builders check it so a
|
|
||||||
// stale build can never commit over a newer one.
|
|
||||||
let listGeneration = 0
|
|
||||||
|
|
||||||
// The compiled stylesheet is the runtime's business: it lands at the global
|
|
||||||
// slide's `<?= css ?>`, or in a `data-ps-slides` sheet in the head when the
|
|
||||||
// scene has no injection point.
|
|
||||||
let runtime: Runtime | null = null
|
|
||||||
let compileDebounceTimer: number | undefined
|
|
||||||
// Set while we push text into Monaco ourselves, so programmatic changes are
|
|
||||||
// never mistaken for the author typing.
|
|
||||||
let applyingToEditor = false
|
|
||||||
|
|
||||||
function updateSelection() {
|
function updateSelection() {
|
||||||
slideList.querySelectorAll<HTMLElement>('.slide-list-item').forEach((element) => {
|
slideList.querySelectorAll<HTMLElement>('.slide-list-item').forEach((element) => {
|
||||||
|
|
@ -73,165 +31,15 @@ function updateSelection() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEditorText(text: string) {
|
|
||||||
applyingToEditor = true
|
|
||||||
try {
|
|
||||||
editor.getModel()?.setValue(text)
|
|
||||||
} finally {
|
|
||||||
applyingToEditor = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSlideInEditor(index: number) {
|
|
||||||
setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSlide(index: number) {
|
function setSlide(index: number) {
|
||||||
if (index !== -1 && !slides[index]) {
|
const slide = index !== -1 ? slides[index] : { code: globalSlide }
|
||||||
|
if (!slide) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasGlobal = currentSlide === -1
|
|
||||||
currentSlide = index
|
currentSlide = index
|
||||||
slide.classList.toggle('global', index === -1)
|
editor.getModel()?.setValue(slide.code)
|
||||||
showSlideInEditor(index)
|
|
||||||
updateSelection()
|
updateSelection()
|
||||||
|
|
||||||
if (wasGlobal !== (index === -1)) {
|
|
||||||
// The two selections compile different things, so the runtime is
|
|
||||||
// holding the wrong one. Rebuild now rather than on the debounce, and
|
|
||||||
// swap the deck before moving: goTo against the other deck would clamp
|
|
||||||
// and report the clamp back through onCurrentChange.
|
|
||||||
void rebuildPreview()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The global slide is shared markup, not a step of the presentation, so it
|
|
||||||
// never moves the runtime.
|
|
||||||
if (index >= 0) {
|
|
||||||
runtime?.goTo(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCompileError(message: string) {
|
|
||||||
compileError.textContent = message
|
|
||||||
compileError.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleCompile() {
|
|
||||||
if (compileDebounceTimer !== undefined) {
|
|
||||||
window.clearTimeout(compileDebounceTimer)
|
|
||||||
}
|
|
||||||
compileDebounceTimer = window.setTimeout(() => {
|
|
||||||
compileDebounceTimer = undefined
|
|
||||||
void rebuildPreview()
|
|
||||||
}, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The global slide is shared markup, not a step of the presentation, so while
|
|
||||||
* it is selected the preview shows it wrapped around the example content
|
|
||||||
* instead of one of the real slides. That is a separate compile from the deck —
|
|
||||||
* and the deck's is needed regardless, because the tiles mirror it.
|
|
||||||
*/
|
|
||||||
function deckInput(): CompileInput {
|
|
||||||
return { slides: slides.map((slide) => slide.code), global: globalSlide }
|
|
||||||
}
|
|
||||||
|
|
||||||
function exampleInput(): CompileInput {
|
|
||||||
return { slides: [EXAMPLE_CONTENT], global: globalSlide }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put a compile on screen. Synchronous, so the deck lands the moment its
|
|
||||||
* compile resolves.
|
|
||||||
*/
|
|
||||||
function showCompiled(result: CompiledPresentation): void {
|
|
||||||
runtime?.setTemplate(result.template)
|
|
||||||
// The global-slide preview is a one-slide stand-in: keep the real
|
|
||||||
// current slide in history so returning to the deck lands where it was.
|
|
||||||
runtime?.setSlides(result.slides, { keepIndex: currentSlide === -1 })
|
|
||||||
runtime?.setCss(result.css)
|
|
||||||
if (currentSlide >= 0) {
|
|
||||||
runtime?.goTo(currentSlide)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rebuildPreview(): Promise<void> {
|
|
||||||
const showingGlobal = currentSlide === -1
|
|
||||||
try {
|
|
||||||
const result = await compilePresentation(deckInput())
|
|
||||||
const example = showingGlobal ? await compilePresentation(exampleInput()) : null
|
|
||||||
|
|
||||||
compileError.hidden = true
|
|
||||||
compileError.textContent = ''
|
|
||||||
|
|
||||||
if (!runtime) {
|
|
||||||
runtime = createRuntime(stage, result.slides, {
|
|
||||||
template: result.template,
|
|
||||||
onCurrentChange: (index) => setSlide(index)
|
|
||||||
})
|
|
||||||
// A reload restores the slide you were on (history state): open
|
|
||||||
// the editor and the selection there too.
|
|
||||||
setSlide(runtime.current)
|
|
||||||
}
|
|
||||||
// This pass's own output is what the preview shows: the global slide
|
|
||||||
// gets the stand-in, everything else the deck.
|
|
||||||
showCompiled(currentSlide === -1 ? (example ?? result) : result)
|
|
||||||
|
|
||||||
// Tiles embed the stylesheet (and, in template mode, the frame), so a
|
|
||||||
// change to either makes every tile stale. The first compile builds
|
|
||||||
// the list itself; render() only builds it when that compile failed.
|
|
||||||
const previous = lastResult
|
|
||||||
lastResult = result
|
|
||||||
if (!previous || previous.css !== result.css || previous.template !== result.template) {
|
|
||||||
void setSlideList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Half-typed Less should not blank the preview: keep the last good
|
|
||||||
// output on screen and report what broke.
|
|
||||||
showCompileError(error instanceof Error ? error.message : String(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compose a tile exactly as the runtime composes the real thing: the slide in
|
|
||||||
* its `<section data-ps-slide>` with state `current` (so the compiled state
|
|
||||||
* rules show the settled look), inside the global template when there is one,
|
|
||||||
* with the shared markup prepended when there is not. The global tile mounts
|
|
||||||
* an example slide in the frame instead — it must not show another real
|
|
||||||
* slide's content, which would go stale whenever that slide is edited.
|
|
||||||
*/
|
|
||||||
function buildThumbnailHtml(index: number, code: string): string {
|
|
||||||
const global = extractStyles(globalSlide).html
|
|
||||||
const isTemplate = HTML_PLACEHOLDER.test(global)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
if (!global.trim()) {
|
|
||||||
return EXAMPLE_FRAME
|
|
||||||
}
|
|
||||||
if (isTemplate) {
|
|
||||||
return global
|
|
||||||
.split(HTML_PLACEHOLDER)
|
|
||||||
.join(EXAMPLE_SECTION)
|
|
||||||
.split(CSS_PLACEHOLDER)
|
|
||||||
.join('')
|
|
||||||
}
|
|
||||||
// Shared markup: it wraps every slide, so show it around the example
|
|
||||||
// rather than on its own, which would render no slide at all.
|
|
||||||
return `${global}\n${EXAMPLE_CONTENT}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const extracted = extractStyles(code).html
|
|
||||||
let inner = extracted
|
|
||||||
if (!isTemplate && global) {
|
|
||||||
inner = `${global}\n${extracted}`
|
|
||||||
}
|
|
||||||
const section = `<section data-ps-slide="${slideId(index)}" data-ps-state="current">${inner}</section>`
|
|
||||||
if (isTemplate) {
|
|
||||||
return global.split(HTML_PLACEHOLDER).join(section).split(CSS_PLACEHOLDER).join('')
|
|
||||||
}
|
|
||||||
return section
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateSlideThumbnail(slide: Slide, i: number) {
|
async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
|
|
@ -242,6 +50,7 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
slideElement.classList.add('global-slide')
|
slideElement.classList.add('global-slide')
|
||||||
} else {
|
} else {
|
||||||
slideElement.draggable = true
|
slideElement.draggable = true
|
||||||
|
slideElements.set(slide, slideElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
const slideInList = document.createElement('button')
|
const slideInList = document.createElement('button')
|
||||||
|
|
@ -256,11 +65,7 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
slideNumber.classList.add('slide-number')
|
slideNumber.classList.add('slide-number')
|
||||||
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
|
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
|
||||||
|
|
||||||
// The tile mirrors the presentation: composed markup, plus the compiled
|
const thumbnail = await createCodeThumbnail(slide.code)
|
||||||
// stylesheet injected into the thumbnail document's head.
|
|
||||||
const thumbnail = await createCodeThumbnail(buildThumbnailHtml(i, slide.code), {
|
|
||||||
styles: lastResult?.css ? [lastResult.css] : []
|
|
||||||
})
|
|
||||||
slideInList.append(slideNumber, thumbnail)
|
slideInList.append(slideNumber, thumbnail)
|
||||||
slideElement.append(slideInList)
|
slideElement.append(slideInList)
|
||||||
|
|
||||||
|
|
@ -276,6 +81,10 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
slideElement.append(deleteButton)
|
slideElement.append(deleteButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (i === -1) {
|
||||||
|
globalSlideElement = slideElement
|
||||||
|
}
|
||||||
|
|
||||||
if (i === currentSlide) {
|
if (i === currentSlide) {
|
||||||
slideElement.classList.add('selected')
|
slideElement.classList.add('selected')
|
||||||
}
|
}
|
||||||
|
|
@ -286,12 +95,9 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
async function refreshThumbnail(index: number, slide: Slide) {
|
async function refreshThumbnail(index: number, slide: Slide) {
|
||||||
const request = (thumbnailRequests.get(index) ?? 0) + 1
|
const request = (thumbnailRequests.get(index) ?? 0) + 1
|
||||||
thumbnailRequests.set(index, request)
|
thumbnailRequests.set(index, request)
|
||||||
const generation = listGeneration
|
|
||||||
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
|
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
|
||||||
const slideElement = await generateSlideThumbnail(slide, index)
|
const slideElement = await generateSlideThumbnail(slide, index)
|
||||||
// A newer refresh or a full rebuild superseded this one while it rendered;
|
if (thumbnailRequests.get(index) !== request) {
|
||||||
// a stale tile must never replace a fresh one.
|
|
||||||
if (generation !== listGeneration || thumbnailRequests.get(index) !== request) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,31 +133,15 @@ async function mapWithConcurrency<T, R>(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSlideList() {
|
async function setSlideList() {
|
||||||
const generation = ++listGeneration
|
|
||||||
const allSlides = [{ code: globalSlide }, ...slides]
|
const allSlides = [{ code: globalSlide }, ...slides]
|
||||||
|
slideList.replaceChildren()
|
||||||
|
slideElements.clear()
|
||||||
|
globalSlideElement = null
|
||||||
|
|
||||||
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
|
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
|
||||||
generateSlideThumbnail(slide, index - 1)
|
generateSlideThumbnail(slide, index - 1)
|
||||||
)
|
)
|
||||||
// Commit atomically: the old list stays interactive while tiles render.
|
slideList.append(...elements)
|
||||||
const deckUnchanged =
|
|
||||||
allSlides.length === slides.length + 1 &&
|
|
||||||
allSlides[0].code === globalSlide &&
|
|
||||||
allSlides.slice(1).every((slide, offset) => slide === slides[offset])
|
|
||||||
if (generation !== listGeneration) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!deckUnchanged) {
|
|
||||||
// A slide was added, removed or moved mid-render; this snapshot is
|
|
||||||
// stale, and no other rebuild is coming — start a fresh one.
|
|
||||||
void setSlideList()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slideList.replaceChildren(...elements)
|
|
||||||
slideElements.clear()
|
|
||||||
globalSlideElement = elements[0] ?? null
|
|
||||||
elements.slice(1).forEach((element, offset) => {
|
|
||||||
slideElements.set(allSlides[offset + 1], element)
|
|
||||||
})
|
|
||||||
updateSelection()
|
updateSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,8 +189,7 @@ function moveSlide(from: number, to: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlideOrder()
|
updateSlideOrder()
|
||||||
showSlideInEditor(currentSlide)
|
editor.getModel()?.setValue(slides[currentSlide]?.code ?? globalSlide)
|
||||||
scheduleCompile()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSlide(index: number) {
|
function deleteSlide(index: number) {
|
||||||
|
|
@ -420,40 +209,15 @@ function deleteSlide(index: number) {
|
||||||
|
|
||||||
updateSlideOrder()
|
updateSlideOrder()
|
||||||
setSlide(currentSlide)
|
setSlide(currentSlide)
|
||||||
scheduleCompile()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addSlideButton.addEventListener('click', async () => {
|
addSlideButton.addEventListener('click', async () => {
|
||||||
const slide = { code: '' }
|
const slide = { code: '' }
|
||||||
slides.push(slide)
|
slides.push(slide)
|
||||||
setSlide(slides.length - 1)
|
|
||||||
scheduleCompile()
|
|
||||||
|
|
||||||
const generation = listGeneration
|
|
||||||
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
|
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
|
||||||
// A full rebuild started meanwhile; its snapshot already has this slide.
|
|
||||||
if (generation !== listGeneration) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slideElements.set(slide, slideElement)
|
|
||||||
slideList.append(slideElement)
|
slideList.append(slideElement)
|
||||||
updateSlideOrder()
|
updateSlideOrder()
|
||||||
})
|
setSlide(slides.length - 1)
|
||||||
|
|
||||||
presentButton.addEventListener('click', () => {
|
|
||||||
slide.requestFullscreen().catch(() => {
|
|
||||||
// Browsers can refuse fullscreen; presenting inside the panel still works.
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', () => {
|
|
||||||
if (document.fullscreenElement === stage) {
|
|
||||||
// Keys must reach the runtime, not the editor we just left behind.
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
|
||||||
document.activeElement.blur()
|
|
||||||
}
|
|
||||||
stage.focus()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
slideList.addEventListener('dragstart', (event) => {
|
slideList.addEventListener('dragstart', (event) => {
|
||||||
|
|
@ -488,10 +252,6 @@ slideList.addEventListener('dragend', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
editor.onDidChangeModelContent(() => {
|
editor.onDidChangeModelContent(() => {
|
||||||
if (applyingToEditor) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = editor.getValue()
|
const code = editor.getValue()
|
||||||
const slideIndex = currentSlide
|
const slideIndex = currentSlide
|
||||||
const slide = slideIndex === -1 ? { code } : slides[slideIndex]
|
const slide = slideIndex === -1 ? { code } : slides[slideIndex]
|
||||||
|
|
@ -501,8 +261,6 @@ editor.onDidChangeModelContent(() => {
|
||||||
slides[slideIndex].code = code
|
slides[slideIndex].code = code
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleCompile()
|
|
||||||
|
|
||||||
if (thumbnailDebounceTimer !== undefined) {
|
if (thumbnailDebounceTimer !== undefined) {
|
||||||
window.clearTimeout(thumbnailDebounceTimer)
|
window.clearTimeout(thumbnailDebounceTimer)
|
||||||
}
|
}
|
||||||
|
|
@ -513,13 +271,8 @@ editor.onDidChangeModelContent(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
setSlide(currentSlide)
|
|
||||||
await rebuildPreview()
|
|
||||||
// A successful first compile builds the list itself; this is only the
|
|
||||||
// fallback when even the boot compile failed.
|
|
||||||
if (!lastResult) {
|
|
||||||
await setSlideList()
|
await setSlideList()
|
||||||
}
|
setSlide(currentSlide)
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
|
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
import LessWorker from './less-worker?worker'
|
|
||||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Less runs in a worker so a heavy stylesheet never blocks the editor. The
|
|
||||||
* worker is an optimisation, not a requirement: if it fails to start or dies
|
|
||||||
* mid-render, everything still in flight (and every later render) falls back
|
|
||||||
* to an on-demand `import('less')` on the main thread — which also keeps the
|
|
||||||
* big Less bundle out of the initial page load.
|
|
||||||
*/
|
|
||||||
interface RenderTask {
|
|
||||||
source: string
|
|
||||||
resolve: (css: string) => void
|
|
||||||
reject: (error: Error) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reply the worker posts back: either the CSS or the failure message. */
|
|
||||||
interface RenderReply {
|
|
||||||
id: number
|
|
||||||
css?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderTasks = new Map<number, RenderTask>()
|
|
||||||
let nextRenderTask = 0
|
|
||||||
/** `undefined` until first use, then the worker — or `null` once degraded. */
|
|
||||||
let lessWorker: Worker | null | undefined
|
|
||||||
|
|
||||||
function renderInline(source: string): Promise<string> {
|
|
||||||
// The same auto-init as in the worker: pre-set `onReady: false` (the init
|
|
||||||
// copies `window.less` before its own defaults) so importing less does
|
|
||||||
// not hide `<body>` or rescan the app's stylesheets just because we only
|
|
||||||
// wanted its compiler.
|
|
||||||
if (typeof window !== 'undefined' && !('less' in window)) {
|
|
||||||
;(window as unknown as { less?: unknown }).less = { onReady: false }
|
|
||||||
}
|
|
||||||
return import('less').then(async (module) => {
|
|
||||||
const { css } = await module.default.render(source)
|
|
||||||
return css
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The worker is gone: re-run everything pending on the main thread. */
|
|
||||||
function degradeWorker(): void {
|
|
||||||
if (!lessWorker) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lessWorker.terminate()
|
|
||||||
lessWorker = null
|
|
||||||
const pending = Array.from(renderTasks.values())
|
|
||||||
renderTasks.clear()
|
|
||||||
for (const task of pending) {
|
|
||||||
renderInline(task.source).then(task.resolve).catch(task.reject)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLessWorker(): Worker | null {
|
|
||||||
if (lessWorker !== undefined) {
|
|
||||||
return lessWorker
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const worker = new LessWorker()
|
|
||||||
worker.addEventListener('message', (event: MessageEvent<RenderReply>) => {
|
|
||||||
const task = renderTasks.get(event.data.id)
|
|
||||||
if (!task) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
renderTasks.delete(event.data.id)
|
|
||||||
if (event.data.error !== undefined) {
|
|
||||||
task.reject(new Error(event.data.error))
|
|
||||||
} else {
|
|
||||||
task.resolve(event.data.css ?? '')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// Uncaught worker failure (bundle load, environment): drop to inline.
|
|
||||||
worker.addEventListener('error', (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
degradeWorker()
|
|
||||||
})
|
|
||||||
lessWorker = worker
|
|
||||||
} catch {
|
|
||||||
lessWorker = null
|
|
||||||
}
|
|
||||||
return lessWorker
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLess(source: string): Promise<string> {
|
|
||||||
const worker = getLessWorker()
|
|
||||||
if (!worker) {
|
|
||||||
return renderInline(source)
|
|
||||||
}
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
const id = ++nextRenderTask
|
|
||||||
renderTasks.set(id, { source, resolve, reject })
|
|
||||||
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
|
||||||
worker.postMessage({ id, source })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A slide's stable id. Deterministic for a given position, so recompiling the
|
|
||||||
* project (on every keystroke) never renames a slide underneath the runtime.
|
|
||||||
*/
|
|
||||||
export function slideId(index: number): string {
|
|
||||||
return `slide-${index}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function slideSelector(id: string): string {
|
|
||||||
return `[data-ps-slide="${id}"]`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What `:thisSlide` means inside the global slide's styles: any slide. */
|
|
||||||
const ANY_SLIDE_SELECTOR = '[data-ps-slide]'
|
|
||||||
|
|
||||||
const THIS_SLIDE = ':thisSlide'
|
|
||||||
|
|
||||||
// `<?= … ?>` is a bogus comment to the HTML parser, so a placeholder would not
|
|
||||||
// survive the DOM round-trip inside extractStyles. Hide the placeholders as
|
|
||||||
// plain text while parsing, put them back afterwards.
|
|
||||||
const HTML_SENTINEL = '__PSHTML__'
|
|
||||||
const CSS_SENTINEL = '__PSCSS__'
|
|
||||||
|
|
||||||
export interface CompiledSlide {
|
|
||||||
/** Stable id, used as the value of the slide's `data-ps-slide` attribute. */
|
|
||||||
id: string
|
|
||||||
index: number
|
|
||||||
html: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompileInput {
|
|
||||||
/** HTML source of every slide, in presentation order. */
|
|
||||||
slides: string[]
|
|
||||||
/**
|
|
||||||
* The global slide: either markup shared by every slide, or a template
|
|
||||||
* carrying `<?= html ?>` / `<?= css ?>` for the runtime to fill in.
|
|
||||||
*/
|
|
||||||
global?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompiledPresentation {
|
|
||||||
slides: CompiledSlide[]
|
|
||||||
css: string
|
|
||||||
/**
|
|
||||||
* The global slide, ready for the runtime. A template (`<?= html ?>`
|
|
||||||
* inside) is rendered once as the scene; anything else was already merged
|
|
||||||
* into every slide and the runtime ignores it.
|
|
||||||
*/
|
|
||||||
template: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull `<style>` tags out of a fragment.
|
|
||||||
*
|
|
||||||
* The markup stays HTML and the tag contents become the project's Less, so
|
|
||||||
* authors write styles right in the slide they are editing. The tags are
|
|
||||||
* removed from the markup because their raw Less must never reach the DOM —
|
|
||||||
* the compiled CSS is injected separately, at `<?= css ?>`. A style tag that
|
|
||||||
* *is* that injection point is left alone.
|
|
||||||
*
|
|
||||||
* Parsed as a fragment rather than a full document: as a document a leading
|
|
||||||
* `<style>` would land in `<head>` and be lost from the body output, taking
|
|
||||||
* the injection point with it.
|
|
||||||
*/
|
|
||||||
export function extractStyles(markup: string): { html: string; style: string } {
|
|
||||||
if (!markup.includes('<style')) {
|
|
||||||
return { html: markup, style: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragment = document.createElement('div')
|
|
||||||
fragment.innerHTML = markup
|
|
||||||
.split(HTML_PLACEHOLDER)
|
|
||||||
.join(HTML_SENTINEL)
|
|
||||||
.split(CSS_PLACEHOLDER)
|
|
||||||
.join(CSS_SENTINEL)
|
|
||||||
|
|
||||||
const blocks: string[] = []
|
|
||||||
for (const element of Array.from(fragment.querySelectorAll('style'))) {
|
|
||||||
const source = element.textContent ?? ''
|
|
||||||
if (source.includes(CSS_SENTINEL)) {
|
|
||||||
// This tag is where the compiled CSS gets injected, not authoring.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blocks.push(source)
|
|
||||||
element.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = fragment.innerHTML
|
|
||||||
.split(HTML_SENTINEL)
|
|
||||||
.join('<?= html ?>')
|
|
||||||
.split(CSS_SENTINEL)
|
|
||||||
.join('<?= css ?>')
|
|
||||||
return { html, style: blocks.join('\n') }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSlideHtml(markup: string, global: string): string {
|
|
||||||
return global ? `${global}\n${markup}` : markup
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The global slide is a template when it carries `<?= html ?>`: the runtime
|
|
||||||
* renders it once and mounts the slides at the placeholder, so its markup must
|
|
||||||
* not be copied into every slide.
|
|
||||||
*/
|
|
||||||
function isGlobalTemplate(global: string): boolean {
|
|
||||||
return HTML_PLACEHOLDER.test(global)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compile a project into runtime-ready data.
|
|
||||||
*
|
|
||||||
* Every `<style>` tag in the deck contributes to one project stylesheet (the
|
|
||||||
* global slide first, then each slide in order). The global block is the
|
|
||||||
* shared layer and compiles deck-wide; each slide's block is *wrapped* in its
|
|
||||||
* section selector before the blocks are joined, so a slide's stylesheet
|
|
||||||
* styles that slide and nothing else — token or not — with `:thisSlide`
|
|
||||||
* resolving to the section itself. The joined source compiles exactly once:
|
|
||||||
* Less stays Less, the output never depends on which slide is visible, and
|
|
||||||
* one slide's rules can never reach its neighbours.
|
|
||||||
*/
|
|
||||||
export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> {
|
|
||||||
const global = extractStyles(input.global ?? '')
|
|
||||||
const globalIsTemplate = isGlobalTemplate(global.html)
|
|
||||||
const extracted = input.slides.map((markup) => extractStyles(markup))
|
|
||||||
|
|
||||||
const slides: CompiledSlide[] = extracted.map((slide, index) => ({
|
|
||||||
id: slideId(index),
|
|
||||||
index,
|
|
||||||
html: globalIsTemplate ? slide.html : buildSlideHtml(slide.html, global.html)
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Scope each block while it still belongs to exactly one slide: the
|
|
||||||
// global block means "everywhere", a slide block means that slide. Only
|
|
||||||
// rewriting the `:thisSlide` token (the previous fix) still left every
|
|
||||||
// untokened rule deck-wide — the whole block now carries the selector.
|
|
||||||
const sources = [
|
|
||||||
bindThisSlide(global.style, ANY_SLIDE_SELECTOR),
|
|
||||||
...extracted.map((slide, index) => scopeBlock(slide.style, slideSelector(slideId(index))))
|
|
||||||
]
|
|
||||||
const source = sources.join('\n')
|
|
||||||
const css = source.trim() ? await renderLess(source) : ''
|
|
||||||
return { slides, css, template: global.html }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rewrite the `:thisSlide` authoring token to a concrete selector. */
|
|
||||||
function bindThisSlide(source: string, selector: string): string {
|
|
||||||
return source.split(THIS_SLIDE).join(selector)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope a slide's whole style block to its section: wrap the block in the
|
|
||||||
* slide's selector and let `:thisSlide` become `&` — the section itself.
|
|
||||||
* Less hoists `@keyframes`/`@font-face` out of the wrapper on its own and
|
|
||||||
* keeps `@media` nested inside it, so only rules end up scoped.
|
|
||||||
*/
|
|
||||||
function scopeBlock(source: string, selector: string): string {
|
|
||||||
if (!source.trim()) return ''
|
|
||||||
return `${selector} {\n${source.split(THIS_SLIDE).join('&')}\n}`
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// Worker `postMessage` takes no targetOrigin; that rule only targets windows.
|
|
||||||
/* oxlint-disable unicorn/require-post-message-target-origin */
|
|
||||||
|
|
||||||
// Side-effect import first: it shims `window`/`document` for less' auto-init,
|
|
||||||
// which reads them while the less module evaluates — a static import would
|
|
||||||
// hoist above any code in this file, hence the separate module.
|
|
||||||
import './worker-globals'
|
|
||||||
import less from 'less'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders one Less source per message so the main thread never blocks on a
|
|
||||||
* big stylesheet. Replies carry the task id back with either the CSS or the
|
|
||||||
* error message — a Less syntax error is an ordinary reply, not a crash.
|
|
||||||
*/
|
|
||||||
interface RenderRequest {
|
|
||||||
id: number
|
|
||||||
source: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RenderReply {
|
|
||||||
id: number
|
|
||||||
css?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typed narrowly on purpose: this file runs as a worker, but the program is
|
|
||||||
// typed with the DOM libs (no WebWorker lib), so `self` here is the scope.
|
|
||||||
const scope = self as unknown as {
|
|
||||||
addEventListener(type: 'message', listener: (event: MessageEvent<RenderRequest>) => void): void
|
|
||||||
postMessage(message: RenderReply): void
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.addEventListener('message', (event) => {
|
|
||||||
const { id, source } = event.data
|
|
||||||
less.render(source)
|
|
||||||
.then(({ css }) => scope.postMessage({ id, css }))
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
scope.postMessage({
|
|
||||||
id,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Injection points the runtime fills in the global slide.
|
|
||||||
*
|
|
||||||
* A global slide carrying `<?= html ?>` is a template: the runtime renders it
|
|
||||||
* once as the scene, mounts the slide markup where `html` is, and injects the
|
|
||||||
* compiled stylesheet where `css` is.
|
|
||||||
*
|
|
||||||
* Shared by the compiler (which must not mistake a placeholder for Less) and
|
|
||||||
* the runtime (which replaces them) — its own module so the runtime never
|
|
||||||
* imports the Less compiler. Non-global regexes, so `.test()` is stateless.
|
|
||||||
*/
|
|
||||||
export const HTML_PLACEHOLDER = /<\?=\s*html\s*\?>/
|
|
||||||
export const CSS_PLACEHOLDER = /<\?=\s*css\s*\?>/
|
|
||||||
|
|
@ -1,482 +0,0 @@
|
||||||
import type { CompiledSlide } from './compile'
|
|
||||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
|
||||||
import { editor } from '../editor'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Slide lifecycle. The runtime moves slides through these states; CSS owns how
|
|
||||||
* each transition looks. State is published as the `data-ps-state` attribute.
|
|
||||||
*
|
|
||||||
* idle -> entering -> current -> leaving -> idle
|
|
||||||
*/
|
|
||||||
export type SlideState = 'idle' | 'entering' | 'current' | 'leaving'
|
|
||||||
|
|
||||||
export interface RuntimeSlide {
|
|
||||||
readonly id: string
|
|
||||||
readonly index: number
|
|
||||||
readonly element: HTMLElement
|
|
||||||
readonly state: SlideState
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeOptions {
|
|
||||||
/** Listen for arrow keys / space / Home / End. Defaults to true. */
|
|
||||||
keyboard?: boolean
|
|
||||||
/** Called whenever the current slide changes. */
|
|
||||||
onCurrentChange?: (index: number) => void
|
|
||||||
/**
|
|
||||||
* The global slide. When it carries `<?= html ?>` it becomes the scene:
|
|
||||||
* rendered once, slides mounted where `html` is, the compiled stylesheet
|
|
||||||
* injected where `css` is.
|
|
||||||
*/
|
|
||||||
template?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Runtime {
|
|
||||||
/** Navigate directly. Out-of-range indexes clamp; repeats are ignored. */
|
|
||||||
goTo(index: number): void
|
|
||||||
next(): void
|
|
||||||
previous(): void
|
|
||||||
/**
|
|
||||||
* Swap the deck in place (after recompiling) without resetting the view.
|
|
||||||
* `keepIndex` leaves the current slide recorded in history alone when the
|
|
||||||
* new deck is shorter — the runtime still clamps what it displays, but the
|
|
||||||
* slide you were on is not overwritten with the clamp.
|
|
||||||
*/
|
|
||||||
setSlides(slides: readonly CompiledSlide[], options?: { keepIndex?: boolean }): void
|
|
||||||
/**
|
|
||||||
* Set the global slide; a template re-renders the scene around the slides.
|
|
||||||
* Order-independent: a scene re-render re-applies the stored stylesheet.
|
|
||||||
*/
|
|
||||||
setTemplate(template: string): void
|
|
||||||
/**
|
|
||||||
* Set the compiled stylesheet: injected at the scene's `<?= css ?>`, or in
|
|
||||||
* the head when the scene has no injection point. Stored, so re-renders of
|
|
||||||
* the scene re-apply it — safe to call in any order with the other setters.
|
|
||||||
*/
|
|
||||||
setCss(css: string): void
|
|
||||||
readonly current: number
|
|
||||||
readonly slides: readonly RuntimeSlide[]
|
|
||||||
/** Remove the runtime, its listeners and its DOM. */
|
|
||||||
destroy(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upper bound on how long a navigation may wait before it settles anyway. */
|
|
||||||
const SETTLE_TIMEOUT_MS = 5000
|
|
||||||
/**
|
|
||||||
* The current slide is remembered in the history entry rather than the URL, so
|
|
||||||
* back/forward steps through slides and a reload lands on the slide you were on.
|
|
||||||
*/
|
|
||||||
const HISTORY_KEY = 'psslides-slide'
|
|
||||||
/** Images in the incoming slide get this long to load before we show it. */
|
|
||||||
const ASSET_TIMEOUT_MS = 1000
|
|
||||||
|
|
||||||
/** Stands in for `<?= html ?>`: the element slides get mounted into. */
|
|
||||||
const MOUNT_POINT = '<div data-ps-html-mount></div>'
|
|
||||||
const MOUNT_SELECTOR = '[data-ps-html-mount]'
|
|
||||||
|
|
||||||
// A scene without `<?= css ?>` still needs the compiled CSS, and it belongs in
|
|
||||||
// exactly one place.
|
|
||||||
let fallbackSheet: HTMLStyleElement | null = null
|
|
||||||
|
|
||||||
function headSheet(): HTMLStyleElement {
|
|
||||||
if (!fallbackSheet) {
|
|
||||||
fallbackSheet = document.createElement('style')
|
|
||||||
fallbackSheet.setAttribute('data-ps-slides', '')
|
|
||||||
document.head.append(fallbackSheet)
|
|
||||||
}
|
|
||||||
return fallbackSheet
|
|
||||||
}
|
|
||||||
|
|
||||||
class SlideRecord {
|
|
||||||
readonly id: string
|
|
||||||
readonly index: number
|
|
||||||
readonly element: HTMLElement
|
|
||||||
html: string
|
|
||||||
state: SlideState = 'idle'
|
|
||||||
|
|
||||||
constructor(slide: CompiledSlide) {
|
|
||||||
this.id = slide.id
|
|
||||||
this.index = slide.index
|
|
||||||
this.html = slide.html
|
|
||||||
this.element = document.createElement('section')
|
|
||||||
this.element.setAttribute('data-ps-slide', slide.id)
|
|
||||||
this.element.setAttribute('data-ps-state', 'idle')
|
|
||||||
this.element.innerHTML = slide.html
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
window.setTimeout(resolve, ms)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves after two frames, which is when CSS has started its transitions. */
|
|
||||||
function nextFrames(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => resolve())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForImage(image: HTMLImageElement): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (image.complete) {
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
image.addEventListener('load', () => resolve(), { once: true })
|
|
||||||
image.addEventListener('error', () => resolve(), { once: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wait (briefly) for images in the incoming slide so it cannot pop in late. */
|
|
||||||
async function waitForAssets(element: HTMLElement): Promise<void> {
|
|
||||||
const pending = Array.from(element.querySelectorAll('img')).filter((image) => !image.complete)
|
|
||||||
if (pending.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await Promise.race([Promise.all(pending.map(waitForImage)), delay(ASSET_TIMEOUT_MS)])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Infinite animations (spinners) never finish, so they must not block a settle. */
|
|
||||||
function isFiniteAnimation(animation: Animation): boolean {
|
|
||||||
return animation.effect?.getTiming().iterations !== Infinity
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAnimations(elements: readonly HTMLElement[]): Promise<void> {
|
|
||||||
// Transitions are scheduled but not started until the next frame.
|
|
||||||
await nextFrames()
|
|
||||||
const animations = elements
|
|
||||||
.flatMap((element) => element.getAnimations({ subtree: true }))
|
|
||||||
.filter(isFiniteAnimation)
|
|
||||||
if (animations.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await Promise.race([
|
|
||||||
Promise.all(animations.map((animation) => animation.finished.catch(() => undefined))),
|
|
||||||
delay(SETTLE_TIMEOUT_MS)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Slide index carried by the current history entry, if it has one. */
|
|
||||||
function storedIndex(): number | null {
|
|
||||||
const value = (window.history.state as Record<string, unknown> | null)?.[HISTORY_KEY]
|
|
||||||
return typeof value === 'number' && Number.isInteger(value) ? value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record the slide in the history entry: push a new one, or amend this one. */
|
|
||||||
function writeIndex(index: number, push: boolean): void {
|
|
||||||
const state = {
|
|
||||||
...(window.history.state as Record<string, unknown> | null),
|
|
||||||
[HISTORY_KEY]: index
|
|
||||||
}
|
|
||||||
if (push) {
|
|
||||||
window.history.pushState(state, '')
|
|
||||||
} else {
|
|
||||||
window.history.replaceState(state, '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ignoresKeyboard(event: KeyboardEvent): boolean {
|
|
||||||
if(editor.hasTextFocus()) return true;
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof HTMLElement)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Never steal keys from the editor, form controls or focused controls.
|
|
||||||
return target.closest('input, textarea, select, button, a, [contenteditable]') !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mount every slide in the DOM at once and drive them through their lifecycle.
|
|
||||||
*
|
|
||||||
* The runtime never rebuilds the scene during navigation: all slides stay
|
|
||||||
* mounted, and only `data-ps-state` changes. It deliberately has no animation
|
|
||||||
* engine, no event bus and no knowledge of editing or Less — CSS describes how
|
|
||||||
* slides look, this moves them between states.
|
|
||||||
*/
|
|
||||||
export function createRuntime(
|
|
||||||
container: HTMLElement,
|
|
||||||
deck: readonly CompiledSlide[],
|
|
||||||
options: RuntimeOptions = {}
|
|
||||||
): Runtime {
|
|
||||||
const records: SlideRecord[] = deck.map((slide) => new SlideRecord(slide))
|
|
||||||
const restored = storedIndex()
|
|
||||||
let current =
|
|
||||||
restored === null ? 0 : Math.min(Math.max(restored, 0), Math.max(records.length - 1, 0))
|
|
||||||
let generation = 0
|
|
||||||
let destroyed = false
|
|
||||||
let template = options.template ?? ''
|
|
||||||
let css = ''
|
|
||||||
let mountPoint: HTMLElement = container
|
|
||||||
|
|
||||||
container.setAttribute('data-psslides-runtime', '')
|
|
||||||
|
|
||||||
// The global slide, when it is a template, owns the scene the slides live
|
|
||||||
// in; render it first so they mount into the right place.
|
|
||||||
renderShell()
|
|
||||||
|
|
||||||
function setState(record: SlideRecord, state: SlideState): void {
|
|
||||||
if (record.state === state) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
record.state = state
|
|
||||||
record.element.setAttribute('data-ps-state', state)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every navigation ends by normalising: target current, everything else idle. */
|
|
||||||
async function settle(generationAtCall: number, target: SlideRecord): Promise<void> {
|
|
||||||
const involved = records.filter((record) => record.state !== 'idle')
|
|
||||||
await Promise.all([
|
|
||||||
waitForAssets(target.element),
|
|
||||||
waitForAnimations(involved.map((record) => record.element))
|
|
||||||
])
|
|
||||||
// A newer navigation took over while we waited; it owns the settle now.
|
|
||||||
if (destroyed || generationAtCall !== generation) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const record of records) {
|
|
||||||
setState(record, record === target ? 'current' : 'idle')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goTo(index: number): void {
|
|
||||||
moveTo(index, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `fromHistory` skips pushing a new entry (back/forward already made it). */
|
|
||||||
function moveTo(index: number, fromHistory: boolean): void {
|
|
||||||
if (destroyed || records.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = Math.min(Math.max(index, 0), records.length - 1)
|
|
||||||
if (target === current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anything on screen (or still mid-flight) starts leaving at once.
|
|
||||||
// Rapid navigation therefore never queues up: one generation, one settle.
|
|
||||||
generation++
|
|
||||||
for (const record of records) {
|
|
||||||
if (record.state !== 'idle') {
|
|
||||||
setState(record, 'leaving')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const targetRecord = records[target]
|
|
||||||
setState(targetRecord, 'entering')
|
|
||||||
current = target
|
|
||||||
if (!fromHistory) {
|
|
||||||
writeIndex(target, true)
|
|
||||||
}
|
|
||||||
options.onCurrentChange?.(target)
|
|
||||||
|
|
||||||
void settle(generation, targetRecord)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSlides(deckUpdate: readonly CompiledSlide[], slideOptions: { keepIndex?: boolean } = {}): void {
|
|
||||||
if (destroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousRecords = records.slice()
|
|
||||||
const nextRecords: SlideRecord[] = deckUpdate.map((slide, index) => {
|
|
||||||
const existing = previousRecords[index]
|
|
||||||
if (existing && existing.id === slide.id) {
|
|
||||||
if (existing.html !== slide.html) {
|
|
||||||
existing.element.innerHTML = slide.html
|
|
||||||
existing.html = slide.html
|
|
||||||
}
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
return new SlideRecord(slide)
|
|
||||||
})
|
|
||||||
|
|
||||||
const structureChanged =
|
|
||||||
nextRecords.length !== previousRecords.length ||
|
|
||||||
nextRecords.some((record, index) => record !== previousRecords[index])
|
|
||||||
records.splice(0, records.length, ...nextRecords)
|
|
||||||
|
|
||||||
if (records.length > 0 && current > records.length - 1) {
|
|
||||||
current = records.length - 1
|
|
||||||
if (!slideOptions.keepIndex) {
|
|
||||||
writeIndex(current, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (structureChanged) {
|
|
||||||
// Rebuilding the DOM restarts transitions, so drop in-flight work and
|
|
||||||
// settle into an explicit state instead of leaving a half-transition.
|
|
||||||
generation++
|
|
||||||
mountPoint.replaceChildren(...records.map((record) => record.element))
|
|
||||||
for (const record of records) {
|
|
||||||
setState(record, record.index === current ? 'current' : 'idle')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function next(): void {
|
|
||||||
goTo(current + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function previous(): void {
|
|
||||||
goTo(current - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent): void {
|
|
||||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
|
|
||||||
if (ignoresKeyboard(event)) return
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowRight':
|
|
||||||
case 'ArrowDown':
|
|
||||||
case 'PageDown':
|
|
||||||
case ' ':
|
|
||||||
case 'Spacebar':
|
|
||||||
next()
|
|
||||||
break
|
|
||||||
case 'ArrowLeft':
|
|
||||||
case 'ArrowUp':
|
|
||||||
case 'PageUp':
|
|
||||||
previous()
|
|
||||||
break
|
|
||||||
case 'Home':
|
|
||||||
goTo(0)
|
|
||||||
break
|
|
||||||
case 'End':
|
|
||||||
goTo(records.length - 1)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialise: the restored (or first) slide is current, the rest idle.
|
|
||||||
writeIndex(current, false)
|
|
||||||
records.forEach((record, index) => {
|
|
||||||
if (index === current) {
|
|
||||||
record.state = 'current'
|
|
||||||
record.element.setAttribute('data-ps-state', 'current')
|
|
||||||
}
|
|
||||||
mountPoint.append(record.element)
|
|
||||||
})
|
|
||||||
|
|
||||||
function onPopState(): void {
|
|
||||||
const index = storedIndex()
|
|
||||||
if (index !== null) {
|
|
||||||
moveTo(index, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyboard = options.keyboard !== false
|
|
||||||
if (keyboard) {
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
}
|
|
||||||
window.addEventListener('popstate', onPopState)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the global slide as the scene (or reset to plain slides when it
|
|
||||||
* is not a template) and re-apply the stored stylesheet to whatever the
|
|
||||||
* new scene offers: its `<?= css ?>`, or the head as a fallback.
|
|
||||||
*/
|
|
||||||
function renderShell(): void {
|
|
||||||
// Start from a clean container so a previous scene never lingers
|
|
||||||
// after the template goes away. The slide elements survive as
|
|
||||||
// objects; the caller re-homes them into the fresh mount point.
|
|
||||||
container.replaceChildren()
|
|
||||||
mountPoint = container
|
|
||||||
if (HTML_PLACEHOLDER.test(template)) {
|
|
||||||
container.innerHTML = template.split(HTML_PLACEHOLDER).join(MOUNT_POINT)
|
|
||||||
mountPoint = container.querySelector<HTMLElement>(MOUNT_SELECTOR) ?? container
|
|
||||||
}
|
|
||||||
applyCss()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put the stored stylesheet in exactly one place: a `<style>` holding
|
|
||||||
* `<?= css ?>` inside the scene takes it in place — tagged `data-ps-css`
|
|
||||||
* so recompiles keep aiming at it once the placeholder text is gone. With
|
|
||||||
* no such target the runtime keeps one stylesheet in the document head.
|
|
||||||
*/
|
|
||||||
function applyCss(): void {
|
|
||||||
let injected = false
|
|
||||||
for (const style of Array.from(container.querySelectorAll('style'))) {
|
|
||||||
const isTarget =
|
|
||||||
style.hasAttribute('data-ps-css') || CSS_PLACEHOLDER.test(style.textContent ?? '')
|
|
||||||
if (!isTarget) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
style.setAttribute('data-ps-css', '')
|
|
||||||
style.textContent = css
|
|
||||||
injected = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (injected) {
|
|
||||||
// Never let a stale head copy fight the scene's own styles.
|
|
||||||
if (fallbackSheet) {
|
|
||||||
fallbackSheet.textContent = ''
|
|
||||||
}
|
|
||||||
} else if (css || fallbackSheet) {
|
|
||||||
headSheet().textContent = css
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCss(value: string): void {
|
|
||||||
if (destroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
css = value
|
|
||||||
applyCss()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the global slide. A template (`<?= html ?>` inside) is rendered once
|
|
||||||
* as the scene — slides are mounted where the placeholder was, and the
|
|
||||||
* stored stylesheet is re-applied where `<?= css ?>` is. Anything else
|
|
||||||
* leaves the slide tree alone; its markup was already merged into the
|
|
||||||
* slides by the compiler.
|
|
||||||
*/
|
|
||||||
function setTemplate(value: string): void {
|
|
||||||
if (destroyed || value === template) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
template = value
|
|
||||||
renderShell()
|
|
||||||
// The shell was rebuilt, but the slide elements themselves survive as
|
|
||||||
// objects: re-home them instead of recreating anything.
|
|
||||||
mountPoint.append(...records.map((record) => record.element))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
goTo,
|
|
||||||
next,
|
|
||||||
previous,
|
|
||||||
setSlides(slides, slideOptions) {
|
|
||||||
setSlides(slides, slideOptions)
|
|
||||||
},
|
|
||||||
setTemplate,
|
|
||||||
setCss,
|
|
||||||
get current() {
|
|
||||||
return current
|
|
||||||
},
|
|
||||||
get slides() {
|
|
||||||
return records
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
if (destroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
destroyed = true
|
|
||||||
generation++
|
|
||||||
if (keyboard) {
|
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
|
||||||
}
|
|
||||||
window.removeEventListener('popstate', onPopState)
|
|
||||||
container.replaceChildren()
|
|
||||||
container.removeAttribute('data-psslides-runtime')
|
|
||||||
fallbackSheet?.remove()
|
|
||||||
fallbackSheet = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
// less' browser build runs a page auto-init while its module loads: it
|
|
||||||
// reaches for `window`, copies any `window.less` config, then — unless told
|
|
||||||
// otherwise — hides `<body>` and rescans the document's stylesheets. A worker
|
|
||||||
// has none of that, so bind `window` to the worker scope, stub the single
|
|
||||||
// `document` probe the init makes (null-safe downstream), and pre-set
|
|
||||||
// `onReady: false` so init stops right after building the compiler.
|
|
||||||
// The worker imports this for side effects before `less`, so ESM evaluation
|
|
||||||
// order guarantees the shim exists by the time the auto-init runs.
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
const worker = globalThis as unknown as Record<string, unknown>
|
|
||||||
worker.window = worker
|
|
||||||
worker.document = { getElementsByTagName: () => [] }
|
|
||||||
worker.less = { onReady: false }
|
|
||||||
}
|
|
||||||
|
|
@ -105,7 +105,6 @@ body {
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
grid-area: preview;
|
grid-area: preview;
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -114,62 +113,6 @@ body {
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The presentation surface. The runtime mounts every slide inside it. */
|
|
||||||
.stage {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage:fullscreen {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compile-error {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1;
|
|
||||||
max-height: 40%;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background-color: #7a1f1f;
|
|
||||||
color: #ffffff;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menubar-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.present-button {
|
|
||||||
padding: 0.25rem 0.7rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.present-button:hover,
|
|
||||||
.present-button:focus-visible {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-list-item {
|
.slide-list-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -256,18 +199,3 @@ body {
|
||||||
.delete-slide:focus-visible {
|
.delete-slide:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide {
|
|
||||||
all: initial;
|
|
||||||
aspect-ratio: 16/9;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The global slide is shared markup, not a step of the deck: mark the surface
|
|
||||||
so it reads as different from a presented slide. */
|
|
||||||
.slide.global {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue