diff --git a/src/code-to-element.ts b/src/code-to-element.ts
index 7c9bd6a..8973521 100644
--- a/src/code-to-element.ts
+++ b/src/code-to-element.ts
@@ -11,9 +11,11 @@ export function codeToElement(
options: CodeToElementOptions = {}
): HTMLIFrameElement {
const iframe = document.createElement('iframe')
- const sandbox = new Set
(['allow-same-origin'])
+ const sandbox = new Set()
- if (options.allowScripts) {
+ if (!options.allowScripts) {
+ sandbox.add('allow-same-origin')
+ } else {
sandbox.add('allow-scripts')
}
@@ -28,7 +30,7 @@ export async function createCodeThumbnail(
code: string,
options: CodeToElementOptions = {}
): Promise {
- const iframe = codeToElement(code, { title: 'Code thumbnail', ...options })
+ const iframe = codeToElement(code, { title: 'Code thumbnail', ...options, allowScripts: false })
iframe.style.width = '640px'
iframe.style.height = '360px'
iframe.style.position = 'fixed'
@@ -36,13 +38,19 @@ export async function createCodeThumbnail(
iframe.style.border = '0'
document.body.appendChild(iframe)
- await new Promise((resolve) => iframe.addEventListener('load', () => resolve(), { once: true }))
+ try {
+ await new Promise((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 thumbnail = document.createElement('img')
- if (content) {
- thumbnail.src = domToSvgDataUrl(content, { width: 640, height: 360 })
+ const content = iframe.contentDocument?.body
+ const thumbnail = document.createElement('img')
+ if (content) {
+ thumbnail.src = domToSvgDataUrl(content, { width: 640, height: 360 })
+ }
+ return thumbnail
+ } finally {
+ iframe.remove()
}
- iframe.remove()
- return thumbnail
}
diff --git a/src/editor.ts b/src/editor.ts
index 7fa74f5..62481d5 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -27,7 +27,7 @@ export function createEditor() {
{
value: 'test',
language: 'html',
- theme: 'vs-light',
+ theme: 'vs-dark',
automaticLayout: true
}
)
diff --git a/src/main.ts b/src/main.ts
index b7eeed2..6ec9581 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -15,8 +15,21 @@ const slides: Slide[] = [
]
const editor = createEditor()
-const slideList = document.querySelector('.slidelist')!
+const slideList = document.querySelector('.slide-list-items')!
+const addSlideButton = document.querySelector('.add-slide')!
let currentSlide = 0
+let draggedSlide: number | null = null
+let globalSlideElement: HTMLElement | null = null
+const slideElements = new Map()
+const thumbnailRequests = new Map()
+let thumbnailDebounceTimer: number | undefined
+
+function updateSelection() {
+ slideList.querySelectorAll('.slide-list-item').forEach((element) => {
+ const index = Number(element.dataset.slideId)
+ element.classList.toggle('selected', index === currentSlide)
+ })
+}
function setSlide(index: number) {
const slide = index !== -1 ? slides[index] : { code: globalSlide }
@@ -26,59 +39,215 @@ function setSlide(index: number) {
currentSlide = index
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')
slideInList.type = 'button'
slideInList.classList.add('slide-in-list')
+ slideInList.setAttribute('aria-label', i === -1 ? 'Global slide' : `Slide ${i + 1}`)
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)
- slideInList.appendChild(thumbnail)
- if (i === -1) {
- const p = document.createElement("p")
- p.innerText = "(global)"
- slideInList.appendChild(p)
+ slideInList.append(slideNumber, thumbnail)
+ slideElement.appendChild(slideInList)
+
+ if (i !== -1) {
+ 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() {
const slideListSlidesList = [{ code: globalSlide }, ...slides]
+ slideList.replaceChildren()
+ slideElements.clear()
+ globalSlideElement = null
for (let i = -1; i < slides.length; i++) {
- const slideInList = await generateSlideThumbnail(slideListSlidesList[i + 1], i)
- slideList.appendChild(slideInList)
+ slideList.appendChild(await generateSlideThumbnail(slideListSlidesList[i + 1], i))
}
+ updateSelection()
}
-editor.onDidChangeModelContent(async () => {
- const code = editor.getValue()
-
- let slide = null
-
- if (currentSlide === -1) {
- globalSlide = code
- slide = { code }
- } else {
- slides[currentSlide].code = code
- slide = slides[currentSlide]
+function updateSlideOrder() {
+ if (globalSlideElement) {
+ globalSlideElement.dataset.slideId = '-1'
+ slideList.appendChild(globalSlideElement)
}
- const slideEl = document.querySelector(`[data-slide-id="${currentSlide}"]`)
- const slthumb = await generateSlideThumbnail(slide, currentSlide)
- slideEl?.replaceWith(slthumb)
-})
+ for (const [index, slide] of slides.entries()) {
+ const slideElement = slideElements.get(slide)
+ if (!slideElement) {
+ continue
+ }
-function render() {
- setSlideList()
+ slideElement.dataset.slideId = index.toString()
+ const slideButton = slideElement.querySelector('.slide-in-list')
+ slideButton?.setAttribute('aria-label', `Slide ${index + 1}`)
+ const slideNumber = slideElement.querySelector('.slide-number')
+ if (slideNumber) {
+ slideNumber.textContent = `${index + 1}`
+ }
+ slideElement.querySelector('.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)
}
-render()
\ No newline at end of file
+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)
+})
+
+slideList.addEventListener('dragstart', (event) => {
+ const slideElement = (event.target as HTMLElement).closest('.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('.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)
+}
+
+render()
diff --git a/src/style.css b/src/style.css
index 25b1c8c..6837783 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,17 +1,34 @@
: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 {
margin: 0;
font-family: sans-serif;
+ background: var(--bg);
+ color: var(--text);
}
.container {
display: grid;
grid-template-columns: minmax(0, 0.6fr) minmax(0, 40%) minmax(0, 1fr);
grid-template-rows: 0.03fr 1fr;
- gap: 0px 0px;
+ gap: 0;
grid-auto-flow: row;
grid-template-areas:
"menubar menubar menubar"
@@ -22,8 +39,8 @@ body {
.menubar {
grid-area: menubar;
- background-color: #008800;
- color: white;
+ background-color: var(--accent);
+ color: var(--accent-text);
display: flex;
align-items: center;
padding-left: 1rem;
@@ -34,13 +51,55 @@ body {
.slidelist {
grid-area: slidelist;
min-width: 0;
+ background-color: var(--bg);
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 {
grid-area: code;
min-width: 0;
+ background-color: var(--bg-sunken);
border-right: 1px solid var(--border-color);
}
@@ -51,27 +110,100 @@ body {
align-items: center;
justify-content: center;
height: 100%;
+ background-color: var(--bg);
}
.slide {
display: block;
width: 100%;
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 {
- display: block;
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: 1.25rem minmax(0, 1fr);
+ gap: 0.4rem;
+ align-items: start;
width: 100%;
- border: 1px solid #000;
- padding: 0;
- margin-bottom: 1rem;
- background: transparent;
+ min-width: 0;
+ padding: 0.35rem;
+ border: 1px solid var(--slide-border);
+ background: var(--bg-raised);
+ color: var(--text);
+ text-align: left;
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 {
display: block;
width: 100%;
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;
}
\ No newline at end of file