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">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/main.ts
53
src/main.ts
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
const slides: CompiledSlide[] = input.slides.map((markup, index) => {
|
||||||
|
const extracted = extractStyles(markup)
|
||||||
|
sources.push(extracted.style)
|
||||||
|
return {
|
||||||
id: slideId(index),
|
id: slideId(index),
|
||||||
index,
|
index,
|
||||||
html: buildSlideHtml(markup, global)
|
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 }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue