1
0
Fork 0
forked from jan/psslides

Compare commits

...
Sign in to create a new pull request.

11 commits
main ... main

Author SHA1 Message Date
jan
fa1467e7a8 2026-10-01 14:50:27 +02:00
jan
7d01ec881e Merge branch 'navi-slide-runtime' 2026-09-28 17:58:33 +02:00
jan
7f214c00e1 asdasdasd 2026-09-28 17:56:57 +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
13 changed files with 1726 additions and 27 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>
@ -19,6 +24,12 @@
</div>
<div class="code"></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>
<script type="module" src="/src/main.ts"></script>
</body>

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

@ -1,10 +1,207 @@
import * as monaco from 'monaco-editor'
import { emmetCSS, emmetHTML } from 'emmet-monaco-es'
import { emmetCSS, expandAbbreviation } from 'emmet-monaco-es'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.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'
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'])
self.MonacoEnvironment = {
@ -21,11 +218,18 @@ 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.
*/
function createEditor(): monaco.editor.IStandaloneCodeEditor {
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: 'test',
value: '',
language: 'html',
theme: 'vs-dark',
automaticLayout: true
})
}
export const editor = createEditor()

301
src/html-worker.ts Normal file
View file

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

View file

@ -1,28 +1,70 @@
import './style.css'
import { createEditor } from './editor'
import { editor } from './editor'
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 {
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 = ''
const slides: Slide[] = [
{
code: '<h1>test</h1>'
code: ''
}
]
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 slide = document.querySelector<HTMLElement>('.slide')!
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 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() {
slideList.querySelectorAll<HTMLElement>('.slide-list-item').forEach((element) => {
@ -31,15 +73,165 @@ 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
}
const wasGlobal = currentSlide === -1
currentSlide = index
editor.getModel()?.setValue(slide.code)
slide.classList.toggle('global', index === -1)
showSlideInEditor(index)
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) {
@ -50,7 +242,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 +256,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 +276,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 +286,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 +327,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 +399,8 @@ function moveSlide(from: number, to: number) {
}
updateSlideOrder()
editor.getModel()?.setValue(slides[currentSlide]?.code ?? globalSlide)
showSlideInEditor(currentSlide)
scheduleCompile()
}
function deleteSlide(index: number) {
@ -209,15 +420,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', () => {
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) => {
@ -252,6 +488,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 +501,8 @@ editor.onDidChangeModelContent(() => {
slides[slideIndex].code = code
}
scheduleCompile()
if (thumbnailDebounceTimer !== undefined) {
window.clearTimeout(thumbnailDebounceTimer)
}
@ -271,8 +513,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*\?>/

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

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

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,62 @@ body {
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 {
position: relative;
display: grid;
@ -199,3 +256,18 @@ body {
.delete-slide:focus-visible {
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;
}