diff --git a/src/components/Typewriter.tsx b/src/components/Typewriter.tsx index 09e9d2e..6e42da1 100644 --- a/src/components/Typewriter.tsx +++ b/src/components/Typewriter.tsx @@ -12,9 +12,7 @@ export function Typewriter(props: { let cursorRef: HTMLDivElement | undefined; const [isTyping, setIsTyping] = createSignal(false); const [isDelaying, setIsDelaying] = createSignal(delay > 0); - const [keepAliveCountdown, setKeepAliveCountdown] = createSignal( - typeof keepAlive === "number" ? keepAlive : -1 - ); + const [shouldHide, setShouldHide] = createSignal(false); const resolved = children(() => props.children); onMount(() => { @@ -67,6 +65,12 @@ export function Typewriter(props: { cursorRef.style.height = `${firstChar.offsetHeight}px`; } + // Listen for animation end to hide cursor + const handleAnimationEnd = () => { + setShouldHide(true); + cursorRef?.removeEventListener("animationend", handleAnimationEnd); + }; + const startReveal = () => { setIsTyping(true); // Switch to typing cursor @@ -102,17 +106,17 @@ export function Typewriter(props: { // Typing finished, switch to block cursor setIsTyping(false); - // Start keepAlive countdown if it's a number + // Start keepAlive timer if it's a number if (typeof keepAlive === "number") { - const keepAliveInterval = setInterval(() => { - setKeepAliveCountdown((prev) => { - if (prev <= 1000) { - clearInterval(keepAliveInterval); - return 0; - } - return prev - 1000; - }); - }, 1000); + // Attach animation end listener + cursorRef?.addEventListener("animationend", handleAnimationEnd); + + // Trigger the animation with finite iteration count + const durationSeconds = keepAlive / 1000; + const iterations = Math.ceil(durationSeconds); + if (cursorRef) { + cursorRef.style.animation = `blink 1s ${iterations}`; + } } } }; @@ -132,22 +136,16 @@ export function Typewriter(props: { }); const getCursorClass = () => { - if (isDelaying()) return "cursor-block"; // Blinking block during delay - if (isTyping()) return "cursor-typing"; // Thin line while typing - - // After typing is done - if (typeof keepAlive === "number") { - return keepAliveCountdown() > 0 ? "cursor-block" : "hidden"; - } + if (isDelaying()) return "cursor-block"; + if (isTyping()) return "cursor-typing"; + if (shouldHide()) return "hidden"; return keepAlive ? "cursor-block" : "hidden"; }; return (
{resolved()} - - {" "} - +
); } diff --git a/src/lib/client-utils.ts b/src/lib/client-utils.ts index 12c0d9a..b4803db 100644 --- a/src/lib/client-utils.ts +++ b/src/lib/client-utils.ts @@ -80,3 +80,34 @@ export function insertSoftHyphens( }) .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 any>( + fn: T, + delay: number +): T & { cancel: () => void } { + let timeoutId: ReturnType | undefined; + + const debounced = function (this: any, ...args: Parameters) { + 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; +} diff --git a/src/routes/blog/create/index.tsx b/src/routes/blog/create/index.tsx index 9d1a9b9..2c47e39 100644 --- a/src/routes/blog/create/index.tsx +++ b/src/routes/blog/create/index.tsx @@ -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 { Title } from "@solidjs/meta"; import { createAsync } from "@solidjs/router"; import { getRequestEvent } from "solid-js/web"; import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { api } from "~/lib/api"; +import { debounce } from "~/lib/client-utils"; import Dropzone from "~/components/blog/Dropzone"; import TextEditor from "~/components/blog/TextEditor"; import TagMaker from "~/components/blog/TagMaker"; @@ -42,8 +43,6 @@ export default function CreatePost() { const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false); const [hasSaved, setHasSaved] = createSignal(false); - let autosaveInterval: number | undefined; - const autoSave = async () => { const titleVal = title(); const bodyVal = body(); @@ -90,18 +89,26 @@ export default function CreatePost() { }, 5000); }; - // Set up autosave interval (2 minutes) - autosaveInterval = setInterval( - () => { - autoSave(); - }, - 2 * 60 * 1000 - ) as unknown as number; + // Debounced auto-save (1 second after last change) + const debouncedAutoSave = debounce(autoSave, 1000); + + // Track changes to trigger auto-save + createEffect(() => { + // Track all relevant fields + 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(() => { - if (autosaveInterval) { - clearInterval(autosaveInterval); - } + debouncedAutoSave.cancel(); }); const handleBannerImageDrop = (acceptedFiles: File[]) => { diff --git a/src/routes/blog/edit/[id]/index.tsx b/src/routes/blog/edit/[id]/index.tsx index 1d85ae6..48a6798 100644 --- a/src/routes/blog/edit/[id]/index.tsx +++ b/src/routes/blog/edit/[id]/index.tsx @@ -5,6 +5,7 @@ import { createAsync } from "@solidjs/router"; import { getRequestEvent } from "solid-js/web"; import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { api } from "~/lib/api"; +import { debounce } from "~/lib/client-utils"; import { ConnectionFactory } from "~/server/utils"; import Dropzone from "~/components/blog/Dropzone"; import TextEditor from "~/components/blog/TextEditor"; @@ -60,8 +61,7 @@ export default function EditPost() { const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(""); const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false); - - let autosaveInterval: number | undefined; + const [isInitialLoad, setIsInitialLoad] = createSignal(true); // Populate form when data loads createEffect(() => { @@ -78,6 +78,9 @@ export default function EditPost() { const tagValues = (postData.tags as any[]).map((t) => t.value); setTags(tagValues); } + + // Mark initial load as complete after data is loaded + setIsInitialLoad(false); } }); @@ -127,18 +130,26 @@ export default function EditPost() { }, 5000); }; - // Set up autosave interval (2 minutes) - autosaveInterval = setInterval( - () => { - autoSave(); - }, - 2 * 60 * 1000 - ) as unknown as number; + // Debounced auto-save (1 second after last change) + const debouncedAutoSave = debounce(autoSave, 1000); + + // Track changes to trigger auto-save (but not on initial load) + createEffect(() => { + // Track all relevant fields + 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(() => { - if (autosaveInterval) { - clearInterval(autosaveInterval); - } + debouncedAutoSave.cancel(); }); const handleBannerImageDrop = (acceptedFiles: File[]) => { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 6b1d492..7e2d87c 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -40,7 +40,7 @@ export default function Home() {
My Collection of By-the-ways:
-
    +
    • I use Neovim
    • I use Arch Linux
    • I use Rust