Path: blob/main/src/vs/base/browser/ui/animations/animations.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { ThemeIcon } from '../../../common/themables.js';6import * as dom from '../../dom.js';78export const enum ClickAnimation {9Confetti = 1,10FloatingIcons = 2,11PulseWave = 3,12RadiantLines = 4,13}1415const confettiColors = [16'#007acc',17'#005a9e',18'#0098ff',19'#4fc3f7',20'#64b5f6',21'#42a5f5',22];2324let activeOverlay: HTMLElement | undefined;2526/**27* Creates a fixed-positioned overlay centered on the given element.28*/29function createOverlay(element: HTMLElement): { overlay: HTMLElement; cx: number; cy: number } | undefined {30if (activeOverlay) {31return undefined;32}3334const rect = element.getBoundingClientRect();35const ownerDocument = dom.getWindow(element).document;3637const overlay = dom.$('.animation-overlay');38overlay.style.position = 'fixed';39overlay.style.left = `${rect.left}px`;40overlay.style.top = `${rect.top}px`;41overlay.style.width = `${rect.width}px`;42overlay.style.height = `${rect.height}px`;43overlay.style.pointerEvents = 'none';44overlay.style.overflow = 'visible';45overlay.style.zIndex = '10000';4647ownerDocument.body.appendChild(overlay);48activeOverlay = overlay;4950return { overlay, cx: rect.width / 2, cy: rect.height / 2 };51}5253/**54* Cleans up the overlay after specified period.55*/56function cleanupOverlay(duration: number) {57setTimeout(() => {58if (activeOverlay) {59activeOverlay.remove();60activeOverlay = undefined;61}62}, duration);63}6465/**66* Bounce the element with a given scale and optional rotation.67*/68export function bounceElement(element: HTMLElement, opts: { scale?: number[]; rotate?: number[]; translateY?: number[]; duration?: number }) {69const frames: Keyframe[] = [];7071const steps = Math.max(opts.scale?.length ?? 0, opts.rotate?.length ?? 0, opts.translateY?.length ?? 0);72if (steps === 0) {73return;74}7576for (let i = 0; i < steps; i++) {77const frame: Keyframe = { offset: steps === 1 ? 1 : i / (steps - 1) };78let transformParts = '';7980const scale = opts.scale?.[i];81if (scale !== undefined) {82transformParts += `scale(${scale})`;83}8485const rotate = opts.rotate?.[i];86if (rotate !== undefined) {87transformParts += ` rotate(${rotate}deg)`;88}8990const translateY = opts.translateY?.[i];91if (translateY !== undefined) {92transformParts += ` translateY(${translateY}px)`;93}9495if (transformParts) {96frame.transform = transformParts.trim();97}98frames.push(frame);99}100101element.animate(frames, {102duration: opts.duration ?? 350,103easing: 'cubic-bezier(0.4, 0, 0.2, 1)',104fill: 'forwards',105});106}107108/**109* Confetti: small particles burst outward in a circle from the element center,110* with an expanding ring.111*/112export function triggerConfettiAnimation(element: HTMLElement) {113const result = createOverlay(element);114if (!result) {115return;116}117118const { overlay, cx, cy } = result;119const rect = element.getBoundingClientRect();120121// Element bounce122bounceElement(element, {123scale: [1, 1.3, 1],124rotate: [0, -10, 10, 0],125duration: 350,126});127128// Confetti particles129const particleCount = 10;130for (let i = 0; i < particleCount; i++) {131const size = 3 + (i % 3) * 1.5;132const angle = (i * 36 * Math.PI) / 180;133const distance = 35;134const particleOpacity = 0.6 + (i % 4) * 0.1;135136const part = dom.$('.animation-particle');137part.style.position = 'absolute';138part.style.width = `${size}px`;139part.style.height = `${size}px`;140part.style.borderRadius = '50%';141part.style.backgroundColor = confettiColors[i % confettiColors.length];142part.style.left = `${cx - size / 2}px`;143part.style.top = `${cy - size / 2}px`;144overlay.appendChild(part);145146const tx = Math.cos(angle) * distance;147const ty = Math.sin(angle) * distance;148149part.animate([150{ opacity: 0, transform: 'scale(0) translate(0, 0)' },151{ opacity: particleOpacity, transform: `scale(1) translate(${tx * 0.5}px, ${ty * 0.5}px)`, offset: 0.3 },152{ opacity: particleOpacity, transform: `scale(1) translate(${tx}px, ${ty}px)`, offset: 0.7 },153{ opacity: 0, transform: `scale(0) translate(${tx}px, ${ty}px)` },154], {155duration: 1100,156easing: 'cubic-bezier(0.4, 0, 0.2, 1)',157fill: 'forwards',158});159}160161// Expanding ring162const ring = dom.$('.animation-particle');163ring.style.position = 'absolute';164ring.style.left = '0';165ring.style.top = '0';166ring.style.width = `${rect.width}px`;167ring.style.height = `${rect.height}px`;168ring.style.borderRadius = '50%';169ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)';170ring.style.boxSizing = 'border-box';171overlay.appendChild(ring);172173ring.animate([174{ transform: 'scale(1)', opacity: 1 },175{ transform: 'scale(2)', opacity: 0 },176], {177duration: 800,178easing: 'cubic-bezier(0.4, 0, 0.2, 1)',179fill: 'forwards',180});181182cleanupOverlay(2000);183}184185/**186* Floating Icons: small icons float upward from the element.187*/188export function triggerFloatingIconsAnimation(element: HTMLElement, icon: ThemeIcon) {189const result = createOverlay(element);190if (!result) {191return;192}193194const { overlay, cx, cy } = result;195const rect = element.getBoundingClientRect();196197// Element bounce upward198bounceElement(element, {199translateY: [0, -6, 0],200duration: 350,201});202203// Floating icons204const iconCount = 6;205for (let i = 0; i < iconCount; i++) {206const size = 12 + (i % 3) * 2;207const iconEl = dom.$('.animation-particle');208iconEl.style.position = 'absolute';209iconEl.style.left = `${cx}px`;210iconEl.style.top = `${cy}px`;211iconEl.style.fontSize = `${size}px`;212iconEl.style.lineHeight = '1';213iconEl.style.color = 'var(--vscode-focusBorder, #007acc)';214iconEl.classList.add(...ThemeIcon.asClassNameArray(icon));215overlay.appendChild(iconEl);216217const driftX = (Math.random() - 0.5) * 50;218const floatY = -50 - (i % 3) * 10;219const rotate1 = (Math.random() - 0.5) * 20;220const rotate2 = (Math.random() - 0.5) * 40;221222iconEl.animate([223{ opacity: 0, transform: `translate(-50%, -50%) scale(0) rotate(${rotate1}deg)` },224{ opacity: 1, transform: `translate(calc(-50% + ${driftX * 0.3}px), calc(-50% + ${floatY * 0.3}px)) scale(1) rotate(${(rotate1 + rotate2) * 0.3}deg)`, offset: 0.3 },225{ opacity: 1, transform: `translate(calc(-50% + ${driftX * 0.7}px), calc(-50% + ${floatY * 0.7}px)) scale(1) rotate(${(rotate1 + rotate2) * 0.7}deg)`, offset: 0.7 },226{ opacity: 0, transform: `translate(calc(-50% + ${driftX}px), calc(-50% + ${floatY}px)) scale(0.8) rotate(${rotate2}deg)` },227], {228duration: 800 + (i % 3) * 200,229delay: i * 80,230easing: 'cubic-bezier(0.4, 0, 0.2, 1)',231fill: 'forwards',232});233}234235// Expanding ring236const ring = dom.$('.animation-particle');237ring.style.position = 'absolute';238ring.style.left = '0';239ring.style.top = '0';240ring.style.width = `${rect.width}px`;241ring.style.height = `${rect.height}px`;242ring.style.borderRadius = '50%';243ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)';244ring.style.boxSizing = 'border-box';245overlay.appendChild(ring);246247ring.animate([248{ transform: 'scale(1)', opacity: 1 },249{ transform: 'scale(2)', opacity: 0 },250], {251duration: 500,252easing: 'cubic-bezier(0.4, 0, 0.2, 1)',253fill: 'forwards',254});255256cleanupOverlay(2000);257}258259/**260* Pulse Wave: expanding rings and sparkle dots radiate from the element center.261*/262export function triggerPulseWaveAnimation(element: HTMLElement) {263const result = createOverlay(element);264if (!result) {265return;266}267268const { overlay, cx, cy } = result;269const rect = element.getBoundingClientRect();270271// Element bounce with slight rotation272bounceElement(element, {273scale: [1, 1.1, 1],274rotate: [0, -12, 0],275duration: 400,276});277278// Expanding rings279for (let i = 0; i < 2; i++) {280const ring = dom.$('.animation-particle');281ring.style.position = 'absolute';282ring.style.left = '0';283ring.style.top = '0';284ring.style.width = `${rect.width}px`;285ring.style.height = `${rect.height}px`;286ring.style.borderRadius = '50%';287ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)';288ring.style.boxSizing = 'border-box';289overlay.appendChild(ring);290291ring.animate([292{ transform: 'scale(0.8)', opacity: 0 },293{ transform: 'scale(0.8)', opacity: 0.6, offset: 0.01 },294{ transform: 'scale(2.5)', opacity: 0 },295], {296duration: 800,297delay: i * 150,298easing: 'cubic-bezier(0.4, 0, 0.2, 1)',299fill: 'forwards',300});301}302303// Sparkle dots304for (let i = 0; i < 6; i++) {305const angle = (i * 60 * Math.PI) / 180;306const distance = 30 + (i % 2) * 10;307const size = 3.5;308309const dot = dom.$('.animation-particle');310dot.style.position = 'absolute';311dot.style.width = `${size}px`;312dot.style.height = `${size}px`;313dot.style.borderRadius = '50%';314dot.style.backgroundColor = '#0098ff';315dot.style.left = `${cx - size / 2}px`;316dot.style.top = `${cy - size / 2}px`;317overlay.appendChild(dot);318319const tx = Math.cos(angle) * distance;320const ty = Math.sin(angle) * distance;321322dot.animate([323{ opacity: 0, transform: 'scale(0) translate(0, 0)' },324{ opacity: 1, transform: `scale(1) translate(${tx}px, ${ty}px)`, offset: 0.5 },325{ opacity: 0, transform: `scale(0) translate(${tx}px, ${ty}px)` },326], {327duration: 600,328delay: 100 + i * 50,329easing: 'cubic-bezier(0.4, 0, 0.2, 1)',330fill: 'forwards',331});332}333334// Background glow335const glow = dom.$('.animation-particle');336glow.style.position = 'absolute';337glow.style.left = '0';338glow.style.top = '0';339glow.style.width = `${rect.width}px`;340glow.style.height = `${rect.height}px`;341glow.style.borderRadius = '50%';342glow.style.backgroundColor = 'var(--vscode-focusBorder, #007acc)';343overlay.appendChild(glow);344345glow.animate([346{ transform: 'scale(0.9)', opacity: 0 },347{ transform: 'scale(0.9)', opacity: 0.5, offset: 0.01 },348{ transform: 'scale(1.5)', opacity: 0 },349], {350duration: 500,351easing: 'cubic-bezier(0.4, 0, 0.2, 1)',352fill: 'forwards',353});354355cleanupOverlay(2000);356}357358/**359* Radiant Lines: lines and dots emanate outward from the element center.360*/361export function triggerRadiantLinesAnimation(element: HTMLElement) {362const result = createOverlay(element);363if (!result) {364return;365}366367const { overlay, cx, cy } = result;368369// Element scale bounce370bounceElement(element, {371scale: [1, 1.15, 1],372duration: 350,373});374375// Dots at offset angles376for (let i = 0; i < 8; i++) {377const size = 3;378const dotOpacity = 0.7;379const angle = ((i * 45 + 22.5) * Math.PI) / 180;380const startDistance = 14;381const endDistance = 30;382383const dot = dom.$('.animation-particle');384dot.style.position = 'absolute';385dot.style.width = `${size}px`;386dot.style.height = `${size}px`;387dot.style.borderRadius = '50%';388dot.style.backgroundColor = 'var(--vscode-editor-foreground, #ffffff)';389dot.style.left = `${cx - size / 2}px`;390dot.style.top = `${cy - size / 2}px`;391overlay.appendChild(dot);392393const startX = Math.cos(angle) * startDistance;394const startY = Math.sin(angle) * startDistance;395const endX = Math.cos(angle) * endDistance;396const endY = Math.sin(angle) * endDistance;397398dot.animate([399{ opacity: 0, transform: `scale(0) translate(${startX}px, ${startY}px)` },400{ opacity: dotOpacity, transform: `scale(1.2) translate(${(startX + endX) / 2}px, ${(startY + endY) / 2}px)`, offset: 0.25 },401{ opacity: dotOpacity, transform: `scale(1) translate(${endX * 0.8}px, ${endY * 0.8}px)`, offset: 0.5 },402{ opacity: dotOpacity * 0.5, transform: `scale(1) translate(${endX}px, ${endY}px)`, offset: 0.75 },403{ opacity: 0, transform: `scale(0.5) translate(${endX}px, ${endY}px)` },404], {405duration: 1100,406easing: 'cubic-bezier(0.4, 0, 0.2, 1)',407fill: 'forwards',408});409}410411// Radiant lines412for (let i = 0; i < 8; i++) {413const angleDeg = i * 45;414415const lineWrapper = dom.$('.animation-particle');416lineWrapper.style.position = 'absolute';417lineWrapper.style.left = `${cx}px`;418lineWrapper.style.top = `${cy}px`;419lineWrapper.style.width = '0';420lineWrapper.style.height = '0';421lineWrapper.style.transform = `rotate(${angleDeg}deg)`;422overlay.appendChild(lineWrapper);423424const line = dom.$('.animation-particle');425line.style.position = 'absolute';426line.style.width = '2px';427line.style.height = '10px';428line.style.backgroundColor = 'var(--vscode-focusBorder, #007acc)';429line.style.left = '-1px';430line.style.top = '-22px';431line.style.transformOrigin = 'bottom center';432lineWrapper.appendChild(line);433434line.animate([435{ transform: 'scale(1, 0)', opacity: 0.6 },436{ transform: 'scale(1, 1)', opacity: 0.6, offset: 0.2 },437{ transform: 'scale(1, 1)', opacity: 0.6, offset: 0.6 },438{ transform: 'scale(1, 1)', opacity: 0.6, offset: 0.8 },439{ transform: 'scale(0, 0.3)', opacity: 0 },440], {441duration: 1200,442delay: 150,443easing: 'cubic-bezier(0.4, 0, 0.2, 1)',444fill: 'forwards',445});446}447448cleanupOverlay(2000);449}450451/**452* Triggers the specified click animation on the element.453* @param element The target element to animate.454* @param animation The type of click animation to trigger.455* @param icon Optional icon for animations that require it (e.g., FloatingIcons).456*/457export function triggerClickAnimation(element: HTMLElement, animation: ClickAnimation, icon?: ThemeIcon) {458switch (animation) {459case ClickAnimation.Confetti:460triggerConfettiAnimation(element);461break;462case ClickAnimation.FloatingIcons:463if (icon) {464triggerFloatingIconsAnimation(element, icon);465}466break;467case ClickAnimation.PulseWave:468triggerPulseWaveAnimation(element);469break;470case ClickAnimation.RadiantLines:471triggerRadiantLinesAnimation(element);472break;473}474}475476477