feat(Dialog): enhance dialog functionality with resizing and improved positioning
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s

- Added support for dialog resizing, allowing users to adjust the size of non-modal dialogs.
- Implemented dynamic positioning for dialogs based on user-defined coordinates or calculated defaults.
- Updated Vuex store to manage dialog dimensions and positions effectively.
- Enhanced the BaseDialog component with resize handles and improved drag-and-drop functionality.
- Updated localization strings to include new resize functionality for better user guidance.
This commit is contained in:
Torsten Schulz (local)
2026-04-08 15:09:47 +02:00
parent 4f8e2fee89
commit 951842c824
4 changed files with 325 additions and 33 deletions

View File

@@ -5,6 +5,7 @@
@click.self="handleOverlayClick"
>
<div
ref="dialogContainer"
:class="['dialog-container', sizeClass]"
:style="dialogStyle"
@mousedown="handleMouseDown"
@@ -17,9 +18,10 @@
>
<h3 class="dialog-title">{{ title }}</h3>
<slot name="header-actions"></slot>
<div class="dialog-controls">
<div class="dialog-controls" @mousedown.stop>
<button
v-if="minimizable"
type="button"
@click="$emit('minimize')"
class="control-btn minimize-btn"
:title="$t('baseDialog.minimize')"
@@ -28,6 +30,7 @@
</button>
<button
v-if="closable"
type="button"
@click="handleClose"
class="control-btn close-btn"
:title="$t('baseDialog.close')"
@@ -46,6 +49,13 @@
<div v-if="$slots.footer" class="dialog-footer">
<slot name="footer"></slot>
</div>
<div
v-if="!isModal && resizable"
class="dialog-resize-handle"
:title="$t('baseDialog.resize')"
@mousedown.stop="startResize"
/>
</div>
</div>
</template>
@@ -116,6 +126,12 @@ export default {
type: Boolean,
default: true
},
/** Nicht-modale Dialoge: Ecke zum Vergrößern/Verkleinern */
resizable: {
type: Boolean,
default: true
},
// Schließen bei Overlay-Klick (nur bei modalen Dialogen)
closeOnOverlay: {
@@ -129,7 +145,11 @@ export default {
localPosition: { ...this.position },
isDragging: false,
dragStartX: 0,
dragStartY: 0
dragStartY: 0,
localWidth: null,
localHeight: null,
isResizing: false,
resizeStart: null,
};
},
@@ -158,6 +178,14 @@ export default {
if (!this.isModal) {
style.left = `${this.localPosition.x}px`;
style.top = `${this.localPosition.y}px`;
if (this.localWidth != null) {
style.width = `${this.localWidth}px`;
style.maxWidth = 'none';
}
if (this.localHeight != null) {
style.height = `${this.localHeight}px`;
style.maxHeight = 'none';
}
}
return style;
@@ -188,7 +216,11 @@ export default {
startDrag(event) {
if (!this.isDraggable) return;
if (event.button !== 0) return;
if (event.target.closest('button, a, input, select, textarea, [contenteditable="true"]')) {
return;
}
this.isDragging = true;
this.dragStartX = event.clientX - this.localPosition.x;
this.dragStartY = event.clientY - this.localPosition.y;
@@ -201,9 +233,20 @@ export default {
onDrag(event) {
if (!this.isDragging) return;
this.localPosition.x = event.clientX - this.dragStartX;
this.localPosition.y = event.clientY - this.dragStartY;
let nx = event.clientX - this.dragStartX;
let ny = event.clientY - this.dragStartY;
const el = this.$refs.dialogContainer;
if (el && typeof window !== 'undefined') {
const w = el.offsetWidth;
const h = el.offsetHeight;
const pad = 8;
nx = Math.min(Math.max(pad, nx), window.innerWidth - w - pad);
ny = Math.min(Math.max(pad, ny), window.innerHeight - h - pad);
}
this.localPosition.x = nx;
this.localPosition.y = ny;
this.$emit('update:position', { ...this.localPosition });
},
@@ -212,7 +255,48 @@ export default {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
}
},
startResize(event) {
if (this.isModal || !this.resizable) return;
if (event.button !== 0) return;
const el = this.$refs.dialogContainer;
if (!el) return;
const rect = el.getBoundingClientRect();
this.isResizing = true;
this.resizeStart = {
mouseX: event.clientX,
mouseY: event.clientY,
width: rect.width,
height: rect.height,
};
document.addEventListener('mousemove', this.onResizeMove);
document.addEventListener('mouseup', this.stopResize);
event.preventDefault();
},
onResizeMove(event) {
if (!this.isResizing || !this.resizeStart || typeof window === 'undefined') return;
const dx = event.clientX - this.resizeStart.mouseX;
const dy = event.clientY - this.resizeStart.mouseY;
let w = this.resizeStart.width + dx;
let h = this.resizeStart.height + dy;
const minW = 260;
const minH = 160;
const maxW = window.innerWidth - this.localPosition.x - 8;
const maxH = window.innerHeight - this.localPosition.y - 8;
w = Math.min(Math.max(minW, w), maxW);
h = Math.min(Math.max(minH, h), maxH);
this.localWidth = w;
this.localHeight = h;
},
stopResize() {
this.isResizing = false;
this.resizeStart = null;
document.removeEventListener('mousemove', this.onResizeMove);
document.removeEventListener('mouseup', this.stopResize);
},
},
watch: {
@@ -228,6 +312,9 @@ export default {
if (this.isDragging) {
this.stopDrag();
}
if (this.isResizing) {
this.stopResize();
}
}
};
</script>
@@ -265,6 +352,7 @@ export default {
/* Dialog Container */
.dialog-container {
position: relative;
background: white;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
@@ -283,6 +371,8 @@ export default {
/* Non-Modal Dialog Container */
.non-modal-dialog .dialog-container {
position: absolute;
min-width: 260px;
min-height: 160px;
}
/* Größen */
@@ -321,7 +411,34 @@ export default {
}
.dialog-header.draggable {
cursor: move;
cursor: grab;
}
.dialog-header.draggable:active {
cursor: grabbing;
}
.dialog-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(
135deg,
transparent 50%,
rgba(0, 0, 0, 0.12) 50%
);
border-bottom-right-radius: 8px;
}
.dialog-resize-handle:hover {
background: linear-gradient(
135deg,
transparent 45%,
rgba(47, 122, 95, 0.35) 45%
);
}
.dialog-title {

View File

@@ -21,11 +21,7 @@
v-for="dialog in activeDialogs"
:key="dialog.id"
class="dialog-window"
:style="{
left: dialog.position.x + 'px',
top: dialog.position.y + 'px',
zIndex: dialog.zIndex
}"
:style="dialogWindowStyle(dialog)"
@mousedown="bringToFront(dialog.id)"
>
<div class="dialog-header" @mousedown="startDrag(dialog.id, $event)">
@@ -39,14 +35,19 @@
@action="handleHeaderAction"
/>
</div>
<div class="dialog-controls">
<button @click="minimizeDialog(dialog.id)" class="control-btn minimize-btn" :title="$t('dialogManager.minimize')"></button>
<button @click="closeDialog(dialog.id)" class="control-btn close-btn" :title="$t('dialogManager.close')">×</button>
<div class="dialog-controls" @mousedown.stop>
<button type="button" @click="minimizeDialog(dialog.id)" class="control-btn minimize-btn" :title="$t('dialogManager.minimize')"></button>
<button type="button" @click="closeDialog(dialog.id)" class="control-btn close-btn" :title="$t('dialogManager.close')">×</button>
</div>
</div>
<div class="dialog-content">
<component :is="getDialogComponent(dialog.component)" v-bind="dialog.props" @close="() => closeDialog(dialog.id)" />
</div>
<div
class="dialog-resize-handle"
:title="$t('baseDialog.resize')"
@mousedown.stop="startResize(dialog.id, $event)"
/>
</div>
</div>
</template>
@@ -69,6 +70,9 @@ const InternalTournamentStats = defineAsyncComponent(() =>
import('./tournament/InternalTournamentStats.vue'),
);
const MIN_DIALOG_W = 280;
const MIN_DIALOG_H = 200;
export default {
components: {
MatchReportDialog,
@@ -84,11 +88,42 @@ export default {
InternalTournamentStats,
},
name: 'DialogManager',
data() {
return {
dragSession: null,
resizeSession: null,
};
},
computed: {
...mapGetters(['dialogs', 'minimizedDialogs', 'activeDialogs'])
},
methods: {
...mapActions(['closeDialog', 'minimizeDialog', 'restoreDialog', 'bringDialogToFront']),
...mapActions(['closeDialog', 'minimizeDialog', 'restoreDialog', 'bringDialogToFront', 'updateDialogBounds']),
dialogWindowStyle(dialog) {
const s = {
left: `${dialog.position.x}px`,
top: `${dialog.position.y}px`,
zIndex: dialog.zIndex,
};
if (dialog.width != null && Number.isFinite(Number(dialog.width))) {
s.width = `${Number(dialog.width)}px`;
}
if (dialog.height != null && Number.isFinite(Number(dialog.height))) {
s.height = `${Number(dialog.height)}px`;
}
return s;
},
approxDialogWidth(dialog) {
if (dialog.width != null && Number.isFinite(Number(dialog.width))) return Number(dialog.width);
return Math.round(window.innerWidth * 0.9);
},
approxDialogHeight(dialog) {
if (dialog.height != null && Number.isFinite(Number(dialog.height))) return Number(dialog.height);
return Math.round(window.innerHeight * 0.9);
},
getDialogComponent(componentName) {
const components = {
@@ -113,9 +148,91 @@ export default {
},
startDrag(dialogId, event) {
// Drag & Drop deaktiviert, da Dialog jetzt fest positioniert ist
// Dialog wird immer zentriert angezeigt
this.bringToFront(dialogId);
if (event.button !== 0) return;
if (event.target.closest('button, a, input, select, textarea, [contenteditable="true"]')) {
return;
}
const dialog = this.dialogs.find((d) => d.id === dialogId);
if (!dialog) return;
this.bringDialogToFront(dialogId);
this.dragSession = {
dialogId,
startX: event.clientX,
startY: event.clientY,
origX: dialog.position.x,
origY: dialog.position.y,
};
document.addEventListener('mousemove', this.onDragMove);
document.addEventListener('mouseup', this.onDragEnd);
event.preventDefault();
},
onDragMove(event) {
if (!this.dragSession) return;
const { dialogId, startX, startY, origX, origY } = this.dragSession;
const dialog = this.dialogs.find((d) => d.id === dialogId);
if (!dialog) return;
let nx = origX + event.clientX - startX;
let ny = origY + event.clientY - startY;
const w = this.approxDialogWidth(dialog);
const h = this.approxDialogHeight(dialog);
const pad = 8;
nx = Math.min(Math.max(pad, nx), window.innerWidth - w - pad);
ny = Math.min(Math.max(pad, ny), window.innerHeight - h - pad);
this.updateDialogBounds({ dialogId, position: { x: nx, y: ny } });
},
onDragEnd() {
this.dragSession = null;
document.removeEventListener('mousemove', this.onDragMove);
document.removeEventListener('mouseup', this.onDragEnd);
},
startResize(dialogId, event) {
if (event.button !== 0) return;
this.bringDialogToFront(dialogId);
const dialog = this.dialogs.find((d) => d.id === dialogId);
if (!dialog) return;
const el = event.currentTarget.closest('.dialog-window');
if (!el) return;
const rect = el.getBoundingClientRect();
this.resizeSession = {
dialogId,
startX: event.clientX,
startY: event.clientY,
origW: rect.width,
origH: rect.height,
};
document.addEventListener('mousemove', this.onResizeMove);
document.addEventListener('mouseup', this.onResizeEnd);
event.preventDefault();
},
onResizeMove(event) {
if (!this.resizeSession) return;
const { dialogId, startX, startY, origW, origH } = this.resizeSession;
const dialog = this.dialogs.find((d) => d.id === dialogId);
if (!dialog) return;
let w = origW + event.clientX - startX;
let h = origH + event.clientY - startY;
const maxW = window.innerWidth - dialog.position.x - 8;
const maxH = window.innerHeight - dialog.position.y - 8;
w = Math.min(Math.max(MIN_DIALOG_W, w), maxW);
h = Math.min(Math.max(MIN_DIALOG_H, h), maxH);
this.updateDialogBounds({ dialogId, width: w, height: h });
let nx = dialog.position.x;
let ny = dialog.position.y;
if (nx + w > window.innerWidth - 8) nx = Math.max(8, window.innerWidth - w - 8);
if (ny + h > window.innerHeight - 8) ny = Math.max(8, window.innerHeight - h - 8);
if (nx !== dialog.position.x || ny !== dialog.position.y) {
this.updateDialogBounds({ dialogId, position: { x: nx, y: ny } });
}
},
onResizeEnd() {
this.resizeSession = null;
document.removeEventListener('mousemove', this.onResizeMove);
document.removeEventListener('mouseup', this.onResizeEnd);
},
handleHeaderAction(action) {
@@ -187,8 +304,9 @@ export default {
},
beforeUnmount() {
// Event-Listener entfernen
window.removeEventListener('message', this.handlePostMessage);
if (this.dragSession) this.onDragEnd();
if (this.resizeSession) this.onResizeEnd();
}
};
</script>
@@ -206,8 +324,11 @@ export default {
.dialog-window {
position: absolute;
width: 90vw;
height: 90vh;
box-sizing: border-box;
min-width: 280px;
min-height: 200px;
width: min(90vw, calc(100vw - 32px));
height: min(90vh, calc(100vh - 32px));
background: white;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
@@ -215,8 +336,6 @@ export default {
display: flex;
flex-direction: column;
overflow: hidden;
left: 5vw !important;
top: 5vh !important;
}
.dialog-header {
@@ -226,8 +345,13 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
cursor: grab;
user-select: none;
flex-shrink: 0;
}
.dialog-header:active {
cursor: grabbing;
}
.dialog-title {
@@ -276,6 +400,31 @@ export default {
flex: 1;
padding: 0;
overflow-y: auto;
min-height: 0;
}
.dialog-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(
135deg,
transparent 50%,
rgba(0, 0, 0, 0.1) 50%
);
border-bottom-right-radius: 8px;
z-index: 2;
}
.dialog-resize-handle:hover {
background: linear-gradient(
135deg,
transparent 45%,
rgba(47, 122, 95, 0.35) 45%
);
}
.minimized-dialogs {
@@ -321,10 +470,8 @@ export default {
/* Responsive Design */
@media (max-width: 768px) {
.dialog-window {
width: 95vw;
height: 95vh;
left: 2.5vw !important;
top: 2.5vh !important;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
}
.minimized-dialogs {

View File

@@ -2078,7 +2078,8 @@
},
"baseDialog": {
"minimize": "Minimieren",
"close": "Schließen"
"close": "Schließen",
"resize": "Größe ändern"
},
"dialogManager": {
"noMinimizedDialogs": "Keine minimierten Dialoge",

View File

@@ -109,15 +109,39 @@ const store = createStore({
// Dialog-Mutations
openDialog(state, dialog) {
const id = ++state.dialogCounter;
let position = { x: 48, y: 48 };
if (
dialog.position &&
Number.isFinite(dialog.position.x) &&
Number.isFinite(dialog.position.y)
) {
position = { x: dialog.position.x, y: dialog.position.y };
} else if (typeof window !== 'undefined') {
position = {
x: Math.max(16, Math.round(window.innerWidth * 0.05)),
y: Math.max(16, Math.round(window.innerHeight * 0.05)),
};
}
const newDialog = {
id,
...dialog,
isMinimized: false,
zIndex: 1000 + state.dialogs.length,
position: { x: 0, y: 0 } // Position wird durch CSS festgelegt
position,
width: dialog.width != null ? dialog.width : null,
height: dialog.height != null ? dialog.height : null,
};
state.dialogs.push(newDialog);
},
updateDialogBounds(state, { dialogId, position, width, height }) {
const d = state.dialogs.find((x) => x.id === dialogId);
if (!d) return;
if (position && Number.isFinite(position.x) && Number.isFinite(position.y)) {
d.position = { x: position.x, y: position.y };
}
if (width !== undefined) d.width = width;
if (height !== undefined) d.height = height;
},
closeDialog(state, dialogId) {
state.dialogs = state.dialogs.filter(dialog => dialog.id !== dialogId);
},
@@ -219,7 +243,10 @@ const store = createStore({
},
bringDialogToFront({ commit }, dialogId) {
commit('bringDialogToFront', dialogId);
}
},
updateDialogBounds({ commit }, payload) {
commit('updateDialogBounds', payload);
},
},
getters: {
isAuthenticated: state => !!state.token,