mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(workbench): auto-scroll on scene switch, unify rename to inline
Two tightly coupled UX fixes on the homepage: 1. Switching scenes via SceneAppBar used to leave the carousel parked wherever it was before — the new scene's SceneHeader appeared off-screen to the left, and the user saw a seemingly stale row of cards until they manually scrolled. Now an $effect watches activeSceneId, tracks the last seen value, and smooth-scrolls the .fokus-track to left=0 on every real change (ignoring the initial hydration tick so we don't fight the carousel's own centering). 2. Scene rename had two concurrent paths: the SceneHeader contenteditable <h1> (live DOM) and a SceneRenameDialog modal opened from the scene-pill context menu (reads from the store). If a user was mid-edit inline and right-clicked Umbenennen, the dialog opened with the pre-edit value and on save clobbered the inline change. The modal is gone. The context-menu "Umbenennen" entry now switches to the target scene, scrolls the carousel to the header, and focuses the contenteditable after a 120ms tick so the scroll has time to start. Single source of truth, single code path. SceneRenameDialog.svelte + the sceneDialog state machine in +page.svelte are removed. ConfirmDialog (used for delete) is untouched. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f06ca2c7c3
commit
db959b6f8f
2 changed files with 40 additions and 238 deletions
|
|
@ -1,208 +0,0 @@
|
|||
<!--
|
||||
SceneRenameDialog — modal for renaming a workbench scene.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
show: boolean;
|
||||
title: string;
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
confirmLabel?: string;
|
||||
onSubmit: (name: string, description: string) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
show,
|
||||
title,
|
||||
initialName = '',
|
||||
initialDescription = '',
|
||||
confirmLabel = 'Speichern',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let pending = $state(false);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
name = initialName;
|
||||
description = initialDescription;
|
||||
queueMicrotask(() => inputEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (pending || !name.trim()) return;
|
||||
pending = true;
|
||||
try {
|
||||
await onSubmit(name.trim(), description);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!show) return;
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if show}
|
||||
<div class="srd-backdrop" onclick={onCancel} role="presentation" tabindex="-1">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="srd-dialog"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-modal="true"
|
||||
aria-labelledby="srd-title"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="srd-title" class="srd-title">{title}</h3>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<label class="srd-field">
|
||||
<span class="srd-label">Name</span>
|
||||
<input
|
||||
class="srd-input"
|
||||
type="text"
|
||||
maxlength="40"
|
||||
placeholder="z.B. Deep Work"
|
||||
bind:this={inputEl}
|
||||
bind:value={name}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="srd-field">
|
||||
<span class="srd-label">Beschreibung</span>
|
||||
<textarea
|
||||
class="srd-input srd-textarea"
|
||||
maxlength="240"
|
||||
rows="3"
|
||||
placeholder="Wofür ist diese Szene gedacht?"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="srd-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="srd-btn srd-btn-cancel"
|
||||
onclick={onCancel}
|
||||
disabled={pending}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="srd-btn srd-btn-primary" disabled={pending || !name.trim()}>
|
||||
{pending ? '…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.srd-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: hsl(0 0% 0% / 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 1rem;
|
||||
}
|
||||
.srd-dialog {
|
||||
background: hsl(var(--color-card));
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
animation: srd-pop 0.18s ease-out;
|
||||
}
|
||||
@keyframes srd-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.srd-title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.srd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.srd-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.srd-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: white;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.srd-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.srd-textarea {
|
||||
resize: vertical;
|
||||
min-height: 4rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.srd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.srd-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.srd-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.srd-btn-cancel {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.srd-btn-cancel:hover:not(:disabled) {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
.srd-btn-primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
.srd-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
import AppPage from '$lib/components/workbench/AppPage.svelte';
|
||||
import AppPagePicker from '$lib/components/workbench/AppPagePicker.svelte';
|
||||
import SceneAppBar from '$lib/components/workbench/SceneAppBar.svelte';
|
||||
import SceneRenameDialog from '$lib/components/workbench/scenes/SceneRenameDialog.svelte';
|
||||
import SceneHeader from '$lib/components/workbench/scenes/SceneHeader.svelte';
|
||||
import ConfirmDialog from '$lib/components/workbench/scenes/ConfirmDialog.svelte';
|
||||
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
|
||||
|
|
@ -120,6 +119,32 @@
|
|||
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
|
||||
function scrollCarouselToStart() {
|
||||
// Reset the horizontal scroll position so the SceneHeader (first
|
||||
// item in the track) is visible — used after switching scenes so
|
||||
// the user lands on the new scene's intro rather than wherever
|
||||
// the previous scene was scrolled to.
|
||||
const track = document.querySelector<HTMLElement>('.fokus-track');
|
||||
track?.scrollTo({ left: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// ── Reset scroll on scene switch ────────────────────────
|
||||
// Watches activeSceneId. On the first tick (initial mount) we skip
|
||||
// the scroll because that's just the hydration pass — otherwise we
|
||||
// would fight the carousel's own centre-the-first-card layout.
|
||||
let lastSceneId: string | null = null;
|
||||
$effect(() => {
|
||||
const id = activeSceneId;
|
||||
if (lastSceneId === null) {
|
||||
lastSceneId = id;
|
||||
return;
|
||||
}
|
||||
if (id !== lastSceneId) {
|
||||
lastSceneId = id;
|
||||
scrollCarouselToStart();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Keyboard shortcuts 1-9 / 0 ─────────────────────────
|
||||
// 1-9 scroll to the Nth open app in the active scene.
|
||||
// 0 opens the new-app picker (which scrolls itself into view).
|
||||
|
|
@ -269,30 +294,25 @@
|
|||
}
|
||||
|
||||
// ── Scene CRUD dialogs ──────────────────────────────────
|
||||
type SceneDialogMode = {
|
||||
kind: 'rename';
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
let sceneDialog = $state<SceneDialogMode | null>(null);
|
||||
let sceneToDelete = $state<{ id: string; name: string } | null>(null);
|
||||
|
||||
function handleRequestRename(id: string) {
|
||||
// Unified rename path: scroll the carousel to the scene header
|
||||
// and focus its contenteditable <h1>. Previously opened a modal
|
||||
// dialog that read from the store while the live header might
|
||||
// already hold unsaved typing — the two paths could overwrite
|
||||
// each other. Inline is the single source of truth now.
|
||||
const scene = scenes.find((s) => s.id === id);
|
||||
if (!scene) return;
|
||||
sceneDialog = {
|
||||
kind: 'rename',
|
||||
id,
|
||||
name: scene.name,
|
||||
description: scene.description ?? '',
|
||||
};
|
||||
}
|
||||
async function handleSubmitSceneDialog(name: string, description: string) {
|
||||
const mode = sceneDialog;
|
||||
if (!mode) return;
|
||||
await workbenchScenesStore.renameScene(mode.id, name, description.trim() || null);
|
||||
sceneDialog = null;
|
||||
if (id !== activeSceneId) workbenchScenesStore.setActiveScene(id);
|
||||
scrollCarouselToStart();
|
||||
// Next tick: the header is mounted + the scroll has started;
|
||||
// querying now lands on the active scene's h1 which
|
||||
// contenteditable plaintext-only focuses cleanly.
|
||||
setTimeout(() => {
|
||||
const h1 = document.querySelector<HTMLElement>('.scene-header .scene-name[contenteditable]');
|
||||
h1?.focus();
|
||||
}, 120);
|
||||
}
|
||||
function handleDuplicateScene(id: string) {
|
||||
workbenchScenesStore.duplicateScene(id);
|
||||
|
|
@ -358,16 +378,6 @@
|
|||
onClose={() => ctxMenu.close()}
|
||||
/>
|
||||
|
||||
<SceneRenameDialog
|
||||
show={sceneDialog !== null}
|
||||
title="Szene bearbeiten"
|
||||
initialName={sceneDialog?.name ?? ''}
|
||||
initialDescription={sceneDialog?.description ?? ''}
|
||||
confirmLabel="Speichern"
|
||||
onSubmit={handleSubmitSceneDialog}
|
||||
onCancel={() => (sceneDialog = null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
show={sceneToDelete !== null}
|
||||
title="Szene löschen"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue