feat(kontext,notes): cross-module handoff — save Kontext as a Note

Wires the "Als Notiz speichern" action at the bottom of the Kontext
widget (UI itself landed in 003f75f7e) to actually open Notes next
to Kontext and focus the new note:

- workbench-scenes: new addAppAfter(appId, anchorAppId). addApp()
  always appended, which pushed Notes to the far end of the
  carousel; addAppAfter inserts directly after the anchor (Kontext)
  and no-ops if the target is already open so the user's current
  position isn't yanked around.
- notes/stores/selection: new transient in-memory focus signal
  (focusedNoteId) that cross-module callers populate. Kept
  non-persistent intentionally — surviving a remount would re-open
  random notes after page loads.
- notes/ListView: $effect reads focusedNoteId, waits for the
  Dexie liveQuery to surface the just-created row, opens it in
  the inline editor, clears the focus signal, then scrolls the
  matching data-note-id element into view via queueMicrotask so
  the DOM has rendered the editor variant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 14:24:56 +02:00
parent cb384bc7ef
commit 6acb044230
3 changed files with 67 additions and 0 deletions

View file

@ -5,6 +5,7 @@
<script lang="ts">
import { useAllNotes, searchNotes, getPreview, formatRelativeTime } from './queries';
import { notesStore } from './stores/notes.svelte';
import { notesSelectionStore } from './stores/selection.svelte';
import type { Note } from './types';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
@ -59,6 +60,24 @@
}
});
// Cross-module focus signal (e.g. Kontext → "Als Notiz speichern").
// The caller populates selectionStore.focusedNoteId; we wait until
// the underlying Dexie row is visible via liveQuery (the just-created
// note may not be in `notes` on the first effect run because liveQuery
// is async), then open it in the editor and scroll it into view.
$effect(() => {
const id = notesSelectionStore.focusedNoteId;
if (!id) return;
const target = notes.find((n) => n.id === id);
if (!target) return;
startEdit(target);
notesSelectionStore.clearFocus();
queueMicrotask(() => {
const el = document.querySelector(`[data-note-id="${id}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
async function saveEdit() {
if (!editingId) return;
await notesStore.updateNote(editingId, {
@ -131,6 +150,7 @@
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="note-item editing"
data-note-id={note.id}
onkeydown={(e) => {
if (e.key === 'Escape') saveEdit();
}}
@ -158,6 +178,7 @@
<!-- Note row -->
<button
class="note-item"
data-note-id={note.id}
onclick={() => startEdit(note)}
oncontextmenu={(e) => ctxMenu.open(e, note)}
>

View file

@ -0,0 +1,26 @@
/**
* Notes focus signal transient, in-memory only.
*
* Used by cross-module actions (e.g. "Kontext → Als Notiz speichern")
* to ask the Notes ListView to open a specific note in its inline
* editor and scroll it into view. Cleared by the ListView once
* handled so the signal doesn't survive remounts.
*/
function createNotesSelectionStore() {
let focusedNoteId = $state<string | null>(null);
return {
get focusedNoteId() {
return focusedNoteId;
},
focusNote(id: string) {
focusedNoteId = id;
},
clearFocus() {
focusedNoteId = null;
},
};
}
export const notesSelectionStore = createNotesSelectionStore();

View file

@ -257,6 +257,26 @@ export const workbenchScenesStore = {
});
},
/**
* Insert `appId` directly after `anchorAppId` in the active scene.
* Used by cross-module actions like "Kontext → Als Notiz speichern",
* where the target widget should land next to the source rather than
* at the end of the carousel. If the app is already open, its
* position is left untouched (we don't want to yank a widget the
* user is already interacting with). If the anchor isn't in the
* scene, falls back to appending.
*/
async addAppAfter(appId: string, anchorAppId: string) {
await patchActiveScene((apps) => {
if (apps.some((a) => a.appId === appId)) return apps;
const anchorIdx = apps.findIndex((a) => a.appId === anchorAppId);
if (anchorIdx === -1) return [...apps, { appId }];
const next = [...apps];
next.splice(anchorIdx + 1, 0, { appId });
return next;
});
},
async removeApp(appId: string) {
await patchActiveScene((apps) => apps.filter((a) => a.appId !== appId));
},