chore(themes): move theme libs into /theme
This commit is contained in:
1 parent
cfb6ec8cce
commit
83b007818d
57 files changed
+117
-103
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
ThemeV2ClockElement,
|
||||
ThemeV2CountdownElement,
|
||||
ThemeV2ParticlesElement,
|
||||
} from './themeV2';
|
||||
|
||||
/**
|
||||
* Shared helpers for the dynamic API 2.0 layer types. The DOM renderer and the
|
||||
* canvas thumbnail renderer both use these so a layer looks the same in the
|
||||
* launcher, the editor canvas and the exported preview image.
|
||||
*/
|
||||
|
||||
const pad = (value: number) => String(Math.max(0, Math.floor(value))).padStart(2, '0');
|
||||
|
||||
export const themeClockTime = (element: ThemeV2ClockElement, now: Date): string => {
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const showSeconds = element.showSeconds !== false;
|
||||
if (element.hour12) {
|
||||
const hour = hours % 12 || 12;
|
||||
return `${hour}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''} ${hours < 12 ? 'AM' : 'PM'}`;
|
||||
}
|
||||
return `${pad(hours)}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''}`;
|
||||
};
|
||||
|
||||
export const themeClockDate = (now: Date): string =>
|
||||
now.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
|
||||
export type ThemeCountdownParts = {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
/** True when the target is a valid date that has already passed. */
|
||||
finished: boolean;
|
||||
/** False for empty or unparsable targets; the layer then shows zeroes. */
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
export const themeCountdownParts = (target: string, now: Date): ThemeCountdownParts => {
|
||||
const timestamp = target.trim() ? Date.parse(target) : Number.NaN;
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, finished: false, valid: false };
|
||||
}
|
||||
const remaining = Math.max(0, timestamp - now.getTime());
|
||||
const totalSeconds = Math.floor(remaining / 1000);
|
||||
return {
|
||||
days: Math.floor(totalSeconds / 86_400),
|
||||
hours: Math.floor((totalSeconds % 86_400) / 3_600),
|
||||
minutes: Math.floor((totalSeconds % 3_600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
finished: remaining <= 0,
|
||||
valid: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const themeCountdownText = (element: ThemeV2CountdownElement, now: Date): string => {
|
||||
const parts = themeCountdownParts(element.target, now);
|
||||
if (parts.finished) return element.finishedText?.trim() || 'The wait is over';
|
||||
const template = element.text?.trim() ? element.text : '{days}d {hours}h {minutes}m {seconds}s';
|
||||
return template
|
||||
.replaceAll('{days}', String(parts.days))
|
||||
.replaceAll('{hours}', pad(parts.hours))
|
||||
.replaceAll('{minutes}', pad(parts.minutes))
|
||||
.replaceAll('{seconds}', pad(parts.seconds));
|
||||
};
|
||||
|
||||
export type ThemeParticleSeed = {
|
||||
/** Horizontal position in percent of the layer. */
|
||||
left: number;
|
||||
/** Vertical offset in percent of the layer. */
|
||||
top: number;
|
||||
/** Full animation duration in seconds. */
|
||||
duration: number;
|
||||
/** Negative delay so particles start already spread along their path. */
|
||||
delay: number;
|
||||
/** Particle size multiplier, 0.45–1.4. */
|
||||
size: number;
|
||||
/** Horizontal drift in percent of the layer width. */
|
||||
drift: number;
|
||||
/** Total rotation in degrees. */
|
||||
spin: number;
|
||||
/** Use the secondary color. */
|
||||
alternate: boolean;
|
||||
/** Peak opacity, 0.35–1. */
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
const seeded = (seed: number) => {
|
||||
let state = seed || 1;
|
||||
return () => {
|
||||
state = (state * 9301 + 49297) % 233_280;
|
||||
return state / 233_280;
|
||||
};
|
||||
};
|
||||
|
||||
export const themeParticleSeeds = (element: ThemeV2ParticlesElement): ThemeParticleSeed[] => {
|
||||
const density = Math.max(4, Math.min(120, Math.round(element.density ?? 28)));
|
||||
const speed = Math.max(0.1, Math.min(3, element.speed ?? 1));
|
||||
const wind = Math.max(-1, Math.min(1, element.wind ?? 0));
|
||||
const random = seeded(density * 7919 + 17);
|
||||
return Array.from({ length: density }, () => {
|
||||
const duration = (6 + random() * 10) / speed;
|
||||
return {
|
||||
left: random() * 100,
|
||||
top: random() * 100,
|
||||
duration,
|
||||
delay: -duration * random(),
|
||||
size: 0.45 + random() * 0.95,
|
||||
drift: (random() * 2 - 1) * 10 + wind * 16,
|
||||
spin: (random() * 2 - 1) * 720,
|
||||
alternate: random() > 0.5,
|
||||
opacity: 0.35 + random() * 0.65,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** SVG linear-gradient vector for an angle, in 0–1 gradient units. */
|
||||
export const themeSvgGradientVector = (angle: number) => {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const x = Math.sin(radians);
|
||||
const y = -Math.cos(radians);
|
||||
return {
|
||||
x1: 0.5 - x / 2,
|
||||
y1: 0.5 - y / 2,
|
||||
x2: 0.5 + x / 2,
|
||||
y2: 0.5 + y / 2,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Arc geometry in a 0–100 view box. Angles start at 12 o'clock and run
|
||||
* clockwise; the stroke is inset so it never clips at the layer edge.
|
||||
*/
|
||||
export const themeArcGeometry = (
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
thickness: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const minSide = Math.max(1, Math.min(width, height));
|
||||
const strokeWidth = Math.max(0.5, Math.min(60, (thickness / minSide) * 100));
|
||||
const radius = Math.max(1, 50 - strokeWidth / 2);
|
||||
const start = ((startAngle % 360) + 360) % 360;
|
||||
let end = ((endAngle % 360) + 360) % 360;
|
||||
if (end <= start) end += 360;
|
||||
const span = Math.min(360, end - start);
|
||||
|
||||
const point = (angle: number) => {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
return {
|
||||
x: 50 + radius * Math.sin(radians),
|
||||
y: 50 - radius * Math.cos(radians),
|
||||
};
|
||||
};
|
||||
|
||||
const from = point(start);
|
||||
if (span >= 359.99) {
|
||||
const to = point(start + 180);
|
||||
return {
|
||||
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 1 1 ${to.x} ${to.y} A ${radius} ${radius} 0 1 1 ${from.x} ${from.y}`,
|
||||
strokeWidth,
|
||||
};
|
||||
}
|
||||
|
||||
const to = point(start + span);
|
||||
const largeArc = span > 180 ? 1 : 0;
|
||||
return {
|
||||
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${largeArc} 1 ${to.x} ${to.y}`,
|
||||
strokeWidth,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user