1
0
Fork 0
forked from jan/psslides

refactor css + add dark mode + maybe some other stuff idk dont remember what else i did

This commit is contained in:
jan 2026-09-24 20:18:18 +02:00
parent 22e17a92d4
commit e309455edc
5 changed files with 368 additions and 53 deletions

View file

@ -11,7 +11,13 @@
<body> <body>
<div class="container"> <div class="container">
<div class="menubar"><b>p/slides</b></div> <div class="menubar"><b>p/slides</b></div>
<div class="slidelist"></div> <div class="slidelist">
<div class="slide-list-header">
<span>Slides</span>
<button class="add-slide" type="button" aria-label="Add slide">+</button>
</div>
<div class="slide-list-items"></div>
</div>
<div class="code"></div> <div class="code"></div>
<div class="preview"> <div class="preview">
<div class="slide"></div> <div class="slide"></div>

View file

@ -11,9 +11,11 @@ export function codeToElement(
options: CodeToElementOptions = {} options: CodeToElementOptions = {}
): HTMLIFrameElement { ): HTMLIFrameElement {
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
const sandbox = new Set<string>(['allow-same-origin']) const sandbox = new Set<string>()
if (options.allowScripts) { if (!options.allowScripts) {
sandbox.add('allow-same-origin')
} else {
sandbox.add('allow-scripts') sandbox.add('allow-scripts')
} }
@ -28,7 +30,7 @@ export async function createCodeThumbnail(
code: string, code: string,
options: CodeToElementOptions = {} options: CodeToElementOptions = {}
): Promise<HTMLImageElement> { ): Promise<HTMLImageElement> {
const iframe = codeToElement(code, { title: 'Code thumbnail', ...options }) const iframe = codeToElement(code, { title: 'Code thumbnail', ...options, allowScripts: false })
iframe.style.width = '640px' iframe.style.width = '640px'
iframe.style.height = '360px' iframe.style.height = '360px'
iframe.style.position = 'fixed' iframe.style.position = 'fixed'
@ -36,13 +38,19 @@ export async function createCodeThumbnail(
iframe.style.border = '0' iframe.style.border = '0'
document.body.appendChild(iframe) document.body.appendChild(iframe)
await new Promise<void>((resolve) => iframe.addEventListener('load', () => resolve(), { once: true })) try {
await new Promise<void>((resolve, reject) => {
iframe.addEventListener('load', () => resolve(), { once: true })
iframe.addEventListener('error', () => reject(new Error('Unable to render code thumbnail')), { once: true })
})
const content = iframe.contentDocument?.body const content = iframe.contentDocument?.body
const thumbnail = document.createElement('img') const thumbnail = document.createElement('img')
if (content) { if (content) {
thumbnail.src = domToSvgDataUrl(content, { width: 640, height: 360 }) thumbnail.src = domToSvgDataUrl(content, { width: 640, height: 360 })
} }
iframe.remove()
return thumbnail return thumbnail
} finally {
iframe.remove()
}
} }

View file

@ -27,7 +27,7 @@ export function createEditor() {
{ {
value: 'test', value: 'test',
language: 'html', language: 'html',
theme: 'vs-light', theme: 'vs-dark',
automaticLayout: true automaticLayout: true
} }
) )

View file

