Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
@@ -0,0 +1,453 @@
import { BindingUtil, TLBaseBinding, IndexKey, Vec, TLShapeId } from '@tldraw/tldraw'
import { CCSlideShowShape } from './CCSlideShowShapeUtil'
import { CCSlideShape } from './CCSlideShapeUtil'
import { CC_SLIDESHOW_STYLE_CONSTANTS } from '../cc-styles'
import { logger } from '../../../../debugConfig'
export interface CCSlideLayoutBinding extends TLBaseBinding<'cc-slide-layout', {
index: IndexKey
isMovingWithParent: boolean
placeholder: boolean
}> {}
export class CCSlideLayoutBindingUtil extends BindingUtil<CCSlideLayoutBinding> {
static type = 'cc-slide-layout' as const
getDefaultProps(): CCSlideLayoutBinding['props'] {
return {
index: 'a1' as IndexKey,
isMovingWithParent: false,
placeholder: false
}
}
private updateSlidePosition(binding: CCSlideLayoutBinding) {
const { fromId: slideshowId, toId: slideId } = binding
const slideshow = this.editor.getShape<CCSlideShowShape>(slideshowId)
const slide = this.editor.getShape<CCSlideShape>(slideId)
if (!slideshow || !slide) return
// Get all bindings from the slideshow, sorted by index
const bindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Find position in sorted bindings array
const index = bindings.findIndex(b => b.id === binding.id)
if (index === -1) return
// Get all slides and their dimensions up to the current index
const slidesBeforeCurrent = bindings
.slice(0, index)
.map(b => {
const s = this.editor.getShape<CCSlideShape>(b.toId)
return s ? { width: s.props.w, height: s.props.h } : null
})
.filter(s => s !== null) as { width: number, height: number }[]
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Calculate new position based on pattern
let offset: Vec = new Vec(0, 0)
if (slideshow.props.slidePattern === 'horizontal') {
// Sum widths of all previous slides plus spacing
const totalWidthBefore = slidesBeforeCurrent.reduce((sum, s) => sum + s.width + spacing, 0)
offset = new Vec(
spacing + totalWidthBefore,
headerHeight + contentPadding + spacing
)
} else if (slideshow.props.slidePattern === 'vertical') {
// Sum heights of all previous slides plus spacing
const totalHeightBefore = slidesBeforeCurrent.reduce((sum, s) => sum + s.height + spacing, 0)
offset = new Vec(
spacing,
headerHeight + contentPadding + spacing + totalHeightBefore
)
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(bindings.length))
const row = Math.floor(index / cols)
const col = index % cols
// Get maximum dimensions for each column and row up to current position
const colWidths = new Array(cols).fill(0)
const rowHeights = new Array(Math.ceil(bindings.length / cols)).fill(0)
bindings.forEach((b, i) => {
const s = this.editor.getShape<CCSlideShape>(b.toId)
if (!s) return
const r = Math.floor(i / cols)
const c = i % cols
colWidths[c] = Math.max(colWidths[c], s.props.w)
rowHeights[r] = Math.max(rowHeights[r], s.props.h)
})
// Calculate position based on accumulated column widths and row heights
const xPos = spacing + colWidths.slice(0, col).reduce((sum, w) => sum + w + spacing, 0)
const yPos = headerHeight + contentPadding + spacing +
rowHeights.slice(0, row).reduce((sum, h) => sum + h + spacing, 0)
offset = new Vec(xPos, yPos)
} else if (slideshow.props.slidePattern === 'radial') {
// For radial pattern, calculate position based on index and total slides
const totalSlides = bindings.length
const angle = (2 * Math.PI * index) / totalSlides
// Find the largest slide dimension to determine radius
const maxDimension = Math.max(
...bindings.map(b => {
const s = this.editor.getShape<CCSlideShape>(b.toId)
return s ? Math.max(s.props.w, s.props.h) : 0
})
)
const radius = maxDimension * 0.75 // Adjust radius based on largest slide
// Calculate position on the circle
const x = spacing + radius + (radius * Math.cos(angle))
const y = headerHeight + contentPadding + spacing + radius + (radius * Math.sin(angle))
offset = new Vec(x, y)
}
const point = this.editor.getPointInParentSpace(
slide,
this.editor.getShapePageTransform(slideshow)!.applyToPoint(offset)
)
if (slide.x !== point.x || slide.y !== point.y) {
this.editor.updateShape<CCSlideShape>({
id: slideId,
type: 'cc-slide',
x: point.x,
y: point.y,
})
}
}
private updateSlideshowSize(slideshowId: TLShapeId) {
const slideshow = this.editor.getShape<CCSlideShowShape>(slideshowId)
if (!slideshow) return
// Get all bindings, including placeholders
const bindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Default dimensions for empty slideshow
const defaultWidth = CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_WIDTH + (spacing * 2)
const defaultHeight = CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_HEIGHT +
headerHeight + (contentPadding * 2) + (spacing * 2)
// If no bindings, set to default size
if (bindings.length === 0) {
if (slideshow.props.w !== defaultWidth || slideshow.props.h !== defaultHeight) {
this.editor.updateShape<CCSlideShowShape>({
id: slideshow.id,
type: 'cc-slideshow',
props: {
...slideshow.props,
w: defaultWidth,
h: defaultHeight
}
})
}
return
}
// Get all slides and their dimensions
const slides = bindings.map(binding => {
const slide = this.editor.getShape<CCSlideShape>(binding.toId)
if (!slide) return null
return {
width: slide.props.w,
height: slide.props.h
}
}).filter(slide => slide !== null) as { width: number, height: number }[]
if (slides.length === 0) return
// Calculate dimensions based on pattern
let width = defaultWidth
let height = defaultHeight
if (slideshow.props.slidePattern === 'horizontal') {
// Sum of all widths plus spacing between them
width = Math.max(
spacing + (slides.reduce((sum, slide) => sum + slide.width, 0) +
((slides.length - 1) * spacing)) + spacing,
slides[0].width + (spacing * 2) // Minimum width is first slide plus spacing
)
// Maximum height of slides plus header and spacing
height = headerHeight + contentPadding * 2 + spacing * 2 +
Math.max(...slides.map(slide => slide.height))
} else if (slideshow.props.slidePattern === 'vertical') {
// Maximum width of slides plus spacing
width = Math.max(...slides.map(slide => slide.width)) + (spacing * 2)
// Sum of all heights plus spacing between them
height = Math.max(
headerHeight + contentPadding * 2 + spacing +
(slides.reduce((sum, slide) => sum + slide.height, 0) +
((slides.length - 1) * spacing)) + spacing,
headerHeight + contentPadding * 2 + spacing * 2 + slides[0].height
)
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(slides.length))
const rows = Math.ceil(slides.length / cols)
// Find maximum width and height for grid cells
const maxCellWidth = Math.max(...slides.map(slide => slide.width))
const maxCellHeight = Math.max(...slides.map(slide => slide.height))
width = Math.max(
spacing + (cols * maxCellWidth + (cols - 1) * spacing) + spacing,
maxCellWidth + (spacing * 2)
)
height = Math.max(
headerHeight + contentPadding * 2 + spacing +
(rows * maxCellHeight + (rows - 1) * spacing) + spacing,
headerHeight + contentPadding * 2 + spacing * 2 + maxCellHeight
)
} else if (slideshow.props.slidePattern === 'radial') {
// For radial pattern, use the largest slide dimensions to ensure proper spacing
const maxSlideWidth = Math.max(...slides.map(slide => slide.width))
const maxSlideHeight = Math.max(...slides.map(slide => slide.height))
// Calculate dimensions to fit all slides in a circle
const radius = Math.max(maxSlideWidth, maxSlideHeight) * 1.5 // 1.5x for spacing
width = radius * 2 + (spacing * 2)
height = headerHeight + contentPadding * 2 + radius * 2 + (spacing * 2)
}
if (width !== slideshow.props.w || height !== slideshow.props.h) {
this.editor.updateShape<CCSlideShowShape>({
id: slideshow.id,
type: 'cc-slideshow',
props: {
...slideshow.props,
w: width,
h: height
}
})
}
// Update positions for all bindings
bindings.forEach(binding => this.updateSlidePosition(binding))
}
private getDistance(a: Vec, b: Vec): number {
const dx = a.x - b.x
const dy = a.y - b.y
return Math.sqrt(dx * dx + dy * dy)
}
private getInsertPosition(
slideshow: CCSlideShowShape,
draggedSlide: CCSlideShape,
point: Vec
): { index: number; offset: Vec } {
const bindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Get all existing slides with their dimensions
const existingSlides = bindings.map(b => {
const slide = this.editor.getShape<CCSlideShape>(b.toId)
return slide ? {
width: slide.props.w,
height: slide.props.h,
binding: b
} : null
}).filter(s => s !== null) as { width: number; height: number; binding: CCSlideLayoutBinding }[]
if (slideshow.props.slidePattern === 'horizontal') {
let currentX = spacing
let insertIndex = 0
// Calculate trigger points based on slide widths and the dragged slide
for (const slide of existingSlides) {
// Calculate the gap between slides based on the larger width
const gapWidth = Math.max(slide.width, draggedSlide.props.w)
const triggerPoint = currentX + (gapWidth / 2)
if (point.x < triggerPoint) break
currentX += slide.width + spacing
insertIndex++
}
return {
index: insertIndex,
offset: new Vec(currentX, headerHeight + contentPadding + spacing)
}
} else if (slideshow.props.slidePattern === 'vertical') {
let currentY = headerHeight + contentPadding + spacing
let insertIndex = 0
// Calculate trigger points based on slide heights and the dragged slide
for (const slide of existingSlides) {
// Calculate the gap between slides based on the larger height
const gapHeight = Math.max(slide.height, draggedSlide.props.h)
const triggerPoint = currentY + (gapHeight / 2)
if (point.y < triggerPoint) break
currentY += slide.height + spacing
insertIndex++
}
return {
index: insertIndex,
offset: new Vec(spacing, currentY)
}
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(existingSlides.length + 1)) // +1 for the dragged slide
const colWidths = new Array(cols).fill(draggedSlide.props.w) // Initialize with dragged slide width
const rowHeights = new Array(Math.ceil((existingSlides.length + 1) / cols)).fill(draggedSlide.props.h) // Initialize with dragged slide height
// Calculate maximum dimensions for each column and row, including dragged slide dimensions
existingSlides.forEach((slide, i) => {
const row = Math.floor(i / cols)
const col = i % cols
colWidths[col] = Math.max(colWidths[col], slide.width)
rowHeights[row] = Math.max(rowHeights[row], slide.height)
})
// Calculate grid cell positions with dynamic cell sizes
let bestDistance = Infinity
let bestIndex = 0
let bestOffset = new Vec(0, 0)
for (let i = 0; i <= existingSlides.length; i++) {
const row = Math.floor(i / cols)
const col = i % cols
// Calculate cell position based on accumulated widths and heights
const cellX = spacing + colWidths.slice(0, col).reduce((sum, w) => sum + w + spacing, 0)
const cellY = headerHeight + contentPadding + spacing +
rowHeights.slice(0, row).reduce((sum, h) => sum + h + spacing, 0)
// Calculate distance to cell center
const cellCenterX = cellX + (colWidths[col] / 2)
const cellCenterY = cellY + (rowHeights[row] / 2)
const dx = point.x - cellCenterX
const dy = point.y - cellCenterY
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
bestOffset = new Vec(cellX, cellY)
}
}
return {
index: bestIndex,
offset: bestOffset
}
} else if (slideshow.props.slidePattern === 'radial') {
// Find the largest slide dimension including the dragged slide
const maxDimension = Math.max(
draggedSlide.props.w,
draggedSlide.props.h,
...existingSlides.map(s => Math.max(s.width, s.height))
)
const radius = maxDimension * 0.75
const center = new Vec(
spacing + radius,
headerHeight + contentPadding + spacing + radius
)
// Calculate angle from center to drag point using manual vector calculation
const dx = point.x - center.x
const dy = point.y - center.y
const angleToPoint = Math.atan2(dy, dx)
// Normalize angle to 0-2π range
const normalizedAngle = angleToPoint < 0 ? angleToPoint + 2 * Math.PI : angleToPoint
// Calculate insert index based on angle
const totalPositions = existingSlides.length + 1
const insertIndex = Math.floor((normalizedAngle * totalPositions) / (2 * Math.PI))
// Calculate position on circle for this index
const angle = (2 * Math.PI * insertIndex) / totalPositions
const x = center.x + (radius * Math.cos(angle))
const y = center.y + (radius * Math.sin(angle))
return {
index: insertIndex,
offset: new Vec(x, y)
}
}
// Default to end of slideshow if pattern not recognized
return {
index: existingSlides.length,
offset: new Vec(spacing, headerHeight + contentPadding + spacing)
}
}
onTranslateBinding(binding: CCSlideLayoutBinding, draggedShape: CCSlideShape, point: Vec): boolean {
const slideshow = this.editor.getShape<CCSlideShowShape>(binding.fromId)
if (!slideshow) return false
const { index } = this.getInsertPosition(slideshow, draggedShape, point)
const newIndex = `a${String(index + 1).padStart(3, '0')}` as IndexKey
this.editor.updateBinding({
id: binding.id,
type: 'cc-slide-layout',
fromId: binding.fromId,
toId: binding.toId,
props: { index: newIndex }
})
return true
}
override onAfterCreate({ binding }: { binding: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterCreate', { binding })
this.updateSlidePosition(binding)
this.updateSlideshowSize(binding.fromId)
}
override onAfterChange({ bindingAfter }: { bindingAfter: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterChange', { bindingAfter })
// Check if the slideshow still exists
const slideshow = this.editor.getShape<CCSlideShowShape>(bindingAfter.fromId)
if (!slideshow) return
this.updateSlidePosition(bindingAfter)
this.updateSlideshowSize(bindingAfter.fromId)
}
override onAfterChangeFromShape({ binding }: { binding: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterChangeFromShape', { binding })
// Check if the slideshow still exists
const slideshow = this.editor.getShape<CCSlideShowShape>(binding.fromId)
if (!slideshow) return
this.updateSlidePosition(binding)
this.updateSlideshowSize(binding.fromId)
}
override onAfterDelete({ binding }: { binding: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterDelete', { binding })
// Check if the slideshow still exists
const slideshow = this.editor.getShape<CCSlideShowShape>(binding.fromId)
if (!slideshow) return
this.updateSlideshowSize(binding.fromId)
}
}
@@ -0,0 +1,43 @@
import { BaseBoxShapeTool, StateNode } from '@tldraw/tldraw'
export class CCSlideShowShapeTool extends BaseBoxShapeTool {
static override id = 'cc-slideshow'
static override initial = 'idle'
override shapeType = 'cc-slideshow'
override onPointerDown = () => {
return this.transition('pointing')
}
override onPointerUp: StateNode['onPointerUp'] = () => {
const shape = this.editor.getSelectedShapes()[0]
if (shape?.type === 'cc-slideshow') {
// Switch to select tool after creating slideshow
this.editor.setCurrentTool('select')
}
return this.transition('idle')
}
}
export class CCSlideShapeTool extends BaseBoxShapeTool {
static override id = 'cc-slide'
static override initial = 'idle'
override shapeType = 'cc-slide'
override onPointerDown = () => {
// Check if there's a selected slideshow before allowing slide creation
const selectedShapes = this.editor.getSelectedShapes()
const slideshow = selectedShapes.find((s) => s.type === 'cc-slideshow')
if (!slideshow) {
this.editor.setCurrentTool('select')
return this.transition('idle')
}
return this.transition('pointing')
}
override onPointerUp: StateNode['onPointerUp'] = () => {
return this.transition('idle')
}
}
@@ -0,0 +1,338 @@
import { DefaultColorStyle, DefaultDashStyle, DefaultSizeStyle, Vec, getIndexBetween, clamp } from '@tldraw/tldraw'
import { ccShapeProps, getDefaultCCSlideProps, CCBaseProps } from '../cc-props'
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCSlideShowShape } from './CCSlideShowShapeUtil'
import { CCSlideLayoutBinding } from './CCSlideLayoutBindingUtil'
import { CC_BASE_STYLE_CONSTANTS, CC_SLIDESHOW_STYLE_CONSTANTS } from '../cc-styles'
import { ccShapeMigrations } from '../cc-migrations'
import { logger } from '../../../../debugConfig'
import { CCBaseShape } from '../cc-types'
type CCSlideProps = CCBaseProps & {
imageData?: string
meta: {
text: string
format: string
}
}
export interface CCSlideShape extends CCBaseShape {
type: 'cc-slide'
props: CCSlideProps
}
export class CCSlideShapeUtil extends CCBaseShapeUtil<CCSlideShape> {
static override type = 'cc-slide' as const
static override props = ccShapeProps.slide
static override migrations = ccShapeMigrations.slide
static styles = {
color: DefaultColorStyle,
dash: DefaultDashStyle,
size: DefaultSizeStyle,
}
override getDefaultProps(): CCSlideShape['props'] {
return getDefaultCCSlideProps() as CCSlideShape['props']
}
override canResize = () => false
override isAspectRatioLocked = () => true
override hideResizeHandles = () => true
override hideRotateHandle = () => true
override canEdit = () => false
override canBind(args: { fromShapeType: string; toShapeType: string; bindingType: string }): boolean {
return args.fromShapeType === 'cc-slideshow' && args.toShapeType === 'cc-slide' && args.bindingType === 'cc-slide-layout'
}
private getTargetSlideshow(shape: CCSlideShape, pageAnchor: Vec) {
return this.editor.getShapeAtPoint(pageAnchor, {
hitInside: true,
filter: (otherShape) =>
this.editor.canBindShapes({ fromShape: otherShape, toShape: shape, binding: 'cc-slide-layout' }),
}) as CCSlideShowShape | undefined
}
getBindingIndexForPosition(shape: CCSlideShape, slideshow: CCSlideShowShape, pageAnchor: Vec) {
// Get all non-placeholder bindings, sorted by index
const allBindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.filter(b => !b.props.placeholder || b.toId === shape.id) // Include our binding if it's placeholder
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_BASE_STYLE_CONSTANTS.HEADER.height
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Calculate target order based on position
let order: number
if (slideshow.props.slidePattern === 'horizontal') {
order = clamp(
Math.round((pageAnchor.x - slideshow.x - spacing) / (shape.props.w + spacing)),
0,
allBindings.length
)
} else if (slideshow.props.slidePattern === 'vertical') {
order = clamp(
Math.round((pageAnchor.y - slideshow.y - headerHeight - contentPadding - spacing) / (shape.props.h + spacing)),
0,
allBindings.length
)
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(allBindings.length))
const col = clamp(
Math.round((pageAnchor.x - slideshow.x - spacing) / (shape.props.w + spacing)),
0,
cols
)
const row = clamp(
Math.round((pageAnchor.y - slideshow.y - headerHeight - contentPadding - spacing) / (shape.props.h + spacing)),
0,
Math.ceil(allBindings.length / cols)
)
order = clamp(row * cols + col, 0, allBindings.length)
} else {
order = 0
}
// Get the bindings before and after our target position
const belowSib = allBindings[order - 1]
const aboveSib = allBindings[order]
// If we're already at this position, keep our current index
if (belowSib?.toId === shape.id) {
return belowSib.props.index
} else if (aboveSib?.toId === shape.id) {
return aboveSib.props.index
}
// Otherwise, get an index between the two siblings
return getIndexBetween(belowSib?.props.index, aboveSib?.props.index)
}
override onTranslateStart = (shape: CCSlideShape) => {
const bindings = this.editor.getBindingsToShape<CCSlideLayoutBinding>(shape.id, 'cc-slide-layout')
logger.debug('shape', '✅ onTranslateStart', {
shape,
bindings,
hasBindings: bindings.length > 0,
bindingTypes: bindings.map(b => ({
id: b.id,
fromId: b.fromId,
placeholder: b.props.placeholder,
isMovingWithParent: b.props.isMovingWithParent
}))
})
this.editor.updateBindings(
bindings.map((binding) => ({
...binding,
props: { ...binding.props, placeholder: true },
}))
)
}
override onTranslate = (initial: CCSlideShape, current: CCSlideShape) => {
const pageAnchor = this.editor.getShapePageTransform(current).applyToPoint({ x: current.props.w / 2, y: current.props.h / 2 })
const targetSlideshow = this.getTargetSlideshow(current, pageAnchor)
// Get current binding if any
const currentBindings = this.editor.getBindingsToShape<CCSlideLayoutBinding>(current.id, 'cc-slide-layout')
const currentBinding = currentBindings[0]
const currentSlideshow = currentBinding ? this.editor.getShape<CCSlideShowShape>(currentBinding.fromId) : undefined
logger.debug('shape', '✅ onTranslate', {
initial,
current,
hasTargetSlideshow: !!targetSlideshow,
targetSlideshowId: targetSlideshow?.id,
currentBindings: currentBindings.map(b => ({
id: b.id,
fromId: b.fromId,
placeholder: b.props.placeholder,
isMovingWithParent: b.props.isMovingWithParent
})),
isInSlideshow: targetSlideshow ? this.isSlideInSlideshow(current, targetSlideshow) : false
})
// If we're moving out of a slideshow
if (currentBinding && currentSlideshow && !this.isSlideInSlideshow(current, currentSlideshow)) {
logger.debug('shape', '✅ onTranslate: Moving out of slideshow', {
slideId: current.id,
slideshowId: currentSlideshow.id
})
// Delete all bindings
this.editor.deleteBindings(currentBindings)
return current
}
// If we have no target slideshow, return
if (!targetSlideshow) {
return current
}
// Calculate new index
const index = this.getBindingIndexForPosition(current, targetSlideshow, pageAnchor)
// If we have a current binding and it's for this slideshow
if (currentBinding && currentBinding.fromId === targetSlideshow.id) {
// Only update if index changed
if (currentBinding.props.index !== index) {
logger.debug('shape', '✅ onTranslate: Updating binding index', {
slideId: current.id,
slideshowId: targetSlideshow.id,
oldIndex: currentBinding.props.index,
newIndex: index
})
this.editor.updateBinding<CCSlideLayoutBinding>({
id: currentBinding.id,
type: currentBinding.type,
fromId: currentBinding.fromId,
toId: currentBinding.toId,
props: {
...currentBinding.props,
index,
isMovingWithParent: true,
},
})
}
} else if (this.isSlideInSlideshow(current, targetSlideshow)) {
logger.debug('shape', '✅ onTranslate: Creating new placeholder binding', {
slideId: current.id,
slideshowId: targetSlideshow.id,
index
})
// Create new placeholder binding if we're inside the slideshow
this.editor.createBinding<CCSlideLayoutBinding>({
type: 'cc-slide-layout',
fromId: targetSlideshow.id,
toId: current.id,
props: {
index,
isMovingWithParent: true,
placeholder: true,
},
})
}
return current
}
private isSlideInSlideshow(slide: CCSlideShape, slideshow: CCSlideShowShape): boolean {
const slideCenter = this.editor.getShapePageTransform(slide).applyToPoint({
x: slide.props.w / 2,
y: slide.props.h / 2
})
const slideshowBounds = this.editor.getShapeGeometry(slideshow).bounds
// Use smaller padding to be more lenient
const padding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING / 4
return (
slideCenter.x >= slideshow.x + padding &&
slideCenter.x <= slideshow.x + slideshowBounds.width - padding &&
slideCenter.y >= slideshow.y + padding &&
slideCenter.y <= slideshow.y + slideshowBounds.height - padding
)
}
override onTranslateEnd = (shape: CCSlideShape) => {
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: shape.props.w / 2, y: shape.props.h / 2 })
const targetSlideshow = this.getTargetSlideshow(shape, pageAnchor)
const bindings = this.editor.getBindingsToShape<CCSlideLayoutBinding>(shape.id, 'cc-slide-layout')
logger.debug('shape', '✅ onTranslateEnd', {
shape,
hasTargetSlideshow: !!targetSlideshow,
targetSlideshowId: targetSlideshow?.id,
bindings: bindings.map(b => ({
id: b.id,
fromId: b.fromId,
placeholder: b.props.placeholder,
isMovingWithParent: b.props.isMovingWithParent
})),
isInSlideshow: targetSlideshow ? this.isSlideInSlideshow(shape, targetSlideshow) : false
})
// If we have a target slideshow and the slide is inside it
if (targetSlideshow && this.isSlideInSlideshow(shape, targetSlideshow)) {
const index = this.getBindingIndexForPosition(shape, targetSlideshow, pageAnchor)
// Instead of deleting and recreating, update existing binding if it exists
const existingBinding = bindings[0]
if (existingBinding && existingBinding.fromId === targetSlideshow.id) {
logger.debug('shape', '✅ onTranslateEnd: Updating existing binding', {
slideId: shape.id,
slideshowId: targetSlideshow.id,
bindingId: existingBinding.id,
index
})
this.editor.updateBinding<CCSlideLayoutBinding>({
id: existingBinding.id,
type: existingBinding.type,
fromId: existingBinding.fromId,
toId: existingBinding.toId,
props: {
index,
isMovingWithParent: true,
placeholder: false,
},
})
} else {
logger.debug('shape', '✅ onTranslateEnd: Creating new binding', {
slideId: shape.id,
slideshowId: targetSlideshow.id,
index
})
// If no existing binding or from different slideshow, delete and create new
this.editor.deleteBindings(bindings)
this.editor.createBinding<CCSlideLayoutBinding>({
type: 'cc-slide-layout',
fromId: targetSlideshow.id,
toId: shape.id,
props: {
index,
isMovingWithParent: true,
placeholder: false,
},
})
}
} else {
logger.debug('shape', '✅ onTranslateEnd: Removing bindings', {
slideId: shape.id,
bindings: bindings.map(b => b.id)
})
// Just delete bindings if we're not in a slideshow
this.editor.deleteBindings(bindings)
}
}
override renderContent = (shape: CCSlideShape) => {
return (
<div style={{
width: '100%',
height: '100%',
position: 'relative',
backgroundColor: 'white',
borderRadius: '4px',
overflow: 'hidden'
}}>
{shape.props.imageData && (
<img
src={shape.props.imageData}
alt={shape.props.title}
style={{
width: '100%',
height: `100%`,
objectFit: 'contain',
position: 'absolute',
top: 0,
left: 0,
}}
/>
)}
</div>
)
}
}
@@ -0,0 +1,51 @@
import { DefaultColorStyle, DefaultDashStyle, DefaultSizeStyle } from '@tldraw/tldraw'
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { ccShapeProps, getDefaultCCSlideShowProps, CCBaseProps } from '../cc-props'
import { ccShapeMigrations } from '../cc-migrations'
import { CCBaseShape } from '../cc-types'
type CCSlideshowProps = CCBaseProps & {
currentSlideIndex: number
slidePattern: string
numSlides: number
slides: string[]
}
export interface CCSlideShowShape extends CCBaseShape {
type: 'cc-slideshow'
props: CCSlideshowProps
}
export class CCSlideShowShapeUtil extends CCBaseShapeUtil<CCSlideShowShape> {
static override type = 'cc-slideshow' as const
static override props = ccShapeProps.slideshow
static override migrations = ccShapeMigrations.slideshow
static styles = {
color: DefaultColorStyle,
dash: DefaultDashStyle,
size: DefaultSizeStyle,
}
getDefaultProps(): CCSlideShowShape['props'] {
return getDefaultCCSlideShowProps() as CCSlideShowShape['props']
}
override canResize = () => false
override isAspectRatioLocked = () => true
override hideResizeHandles = () => false
override hideRotateHandle = () => false
override canEdit = () => false
// eslint-disable-next-line @typescript-eslint/no-unused-vars
override canBind(args: { fromShapeType: string; toShapeType: string; bindingType: string }): boolean {
return true
}
onBeforeCreate(shape: CCSlideShowShape): CCSlideShowShape {
return shape
}
override renderContent = () => {
return <div />
}
}
@@ -0,0 +1,165 @@
import { Editor, atom, useEditor, useValue } from '@tldraw/tldraw'
import { CCSlideShowShape } from './CCSlideShowShapeUtil'
import { CCSlideShape } from './CCSlideShapeUtil'
import { logger } from '../../../../debugConfig'
import { CCSlideLayoutBinding } from './CCSlideLayoutBindingUtil'
// Atoms for tracking current slideshow and slide
export const $currentSlideShow = atom<CCSlideShowShape | null>('current slideshow', null)
export const $currentSlide = atom<CCSlideShape | null>('current slide', null)
// Helper functions for getting slides and slideshows
export function getSlidesFromPage(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s): s is CCSlideShape => s?.type === 'cc-slide')
}
export function getSlideShowsFromPage(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s): s is CCSlideShowShape => s?.type === 'cc-slideshow')
}
// Hooks for accessing slides and slideshows
export function useSlideShows() {
const editor = useEditor()
return useValue<CCSlideShowShape[]>('slideshow shapes', () => getSlideShowsFromPage(editor), [editor])
}
export function useSlides() {
const editor = useEditor()
return useValue<CCSlideShape[]>('slide shapes', () => getSlidesFromPage(editor), [editor])
}
export function useCurrentSlide() {
return useValue($currentSlide)
}
export function useCurrentSlideShow() {
return useValue($currentSlideShow)
}
// Navigation functions
export function moveToSlide(editor: Editor, slide: CCSlideShape, isPresentation: boolean = false) {
logger.info('navigation', '🎯 Moving to slide', {
slideId: slide.id,
currentProps: slide.props,
isPresentation,
timestamp: new Date().toISOString()
})
// Find the parent slideshow through bindings
const binding = editor.getBindingsToShape(slide.id, 'cc-slide-layout')[0]
if (!binding) {
logger.warn('navigation', '⚠️ No binding found for slide', { slideId: slide.id })
return
}
const parentSlideshow = editor.getShape(binding.fromId) as CCSlideShowShape
if (!parentSlideshow) {
logger.warn('navigation', '⚠️ No parent slideshow found for slide', { slideId: slide.id })
return
}
// Get all bindings for this slideshow, sorted by index
const bindings = editor
.getBindingsFromShape(parentSlideshow, 'cc-slide-layout')
.filter((b): b is CCSlideLayoutBinding => b.type === 'cc-slide-layout')
.filter(b => !b.props.placeholder)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Find the index of this slide's binding
const slideIndex = bindings.findIndex(b => b.toId === slide.id)
logger.debug('selection', '📍 Current slide position', {
slideId: slide.id,
slideIndex,
slideshowId: parentSlideshow.id,
totalSlides: bindings.length
})
editor.batch(() => {
logger.debug('navigation', '🔄 Starting slide transition', {
from: parentSlideshow.props.currentSlideIndex,
to: slideIndex
})
// Update the slideshow's currentSlideIndex and slides array
editor.updateShape<CCSlideShowShape>({
id: parentSlideshow.id,
type: 'cc-slideshow',
props: {
...parentSlideshow.props,
currentSlideIndex: slideIndex,
slides: bindings.map(b => b.toId),
numSlides: bindings.length
}
})
// Update UI atoms for state tracking
$currentSlide.set(slide)
$currentSlideShow.set(parentSlideshow)
})
logger.info('navigation', '✅ Slide transition complete', {
slideId: slide.id,
slideIndex,
slideshowId: parentSlideshow.id
})
}
export function moveToSlideShow(editor: Editor, slideshow: CCSlideShowShape, isPresentation: boolean = false) {
logger.info('navigation', '🎯 Moving to slideshow', {
slideshowId: slideshow.id,
currentIndex: slideshow.props.currentSlideIndex,
isPresentation,
timestamp: new Date().toISOString()
})
// Update current slideshow state
$currentSlideShow.set(slideshow)
// Get all bindings for this slideshow, sorted by index
const bindings = editor
.getBindingsFromShape(slideshow, 'cc-slide-layout')
.filter((b): b is CCSlideLayoutBinding => b.type === 'cc-slide-layout')
.filter(b => !b.props.placeholder)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Get the current slide based on currentSlideIndex
const currentBinding = bindings[slideshow.props.currentSlideIndex]
if (currentBinding) {
const currentSlide = editor.getShape(currentBinding.toId) as CCSlideShape
if (currentSlide) {
moveToSlide(editor, currentSlide, isPresentation)
} else {
logger.warn('navigation', '⚠️ Could not find current slide in slideshow', {
slideshowId: slideshow.id,
currentSlideId: currentBinding.toId,
currentIndex: slideshow.props.currentSlideIndex
})
}
}
}
// Helper functions for labels
export function getSlideLabel(slide: CCSlideShape, index: number) {
return `Slide ${index + 1} (${slide.id})`
}
export function getSlideShowLabel(slideshow: CCSlideShowShape, index: number) {
return `Slideshow ${index + 1} (${slideshow.id})`
}
// Current ID getters
export function getCurrentSlideId(): string | undefined {
const currentSlide = $currentSlide.get()
return currentSlide?.id
}
export function getCurrentSlideShowId(): string | undefined {
const currentSlideShow = $currentSlideShow.get()
return currentSlideShow?.id
}