diff --git a/src/alpine/index.ts b/src/alpine/index.ts index 00b544a..22699c6 100644 --- a/src/alpine/index.ts +++ b/src/alpine/index.ts @@ -3,6 +3,7 @@ import type { Alpine } from "alpinejs"; import { headerMenu } from "./header-menu"; import { postLineNum } from "./post-line-num"; import { postToc } from "./post-toc"; +import { initDevcmPromptLoading } from "./prompt-loading"; import { themeComment } from "./theme-comment"; import { themeMode } from "./theme-mode"; import { upvote } from "./upvote"; @@ -20,6 +21,7 @@ export const bootDevcmAlpine = (Alpine: Alpine) => { Alpine.data("themeComment", themeComment); initDevcmActivityActions(); + initDevcmPromptLoading(); initTypedText(); Alpine.start(); diff --git a/src/alpine/prompt-loading.ts b/src/alpine/prompt-loading.ts new file mode 100644 index 0000000..f41d420 --- /dev/null +++ b/src/alpine/prompt-loading.ts @@ -0,0 +1,181 @@ +const PROMPT_LINE_SELECTOR = ".devcm-prompt-stream__line.dividing"; +const LOADING_CLASS = "devcm-prompt-stream__line--loading"; +const FINISHING_CLASS = "devcm-prompt-stream__line--finishing"; +const STORAGE_KEY = "devcm:prompt-loading"; +const ROUTE_PENDING_VALUE = "route"; +const FINISH_ANIMATION_NAMES = ["devcm-route-tail-shrink", "devcm-route-finish-state"]; + +let removeFinishListener: (() => void) | null = null; + +const getPromptLine = () => document.querySelector(PROMPT_LINE_SELECTOR); + +const clearFinishListener = () => { + removeFinishListener?.(); + removeFinishListener = null; +}; + +const setPendingRoute = () => { + try { + window.sessionStorage.setItem(STORAGE_KEY, ROUTE_PENDING_VALUE); + } catch { + // Ignore storage failures in private browsing or restricted environments. + } +}; + +const consumePendingRoute = () => { + try { + const isPending = window.sessionStorage.getItem(STORAGE_KEY) === ROUTE_PENDING_VALUE; + window.sessionStorage.removeItem(STORAGE_KEY); + return isPending; + } catch { + return false; + } +}; + +const resetPromptLoading = () => { + const line = getPromptLine(); + + clearFinishListener(); + + if (!line) { + return; + } + + line.classList.remove(LOADING_CLASS, FINISHING_CLASS); + delete line.dataset.devcmLoading; +}; + +const startPromptLoading = () => { + const line = getPromptLine(); + + clearFinishListener(); + + if (!line) { + return; + } + + line.classList.remove(FINISHING_CLASS); + line.classList.add(LOADING_CLASS); + line.dataset.devcmLoading = "true"; +}; + +const startPromptFinishing = () => { + const line = getPromptLine(); + + clearFinishListener(); + + if (!line) { + return; + } + + const handleAnimationEnd = (event: AnimationEvent) => { + if (FINISH_ANIMATION_NAMES.includes(event.animationName)) { + resetPromptLoading(); + } + }; + + line.classList.remove(LOADING_CLASS); + line.classList.add(FINISHING_CLASS); + line.dataset.devcmLoading = "done"; + line.addEventListener("animationend", handleAnimationEnd); + removeFinishListener = () => line.removeEventListener("animationend", handleAnimationEnd); +}; + +const finishWhenReady = () => { + if (document.readyState === "complete") { + startPromptFinishing(); + return; + } + + window.addEventListener("load", startPromptFinishing, { once: true }); +}; + +const isReloadNavigation = () => { + if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") { + return false; + } + + const [navigationEntry] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[]; + return navigationEntry?.type === "reload"; +}; + +const resumeInitialPromptLoading = () => { + if (consumePendingRoute()) { + startPromptFinishing(); + return; + } + + if (!isReloadNavigation()) { + return; + } + + startPromptLoading(); + finishWhenReady(); +}; + +const hasModifiedClick = (event: MouseEvent) => event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + +const isNavigableAnchor = (anchor: HTMLAnchorElement, event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0 || hasModifiedClick(event)) { + return false; + } + + if (anchor.hasAttribute("download")) { + return false; + } + + const target = anchor.getAttribute("target"); + if (target && target.toLowerCase() !== "_self") { + return false; + } + + const rawHref = anchor.getAttribute("href"); + if (!rawHref || rawHref === "#" || rawHref.startsWith("#")) { + return false; + } + + const url = new URL(anchor.href, window.location.href); + if (url.origin !== window.location.origin) { + return false; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + + const current = new URL(window.location.href); + return url.pathname !== current.pathname || url.search !== current.search; +}; + +export const initDevcmPromptLoading = () => { + resumeInitialPromptLoading(); + + document.addEventListener("click", (event) => { + const target = event.target; + + if (!(event instanceof MouseEvent) || !(target instanceof Element)) { + return; + } + + const anchor = target.closest("a[href]"); + + if (!anchor || !isNavigableAnchor(anchor, event)) { + return; + } + + setPendingRoute(); + startPromptLoading(); + }); + + window.addEventListener("beforeunload", startPromptLoading); + window.addEventListener("pagehide", (event) => { + if (!event.persisted) { + startPromptLoading(); + } + }); + window.addEventListener("pageshow", (event) => { + if (event.persisted) { + resetPromptLoading(); + } + }); +}; diff --git a/src/styles/devcm-shell.scss b/src/styles/devcm-shell.scss index 770877c..827b1b0 100644 --- a/src/styles/devcm-shell.scss +++ b/src/styles/devcm-shell.scss @@ -10,7 +10,7 @@ body.devcm-body { min-height: 0; background: radial-gradient(circle at 46% 0%, rgba(104, 243, 127, 0.11), transparent 28%), radial-gradient(circle at 85% 18%, rgba(255, 176, 0, 0.1), transparent 28%), - linear-gradient(180deg, #090b09 0%, #050706 100%); + linear-gradient(180deg, #0f130f 0%, #090c09 100%); color: var(--devcm-text); font-family: var(--devcm-mono); overflow: hidden; @@ -58,18 +58,18 @@ body.devcm-body::after { } body.devcm-body::before { - opacity: 0.16; + opacity: 0.24; background: repeating-linear-gradient( 0deg, - rgba(255, 255, 255, 0.035) 0, - rgba(255, 255, 255, 0.035) 1px, + rgba(255, 255, 255, 0.055) 0, + rgba(255, 255, 255, 0.055) 1px, transparent 1px, transparent 3px ), repeating-linear-gradient( 90deg, - rgba(255, 176, 0, 0.025) 0, - rgba(255, 176, 0, 0.025) 1px, + rgba(255, 176, 0, 0.045) 0, + rgba(255, 176, 0, 0.045) 1px, transparent 1px, transparent 28px ); @@ -77,8 +77,8 @@ body.devcm-body::before { } body.devcm-body::after { - background: radial-gradient(ellipse at center, transparent 48%, rgba(0, 0, 0, 0.52) 100%); - opacity: 0.62; + background: radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.38) 100%); + opacity: 0.46; } .devcm-shell { @@ -105,9 +105,9 @@ html[data-color-scheme="light"] .devcm-shell { min-height: 0; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; - background: linear-gradient(90deg, rgba(255, 176, 0, 0.04) 1px, transparent 1px) 0 0 / 28px 28px, - linear-gradient(180deg, rgba(255, 176, 0, 0.032) 1px, transparent 1px) 0 0 / 28px 28px, - linear-gradient(180deg, rgba(8, 10, 8, 0.98), rgba(6, 8, 6, 0.98)); + background: linear-gradient(90deg, rgba(255, 176, 0, 0.064) 1px, transparent 1px) 0 0 / 28px 28px, + linear-gradient(180deg, rgba(104, 243, 127, 0.04) 1px, transparent 1px) 0 0 / 28px 28px, + linear-gradient(180deg, rgba(13, 17, 13, 0.96), rgba(10, 13, 10, 0.96)); } html[data-color-scheme="light"] .devcm-app { diff --git a/src/styles/devcm-tokens.scss b/src/styles/devcm-tokens.scss index 38d6ccc..be98f82 100644 --- a/src/styles/devcm-tokens.scss +++ b/src/styles/devcm-tokens.scss @@ -1,11 +1,11 @@ :root, html[data-color-scheme="dark"] { color-scheme: dark; - --devcm-bg: #070908; - --devcm-bg-deep: #040504; - --devcm-panel: rgba(12, 14, 11, 0.94); - --devcm-panel-soft: rgba(18, 20, 14, 0.88); - --devcm-panel-warm: rgba(22, 18, 10, 0.74); + --devcm-bg: #0e120e; + --devcm-bg-deep: #090c09; + --devcm-panel: rgba(18, 22, 16, 0.94); + --devcm-panel-soft: rgba(24, 28, 19, 0.88); + --devcm-panel-warm: rgba(31, 25, 14, 0.76); --devcm-line: rgba(255, 176, 0, 0.22); --devcm-line-strong: rgba(255, 176, 0, 0.48); --devcm-text: #ddd6b9; diff --git a/src/styles/header-dividing.scss b/src/styles/header-dividing.scss index a8a8d92..11e2085 100644 --- a/src/styles/header-dividing.scss +++ b/src/styles/header-dividing.scss @@ -53,6 +53,55 @@ z-index: 10; transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); } + + &.devcm-prompt-stream__line--loading { + opacity: 1; + + &::after { + left: 0; + width: 100%; + opacity: 0.78; + filter: blur(3px); + background: linear-gradient( + 90deg, + rgba(255, 93, 93, 0.04) 0%, + rgba(255, 93, 93, 0.28) 52%, + rgba(255, 93, 93, 0.46) 78%, + rgba(255, 93, 93, 0.72) 100% + ); + animation-name: devcm-route-fill-grow, devcm-route-loading-pulse; + animation-duration: 1.28s, 0.72s; + animation-timing-function: cubic-bezier(0.18, 0.72, 0.22, 1), ease-in-out; + animation-fill-mode: forwards, none; + animation-iteration-count: 1, infinite; + animation-direction: normal, alternate; + transform: translateY(-50%) scaleX(0); + transform-origin: left center; + transition: opacity 0.18s ease; + will-change: transform, opacity, filter; + } + } + + &.devcm-prompt-stream__line--finishing { + opacity: 1; + animation: devcm-route-finish-state 0.86s linear forwards; + + &::after { + left: 0; + width: 100%; + opacity: 0.66; + filter: blur(3px); + clip-path: inset(0 0 0 0); + background: linear-gradient(90deg, rgba(255, 93, 93, 0.05) 0%, rgba(255, 93, 93, 0.62) 70%, transparent 100%); + animation-name: devcm-route-tail-shrink; + animation-duration: 0.86s; + animation-timing-function: cubic-bezier(0.48, 0, 0.22, 1); + animation-fill-mode: forwards; + transform: translateY(-50%); + transition: opacity 0.18s ease; + will-change: clip-path, opacity, filter; + } + } } // 虚线流动动画 - 模拟加载进度 @@ -84,3 +133,39 @@ opacity: 0; } } + +@keyframes devcm-route-loading-pulse { + 0% { + opacity: 0.58; + } + 100% { + opacity: 0.78; + } +} + +@keyframes devcm-route-fill-grow { + 0% { + transform: translateY(-50%) scaleX(0); + } + 100% { + transform: translateY(-50%) scaleX(1); + } +} + +@keyframes devcm-route-tail-shrink { + 0% { + clip-path: inset(0 0 0 0); + opacity: 0.66; + } + 100% { + clip-path: inset(0 0 0 100%); + opacity: 0; + } +} + +@keyframes devcm-route-finish-state { + 0%, + 100% { + opacity: 1; + } +} diff --git a/theme.yaml b/theme.yaml index 1383540..3f472b9 100644 --- a/theme.yaml +++ b/theme.yaml @@ -13,5 +13,5 @@ spec: repo: https://git.dev.cm/theme-terminal settingName: "theme-terminal-setting" configMapName: "theme-terminal-configMap" - version: 2.0.8 + version: 2.0.9 require: ">=2.22.0"