diff --git a/bun.lock b/bun.lock index c48afdd..da71713 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/index.html b/index.html index 2f70b98..c9a8740 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,12 @@
- +
Slides diff --git a/package.json b/package.json index 541d3c7..f0e1710 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/code-to-element.ts b/src/code-to-element.ts index 55408cb..8c7e66c 100644 --- a/src/code-to-element.ts +++ b/src/code-to-element.ts @@ -3,6 +3,7 @@ import { domToSvgDataUrl } from './dom-to-image' export interface CodeToElementOptions { title?: string allowScripts?: boolean + /** Plain CSS strings — each becomes a `${(options.styles ?? []).join('\n')}${code}` + const headStyles = (options.styles ?? []).map((css) => ``).join('\n') + iframe.srcdoc = `${headStyles}${code}` return iframe } diff --git a/src/editor.ts b/src/editor.ts index cce20ce..5c9b840 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -21,9 +21,14 @@ self.MonacoEnvironment = { const MONACO_PARENT = '.code' -export function createEditor() { +/** + * One buffer, one model. A slide is HTML with an optional ` +
+
Example deck
+
${EXAMPLE_SECTION}
+
Global slide — wraps every slide
+
` + let globalSlide = '' const slides: Slide[] = [ { - code: '

test

' + code: `\n

test

` } ] const editor = createEditor() const slideList = document.querySelector('.slide-list-items')! const addSlideButton = document.querySelector('.add-slide')! +const stage = document.querySelector('.stage')! +const compileError = document.querySelector('.compile-error')! +const presentButton = document.querySelector('.present-button')! let currentSlide = 0 let draggedSlide: number | null = null let globalSlideElement: HTMLElement | null = null const slideElements = new Map() const thumbnailRequests = new Map() 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 ``, 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('.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 { + 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 `
` 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 = `
${inner}
` + 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( } 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() diff --git a/src/slides/compile.ts b/src/slides/compile.ts new file mode 100644 index 0000000..d86f909 --- /dev/null +++ b/src/slides/compile.ts @@ -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() +let nextRenderTask = 0 +/** `undefined` until first use, then the worker — or `null` once degraded. */ +let lessWorker: Worker | null | undefined + +function renderInline(source: string): Promise { + // 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 `` 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) => { + 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 { + const worker = getLessWorker() + if (!worker) { + return renderInline(source) + } + return new Promise((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 `` / `` for the runtime to fill in. + */ + global?: string +} + +export interface CompiledPresentation { + slides: CompiledSlide[] + css: string + /** + * The global slide, ready for the runtime. A template (`` + * inside) is rendered once as the scene; anything else was already merged + * into every slide and the runtime ignores it. + */ + template: string +} + +/** + * Pull `