Presentations

Designing collaborative presentation software with slides, templates, and views.

Document Structure

Document
├── slides: Map<slideId, SlideData>
├── slideOrder: Array<slideId>
├── containers: Map<containerId, Container>
├── masters: Map<masterId, MasterTemplate>
├── styles: Map<styleId, Style>
└── notes: Map<slideId, string | Y.Text>

Slides contain references to containers (text boxes, shapes, images). Masters define reusable layouts and styles.

Slide/Container Relationship

interface Slide {
  id: string
  masterId: string
  containerIds: string[]
  background?: Background
}

interface Container {
  id: string
  slideId: string
  type: 'text' | 'image' | 'shape'
  x: number; y: number
  width: number; height: number
  contentId?: string  // For text: Y.Text ID; for image: blob ID
  styleId?: string
  zIndex: number
}

When duplicating a slide, duplicate its containers too:

function duplicateSlide(slideId: string): string {
  const slide = slides.get(slideId)
  const newId = nanoid(8)
  const newContainerIds: string[] = []

  yDoc.transact(() => {
    for (const cid of slide.containerIds) {
      const container = containers.get(cid)
      const newCid = nanoid(8)
      containers.set(newCid, { ...container, id: newCid, slideId: newId })
      newContainerIds.push(newCid)
    }

    slides.set(newId, {
      ...slide, id: newId,
      containerIds: newContainerIds
    })

    const index = slideOrder.toArray().indexOf(slideId)
    slideOrder.insert(index + 1, [newId])
  })

  return newId
}

Style Inheritance

Resolve styles by cascading: master → slide → container:

function resolveStyle(container: Container): ResolvedStyle {
  const slide = slides.get(container.slideId)
  const master = masters.get(slide.masterId)

  let style = {}

  // 1. Master base style
  if (master?.styles?.[container.styleId]) {
    style = { ...master.styles[container.styleId] }
  }

  // 2. Slide overrides
  if (slide?.styleOverrides?.[container.styleId]) {
    style = { ...style, ...slide.styleOverrides[container.styleId] }
  }

  // 3. Container overrides
  if (container.styleOverrides) {
    style = { ...style, ...container.styleOverrides }
  }

  return style
}

Presentation Mode

Presentation state is local (not in CRDT), but can be shared via awareness:

// Broadcast current slide for "follow presenter" mode
awareness.setLocalStateField('presenting', {
  slideIndex: currentIndex,
  user: getCurrentUser()
})

// Follow mode: sync to presenter's slide
awareness.on('change', () => {
  if (followingClientId) {
    const state = awareness.getStates().get(followingClientId)
    if (state?.presenting) {
      goToSlide(state.presenting.slideIndex)
    }
  }
})

Common Mistakes

Slide content directly in slideOrder:

// WRONG: content in array
slideOrder.push([{ title: 'Intro', containers: [...] }])

// CORRECT: content separate
slides.set(id, { title: 'Intro', containerIds: [...] })
slideOrder.push([id])

Not cleaning up containers on slide delete:

function deleteSlide(slideId: string) {
  const slide = slides.get(slideId)
  yDoc.transact(() => {
    // Delete containers first
    for (const cid of slide.containerIds) {
      containers.delete(cid)
    }
    // Then slide
    const index = slideOrder.toArray().indexOf(slideId)
    if (index !== -1) slideOrder.delete(index, 1)
    slides.delete(slideId)
  })
}

See Also