From 0f318dfaddeae147bd4d22c6b15c67d36c4dfd03 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 8 Mar 2024 16:10:08 +0100 Subject: [PATCH 01/13] BREAKING: Stopping a fallback animation should be imperative --- packages/houdini-utils/src/index.ts | 3 +- .../src/paint/fallback/animate.ts | 139 +++++++------- .../src/paint/fallback/anims/play.ts | 5 +- .../paint/fallback/fallbackPaintAnimation.ts | 7 + packages/houdini-utils/src/types.ts | 12 +- .../stories/Utils/Fallback.stories.tsx | 12 +- .../Utils/FallbackWithDuration.stories.tsx | 171 ++++++++++++++++++ .../stories/Utils/index.stories.tsx | 1 + 8 files changed, 272 insertions(+), 78 deletions(-) create mode 100644 packages/houdini-utils/stories/Utils/FallbackWithDuration.stories.tsx diff --git a/packages/houdini-utils/src/index.ts b/packages/houdini-utils/src/index.ts index f02d716bc..15c8a003c 100644 --- a/packages/houdini-utils/src/index.ts +++ b/packages/houdini-utils/src/index.ts @@ -1,4 +1,5 @@ -export { PaintWorklet, PaintWorkletGeometry } from './types'; +export { PaintWorklet } from './types'; +export type { PaintWorkletGeometry } from './types'; export { registerPaintWorklet } from './paint/houdini/registerPaintWorklet'; export { fallbackPaintAnimation } from './paint/fallback/fallbackPaintAnimation'; export { diff --git a/packages/houdini-utils/src/paint/fallback/animate.ts b/packages/houdini-utils/src/paint/fallback/animate.ts index 90dc5be3a..831c331b0 100644 --- a/packages/houdini-utils/src/paint/fallback/animate.ts +++ b/packages/houdini-utils/src/paint/fallback/animate.ts @@ -1,9 +1,19 @@ import { createKeyframeAnimation } from './keyframe'; import { arrayToRgba } from './util/css'; -import type { FallbackAnimationFn, TickFn } from '../../types'; +import type { + FallbackAnimation, + FallbackAnimationFn, + TickFn, +} from '../../types'; export const animate: FallbackAnimationFn = (params) => { - const { onComplete, isStopped, onUpdate, ...otherParams } = params; + const { + onComplete, + isStopped, + onUpdate, + iterationCount = 1, + ...otherParams + } = params; const result = createKeyframeAnimation(otherParams); if (!result) { console.error('Unable to create keyframe animation.'); @@ -12,7 +22,7 @@ export const animate: FallbackAnimationFn = (params) => { const { anims, overallDuration } = result; - tick(anims, overallDuration, onComplete, onUpdate, Infinity, isStopped); + tick(anims, overallDuration, onComplete, onUpdate, iterationCount, isStopped); }; const stringifyValue = (value: number | number[]): string => @@ -34,79 +44,86 @@ const tick: TickFn = ( const currentDuration = time - start; currentValues.clear(); + const initialFrame = (animValue: FallbackAnimation) => { + for (const [key, value] of Object.entries( + animValue.keyframes[0].startValues + )) { + currentValues.set(key, stringifyValue(value)); + } + }; + + const lastFrame = (animValue: FallbackAnimation) => { + for (const [key, value] of Object.entries( + animValue.keyframes[animValue.keyframes.length - 1].endValues + )) { + currentValues.set(key, stringifyValue(value)); + } + }; + + const nextFrame = (animValue: FallbackAnimation) => { + const animData = animValue.keyframes.find( + (animData) => + currentDuration >= animData.startTime && + currentDuration <= animData.endTime + ); + + if (!animData) { + console.error( + 'Could not find animData for currentDuration', + currentDuration + ); + return; + } + + for (const key of Object.keys(animData.startValues)) { + const startValue = animData.startValues[key]; + const endValue = animData.endValues[key]; + const value = animData.ease( + startValue, + endValue, + currentDuration - animData.startTime, + animData.duration + ); + + animValue.currentValues[key] = value; + currentValues.set(key, stringifyValue(value)); + } + }; + for (const animValue of anims) { if (currentDuration <= animValue.keyframes[0].startTime) { - // We're before the first animation. - // Just use its initial values. - for (const [key, value] of Object.entries( - animValue.keyframes[0].startValues - )) { - currentValues.set(key, stringifyValue(value)); - } + initialFrame(animValue); } else if ( currentDuration >= animValue.keyframes[animValue.keyframes.length - 1].endTime ) { // We're after the last animation. // Just use its final values. - for (const [key, value] of Object.entries( - animValue.keyframes[animValue.keyframes.length - 1].endValues - )) { - currentValues.set(key, stringifyValue(value)); - } + lastFrame(animValue); } else { - const animData = animValue.keyframes.find( - (animData) => - currentDuration >= animData.startTime && - currentDuration <= animData.endTime - ); - - if (!animData) { - console.error( - 'Could not find animData for currentDuration', - currentDuration - ); - return; - } - - for (const key of Object.keys(animData.startValues)) { - const startValue = animData.startValues[key]; - const endValue = animData.endValues[key]; - const value = animData.ease( - startValue, - endValue, - currentDuration - animData.startTime, - animData.duration - ); - - animValue.currentValues[key] = value; - currentValues.set(key, stringifyValue(value)); - } + nextFrame(animValue); } } - onUpdate(currentValues); - - if (isStopped() === true) { - if (currentDuration >= overallDuration) { - onComplete(currentValues); - } else { - requestAnimationFrame(raf); + if (isStopped()) { + for (const animValue of anims) { + initialFrame(animValue); } + onComplete(currentValues); + } else if ( + currentDuration >= overallDuration && + currentIteration < iterationCount + ) { + currentIteration++; + start = performance.now(); + requestAnimationFrame(raf); + } else if (currentDuration >= overallDuration) { + onComplete(currentValues); } else { - if (currentDuration >= overallDuration) { - if (currentIteration < iterationCount) { - currentIteration++; - start = performance.now(); - requestAnimationFrame(raf); - // how to reset this animation?? - } else { - onComplete(currentValues); - } - } else { - requestAnimationFrame(raf); - } + requestAnimationFrame(raf); } + + onUpdate(currentValues); }; raf(); diff --git a/packages/houdini-utils/src/paint/fallback/anims/play.ts b/packages/houdini-utils/src/paint/fallback/anims/play.ts index 76f7ede1b..a3d683bc0 100644 --- a/packages/houdini-utils/src/paint/fallback/anims/play.ts +++ b/packages/houdini-utils/src/paint/fallback/anims/play.ts @@ -43,9 +43,9 @@ export const playAnim = ( return ( onComplete: CallbackFn, - isStopped: () => boolean, onUpdate?: CallbackFn ) => { + state.running = true; resizeObserver.observe(state.target); const onAnimUpdate: CallbackFn = (currentValues) => { @@ -70,9 +70,10 @@ export const playAnim = ( animate({ ...animationParams, target: state.target, - isStopped, + isStopped: () => !state.running, onUpdate: onAnimUpdate, onComplete: (currentValues) => { + state.running = false; resizeObserver.unobserve(state.target); onComplete(currentValues); }, diff --git a/packages/houdini-utils/src/paint/fallback/fallbackPaintAnimation.ts b/packages/houdini-utils/src/paint/fallback/fallbackPaintAnimation.ts index 264f04cc1..794929e83 100644 --- a/packages/houdini-utils/src/paint/fallback/fallbackPaintAnimation.ts +++ b/packages/houdini-utils/src/paint/fallback/fallbackPaintAnimation.ts @@ -20,6 +20,8 @@ const cannotDraw = { play: () => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function cleanup: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + stop: () => {}, }; let flairFallbackId = 0; @@ -35,6 +37,7 @@ export const fallbackPaintAnimation = ( mode: 'to-data-url', id: `houdini-fallback-${++flairFallbackId}`, wrapper: null, + running: false, }; // Non-Houdini fallbacks require a canvas element be present in the DOM. @@ -70,6 +73,9 @@ export const fallbackPaintAnimation = ( } const play = playAnim(state, paintWorklet, animationParams); + const stop = () => { + state.running = false; + }; const cleanup = () => { state.ctx?.canvas.remove(); if (state.wrapper?.childElementCount === 0) { @@ -82,5 +88,6 @@ export const fallbackPaintAnimation = ( canvas: state.ctx?.canvas ?? null, play, cleanup, + stop, }; }; diff --git a/packages/houdini-utils/src/types.ts b/packages/houdini-utils/src/types.ts index 1a6a62d5e..94e9388e2 100644 --- a/packages/houdini-utils/src/types.ts +++ b/packages/houdini-utils/src/types.ts @@ -14,7 +14,7 @@ export interface PaintWorkletGeometry { /** * Function that animates values. */ -export type FallbackAnimationFn = (params: FallbackAnimationParams) => void; +export type FallbackAnimationFn = (params: FallbackAnimationParams & { isStopped: () => boolean }) => void; /** * Function that creates keyframe animation objects from CSS input. @@ -57,8 +57,6 @@ export type FallbackAnimationParams = { */ target: HTMLElement; - isStopped: () => boolean; - /** * Callback function that will be called on every animation frame. */ @@ -191,11 +189,8 @@ export type TickFn = ( export type FallbackAnimationReturn = { id: string; canvas: HTMLCanvasElement | null; - play: ( - onComplete: CallbackFn, - isStopped: () => boolean, - onUpdate?: CallbackFn - ) => void; + play: (onComplete: CallbackFn, onUpdate?: CallbackFn) => void; + stop: () => void; cleanup: () => void; }; @@ -205,4 +200,5 @@ export type FallbackAnimationState = { id: string; mode: 'moz-element' | 'webkit-canvas' | 'to-data-url'; wrapper: HTMLElement | null; + running: boolean; }; diff --git a/packages/houdini-utils/stories/Utils/Fallback.stories.tsx b/packages/houdini-utils/stories/Utils/Fallback.stories.tsx index 0ac4f5251..c41640b57 100644 --- a/packages/houdini-utils/stories/Utils/Fallback.stories.tsx +++ b/packages/houdini-utils/stories/Utils/Fallback.stories.tsx @@ -53,6 +53,7 @@ export const fallback = (target: HTMLElement) => { return fallbackPaintAnimation(target, new MyPaintWorklet(), { duration: '1000ms', timingFunction: 'ease-in-out', + iterationCount: Infinity, target, onComplete: () => null, onUpdate: () => null, @@ -76,7 +77,6 @@ export const fallback = (target: HTMLElement) => { }, }, ], - isStopped: () => false, }); }; @@ -93,6 +93,7 @@ const getBackgroundImage = (id: string) => { const useFallbackAnimation = () => { const stateRef = React.useRef<'rest' | 'play'>('rest'); const playRef = React.useRef<() => void>(() => null); + const stopRef = React.useRef<() => void>(() => null); const cleanupRef = React.useRef<() => void>(() => null); const targetRef = React.useCallback((node: HTMLElement | null) => { if (!node) { @@ -100,7 +101,8 @@ const useFallbackAnimation = () => { return; } - const { id, play, canvas, cleanup } = fallback(node); + const { id, play, stop, canvas, cleanup } = fallback(node); + stopRef.current = stop; cleanupRef.current = cleanup; const backgroundImage = getBackgroundImage(id); @@ -108,8 +110,6 @@ const useFallbackAnimation = () => { stateRef.current = 'rest'; }; - const isStopped = () => stateRef.current === 'rest'; - let onUpdate: () => void = () => null; if (backgroundImage) { @@ -126,7 +126,7 @@ const useFallbackAnimation = () => { playRef.current = () => { if (stateRef.current === 'rest') { stateRef.current = 'play'; - play(onComplete, isStopped, onUpdate); + play(onComplete, onUpdate); } }; }, []); @@ -134,7 +134,7 @@ const useFallbackAnimation = () => { return { targetRef, play: () => playRef.current(), - stop: () => (stateRef.current = 'rest'), + stop: () => stopRef.current(), }; }; diff --git a/packages/houdini-utils/stories/Utils/FallbackWithDuration.stories.tsx b/packages/houdini-utils/stories/Utils/FallbackWithDuration.stories.tsx new file mode 100644 index 000000000..3beda723e --- /dev/null +++ b/packages/houdini-utils/stories/Utils/FallbackWithDuration.stories.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { + blobify, + registerPaintWorklet, + fallbackPaintAnimation, + hasMozElement, + hasWebkitCanvas, + PaintWorklet, + PaintWorkletGeometry, +} from '@fluentui-contrib/houdini-utils'; +import { Button } from '@fluentui/react-components'; + +class MyPaintWorklet implements PaintWorklet { + public static get inputProperties() { + return [ + '--checkerboard-color-1', + '--checkerboard-color-2', + '--checkerboard-color-3', + ]; + } + + paint( + ctx: CanvasRenderingContext2D, + geom: PaintWorkletGeometry, + properties: Map + ) { + // Use `ctx` as if it was a normal canvas + const colors = [ + properties.get('--checkerboard-color-1'), + properties.get('--checkerboard-color-2'), + properties.get('--checkerboard-color-3'), + ].filter(Boolean) as string[]; + + const size = 32; + for (let y = 0; y < geom.height / size; y++) { + for (let x = 0; x < geom.width / size; x++) { + const color = colors[(x + y) % colors.length]; + ctx.beginPath(); + ctx.fillStyle = color; + ctx.rect(x * size, y * size, size, size); + ctx.fill(); + } + } + } +} + +registerPaintWorklet( + URL.createObjectURL(blobify('mypaintworklet', MyPaintWorklet)), + '' +).then(() => console.log('registered')); + +export const fallback = (target: HTMLElement) => { + return fallbackPaintAnimation(target, new MyPaintWorklet(), { + duration: '1000ms', + timingFunction: 'ease-in-out', + target, + onComplete: () => null, + onUpdate: () => null, + delay: '0', + animations: [ + { + '0%': { + '--checkerboard-color-1': 'var(--red)', + '--checkerboard-color-2': 'var(--green)', + '--checkerboard-color-3': 'var(--blue)', + }, + '50%': { + '--checkerboard-color-1': 'var(--blue)', + '--checkerboard-color-2': 'var(--red)', + '--checkerboard-color-3': 'var(--green)', + }, + '100%': { + '--checkerboard-color-1': 'var(--green)', + '--checkerboard-color-2': 'var(--blue)', + '--checkerboard-color-3': 'var(--red)', + }, + }, + ], + }); +}; + +const getBackgroundImage = (id: string) => { + if (hasMozElement()) { + return `-moz-element(#${id})`; + } else if (hasWebkitCanvas()) { + return `-webkit-canvas(${id})`; + } + + return undefined; +}; + +const useFallbackAnimation = (options: { onComplete: () => void }) => { + const stateRef = React.useRef<'rest' | 'play'>('rest'); + const playRef = React.useRef<() => void>(() => null); + const stopRef = React.useRef<() => void>(() => null); + const cleanupRef = React.useRef<() => void>(() => null); + const targetRef = React.useCallback((node: HTMLElement | null) => { + if (!node) { + cleanupRef.current(); + return; + } + + const { id, play, stop, canvas, cleanup } = fallback(node); + stopRef.current = stop; + cleanupRef.current = cleanup; + const backgroundImage = getBackgroundImage(id); + + const onComplete = () => { + stateRef.current = 'rest'; + options.onComplete(); + }; + + let onUpdate: () => void = () => null; + + if (backgroundImage) { + node.style.backgroundImage = backgroundImage; + } else { + onUpdate = () => { + if (canvas) { + const backgroundImage = `url(${canvas.toDataURL('image/png')})`; + node.style.backgroundImage = backgroundImage; + } + }; + } + + playRef.current = () => { + if (stateRef.current === 'rest') { + stateRef.current = 'play'; + play(onComplete, onUpdate); + } + }; + }, []); + + return { + targetRef, + play: () => playRef.current(), + stop: () => stopRef.current(), + }; +}; + +export const FallbackWithDuration = () => { + const { targetRef, play } = useFallbackAnimation({ + onComplete: () => setRunning(false), + }); + const [running, setRunning] = React.useState(false); + React.useEffect(() => { + if (running) { + play(); + } + }, [running]); + + return ( + <> + +
+ + ); +}; diff --git a/packages/houdini-utils/stories/Utils/index.stories.tsx b/packages/houdini-utils/stories/Utils/index.stories.tsx index 196babaf2..9711e0a78 100644 --- a/packages/houdini-utils/stories/Utils/index.stories.tsx +++ b/packages/houdini-utils/stories/Utils/index.stories.tsx @@ -3,6 +3,7 @@ import { Meta } from '@storybook/react'; export { Default } from './Default.stories'; export { Fallback } from './Fallback.stories'; +export { FallbackWithDuration } from './FallbackWithDuration.stories'; const meta: Meta = { title: 'Houdini Utils', From 02c41790e2cce3b4891cc35f6ced5c7ae6534839 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 8 Mar 2024 16:16:19 +0100 Subject: [PATCH 02/13] Change files --- ...houdini-utils-09b68852-bed9-4483-9f6c-f11a7abdb080.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-contrib-houdini-utils-09b68852-bed9-4483-9f6c-f11a7abdb080.json diff --git a/change/@fluentui-contrib-houdini-utils-09b68852-bed9-4483-9f6c-f11a7abdb080.json b/change/@fluentui-contrib-houdini-utils-09b68852-bed9-4483-9f6c-f11a7abdb080.json new file mode 100644 index 000000000..a57132861 --- /dev/null +++ b/change/@fluentui-contrib-houdini-utils-09b68852-bed9-4483-9f6c-f11a7abdb080.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "BREAKING: Stopping a fallback animation should be imperative", + "packageName": "@fluentui-contrib/houdini-utils", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} From ca10c36d0c8bb963bca301c95ad1b3b9bec908db Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 8 Mar 2024 16:16:28 +0100 Subject: [PATCH 03/13] formatting --- packages/houdini-utils/src/paint/fallback/anims/play.ts | 5 +---- packages/houdini-utils/src/types.ts | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/houdini-utils/src/paint/fallback/anims/play.ts b/packages/houdini-utils/src/paint/fallback/anims/play.ts index a3d683bc0..83e1a95f8 100644 --- a/packages/houdini-utils/src/paint/fallback/anims/play.ts +++ b/packages/houdini-utils/src/paint/fallback/anims/play.ts @@ -41,10 +41,7 @@ export const playAnim = ( } }); - return ( - onComplete: CallbackFn, - onUpdate?: CallbackFn - ) => { + return (onComplete: CallbackFn, onUpdate?: CallbackFn) => { state.running = true; resizeObserver.observe(state.target); diff --git a/packages/houdini-utils/src/types.ts b/packages/houdini-utils/src/types.ts index 94e9388e2..2808f77b3 100644 --- a/packages/houdini-utils/src/types.ts +++ b/packages/houdini-utils/src/types.ts @@ -14,7 +14,9 @@ export interface PaintWorkletGeometry { /** * Function that animates values. */ -export type FallbackAnimationFn = (params: FallbackAnimationParams & { isStopped: () => boolean }) => void; +export type FallbackAnimationFn = ( + params: FallbackAnimationParams & { isStopped: () => boolean } +) => void; /** * Function that creates keyframe animation objects from CSS input. From c2ed617185b11baa5fdd55f3658b581e450b03c2 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Sat, 9 Mar 2024 03:01:23 +0100 Subject: [PATCH 04/13] add test story --- .../stories/Utils/Test.stories.tsx | 450 ++++++++++++++++++ .../stories/Utils/index.stories.tsx | 1 + 2 files changed, 451 insertions(+) create mode 100644 packages/houdini-utils/stories/Utils/Test.stories.tsx diff --git a/packages/houdini-utils/stories/Utils/Test.stories.tsx b/packages/houdini-utils/stories/Utils/Test.stories.tsx new file mode 100644 index 000000000..8c1ea8dea --- /dev/null +++ b/packages/houdini-utils/stories/Utils/Test.stories.tsx @@ -0,0 +1,450 @@ +import * as React from 'react'; +import { + blobify, + registerPaintWorklet, + PaintWorklet, + PaintWorkletGeometry, + hasHoudini, +} from '@fluentui-contrib/houdini-utils'; +import { Switch, tokens, Button } from '@fluentui/react-components'; + +try { + CSS.registerProperty({ + // Percentage! + name: '--liveness-border-top-visibility', + syntax: '', + inherits: true, + initialValue: '0%', + }); + + CSS.registerProperty({ + // Percentage! + name: '--liveness-border-right-visibility', + syntax: '', + inherits: true, + initialValue: '0%', + }); + + CSS.registerProperty({ + // Percentage! + name: '--liveness-border-bottom-visibility', + syntax: '', + inherits: true, + initialValue: '0%', + }); + + CSS.registerProperty({ + // Percentage! + name: '--liveness-border-left-visibility', + syntax: '', + inherits: true, + initialValue: '0%', + }); + + CSS.registerProperty({ + // Radians! + name: '--liveness-angle', + syntax: '', + inherits: true, + initialValue: String((3 * Math.PI) / 4), + }); + + CSS.registerProperty({ + name: '--liveness-color-1', + syntax: '', + inherits: true, + initialValue: 'transparent', + }); + + CSS.registerProperty({ + name: '--liveness-color-2', + syntax: '', + inherits: true, + initialValue: 'transparent', + }); + + CSS.registerProperty({ + name: '--liveness-color-3', + syntax: '', + inherits: true, + initialValue: 'transparent', + }); + + CSS.registerProperty({ + name: '--liveness-stroke-width', + syntax: '', + inherits: true, + initialValue: '2px', + }); +} catch { + /* empty */ +} + +class MyPaintWorklet implements PaintWorklet { + public static get inputProperties() { + return [ + '--liveness-angle', + '--liveness-color-1', + '--liveness-color-2', + '--liveness-color-3', + '--liveness-stroke-width', + '--liveness-border-top-visibility', + '--liveness-border-right-visibility', + '--liveness-border-bottom-visibility', + '--liveness-border-left-visibility', + 'border-top-left-radius', + 'border-top-right-radius', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + ]; + } + + private parseProps(props: Map) { + const angle = parseFloat(String(props.get('--liveness-angle'))); + + const borderTopVisibility = + parseFloat(String(props.get('--liveness-border-top-visibility'))) / 100; + const borderRightVisibility = + parseFloat(String(props.get('--liveness-border-right-visibility'))) / 100; + const borderBottomVisibility = + parseFloat(String(props.get('--liveness-border-bottom-visibility'))) / + 100; + const borderLeftVisibility = + parseFloat(String(props.get('--liveness-border-left-visibility'))) / 100; + const strokeWidth = parseFloat( + String(props.get('--liveness-stroke-width')) + ); + + return { + angle, + borderBottomVisibility, + borderLeftVisibility, + borderTopVisibility, + borderRightVisibility, + strokeWidth, + colors: [ + String(props.get(`--liveness-color-1`)), + String(props.get(`--liveness-color-2`)), + String(props.get(`--liveness-color-3`)), + ], + borderTopLeftRadius: parseFloat( + String(props.get('border-top-left-radius')) + ), + borderTopRightRadius: parseFloat( + String(props.get('border-top-right-radius')) + ), + borderBottomLeftRadius: parseFloat( + String(props.get('border-bottom-left-radius')) + ), + borderBottomRightRadius: parseFloat( + String(props.get('border-bottom-right-radius')) + ), + }; + } + + /** + * Renders the main gradient rectangle which will spin + */ + private renderGradientRect( + ctx: CanvasRenderingContext2D, + options: { colors: string[]; angle: number; width: number; height: number } + ) { + const { angle, width, height, colors } = options; + const midX = width / 2; + const midY = height / 2; + const length = Math.sqrt(midX * midX + midY * midY); + + const lenX = Math.cos(angle) * length; + const lenY = Math.sin(angle) * length; + + const x1 = midX - lenX; + const y1 = midY - lenY; + const x2 = midX + lenX; + const y2 = midY + lenY; + + const gradient = ctx.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, colors[0]); + gradient.addColorStop(0.5, colors[1]); + gradient.addColorStop(1, colors[2]); + + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = gradient; + // main rectangle with gradient + ctx.fillRect(0, 0, width, height); + } + + /** + * Renders a clipping rect inside the gradient rect to get a border effect + */ + private renderClippingBorderRect( + ctx: CanvasRenderingContext2D, + options: { + borderTopLeftRadius: number; + borderTopRightRadius: number; + borderBottomLeftRadius: number; + borderBottomRightRadius: number; + strokeWidth: number; + width: number; + height: number; + } + ) { + const { + strokeWidth, + width, + height, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopLeftRadius, + borderTopRightRadius, + } = options; + const radii = [ + Math.max(borderTopLeftRadius - strokeWidth, 0), + Math.max(borderTopRightRadius - strokeWidth, 0), + Math.max(borderBottomRightRadius - strokeWidth, 0), + Math.max(borderBottomLeftRadius - strokeWidth, 0), + ]; + + ctx.globalCompositeOperation = 'destination-out'; + // This should never render because of the composition mode. + // Using an obviously wrong color so if it _does_ render + // we'll catch it early. + ctx.fillStyle = 'yellow'; + ctx.beginPath(); + + // mask rectangle + // clips the gradient to have a border + ctx.roundRect( + strokeWidth, + strokeWidth, + width - strokeWidth * 2, + height - strokeWidth * 2, + radii + ); + + ctx.fill(); + } + + /** + * Renders two overlapping rects that will shrink based on each side's visibility + * Provides the effect that the border is being 'drawn' around the rect + */ + private renderClippingProgressBorderRects( + ctx: CanvasRenderingContext2D, + options: { + borderBottomVisibility: number; + borderLeftVisibility: number; + borderTopVisibility: number; + borderRightVisibility: number; + strokeWidth: number; + width: number; + height: number; + } + ) { + const { + borderBottomVisibility, + borderLeftVisibility, + borderRightVisibility, + borderTopVisibility, + height, + strokeWidth, + width, + } = options; + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.fillStyle = 'cyan'; + ctx.rect( + width, + height, + Math.min(-width * (1 - borderTopVisibility), -strokeWidth), + -height * (1 - borderRightVisibility) + ); + ctx.fill(); + + ctx.beginPath(); + ctx.fillStyle = 'pink'; + ctx.rect( + 0, + 0 + strokeWidth, + Math.max( + (width - strokeWidth) * (1 - borderBottomVisibility), + strokeWidth + ), + height * (1 - borderLeftVisibility) + ); + ctx.fill(); + } + + paint( + ctx: CanvasRenderingContext2D, + size: PaintWorkletGeometry, + props: Map + ) { + const { + angle, + borderBottomVisibility, + borderLeftVisibility, + borderTopVisibility, + borderRightVisibility, + strokeWidth, + colors, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopLeftRadius, + borderTopRightRadius, + } = this.parseProps(props); + const { width, height } = size; + + this.renderGradientRect(ctx, { colors, angle, width, height }); + this.renderClippingBorderRect(ctx, { + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopLeftRadius, + borderTopRightRadius, + strokeWidth, + width, + height, + }); + this.renderClippingProgressBorderRects(ctx, { + borderBottomVisibility, + borderLeftVisibility, + borderTopVisibility, + borderRightVisibility, + height, + strokeWidth, + width, + }); + + // top + // ctx.beginPath(); + // ctx.fillStyle = 'pink'; + // ctx.rect(width, 0, -width * (1 - borderTopVisibility), strokeWidth); + // ctx.fill(); + + // // left + // ctx.beginPath(); + // ctx.fillStyle = 'pink'; + // ctx.rect(0, 0, strokeWidth, width * (1 - borderLeftVisibility)); + // ctx.fill(); + + // // right + // ctx.beginPath(); + // ctx.fillStyle = 'pink'; + // ctx.rect( + // width, + // height, + // -strokeWidth, + // -height * (1 - borderRightVisibility) + // ); + // ctx.fill(); + + // // bottom + // ctx.beginPath(); + // ctx.fillStyle = 'pink'; + // ctx.rect(0, height, width * (1 - borderBottomVisibility), -strokeWidth); + // ctx.fill(); + } +} + +registerPaintWorklet( + URL.createObjectURL(blobify('testworklet', MyPaintWorklet)), + '' +).then(() => console.log('registered')); + +export const Test = () => { + const ref = React.useRef(null); + const drawAnimRef = React.useRef(null); + const spinAnimRef = React.useRef(null); + const [running, setRunning] = React.useState(false); + React.useLayoutEffect(() => { + if (!ref.current) { + return; + } + + if (running) { + drawAnimRef.current = ref.current.animate( + [ + { + '--liveness-border-top-visibility': '0%', + '--liveness-border-right-visibility': '0%', + '--liveness-border-bottom-visibility': '0%', + '--liveness-border-left-visibility': '0%', + }, + { + '--liveness-border-top-visibility': '100%', + '--liveness-border-right-visibility': '0%', + '--liveness-border-bottom-visibility': '0%', + '--liveness-border-left-visibility': '0%', + }, + { + '--liveness-border-top-visibility': '100%', + '--liveness-border-right-visibility': '100%', + '--liveness-border-bottom-visibility': '0%', + '--liveness-border-left-visibility': '0%', + }, + { + '--liveness-border-top-visibility': '100%', + '--liveness-border-right-visibility': '100%', + '--liveness-border-bottom-visibility': '100%', + '--liveness-border-left-visibility': '0%', + }, + { + '--liveness-border-top-visibility': '100%', + '--liveness-border-right-visibility': '100%', + '--liveness-border-bottom-visibility': '100%', + '--liveness-border-left-visibility': '100%', + }, + ], + { duration: 200, easing: 'linear', fill: 'forwards' } + ); + + // drawAnimRef.current.persist(); + + const START_ANGLE = (3 * Math.PI) / 4; + const startAngle = String(START_ANGLE); + const endAngle = String(START_ANGLE + 2 * Math.PI); + spinAnimRef.current = ref.current.animate( + [{ '--liveness-angle': startAngle }, { '--liveness-angle': endAngle }], + { duration: 2000, delay: 0, easing: 'linear', iterations: Infinity } + ); + } else { + drawAnimRef.current?.cancel(); + spinAnimRef.current?.cancel(); + } + }, [running]); + + if (!hasHoudini()) { + return ( +
+ ⚠️ This browser does not support houdini, please take a look at the + fallback example. +
+ ); + } + return ( + <> + setRunning(data.checked)} + checked={running} + label="Toggle animation" + /> +
+ +
+ + ); +}; diff --git a/packages/houdini-utils/stories/Utils/index.stories.tsx b/packages/houdini-utils/stories/Utils/index.stories.tsx index 196babaf2..a3c35a520 100644 --- a/packages/houdini-utils/stories/Utils/index.stories.tsx +++ b/packages/houdini-utils/stories/Utils/index.stories.tsx @@ -3,6 +3,7 @@ import { Meta } from '@storybook/react'; export { Default } from './Default.stories'; export { Fallback } from './Fallback.stories'; +export { Test } from './Test.stories'; const meta: Meta = { title: 'Houdini Utils', From 89c139900954dc7ba7eb16a98ce71680a80b2db7 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Sat, 9 Mar 2024 04:47:47 +0100 Subject: [PATCH 05/13] update --- .../stories/Utils/Test.stories.tsx | 226 +++++------------- 1 file changed, 61 insertions(+), 165 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/Test.stories.tsx b/packages/houdini-utils/stories/Utils/Test.stories.tsx index 8c1ea8dea..ff74c0bab 100644 --- a/packages/houdini-utils/stories/Utils/Test.stories.tsx +++ b/packages/houdini-utils/stories/Utils/Test.stories.tsx @@ -6,39 +6,15 @@ import { PaintWorkletGeometry, hasHoudini, } from '@fluentui-contrib/houdini-utils'; -import { Switch, tokens, Button } from '@fluentui/react-components'; +import { Switch, tokens } from '@fluentui/react-components'; try { CSS.registerProperty({ - // Percentage! - name: '--liveness-border-top-visibility', - syntax: '', - inherits: true, - initialValue: '0%', - }); - - CSS.registerProperty({ - // Percentage! - name: '--liveness-border-right-visibility', - syntax: '', - inherits: true, - initialValue: '0%', - }); - - CSS.registerProperty({ - // Percentage! - name: '--liveness-border-bottom-visibility', - syntax: '', - inherits: true, - initialValue: '0%', - }); - - CSS.registerProperty({ - // Percentage! - name: '--liveness-border-left-visibility', - syntax: '', + // Radians! + name: '--liveness-progress', + syntax: '', inherits: true, - initialValue: '0%', + initialValue: '0', }); CSS.registerProperty({ @@ -88,10 +64,7 @@ class MyPaintWorklet implements PaintWorklet { '--liveness-color-2', '--liveness-color-3', '--liveness-stroke-width', - '--liveness-border-top-visibility', - '--liveness-border-right-visibility', - '--liveness-border-bottom-visibility', - '--liveness-border-left-visibility', + '--liveness-progress', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', @@ -101,27 +74,14 @@ class MyPaintWorklet implements PaintWorklet { private parseProps(props: Map) { const angle = parseFloat(String(props.get('--liveness-angle'))); - - const borderTopVisibility = - parseFloat(String(props.get('--liveness-border-top-visibility'))) / 100; - const borderRightVisibility = - parseFloat(String(props.get('--liveness-border-right-visibility'))) / 100; - const borderBottomVisibility = - parseFloat(String(props.get('--liveness-border-bottom-visibility'))) / - 100; - const borderLeftVisibility = - parseFloat(String(props.get('--liveness-border-left-visibility'))) / 100; const strokeWidth = parseFloat( String(props.get('--liveness-stroke-width')) ); return { angle, - borderBottomVisibility, - borderLeftVisibility, - borderTopVisibility, - borderRightVisibility, strokeWidth, + progress: parseFloat(String(props.get('--liveness-progress'))), colors: [ String(props.get(`--liveness-color-1`)), String(props.get(`--liveness-color-2`)), @@ -225,52 +185,36 @@ class MyPaintWorklet implements PaintWorklet { } /** - * Renders two overlapping rects that will shrink based on each side's visibility - * Provides the effect that the border is being 'drawn' around the rect + * Renders a cone that will clip the border. + * When there no progress the cone is in fact a circle and hides the entire border + * As the progress increases the cone becomes smaller and smaller which gradually reveals the border */ - private renderClippingProgressBorderRects( + private renderClippingCone( ctx: CanvasRenderingContext2D, - options: { - borderBottomVisibility: number; - borderLeftVisibility: number; - borderTopVisibility: number; - borderRightVisibility: number; - strokeWidth: number; - width: number; - height: number; - } + options: { width: number; height: number; progress: number } ) { - const { - borderBottomVisibility, - borderLeftVisibility, - borderRightVisibility, - borderTopVisibility, - height, - strokeWidth, - width, - } = options; - ctx.globalCompositeOperation = 'destination-out'; - ctx.beginPath(); - ctx.fillStyle = 'cyan'; - ctx.rect( - width, - height, - Math.min(-width * (1 - borderTopVisibility), -strokeWidth), - -height * (1 - borderRightVisibility) - ); - ctx.fill(); + const { width, height, progress } = options; + + function toRadians(deg: number) { + return (deg * Math.PI) / 180; + } + + if (progress === 1) { + return; + } + + const startAngle = toRadians(360 * progress); + const endAngle = toRadians(0); + ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); - ctx.fillStyle = 'pink'; - ctx.rect( - 0, - 0 + strokeWidth, - Math.max( - (width - strokeWidth) * (1 - borderBottomVisibility), - strokeWidth - ), - height * (1 - borderLeftVisibility) - ); + const midX = width / 2; + const midY = height / 2; + ctx.moveTo(midX, midY); + ctx.arc(midX, midY, 400, startAngle, endAngle); + ctx.lineTo(midX, midY); + ctx.closePath(); + ctx.fillStyle = 'yellow'; ctx.fill(); } @@ -281,20 +225,20 @@ class MyPaintWorklet implements PaintWorklet { ) { const { angle, - borderBottomVisibility, - borderLeftVisibility, - borderTopVisibility, - borderRightVisibility, strokeWidth, colors, borderBottomLeftRadius, borderBottomRightRadius, borderTopLeftRadius, borderTopRightRadius, + progress, } = this.parseProps(props); const { width, height } = size; this.renderGradientRect(ctx, { colors, angle, width, height }); + + this.renderClippingCone(ctx, { width, height, progress }); + this.renderClippingBorderRect(ctx, { borderBottomLeftRadius, borderBottomRightRadius, @@ -304,44 +248,6 @@ class MyPaintWorklet implements PaintWorklet { width, height, }); - this.renderClippingProgressBorderRects(ctx, { - borderBottomVisibility, - borderLeftVisibility, - borderTopVisibility, - borderRightVisibility, - height, - strokeWidth, - width, - }); - - // top - // ctx.beginPath(); - // ctx.fillStyle = 'pink'; - // ctx.rect(width, 0, -width * (1 - borderTopVisibility), strokeWidth); - // ctx.fill(); - - // // left - // ctx.beginPath(); - // ctx.fillStyle = 'pink'; - // ctx.rect(0, 0, strokeWidth, width * (1 - borderLeftVisibility)); - // ctx.fill(); - - // // right - // ctx.beginPath(); - // ctx.fillStyle = 'pink'; - // ctx.rect( - // width, - // height, - // -strokeWidth, - // -height * (1 - borderRightVisibility) - // ); - // ctx.fill(); - - // // bottom - // ctx.beginPath(); - // ctx.fillStyle = 'pink'; - // ctx.rect(0, height, width * (1 - borderBottomVisibility), -strokeWidth); - // ctx.fill(); } } @@ -353,6 +259,7 @@ registerPaintWorklet( export const Test = () => { const ref = React.useRef(null); const drawAnimRef = React.useRef(null); + const colorAnimRef = React.useRef(null); const spinAnimRef = React.useRef(null); const [running, setRunning] = React.useState(false); React.useLayoutEffect(() => { @@ -361,52 +268,47 @@ export const Test = () => { } if (running) { - drawAnimRef.current = ref.current.animate( + colorAnimRef.current = ref.current.animate( [ { - '--liveness-border-top-visibility': '0%', - '--liveness-border-right-visibility': '0%', - '--liveness-border-bottom-visibility': '0%', - '--liveness-border-left-visibility': '0%', + '--liveness-color-1': 'transparent', + '--liveness-color-2': 'transparent', + '--liveness-color-3': 'transparent', }, { - '--liveness-border-top-visibility': '100%', - '--liveness-border-right-visibility': '0%', - '--liveness-border-bottom-visibility': '0%', - '--liveness-border-left-visibility': '0%', - }, - { - '--liveness-border-top-visibility': '100%', - '--liveness-border-right-visibility': '100%', - '--liveness-border-bottom-visibility': '0%', - '--liveness-border-left-visibility': '0%', + '--liveness-color-1': tokens.colorPaletteLilacBorderActive, + '--liveness-color-2': tokens.colorBrandStroke1, + '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, }, + ], + { duration: 200, easing: 'linear', fill: 'forwards' } + ); + + colorAnimRef.current.persist(); + + drawAnimRef.current = ref.current.animate( + [ { - '--liveness-border-top-visibility': '100%', - '--liveness-border-right-visibility': '100%', - '--liveness-border-bottom-visibility': '100%', - '--liveness-border-left-visibility': '0%', + '--liveness-progress': '0', }, { - '--liveness-border-top-visibility': '100%', - '--liveness-border-right-visibility': '100%', - '--liveness-border-bottom-visibility': '100%', - '--liveness-border-left-visibility': '100%', + '--liveness-progress': '1', }, ], { duration: 200, easing: 'linear', fill: 'forwards' } ); - // drawAnimRef.current.persist(); + drawAnimRef.current.persist(); const START_ANGLE = (3 * Math.PI) / 4; const startAngle = String(START_ANGLE); const endAngle = String(START_ANGLE + 2 * Math.PI); spinAnimRef.current = ref.current.animate( [{ '--liveness-angle': startAngle }, { '--liveness-angle': endAngle }], - { duration: 2000, delay: 0, easing: 'linear', iterations: Infinity } + { duration: 1000, easing: 'linear', iterations: Infinity } ); } else { + colorAnimRef.current?.cancel(); drawAnimRef.current?.cancel(); spinAnimRef.current?.cancel(); } @@ -432,19 +334,13 @@ export const Test = () => { style={ { background: 'paint(testworklet)', - borderRadius: '4px', - display: 'inline-flex', - justifyContent: 'center', - alignItems: 'center', + borderRadius: '99px', + height: 200, + width: 200, padding: 2, - '--liveness-color-1': tokens.colorPaletteLilacBorderActive, - '--liveness-color-2': tokens.colorBrandStroke1, - '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, } as React.CSSProperties } - > - -
+ /> ); }; From c8d3de6593029ce1f516f87ea6e778e5674ff90d Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Sat, 9 Mar 2024 04:59:01 +0100 Subject: [PATCH 06/13] update --- .../houdini-utils/stories/Utils/Test.stories.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/Test.stories.tsx b/packages/houdini-utils/stories/Utils/Test.stories.tsx index ff74c0bab..51c751c7b 100644 --- a/packages/houdini-utils/stories/Utils/Test.stories.tsx +++ b/packages/houdini-utils/stories/Utils/Test.stories.tsx @@ -199,12 +199,16 @@ class MyPaintWorklet implements PaintWorklet { return (deg * Math.PI) / 180; } - if (progress === 1) { - return; + let startAngle = toRadians(360 * progress); + const endAngle = toRadians(0); + + if (progress === 0) { + startAngle = toRadians(360); } - const startAngle = toRadians(360 * progress); - const endAngle = toRadians(0); + if (progress === 1) { + startAngle = toRadians(0); + } ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); @@ -338,6 +342,9 @@ export const Test = () => { height: 200, width: 200, padding: 2, + '--liveness-color-1': tokens.colorPaletteLilacBorderActive, + '--liveness-color-2': tokens.colorBrandStroke1, + '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, } as React.CSSProperties } /> From 593900110b37006da0ca503444e7b384f2a92848 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Sat, 9 Mar 2024 20:57:13 +0100 Subject: [PATCH 07/13] implement chasing animation --- .../stories/Utils/Test.stories.tsx | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/Test.stories.tsx b/packages/houdini-utils/stories/Utils/Test.stories.tsx index 51c751c7b..cc25daa29 100644 --- a/packages/houdini-utils/stories/Utils/Test.stories.tsx +++ b/packages/houdini-utils/stories/Utils/Test.stories.tsx @@ -72,7 +72,24 @@ class MyPaintWorklet implements PaintWorklet { ]; } - private parseProps(props: Map) { + /** + * Canvas drawing context only handles numbers, so we need to parse percentage values + * The percentage handling is explicitly wrong since it doesn't take into account both dimensions. + * + * However 50% is generally used for circles so we should handle that to some degree + * @param value border radius in pixel value + * @returns border radius in pixel value + */ + private parseBorderRadiusValue(value: string, size: number) { + const parsed = parseFloat(value); + if (value.includes('%')) { + return (parsed / 100) * size; + } + + return parsed; + } + + private parseProps(props: Map, geom: PaintWorkletGeometry) { const angle = parseFloat(String(props.get('--liveness-angle'))); const strokeWidth = parseFloat( String(props.get('--liveness-stroke-width')) @@ -87,17 +104,21 @@ class MyPaintWorklet implements PaintWorklet { String(props.get(`--liveness-color-2`)), String(props.get(`--liveness-color-3`)), ], - borderTopLeftRadius: parseFloat( - String(props.get('border-top-left-radius')) + borderTopLeftRadius: this.parseBorderRadiusValue( + String(props.get('border-top-left-radius')), + geom.width ), - borderTopRightRadius: parseFloat( - String(props.get('border-top-right-radius')) + borderTopRightRadius: this.parseBorderRadiusValue( + String(props.get('border-top-right-radius')), + geom.width ), - borderBottomLeftRadius: parseFloat( - String(props.get('border-bottom-left-radius')) + borderBottomLeftRadius: this.parseBorderRadiusValue( + String(props.get('border-bottom-left-radius')), + geom.width ), - borderBottomRightRadius: parseFloat( - String(props.get('border-bottom-right-radius')) + borderBottomRightRadius: this.parseBorderRadiusValue( + String(props.get('border-bottom-right-radius')), + geom.width ), }; } @@ -199,15 +220,20 @@ class MyPaintWorklet implements PaintWorklet { return (deg * Math.PI) / 180; } - let startAngle = toRadians(360 * progress); - const endAngle = toRadians(0); + // move both the start and end angles as progress increases to create + // the effect that the border is moving around like a snake + const rotation = 90 * progress; + let startAngle = toRadians(360 * progress + rotation); + let endAngle = toRadians(rotation); if (progress === 0) { startAngle = toRadians(360); + endAngle = toRadians(0); } if (progress === 1) { startAngle = toRadians(0); + endAngle = toRadians(0); } ctx.globalCompositeOperation = 'destination-out'; @@ -236,7 +262,7 @@ class MyPaintWorklet implements PaintWorklet { borderTopLeftRadius, borderTopRightRadius, progress, - } = this.parseProps(props); + } = this.parseProps(props, size); const { width, height } = size; this.renderGradientRect(ctx, { colors, angle, width, height }); @@ -299,7 +325,7 @@ export const Test = () => { '--liveness-progress': '1', }, ], - { duration: 200, easing: 'linear', fill: 'forwards' } + { duration: 500, easing: 'linear', fill: 'forwards' } ); drawAnimRef.current.persist(); @@ -338,13 +364,10 @@ export const Test = () => { style={ { background: 'paint(testworklet)', - borderRadius: '99px', + borderRadius: '20px', height: 200, width: 200, padding: 2, - '--liveness-color-1': tokens.colorPaletteLilacBorderActive, - '--liveness-color-2': tokens.colorBrandStroke1, - '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, } as React.CSSProperties } /> From 604f3b8c4d384e21cf32f988712fb8e710b9248a Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Mon, 11 Mar 2024 08:24:23 +0100 Subject: [PATCH 08/13] remove unnecessary animation --- .../stories/Utils/Test.stories.tsx | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/Test.stories.tsx b/packages/houdini-utils/stories/Utils/Test.stories.tsx index cc25daa29..131fb550c 100644 --- a/packages/houdini-utils/stories/Utils/Test.stories.tsx +++ b/packages/houdini-utils/stories/Utils/Test.stories.tsx @@ -298,24 +298,6 @@ export const Test = () => { } if (running) { - colorAnimRef.current = ref.current.animate( - [ - { - '--liveness-color-1': 'transparent', - '--liveness-color-2': 'transparent', - '--liveness-color-3': 'transparent', - }, - { - '--liveness-color-1': tokens.colorPaletteLilacBorderActive, - '--liveness-color-2': tokens.colorBrandStroke1, - '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, - }, - ], - { duration: 200, easing: 'linear', fill: 'forwards' } - ); - - colorAnimRef.current.persist(); - drawAnimRef.current = ref.current.animate( [ { @@ -368,6 +350,9 @@ export const Test = () => { height: 200, width: 200, padding: 2, + '--liveness-color-1': tokens.colorPaletteLilacBorderActive, + '--liveness-color-2': tokens.colorBrandStroke1, + '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, } as React.CSSProperties } /> From 1945593f9af989f88ae2824265a88b6bbe6406af Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Mon, 11 Mar 2024 08:34:43 +0100 Subject: [PATCH 09/13] add polyfile --- .../stories/Utils/Test.stories.tsx | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/Test.stories.tsx b/packages/houdini-utils/stories/Utils/Test.stories.tsx index 131fb550c..9993fc6b4 100644 --- a/packages/houdini-utils/stories/Utils/Test.stories.tsx +++ b/packages/houdini-utils/stories/Utils/Test.stories.tsx @@ -72,6 +72,45 @@ class MyPaintWorklet implements PaintWorklet { ]; } + /** + * roundRect does not meet the browser support matrix of Fluent UI + * @link https://react.fluentui.dev/?path=/docs/concepts-developer-browser-support-matrix--page#full-browser-support-matrix + */ + private roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radii: number | number[] = 0 + ) { + if (ctx.roundRect) { + ctx.roundRect(x, y, w, h, radii); + } else { + let rad: number[] = [0, 0, 0, 0]; + if (Array.isArray(radii)) { + if (radii.length === 4) { + rad = radii; + } else if (radii.length === 1) { + rad = new Array(4).fill(radii[0]); + } else if (radii.length === 2) { + rad = [radii[0], radii[1], radii[0], radii[1]]; + } else if (radii.length === 3) { + rad = [radii[0], radii[1], radii[2], radii[1]]; + } + } else if (typeof radii === 'number') { + rad = new Array(4).fill(radii); + } + + ctx.moveTo(x + rad[0], y); + ctx.arcTo(x + w, y, x + w, y + h, rad[1]); + ctx.arcTo(x + w, y + h, x, y + h, rad[2]); + ctx.arcTo(x, y + h, x, y, rad[3]); + ctx.arcTo(x, y, x + w, y, rad[0]); + ctx.closePath(); + } + } + /** * Canvas drawing context only handles numbers, so we need to parse percentage values * The percentage handling is explicitly wrong since it doesn't take into account both dimensions. @@ -194,7 +233,8 @@ class MyPaintWorklet implements PaintWorklet { // mask rectangle // clips the gradient to have a border - ctx.roundRect( + this.roundRect( + ctx, strokeWidth, strokeWidth, width - strokeWidth * 2, @@ -289,7 +329,6 @@ registerPaintWorklet( export const Test = () => { const ref = React.useRef(null); const drawAnimRef = React.useRef(null); - const colorAnimRef = React.useRef(null); const spinAnimRef = React.useRef(null); const [running, setRunning] = React.useState(false); React.useLayoutEffect(() => { @@ -320,7 +359,6 @@ export const Test = () => { { duration: 1000, easing: 'linear', iterations: Infinity } ); } else { - colorAnimRef.current?.cancel(); drawAnimRef.current?.cancel(); spinAnimRef.current?.cancel(); } From 72f36c9adab187e87b4a49dc23e7824d281d2996 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Mon, 11 Mar 2024 09:58:31 +0100 Subject: [PATCH 10/13] fallback --- .../stories/Utils/TestFallback.stories.tsx | 411 ++++++++++++++++++ .../stories/Utils/index.stories.tsx | 1 + 2 files changed, 412 insertions(+) create mode 100644 packages/houdini-utils/stories/Utils/TestFallback.stories.tsx diff --git a/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx new file mode 100644 index 000000000..715e9cb50 --- /dev/null +++ b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx @@ -0,0 +1,411 @@ +import * as React from 'react'; +import { + PaintWorklet, + PaintWorkletGeometry, + hasHoudini, + hasMozElement, + hasWebkitCanvas, + fallbackPaintAnimation, +} from '@fluentui-contrib/houdini-utils'; +import { Switch, tokens } from '@fluentui/react-components'; + +class MyPaintWorklet implements PaintWorklet { + public static get inputProperties() { + return [ + '--liveness-angle', + '--liveness-color-1', + '--liveness-color-2', + '--liveness-color-3', + '--liveness-stroke-width', + '--liveness-progress', + 'border-top-left-radius', + 'border-top-right-radius', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + ]; + } + + /** + * roundRect does not meet the browser support matrix of Fluent UI + * @link https://react.fluentui.dev/?path=/docs/concepts-developer-browser-support-matrix--page#full-browser-support-matrix + */ + private roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radii: number | number[] = 0 + ) { + if (ctx.roundRect) { + console.log(x, y, w, h); + ctx.roundRect(x, y, w, h, radii); + } else { + let rad: number[] = [0, 0, 0, 0]; + if (Array.isArray(radii)) { + if (radii.length === 4) { + rad = radii; + } else if (radii.length === 1) { + rad = new Array(4).fill(radii[0]); + } else if (radii.length === 2) { + rad = [radii[0], radii[1], radii[0], radii[1]]; + } else if (radii.length === 3) { + rad = [radii[0], radii[1], radii[2], radii[1]]; + } + } else if (typeof radii === 'number') { + rad = new Array(4).fill(radii); + } + + ctx.moveTo(x + rad[0], y); + ctx.arcTo(x + w, y, x + w, y + h, rad[1]); + ctx.arcTo(x + w, y + h, x, y + h, rad[2]); + ctx.arcTo(x, y + h, x, y, rad[3]); + ctx.arcTo(x, y, x + w, y, rad[0]); + ctx.closePath(); + } + } + + /** + * Canvas drawing context only handles numbers, so we need to parse percentage values + * The percentage handling is explicitly wrong since it doesn't take into account both dimensions. + * + * However 50% is generally used for circles so we should handle that to some degree + * @param value border radius in pixel value + * @returns border radius in pixel value + */ + private parseBorderRadiusValue(value: string, size: number) { + const parsed = parseFloat(value); + if (value.includes('%')) { + return (parsed / 100) * size; + } + + return parsed; + } + + private parseProps(props: Map, geom: PaintWorkletGeometry) { + const angle = parseFloat(String(props.get('--liveness-angle'))); + const strokeWidth = parseFloat( + String(props.get('--liveness-stroke-width')) + ); + + return { + angle, + strokeWidth, + progress: parseFloat(String(props.get('--liveness-progress'))), + colors: [ + String(props.get(`--liveness-color-1`)), + String(props.get(`--liveness-color-2`)), + String(props.get(`--liveness-color-3`)), + ], + borderTopLeftRadius: this.parseBorderRadiusValue( + String(props.get('border-top-left-radius')), + geom.width + ), + borderTopRightRadius: this.parseBorderRadiusValue( + String(props.get('border-top-right-radius')), + geom.width + ), + borderBottomLeftRadius: this.parseBorderRadiusValue( + String(props.get('border-bottom-left-radius')), + geom.width + ), + borderBottomRightRadius: this.parseBorderRadiusValue( + String(props.get('border-bottom-right-radius')), + geom.width + ), + }; + } + + /** + * Renders the main gradient rectangle which will spin + */ + private renderGradientRect( + ctx: CanvasRenderingContext2D, + options: { colors: string[]; angle: number; width: number; height: number } + ) { + ctx.globalCompositeOperation = 'source-over'; + const { angle, width, height, colors } = options; + const midX = width / 2; + const midY = height / 2; + const length = Math.sqrt(midX * midX + midY * midY); + + const lenX = Math.cos(angle) * length; + const lenY = Math.sin(angle) * length; + + const x1 = midX - lenX; + const y1 = midY - lenY; + const x2 = midX + lenX; + const y2 = midY + lenY; + + const gradient = ctx.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, colors[0]); + gradient.addColorStop(0.5, colors[1]); + gradient.addColorStop(1, colors[2]); + + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = gradient; + // main rectangle with gradient + ctx.fillRect(0, 0, width, height); + } + + /** + * Renders a clipping rect inside the gradient rect to get a border effect + */ + private renderClippingBorderRect( + ctx: CanvasRenderingContext2D, + options: { + borderTopLeftRadius: number; + borderTopRightRadius: number; + borderBottomLeftRadius: number; + borderBottomRightRadius: number; + strokeWidth: number; + width: number; + height: number; + } + ) { + const { + strokeWidth, + width, + height, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopLeftRadius, + borderTopRightRadius, + } = options; + const radii = [ + Math.max(borderTopLeftRadius - strokeWidth, 0), + Math.max(borderTopRightRadius - strokeWidth, 0), + Math.max(borderBottomRightRadius - strokeWidth, 0), + Math.max(borderBottomLeftRadius - strokeWidth, 0), + ]; + + ctx.globalCompositeOperation = 'destination-out'; + // This should never render because of the composition mode. + // Using an obviously wrong color so if it _does_ render + // we'll catch it early. + ctx.fillStyle = 'yellow'; + ctx.beginPath(); + + // mask rectangle + // clips the gradient to have a border + this.roundRect( + ctx, + strokeWidth, + strokeWidth, + width - strokeWidth * 2, + height - strokeWidth * 2, + radii + ); + + ctx.fill(); + } + + /** + * Renders a cone that will clip the border. + * When there no progress the cone is in fact a circle and hides the entire border + * As the progress increases the cone becomes smaller and smaller which gradually reveals the border + */ + private renderClippingCone( + ctx: CanvasRenderingContext2D, + options: { width: number; height: number; progress: number } + ) { + const { width, height, progress } = options; + + function toRadians(deg: number) { + return (deg * Math.PI) / 180; + } + + // move both the start and end angles as progress increases to create + // the effect that the border is moving around like a snake + const rotation = 90 * progress; + let startAngle = toRadians(360 * progress + rotation); + let endAngle = toRadians(rotation); + + if (progress === 0) { + startAngle = toRadians(360); + endAngle = toRadians(0); + } + + if (progress === 1) { + startAngle = toRadians(0); + endAngle = toRadians(0); + } + + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + const midX = width / 2; + const midY = height / 2; + ctx.moveTo(midX, midY); + ctx.arc(midX, midY, 400, startAngle, endAngle); + ctx.lineTo(midX, midY); + ctx.closePath(); + ctx.fillStyle = 'yellow'; + ctx.fill(); + } + + paint( + ctx: CanvasRenderingContext2D, + size: PaintWorkletGeometry, + props: Map + ) { + const { + angle, + strokeWidth, + colors, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopLeftRadius, + borderTopRightRadius, + progress, + } = this.parseProps(props, size); + const { width, height } = size; + + this.renderGradientRect(ctx, { colors, angle, width, height }); + + this.renderClippingCone(ctx, { width, height, progress }); + + this.renderClippingBorderRect(ctx, { + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopLeftRadius, + borderTopRightRadius, + strokeWidth, + width, + height, + }); + } +} + +export const fallback = (target: HTMLElement) => { + const START_ANGLE = (3 * Math.PI) / 4; + const startAngle = String(START_ANGLE); + const endAngle = String(START_ANGLE + 2 * Math.PI); + return fallbackPaintAnimation(target, new MyPaintWorklet(), { + duration: '1000ms, 1000ms', + timingFunction: 'linear', + target, + onComplete: () => null, + onUpdate: () => null, + delay: '0', + animations: [ + { + '0%': { + '--liveness-progress': '0', + }, + '100%': { + '--liveness-progress': '1', + }, + }, + { + '0%': { + '--liveness-angle': startAngle, + }, + '100%': { + '--liveness-angle': endAngle, + }, + }, + ], + isStopped: () => false, + }); +}; + +const getBackgroundImage = (id: string) => { + if (hasMozElement()) { + return `-moz-element(#${id})`; + } else if (hasWebkitCanvas()) { + return `-webkit-canvas(${id})`; + } + + return undefined; +}; + +const useFallbackAnimation = () => { + const stateRef = React.useRef<'rest' | 'play'>('rest'); + const playRef = React.useRef<() => void>(() => null); + const cleanupRef = React.useRef<() => void>(() => null); + const targetRef = React.useCallback((node: HTMLElement | null) => { + if (!node) { + cleanupRef.current(); + return; + } + + const { id, play, canvas, cleanup } = fallback(node); + cleanupRef.current = cleanup; + const backgroundImage = getBackgroundImage(id); + + const onComplete = () => { + stateRef.current = 'rest'; + }; + + const isStopped = () => stateRef.current === 'rest'; + + let onUpdate: () => void = () => null; + + if (backgroundImage) { + node.style.backgroundImage = backgroundImage; + } else { + onUpdate = () => { + if (canvas) { + const backgroundImage = `url(${canvas.toDataURL('image/png')})`; + node.style.backgroundImage = backgroundImage; + } + }; + } + + playRef.current = () => { + if (stateRef.current === 'rest') { + stateRef.current = 'play'; + play(onComplete, isStopped, onUpdate); + } + }; + }, []); + + return { + targetRef, + play: () => playRef.current(), + stop: () => (stateRef.current = 'rest'), + }; +}; + +export const TestFallback = () => { + const [running, setRunning] = React.useState(false); + const { targetRef, play } = useFallbackAnimation(); + React.useLayoutEffect(() => { + if (running) { + play(); + } + }, [running]); + + if (!hasHoudini()) { + return ( +
+ ⚠️ This browser does not support houdini, please take a look at the + fallback example. +
+ ); + } + return ( + <> + setRunning(data.checked)} + checked={running} + label="Toggle animation" + /> +
+ + ); +}; diff --git a/packages/houdini-utils/stories/Utils/index.stories.tsx b/packages/houdini-utils/stories/Utils/index.stories.tsx index a3c35a520..9387f39f1 100644 --- a/packages/houdini-utils/stories/Utils/index.stories.tsx +++ b/packages/houdini-utils/stories/Utils/index.stories.tsx @@ -4,6 +4,7 @@ import { Meta } from '@storybook/react'; export { Default } from './Default.stories'; export { Fallback } from './Fallback.stories'; export { Test } from './Test.stories'; +export { TestFallback } from './TestFallback.stories'; const meta: Meta = { title: 'Houdini Utils', From 41f1aca87379c3f90b5cf643197945025ec5e255 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Mon, 11 Mar 2024 14:02:22 +0100 Subject: [PATCH 11/13] fix fallback in firefox --- .../houdini-utils/stories/Utils/TestFallback.stories.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx index d9402a622..a592ba0d8 100644 --- a/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx +++ b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx @@ -382,14 +382,6 @@ export const TestFallback = () => { } }, [running]); - if (!hasHoudini()) { - return ( -
- ⚠️ This browser does not support houdini, please take a look at the - fallback example. -
- ); - } return ( <> { '--liveness-color-1': tokens.colorPaletteLilacBorderActive, '--liveness-color-2': tokens.colorBrandStroke1, '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, + '--liveness-stroke-width': '2px', } as React.CSSProperties } /> From 74620b693bba1820b118ec55e4dbde082a133ce6 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Mon, 11 Mar 2024 15:06:58 +0100 Subject: [PATCH 12/13] merge main --- .../stories/Utils/TestFallback.stories.tsx | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx index a592ba0d8..7f11aff65 100644 --- a/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx +++ b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx @@ -2,9 +2,6 @@ import * as React from 'react'; import { PaintWorklet, PaintWorkletGeometry, - hasHoudini, - hasMozElement, - hasWebkitCanvas, fallbackPaintAnimation, } from '@fluentui-contrib/houdini-utils'; import { Switch, tokens } from '@fluentui/react-components'; @@ -282,9 +279,6 @@ export const fallback = (target: HTMLElement) => { return fallbackPaintAnimation(target, new MyPaintWorklet(), { duration: '500ms, 1000ms', timingFunction: 'linear', - target, - onComplete: () => null, - onUpdate: () => null, iterationCount: [1, Infinity], delay: '0', animations: [ @@ -308,16 +302,6 @@ export const fallback = (target: HTMLElement) => { }); }; -const getBackgroundImage = (id: string) => { - if (hasMozElement()) { - return `-moz-element(#${id})`; - } else if (hasWebkitCanvas()) { - return `-webkit-canvas(${id})`; - } - - return undefined; -}; - const useFallbackAnimation = () => { const stateRef = React.useRef<'rest' | 'play'>('rest'); const playRef = React.useRef<() => void>(() => null); @@ -329,32 +313,18 @@ const useFallbackAnimation = () => { return; } - const { id, play, stop, canvas, cleanup } = fallback(node); + const { play, stop, cleanup } = fallback(node); stopRef.current = stop; cleanupRef.current = cleanup; - const backgroundImage = getBackgroundImage(id); const onComplete = () => { stateRef.current = 'rest'; }; - let onUpdate: () => void = () => null; - - if (backgroundImage) { - node.style.backgroundImage = backgroundImage; - } else { - onUpdate = () => { - if (canvas) { - const backgroundImage = `url(${canvas.toDataURL('image/png')})`; - node.style.backgroundImage = backgroundImage; - } - }; - } - playRef.current = () => { if (stateRef.current === 'rest') { stateRef.current = 'play'; - play(onComplete, onUpdate); + play(onComplete); } }; }, []); From daadbf0762ae568c11ad1d067fbb04657baa0fa2 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 14 Mar 2024 11:21:02 +0100 Subject: [PATCH 13/13] render over existing border --- .../stories/Utils/Test.stories.tsx | 59 ++++++++++++------- .../stories/Utils/TestFallback.stories.tsx | 49 +++++++++------ 2 files changed, 70 insertions(+), 38 deletions(-) diff --git a/packages/houdini-utils/stories/Utils/Test.stories.tsx b/packages/houdini-utils/stories/Utils/Test.stories.tsx index 9993fc6b4..61ecc100c 100644 --- a/packages/houdini-utils/stories/Utils/Test.stories.tsx +++ b/packages/houdini-utils/stories/Utils/Test.stories.tsx @@ -6,7 +6,13 @@ import { PaintWorkletGeometry, hasHoudini, } from '@fluentui-contrib/houdini-utils'; -import { Switch, tokens } from '@fluentui/react-components'; +import { + Switch, + tokens, + Button, + makeStyles, + shorthands, +} from '@fluentui/react-components'; try { CSS.registerProperty({ @@ -326,18 +332,41 @@ registerPaintWorklet( '' ).then(() => console.log('registered')); +const useStyles = makeStyles({ + liveness: { + position: 'relative', + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + ...shorthands.borderRadius('4px'), + '--liveness-color-1': tokens.colorPaletteLilacBorderActive, + '--liveness-color-2': tokens.colorBrandStroke1, + '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, + ':after': { + ...shorthands.borderRadius('inherit'), + position: 'absolute', + content: "''", + width: '100%', + height: '100%', + backgroundImage: 'paint(testworklet)', + }, + }, +}); + export const Test = () => { const ref = React.useRef(null); - const drawAnimRef = React.useRef(null); + const fadeRef = React.useRef(null); const spinAnimRef = React.useRef(null); const [running, setRunning] = React.useState(false); + const styles = useStyles(); React.useLayoutEffect(() => { if (!ref.current) { return; } if (running) { - drawAnimRef.current = ref.current.animate( + const inDuration = 500; + fadeRef.current = ref.current.animate( [ { '--liveness-progress': '0', @@ -346,10 +375,10 @@ export const Test = () => { '--liveness-progress': '1', }, ], - { duration: 500, easing: 'linear', fill: 'forwards' } + { duration: inDuration, easing: 'linear', fill: 'forwards' } ); - drawAnimRef.current.persist(); + fadeRef.current.persist(); const START_ANGLE = (3 * Math.PI) / 4; const startAngle = String(START_ANGLE); @@ -359,8 +388,8 @@ export const Test = () => { { duration: 1000, easing: 'linear', iterations: Infinity } ); } else { - drawAnimRef.current?.cancel(); spinAnimRef.current?.cancel(); + fadeRef.current?.cancel(); } }, [running]); @@ -379,21 +408,9 @@ export const Test = () => { checked={running} label="Toggle animation" /> -
+
+ +
); }; diff --git a/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx index 7f11aff65..7b217876a 100644 --- a/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx +++ b/packages/houdini-utils/stories/Utils/TestFallback.stories.tsx @@ -4,7 +4,13 @@ import { PaintWorkletGeometry, fallbackPaintAnimation, } from '@fluentui-contrib/houdini-utils'; -import { Switch, tokens } from '@fluentui/react-components'; +import { + Switch, + makeStyles, + tokens, + shorthands, + Button, +} from '@fluentui/react-components'; class MyPaintWorklet implements PaintWorklet { public static get inputProperties() { @@ -340,8 +346,30 @@ const useFallbackAnimation = () => { }, }; }; +const useStyles = makeStyles({ + liveness: { + position: 'relative', + display: 'inline-flex', + justifyContent: 'center', + ...shorthands.borderRadius('4px'), + alignItems: 'center', + '--liveness-color-1': tokens.colorPaletteLilacBorderActive, + '--liveness-color-2': tokens.colorBrandStroke1, + '--liveness-color-3': tokens.colorPaletteLightTealBorderActive, + '--liveness-stroke-width': '2px', + ':after': { + ...shorthands.borderRadius('inherit'), + position: 'absolute', + content: "''", + width: '100%', + height: '100%', + backgroundImage: 'inherit', + }, + }, +}); export const TestFallback = () => { + const styles = useStyles(); const [running, setRunning] = React.useState(false); const { targetRef, play, stop } = useFallbackAnimation(); React.useLayoutEffect(() => { @@ -359,22 +387,9 @@ export const TestFallback = () => { checked={running} label="Toggle animation" /> -
+
+ +
); };