From a0088a0d6a77dde55cc1a5fa3bf30a7db3452c2c Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 20 Dec 2024 13:46:47 +0000 Subject: [PATCH] Add snow (#5141) This PR adds snow to tldraw.com. ### Change type - [ ] `bugfix` - [ ] `improvement` - [ ] `feature` - [ ] `api` - [x] `other` --- .../client/src/components/LocalEditor.tsx | 4 + .../src/components/MultiplayerEditor.tsx | 4 + .../client/src/components/SnapshotsEditor.tsx | 4 + .../client/src/components/SnowStorm.tsx | 204 ++++++++++++++++++ apps/dotcom/client/styles/globals.css | 16 ++ apps/examples/src/misc/develop.tsx | 2 +- 6 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 apps/dotcom/client/src/components/SnowStorm.tsx diff --git a/apps/dotcom/client/src/components/LocalEditor.tsx b/apps/dotcom/client/src/components/LocalEditor.tsx index 95d5403547a0..5ded1bc760f5 100644 --- a/apps/dotcom/client/src/components/LocalEditor.tsx +++ b/apps/dotcom/client/src/components/LocalEditor.tsx @@ -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 = { @@ -83,6 +84,9 @@ const components: TLComponents = { ) }, + InFrontOfTheCanvas: () => { + return + }, } export function LocalEditor({ diff --git a/apps/dotcom/client/src/components/MultiplayerEditor.tsx b/apps/dotcom/client/src/components/MultiplayerEditor.tsx index b0ff9085eee6..c7b3b8373641 100644 --- a/apps/dotcom/client/src/components/MultiplayerEditor.tsx +++ b/apps/dotcom/client/src/components/MultiplayerEditor.tsx @@ -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' @@ -113,6 +114,9 @@ const components: TLComponents = { ) }, + InFrontOfTheCanvas: () => { + return + }, } export function MultiplayerEditor({ diff --git a/apps/dotcom/client/src/components/SnapshotsEditor.tsx b/apps/dotcom/client/src/components/SnapshotsEditor.tsx index 32f468daeca8..abdd320c8f92 100644 --- a/apps/dotcom/client/src/components/SnapshotsEditor.tsx +++ b/apps/dotcom/client/src/components/SnapshotsEditor.tsx @@ -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 }) => { @@ -47,6 +48,9 @@ const components: TLComponents = { ) }, + InFrontOfTheCanvas: () => { + return + }, } interface SnapshotEditorProps { diff --git a/apps/dotcom/client/src/components/SnowStorm.tsx b/apps/dotcom/client/src/components/SnowStorm.tsx new file mode 100644 index 000000000000..3b88f1d3b516 --- /dev/null +++ b/apps/dotcom/client/src/components/SnowStorm.tsx @@ -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(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
+} diff --git a/apps/dotcom/client/styles/globals.css b/apps/dotcom/client/styles/globals.css index 22cc3fd5d8b2..26fdd216572f 100644 --- a/apps/dotcom/client/styles/globals.css +++ b/apps/dotcom/client/styles/globals.css @@ -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; +} diff --git a/apps/examples/src/misc/develop.tsx b/apps/examples/src/misc/develop.tsx index 49a27f6792e5..8a9774f5d859 100644 --- a/apps/examples/src/misc/develop.tsx +++ b/apps/examples/src/misc/develop.tsx @@ -71,7 +71,7 @@ export default function Develop() { } }} components={components} - /> + >
) }