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
1 change: 1 addition & 0 deletions .github/workflows/deploy-dotcom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,4 @@ jobs:
VITE_GA4_MEASUREMENT_ID: ${{ secrets.VITE_GA4_MEASUREMENT_ID }}
WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }}
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
PIERRE_KEY: ${{ secrets.PIERRE_KEY }}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/dotcom/client/public/fairy/fairy-hat-cap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions apps/dotcom/client/src/__snapshots__/routes.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ exports[`the_routes 1`] = `
"reactRouterPattern": "/f/:fileSlug/history/:timestamp",
"vercelRouterPattern": "^/f/[^/]*/history/[^/]*/?$",
},
{
"reactRouterPattern": "/f/:fileSlug/pierre-history",
"vercelRouterPattern": "^/f/[^/]*/pierre-history/?$",
},
{
"reactRouterPattern": "/f/:fileSlug/pierre-history/:commitHash",
"vercelRouterPattern": "^/f/[^/]*/pierre-history/[^/]*/?$",
},
{
"reactRouterPattern": "/lf",
"vercelRouterPattern": "^/lf/?$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
// todo: remove tailwind

interface BoardHistoryLogProps {
data: string[]
data: { timestamp: string; href?: string }[]
hasMore?: boolean
onLoadMore?(): void
isLoading?: boolean
Expand All @@ -18,16 +18,16 @@ function getMonthYear(timestamp: string): string {
}

function groupTimestampsByMonth(
timestamps: string[]
): Array<{ month: string; timestamps: string[] }> {
const groups: { [key: string]: string[] } = {}
timestamps: { timestamp: string; href?: string }[]
): Array<{ month: string; timestamps: { timestamp: string; href?: string }[] }> {
const groups: { [key: string]: { timestamp: string; href?: string }[] } = {}

timestamps.forEach((timestamp) => {
timestamps.forEach(({ timestamp, href }) => {
const monthKey = getMonthYear(timestamp)
if (!groups[monthKey]) {
groups[monthKey] = []
}
groups[monthKey].push(timestamp)
groups[monthKey].push({ timestamp, href })
})

return Object.entries(groups).map(([month, timestamps]) => ({
Expand Down Expand Up @@ -56,11 +56,10 @@ export function BoardHistoryLog({ data, hasMore, onLoadMore, isLoading }: BoardH
<div key={groupIndex} className="board-history__month-group">
<h3 className="board-history__month-header">{group.month}</h3>
<ol className="board-history__list">
{group.timestamps.map((v, i) => {
const timeStamp = v.split('/').pop()
{group.timestamps.map(({ timestamp, href }, i) => {
return (
<li key={i}>
<Link to={`./${timeStamp}`}>{formatDate(timeStamp!)}</Link>
<Link to={href || `./${timestamp}`}>{formatDate(timestamp)}</Link>
</li>
)
})}
Expand Down
134 changes: 91 additions & 43 deletions apps/dotcom/client/src/fairy/Fairy.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { FairyEntity } from '@tldraw/fairy-shared'
import { ContextMenu as _ContextMenu } from 'radix-ui'
import React, { useEffect, useRef } from 'react'
import { Box, useEditor, useValue } from 'tldraw'
import { Atom, Box, useEditor, useValue } from 'tldraw'
import { FairyAgent } from './fairy-agent/agent/FairyAgent'
import { $fairyAgentsAtom } from './fairy-agent/agent/fairyAgentsAtom'
import { FairySpriteComponent } from './fairy-sprite/FairySprite'
import { FairyContextMenuContent } from './FairyContextMenuContent'
import { FairyThrowTool } from './FairyThrowTool'

export const FAIRY_SIZE = 70
const FAIRY_CLICKABLE_SIZE_DEFAULT = 60
const FAIRY_CLICKABLE_SIZE_SELECTED = 70
export const FAIRY_SIZE = 60
const FAIRY_CLICKABLE_SIZE_DEFAULT = 50
const FAIRY_CLICKABLE_SIZE_SELECTED = 60

// We use the agent directly here because we need to access the isGenerating method
// which is not exposed on the fairy atom
Expand Down Expand Up @@ -118,6 +121,8 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {
// Handle fairy pointer down, we don't enter fairy throw tool until the user actually moves their mouse
const handleFairyPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
// Don't activate tool immediately - wait for drag to start
// Skip dragging behavior on right-click (context menu will handle it)
if (e.button === 2) return
if (!editor.isIn('select.idle')) return
if (editor.getCurrentTool().id === 'fairy-throw') return

Expand All @@ -126,9 +131,48 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {
// when you press generate, so this is enough for now
if (agent.isGenerating()) return

// Determine which fairies to drag before updating selection
const fairyAgents = $fairyAgentsAtom.get(editor)
const clickedFairyEntity = fairy.get()
const wasClickedFairySelected = clickedFairyEntity?.isSelected ?? false

// Only drag multiple fairies if the clicked fairy was already selected
// and there are other selected fairies
let fairiesToDrag: Atom<FairyEntity>[]
if (wasClickedFairySelected) {
// Collect all currently selected fairies
const selectedFairies: Atom<FairyEntity>[] = []
fairyAgents.forEach((a) => {
const entity = a.$fairyEntity.get()
if (entity?.isSelected) {
selectedFairies.push(a.$fairyEntity)
}
})
fairiesToDrag = selectedFairies
} else {
// Only drag the clicked fairy
fairiesToDrag = [fairy]
}

// Update selection state
// If dragging multiple fairies, keep them all selected
// Otherwise, update selection as normal
const isDraggingMultiple = fairiesToDrag.length > 1
const fairiesToDragIds = new Set(
fairiesToDrag
.map((f) => {
// We need to get the agent ID for each fairy to match
const agentForFairy = fairyAgents.find((a) => a.$fairyEntity === f)
return agentForFairy?.id
})
.filter((id): id is string => id !== undefined)
)

fairyAgents.forEach((a) => {
if (a.id === agent.id) {
if (isDraggingMultiple && fairiesToDragIds.has(a.id)) {
// Keep all fairies that will be dragged selected
a.$fairyEntity.update((f) => (f ? { ...f, isSelected: true } : f))
} else if (a.id === agent.id) {
a.$fairyEntity.update((f) => (f ? { ...f, isSelected: true } : f))
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
a.$fairyEntity.update((f) => (f ? { ...f, isSelected: false } : f))
Expand All @@ -154,10 +198,10 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)

// Activate the tool
// Activate the tool with all fairies that were selected at pointer down
const tool = editor.getStateDescendant('fairy-throw')
if (tool && 'setFairy' in tool) {
;(tool as FairyThrowTool).setFairy(fairy)
if (tool && 'setFairies' in tool) {
;(tool as FairyThrowTool).setFairies(fairiesToDrag)
}
editor.setCurrentTool('fairy-throw')
}
Expand All @@ -178,42 +222,46 @@ export default function Fairy({ agent }: { agent: FairyAgent }) {

// Early return if fairy doesn't exist (after all hooks)
const fairyEntity = fairy.get()
if (!fairyEntity) return null

return (
<div
ref={fairyRef}
style={{
position: 'absolute',
left: position.x,
top: position.y,
width: `${FAIRY_SIZE}px`,
height: `${FAIRY_SIZE}px`,
transform: `translate(-75%, -25%) scale(var(--tl-scale)) ${flipX ? ' scaleX(-1)' : ''}`,
transformOrigin: '75% 25%',
transition: isGenerating ? 'left 0.1s ease-in-out, top 0.1s ease-in-out' : 'none',
}}
className={isSelected ? 'fairy-selected' : ''}
>
{/* Fairy clickable zone */}
<div
onPointerDown={handleFairyPointerDown}
style={{
position: 'absolute',
width: `${isSelected ? FAIRY_CLICKABLE_SIZE_SELECTED : FAIRY_CLICKABLE_SIZE_DEFAULT}px`,
height: `${isSelected ? FAIRY_CLICKABLE_SIZE_SELECTED : FAIRY_CLICKABLE_SIZE_DEFAULT}px`,
pointerEvents: isFairyGrabbable ? 'all' : 'none',
cursor: isFairyGrabbable ? 'grab' : 'default',
}}
/>
<FairySpriteComponent
entity={fairyEntity}
outfit={fairyOutfit}
animated={true}
onGestureEnd={() => {
fairy.update((f) => (f ? { ...f, gesture: null } : f))
}}
/>
</div>
<_ContextMenu.Root dir="ltr">
<_ContextMenu.Trigger asChild>
<div
ref={fairyRef}
style={{
position: 'absolute',
left: position.x,
top: position.y,
width: `${FAIRY_SIZE}px`,
height: `${FAIRY_SIZE}px`,
transform: `translate(-75%, -25%) scale(var(--tl-scale)) ${flipX ? ' scaleX(-1)' : ''}`,
transformOrigin: '75% 25%',
transition: isGenerating ? 'left 0.1s ease-in-out, top 0.1s ease-in-out' : 'none',
}}
className={isSelected ? 'fairy-selected' : ''}
>
{/* Fairy clickable zone */}
<div
onPointerDown={handleFairyPointerDown}
style={{
position: 'absolute',
width: `${isSelected ? FAIRY_CLICKABLE_SIZE_SELECTED : FAIRY_CLICKABLE_SIZE_DEFAULT}px`,
height: `${isSelected ? FAIRY_CLICKABLE_SIZE_SELECTED : FAIRY_CLICKABLE_SIZE_DEFAULT}px`,
pointerEvents: isFairyGrabbable ? 'all' : 'none',
cursor: isFairyGrabbable ? 'grab' : 'default',
}}
/>
<FairySpriteComponent
entity={fairyEntity}
outfit={fairyOutfit}
animated={true}
onGestureEnd={() => {
fairy.update((f) => (f ? { ...f, gesture: null } : f))
}}
/>
</div>
</_ContextMenu.Trigger>
<FairyContextMenuContent agent={agent} />
</_ContextMenu.Root>
)
}
Loading
Loading