Compare commits

..

9 commits

Author SHA1 Message Date
jan
7d01ec881e Merge branch 'navi-slide-runtime' 2026-09-28 17:58:33 +02:00
navi
eeabb70855 fix: scope every rule in a slide's own style block to that slide
Binding only the `:thisSlide` token still left every untokened rule in a
slide's tag compiling deck-wide, so one slide's styles kept applying to
all slides. Wrap each slide's whole style block in its section selector
instead, with `:thisSlide` resolving to `&` — Less hoists `@keyframes`/
`@font-face` out of the wrapper itself and keeps `@media` nested inside,
so only rules end up scoped. The global block stays the shared layer and
the deck still compiles exactly once.
2026-09-28 15:40:31 +02:00
navi
48e06bb689 chore: sync bun.lock with less + @types/less 2026-09-28 14:32:35 +02:00
navi
a05c860c6e fix: bind :thisSlide per style block instead of per compile pass
Every slide's :thisSlide rules leaked into every other slide: the whole
joined source was recompiled once per slide with all occurrences rebound,
so each pass attached every slide's scoped rules to that pass's slide and
the concatenated output applied them all to everyone. Bind the token while
each block still belongs to one slide (global block -> any slide) and
compile the project once - also drops the O(n) duplicated CSS output.
2026-09-28 14:29:37 +02:00
navi
97ae327415 feat: compiled-style thumbnails and Less compilation in a worker
- thumbnails: compile the project stylesheet once per rebuild and feed it
  into the layout sandbox, compose each tile like the runtime does
  (<section data-ps-slide> around the slide), and show example HTML on
  the global tile instead of another real slide's content.
- worker: less compiles off the main thread in less-worker; a side-effect
  module shims window/document and disables the browser build's page
  auto-init, and compilation falls back inline if the worker can't start.
- code-to-element: wrap styles[] entries in <style> blocks - raw CSS text
  in the iframe head fell into <body> and rendered as literal garbage.
2026-09-28 13:05:42 +02:00
navi
14916e6416 refactor: order-independent scene setters, fragment-safe style extraction 2026-09-27 23:00:24 +02:00
navi
a7e463cb94 feat: render the global slide as a <?= html ?> / <?= css ?> template 2026-09-27 22:36:46 +02:00
navi
2282b13325 feat: author styles in a <style> tag written in the slide itself
Less now lives in <style> tags alongside the slide HTML, so there is one
editor buffer and no separate stylesheet tab.

- compile.ts extracts every <style> tag in the deck (global slide first,
  then each slide in order) into one project stylesheet, strips the raw tags
  from the markup so uncompiled Less never reaches the DOM, and compiles that
  source once per slide with :thisSlide resolving to the slide being compiled.
- editor.ts is back to a single buffer; the CSS/LESS workers still highlight,
  validate and complete the Less inside the tag.
- thumbnails render markup with style tags removed, exactly like the
  presentation does.
2026-09-27 21:58:26 +02:00
navi
799921e213 feat: presentation runtime with lifecycle states and :thisSlide
- src/slides/compile.ts compiles a project into runtime-ready slides.
  :thisSlide is resolved to each slide's own selector before Less runs,
  so output is deterministic and never depends on which slide is visible.
- src/slides/runtime.ts mounts every slide once and moves them through
  idle -> entering -> current -> leaving via a data-ps-state attribute;
  CSS owns the animation, JS only owns the lifecycle. Rapid input
  coalesces into a single settle instead of a queue of stale operations.
  Arrow/space/Home/End keys, plus history integration: back/forward step
  through slides and a reload restores the slide you were on.
- The editor gained two buffers: HTML slides and the project Less. The
  preview and the presentation are the same runtime (present = fullscreen
  of the same stage), and broken Less keeps the last good preview while
  reporting the error.

Adds `less` (the authoring language) and `@types/less`.
2026-09-27 21:22:04 +02:00
12 changed files with 1222 additions and 23 deletions

View file

@ -6,9 +6,11 @@
"name": "psslides",
"dependencies": {
"emmet-monaco-es": "^5.7.0",
"less": "^4.9.1",
"monaco-editor": "0.55.1",
},
"devDependencies": {
"@types/less": "^3.0.8",
"oxlint": "^1.85.0",
"prettier": "^3.9.9",
"typescript": "~6.0.2",
@ -95,8 +97,14 @@
"@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=="],
"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=="],
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
@ -105,10 +113,20 @@
"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=="],
"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-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
@ -133,14 +151,26 @@
"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=="],
"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=="],
"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=="],
"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=="],
"parse-node-version": ["parse-node-version@1.0.1", "", {}, "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
@ -149,14 +179,34 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
}
}

View file

@ -9,7 +9,12 @@
<body>
<div class="container">
<div class="menubar"><b>p/slides</b></div>
<div class="menubar">
<b>p/slides</b>
<div class="menubar-actions">
<button class="present-button" type="button">Present</button>
</div>
</div>
<div class="slidelist">
<div class="slide-list-header">
<span>Slides</span>

