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:
parent
799921e213
commit
2282b13325
5 changed files with 61 additions and 113 deletions
16
index.html
16
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
53
src/main.ts
53
src/main.ts
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue