· Webentwicklung · 7 minuten Lesezeit
Notiz und Tags beim Screenshot-Speichern im Shadow DOM Overlay
Notiz und Tags werden direkt im Capture-Overlay eingegeben, in Qdrant persistiert und ins Embedding eingebettet. Wie Typen, Shadow DOM und ein Chip-Input das umsetzen.

Inhalt
- Warum Notiz und Tags?
- Wo die Eingabe stattfindet
- Der Typ-Umbau
- Das Shadow DOM Chip-UI
- Tags anlegen mit commitTagInput
- Das Keyboard-Routing
- Note und Tags in den Payload injizieren
- Embedding und LLM-Kontext
- Alle Artikel der Serie
Warum Notiz und Tags?
Das RAG-System lebt von der Qualität der Embeddings. Je mehr semantischen Kontext ein Qdrant-Punkt enthält, desto besser findet eine Suchanfrage den richtigen Post.
Instagram liefert bereits Alt-Texte, Captions und Channel-Namen. Das reicht für Inhaltssuche. Für intentionale Suche fehlt aber etwas: Was dachte der Nutzer beim Speichern? Warum war dieser Post relevant? Zu welchem Thema gehört er in seinem mentalen Modell?
Eine freie Notiz und strukturierte Tags füllen diese Lücke.
Wo die Eingabe stattfindet
Zwei Orte kommen in Frage: das Capture-Overlay oder ein separates Formular danach.
Das Overlay hat einen klaren Vorteil. Der Nutzer steht in diesem Moment mit Aufmerksamkeit beim Post. Der Kontext ist frisch. Erzwingt das System eine separate Seite, gehen Notizen typischerweise verloren.
Das Overlay ist außerdem bereits da. Es zeigt die Vorschau und wartet auf eine Entscheidung. Wie das Shadow DOM Overlay für die Vorschau entstanden ist, habe ich bereits beschrieben. Notiz und Tags lassen sich hier nahtlos integrieren, ohne den Ablauf zu unterbrechen.
Noch wichtiger: Karussell-Posts und Einzelposts durchlaufen dasselbe Overlay. Die Carousel-Erfassung läuft nach der Bestätigung im Hintergrund. Der Nutzer interagiert also in beiden Fällen mit demselben UI-Element.
Der Typ-Umbau
Ausgangspunkt ist ShowPreviewResponse. Vorher war das ein simples boolean-Wrapper-Objekt:
export type ShowPreviewResponse = {
confirmed: boolean;
};Danach trägt es Note und Tags aus dem Overlay:
export type ShowPreviewResponse = {
confirmed: boolean;
note: string;
tags: string[];
};InstagramPostMetadata und InstagramAnalysisPayload.metadata bekommen zwei neue Felder:
note: string | null; // null if the user did not enter anything
tags: string[]; // empty array when no tags were setnull für die Notiz, weil null in der Payload eine semantische Aussage ist: kein Wert vorhanden. Ein leerer String wäre zweideutig. Tags starten als leeres Array, weil das einfacher zu iterieren ist.
Das Shadow DOM Chip-UI
Das Overlay nutzt Shadow DOM für volle Stilkapselung. Kein CSS der Seite dringt rein, kein CSS des Overlays dringt raus.
Die Notiz ist ein einfacher <input type="text">. Tags sind Chips: kleine Pills, die im Container neben einem Text-Input erscheinen.
const tagsBox = document.createElement('div');
tagsBox.className = 'tags-box';
const tagInput = document.createElement('input');
tagInput.className = 'tag-input';
tagInput.placeholder = 'Tag hinzufügen …';
tagsBox.appendChild(tagInput);Das tags-box-Element rendert als flex-wrap-Container. Chips landen links, der Input rechts. Klick auf den Container gibt dem tagInput den Fokus. Dieses Verhalten kennt der Nutzer von anderen Tag-Interfaces.
.tags-box {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 8px;
cursor: text;
}
.tags-box:focus-within {
border-color: #6366f1;
}focus-within macht den Rahmen lila, sobald das Input-Feld Fokus hat. Pure CSS, kein JavaScript.
Tags anlegen mit commitTagInput
Die Chips leben in einem lokalen string[]. renderChips() baut den DOM daraus neu auf.
const currentTags: string[] = [];
const renderChips = () => {
tagsBox.querySelectorAll('.chip').forEach((c) => c.remove());
currentTags.forEach((tag, index) => {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = tag;
const removeBtn = document.createElement('button');
removeBtn.className = 'chip-remove';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
currentTags.splice(index, 1);
renderChips();
});
chip.appendChild(removeBtn);
tagsBox.insertBefore(chip, tagInput);
});
};
const commitTagInput = () => {
const val = tagInput.value.replace(/,/g, '').trim().toLowerCase();
if (!val) return;
currentTags.push(val);
tagInput.value = '';
renderChips();
};commitTagInput() läuft bei Enter (mit Text im Feld), bei Komma und beim Speichern. Der Tag landet als Lowercase im Array. Die Komma-Bereinigung im replace() fängt den Fall ab, dass der Nutzer ein Komma als Abschluss eingibt.
Das Keyboard-Routing
Vier Tastenkombinationen brauchen bewusstes Routing:
| Taste | Kontext | Effekt |
|---|---|---|
Enter | Notiz-Feld hat Fokus | Fokus zum Tag-Input |
Enter + Text | Tag-Input hat Fokus | Tag bestätigen |
Enter + leer | Tag-Input hat Fokus | Overlay bestätigen |
Ctrl+Enter | Überall | Overlay bestätigen |
Im Note-Input:
noteInput.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
tagInput.focus();
}
});Im Tag-Input:
tagInput.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === ',' || (e.key === 'Enter' && tagInput.value.trim())) {
e.preventDefault();
e.stopPropagation();
commitTagInput();
return;
}
if (e.key === 'Backspace' && tagInput.value === '' && currentTags.length > 0) {
e.preventDefault();
e.stopPropagation();
currentTags.pop();
renderChips();
return;
}
// Enter on empty input: propagates to backdrop handler and confirms the overlay
});Backspace auf leerem Tag-Input entfernt den letzten Chip. Das ist das Standard-Verhalten aus allen modernen Tag-Inputs und ist sofort intuitiv.
Note und Tags in den Payload injizieren
Das Background-Skript ruft nach der Overlay-Bestätigung buildPayloadContext() auf, der die Instagram-Metadaten aus dem DOM lädt. Note und Tags kommen jedoch aus dem Overlay-Response. Sie stehen fest, bevor die DOM-Extraktion beginnt.
const { note, tags } = previewResponse;
const payloadContext = await buildPayloadContext(tab.id);
if (payloadContext.platform === 'instagram') {
payloadContext.metadata.note = note || null;
payloadContext.metadata.tags = tags;
}
payload = await createPayload(croppedScreenshot.dataUrl, boundsResponse.bounds, payloadContext);note || null konvertiert den leeren String zu null. Der leere String kommt aus dem Overlay, wenn der Nutzer nichts eingetragen hat. null ist im Payload aussagekräftiger.
Embedding und LLM-Kontext
Das Backend übernimmt beide Felder in den Embedding-Text und in den LLM-Kontext für RAG-Antworten. Wie daraus Vektoren in Qdrant werden, beschreibe ich im Artikel über den Aufbau des Embedding-Systems.
// ingest.ts: buildEmbeddingText
const parts = [channel ?? '', caption ?? '', altTexts.join(' '), note ?? '', tags.join(' ')].filter(Boolean);
return parts.join(' ').trim() || 'instagram post';Das ist wichtig. Die Notiz und die Tags sind jetzt Teil des Vektors. Eine Suchanfrage nach dem Tag-Begriff oder dem Notiz-Inhalt trifft direkt auf den richtigen Punkt.
// query.ts - LLM context
if (m.note) lines.push(`Note: ${m.note}`);
if (m.tags?.length) lines.push(`Tags: ${m.tags.join(', ')}`);Der LLM bekommt die Note und Tags als explizite Felder. Er kann also direkt darauf antworten, wenn der Nutzer fragt: “Zeig mir alle Posts, die ich mit dem Tag ‘inspiration’ gespeichert habe.”

Abbildung: Datenfluss vom Overlay bis Qdrant: Note und Tags werden durch alle Schichten bis zum finalen Payload weitergereicht.
Das Diagramm zeigt den kompletten Fluss: Nutzer tippt im Overlay, Note und Tags landen im PreviewResult, Background injiziert sie in die Instagram-Metadaten, createPayload baut den finalen Payload, Backend embeds und speichert alles gemeinsam in Qdrant.
Alle Artikel der Serie
Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesenInstagram Caption mit MutationObserver vollständig laden: Artikel lesen
Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
Notiz und Tags beim Screenshot-Speichern (dieser Artikel)
Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
React Frontend mit react-oidc-context und Zitadel: Artikel lesen
Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen
Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen
Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen
Frontend gesund machen wenn der nginx Healthcheck an localhost scheitert: Artikel lesen
Du baust ein Datenerfassungssystem auf Basis von Browser Extensions mit komplexen DOM-Interaktionen? Lass uns das gemeinsam einschätzen.