View file

@ -13,6 +13,7 @@
"preview": "vite preview"
},
"devDependencies": {
"@types/less": "^3.0.8",
"oxlint": "^1.85.0",
"prettier": "^3.9.9",
"typescript": "~6.0.2",
@ -20,6 +21,7 @@
},
"dependencies": {
"emmet-monaco-es": "^5.7.0",
"less": "^4.9.1",
"monaco-editor": "0.55.1"
}
}

View file

@ -3,6 +3,7 @@ import { domToSvgDataUrl } from './dom-to-image'
export interface CodeToElementOptions {
title?: string
allowScripts?: boolean
/** Plain CSS strings — each becomes a `<style>` block in the iframe head. */
styles?: string[]
}
@ -18,7 +19,8 @@ export function codeToElement(code: string, options: CodeToElementOptions = {}):
iframe.title = options.title ?? 'Rendered code'
iframe.setAttribute('sandbox', Array.from(sandbox).join(' '))
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>`
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>${headStyles}</head><body>${code}</body></html>`
return iframe
}

View file

@ -21,9 +21,14 @@ self.MonacoEnvironment = {
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.
*/
export function createEditor(): monaco.editor.IStandaloneCodeEditor {
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: 'test',
value: '',
language: 'html',
theme: 'vs-dark',
automaticLayout: true

View file

@ -1,28 +1,139 @@
import './style.css'
import { createEditor } from './editor'
import { createCodeThumbnail } from './code-to-element'
import {
compilePresentation,
extractStyles,
slideId,
type CompiledPresentation
} from './slides/compile'
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './slides/placeholders'
import { createRuntime, type Runtime } from './slides/runtime'
interface Slide {
code: string
}
// The starter slide opens with a <style> tag. 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.
const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nesting,
// mixins and imports all work as usual.
//
// A slide's own style tag is scoped to that slide: every rule in it applies
// to that slide alone. The global slide's styles are shared by all slides.
// :thisSlide is the slide's own section, never "the visible slide" — handy
// for state rules: :thisSlide[data-ps-state='current'] { ... }.
@bg: #101010;
@fg: #f0f0f0;
@accent: #8fc98f;
:thisSlide {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
background: @bg;
color: @fg;
transition: opacity 260ms ease, transform 260ms ease;
}
// The runtime moves slides through idle -> entering -> current -> leaving.
// CSS owns the transition itself; JavaScript only sets data-ps-state.
// idle and leaving deliberately share values so settling costs no jump.
:thisSlide[data-ps-state='idle'],
:thisSlide[data-ps-state='leaving'] {
opacity: 0;
transform: scale(0.98);
}
:thisSlide[data-ps-state='entering'],
:thisSlide[data-ps-state='current'] {
opacity: 1;
transform: scale(1);
}
`
// Shown in the global-slide tile while the global slide is empty: an example
// frame around a sample slide, so the tile explains what the global slide is
// for instead of rendering a blank box. Template mode mounts this same sample
// in the author's own frame.
const EXAMPLE_SECTION = `<section data-ps-slide="${slideId(0)}" data-ps-state="current"><h1>Example slide</h1><p>The global slide wraps every slide like this.</p></section>`
const EXAMPLE_FRAME = `<style>
.ps-example-frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: #161a22;
color: #dbe4f0;
font-family: system-ui, sans-serif;
}
.ps-example-frame header {
font-size: 1.75rem;
font-weight: 650;
opacity: 0.85;
}
.ps-example-frame .ps-example-viewport {
position: relative;
flex: 1;
border-radius: 0.75rem;
overflow: hidden;
}
.ps-example-frame footer {
font-size: 1.25rem;
opacity: 0.55;
}
</style>
<div class="ps-example-frame">
<header>Example deck</header>
<div class="ps-example-viewport">${EXAMPLE_SECTION}</div>
<footer>Global slide — wraps every slide</footer>
</div>`
let globalSlide = ''
const slides: Slide[] = [
{
code: '<h1>test</h1>'
code: `<style>\n${STARTER_STYLE}\n</style>\n<h1>test</h1>`
}
]
const editor = createEditor()
const slideList = document.querySelector<HTMLElement>('.slide-list-items')!
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
const stage = document.querySelector<HTMLElement>('.stage')!
const compileError = document.querySelector<HTMLElement>('.compile-error')!
const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
let currentSlide = 0
let draggedSlide: number | null = null
let globalSlideElement: HTMLElement | null = null
const slideElements = new Map<Slide, HTMLElement>()
const thumbnailRequests = new Map<number, number>()
let thumbnailDebounceTimer: number | undefined
// The last good compile: 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() {
slideList.querySelectorAll<HTMLElement>('.slide-list-item').forEach((element) => {
@ -31,15 +142,127 @@ 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) {
const slide = index !== -1 ? slides[index] : { code: globalSlide }
if (!slide) {
if (index !== -1 && !slides[index]) {
return
}
currentSlide = index
editor.getModel()?.setValue(slide.code)
showSlideInEditor(index)
updateSelection()
// 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)
}
async function rebuildPreview(): Promise<void> {
try {
const result = await compilePresentation({
slides: slides.map((slide) => slide.code),
global: globalSlide
})
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)
}
runtime.setTemplate(result.template)
runtime.setSlides(result.slides)
runtime.setCss(result.css)
// Follow the slide being edited; the deck may just have grown.
if (currentSlide >= 0) {
runtime.goTo(currentSlide)
}
// 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('')
}
return global
}
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) {
@ -50,7 +273,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
slideElement.classList.add('global-slide')
} else {
slideElement.draggable = true
slideElements.set(slide, slideElement)
}
const slideInList = document.createElement('button')
@ -65,7 +287,11 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
slideNumber.classList.add('slide-number')
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
const thumbnail = await createCodeThumbnail(slide.code)
// The tile mirrors the presentation: composed markup, plus the compiled
// 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)
slideElement.append(slideInList)
@ -81,10 +307,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
slideElement.append(deleteButton)
}
if (i === -1) {
globalSlideElement = slideElement
}
if (i === currentSlide) {
slideElement.classList.add('selected')
}
@ -95,9 +317,12 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
async function refreshThumbnail(index: number, slide: Slide) {
const request = (thumbnailRequests.get(index) ?? 0) + 1
thumbnailRequests.set(index, request)
const generation = listGeneration
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
const slideElement = await generateSlideThumbnail(slide, index)
if (thumbnailRequests.get(index) !== request) {
// A newer refresh or a full rebuild superseded this one while it rendered;
// a stale tile must never replace a fresh one.
if (generation !== listGeneration || thumbnailRequests.get(index) !== request) {
return
}
@ -133,15 +358,31 @@ async function mapWithConcurrency<T, R>(
}
async function setSlideList() {
const generation = ++listGeneration
const allSlides = [{ code: globalSlide }, ...slides]
slideList.replaceChildren()
slideElements.clear()
globalSlideElement = null
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
generateSlideThumbnail(slide, index - 1)
)
slideList.append(...elements)
// Commit atomically: the old list stays interactive while tiles render.
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()
}
@ -189,7 +430,8 @@ function moveSlide(from: number, to: number) {
}
updateSlideOrder()
editor.getModel()?.setValue(slides[currentSlide]?.code ?? globalSlide)
showSlideInEditor(currentSlide)
scheduleCompile()
}
function deleteSlide(index: number) {
@ -209,15 +451,40 @@ function deleteSlide(index: number) {
updateSlideOrder()
setSlide(currentSlide)
scheduleCompile()
}
addSlideButton.addEventListener('click', async () => {
const slide = { code: '' }
slides.push(slide)
setSlide(slides.length - 1)
scheduleCompile()
const generation = listGeneration
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)
updateSlideOrder()
setSlide(slides.length - 1)
})
presentButton.addEventListener('click', () => {
stage.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) => {
@ -252,6 +519,10 @@ slideList.addEventListener('dragend', () => {
})
editor.onDidChangeModelContent(() => {
if (applyingToEditor) {
return
}
const code = editor.getValue()
const slideIndex = currentSlide
const slide = slideIndex === -1 ? { code } : slides[slideIndex]
@ -261,6 +532,8 @@ editor.onDidChangeModelContent(() => {
slides[slideIndex].code = code
}
scheduleCompile()
if (thumbnailDebounceTimer !== undefined) {
window.clearTimeout(thumbnailDebounceTimer)
}
@ -271,8 +544,13 @@ editor.onDidChangeModelContent(() => {
})
async function render() {
await setSlideList()
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()
}
}
render()

258
src/slides/compile.ts Normal file
View file

@ -0,0 +1,258 @@
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}`
}

43
src/slides/less-worker.ts Normal file
View file

@ -0,0 +1,43 @@
// 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)
})
})
})

View file

@ -0,0 +1,13 @@
/**
* 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*\?>/

471
src/slides/runtime.ts Normal file
View file

@ -0,0 +1,471 @@
import type { CompiledSlide } from './compile'
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
/**
* 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. */
setSlides(slides: readonly CompiledSlide[]): 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 {
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[]): 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
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,
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
}
}
}

View file

@ -0,0 +1,14 @@
// 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 }
}

View file

@ -105,6 +105,7 @@ body {
.preview {
grid-area: preview;
position: relative;
min-width: 0;
display: flex;
align-items: center;
@ -113,6 +114,63 @@ body {
background-color: var(--bg);
}
/* The presentation surface. The runtime mounts every slide inside it. */
.stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--bg-sunken);
}
.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 {
position: relative;
display: grid;