@ -15,8 +15,21 @@ const slides: Slide[] = [
] ]
const editor = createEditor() const editor = createEditor()
const slideList = document.querySelector<HTMLElement>('.slidelist')! const slideList = document.querySelector<HTMLElement>('.slide-list-items')!
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
let currentSlide = 0 let currentSlide = 0
let draggedSlide: number | null = null
let globalSlideElement: HTMLElement | null = null
const slideElements = new Map<Slide, HTMLElement>()
const thumbnailRequests = new Map<number, number>()
let thumbnailDebounceTimer: number | undefined
function updateSelection() {
slideList.querySelectorAll<HTMLElement>('.slide-list-item').forEach((element) => {
const index = Number(element.dataset.slideId)
element.classList.toggle('selected', index === currentSlide)
})
}
function setSlide(index: number) { function setSlide(index: number) {
const slide = index !== -1 ? slides[index] : { code: globalSlide } const slide = index !== -1 ? slides[index] : { code: globalSlide }
@ -26,58 +39,214 @@ function setSlide(index: number) {
currentSlide = index currentSlide = index
editor.getModel()?.setValue(slide.code) editor.getModel()?.setValue(slide.code)
updateSelection()
} }
async function generateSlideThumbnail(slide: any, i: number) { async function generateSlideThumbnail(slide: Slide, i: number) {
const slideElement = document.createElement('div')
slideElement.classList.add('slide-list-item')
slideElement.dataset.slideId = i.toString()
if (i === -1) {
slideElement.classList.add('global-slide')
} else {
slideElement.draggable = true
slideElements.set(slide, slideElement)
}
const slideInList = document.createElement('button') const slideInList = document.createElement('button')
slideInList.type = 'button' slideInList.type = 'button'
slideInList.classList.add('slide-in-list') slideInList.classList.add('slide-in-list')
slideInList.setAttribute('aria-label', i === -1 ? 'Global slide' : `Slide ${i + 1}`)
slideInList.addEventListener('click', () => { slideInList.addEventListener('click', () => {
setSlide(i) setSlide(Number(slideElement.dataset.slideId))
}) })
slideInList.dataset.slideId = i.toString()
const slideNumber = document.createElement('span')
slideNumber.classList.add('slide-number')
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
const thumbnail = await createCodeThumbnail(slide.code) const thumbnail = await createCodeThumbnail(slide.code)
slideInList.appendChild(thumbnail) slideInList.append(slideNumber, thumbnail)
if (i === -1) { slideElement.appendChild(slideInList)
const p = document.createElement("p")
p.innerText = "(global)" if (i !== -1) {
slideInList.appendChild(p) const deleteButton = document.createElement('button')
deleteButton.type = 'button'
deleteButton.classList.add('delete-slide')
deleteButton.setAttribute('aria-label', `Delete slide ${i + 1}`)
deleteButton.textContent = '×'
deleteButton.addEventListener('click', () => {
deleteSlide(Number(slideElement.dataset.slideId))
})
slideElement.appendChild(deleteButton)
} }
return slideInList if (i === -1) {
globalSlideElement = slideElement
}
if (i === currentSlide) {
slideElement.classList.add('selected')
}
return slideElement
}
async function refreshThumbnail(index: number, slide: Slide) {
const request = (thumbnailRequests.get(index) ?? 0) + 1
thumbnailRequests.set(index, request)
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
const slideElement = await generateSlideThumbnail(slide, index)
if (thumbnailRequests.get(index) !== request) {
return
}
oldSlideElement?.replaceWith(slideElement)
if (index === -1) {
globalSlideElement = slideElement
} else {
slideElements.set(slide, slideElement)
}
updateSelection()
} }
async function setSlideList() { async function setSlideList() {
const slideListSlidesList = [{ code: globalSlide }, ...slides] const slideListSlidesList = [{ code: globalSlide }, ...slides]
slideList.replaceChildren()
slideElements.clear()
globalSlideElement = null
for (let i = -1; i < slides.length; i++) { for (let i = -1; i < slides.length; i++) {
const slideInList = await generateSlideThumbnail(slideListSlidesList[i + 1], i) slideList.appendChild(await generateSlideThumbnail(slideListSlidesList[i + 1], i))
slideList.appendChild(slideInList)
} }
updateSelection()
} }
editor.onDidChangeModelContent(async () => { function updateSlideOrder() {
const code = editor.getValue() if (globalSlideElement) {
globalSlideElement.dataset.slideId = '-1'
let slide = null slideList.appendChild(globalSlideElement)
if (currentSlide === -1) {
globalSlide = code
slide = { code }
} else {
slides[currentSlide].code = code
slide = slides[currentSlide]
} }
const slideEl = document.querySelector(`[data-slide-id="${currentSlide}"]`) for (const [index, slide] of slides.entries()) {
const slthumb = await generateSlideThumbnail(slide, currentSlide) const slideElement = slideElements.get(slide)
slideEl?.replaceWith(slthumb) if (!slideElement) {
continue
}
slideElement.dataset.slideId = index.toString()
const slideButton = slideElement.querySelector<HTMLButtonElement>('.slide-in-list')
slideButton?.setAttribute('aria-label', `Slide ${index + 1}`)
const slideNumber = slideElement.querySelector<HTMLElement>('.slide-number')
if (slideNumber) {
slideNumber.textContent = `${index + 1}`
}
slideElement.querySelector<HTMLButtonElement>('.delete-slide')?.setAttribute('aria-label', `Delete slide ${index + 1}`)
slideList.appendChild(slideElement)
}
updateSelection()
}
function moveSlide(from: number, to: number) {
if (from === to || from < 0 || to < 0) {
return
}
const [movedSlide] = slides.splice(from, 1)
slides.splice(to, 0, movedSlide)
if (currentSlide === from) {
currentSlide = to
} else if (from < currentSlide && to >= currentSlide) {
currentSlide--
} else if (from > currentSlide && to <= currentSlide) {
currentSlide++
}
updateSlideOrder()
editor.getModel()?.setValue(slides[currentSlide]?.code ?? globalSlide)
}
function deleteSlide(index: number) {
if (index < 0 || slides.length <= 1) {
return
}
const [deletedSlide] = slides.splice(index, 1)
slideElements.get(deletedSlide)?.remove()
slideElements.delete(deletedSlide)
if (index < currentSlide) {
currentSlide--
} else if (index === currentSlide) {
currentSlide = Math.min(currentSlide, slides.length - 1)
}
updateSlideOrder()
setSlide(currentSlide)
}
addSlideButton.addEventListener('click', async () => {
const slide = { code: '' }
slides.push(slide)
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
slideList.appendChild(slideElement)
updateSlideOrder()
setSlide(slides.length - 1)
}) })
function render() { slideList.addEventListener('dragstart', (event) => {
setSlideList() const slideElement = (event.target as HTMLElement).closest<HTMLElement>('.slide-list-item')
const index = slideElement ? Number(slideElement.dataset.slideId) : -1
if (index < 0) {
event.preventDefault()
return
}
draggedSlide = index
event.dataTransfer?.setData('text/plain', index.toString())
})
slideList.addEventListener('dragover', (event) => {
if (draggedSlide !== null) {
event.preventDefault()
}
})
slideList.addEventListener('drop', (event) => {
event.preventDefault()
const target = (event.target as HTMLElement).closest<HTMLElement>('.slide-list-item')
const targetIndex = target ? Number(target.dataset.slideId) : -1
if (draggedSlide !== null && targetIndex >= 0) {
moveSlide(draggedSlide, targetIndex)
}
draggedSlide = null
})
slideList.addEventListener('dragend', () => {
draggedSlide = null
})
editor.onDidChangeModelContent(() => {
const code = editor.getValue()
const slideIndex = currentSlide
const slide = slideIndex === -1 ? { code } : slides[slideIndex]
if (slideIndex === -1) {
globalSlide = code
} else {
slides[slideIndex].code = code
}
if (thumbnailDebounceTimer !== undefined) {
window.clearTimeout(thumbnailDebounceTimer)
}
thumbnailDebounceTimer = window.setTimeout(() => {
thumbnailDebounceTimer = undefined
void refreshThumbnail(slideIndex, slide)
}, 250)
})
async function render() {
await setSlideList()
setSlide(currentSlide) setSlide(currentSlide)
} }

View file

@ -1,17 +1,34 @@
:root { :root {
--border-color: #3f3f3f color-scheme: dark;
--bg: #181818;
--bg-raised: #202020;
--bg-sunken: #101010;
--text: #e8e8e8;
--text-muted: #a0a0a0;
--border-color: #3f3f3f;
--accent: #008800;
--accent-text: #ffffff;
--slide-bg: #ffffff;
--slide-border: #000000;
} }
body { body {
margin: 0; margin: 0;
font-family: sans-serif; font-family: sans-serif;
background: var(--bg);
color: var(--text);
} }
.container { .container {
display: grid; display: grid;
grid-template-columns: minmax(0, 0.6fr) minmax(0, 40%) minmax(0, 1fr); grid-template-columns: minmax(0, 0.6fr) minmax(0, 40%) minmax(0, 1fr);
grid-template-rows: 0.03fr 1fr; grid-template-rows: 0.03fr 1fr;
gap: 0px 0px; gap: 0;
grid-auto-flow: row; grid-auto-flow: row;
grid-template-areas: grid-template-areas:
"menubar menubar menubar" "menubar menubar menubar"
@ -22,8 +39,8 @@ body {
.menubar { .menubar {
grid-area: menubar; grid-area: menubar;
background-color: #008800; background-color: var(--accent);
color: white; color: var(--accent-text);
display: flex; display: flex;
align-items: center; align-items: center;
padding-left: 1rem; padding-left: 1rem;
@ -34,13 +51,55 @@ body {
.slidelist { .slidelist {
grid-area: slidelist; grid-area: slidelist;
min-width: 0; min-width: 0;
background-color: var(--bg);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
padding: 2rem; overflow-y: auto;
}
.slide-list-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 3rem;
padding: 0 1rem;
border-bottom: 1px solid var(--border-color);
color: var(--text);
font-weight: 600;
}
.add-slide {
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid var(--border-color);
background: var(--bg-raised);
color: var(--text);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.add-slide:hover,
.add-slide:focus-visible,
.delete-slide:hover,
.delete-slide:focus-visible {
border-color: var(--accent);
color: var(--accent-text);
background: var(--accent);
outline: none;
}
.slide-list-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
} }
.code { .code {
grid-area: code; grid-area: code;
min-width: 0; min-width: 0;
background-color: var(--bg-sunken);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
} }
@ -51,27 +110,100 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
background-color: var(--bg);
} }
.slide { .slide {
display: block; display: block;
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
border: #000 solid 1px; background: var(--slide-bg);
border: 1px solid var(--slide-border);
}
.slide-list-item {
position: relative;
display: grid;
grid-template-columns: 1.25rem minmax(0, 1fr);
gap: 0.4rem;
align-items: start;
min-width: 0;
} }
.slide-in-list { .slide-in-list {
display: block; grid-column: 1 / -1;
display: grid;
grid-template-columns: 1.25rem minmax(0, 1fr);
gap: 0.4rem;
align-items: start;
width: 100%; width: 100%;
border: 1px solid #000; min-width: 0;
padding: 0; padding: 0.35rem;
margin-bottom: 1rem; border: 1px solid var(--slide-border);
background: transparent; background: var(--bg-raised);
color: var(--text);
text-align: left;
cursor: pointer; cursor: pointer;
} }
.slide-in-list:hover,
.slide-in-list:focus-visible {
border-color: var(--accent);
outline: none;
}
.slide-list-item.selected .slide-in-list {
border-color: var(--accent);
box-shadow: inset 2px 0 0 var(--accent);
}
.slide-number {
display: block;
min-height: 1rem;
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1rem;
text-align: center;
}
.global-slide {
padding-left: 0.25rem;
border-left: 2px solid var(--accent);
}
.global-slide .slide-number {
color: var(--accent);
font-weight: 700;
}
.slide-in-list img { .slide-in-list img {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: 16 / 9;
object-fit: contain;
background: var(--slide-bg);
}
.delete-slide {
position: absolute;
top: 0.25rem;
right: 0.25rem;
display: grid;
place-items: center;
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: 1px solid var(--border-color);
background: var(--bg-raised);
color: var(--text);
font-size: 1rem;
line-height: 1;
opacity: 0;
cursor: pointer;
}
.slide-list-item:hover .delete-slide,
.delete-slide:focus-visible {
opacity: 1;
} }