feat(ui): remember main-window dialog positions across sessions

Add an optional positionKey prop to ModalDialog that persists x/y in
localStorage under lap.dialog.pos.<key>. Position is restored on next
mount (clamped to viewport) and saved once on unmount, so drag hot path
stays zero-IO. First open with no saved data still centers as before.

Wired for 11 main-window dialogs (manage-libraries, album-edit,
import-organize, smart-album-edit, move-to, file-conflict,
index-recovery, tag-picker, edit-tag, edit-collection, edit-comment).
Cross-window components (MessageBox / TaggingDialog /
AddToCollectionDialog) forward the prop; independent-window call sites
in Settings and ImageViewer keep the original centered behavior.
This commit is contained in:
julyx10
2026-09-18 11:54:40 +08:00
parent 3c70cb9386
commit 3f9c83aa29
13 changed files with 76 additions and 11 deletions
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="$t('collection.edit_collections')" :width="500" @cancel="close">
<ModalDialog :title="$t('collection.edit_collections')" :width="500" :position-key="positionKey" @cancel="close">
<section class="space-y-3">
<div class="flex items-center gap-2">
<div class="grow h-8 flex items-center rounded-box overflow-hidden bg-base-100 border border-neutral-content/30 focus-within:border-primary">
@@ -131,7 +131,7 @@ import MessageBox from '@/components/MessageBox.vue';
import ModalDialog from '@/components/ModalDialog.vue';
import { useUIStore } from '@/stores/uiStore';
const props = defineProps<{ fileIds: number[] }>();
const props = defineProps<{ fileIds: number[]; positionKey?: string }>();
const emit = defineEmits(['applied', 'cancel', 'deleted']);
const collections = ref<any[]>([]);
const query = ref('');
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="isNewAlbum ? $t('album.edit.title_add') : $t('album.edit.title')" @cancel="clickCancel">
<ModalDialog :title="isNewAlbum ? $t('album.edit.title_add') : $t('album.edit.title')" position-key="album-edit" @cancel="clickCancel">
<section class="space-y-2 select-none">
<!-- General Information -->
+3
View File
@@ -608,6 +608,7 @@
<TaggingDialog
v-if="showTaggingDialog"
:fileIds="fileIdsToTag"
position-key="edit-tag"
@ok="updateFileHasTags"
@states-changed="syncTagStates"
@cancel="showTaggingDialog = false"
@@ -617,6 +618,7 @@
<AddToCollectionDialog
v-if="showAddToCollectionDialog"
:fileIds="fileIdsToAddToCollection"
position-key="edit-collection"
@applied="handleCollectionsAdded"
@deleted="handleCollectionDeleted"
@cancel="showAddToCollectionDialog = false"
@@ -632,6 +634,7 @@
:multiLine="true"
:OkText="$t('msgbox.ok')"
:cancelText="$t('msgbox.cancel')"
position-key="edit-comment"
@ok="onEditComment"
@cancel="showCommentMsgbox = false"
/>
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="$t('msgbox.file_conflict.title')" :width="440" @cancel="resolve('skip')">
<ModalDialog :title="$t('msgbox.file_conflict.title')" :width="440" position-key="file-conflict" @cancel="resolve('skip')">
<p class="text-sm whitespace-pre-line wrap-break-word">
{{ $t('msgbox.file_conflict.message', { name, destination }) }}
</p>
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="$t('import_organize.import')" :width="520" @cancel="close">
<ModalDialog :title="$t('import_organize.import')" :width="520" position-key="import-organize" @cancel="close">
<div class="space-y-2 select-none">
<section class="rounded-box p-2 space-y-2 bg-base-300/30 border border-base-content/5 shadow-sm">
<div class="font-bold uppercase text-[10px] tracking-widest text-base-content/30">{{ $t('import_organize.section_folders') }}</div>
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="title" :width="440" @cancel="clickCancel">
<ModalDialog :title="title" :width="440" position-key="index-recovery" @cancel="clickCancel">
<div class="flex flex-col gap-2 select-none">
<div v-if="message" class="text-sm wrap-break-word text-base-content/30">
{{ message }}
@@ -3,6 +3,7 @@
:title="$t('msgbox.manage_libraries.title')"
:width="600"
:height="400"
position-key="manage-libraries"
@cancel="clickCancel"
>
<div class="flex flex-col flex-1 min-h-0 border border-base-content/5 bg-base-300/30 shadow-sm rounded-box overflow-hidden relative">
+7 -1
View File
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="title" :width="400" @cancel="clickCancel">
<ModalDialog :title="title" :width="400" :position-key="positionKey" @cancel="clickCancel">
<div v-if="message" class="text-sm whitespace-pre-line wrap-break-word select-none">
{{ message }}
</div>
@@ -141,6 +141,12 @@ const props = defineProps({
isLoading: {
type: Boolean,
default: false
},
// Optional key for persisting dialog position across sessions.
// When empty, dialog always centers on mount (backward compatible).
positionKey: {
type: String,
default: ''
}
});
+50 -1
View File
@@ -48,6 +48,12 @@ const props = defineProps({
height: {
type: Number,
default: undefined // undefined means no height limit
},
// When provided, dialog position is persisted in localStorage under this key
// and restored on next mount. Without it, dialog always centers on mount.
positionKey: {
type: String,
default: ''
}
});
@@ -124,10 +130,50 @@ const centerDialog = () => {
}
};
const POS_PREFIX = 'lap.dialog.pos.';
// Guards against persisting the initial (0,0) if the dialog unmounts
// before the nextTick positioning pass has run.
let positionReady = false;
const loadPosition = (): boolean => {
if (!props.positionKey) return false;
try {
const raw = localStorage.getItem(POS_PREFIX + props.positionKey);
if (!raw) return false;
const parsed = JSON.parse(raw);
if (typeof parsed?.x !== 'number' || typeof parsed?.y !== 'number') return false;
if (!Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) return false;
x.value = parsed.x;
y.value = parsed.y;
return true;
} catch {
return false;
}
};
const savePosition = () => {
if (!props.positionKey || !positionReady) return;
try {
localStorage.setItem(
POS_PREFIX + props.positionKey,
JSON.stringify({ x: Math.round(x.value), y: Math.round(y.value) })
);
} catch {
// ignore quota / private-mode errors
}
};
onMounted(() => {
window.addEventListener('resize', clampPosition);
nextTick(() => {
centerDialog(); // Center on mount
// Restore saved position if available, otherwise center.
// Either way, clamp to viewport to handle resolution / display changes.
if (!loadPosition()) {
centerDialog();
} else {
clampPosition();
}
positionReady = true;
});
});
@@ -138,6 +184,9 @@ onUnmounted(() => {
if (isDragging.value) {
dragEnd();
}
// Persist final position once per dialog session (on close), not per drag.
savePosition();
});
const clampPosition = () => {
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="title" :width="500" @cancel="clickCancel">
<ModalDialog :title="title" :width="500" position-key="move-to" @cancel="clickCancel">
<!-- select album and folder -->
<div class="h-[400px] overflow-auto">
<AlbumList ref="albumListRef"
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="isNew ? $t('album.smart_edit.title_add') : $t('album.smart_edit.title_edit')" :width="620" @cancel="clickCancel">
<ModalDialog :title="isNew ? $t('album.smart_edit.title_add') : $t('album.smart_edit.title_edit')" :width="620" position-key="smart-album-edit" @cancel="clickCancel">
<section
class="min-h-0 overflow-y-auto pr-1"
@dragenter.stop
+1
View File
@@ -253,6 +253,7 @@
: $t('msgbox.new_tag.title')
"
:width="400"
position-key="tag-picker"
@cancel="editor = null"
>
<form
+6 -1
View File
@@ -1,5 +1,5 @@
<template>
<ModalDialog :title="$t('tag.edit_tag')" :width="500" @cancel="clickCancel">
<ModalDialog :title="$t('tag.edit_tag')" :width="500" :position-key="positionKey" @cancel="clickCancel">
<section class="space-y-3">
<div class="flex items-center gap-2">
<div
@@ -273,6 +273,11 @@ const props = defineProps({
type: Array as () => number[],
default: () => [],
},
// Optional key for persisting dialog position across sessions.
positionKey: {
type: String,
default: '',
},
});
const emit = defineEmits(["ok", "cancel", "states-changed"]);