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"> <div class="menubar">
<b>p/slides</b> <b>p/slides</b>
<div class="menubar-actions"> <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> <button class="present-button" type="button">Present</button>
</div> </div>
</div> </div>

View file

@ -21,39 +21,16 @@ self.MonacoEnvironment = {
const MONACO_PARENT = '.code' const MONACO_PARENT = '.code'
/** Which buffer the single Monaco instance is currently showing. */ /**
export type EditorBuffer = 'slides' | 'style' * 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
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null * CSS/LESS workers above still highlight, validate and complete that Less.
let slidesModel: monaco.editor.ITextModel | null = null */
let styleModel: monaco.editor.ITextModel | null = null export function createEditor(): monaco.editor.IStandaloneCodeEditor {
let activeBuffer: EditorBuffer = 'slides' return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: '',
export function createEditor(initialStyle: string): monaco.editor.IStandaloneCodeEditor {
const editor = monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: 'test',
language: 'html', language: 'html',
theme: 'vs-dark', theme: 'vs-dark',
automaticLayout: true 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 './style.css'
import { createEditor, getEditorBuffer, setEditorBuffer } from './editor' import { createEditor } from './editor'
import { createCodeThumbnail } from './code-to-element' import { createCodeThumbnail } from './code-to-element'
import { compilePresentation } from './slides/compile' import { compilePresentation, extractStyles } from './slides/compile'
import { createRuntime, type Runtime } from './slides/runtime' import { createRuntime, type Runtime } from './slides/runtime'
interface Slide { interface Slide {
code: string code: string
} }
// Starter stylesheet for a new project. Authors write Less here and HTML in the // The starter slide opens with a <style> tag. Less written inside any <style>
// slides; `:thisSlide` compiles to each slide's own selector, so deck styles // tag in the deck becomes the project stylesheet, and `:thisSlide` compiles to
// stay scoped without inventing any extra CSS syntax. // each slide's own selector, so deck styles stay scoped without inventing any
const DEFAULT_STYLE = `// Project styles. This is ordinary Less: variables, nesting, // extra CSS syntax.
const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nesting,
// mixins and imports all work as usual. // mixins and imports all work as usual.
// //
// :thisSlide compiles to the selector of the slide this stylesheet is being // :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 = '' let globalSlide = ''
const slides: Slide[] = [ 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 slideList = document.querySelector<HTMLElement>('.slide-list-items')!
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')! const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
const stage = document.querySelector<HTMLElement>('.stage')! const stage = document.querySelector<HTMLElement>('.stage')!
const compileError = document.querySelector<HTMLElement>('.compile-error')! 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')! const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
let currentSlide = 0 let currentSlide = 0
let draggedSlide: number | null = null 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) { function setEditorText(text: string) {
applyingToEditor = true applyingToEditor = true
try { try {
@ -111,8 +101,6 @@ function setEditorText(text: string) {
} }
function showSlideInEditor(index: number) { function showSlideInEditor(index: number) {
setEditorBuffer('slides')
syncBufferTabs()
setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? '')) setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? ''))
} }
@ -150,8 +138,7 @@ async function rebuildPreview(): Promise<void> {
try { try {
const result = await compilePresentation({ const result = await compilePresentation({
slides: slides.map((slide) => slide.code), slides: slides.map((slide) => slide.code),
global: globalSlide, global: globalSlide
style: projectStyle
}) })
slideStyles.textContent = result.css slideStyles.textContent = result.css
@ -199,7 +186,9 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
slideNumber.classList.add('slide-number') slideNumber.classList.add('slide-number')
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}` slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
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) slideInList.append(slideNumber, thumbnail)
slideElement.append(slideInList) slideElement.append(slideInList)
@ -357,15 +346,6 @@ addSlideButton.addEventListener('click', async () => {
scheduleCompile() scheduleCompile()
}) })
slidesTab.addEventListener('click', () => {
showSlideInEditor(currentSlide)
})
styleTab.addEventListener('click', () => {
setEditorBuffer('style')
syncBufferTabs()
})
presentButton.addEventListener('click', () => { presentButton.addEventListener('click', () => {
stage.requestFullscreen().catch(() => { stage.requestFullscreen().catch(() => {
// Browsers can refuse fullscreen; presenting inside the panel still works. // Browsers can refuse fullscreen; presenting inside the panel still works.
@ -418,12 +398,6 @@ editor.onDidChangeModelContent(() => {
return return
} }
if (getEditorBuffer() === 'style') {
projectStyle = editor.getValue()
scheduleCompile()
return
}
const code = editor.getValue() const code = editor.getValue()
const slideIndex = currentSlide const slideIndex = currentSlide
const slide = slideIndex === -1 ? { code } : slides[slideIndex] const slide = slideIndex === -1 ? { code } : slides[slideIndex]
@ -445,7 +419,6 @@ editor.onDidChangeModelContent(() => {
}) })
async function render() { async function render() {
syncBufferTabs()
await setSlideList() await setSlideList()
setSlide(currentSlide) setSlide(currentSlide)
await rebuildPreview() await rebuildPreview()

View file

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

View file

@ -155,12 +155,6 @@ body {
margin-left: auto; margin-left: auto;
} }
.buffer-tabs {
display: flex;
gap: 0.25rem;
}
.buffer-tab,
.present-button { .present-button {
padding: 0.25rem 0.7rem; padding: 0.25rem 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.55); border: 1px solid rgba(255, 255, 255, 0.55);
@ -171,13 +165,6 @@ body {
border-radius: 3px; 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:hover,
.present-button:focus-visible { .present-button:focus-visible {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);