From 48f229883f9f2f68c6bcdc7c8fe67967e57a2370 Mon Sep 17 00:00:00 2001 From: Inna Abdrakhmanova Date: Mon, 13 Jan 2025 20:49:05 +0100 Subject: [PATCH 1/5] Add GlowHoverContainer component --- .../components/containers/base-container.tsx | 24 +- .../use-glow-hover/glow-hover-effect.ts | 274 ++++++++++++++++++ ui/src/components/use-glow-hover/index.ts | 4 + .../use-glow-hover/linear-animation.ts | 37 +++ .../use-glow-hover/use-glow-hover.ts | 20 ++ ui/src/index.ts | 1 + 6 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/use-glow-hover/glow-hover-effect.ts create mode 100644 ui/src/components/use-glow-hover/index.ts create mode 100644 ui/src/components/use-glow-hover/linear-animation.ts create mode 100644 ui/src/components/use-glow-hover/use-glow-hover.ts diff --git a/ui/src/components/containers/base-container.tsx b/ui/src/components/containers/base-container.tsx index ba4f1b77d..24058e47e 100644 --- a/ui/src/components/containers/base-container.tsx +++ b/ui/src/components/containers/base-container.tsx @@ -1,17 +1,21 @@ +'use client'; + import React from 'react'; import { twMerge } from 'tailwind-merge'; import { BackgroundColor } from '../../interfaces/color'; +import { useGlowHover } from '../use-glow-hover'; type BaseContainerProps = { backgroundColor?: BackgroundColor; + wrapperRef?: React.Ref; } & React.HTMLAttributes; export const BaseContainer = React.forwardRef( - ({ children, className, backgroundColor, ...props }, ref) => { + ({ children, className, backgroundColor, wrapperClassName, wrapperRef, ...props }, ref) => { return ( -
+
-
+
{children}
@@ -19,3 +23,17 @@ export const BaseContainer = React.forwardRef>( + ({ className, ...props }, ref) => { + const refCard = useGlowHover({ lightColor: '#CEFF00' }); + + return ( + } + /> + ); + }, +); diff --git a/ui/src/components/use-glow-hover/glow-hover-effect.ts b/ui/src/components/use-glow-hover/glow-hover-effect.ts new file mode 100644 index 000000000..f8d971a91 --- /dev/null +++ b/ui/src/components/use-glow-hover/glow-hover-effect.ts @@ -0,0 +1,274 @@ +'use client'; + +import { linearAnimation } from './linear-animation'; +/*import presets, { GlowHoverPreset } from './presets';*/ + +/*import { + forceLightTheme, + forceDarkTheme, + resetForcedTheme +} from './theme-utils';*/ + +export type GlowHoverOptions = { + hoverBg?: string; + lightSize?: number; + lightSizeEnterAnimationTime?: number; + lightSizeLeaveAnimationTime?: number; + isElementMovable?: boolean; + customStaticBg?: string; + /* /!** + * Force theme on hover. Use with caution. + *!/ + forceTheme?: 'light' | 'dark' | false;*/ + enableBurst?: boolean; +} & ( + | { + /*preset: keyof typeof presets;*/ + lightColor?: string; + } + | { + /*preset?: never;*/ + lightColor: string; + } +); + +type Coords = { + x: number; + y: number; +}; + +const BURST_TIME = 300; + +export function parseColor(colorToParse: string) { + const div = document.createElement('div'); + div.style.color = colorToParse; + div.style.position = 'absolute'; + div.style.display = 'none'; + document.body.appendChild(div); + const colorFromEl = getComputedStyle(div).color; + document.body.removeChild(div); + const parsedColor = colorFromEl.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i); + if (parsedColor) { + const alpha = typeof parsedColor[4] === 'undefined' ? 1 : parsedColor[4]; + return [parsedColor[1], parsedColor[2], parsedColor[3], alpha]; + } else { + console.error(`Color ${colorToParse} could not be parsed.`); + return [0, 0, 0, 0]; + } +} + +// eslint-disable-next-line complexity +export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHoverOptions) => { + if (!el) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + } + /*let presetCfg: GlowHoverPreset = {}; + if (preset) { + const maybePreset = presets[preset] as GlowHoverPreset; + if (!maybePreset) { + throw new Error(`Unknown preset ${preset}`); + } + presetCfg = maybePreset; + }*/ + + const lightColor = options.lightColor ?? '#CEFF00'; + const lightSize = options.lightSize ?? 130; + const lightSizeEnterAnimationTime = options.lightSizeEnterAnimationTime ?? 100; + const lightSizeLeaveAnimationTime = options.lightSizeLeaveAnimationTime ?? 50; + const isElementMovable = options.isElementMovable ?? false; + const customStaticBg = options.customStaticBg ?? null; + + /*let forceTheme = options.forceTheme ?? null;*/ + + const enableBurst = options.enableBurst ?? false; + + const getResolvedHoverBg = () => getComputedStyle(el).backgroundColor; + + let resolvedHoverBg = getResolvedHoverBg(); + + // default bg (if not defined) is rgba(0, 0, 0, 0) which is bugged in gradients in Safari + // so we use transparent lightColor instead + const parsedLightColor = parseColor(lightColor); + const parsedLightColorRGBString = parsedLightColor.slice(0, 3).join(','); + const resolvedGradientBg = `rgba(${parsedLightColorRGBString},0)`; + + let isMouseInside = false; + let currentLightSize = 0; + let blownSize = 0; + let lightSizeEnterAnimationId: number = null; + let lightSizeLeaveAnimationId: number = null; + let blownSizeIncreaseAnimationId: number = null; + let blownSizeDecreaseAnimationId: number = null; + let lastMousePos: Coords; + const defaultBox = el.getBoundingClientRect(); + let lastElPos: Coords = { x: defaultBox.left, y: defaultBox.top }; + + const updateGlowEffect = () => { + if (!lastMousePos) { + return; + } + const gradientXPos = lastMousePos.x - lastElPos.x; + const gradientYPos = lastMousePos.y - lastElPos.y; + // we do not use transparent color here because of dirty gradient in Safari (more info: https://stackoverflow.com/questions/38391457/linear-gradient-to-transparent-bug-in-latest-safari) + const gradient = `radial-gradient(circle at ${gradientXPos}px ${gradientYPos}px, ${lightColor} 0%, ${resolvedGradientBg} calc(${ + blownSize * 2.5 + }% + ${currentLightSize}px)) no-repeat`; + + // we duplicate resolvedHoverBg layer here because of transition "blinking" without it + el.style.background = `${gradient} border-box border-box ${resolvedHoverBg}`; + }; + + const updateEffectWithPosition = () => { + if (isMouseInside) { + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + updateGlowEffect(); + } + }; + + const onMouseEnter = (e: MouseEvent) => { + resolvedHoverBg = getResolvedHoverBg(); + lastMousePos = { x: e.clientX, y: e.clientY }; + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + isMouseInside = true; + window.cancelAnimationFrame(lightSizeEnterAnimationId); + window.cancelAnimationFrame(lightSizeLeaveAnimationId); + + /*if (forceTheme === 'light') { + forceLightTheme(el); + } else if (forceTheme === 'dark') { + forceDarkTheme(el); + }*/ + + // animate currentLightSize from 0 to lightSize + linearAnimation({ + onProgress: (progress) => { + currentLightSize = lightSize * progress; + updateGlowEffect(); + }, + time: lightSizeEnterAnimationTime, + initialProgress: currentLightSize / lightSize, + onIdUpdate: (newId) => (lightSizeEnterAnimationId = newId), + }); + }; + + const onMouseMove = (e: MouseEvent) => { + lastMousePos = { x: e.clientX, y: e.clientY }; + if (isElementMovable) { + updateEffectWithPosition(); + } else { + updateGlowEffect(); + } + }; + + const onMouseLeave = () => { + isMouseInside = false; + window.cancelAnimationFrame(lightSizeEnterAnimationId); + window.cancelAnimationFrame(lightSizeLeaveAnimationId); + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + // animate currentLightSize from lightSize to 0 + linearAnimation({ + onProgress: (progress) => { + currentLightSize = lightSize * (1 - progress); + blownSize = Math.min(blownSize, (1 - progress) * 100); + + if (progress < 1) { + updateGlowEffect(); + } else { + el.style.background = customStaticBg ? customStaticBg : ''; + } + + /*if (forceTheme && progress === 1) { + resetForcedTheme(el); + }*/ + }, + time: lightSizeLeaveAnimationTime, + initialProgress: 1 - currentLightSize / lightSize, + onIdUpdate: (newId) => (lightSizeLeaveAnimationId = newId), + }); + }; + + const onMouseDown = (e: MouseEvent) => { + lastMousePos = { x: e.clientX, y: e.clientY }; + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + // animate blownSize from 0 to 100 + linearAnimation({ + onProgress: (progress) => { + blownSize = 100 * progress; + updateGlowEffect(); + }, + time: BURST_TIME, + initialProgress: blownSize / 100, + onIdUpdate: (newId) => (blownSizeIncreaseAnimationId = newId), + }); + }; + + const onMouseUp = (e: MouseEvent) => { + lastMousePos = { x: e.clientX, y: e.clientY }; + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + // animate blownSize from 100 to 0 + linearAnimation({ + onProgress: (progress) => { + blownSize = (1 - progress) * 100; + updateGlowEffect(); + }, + time: BURST_TIME, + initialProgress: 1 - blownSize / 100, + onIdUpdate: (newId) => (blownSizeDecreaseAnimationId = newId), + }); + }; + + document.addEventListener('scroll', updateEffectWithPosition); + window.addEventListener('resize', updateEffectWithPosition); + el.addEventListener('mouseenter', onMouseEnter); + el.addEventListener('mousemove', onMouseMove); + el.addEventListener('mouseleave', onMouseLeave); + if (enableBurst) { + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('mouseup', onMouseUp); + } + + let resizeObserver: ResizeObserver; + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(updateEffectWithPosition); + resizeObserver.observe(el); + } + + return () => { + window.cancelAnimationFrame(lightSizeEnterAnimationId); + window.cancelAnimationFrame(lightSizeLeaveAnimationId); + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + document.removeEventListener('scroll', updateEffectWithPosition); + window.removeEventListener('resize', updateEffectWithPosition); + el.removeEventListener('mouseenter', onMouseEnter); + el.removeEventListener('mousemove', onMouseMove); + el.removeEventListener('mouseleave', onMouseLeave); + if (enableBurst) { + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('mouseup', onMouseUp); + } + + /*if (forceTheme) { + resetForcedTheme(el); + }*/ + + if (resizeObserver) { + resizeObserver.unobserve(el); + resizeObserver.disconnect(); + } + }; +}; diff --git a/ui/src/components/use-glow-hover/index.ts b/ui/src/components/use-glow-hover/index.ts new file mode 100644 index 000000000..7818a6ed8 --- /dev/null +++ b/ui/src/components/use-glow-hover/index.ts @@ -0,0 +1,4 @@ +export { glowHoverEffect } from './glow-hover-effect'; +export type { GlowHoverOptions } from './glow-hover-effect'; +export { useGlowHover } from './use-glow-hover'; +export type { GlowHoverHookOptions } from './use-glow-hover'; diff --git a/ui/src/components/use-glow-hover/linear-animation.ts b/ui/src/components/use-glow-hover/linear-animation.ts new file mode 100644 index 000000000..b8555009f --- /dev/null +++ b/ui/src/components/use-glow-hover/linear-animation.ts @@ -0,0 +1,37 @@ +'use client'; + +interface LinearAnimationParams { + onProgress: (progress: number) => void; + onIdUpdate?: (id: number) => void; + time: number; + initialProgress?: number; +} + +export const linearAnimation = ({ + onProgress, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onIdUpdate = () => {}, + time, + initialProgress = 0, +}: LinearAnimationParams) => { + if (time === 0) { + onProgress(1); + onIdUpdate(null); + return; + } + + let start: number = null; + const step = (timestamp: number) => { + if (!start) start = timestamp; + const progress = Math.min((timestamp - start) / time + initialProgress, 1); + + onProgress(progress); + + if (progress < 1) { + onIdUpdate(window.requestAnimationFrame(step)); + } else { + onIdUpdate(null); + } + }; + onIdUpdate(window.requestAnimationFrame(step)); +}; diff --git a/ui/src/components/use-glow-hover/use-glow-hover.ts b/ui/src/components/use-glow-hover/use-glow-hover.ts new file mode 100644 index 000000000..2fa16d585 --- /dev/null +++ b/ui/src/components/use-glow-hover/use-glow-hover.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import { glowHoverEffect, GlowHoverOptions } from './glow-hover-effect'; + +export type GlowHoverHookOptions = GlowHoverOptions & { disabled?: boolean }; +export const useGlowHover = ({ disabled = false, ...options }: GlowHoverHookOptions) => { + const ref = useRef(null); + + useEffect( + () => + !disabled && ref.current + ? glowHoverEffect(ref.current, options) + : // eslint-disable-next-line @typescript-eslint/no-empty-function + () => {}, + [disabled, ...Object.values(options)], + ); + return ref; +}; diff --git a/ui/src/index.ts b/ui/src/index.ts index eedc35e21..8b2e48f95 100644 --- a/ui/src/index.ts +++ b/ui/src/index.ts @@ -27,3 +27,4 @@ export * from './components/table'; export * from './components/tabs'; export * from './components/tooltip'; export * from './components/typography'; +export * from './components/use-glow-hover'; From 9acb966ec3bd4cae7d9e74b8d508bbfde2958085 Mon Sep 17 00:00:00 2001 From: Inna Abdrakhmanova Date: Mon, 13 Jan 2025 20:49:49 +0100 Subject: [PATCH 2/5] Add usage example to the main page (TODO: remove the example before merge) --- .../(website)/(home)/(sections)/overview.tsx | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx b/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx index f074a7cf0..7fd1d126a 100644 --- a/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx +++ b/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx @@ -1,6 +1,6 @@ import { DefaultParams } from '@/app/[lang]/[region]'; import { Translator } from '@socialincome/shared/src/utils/i18n'; -import { BaseContainer, Typography } from '@socialincome/ui'; +import { BaseContainer, GLowHoverContainer, Typography } from '@socialincome/ui'; import { FontColor } from '@socialincome/ui/src/interfaces/color'; export async function Overview({ lang }: DefaultParams) { @@ -10,36 +10,52 @@ export async function Overview({ lang }: DefaultParams) { }); return ( - - - {translator.t('section-2.title-1')} - -
- {translator.t<{ text: string; color?: FontColor }[]>('section-2.title-2').map((title, index) => ( - - {title.text}{' '} - - ))} -
- - {translator.t('section-2.title-3')} - -
    -
  1. - {translator.t('section-2.text-3.1')} -
  2. -
  3. - {translator.t('section-2.text-3.2')} -
  4. -
- - {translator.t('section-2.title-4')} - - {translator.t('section-2.text-4')} - - {translator.t('section-2.title-5')} - - {translator.t('section-2.text-5')} -
+ <> + {/* TODO: remove the example of GlowHoverContainer usage below before merge */} + + + {translator.t('section-2.title-1')} + +
+ {translator.t<{ text: string; color?: FontColor }[]>('section-2.title-2').map((title, index) => ( + + {title.text}{' '} + + ))} +
+
+ + + + {translator.t('section-2.title-1')} + +
+ {translator.t<{ text: string; color?: FontColor }[]>('section-2.title-2').map((title, index) => ( + + {title.text}{' '} + + ))} +
+ + {translator.t('section-2.title-3')} + +
    +
  1. + {translator.t('section-2.text-3.1')} +
  2. +
  3. + {translator.t('section-2.text-3.2')} +
  4. +
+ + {translator.t('section-2.title-4')} + + {translator.t('section-2.text-4')} + + {translator.t('section-2.title-5')} + + {translator.t('section-2.text-5')} +
+ ); } From c2773a969f3568ec67925e8db0bc032f49db8e9f Mon Sep 17 00:00:00 2001 From: Inna Abdrakhmanova Date: Mon, 13 Jan 2025 21:39:16 +0100 Subject: [PATCH 3/5] Remove unnecessary comments --- .../use-glow-hover/glow-hover-effect.ts | 37 ------------------- .../use-glow-hover/linear-animation.ts | 1 - .../use-glow-hover/use-glow-hover.ts | 2 +- 3 files changed, 1 insertion(+), 39 deletions(-) diff --git a/ui/src/components/use-glow-hover/glow-hover-effect.ts b/ui/src/components/use-glow-hover/glow-hover-effect.ts index f8d971a91..741eb8ca0 100644 --- a/ui/src/components/use-glow-hover/glow-hover-effect.ts +++ b/ui/src/components/use-glow-hover/glow-hover-effect.ts @@ -1,13 +1,6 @@ 'use client'; import { linearAnimation } from './linear-animation'; -/*import presets, { GlowHoverPreset } from './presets';*/ - -/*import { - forceLightTheme, - forceDarkTheme, - resetForcedTheme -} from './theme-utils';*/ export type GlowHoverOptions = { hoverBg?: string; @@ -16,10 +9,6 @@ export type GlowHoverOptions = { lightSizeLeaveAnimationTime?: number; isElementMovable?: boolean; customStaticBg?: string; - /* /!** - * Force theme on hover. Use with caution. - *!/ - forceTheme?: 'light' | 'dark' | false;*/ enableBurst?: boolean; } & ( | { @@ -57,20 +46,10 @@ export function parseColor(colorToParse: string) { } } -// eslint-disable-next-line complexity export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHoverOptions) => { if (!el) { - // eslint-disable-next-line @typescript-eslint/no-empty-function return () => {}; } - /*let presetCfg: GlowHoverPreset = {}; - if (preset) { - const maybePreset = presets[preset] as GlowHoverPreset; - if (!maybePreset) { - throw new Error(`Unknown preset ${preset}`); - } - presetCfg = maybePreset; - }*/ const lightColor = options.lightColor ?? '#CEFF00'; const lightSize = options.lightSize ?? 130; @@ -79,8 +58,6 @@ export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHov const isElementMovable = options.isElementMovable ?? false; const customStaticBg = options.customStaticBg ?? null; - /*let forceTheme = options.forceTheme ?? null;*/ - const enableBurst = options.enableBurst ?? false; const getResolvedHoverBg = () => getComputedStyle(el).backgroundColor; @@ -136,12 +113,6 @@ export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHov window.cancelAnimationFrame(lightSizeEnterAnimationId); window.cancelAnimationFrame(lightSizeLeaveAnimationId); - /*if (forceTheme === 'light') { - forceLightTheme(el); - } else if (forceTheme === 'dark') { - forceDarkTheme(el); - }*/ - // animate currentLightSize from 0 to lightSize linearAnimation({ onProgress: (progress) => { @@ -181,10 +152,6 @@ export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHov } else { el.style.background = customStaticBg ? customStaticBg : ''; } - - /*if (forceTheme && progress === 1) { - resetForcedTheme(el); - }*/ }, time: lightSizeLeaveAnimationTime, initialProgress: 1 - currentLightSize / lightSize, @@ -262,10 +229,6 @@ export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHov el.removeEventListener('mouseup', onMouseUp); } - /*if (forceTheme) { - resetForcedTheme(el); - }*/ - if (resizeObserver) { resizeObserver.unobserve(el); resizeObserver.disconnect(); diff --git a/ui/src/components/use-glow-hover/linear-animation.ts b/ui/src/components/use-glow-hover/linear-animation.ts index b8555009f..595f0cd15 100644 --- a/ui/src/components/use-glow-hover/linear-animation.ts +++ b/ui/src/components/use-glow-hover/linear-animation.ts @@ -9,7 +9,6 @@ interface LinearAnimationParams { export const linearAnimation = ({ onProgress, - // eslint-disable-next-line @typescript-eslint/no-empty-function onIdUpdate = () => {}, time, initialProgress = 0, diff --git a/ui/src/components/use-glow-hover/use-glow-hover.ts b/ui/src/components/use-glow-hover/use-glow-hover.ts index 2fa16d585..b799181e1 100644 --- a/ui/src/components/use-glow-hover/use-glow-hover.ts +++ b/ui/src/components/use-glow-hover/use-glow-hover.ts @@ -12,7 +12,7 @@ export const useGlowHover = ({ disabled = false, ...options }: GlowHoverHookOpti () => !disabled && ref.current ? glowHoverEffect(ref.current, options) - : // eslint-disable-next-line @typescript-eslint/no-empty-function + : () => {}, [disabled, ...Object.values(options)], ); From d618b555c202cae13617ec455c53838093eac195 Mon Sep 17 00:00:00 2001 From: IPogorelova Date: Mon, 13 Jan 2025 20:41:36 +0000 Subject: [PATCH 4/5] Prettified Code! --- ui/src/components/use-glow-hover/use-glow-hover.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ui/src/components/use-glow-hover/use-glow-hover.ts b/ui/src/components/use-glow-hover/use-glow-hover.ts index b799181e1..e86636125 100644 --- a/ui/src/components/use-glow-hover/use-glow-hover.ts +++ b/ui/src/components/use-glow-hover/use-glow-hover.ts @@ -9,11 +9,7 @@ export const useGlowHover = ({ disabled = false, ...options }: GlowHoverHookOpti const ref = useRef(null); useEffect( - () => - !disabled && ref.current - ? glowHoverEffect(ref.current, options) - : - () => {}, + () => (!disabled && ref.current ? glowHoverEffect(ref.current, options) : () => {}), [disabled, ...Object.values(options)], ); return ref; From 1dd4189a995f7929002a587ab7cf49600f9f2361 Mon Sep 17 00:00:00 2001 From: Inna Abdrakhmanova Date: Tue, 14 Jan 2025 19:04:53 +0100 Subject: [PATCH 5/5] Add missing wrapperClassName prop to BaseContainerProps --- ui/src/components/containers/base-container.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/components/containers/base-container.tsx b/ui/src/components/containers/base-container.tsx index 24058e47e..9bfb43532 100644 --- a/ui/src/components/containers/base-container.tsx +++ b/ui/src/components/containers/base-container.tsx @@ -7,6 +7,7 @@ import { useGlowHover } from '../use-glow-hover'; type BaseContainerProps = { backgroundColor?: BackgroundColor; + wrapperClassName?: string; wrapperRef?: React.Ref; } & React.HTMLAttributes;