Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user