1
0
Fork 0
forked from jan/psslides

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.
This commit is contained in:
navi 2026-09-27 21:58:26 +02:00
parent 799921e213
commit 2282b13325
5 changed files with 61 additions and 113 deletions

View file

@ -12,22 +12,6 @@
<div class="menubar">
<b>p/slides</b>
<div class="menubar-actions">
<div class="buffer-tabs" role="group" aria-label="Editor buffer">
<button
class="buffer-tab buffer-tab-slides"
type="button"
aria-pressed="true"
>
Slides
</button>
<button
class="buffer-tab buffer-tab-style"
type="button"
aria-pressed="false"
>
Styles
</button>
</div>
<button class="present-button" type="button">Present</button>
</div>
</div>

View file

@ -21,39 +21,16 @@ self.MonacoEnvironment = {
const MONACO_PARENT = '.code'
/** Which buffer the single Monaco instance is currently showing. */
export type EditorBuffer = 'slides' | 'style'
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null
let slidesModel: monaco.editor.ITextModel | null = null
let styleModel: monaco.editor.ITextModel | null = null
let activeBuffer: EditorBuffer = 'slides'
export function createEditor(initialStyle: string): monaco.editor.IStandaloneCodeEditor {
const editor = monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: 'test',
/**
* One buffer, one model. A slide is HTML with an optional `<style>` tag and
* the Less lives inside that tag, so there is nothing to switch between. The
* CSS/LESS workers above still highlight, validate and complete that Less.
*/
export function createEditor(): monaco.editor.IStandaloneCodeEditor {
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: '',
language: 'html',
theme: 'vs-dark',
automaticLayout: true
})
editorInstance = editor
slidesModel = editor.getModel()
styleModel = monaco.editor.createModel(initialStyle, 'less')
activeBuffer = 'slides'
return editor
}
export function getEditorBuffer(): EditorBuffer {
return activeBuffer
}
/** Swap the visible buffer. Slide text lives in its own model, so styles and
* slide markup never overwrite each other. */
export function setEditorBuffer(buffer: EditorBuffer): void {
if (!editorInstance || !slidesModel || !styleModel || buffer === activeBuffer) {
return
}
activeBuffer = buffer
editorInstance.setModel(buffer === 'style' ? styleModel : slidesModel)
}

View file

