mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
feat(saved-sql): improve SQL library save feedback with fly-in animation
This commit is contained in:
+109
-5
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ChevronsRight } from "@lucide/vue";
|
||||
import { ChevronsRight, FileText } from "@lucide/vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import AppToolbar from "@/components/layout/AppToolbar.vue";
|
||||
import AppTabBar from "@/components/layout/AppTabBar.vue";
|
||||
@@ -257,6 +257,16 @@ const queryEditorObjectSourceTarget = ref<{
|
||||
relationName?: string;
|
||||
} | null>(null);
|
||||
const showSaveSqlDialog = ref(false);
|
||||
const sqlLibrarySaveFeedbackId = ref(0);
|
||||
const saveSqlConfirmButtonRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null);
|
||||
const sqlLibraryFlyAnimation = ref<{
|
||||
id: number;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
} | null>(null);
|
||||
let sqlLibraryFlyAnimationTimer = 0;
|
||||
const showMultiDbExecuteDialog = ref(false);
|
||||
const multiExecuteSql = ref("");
|
||||
const multiExecuteSourceTabId = ref("");
|
||||
@@ -853,6 +863,58 @@ function defaultSavedSqlName(title: string) {
|
||||
return normalized.endsWith(".sql") ? normalized : `${normalized}.sql`;
|
||||
}
|
||||
|
||||
function notifySqlLibrarySaved() {
|
||||
sqlLibrarySaveFeedbackId.value += 1;
|
||||
toast(t("savedSql.saved"), 2000);
|
||||
}
|
||||
|
||||
function elementCenter(element: HTMLElement | null): { x: number; y: number } | null {
|
||||
if (!element || element.getClientRects().length === 0) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
|
||||
function saveSqlConfirmButtonElement(): HTMLElement | null {
|
||||
const button = saveSqlConfirmButtonRef.value;
|
||||
if (button instanceof HTMLElement) return button;
|
||||
return button?.$el instanceof HTMLElement ? button.$el : null;
|
||||
}
|
||||
|
||||
async function notifyNewSqlLibrarySaved(origin: { x: number; y: number } | null) {
|
||||
toast(t("savedSql.saved"), 2000);
|
||||
if (showSqlLibraryPanel.value) {
|
||||
sqlLibrarySaveFeedbackId.value += 1;
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
const target = document.querySelector<HTMLElement>("[data-sql-library-trigger]");
|
||||
const destination = elementCenter(target);
|
||||
if (!origin || !destination || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
sqlLibrarySaveFeedbackId.value += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(sqlLibraryFlyAnimationTimer);
|
||||
const animationId = Date.now();
|
||||
sqlLibraryFlyAnimation.value = {
|
||||
id: animationId,
|
||||
fromX: origin.x,
|
||||
fromY: origin.y,
|
||||
toX: destination.x,
|
||||
toY: destination.y,
|
||||
};
|
||||
sqlLibraryFlyAnimationTimer = window.setTimeout(() => finishSqlLibraryFlyAnimation(animationId), 1000);
|
||||
}
|
||||
|
||||
function finishSqlLibraryFlyAnimation(animationId: number) {
|
||||
if (sqlLibraryFlyAnimation.value?.id !== animationId) return;
|
||||
window.clearTimeout(sqlLibraryFlyAnimationTimer);
|
||||
sqlLibraryFlyAnimation.value = null;
|
||||
sqlLibrarySaveFeedbackId.value += 1;
|
||||
}
|
||||
|
||||
function canSaveSqlTab(tab: QueryTab): boolean {
|
||||
return !!tab.externalSqlPath || !!tab.sql.trim();
|
||||
}
|
||||
@@ -1087,7 +1149,7 @@ async function handleSaveTab(tabId: string) {
|
||||
});
|
||||
queryStore.linkSavedSql(tab.id, updated.id, updated.name);
|
||||
queryStore.markTabClean(tab);
|
||||
toast(t("savedSql.saved"), 2000);
|
||||
notifySqlLibrarySaved();
|
||||
completePendingTabSave(tabId);
|
||||
return;
|
||||
}
|
||||
@@ -1123,7 +1185,7 @@ async function openSaveSqlDialog() {
|
||||
});
|
||||
queryStore.linkSavedSql(tab.id, updated.id, updated.name);
|
||||
queryStore.markTabClean(tab);
|
||||
toast(t("savedSql.saved"), 2000);
|
||||
notifySqlLibrarySaved();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1204,6 +1266,7 @@ async function confirmSaveSqlToLibrary() {
|
||||
const tab = activeTab.value;
|
||||
const name = saveSqlName.value.trim();
|
||||
if (!tab || !tab.sql.trim() || !name) return;
|
||||
const flyOrigin = elementCenter(saveSqlConfirmButtonElement());
|
||||
try {
|
||||
const target = savedSqlTargetForSave(tab);
|
||||
const saved = await savedSqlStore.saveFile({
|
||||
@@ -1219,7 +1282,7 @@ async function confirmSaveSqlToLibrary() {
|
||||
queryStore.markTabClean(tab);
|
||||
showSaveSqlDialog.value = false;
|
||||
closePendingSavedTab();
|
||||
toast(t("savedSql.saved"), 2000);
|
||||
void notifyNewSqlLibrarySaved(flyOrigin);
|
||||
} catch (e: any) {
|
||||
toast(t("savedSql.saveFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
@@ -2459,6 +2522,7 @@ onUnmounted(() => {
|
||||
window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
|
||||
window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged);
|
||||
document.removeEventListener("contextmenu", handleContextMenu);
|
||||
window.clearTimeout(sqlLibraryFlyAnimationTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2473,6 +2537,7 @@ onUnmounted(() => {
|
||||
:show-ai-panel="showAiPanel"
|
||||
:show-history="showHistory"
|
||||
:show-sql-library="showSqlLibraryPanel"
|
||||
:sql-library-save-feedback-id="sqlLibrarySaveFeedbackId"
|
||||
:show-sql-file-panel="showSqlFilePanel"
|
||||
:show-driver-store="showDriverStore"
|
||||
:show-settings-page="showSettingsPage"
|
||||
@@ -2803,6 +2868,19 @@ onUnmounted(() => {
|
||||
<QuickOpenDialog :open="showQuickOpen" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<FileText
|
||||
v-if="sqlLibraryFlyAnimation"
|
||||
:key="sqlLibraryFlyAnimation.id"
|
||||
aria-hidden="true"
|
||||
class="sql-library-fly-icon pointer-events-none fixed left-0 top-0 z-[100000] h-6 w-6 text-primary"
|
||||
:style="{
|
||||
'--sql-library-fly-from-x': `${sqlLibraryFlyAnimation.fromX}px`,
|
||||
'--sql-library-fly-from-y': `${sqlLibraryFlyAnimation.fromY}px`,
|
||||
'--sql-library-fly-to-x': `${sqlLibraryFlyAnimation.toX}px`,
|
||||
'--sql-library-fly-to-y': `${sqlLibraryFlyAnimation.toY}px`,
|
||||
}"
|
||||
@animationend="finishSqlLibraryFlyAnimation(sqlLibraryFlyAnimation.id)"
|
||||
/>
|
||||
<Transition name="toast">
|
||||
<div v-if="toastVisible" class="fixed bottom-6 inset-x-0 w-max max-w-[90vw] sm:max-w-3xl mx-auto z-99999 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg select-text whitespace-pre-wrap break-words">
|
||||
{{ toastMessage }}
|
||||
@@ -2857,7 +2935,7 @@ onUnmounted(() => {
|
||||
<DialogFooter>
|
||||
<Button v-if="isDesktop" variant="secondary" @click="saveActiveSqlAsLocalFile">{{ t("savedSql.saveToFile") }}</Button>
|
||||
<Button variant="outline" @click="cancelPendingSaveAndClose()">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="saveSqlFolderCreationPending || !saveSqlName.trim()" @click="confirmSaveSqlToLibrary">{{ t("savedSql.save") }}</Button>
|
||||
<Button ref="saveSqlConfirmButtonRef" :disabled="saveSqlFolderCreationPending || !saveSqlName.trim()" @click="confirmSaveSqlToLibrary">{{ t("savedSql.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -2895,6 +2973,26 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes sql-library-fly-in {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(var(--sql-library-fly-from-x), var(--sql-library-fly-from-y), 0) translate(-50%, -50%) scale(0.78);
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
transform: translate3d(var(--sql-library-fly-to-x), var(--sql-library-fly-to-y), 0) translate(-50%, -50%) scale(0.5);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(var(--sql-library-fly-to-x), var(--sql-library-fly-to-y), 0) translate(-50%, -50%) scale(0.42);
|
||||
}
|
||||
}
|
||||
|
||||
.sql-library-fly-icon {
|
||||
animation: sql-library-fly-in 560ms cubic-bezier(0.45, 0, 0.55, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: 0.25s ease;
|
||||
@@ -2905,4 +3003,10 @@ onUnmounted(() => {
|
||||
opacity: 0;
|
||||
transform: translateY(100%) scale(0.95);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sql-library-fly-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,7 @@ const props = defineProps<{
|
||||
showAiPanel: boolean;
|
||||
showHistory: boolean;
|
||||
showSqlLibrary: boolean;
|
||||
sqlLibrarySaveFeedbackId: number;
|
||||
showSqlFilePanel: boolean;
|
||||
showDriverStore: boolean;
|
||||
showSettingsPage: boolean;
|
||||
@@ -64,6 +65,41 @@ const settingsStore = useSettingsStore();
|
||||
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
|
||||
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
|
||||
const checkingUpdates = computed(() => props.checkingUpdates);
|
||||
const sqlLibrarySaveFeedbackActive = ref(false);
|
||||
const SQL_LIBRARY_BOOKMARK_PATH = "M10 2 L10 10 L13 7 L16 10 L16 2";
|
||||
const SQL_LIBRARY_CHECK_PATH = "M9 9.5 L9 9.5 L11 11.5 L15 7.5 L15 7.5";
|
||||
type SvgAnimateElement = SVGElement & { beginElement?: () => void };
|
||||
const sqlLibraryIconPathRef = ref<SVGPathElement | null>(null);
|
||||
const sqlLibraryMorphToCheckRef = ref<SvgAnimateElement | null>(null);
|
||||
const sqlLibraryMorphToBookmarkRef = ref<SvgAnimateElement | null>(null);
|
||||
let sqlLibrarySaveFeedbackTimer = 0;
|
||||
|
||||
watch(
|
||||
() => props.sqlLibrarySaveFeedbackId,
|
||||
async (id, previousId) => {
|
||||
if (id <= 0 || id === previousId) return;
|
||||
sqlLibrarySaveFeedbackActive.value = false;
|
||||
await nextTick();
|
||||
sqlLibrarySaveFeedbackActive.value = true;
|
||||
window.clearTimeout(sqlLibrarySaveFeedbackTimer);
|
||||
sqlLibrarySaveFeedbackTimer = window.setTimeout(() => {
|
||||
sqlLibrarySaveFeedbackActive.value = false;
|
||||
}, 850);
|
||||
},
|
||||
);
|
||||
|
||||
watch(sqlLibrarySaveFeedbackActive, async (active) => {
|
||||
await nextTick();
|
||||
const path = sqlLibraryIconPathRef.value;
|
||||
const animation = active ? sqlLibraryMorphToCheckRef.value : sqlLibraryMorphToBookmarkRef.value;
|
||||
const destination = active ? SQL_LIBRARY_CHECK_PATH : SQL_LIBRARY_BOOKMARK_PATH;
|
||||
if (!path) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !animation?.beginElement) {
|
||||
path.setAttribute("d", destination);
|
||||
return;
|
||||
}
|
||||
animation.beginElement();
|
||||
});
|
||||
|
||||
const themeTriggerIcon = computed(() => {
|
||||
if (isSystemAppThemeMode(props.themeMode)) return SunMoon;
|
||||
@@ -351,6 +387,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
if (toolbarLayoutRaf) cancelAnimationFrame(toolbarLayoutRaf);
|
||||
if (trafficLightSyncRaf) cancelAnimationFrame(trafficLightSyncRaf);
|
||||
window.clearTimeout(sqlLibrarySaveFeedbackTimer);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
});
|
||||
@@ -559,8 +596,27 @@ const toolbarStyle = computed(() => {
|
||||
|
||||
<Tooltip v-if="toolbarItems.sqlLibrary">
|
||||
<TooltipTrigger as-child>
|
||||
<Button v-show="isRightItemVisible('sqlLibrary')" variant="ghost" size="icon" class="h-8 w-8 shrink-0" :class="{ 'bg-accent': showSqlLibrary }" @click="emit('toggle-sql-library')">
|
||||
<BookMarked class="h-4 w-4" />
|
||||
<Button v-show="isRightItemVisible('sqlLibrary')" data-sql-library-trigger variant="ghost" size="icon" class="relative h-8 w-8 shrink-0" :class="{ 'bg-accent': showSqlLibrary, 'sql-library-save-feedback': sqlLibrarySaveFeedbackActive }" @click="emit('toggle-sql-library')">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="sql-library-icon h-4 w-4"
|
||||
:class="{ 'sql-library-save-feedback-icon text-primary': sqlLibrarySaveFeedbackActive }"
|
||||
>
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
|
||||
<path ref="sqlLibraryIconPathRef" :d="SQL_LIBRARY_BOOKMARK_PATH">
|
||||
<animate ref="sqlLibraryMorphToCheckRef" attributeName="d" :values="`${SQL_LIBRARY_BOOKMARK_PATH};${SQL_LIBRARY_CHECK_PATH}`" dur="180ms" begin="indefinite" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.22 1 0.36 1" />
|
||||
<animate ref="sqlLibraryMorphToBookmarkRef" attributeName="d" :values="`${SQL_LIBRARY_CHECK_PATH};${SQL_LIBRARY_BOOKMARK_PATH}`" dur="180ms" begin="indefinite" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.22 1 0.36 1" />
|
||||
</path>
|
||||
</svg>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("sqlLibrary.title") }}</TooltipContent>
|
||||
@@ -630,3 +686,58 @@ const toolbarStyle = computed(() => {
|
||||
<WindowControls v-if="showControls" :is-maximized="isMaximized" @minimize="minimize" @toggle-maximize="toggleMaximize" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes sql-library-save-confirm-button {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
36% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--primary) 38%, transparent),
|
||||
0 0 0 3px color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sql-library-save-confirm-icon {
|
||||
0% {
|
||||
opacity: 0.75;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
38% {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px) scale(1.12);
|
||||
}
|
||||
68% {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sql-library-save-feedback {
|
||||
animation: sql-library-save-confirm-button 760ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sql-library-icon {
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.sql-library-save-feedback-icon {
|
||||
animation: sql-library-save-confirm-icon 760ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sql-library-save-feedback,
|
||||
.sql-library-save-feedback-icon {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sql-library-icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user