Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"electron-updater": "^6.8.3",
"fabric": "^7.2.0",
"fontkit": "^2.0.4",
"gifuct-js": "^2.1.2",
"svelte-i18n": "^4.0.1",
"uuid": "^11.1.0"
},
Expand All @@ -51,6 +52,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.13",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.1.4",
"dotenv-cli": "^11.0.0",
"electron": "^40.6.1",
"electron-builder": "^26.8.1",
Expand All @@ -68,7 +70,6 @@
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vitest": "^4.1.4",
"@vitest/coverage-v8": "^4.1.4"
"vitest": "^4.1.4"
}
}
58 changes: 53 additions & 5 deletions src/renderer/src/Presentation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import { normalizeFontBytes, type FontBytes } from './lib/fontUtils'
import { shapeStyle } from './lib/shapeStyle'
import { getTextboxWrappingOptions } from './lib/textboxUtils'
import { GifAnimationManager, isGifDataUrl } from './lib/gif'

interface PresentationState {
/** ID of the slide to display — presentation window fetches the full slide from DB */
Expand Down Expand Up @@ -127,6 +128,10 @@

let canvasEl: HTMLCanvasElement
let presentationCanvas: Canvas | undefined
// Animates GIF images during presentation. Owned 1:1 with the canvas.
// Future video support should follow the same shape — one per-canvas manager,
// reset() per slide, dispose() on teardown — but as a separate manager.
let gifManager: GifAnimationManager | undefined
let currentState = $state<PresentationState>({
slideId: null,
slideIndex: 0,
Expand Down Expand Up @@ -177,6 +182,7 @@
interactive: false,
backgroundColor: '#ffffff'
})
gifManager = new GifAnimationManager(presentationCanvas)

scaleCanvas()

Expand All @@ -189,10 +195,37 @@
const { slideId, filePath } = newState

if (!slideId || !filePath) {
// Full teardown — invalidate every async pipeline (DB fetch, render,
// transition) and clear any visible canvas state so stale visuals,
// timers, and in-flight image/GIF work do not survive the wipe.
++fetchGeneration
++renderGeneration
++transitionGeneration

loadedSlide = null
lastRenderedSlideId = null
transitionOverlaySrc = null
transitionOverlayStyle = ''
slideWrapperStyle = 'position: relative;'
transitioning = false
animating = false
pendingAdvanceAfterImageLoad = false
animProgress = 0
hasRenderedOnce = false
lastSlideTransition = undefined

fabObjById.clear()
elementById.clear()
failedElementIds.clear()

gifManager?.reset()

if (presentationCanvas) {
presentationCanvas.remove(...presentationCanvas.getObjects())
presentationCanvas.backgroundImage = undefined
presentationCanvas.backgroundColor = '#ffffff'
presentationCanvas.renderAll()
}
return
}

Expand Down Expand Up @@ -222,6 +255,10 @@
})

onDestroy(() => {
// Dispose GIF manager BEFORE the canvas: dispose() cancels timers and
// schedulers that would otherwise touch a torn-down canvas.
gifManager?.dispose()
gifManager = undefined
if (presentationCanvas) {
presentationCanvas.dispose()
presentationCanvas = undefined
Expand Down Expand Up @@ -370,6 +407,9 @@
// Stamp the current generation so async image callbacks from a previous
// render can detect that the slide has since changed and bail out.
const generation = ++renderGeneration
// Reset GIF playback for the previous slide. Decode for the new slide's
// GIFs is scheduled below, after their Fabric images have been added.
gifManager?.reset()

presentationCanvas.remove(...presentationCanvas.getObjects())
fabObjById.clear()
Expand Down Expand Up @@ -521,6 +561,9 @@
fabObjById.set(element.id, img)
applyAnimationStateToObject(slide, element, img)
presentationCanvas.renderAll()
// Schedule GIF decode/playback. Fire-and-forget: never await — the
// static frame stays visible until decode completes.
if (isGifDataUrl(element.src)) gifManager?.register(element, img)
continuePendingAdvance(slide, generation)
})
.catch((err) => {
Expand All @@ -537,14 +580,19 @@

await Promise.allSettled([backgroundLoad, ...imageLoads])

if (!presentationCanvas || renderGeneration !== generation || imageLoads.length <= 1) return
if (!presentationCanvas || renderGeneration !== generation) return

const orderedObjects = (presentationCanvas.getObjects() as PresentationFabricObject[])
.slice()
.sort((a, b) => (zIndexById.get(a.id ?? '') ?? 0) - (zIndexById.get(b.id ?? '') ?? 0))
if (imageLoads.length > 1) {
const orderedObjects = (presentationCanvas.getObjects() as PresentationFabricObject[])
.slice()
.sort((a, b) => (zIndexById.get(a.id ?? '') ?? 0) - (zIndexById.get(b.id ?? '') ?? 0))

orderedObjects.forEach((obj, targetIndex) => presentationCanvas!.moveObjectTo(obj, targetIndex))
orderedObjects.forEach((obj, targetIndex) =>
presentationCanvas!.moveObjectTo(obj, targetIndex)
)
}
presentationCanvas.renderAll()
gifManager?.start()
}

function applyAnimationStateToObject(slide: Slide, el: TwigElement, fabObj: FabricObject): void {
Expand Down
38 changes: 37 additions & 1 deletion src/renderer/src/components/PropertiesPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import { DEFAULT_ARROW_SHAPE } from '../lib/types'
import { _ } from 'svelte-i18n'
import { isSvgDataUrl, normalizeSvgDataUrl } from '../lib/svg'
import { isGifDataUrl, getGifFirstFrameDataUrl } from '../lib/gif'

type RichText = {
isBold: boolean
Expand Down Expand Up @@ -262,6 +263,37 @@
const currentBg = $derived(appState.currentSlide?.background)
const bgType = $derived(currentBg?.type ?? 'solid')

// Native <img> tags animate GIFs. Decode just the first frame so the
// properties-panel preview matches the editor canvas (which stays static).
let bgPreviewSrc = $state<string | null>(null)
let bgPreviewKey = ''
$effect(() => {
const bg = currentBg
const src = bg?.type === 'image' ? bg.src : null
if (src === bgPreviewKey) return undefined
bgPreviewKey = src ?? ''
if (!src) {
bgPreviewSrc = null
return undefined
}
if (!isGifDataUrl(src)) {
bgPreviewSrc = src
return undefined
}
// Show the animated GIF first so the preview is never blank, then swap
// in the decoded first-frame PNG once available.
bgPreviewSrc = src
let cancelled = false
getGifFirstFrameDataUrl(src)
.then((staticUrl) => {
if (!cancelled && bgPreviewKey === src) bgPreviewSrc = staticUrl
})
.catch(() => {})
return () => {
cancelled = true
}
})

let activeTab = $derived.by<'solid' | 'gradient' | 'image'>(
() => bgType as 'solid' | 'gradient' | 'image'
)
Expand Down Expand Up @@ -1035,7 +1067,11 @@
<div class="space-y-2">
{#if currentBg?.type === 'image'}
<div class="rounded overflow-hidden border border-gray-200">
<img src={currentBg.src} alt="slide background" class="w-full object-cover h-16" />
<img
src={bgPreviewSrc ?? currentBg.src}
alt="slide background"
class="w-full object-cover h-16"
/>
</div>
<p class="text-xs text-gray-400 truncate">{currentBg.filename ?? 'image'}</p>
<div>
Expand Down
Loading
Loading