Merge branch 'main' into develop

This commit is contained in:
Dan 2026-01-14 12:57:31 +00:00
commit 3b5f1e3876
23 changed files with 1424 additions and 138 deletions

View file

@ -216,6 +216,23 @@
line-height: 1.6; line-height: 1.6;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
a {
color: greenyellow;
text-decoration: none;
position: relative;
padding-bottom: 2px;
border-bottom: 1px dotted rgba(173, 255, 47, 0.5);
transition: all 0.3s ease;
text-shadow: 0 0 5px rgba(173, 255, 47, 0.3);
&:hover {
border-bottom-style: solid;
border-bottom-color: rgba(173, 255, 47, 0.8);
text-shadow: 0 0 10px rgba(173, 255, 47, 0.8);
background: rgba(173, 255, 47, 0.05);
}
}
} }
// Blog posts listing // Blog posts listing
@ -277,6 +294,7 @@
a { a {
color: #ff9900; color: #ff9900;
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5); text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
border-bottom: 1px dotted rgba(255, 200, 47, 0.5);
&::after { &::after {
background: #ff9900; background: #ff9900;
@ -285,11 +303,50 @@
} }
} }
.blog-summary,
.blogs-content {
color: #ff9900;
text-shadow: 0 0 10px rgba(255, 153, 0, 0.5);
a {
color: #ffb13d;
text-shadow: 0 0 5px rgba(255, 162, 23, 0.5);
border-bottom: 1px dotted rgba(255, 200, 47, 0.5);
&:hover {
border-bottom-color: orange;
text-shadow: 0 0 10px rgba(255, 217, 47, 0.8);
}
&::after {
background: #ff9900;
box-shadow: 0 0 10px #ff9900;
}
}
> .read-more {
color: #ff9900;
text-shadow: 0 0 10px rgba(255, 153, 0, 0.5);
&:after {
background: #ff9900;
box-shadow: 0 0 10px rgb(255, 213, 47);
}
}
}
.tag-filter-link { .tag-filter-link {
background: rgba(255, 153, 0, 0.2); background: rgba(255, 153, 0, 0.2);
border-color: rgba(255, 153, 0, 0.5); border-color: rgba(255, 153, 0, 0.5);
color: #ff9900; color: #ff9900;
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5); text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
&:hover {
background: rgba(255, 153, 0, 0.2);
border-color: rgba(255, 177, 60, 0.828);
box-shadow: 0 0 10px rgba(255, 164, 29, 0.4);
text-shadow: 0 0 10px rgba(255, 169, 39, 0.8);
}
} }
} }
@ -371,6 +428,7 @@
line-height: 1.6; line-height: 1.6;
opacity: 0.9; opacity: 0.9;
color: #0f0; color: #0f0;
margin-top: 1rem;
@include media-down(lg) { @include media-down(lg) {
font-size: 0.9rem; font-size: 0.9rem;
@ -504,6 +562,13 @@
} }
} }
} }
.blog-footer {
text-align: center;
padding: 1rem;
margin: auto;
font-size: 1rem;
}
} }
// Tag filter navigation // Tag filter navigation
@ -811,6 +876,7 @@
transition: all 0.3s ease; transition: all 0.3s ease;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5); text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
border-radius: 3px; border-radius: 3px;
line-height: 2.2rem;
&:hover:not(.disabled) { &:hover:not(.disabled) {
background: rgba(0, 255, 0, 0.2); background: rgba(0, 255, 0, 0.2);

View file

@ -14,6 +14,11 @@ outputFormats:
mediaType: "application/json" mediaType: "application/json"
baseName: "webmentions" baseName: "webmentions"
isPlainText: true isPlainText: true
RSS:
mediaType: application/rss+xml
baseName: feed
isPlainText: false
rel: alternate
pagination: pagination:
pagerSize: 5 pagerSize: 5
@ -41,16 +46,6 @@ params:
iconImageHeight: 35 iconImageHeight: 35
iconHeight: 70 iconHeight: 70
fuseOpts:
isCaseSensitive: false
shouldSort: true
location: 0
distance: 1000
threshold: 0.4
minMatchCharLength: 0
limit: 10
keys: ["title", "permalink", "summary", "content"]
markup: markup:
highlight: highlight:
noClasses: false noClasses: false

View file

@ -9,14 +9,19 @@ draft: false
- 🗨️ Added the ability to send and receive [webmentions](https://indieweb.org/Webmention) to the blog. Haven't automated displaying of received ones yet, but I'll get there. - 🗨️ Added the ability to send and receive [webmentions](https://indieweb.org/Webmention) to the blog. Haven't automated displaying of received ones yet, but I'll get there.
- 🧰 Setup a little personal API to handle the guestbook, visitor counter, and aforementioned webmentions. - 🧰 Setup a little personal API to handle the guestbook, visitor counter, and aforementioned webmentions.
- 📺 Setup a little dashboard on my homelab for monitoring all of the above! - 📺 Setup a dashboard on my homelab for monitoring all of the above.
- 🛜 Added an RSS feed for the blog. I've made my full posts available via RSS so you can consume them however you please.
## Links I Found Interesting ## Links I Found Interesting
- [iCloud Photos Downloader](https://github.com/icloud-photos-downloader/icloud_photos_downloader) and [Photos Backup Anywhere](https://photosbackup.app/) - Two useful looking things for backing up my Photos library - [iCloud Photos Downloader](https://github.com/icloud-photos-downloader/icloud_photos_downloader) and [Photos Backup Anywhere](https://photosbackup.app/) - Two useful looking things for backing up my Photos library
- [Zootopia 2](https://blog.yiningkarlli.com/2025/12/zootopia-2.html) - CG Engineer from Walt Disney Studios talks about some of the technical stuff from making Zootopia 2.
## Music ## Music
- 📺 [Mortal Kombat x Rhythm is a Dancer](https://www.youtube.com/watch?v=vKxn6P947PE)
## Next Week ## Next Week
Not necessary Not necessary

View file

@ -3,3 +3,5 @@ title: "Blog"
--- ---
The home of my weekly updates and occasional other thoughts. The home of my weekly updates and occasional other thoughts.
🛜 [Available via RSS](/blog/feed.xml)

View file

@ -0,0 +1,34 @@
---
title: "App Defaults - 2026"
date: 2026-01-13T18:27:20Z
tags:
- meta
draft: false
---
I've seen a lot of these popping up around the tech space and find them very interesting to see what tools and apps are peoples go-to for various every day things. Apparently there are [over 500 posts](https://defaults.rknight.me/) and counting! Here's mine right now:
- 📨 Mail Client - Proton Mail
- 📮 Mail Server - [Proton Mail](https://pr.tn/ref/MNB13JYX)
- 📝 Notes - Notes.app
- ✅ To-Do - Notes.app
- 📷 Phone Photo Shooting - Camera.app
- 🟦 Photo Management - Photos.app
- 📆 Calendar - Calendar.app
- 📁 Cloud File Storage - None
- 📖 RSS - [FreshRSS](https://github.com/FreshRSS/FreshRSS)
- 🙍🏻‍♂️ Contacts - Contacts.app
- 🌐 Browser - [Zen](https://zen-browser.app/)
- 💬 Chat - Discord
- 🔖 Bookmarks - Firefox Bookmarks Sync
- 📑 Read It Later - Notes.app
- 🧑‍💻 Code Editor - [VSCodium](https://vscodium.com/)
- 🛒 Shopping Lists - Notes.app
- 📰 News - Avoid at all costs
- 🎵 Music - Tidal, [High Tide](https://github.com/Nokse22/high-tide), Turntable
- 🎤 Podcasts - Apple Podcasts
- 🔐 Password Management - Proton Pass
Apparently I dump a lot of stuff in the Notes app on my phone. It's the closest thing to hand and has almost zero barrier to entry. Also I know where all my stuff is then.
I should probably find better ways of organising things, but you know what they say - the best system is the one that works for you.

View file

@ -25,6 +25,7 @@ Big thanks to [neonaut's 88x31 archive](https://neonaut.neocities.org/cyber/88x3
- 07/01/2025 - Initial release. - 07/01/2025 - Initial release.
- 08/01/2025 - Total refactor to be modular, added many more effects. - 08/01/2025 - Total refactor to be modular, added many more effects.
- 09/01/2025 - Rewrote the bevel inset and outset border code so they look a lot nicer at the corners. Updates throughout so that multibyte (emoji!) characters should work. - 09/01/2025 - Rewrote the bevel inset and outset border code so they look a lot nicer at the corners. Updates throughout so that multibyte (emoji!) characters should work.
- 13/01/2025 - Added ticker scrolling and flashing text options. Added a background image selector.
--- ---

View file

@ -9,7 +9,13 @@
}}{{ end }}{{ end }} }}{{ end }}{{ end }}
</title> </title>
<meta name="viewport" content="width=device-width,minimum-scale=1" /> <meta name="viewport" content="width=device-width,minimum-scale=1" />
{{ hugo.Generator }} {{ $sass := resources.Get "sass/style.scss" }} {{ <link rel="me" href="https://indieweb.social/@ritual" />
<meta name="fediverse:creator" content="@ritual@indieweb.social">
{{ range .AlternativeOutputFormats }}
<link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .RelPermalink }}">
{{ end }}
{{ $sass := resources.Get "sass/style.scss" }} {{
$style := $sass | css.Sass | resources.Minify | resources.Fingerprint }} $style := $sass | css.Sass | resources.Minify | resources.Fingerprint }}
<link rel="stylesheet" href="{{ $style.RelPermalink }}" /> <link rel="stylesheet" href="{{ $style.RelPermalink }}" />

View file

@ -21,6 +21,9 @@
</div> </div>
{{ end }} {{ end }}
<div class="blog-summary">{{ .Content }}</div> <div class="blog-summary">{{ .Content }}</div>
<div class="blog-footer">
🛜 <a href="/blog/feed.xml">Available via RSS</a>
</div>
</article> </article>
{{ partial "contact-section.html" . }} {{ partial "contact-section.html" . }}

View file

@ -23,5 +23,14 @@
<div class="blog-summary"> <div class="blog-summary">
{{ .Content }} {{ .Content }}
</div> </div>
{{ else }}
<div class="blog-summary">
{{ .Summary }}
<div class="read-more" >
<a href="{{ .Permalink }}">Read More</a>
</div>
</div>
{{ end }} {{ end }}
</div> </div>

56
layouts/rss.xml Normal file
View file

@ -0,0 +1,56 @@
{{- $authorEmail := "" }}
{{- with site.Params.author }}
{{- if reflect.IsMap . }}
{{- with .email }}
{{- $authorEmail = . }}
{{- end }}
{{- end }}
{{- end }}
{{- $authorName := "" }}
{{- with site.Params.author }}
{{- if reflect.IsMap . }}
{{- with .name }}
{{- $authorName = . }}
{{- end }}
{{- else }}
{{- $authorName = . }}
{{- end }}
{{- end }}
{{- $pctx := . }}
{{- if .IsHome }}{{ $pctx = .Site }}{{ end }}
{{- $pages := slice }}
{{- if or $.IsHome $.IsSection }}
{{- $pages = $pctx.RegularPages }}
{{- else }}
{{- $pages = $pctx.Pages }}
{{- end }}
{{- $limit := .Site.Config.Services.RSS.Limit }}
{{- if ge $limit 1 }}
{{- $pages = $pages | first $limit }}
{{- end }}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Blog(gish) by Dan @ ritual.sh</title>
<link>{{ .Permalink }}</link>
<description>Dan's thoughts on web and game development, tech experiments, and whatever else catches my attention in the Golden Valley.</description>
<language>{{ site.Language.LanguageCode }}</language>{{ if not .Date.IsZero }}
<lastBuildDate>{{ (index $pages.ByLastmod.Reverse 0).Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>{{ end }}
{{- with .OutputFormats.Get "RSS" }}
{{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
{{- end }}
{{- range $pages }}
{{- $content := .Content }}
{{- /* Remove graph containers with all their content including canvas and script tags */ -}}
{{- $content = replaceRE `(?s)<div class="graph-container"[^>]*>.*?</div>\s*<script>.*?</script>` "<p><em>[Interactive graph not available in RSS - please visit the full post to view the charts]</em></p>" $content }}
<item>
<title>{{ .Title }}</title>
<link>{{ .Permalink }}</link>
<pubDate>{{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
{{- with $authorEmail }}<author>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</author>{{ end }}
<guid>{{ .Permalink }}</guid>
<description>{{ $content | transform.XMLEscape | safeHTML }}</description>
</item>
{{- end }}
</channel>
</rss>

View file

@ -174,6 +174,14 @@ export class ButtonGenerator {
values[id] = element.value; values[id] = element.value;
} }
} }
// For range-dual controls, also check for -start and -end suffixed elements
const startElement = document.getElementById(`${id}-start`);
const endElement = document.getElementById(`${id}-end`);
if (startElement && endElement) {
values[`${id}-start`] = parseFloat(startElement.value);
values[`${id}-end`] = parseFloat(endElement.value);
}
}); });
return values; return values;

View file

@ -245,12 +245,17 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) { apply(context, controlValues, animState, renderData) {
const file = controlValues['bg-image-file']; const file = controlValues['bg-image-file'];
const bgColor = controlValues['bg-color'] || '#FFFFFF';
const fitMode = controlValues['bg-image-fit'] || 'cover'; const fitMode = controlValues['bg-image-fit'] || 'cover';
const opacity = controlValues['bg-image-opacity'] ?? 1; const opacity = controlValues['bg-image-opacity'] ?? 1;
const zoom = controlValues['bg-image-zoom'] ?? 100; const zoom = controlValues['bg-image-zoom'] ?? 100;
const offsetX = controlValues['bg-image-offset-x'] ?? 50; const offsetX = controlValues['bg-image-offset-x'] ?? 50;
const offsetY = controlValues['bg-image-offset-y'] ?? 50; const offsetY = controlValues['bg-image-offset-y'] ?? 50;
// Draw background color first (always, for all states)
context.fillStyle = bgColor;
context.fillRect(0, 0, renderData.width, renderData.height);
// If no file selected, fill with a placeholder color // If no file selected, fill with a placeholder color
if (!file || !file.blobUrl) { if (!file || !file.blobUrl) {
context.fillStyle = '#cccccc'; context.fillStyle = '#cccccc';

View file

@ -35,7 +35,7 @@ export class SolidBackgroundEffect extends ButtonEffect {
label: "Background Color", label: "Background Color",
defaultValue: "#4a90e2", defaultValue: "#4a90e2",
showWhen: "bg-type", showWhen: "bg-type",
description: "Solid background color", description: "Background color (also used behind images)",
}, },
]; ];
} }

View file

@ -0,0 +1,88 @@
import { ButtonEffect } from '../effect-base.js';
export class FlashTextEffect extends ButtonEffect {
constructor(textLineNumber = 1) {
const suffix = textLineNumber === 1 ? '' : '2';
super({
id: `text-flash${suffix}`,
name: `Flash Text ${textLineNumber}`,
type: textLineNumber === 1 ? 'text' : 'text2',
category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
renderOrder: 1, // Execute very early, before all other text effects
textLineNumber: textLineNumber
});
this.textLineNumber = textLineNumber;
}
defineControls() {
const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
const suffix = textLineNumber === 1 ? '' : '2';
return [
{
id: `animate-text-flash${suffix}`,
type: 'checkbox',
label: 'Flash Visibility',
defaultValue: false
},
{
id: `flash-range${suffix}`,
type: 'range-dual',
label: 'Visible Frame Range',
defaultValueStart: textLineNumber === 1 ? 0 : 20,
defaultValueEnd: textLineNumber === 1 ? 19 : 39,
min: 0,
max: 39,
step: 1,
showWhen: `animate-text-flash${suffix}`,
description: 'Frame range where text is visible (0-39 frames total)'
}
];
}
isEnabled(controlValues) {
const suffix = this.textLineNumber === 1 ? '' : '2';
return controlValues[`animate-text-flash${suffix}`] === true;
}
apply(context, controlValues, animState, renderData) {
if (!animState) return; // Flash requires animation
const suffix = this.textLineNumber === 1 ? '' : '2';
const startFrame = controlValues[`flash-range${suffix}-start`] || 0;
const endFrame = controlValues[`flash-range${suffix}-end`] || 39;
// Check if current frame is within visible range
const isVisible = this.isFrameVisible(animState.frame, startFrame, endFrame);
// Store visibility state in renderData so other text effects can check it
const visibilityKey = `textFlashVisible${suffix}`;
renderData[visibilityKey] = isVisible;
// Also set globalAlpha (even though it gets restored, it helps during this effect's lifecycle)
if (!isVisible) {
context.globalAlpha = 0;
}
}
/**
* Check if frame is within visible range
* @param {number} frame - Current frame number (0-39)
* @param {number} startFrame - Start of visible range
* @param {number} endFrame - End of visible range
* @returns {boolean} - True if frame is in visible range
*/
isFrameVisible(frame, startFrame, endFrame) {
// Ensure start is less than or equal to end
const start = Math.min(startFrame, endFrame);
const end = Math.max(startFrame, endFrame);
return frame >= start && frame <= end;
}
}
// Auto-register effects for both text lines
export function register(generator) {
generator.registerEffect(new FlashTextEffect(1));
generator.registerEffect(new FlashTextEffect(2));
}

View file

@ -52,6 +52,18 @@ export class RainbowTextEffect extends ButtonEffect {
const suffix = this.textLineNumber === 1 ? '' : '2'; const suffix = this.textLineNumber === 1 ? '' : '2';
// Check if ticker is active - if so, ticker handles rendering
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
if (tickerActive) {
return;
}
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
// Get text configuration // Get text configuration
const text = controlValues[`button-text${suffix}`] || ''; const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return; if (!text || text.trim() === '') return;

View file

@ -61,6 +61,19 @@ export class SpinTextEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) { apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2"; const suffix = this.textLineNumber === 1 ? "" : "2";
// Check if ticker is active - if so, ticker handles rendering
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
if (tickerActive) {
return;
}
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
const text = controlValues[`button-text${suffix}`] || ""; const text = controlValues[`button-text${suffix}`] || "";
if (!text || text.trim() === '') return; if (!text || text.trim() === '') return;

View file

@ -98,6 +98,12 @@ export class TextShadowEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) { apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2"; const suffix = this.textLineNumber === 1 ? "" : "2";
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
const text = controlValues[`button-text${suffix}`] || ""; const text = controlValues[`button-text${suffix}`] || "";
if (!text) return; if (!text) return;

View file

@ -150,13 +150,20 @@ export class StandardTextEffect extends ButtonEffect {
const waveActive = controlValues[`animate-text-wave${suffix}`]; const waveActive = controlValues[`animate-text-wave${suffix}`];
const rainbowActive = controlValues[`animate-text-rainbow${suffix}`]; const rainbowActive = controlValues[`animate-text-rainbow${suffix}`];
const spinActive = controlValues[`animate-text-spin${suffix}`]; const spinActive = controlValues[`animate-text-spin${suffix}`];
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive; return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive && !tickerActive;
} }
apply(context, controlValues, animState, renderData) { apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2"; const suffix = this.textLineNumber === 1 ? "" : "2";
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
const text = controlValues[`button-text${suffix}`]; const text = controlValues[`button-text${suffix}`];
if (!text) return; if (!text) return;

View file

@ -0,0 +1,312 @@
import { ButtonEffect } from '../effect-base.js';
/**
* Ticker text animation effect
* Makes text scroll across the button in various directions with seamless looping
*/
export class TickerTextEffect extends ButtonEffect {
constructor(textLineNumber = 1) {
const suffix = textLineNumber === 1 ? '' : '2';
super({
id: `text-ticker${suffix}`,
name: `Ticker Text ${textLineNumber}`,
type: textLineNumber === 1 ? 'text' : 'text2',
category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
renderOrder: 12, // After wave(10) and spin(8), before shadow(19) and standard(20)
textLineNumber: textLineNumber
});
this.textLineNumber = textLineNumber;
}
defineControls() {
const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
const suffix = textLineNumber === 1 ? '' : '2';
return [
{
id: `animate-text-ticker${suffix}`,
type: 'checkbox',
label: 'Ticker Scroll',
defaultValue: false
},
{
id: `ticker-direction${suffix}`,
type: 'select',
label: 'Scroll Direction',
defaultValue: 'left',
options: [
{ value: 'left', label: 'Right to Left' },
{ value: 'right', label: 'Left to Right' },
{ value: 'up', label: 'Down to Up' },
{ value: 'down', label: 'Up to Down' }
],
showWhen: `animate-text-ticker${suffix}`,
description: 'Direction of text scrolling'
}
];
}
isEnabled(controlValues) {
const suffix = this.textLineNumber === 1 ? '' : '2';
return controlValues[`animate-text-ticker${suffix}`] === true;
}
apply(context, controlValues, animState, renderData) {
if (!animState) return; // Ticker requires animation
const suffix = this.textLineNumber === 1 ? '' : '2';
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
// Get text configuration
const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return;
const fontSize = controlValues[`font-size${suffix}`] || 12;
const fontWeight = controlValues[`font-bold${suffix}`] ? 'bold' : 'normal';
const fontStyle = controlValues[`font-italic${suffix}`] ? 'italic' : 'normal';
const fontFamily = controlValues[`font-family${suffix}`] || 'Arial';
const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
const direction = controlValues[`ticker-direction${suffix}`] || 'left';
// Set font
context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
context.textAlign = 'center';
context.textBaseline = 'middle';
// Check if other effects are active
const waveActive = controlValues[`animate-text-wave${suffix}`];
const spinActive = controlValues[`animate-text-spin${suffix}`];
const rainbowActive = controlValues[`animate-text-rainbow${suffix}`];
// Split text into grapheme clusters (handles emojis properly)
const chars = this.splitGraphemes(text);
// Measure total width
const totalWidth = context.measureText(text).width;
// Calculate scroll parameters - SIMPLIFIED
const horizontal = direction === 'left' || direction === 'right';
const gapSize = 50; // Gap between text repetitions
// For ticker to work: text must scroll across full screen width PLUS its own width PLUS gap
// This ensures text fully enters, crosses, and exits with proper spacing
const copySpacing = horizontal
? (renderData.width + totalWidth + gapSize)
: (renderData.height + fontSize * 2 + gapSize);
// For a seamless loop, offset scrolls through one full copy spacing in 40 frames
// At frame 0: offset = 0
// At frame 39: offset approaches copySpacing (ready to wrap to next copy)
const offset = animState.progress * copySpacing;
// Apply direction
const scrollOffset = {
x: direction === 'left' ? -offset : (direction === 'right' ? offset : 0),
y: direction === 'up' ? -offset : (direction === 'down' ? offset : 0)
};
// Calculate how many copies we need to fill the screen
const numCopies = horizontal
? Math.ceil(renderData.width / copySpacing) + 3
: Math.ceil(renderData.height / copySpacing) + 3;
// Get colors
const colors = this.getTextColors(context, controlValues, text, baseX, baseY, fontSize, animState, rainbowActive);
// Set ticker active flag so other effects can skip rendering if needed
renderData[`tickerActive${suffix}`] = true;
// Render scrolling text
this.renderScrollingText(
context, chars, scrollOffset, numCopies,
totalWidth, fontSize, copySpacing, horizontal, direction,
{ wave: waveActive, spin: spinActive, rainbow: rainbowActive },
controlValues, animState, renderData, colors
);
}
/**
* Split text into grapheme clusters (emoji-safe)
*/
splitGraphemes(text) {
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return Array.from(segmenter.segment(text), s => s.segment);
} else {
// Fallback: spread operator handles basic emoji
return [...text];
}
}
/**
* Render scrolling text with multiple copies for seamless looping
*/
renderScrollingText(
context, chars, scrollOffset, numCopies,
totalWidth, fontSize, copySpacing, horizontal, direction,
effects, controlValues, animState, renderData, colors
) {
const suffix = this.textLineNumber === 1 ? '' : '2';
// Get wave parameters if active
let waveAmplitude, waveSpeed;
if (effects.wave) {
waveAmplitude = controlValues[`wave-amplitude${suffix}`] || 3;
waveSpeed = controlValues[`wave-speed${suffix}`] || 1;
}
// Get spin parameters if active
let spinSpeed, spinStagger;
if (effects.spin) {
spinSpeed = controlValues[`spin-speed${suffix}`] || 1;
spinStagger = controlValues[`spin-stagger${suffix}`] || 0.3;
}
// Get base positioning from controls
const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
// Loop through copies - render multiple instances for seamless wrap
for (let copy = 0; copy < numCopies; copy++) {
// Each copy is spaced by copySpacing (which includes text width + gap)
const copyOffsetX = horizontal ? copy * copySpacing : 0;
const copyOffsetY = !horizontal ? copy * copySpacing : 0;
// Calculate starting position based on direction
// The key: text should be fully OFF screen before appearing on the other side
let startX, startY;
if (direction === 'left') {
// Right to left: Position so after scrolling copySpacing left, text fully exits
// copySpacing = totalWidth + gap
// Start with left edge at: copySpacing (so right edge is at copySpacing + totalWidth)
// After scrolling copySpacing left: right edge is at totalWidth (still need to exit more!)
// Actually: start at renderData.width so left edge begins at right screen edge
startX = renderData.width;
} else if (direction === 'right') {
// Left to right: Start with RIGHT edge of text at left edge of screen
// Text scrolls right, exits when LEFT edge reaches right edge of screen
startX = -totalWidth;
} else if (direction === 'up') {
// Down to up: Start off-screen below
startX = baseX - totalWidth / 2; // Center the text horizontally
startY = renderData.height;
} else { // down
// Up to down: Start off-screen above
startX = baseX - totalWidth / 2; // Center the text horizontally
startY = -fontSize * 2;
}
// Calculate current position with scroll offset and copy offset
let currentX = startX + scrollOffset.x + copyOffsetX;
let currentY = horizontal ? baseY : (startY + scrollOffset.y + copyOffsetY);
// Render each character
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
const charWidth = context.measureText(char).width;
const charCenterX = currentX + charWidth / 2;
let charY = currentY;
// Apply wave effect if active
let waveY = 0;
if (effects.wave) {
const phase = animState.getPhase(waveSpeed);
const charOffset = i / chars.length;
waveY = Math.sin(phase + charOffset * Math.PI * 2) * waveAmplitude;
}
// Apply spin effect if active
if (effects.spin) {
const phase = animState.getPhase(spinSpeed);
const rotation = (phase + i * spinStagger * Math.PI * 2) % (Math.PI * 2);
context.save();
context.translate(charCenterX, charY + waveY);
context.rotate(rotation);
// Draw outline if enabled
if (controlValues[`text${suffix}-outline`]) {
context.strokeStyle = colors.strokeStyle;
context.lineWidth = 2;
context.strokeText(char, 0, 0);
}
// Draw character
context.fillStyle = colors.fillStyle;
context.fillText(char, 0, 0);
context.restore();
} else {
// No spin - draw normally with wave offset
const finalY = charY + waveY;
// Draw outline if enabled
if (controlValues[`text${suffix}-outline`]) {
context.strokeStyle = colors.strokeStyle;
context.lineWidth = 2;
context.strokeText(char, charCenterX, finalY);
}
// Draw character
context.fillStyle = colors.fillStyle;
context.fillText(char, charCenterX, finalY);
}
currentX += charWidth;
}
}
}
/**
* Get text colors (solid, gradient, or rainbow)
*/
getTextColors(context, controlValues, text, x, y, fontSize, animState, rainbowActive) {
const suffix = this.textLineNumber === 1 ? '' : '2';
let fillStyle, strokeStyle;
// Check if rainbow text is also enabled
if (animState && rainbowActive) {
const speed = controlValues[`text-rainbow-speed${suffix}`] || 1;
const hue = (animState.progress * speed * 360) % 360;
fillStyle = `hsl(${hue}, 80%, 60%)`;
strokeStyle = `hsl(${hue}, 80%, 30%)`;
} else {
const colorType = controlValues[`text${suffix}-color-type`] || 'solid';
if (colorType === 'solid') {
fillStyle = controlValues[`text${suffix}-color`] || '#ffffff';
strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
} else {
// Gradient
const angle = (controlValues[`text${suffix}-gradient-angle`] || 0) * (Math.PI / 180);
const textWidth = context.measureText(text).width;
const x1 = x - textWidth / 2 + (Math.cos(angle) * textWidth) / 2;
const y1 = y - fontSize / 2 + (Math.sin(angle) * fontSize) / 2;
const x2 = x + textWidth / 2 - (Math.cos(angle) * textWidth) / 2;
const y2 = y + fontSize / 2 - (Math.sin(angle) * fontSize) / 2;
const gradient = context.createLinearGradient(x1, y1, x2, y2);
gradient.addColorStop(0, controlValues[`text${suffix}-gradient-color1`] || '#ffffff');
gradient.addColorStop(1, controlValues[`text${suffix}-gradient-color2`] || '#00ffff');
fillStyle = gradient;
strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
}
}
return { fillStyle, strokeStyle };
}
}
// Auto-register effect
export function register(generator) {
generator.registerEffect(new TickerTextEffect(1));
generator.registerEffect(new TickerTextEffect(2));
}

View file

@ -63,6 +63,18 @@ export class WaveTextEffect extends ButtonEffect {
const suffix = this.textLineNumber === 1 ? '' : '2'; const suffix = this.textLineNumber === 1 ? '' : '2';
// Check if ticker is active - if so, ticker handles rendering
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
if (tickerActive) {
return;
}
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
// Get text configuration // Get text configuration
const text = controlValues[`button-text${suffix}`] || ''; const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return; if (!text || text.trim() === '') return;

View file

@ -25,7 +25,9 @@ import * as standardText from "./effects/text-standard.js";
import * as textShadow from "./effects/text-shadow.js"; import * as textShadow from "./effects/text-shadow.js";
import * as waveText from "./effects/wave-text.js"; import * as waveText from "./effects/wave-text.js";
import * as rainbowText from "./effects/rainbow-text.js"; import * as rainbowText from "./effects/rainbow-text.js";
import * as flashText from "./effects/flash-text.js";
import * as spinText from "./effects/spin-text.js"; import * as spinText from "./effects/spin-text.js";
import * as tickerText from "./effects/ticker-text.js";
import * as glitch from "./effects/glitch.js"; import * as glitch from "./effects/glitch.js";
import * as pulse from "./effects/pulse.js"; import * as pulse from "./effects/pulse.js";
import * as shimmer from "./effects/shimmer.js"; import * as shimmer from "./effects/shimmer.js";
@ -101,7 +103,9 @@ async function setupApp() {
textShadow.register(generator); textShadow.register(generator);
waveText.register(generator); waveText.register(generator);
rainbowText.register(generator); rainbowText.register(generator);
flashText.register(generator);
spinText.register(generator); spinText.register(generator);
tickerText.register(generator);
glitch.register(generator); glitch.register(generator);
pulse.register(generator); pulse.register(generator);
shimmer.register(generator); shimmer.register(generator);

View file

@ -16,8 +16,8 @@ export class UIBuilder {
*/ */
setupTooltip() { setupTooltip() {
// Wait for DOM to be ready // Wait for DOM to be ready
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
this.createTooltipElement(); this.createTooltipElement();
}); });
} else { } else {
@ -26,8 +26,8 @@ export class UIBuilder {
} }
createTooltipElement() { createTooltipElement() {
this.tooltip = document.createElement('div'); this.tooltip = document.createElement("div");
this.tooltip.className = 'control-tooltip'; this.tooltip.className = "control-tooltip";
this.tooltip.style.cssText = ` this.tooltip.style.cssText = `
position: fixed; position: fixed;
background: linear-gradient(135deg, rgba(0, 120, 200, 0.98) 0%, rgba(0, 100, 180, 0.98) 100%); background: linear-gradient(135deg, rgba(0, 120, 200, 0.98) 0%, rgba(0, 100, 180, 0.98) 100%);
@ -57,20 +57,20 @@ export class UIBuilder {
clearTimeout(this.tooltipTimeout); clearTimeout(this.tooltipTimeout);
this.tooltip.textContent = text; this.tooltip.textContent = text;
this.tooltip.style.opacity = '1'; this.tooltip.style.opacity = "1";
// Position tooltip above the element // Position tooltip above the element
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
// Set initial position to measure // Set initial position to measure
this.tooltip.style.left = '0px'; this.tooltip.style.left = "0px";
this.tooltip.style.top = '0px'; this.tooltip.style.top = "0px";
this.tooltip.style.visibility = 'hidden'; this.tooltip.style.visibility = "hidden";
this.tooltip.style.display = 'block'; this.tooltip.style.display = "block";
const tooltipRect = this.tooltip.getBoundingClientRect(); const tooltipRect = this.tooltip.getBoundingClientRect();
this.tooltip.style.visibility = 'visible'; this.tooltip.style.visibility = "visible";
let left = rect.left + rect.width / 2 - tooltipRect.width / 2; let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
let top = rect.top - tooltipRect.height - 10; let top = rect.top - tooltipRect.height - 10;
@ -96,7 +96,7 @@ export class UIBuilder {
if (!this.tooltip) return; if (!this.tooltip) return;
clearTimeout(this.tooltipTimeout); clearTimeout(this.tooltipTimeout);
this.tooltipTimeout = setTimeout(() => { this.tooltipTimeout = setTimeout(() => {
this.tooltip.style.opacity = '0'; this.tooltip.style.opacity = "0";
}, 100); }, 100);
} }
@ -106,17 +106,17 @@ export class UIBuilder {
addTooltipHandlers(element, description) { addTooltipHandlers(element, description) {
if (!description) return; if (!description) return;
element.addEventListener('mouseenter', () => { element.addEventListener("mouseenter", () => {
this.showTooltip(element, description); this.showTooltip(element, description);
}); });
element.addEventListener('mouseleave', () => { element.addEventListener("mouseleave", () => {
this.hideTooltip(); this.hideTooltip();
}); });
element.addEventListener('mousemove', () => { element.addEventListener("mousemove", () => {
// Update position on mouse move for better following // Update position on mouse move for better following
if (this.tooltip && this.tooltip.style.opacity === '1') { if (this.tooltip && this.tooltip.style.opacity === "1") {
this.showTooltip(element, description); this.showTooltip(element, description);
} }
}); });
@ -128,7 +128,7 @@ export class UIBuilder {
*/ */
buildUI(effects) { buildUI(effects) {
// Clear existing content // Clear existing content
this.container.innerHTML = ''; this.container.innerHTML = "";
this.controlGroups.clear(); this.controlGroups.clear();
// Group effects by category // Group effects by category
@ -148,8 +148,8 @@ export class UIBuilder {
categorizeEffects(effects) { categorizeEffects(effects) {
const categories = new Map(); const categories = new Map();
effects.forEach(effect => { effects.forEach((effect) => {
const category = effect.category || 'Other'; const category = effect.category || "Other";
if (!categories.has(category)) { if (!categories.has(category)) {
categories.set(category, []); categories.set(category, []);
} }
@ -159,17 +159,17 @@ export class UIBuilder {
// Sort categories in a logical order // Sort categories in a logical order
const orderedCategories = new Map(); const orderedCategories = new Map();
const categoryOrder = [ const categoryOrder = [
'Text Line 1', "Text Line 1",
'Text Line 2', "Text Line 2",
'Background', "Background",
'Background Animations', "Background Animations",
'Border', "Border",
'Visual Effects', "Visual Effects",
'General Effects', "General Effects",
'Special Effects' "Special Effects",
]; ];
categoryOrder.forEach(cat => { categoryOrder.forEach((cat) => {
if (categories.has(cat)) { if (categories.has(cat)) {
orderedCategories.set(cat, categories.get(cat)); orderedCategories.set(cat, categories.get(cat));
} }
@ -191,29 +191,29 @@ export class UIBuilder {
* @param {Array<ButtonEffect>} effects - Effects in this category * @param {Array<ButtonEffect>} effects - Effects in this category
*/ */
createControlGroup(category, effects) { createControlGroup(category, effects) {
const groupDiv = document.createElement('div'); const groupDiv = document.createElement("div");
groupDiv.className = 'control-group'; groupDiv.className = "control-group";
// Create header // Create header
const header = document.createElement('h3'); const header = document.createElement("h3");
header.className = 'control-group-header'; header.className = "control-group-header";
header.innerHTML = ` header.innerHTML = `
<span>${category}</span> <span>${category}</span>
<span class="toggle-icon"></span> <span class="toggle-icon"></span>
`; `;
// Create content container // Create content container
const content = document.createElement('div'); const content = document.createElement("div");
content.className = 'control-group-content'; content.className = "control-group-content";
// Add controls for each effect in this category // Add controls for each effect in this category
effects.forEach(effect => { effects.forEach((effect) => {
this.addEffectControls(content, effect); this.addEffectControls(content, effect);
}); });
// Add click handler for collapsing // Add click handler for collapsing
header.addEventListener('click', () => { header.addEventListener("click", () => {
groupDiv.classList.toggle('collapsed'); groupDiv.classList.toggle("collapsed");
}); });
groupDiv.appendChild(header); groupDiv.appendChild(header);
@ -229,7 +229,7 @@ export class UIBuilder {
* @param {ButtonEffect} effect - Effect to create controls for * @param {ButtonEffect} effect - Effect to create controls for
*/ */
addEffectControls(container, effect) { addEffectControls(container, effect) {
effect.controls.forEach(control => { effect.controls.forEach((control) => {
const controlEl = this.createControl(control); const controlEl = this.createControl(control);
if (controlEl) { if (controlEl) {
container.appendChild(controlEl); container.appendChild(controlEl);
@ -243,26 +243,85 @@ export class UIBuilder {
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
createControl(controlDef) { createControl(controlDef) {
const { id, type, label, defaultValue, min, max, step, options, showWhen, description } = controlDef; const {
id,
type,
label,
defaultValue,
min,
max,
step,
options,
showWhen,
description,
} = controlDef;
switch (type) { switch (type) {
case 'checkbox': case "checkbox":
return this.createCheckbox(id, label, defaultValue, showWhen, description); return this.createCheckbox(
id,
label,
defaultValue,
showWhen,
description,
);
case 'range': case "range":
return this.createRange(id, label, defaultValue, min, max, step, description, showWhen); return this.createRange(
id,
label,
defaultValue,
min,
max,
step,
description,
showWhen,
);
case 'color': case "range-dual":
return this.createRangeDual(
id,
label,
controlDef.defaultValueStart,
controlDef.defaultValueEnd,
min,
max,
step,
description,
showWhen,
);
case "color":
return this.createColor(id, label, defaultValue, showWhen, description); return this.createColor(id, label, defaultValue, showWhen, description);
case 'select': case "select":
return this.createSelect(id, label, defaultValue, options, showWhen, description); return this.createSelect(
id,
label,
defaultValue,
options,
showWhen,
description,
);
case 'text': case "text":
return this.createTextInput(id, label, defaultValue, showWhen, description); return this.createTextInput(
id,
label,
defaultValue,
showWhen,
description,
);
case 'file': case "file":
return this.createFileInput(id, label, defaultValue, showWhen, description, controlDef.accept); return this.createFileInput(
id,
label,
defaultValue,
showWhen,
description,
controlDef.accept,
);
default: default:
console.warn(`Unknown control type: ${type}`); console.warn(`Unknown control type: ${type}`);
@ -274,22 +333,22 @@ export class UIBuilder {
* Create a checkbox control * Create a checkbox control
*/ */
createCheckbox(id, label, defaultValue, showWhen, description) { createCheckbox(id, label, defaultValue, showWhen, description) {
const wrapper = document.createElement('label'); const wrapper = document.createElement("label");
wrapper.className = 'checkbox-label'; wrapper.className = "checkbox-label";
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'checkbox'; input.type = "checkbox";
input.id = id; input.id = id;
input.checked = defaultValue || false; input.checked = defaultValue || false;
const span = document.createElement('span'); const span = document.createElement("span");
span.textContent = label; span.textContent = label;
wrapper.appendChild(input); wrapper.appendChild(input);
wrapper.appendChild(span); wrapper.appendChild(span);
if (showWhen) { if (showWhen) {
wrapper.style.display = 'none'; wrapper.style.display = "none";
wrapper.dataset.showWhen = showWhen; wrapper.dataset.showWhen = showWhen;
} }
@ -303,14 +362,14 @@ export class UIBuilder {
* Create a range slider control * Create a range slider control
*/ */
createRange(id, label, defaultValue, min, max, step, description, showWhen) { createRange(id, label, defaultValue, min, max, step, description, showWhen) {
const container = document.createElement('div'); const container = document.createElement("div");
const labelEl = document.createElement('label'); const labelEl = document.createElement("label");
labelEl.htmlFor = id; labelEl.htmlFor = id;
labelEl.innerHTML = `${label}: <span id="${id}-value">${defaultValue}</span>`; labelEl.innerHTML = `${label}: <span id="${id}-value">${defaultValue}</span>`;
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'range'; input.type = "range";
input.id = id; input.id = id;
input.min = min !== undefined ? min : 0; input.min = min !== undefined ? min : 0;
input.max = max !== undefined ? max : 100; input.max = max !== undefined ? max : 100;
@ -320,7 +379,7 @@ export class UIBuilder {
} }
// Update value display on input // Update value display on input
input.addEventListener('input', () => { input.addEventListener("input", () => {
const valueDisplay = document.getElementById(`${id}-value`); const valueDisplay = document.getElementById(`${id}-value`);
if (valueDisplay) { if (valueDisplay) {
valueDisplay.textContent = input.value; valueDisplay.textContent = input.value;
@ -331,7 +390,7 @@ export class UIBuilder {
container.appendChild(input); container.appendChild(input);
if (showWhen) { if (showWhen) {
container.style.display = 'none'; container.style.display = "none";
container.dataset.showWhen = showWhen; container.dataset.showWhen = showWhen;
} }
@ -341,26 +400,239 @@ export class UIBuilder {
return container; return container;
} }
/**
* Create a dual-range slider control (two handles on one track)
*/
createRangeDual(
id,
label,
defaultValueStart,
defaultValueEnd,
min,
max,
step,
description,
showWhen,
) {
const container = document.createElement("div");
container.className = "range-dual-container";
const labelEl = document.createElement("label");
labelEl.innerHTML = `${label}: <span id="${id}-value">${defaultValueStart}-${defaultValueEnd}</span>`;
// Create wrapper for the dual range slider
const sliderWrapper = document.createElement("div");
sliderWrapper.className = "range-dual-wrapper";
sliderWrapper.style.cssText = `
position: relative;
height: 30px;
margin: 10px 0;
`;
// Create the track
const track = document.createElement("div");
track.className = "range-dual-track";
track.style.cssText = `
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
height: 4px;
background: #444;
border-radius: 2px;
z-index: 1;
`;
// Create the filled range indicator
const range = document.createElement("div");
range.className = "range-dual-range";
range.id = `${id}-range`;
range.style.cssText = `
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 4px;
background: linear-gradient(90deg, #ff9966, #00bcf2); // Change this to orange
border-radius: 2px;
pointer-events: none;
z-index: 10;
`;
// Create start handle input
const inputStart = document.createElement("input");
inputStart.type = "range";
inputStart.id = `${id}-start`;
inputStart.className = "range-dual-input";
inputStart.min = min !== undefined ? min : 0;
inputStart.max = max !== undefined ? max : 100;
inputStart.value =
defaultValueStart !== undefined ? defaultValueStart : min || 0;
if (step !== undefined) {
inputStart.step = step;
}
inputStart.style.cssText = `
position: absolute;
width: 100%;
top: 50%;
transform: translateY(-50%);
-webkit-appearance: none;
appearance: none;
background: transparent;
pointer-events: none;
margin: 0;
z-index: 3;
`;
// Create end handle input
const inputEnd = document.createElement("input");
inputEnd.type = "range";
inputEnd.id = `${id}-end`;
inputEnd.className = "range-dual-input";
inputEnd.min = min !== undefined ? min : 0;
inputEnd.max = max !== undefined ? max : 100;
inputEnd.value =
defaultValueEnd !== undefined ? defaultValueEnd : max || 100;
if (step !== undefined) {
inputEnd.step = step;
}
inputEnd.style.cssText = `
position: absolute;
width: 100%;
top: 50%;
transform: translateY(-50%);
-webkit-appearance: none;
appearance: none;
background: transparent;
pointer-events: none;
margin: 0;
z-index: 4;
`;
// Add CSS for the range inputs
const style = document.createElement("style");
style.textContent = `
.range-dual-input::-webkit-slider-runnable-track {
background: transparent;
border: none;
height: 4px;
}
.range-dual-input::-moz-range-track {
background: transparent;
border: none;
height: 4px;
}
.range-dual-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #0078d4;
cursor: pointer;
pointer-events: all;
border: 2px solid #fff;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 100;
}
.range-dual-input::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #0078d4;
cursor: pointer;
pointer-events: all;
border: 2px solid #fff;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 100;
}
.range-dual-input::-webkit-slider-thumb:hover {
background: #00bcf2;
}
.range-dual-input::-moz-range-thumb:hover {
background: #00bcf2;
}
`;
if (!document.getElementById("range-dual-styles")) {
style.id = "range-dual-styles";
document.head.appendChild(style);
}
// Update function
const updateRange = () => {
let startVal = parseInt(inputStart.value);
let endVal = parseInt(inputEnd.value);
// Ensure start is never greater than end
if (startVal > endVal) {
const temp = startVal;
startVal = endVal;
endVal = temp;
inputStart.value = startVal;
inputEnd.value = endVal;
}
const minVal = parseInt(inputStart.min);
const maxVal = parseInt(inputStart.max);
const startPercent = ((startVal - minVal) / (maxVal - minVal)) * 100;
const endPercent = ((endVal - minVal) / (maxVal - minVal)) * 100;
range.style.left = `${startPercent}%`;
range.style.width = `${endPercent - startPercent}%`;
const valueDisplay = document.getElementById(`${id}-value`);
if (valueDisplay) {
valueDisplay.textContent = `${startVal}-${endVal}`;
}
};
inputStart.addEventListener("input", updateRange);
inputEnd.addEventListener("input", updateRange);
// Assemble the dual range slider
sliderWrapper.appendChild(track);
sliderWrapper.appendChild(range);
sliderWrapper.appendChild(inputStart);
sliderWrapper.appendChild(inputEnd);
container.appendChild(labelEl);
container.appendChild(sliderWrapper);
if (showWhen) {
container.style.display = "none";
container.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label
this.addTooltipHandlers(labelEl, description);
// Initialize range display
updateRange();
return container;
}
/** /**
* Create a color picker control * Create a color picker control
*/ */
createColor(id, label, defaultValue, showWhen, description) { createColor(id, label, defaultValue, showWhen, description) {
const container = document.createElement('div'); const container = document.createElement("div");
const labelEl = document.createElement('label'); const labelEl = document.createElement("label");
labelEl.htmlFor = id; labelEl.htmlFor = id;
labelEl.textContent = label; labelEl.textContent = label;
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'color'; input.type = "color";
input.id = id; input.id = id;
input.value = defaultValue || '#ffffff'; input.value = defaultValue || "#ffffff";
container.appendChild(labelEl); container.appendChild(labelEl);
container.appendChild(input); container.appendChild(input);
if (showWhen) { if (showWhen) {
container.style.display = 'none'; container.style.display = "none";
container.dataset.showWhen = showWhen; container.dataset.showWhen = showWhen;
} }
@ -374,17 +646,17 @@ export class UIBuilder {
* Create a select dropdown control * Create a select dropdown control
*/ */
createSelect(id, label, defaultValue, options, showWhen, description) { createSelect(id, label, defaultValue, options, showWhen, description) {
const container = document.createElement('div'); const container = document.createElement("div");
const labelEl = document.createElement('label'); const labelEl = document.createElement("label");
labelEl.htmlFor = id; labelEl.htmlFor = id;
labelEl.textContent = label; labelEl.textContent = label;
const select = document.createElement('select'); const select = document.createElement("select");
select.id = id; select.id = id;
options.forEach(opt => { options.forEach((opt) => {
const option = document.createElement('option'); const option = document.createElement("option");
option.value = opt.value; option.value = opt.value;
option.textContent = opt.label; option.textContent = opt.label;
if (opt.value === defaultValue) { if (opt.value === defaultValue) {
@ -397,7 +669,7 @@ export class UIBuilder {
container.appendChild(select); container.appendChild(select);
if (showWhen) { if (showWhen) {
container.style.display = 'none'; container.style.display = "none";
container.dataset.showWhen = showWhen; container.dataset.showWhen = showWhen;
} }
@ -411,19 +683,19 @@ export class UIBuilder {
* Create a text input control * Create a text input control
*/ */
createTextInput(id, label, defaultValue, showWhen, description) { createTextInput(id, label, defaultValue, showWhen, description) {
const container = document.createElement('div'); const container = document.createElement("div");
const labelEl = document.createElement('label'); const labelEl = document.createElement("label");
labelEl.htmlFor = id; labelEl.htmlFor = id;
labelEl.textContent = label; labelEl.textContent = label;
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'text'; input.type = "text";
input.id = id; input.id = id;
input.value = defaultValue || ''; input.value = defaultValue || "";
// Only set maxLength for text inputs that aren't URLs // Only set maxLength for text inputs that aren't URLs
if (id !== 'bg-image-url') { if (id !== "bg-image-url") {
input.maxLength = 20; input.maxLength = 20;
} }
@ -431,7 +703,7 @@ export class UIBuilder {
container.appendChild(input); container.appendChild(input);
if (showWhen) { if (showWhen) {
container.style.display = 'none'; container.style.display = "none";
container.dataset.showWhen = showWhen; container.dataset.showWhen = showWhen;
} }
@ -445,21 +717,21 @@ export class UIBuilder {
* Create a file input control * Create a file input control
*/ */
createFileInput(id, label, defaultValue, showWhen, description, accept) { createFileInput(id, label, defaultValue, showWhen, description, accept) {
const container = document.createElement('div'); const container = document.createElement("div");
const labelEl = document.createElement('label'); const labelEl = document.createElement("label");
labelEl.htmlFor = id; labelEl.htmlFor = id;
labelEl.textContent = label; labelEl.textContent = label;
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'file'; input.type = "file";
input.id = id; input.id = id;
if (accept) { if (accept) {
input.accept = accept; input.accept = accept;
} }
// Store the file data on the input element // Store the file data on the input element
input.addEventListener('change', (e) => { input.addEventListener("change", (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
// Create a blob URL for the file // Create a blob URL for the file
@ -482,7 +754,7 @@ export class UIBuilder {
container.appendChild(input); container.appendChild(input);
if (showWhen) { if (showWhen) {
container.style.display = 'none'; container.style.display = "none";
container.dataset.showWhen = showWhen; container.dataset.showWhen = showWhen;
} }
@ -498,64 +770,103 @@ export class UIBuilder {
*/ */
setupConditionalVisibility() { setupConditionalVisibility() {
// Find all controls with showWhen attribute // Find all controls with showWhen attribute
const conditionalControls = this.container.querySelectorAll('[data-show-when]'); const conditionalControls =
this.container.querySelectorAll("[data-show-when]");
conditionalControls.forEach(control => { conditionalControls.forEach((control) => {
const triggerControlId = control.dataset.showWhen; const triggerControlId = control.dataset.showWhen;
const triggerControl = document.getElementById(triggerControlId); const triggerControl = document.getElementById(triggerControlId);
if (triggerControl) { if (triggerControl) {
const updateVisibility = () => { const updateVisibility = () => {
if (triggerControl.type === 'checkbox') { if (triggerControl.type === "checkbox") {
control.style.display = triggerControl.checked ? 'block' : 'none'; control.style.display = triggerControl.checked ? "block" : "none";
} else if (triggerControl.tagName === 'SELECT') { } else if (triggerControl.tagName === "SELECT") {
// Get the control ID to determine what value to check for // Get the control ID to determine what value to check for
const controlId = control.querySelector('input, select')?.id; const controlId = control.querySelector("input, select")?.id;
// For background controls // For background controls
if (triggerControlId === 'bg-type') { if (triggerControlId === "bg-type") {
if (controlId === 'bg-color') { if (controlId === "bg-color") {
control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; control.style.display =
} else if (controlId && (controlId.startsWith('gradient-') || controlId === 'gradient-angle')) { triggerControl.value === "solid" ||
control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; triggerControl.value === "external-image"
} else if (controlId && (controlId.startsWith('texture-') || controlId === 'texture-type' || controlId === 'texture-scale')) { ? "block"
control.style.display = triggerControl.value === 'texture' ? 'block' : 'none'; : "none";
} else if (controlId && (controlId.startsWith('emoji-') || controlId === 'emoji-text')) { } else if (
control.style.display = triggerControl.value === 'emoji-wallpaper' ? 'block' : 'none'; controlId &&
} else if (controlId && (controlId.startsWith('bg-image-') || controlId === 'bg-image-file' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) { (controlId.startsWith("gradient-") ||
control.style.display = triggerControl.value === 'external-image' ? 'block' : 'none'; controlId === "gradient-angle")
) {
control.style.display =
triggerControl.value === "gradient" ? "block" : "none";
} else if (
controlId &&
(controlId.startsWith("texture-") ||
controlId === "texture-type" ||
controlId === "texture-scale")
) {
control.style.display =
triggerControl.value === "texture" ? "block" : "none";
} else if (
controlId &&
(controlId.startsWith("emoji-") || controlId === "emoji-text")
) {
control.style.display =
triggerControl.value === "emoji-wallpaper" ? "block" : "none";
} else if (
controlId &&
(controlId.startsWith("bg-image-") ||
controlId === "bg-image-file" ||
controlId === "bg-image-fit" ||
controlId === "bg-image-opacity")
) {
control.style.display =
triggerControl.value === "external-image" ? "block" : "none";
} }
} }
// For image fit controls (zoom and position only show when manual mode) // For image fit controls (zoom and position only show when manual mode)
else if (triggerControlId === 'bg-image-fit') { else if (triggerControlId === "bg-image-fit") {
if (controlId && (controlId === 'bg-image-zoom' || controlId === 'bg-image-offset-x' || controlId === 'bg-image-offset-y')) { if (
control.style.display = triggerControl.value === 'manual' ? 'block' : 'none'; controlId &&
(controlId === "bg-image-zoom" ||
controlId === "bg-image-offset-x" ||
controlId === "bg-image-offset-y")
) {
control.style.display =
triggerControl.value === "manual" ? "block" : "none";
} }
} }
// For text color controls // For text color controls
else if (triggerControlId === 'text-color-type') { else if (triggerControlId === "text-color-type") {
if (controlId === 'text-color') { if (controlId === "text-color") {
control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; control.style.display =
} else if (controlId && controlId.startsWith('text-gradient-')) { triggerControl.value === "solid" ? "block" : "none";
control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; } else if (controlId && controlId.startsWith("text-gradient-")) {
control.style.display =
triggerControl.value === "gradient" ? "block" : "none";
} }
} else if (triggerControlId === 'text2-color-type') { } else if (triggerControlId === "text2-color-type") {
if (controlId === 'text2-color') { if (controlId === "text2-color") {
control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; control.style.display =
} else if (controlId && controlId.startsWith('text2-gradient-')) { triggerControl.value === "solid" ? "block" : "none";
control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; } else if (controlId && controlId.startsWith("text2-gradient-")) {
control.style.display =
triggerControl.value === "gradient" ? "block" : "none";
} }
} }
// For border style controls // For border style controls
else if (triggerControlId === 'border-style') { else if (triggerControlId === "border-style") {
if (controlId === 'border-rainbow-speed') { if (controlId === "border-rainbow-speed") {
control.style.display = triggerControl.value === 'rainbow' ? 'block' : 'none'; control.style.display =
} else if (controlId === 'border-march-speed') { triggerControl.value === "rainbow" ? "block" : "none";
control.style.display = triggerControl.value === 'marching-ants' ? 'block' : 'none'; } else if (controlId === "border-march-speed") {
control.style.display =
triggerControl.value === "marching-ants" ? "block" : "none";
} }
} else { } else {
// Default: show when any value is selected // Default: show when any value is selected
control.style.display = triggerControl.value ? 'block' : 'none'; control.style.display = triggerControl.value ? "block" : "none";
} }
} }
}; };
@ -564,8 +875,8 @@ export class UIBuilder {
updateVisibility(); updateVisibility();
// Update on change // Update on change
triggerControl.addEventListener('change', updateVisibility); triggerControl.addEventListener("change", updateVisibility);
triggerControl.addEventListener('input', updateVisibility); triggerControl.addEventListener("input", updateVisibility);
} }
}); });
} }

331
static/test/moleskine.html Normal file
View file

@ -0,0 +1,331 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Moleskine Notebook</title>
<style>
:root {
--page-width: 500px;
--page-height: calc(var(--page-width) * 1.618);
--binding-width: 1px;
--total-width: calc(var(--page-width) * 2 + var(--binding-width));
--border-radius: 30px;
--paper-color: #fff8dc;
--cover-color-top: #be8f32;
--cover-color-bottom: #846f24;
--line-color: rgba(135, 180, 210, 0.25);
--internal-border-radius: calc(var(--border-radius) * 0.9);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
font-family: "Georgia", serif;
padding: 40px 20px;
}
.notebook-container {
position: relative;
width: var(--total-width);
height: var(--page-height);
display: flex;
align-items: stretch;
filter: drop-shadow(0 30px 60px rgba(0, 0, 0, 0.5));
}
/* Covers */
.cover {
position: absolute;
width: 40px;
height: 100%;
background: linear-gradient(
135deg,
var(--cover-color-top) 0%,
var(--cover-color-bottom) 100%
);
z-index: 1;
border-radius: var(--border-radius);
}
.cover-left {
left: -10px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background:
radial-gradient(
ellipse at 20% 30%,
rgba(160, 130, 90, 0.4) 0%,
transparent 50%
),
radial-gradient(
ellipse at 80% 70%,
rgba(70, 50, 30, 0.3) 0%,
transparent 60%
),
linear-gradient(
135deg,
var(--cover-color-top) 0%,
var(--cover-color-top) 100%
);
box-shadow:
inset -2px 0 8px rgba(0, 0, 0, 0.4),
inset 2px 2px 4px rgba(160, 130, 90, 0.3);
}
.cover-right {
right: -10px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
background:
radial-gradient(
ellipse at 80% 30%,
rgba(160, 130, 90, 0.4) 0%,
transparent 50%
),
radial-gradient(
ellipse at 20% 70%,
rgba(70, 50, 30, 0.3) 0%,
transparent 60%
),
linear-gradient(
225deg,
var(--cover-color-top) 0%,
var(--cover-color-bottom) 100%
);
box-shadow:
inset 2px 0 8px rgba(0, 0, 0, 0.4),
inset -2px 2px 4px rgba(160, 130, 90, 0.3);
}
/* Page stacks */
.page-stack {
position: relative;
width: var(--page-width);
height: 100%;
display: flex;
align-items: stretch;
}
.page-stack-left {
z-index: 3;
}
.page-stack-right {
z-index: 3;
}
/* Page layers stack kinda*/
.page-layer {
position: absolute;
width: 100%;
height: 100%;
background: var(--paper-color);
border-radius: var(--internal-border-radius);
box-shadow: inset 1px 0 1px rgba(0, 0, 0, 0.2);
}
.page-stack-left .page-layer:nth-child(1) {
transform: translateX(-5px);
filter: brightness(0.85);
z-index: 1;
}
.page-stack-left .page-layer:nth-child(2) {
transform: translateX(-2px);
filter: brightness(0.9);
z-index: 2;
}
.page-stack-left .page-layer:nth-child(3) {
transform: translateX(-1px);
filter: brightness(0.95);
z-index: 3;
}
.page-stack-right .page-layer:nth-child(2) {
transform: translateX(5px);
filter: brightness(0.85);
z-index: 1;
}
.page-stack-right .page-layer:nth-child(3) {
transform: translateX(2px);
filter: brightness(0.9);
z-index: 2;
}
.page-stack-right .page-layer:nth-child(4) {
transform: translateX(1px);
filter: brightness(0.95);
z-index: 3;
}
/* Main pages */
.page {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
background: var(--paper-color);
border-radius: var(--internal-border-radius);
z-index: 4;
box-shadow:
inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.15);
}
.page::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(
ellipse at 30% 20%,
rgba(255, 255, 255, 0.5) 0%,
transparent 40%
),
radial-gradient(
ellipse at 70% 80%,
rgba(0, 0, 0, 0.02) 0%,
transparent 50%
);
pointer-events: none;
border-radius: var(--internal-border-radius);
}
.page-left {
border-top-left-radius: var(--internal-border-radius);
border-bottom-left-radius: var(--internal-border-radius);
}
.page-right {
border-top-right-radius: var(--internal-border-radius);
border-bottom-right-radius: var(--internal-border-radius);
}
/** Shadow for the binding effect **/
.page-left::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 10px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(0, 0, 0, 0.1) 100%
);
pointer-events: none;
}
.page-right::after {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 10px;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.1) 0%,
transparent 100%
);
pointer-events: none;
}
/* Page lines */
.page-lines {
position: absolute;
top: 60px;
left: 0;
right: 0;
bottom: 40px;
background: repeating-linear-gradient(
180deg,
transparent 0px,
transparent 27px,
var(--line-color) 27px,
var(--line-color) 28px
);
pointer-events: none;
}
/* Central binding */
.binding {
position: absolute;
left: 49.95%;
top: 50%;
transform: translate(-50%, -50%);
width: var(--binding-width);
height: calc((var(--page-height) - (var(--border-radius) * 1.5)));
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.05) 0%,
rgba(0, 0, 0, 0.15) 50%,
rgba(0, 0, 0, 0.05) 100%
);
z-index: 5;
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
}
.page-content {
padding: 2rem;
padding-top: 3.5rem;
}
</style>
</head>
<body>
<div class="notebook-container">
<!-- Left cover -->
<div class="cover cover-left"></div>
<!-- Left page stack -->
<div class="page-stack page-stack-left">
<div class="page-layer"></div>
<div class="page-layer"></div>
<div class="page-layer"></div>
<div class="page page-left">
<div class="page-lines"></div>
<div class="page-content left">
<p>This is where the left page content goes</p>
</div>
</div>
</div>
<!-- Center binding -->
<div class="binding"></div>
<!-- Right page stack -->
<div class="page-stack page-stack-right">
<div class="page page-right">
<div class="page-lines"></div>
<div class="page-content right">
<p>This is where the right page content goes</p>
</div>
</div>
<div class="page-layer"></div>
<div class="page-layer"></div>
<div class="page-layer"></div>
</div>
<!-- Right cover -->
<div class="cover cover-right"></div>
</div>
</body>
</html>