Skip to content

Commit

Permalink
Add snow (tldraw#5141)
Browse files Browse the repository at this point in the history
This PR adds snow to tldraw.com.

### Change type

- [ ] `bugfix`
- [ ] `improvement`
- [ ] `feature`
- [ ] `api`
- [x] `other`
  • Loading branch information
steveruizok authored Dec 20, 2024
1 parent 0d88f3e commit a0088a0
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 1 deletion.
4 changes: 4 additions & 0 deletions apps/dotcom/client/src/components/LocalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { LocalFileMenu } from './FileMenu'
import { Links } from './Links'
import { ShareMenu } from './ShareMenu'
import { SneakyOnDropOverride } from './SneakyOnDropOverride'
import { SnowStorm } from './SnowStorm'
import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater'

const components: TLComponents = {
Expand Down Expand Up @@ -83,6 +84,9 @@ const components: TLComponents = {
</div>
)
},
InFrontOfTheCanvas: () => {
return <SnowStorm />
},
}

export function LocalEditor({
Expand Down
4 changes: 4 additions & 0 deletions apps/dotcom/client/src/components/MultiplayerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { MultiplayerFileMenu } from './FileMenu'
import { Links } from './Links'
import { ShareMenu } from './ShareMenu'
import { SneakyOnDropOverride } from './SneakyOnDropOverride'
import { SnowStorm } from './SnowStorm'
import { StoreErrorScreen } from './StoreErrorScreen'
import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater'

Expand Down Expand Up @@ -113,6 +114,9 @@ const components: TLComponents = {
</div>
)
},
InFrontOfTheCanvas: () => {
return <SnowStorm />
},
}

