diff --git a/index.html b/index.html index efb8e9d..963aa4e 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,13 @@
-
+
+
+ Slides + +
+
+
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