@ -1,17 +1,18 @@
import './style.css'
import { createEditor, getEditorBuffer, setEditorBuffer } from './editor'
import { createEditor } from './editor'
import { createCodeThumbnail } from './code-to-element'
import { compilePresentation } from './slides/compile'
import { compilePresentation, extractStyles } from './slides/compile'
import { createRuntime, type Runtime } from './slides/runtime'
interface Slide {
code: string
}
// Starter stylesheet for a new project. Authors write Less here and HTML in the
// slides; `:thisSlide` compiles to each slide's own selector, so deck styles
// stay scoped without inventing any extra CSS syntax.
const DEFAULT_STYLE = `// Project styles. This is ordinary Less: variables, nesting,
// The starter slide opens with a <style> tag. Less written inside any <style>
// tag in the deck becomes the project stylesheet, and `:thisSlide` compiles to
// each slide's own selector, so deck styles stay scoped without inventing any
// extra CSS syntax.
const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nesting,
// mixins and imports all work as usual.
//
// :thisSlide compiles to the selector of the slide this stylesheet is being
@ -50,22 +51,19 @@ const DEFAULT_STYLE = `// Project styles. This is ordinary Less: variables, nest
}
`
let projectStyle = DEFAULT_STYLE
let globalSlide = ''
const slides: Slide[] = [
{
code: '<h1>test</h1>'
code: `<style>\n${STARTER_STYLE}\n</style>\n<h1>test</h1>`
}
]
const editor = createEditor(projectStyle)
const editor = createEditor()
const slideList = document.querySelector<HTMLElement>('.slide-list-items')!
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
const stage = document.querySelector<HTMLElement>('.stage')!
const compileError = document.querySelector<HTMLElement>('.compile-error')!
const slidesTab = document.querySelector<HTMLButtonElement>('.buffer-tab-slides')!
const styleTab = document.querySelector<HTMLButtonElement>('.buffer-tab-style')!
const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
let currentSlide = 0
let draggedSlide: number | null = null
@ -93,14 +91,6 @@ function updateSelection() {
})
}
function syncBufferTabs() {
const showingSlides = getEditorBuffer() === 'slides'
slidesTab.classList.toggle('active', showingSlides)
styleTab.classList.toggle('active', !showingSlides)
slidesTab.setAttribute('aria-pressed', String(showingSlides))
styleTab.setAttribute('aria-pressed', String(!showingSlides))
}
function setEditorText(text: string) {
applyingToEditor = true
try {
@ -111,8 +101,6 @@ function setEditorText(text: string) {
}
function showSlideInEditor(index: number) {
setEditorBuffer('slides')
syncBufferTabs()
setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? ''))
}
@ -150,8 +138,7 @@ async function rebuildPreview(): Promise<void> {
try {
const result = await compilePresentation({
slides: slides.map((slide) => slide.code),
global: globalSlide,
style: projectStyle
global: globalSlide
})
slideStyles.textContent = result.css
@ -199,7 +186,9 @@ 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)
// Style tags are the project stylesheet, never slide content: the
// thumbnail renders markup only, exactly like the presentation does.
const thumbnail = await createCodeThumbnail(extractStyles(slide.code).html)
slideInList.append(slideNumber, thumbnail)
slideElement.append(slideInList)
@ -357,15 +346,6 @@ addSlideButton.addEventListener('click', async () => {
scheduleCompile()
})
slidesTab.addEventListener('click', () => {
showSlideInEditor(currentSlide)
})
styleTab.addEventListener('click', () => {
setEditorBuffer('style')
syncBufferTabs()
})
presentButton.addEventListener('click', () => {
stage.requestFullscreen().catch(() => {
// Browsers can refuse fullscreen; presenting inside the panel still works.
@ -418,12 +398,6 @@ editor.onDidChangeModelContent(() => {
return
}
if (getEditorBuffer() === 'style') {
projectStyle = editor.getValue()
scheduleCompile()
return
}
const code = editor.getValue()
const slideIndex = currentSlide
const slide = slideIndex === -1 ? { code } : slides[slideIndex]
@ -445,7 +419,6 @@ editor.onDidChangeModelContent(() => {
})
async function render() {
syncBufferTabs()
await setSlideList()
setSlide(currentSlide)
await rebuildPreview()

View file

@ -26,8 +26,6 @@ export interface CompileInput {
slides: string[]
/** HTML shared by every slide (the "global" slide in the editor). */
global?: string
/** Project Less source. `:thisSlide` resolves to the slide being compiled. */
style: string
}
export interface CompiledPresentation {
@ -35,6 +33,28 @@ export interface CompiledPresentation {
css: 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.
*/
export function extractStyles(markup: string): { html: string; style: string } {
if (!markup.includes('<style')) {
return { html: markup, style: '' }
}
const parsed = new DOMParser().parseFromString(markup, 'text/html')
const blocks: string[] = []
for (const element of Array.from(parsed.querySelectorAll('style'))) {
blocks.push(element.textContent ?? '')
element.remove()
}
return { html: parsed.body.innerHTML, style: blocks.join('\n') }
}
function buildSlideHtml(markup: string, global: string): string {
return global ? `${global}\n${markup}` : markup
}
@ -42,21 +62,28 @@ function buildSlideHtml(markup: string, global: string): string {
/**
* Compile a project into runtime-ready data.
*
* `:thisSlide` is replaced with the slide's own selector *before* Less runs, so
* Less keeps behaving like Less: nesting, variables, mixins and imports all work
* unchanged, and the output is deterministic — it never depends on which slide
* happens to be visible.
* Every `<style>` tag in the deck contributes to one project stylesheet (the
* global slide first, then each slide in order). That stylesheet is compiled
* once per slide, with `:thisSlide` replaced by the selector of the slide being
* compiled — so Less keeps behaving like Less, and the output never depends on
* which slide happens to be visible.
*/
export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> {
const global = input.global ?? ''
const slides: CompiledSlide[] = input.slides.map((markup, index) => ({
const global = extractStyles(input.global ?? '')
const sources: string[] = [global.style]
const slides: CompiledSlide[] = input.slides.map((markup, index) => {
const extracted = extractStyles(markup)
sources.push(extracted.style)
return {
id: slideId(index),
index,
html: buildSlideHtml(markup, global)
}))
html: buildSlideHtml(extracted.html, global.html)
}
})
const css = await compileStyles(
input.style,
sources.join('\n'),
slides.map((slide) => slide.id)
)
return { slides, css }

View file

@ -155,12 +155,6 @@ body {
margin-left: auto;
}
.buffer-tabs {
display: flex;
gap: 0.25rem;
}
.buffer-tab,
.present-button {
padding: 0.25rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.55);
@ -171,13 +165,6 @@ body {
border-radius: 3px;
}
.buffer-tab.active {
background: var(--accent-text);
color: var(--accent);
}
.buffer-tab:hover,
.buffer-tab:focus-visible,
.present-button:hover,
.present-button:focus-visible {
background: rgba(255, 255, 255, 0.2);