fix keepalive blink cut short

This commit is contained in:
Michael Freno
2025-12-19 14:21:29 -05:00
parent 8f4fac422b
commit 14fafdc14d
5 changed files with 96 additions and 49 deletions

View File

@@ -12,9 +12,7 @@ export function Typewriter(props: {
let cursorRef: HTMLDivElement | undefined; let cursorRef: HTMLDivElement | undefined;
const [isTyping, setIsTyping] = createSignal(false); const [isTyping, setIsTyping] = createSignal(false);
const [isDelaying, setIsDelaying] = createSignal(delay > 0); const [isDelaying, setIsDelaying] = createSignal(delay > 0);
const [keepAliveCountdown, setKeepAliveCountdown] = createSignal( const [shouldHide, setShouldHide] = createSignal(false);
typeof keepAlive === "number" ? keepAlive : -1
);
const resolved = children(() => props.children); const resolved = children(() => props.children);
onMount(() => { onMount(() => {
@@ -67,6 +65,12 @@ export function Typewriter(props: {
cursorRef.style.height = `${firstChar.offsetHeight}px`; cursorRef.style.height = `${firstChar.offsetHeight}px`;
} }
// Listen for animation end to hide cursor
const handleAnimationEnd = () => {
setShouldHide(true);
cursorRef?.removeEventListener("animationend", handleAnimationEnd);
};
const startReveal = () => { const startReveal = () => {
setIsTyping(true); // Switch to typing cursor setIsTyping(true); // Switch to typing cursor
@@ -102,17 +106,17 @@ export function Typewriter(props: {
// Typing finished, switch to block cursor // Typing finished, switch to block cursor
setIsTyping(false); setIsTyping(false);
// Start keepAlive countdown if it's a number // Start keepAlive timer if it's a number
if (typeof keepAlive === "number") { if (typeof keepAlive === "number") {
const keepAliveInterval = setInterval(() => { // Attach animation end listener
setKeepAliveCountdown((prev) => { cursorRef?.addEventListener("animationend", handleAnimationEnd);
if (prev <= 1000) {
clearInterval(keepAliveInterval); // Trigger the animation with finite iteration count
return 0; const durationSeconds = keepAlive / 1000;
} const iterations = Math.ceil(durationSeconds);
return prev - 1000; if (cursorRef) {
}); cursorRef.style.animation = `blink 1s ${iterations}`;
}, 1000); }
} }
} }
}; };
@@ -132,22 +136,16 @@ export function Typewriter(props: {
}); });
const getCursorClass = () => { const getCursorClass = () => {
if (isDelaying()) return "cursor-block"; // Blinking block during delay if (isDelaying()) return "cursor-block";
if (isTyping()) return "cursor-typing"; // Thin line while typing if (isTyping()) return "cursor-typing";
if (shouldHide()) return "hidden";
// After typing is done
if (typeof keepAlive === "number") {
return keepAliveCountdown() > 0 ? "cursor-block" : "hidden";
}
return keepAlive ? "cursor-block" : "hidden"; return keepAlive ? "cursor-block" : "hidden";
}; };
return ( return (
<div ref={containerRef} class={props.class}> <div ref={containerRef} class={props.class}>
{resolved()} {resolved()}
<span ref={cursorRef} class={getCursorClass()}> <span ref={cursorRef} class={getCursorClass()}></span>
{" "}
</span>
</div> </div>
); );
} }

View File

@@ -80,3 +80,34 @@ export function insertSoftHyphens(
}) })
.join(" "); .join(" ");
} }
/**
* Creates a debounced function that delays execution until after specified delay
* @param fn - The function to debounce
* @param delay - Delay in milliseconds
* @returns Debounced function with cancel method
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): T & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const debounced = function (this: any, ...args: Parameters<T>) {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
} as T & { cancel: () => void };
debounced.cancel = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
return debounced;
}

View File

@@ -1,10 +1,11 @@
import { Show, createSignal, onCleanup } from "solid-js"; import { Show, createSignal, createEffect, onCleanup } from "solid-js";
import { useNavigate, query } from "@solidjs/router"; import { useNavigate, query } from "@solidjs/router";
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { getPrivilegeLevel, getUserID } from "~/server/utils";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { debounce } from "~/lib/client-utils";
import Dropzone from "~/components/blog/Dropzone"; import Dropzone from "~/components/blog/Dropzone";
import TextEditor from "~/components/blog/TextEditor"; import TextEditor from "~/components/blog/TextEditor";
import TagMaker from "~/components/blog/TagMaker"; import TagMaker from "~/components/blog/TagMaker";
@@ -42,8 +43,6 @@ export default function CreatePost() {
const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false); const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false);
const [hasSaved, setHasSaved] = createSignal(false); const [hasSaved, setHasSaved] = createSignal(false);
let autosaveInterval: number | undefined;
const autoSave = async () => { const autoSave = async () => {
const titleVal = title(); const titleVal = title();
const bodyVal = body(); const bodyVal = body();
@@ -90,18 +89,26 @@ export default function CreatePost() {
}, 5000); }, 5000);
}; };
// Set up autosave interval (2 minutes) // Debounced auto-save (1 second after last change)
autosaveInterval = setInterval( const debouncedAutoSave = debounce(autoSave, 1000);
() => {
autoSave(); // Track changes to trigger auto-save
}, createEffect(() => {
2 * 60 * 1000 // Track all relevant fields
) as unknown as number; const titleVal = title();
const subtitleVal = subtitle();
const bodyVal = body();
const tagsVal = tags();
const publishedVal = published();
// Only trigger auto-save if we have at least title and body
if (titleVal && bodyVal) {
debouncedAutoSave();
}
});
onCleanup(() => { onCleanup(() => {
if (autosaveInterval) { debouncedAutoSave.cancel();
clearInterval(autosaveInterval);
}
}); });
const handleBannerImageDrop = (acceptedFiles: File[]) => { const handleBannerImageDrop = (acceptedFiles: File[]) => {

View File

@@ -5,6 +5,7 @@ import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { getPrivilegeLevel, getUserID } from "~/server/utils";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { debounce } from "~/lib/client-utils";
import { ConnectionFactory } from "~/server/utils"; import { ConnectionFactory } from "~/server/utils";
import Dropzone from "~/components/blog/Dropzone"; import Dropzone from "~/components/blog/Dropzone";
import TextEditor from "~/components/blog/TextEditor"; import TextEditor from "~/components/blog/TextEditor";
@@ -60,8 +61,7 @@ export default function EditPost() {
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal(""); const [error, setError] = createSignal("");
const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false); const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false);
const [isInitialLoad, setIsInitialLoad] = createSignal(true);
let autosaveInterval: number | undefined;
// Populate form when data loads // Populate form when data loads
createEffect(() => { createEffect(() => {
@@ -78,6 +78,9 @@ export default function EditPost() {
const tagValues = (postData.tags as any[]).map((t) => t.value); const tagValues = (postData.tags as any[]).map((t) => t.value);
setTags(tagValues); setTags(tagValues);
} }
// Mark initial load as complete after data is loaded
setIsInitialLoad(false);
} }
}); });
@@ -127,18 +130,26 @@ export default function EditPost() {
}, 5000); }, 5000);
}; };
// Set up autosave interval (2 minutes) // Debounced auto-save (1 second after last change)
autosaveInterval = setInterval( const debouncedAutoSave = debounce(autoSave, 1000);
() => {
autoSave(); // Track changes to trigger auto-save (but not on initial load)
}, createEffect(() => {
2 * 60 * 1000 // Track all relevant fields
) as unknown as number; const titleVal = title();
const subtitleVal = subtitle();
const bodyVal = body();
const tagsVal = tags();
const publishedVal = published();
// Only trigger auto-save if not initial load and we have title
if (!isInitialLoad() && titleVal) {
debouncedAutoSave();
}
});
onCleanup(() => { onCleanup(() => {
if (autosaveInterval) { debouncedAutoSave.cancel();
clearInterval(autosaveInterval);
}
}); });
const handleBannerImageDrop = (acceptedFiles: File[]) => { const handleBannerImageDrop = (acceptedFiles: File[]) => {

View File

@@ -40,7 +40,7 @@ export default function Home() {
<div>My Collection of By-the-ways:</div> <div>My Collection of By-the-ways:</div>
</Typewriter> </Typewriter>
<Typewriter speed={50} keepAlive={false}> <Typewriter speed={50} keepAlive={false}>
<ul class="list-disc pl-8"> <ul class="list-disc pr-8">
<li>I use Neovim</li> <li>I use Neovim</li>
<li>I use Arch Linux</li> <li>I use Arch Linux</li>
<li>I use Rust</li> <li>I use Rust</li>