export function MultiplayerEditor({
Expand Down
4 changes: 4 additions & 0 deletions apps/dotcom/client/src/components/SnapshotsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { SAVE_FILE_COPY_ACTION, useFileSystem } from '../utils/useFileSystem'
import { useHandleUiEvents } from '../utils/useHandleUiEvent'
import { ExportMenu } from './ExportMenu'
import { MultiplayerFileMenu } from './FileMenu'
import { SnowStorm } from './SnowStorm'

const components: TLComponents = {
ErrorFallback: ({ error }) => {
Expand Down Expand Up @@ -47,6 +48,9 @@ const components: TLComponents = {
</div>
)
},
InFrontOfTheCanvas: () => {
return <SnowStorm />
},
}

interface SnapshotEditorProps {
Expand Down
204 changes: 204 additions & 0 deletions apps/dotcom/client/src/components/SnowStorm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useEffect, useRef } from 'react'
import { Vec, useEditor } from 'tldraw'

/* eslint-disable local/prefer-class-methods */
interface Snowflake {
element: HTMLElement
x: number
y: number
vx: number
vy: number
pvx: number
pvy: number
size: number
}

function rnd(min: number, max: number): number {
return Math.random() * (max - min) + min
}

class Snowstorm {
private flakes: Snowflake[] = []
private active: boolean = false
private container: HTMLElement
private width: number
private height: number

windX = 0
windY = 0

baseWindX = 0

// Configuration options
private readonly config = {
flakesMax: 128,
flakesMaxActive: 64,
animationInterval: 30,
flakeSizeMin: 2,
flakeSizeMax: 5,
windMax: 2,
}

constructor(container: HTMLElement = document.body) {
this.container = container
this.width = container.clientWidth
this.height = container.clientHeight
}

private createSnowflake(): Snowflake {
const size = rnd(this.config.flakeSizeMin, this.config.flakeSizeMax)
const opacity = rnd(0.5, 1)

const element = document.createElement('div')
element.style.width = `${size}px`
element.style.height = `${size}px`
element.classList.add('tl-snowflake')
element.style.opacity = opacity.toString()

this.container.appendChild(element)

return {
element,
x: rnd(0, this.width),
y: rnd(-this.height, 0),
vx: rnd(-this.config.windMax, this.config.windMax),
vy: rnd(1, 3),
pvx: 0,
pvy: 0,
size,
}
}

// Main render loop
render = (screenPoint: Vec, pointerVelocity: Vec) => {
if (!this.active) return

const pointerLen = pointerVelocity.len2()

for (const flake of this.flakes) {
const dist2 = Vec.Dist2(screenPoint, new Vec(flake.x, flake.y))
// if the snowflake is close to the pointer, give it a little boost based on the pointer velocity

if (dist2 < 10000 && pointerLen > 1) {
flake.pvx = pointerVelocity.x
flake.pvy = pointerVelocity.y
} else {
if (flake.pvx !== 0) {
flake.pvx *= 0.9
if (Math.abs(flake.pvx) < 0.01) {
flake.pvx = 0
}
flake.pvy *= 0.9
if (Math.abs(flake.pvy) < 0.01) {
flake.pvy = 0
}
}
}

flake.x += flake.vx + this.windX + this.baseWindX + flake.pvx
flake.y += flake.vy + this.windY + flake.pvy

if (flake.x < 0) {
flake.x += this.width
flake.pvx = 0
} else if (flake.x > this.width) {
flake.x -= this.width
flake.pvx = 0
}

if (flake.y < 0) {
flake.y += this.height
flake.pvx = 0
} else if (flake.y > this.height) {
flake.y -= this.height
flake.pvx = 0
}

flake.element.style.transform = `translate(${flake.x}px, ${flake.y}px)`
}
}

resize = () => {
this.width = this.container.clientWidth
this.height = this.container.clientHeight
}

start() {
this.active = true
while (this.flakes.length < this.config.flakesMax) {
this.flakes.push(this.createSnowflake())
}
window.addEventListener('resize', this.resize)
}

stop() {
this.active = false
for (const flake of this.flakes) {
this.container.removeChild(flake.element)
}
this.flakes = []
}

dispose() {
this.stop()
window.removeEventListener('resize', this.resize)
}
}

export function SnowStorm() {
const editor = useEditor()
const rElm = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!rElm.current) return
const snowstorm = new Snowstorm(rElm.current)
const velocity = new Vec(0, 0)
const camera = Vec.From(editor.getCamera())

const start = Date.now()

function updateOnTick() {
const time = Date.now() - start

// make wind gradually cycle between 0 and 10, maybe a bit randomly, like gusts of wind
snowstorm.baseWindX = Math.sin(time / 30_000) * 3

const newCamera = editor.getCamera()

if (newCamera.z === camera.z) {
const dx = (newCamera.x - camera.x) * camera.z
const dy = (newCamera.y - camera.y) * camera.z

// add the camera movement to the velocity
velocity.addXY(dx / 18, dy / 18)

// decay velocity
velocity.mul(0.82)

// stop the snowflakes from moving if the camera is not moving
if (velocity.len2() < 1) {
velocity.x = 0
velocity.y = 0
}

snowstorm.windX = velocity.x
snowstorm.windY = velocity.y
}

camera.setTo(newCamera)
snowstorm.render(editor.inputs.currentScreenPoint, editor.inputs.pointerVelocity)
}

snowstorm.start()
editor.on('tick', updateOnTick)

// eslint-disable-next-line no-console
console.log('happy holidays from tldraw')
return () => {
editor.off('tick', updateOnTick)
snowstorm.dispose()
}
}, [editor])

return <div ref={rElm} className="tl-snowstorm" />
}
16 changes: 16 additions & 0 deletions apps/dotcom/client/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,19 @@ a {
min-width: 36px;
height: 36px;
}

/* -------------------- Snowstorm ------------------- */

.tl-snowflake {
position: absolute;
background-color: #ccc;
border-radius: 100%;
pointer-events: none;
}

.tl-snowstorm {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
}
2 changes: 1 addition & 1 deletion apps/examples/src/misc/develop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function Develop() {
}
}}
components={components}
/>
></Tldraw>
</div>
)
}

0 comments on commit a0088a0

Please sign in to comment.