From 4f141b94e560a90248a41ec7638bc122efc4edd9 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Fri, 13 Mar 2026 20:40:50 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20bullet-editor=20=E2=80=93=20n?= =?UTF-8?q?ummerierte=20Liste=20+=20Tab/Shift+Tab=20Einrueckung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Funktion blLineAt() auf Modul-Ebene: parst Zeile an Cursor-Position (Einrueckung, Bullet/Nummeriert, Zahl, Zeileninhalt) ohne Closure-Probleme - Neuer Toolbar-Button "1. Liste": toggled nummerierte Liste (1./2./3.); wandelt Bullet→Nummeriert und Nummeriert→Bullet automatisch um - Tab-Taste: fuegt 2 Leerzeichen am Zeilenanfang ein (Einrueckung) - Shift+Tab: entfernt bis zu 2 Leerzeichen (Ausrueckung) - Enter in nummerierter Liste: setzt naechste Zeile mit N+1 fort - Enter auf leerem Listenelement: beendet die Liste (Bullet + Nummeriert) - Enter beruecksichtigt Einrueckung bei Bullets - CSS: bulletToolbarSep (Trennlinie) + bulletToolbarHint (Keyboard-Hinweis) Co-Authored-By: Claude Sonnet 4.6 --- Summarize.md | 18 ++ .../src/profile/ExpertProfileTab.module.css | 18 ++ .../src/profile/sections/ProjectModal.tsx | 181 +++++++++++++----- 3 files changed, 173 insertions(+), 44 deletions(-) diff --git a/Summarize.md b/Summarize.md index fbab242..5b0bd20 100644 --- a/Summarize.md +++ b/Summarize.md @@ -6,6 +6,24 @@ --- +### Aenderungen 2026-03-13 (14): Experten-Profil – BulletEditor mit nummerierter Liste + Tab-Einrueckung + +#### Frontend +- `profile/sections/ProjectModal.tsx` — BulletEditor erweitert: + - Neuer Button "1. Liste": toggled nummerierte Liste (1. 2. 3.) ein/aus; konvertiert auch Bullet→Nummeriert und umgekehrt + - Tab-Taste: rueckt aktuelle Zeile um 2 Leerzeichen ein; Shift+Tab nimmt sie wieder raus + - Enter in nummerierter Liste: naechste Zeile erhaelt automatisch naechste Zahl (z.B. "3. " → "4. ") + - Enter in leerer Listenelement: beendet die Liste (Bullet oder Nummeriert) + - Enter beruecksichtigt Einrueckung (eingerueckte " • " → naechste Zeile ebenfalls " • ") + - Modul-Level-Hilfsfunktion `blLineAt()`: parst Zeile an Cursor-Position + - Toolbar-Hinweistext: "Tab = einruecken · Shift+Tab = ausruecken" +- `profile/ExpertProfileTab.module.css` — `.bulletToolbarSep` (vertikale Linie), `.bulletToolbarHint` (Hinweistext) + +#### Deployment-Hinweis (Schritt 14) +- Rebuild + Restart: nur frontend + +--- + ### Aenderungen 2026-03-13 (13): Experten-Profil – Bullet-Editor fuer Aufgaben + Popup-Backdrop deaktiviert #### Frontend diff --git a/packages/frontend/src/profile/ExpertProfileTab.module.css b/packages/frontend/src/profile/ExpertProfileTab.module.css index b2a5098..f2263ea 100644 --- a/packages/frontend/src/profile/ExpertProfileTab.module.css +++ b/packages/frontend/src/profile/ExpertProfileTab.module.css @@ -479,6 +479,24 @@ box-shadow: none; } +.bulletToolbarSep { + display: inline-block; + width: 1px; + height: 1.125rem; + background: var(--color-border); + align-self: center; + flex-shrink: 0; + margin: 0 0.125rem; +} + +.bulletToolbarHint { + font-size: 0.75rem; + color: var(--color-text-muted); + align-self: center; + user-select: none; + white-space: nowrap; +} + .checkboxRow { display: flex; align-items: center; diff --git a/packages/frontend/src/profile/sections/ProjectModal.tsx b/packages/frontend/src/profile/sections/ProjectModal.tsx index 33f918c..c0ac9f5 100644 --- a/packages/frontend/src/profile/sections/ProjectModal.tsx +++ b/packages/frontend/src/profile/sections/ProjectModal.tsx @@ -34,7 +34,36 @@ const INDUSTRIES = [ const currentYear = new Date().getFullYear(); const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i); -// Lightweight bullet-point editor — no external dependencies +// ─── BulletEditor helpers (module-level, pure) ─────────────────────────────── + +/** Parse the line at cursor position into its structural components */ +function blLineAt(val: string, pos: number) { + const s = val.lastIndexOf('\n', pos - 1) + 1; + const eIdx = val.indexOf('\n', pos); + const e = eIdx === -1 ? val.length : eIdx; + const text = val.substring(s, e); + const bm = text.match(/^( *)(• )([\s\S]*)$/); + const nm = text.match(/^( *)(\d+)\. ([\s\S]*)$/); + return { + s, + e, + text, + indent: bm?.[1] ?? nm?.[1] ?? (text.match(/^ */)?.[0] ?? ''), + isBullet: !!bm, + isNum: !!nm, + num: nm ? parseInt(nm[2]) : 0, + body: bm ? bm[3] : nm ? nm[3] : text.replace(/^ */, ''), + }; +} + +// ─── BulletEditor component ─────────────────────────────────────────────────── + +/** + * Lightweight rich-text-like editor for task lists. + * Supports bullet lists (• ), numbered lists (1. 2. 3.), + * Tab/Shift+Tab indentation, and auto-continuation on Enter. + * No external dependencies — plain textarea with smart keyboard handling. + */ function BulletEditor({ value, onChange, @@ -50,65 +79,119 @@ function BulletEditor({ }) { const textareaRef = useRef(null); + /** Replace the current line and reposition cursor */ + function applyLineMutation( + ta: HTMLTextAreaElement, + v: string, + s: number, + e: number, + newLine: string, + newPos: number, + ) { + onChange(v.substring(0, s) + newLine + v.substring(e)); + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = newPos; + ta.focus(); + }); + } + + // ── Toggle bullet list ────────────────────────────────────────────────────── const toggleBullet = useCallback(() => { const ta = textareaRef.current; if (!ta) return; - const start = ta.selectionStart; - const val = ta.value; - const lineStart = val.lastIndexOf('\n', start - 1) + 1; - const lineEnd = val.indexOf('\n', start); - const line = val.substring(lineStart, lineEnd === -1 ? val.length : lineEnd); + const pos = ta.selectionStart; + const v = ta.value; + const { s, e, indent, isBullet, isNum, num, body } = blLineAt(v, pos); - let newVal: string; - let newCursor: number; - - if (line.startsWith('• ')) { - // Remove bullet from current line - newVal = - val.substring(0, lineStart) + - line.substring(2) + - val.substring(lineEnd === -1 ? val.length : lineEnd); - newCursor = Math.max(lineStart, start - 2); + if (isBullet) { + // Remove bullet + applyLineMutation(ta, v, s, e, indent + body, Math.max(s + indent.length, pos - 2)); + } else if (isNum) { + // Convert numbered → bullet + const oldNumLen = String(num).length + 2; // "N. " + applyLineMutation(ta, v, s, e, indent + '• ' + body, Math.max(s + indent.length + 2, pos - oldNumLen + 2)); } else { - // Add bullet to current line - newVal = val.substring(0, lineStart) + '• ' + val.substring(lineStart); - newCursor = start + 2; + // Add bullet + applyLineMutation(ta, v, s, e, indent + '• ' + body, pos + 2); } + }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps - onChange(newVal); - requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = newCursor; - ta.focus(); - }); - }, [onChange]); + // ── Toggle numbered list ──────────────────────────────────────────────────── + const toggleNumbered = useCallback(() => { + const ta = textareaRef.current; + if (!ta) return; + const pos = ta.selectionStart; + const v = ta.value; + const { s, e, indent, isBullet, isNum, num, body } = blLineAt(v, pos); + if (isNum) { + // Remove numbered + const oldNumLen = String(num).length + 2; + applyLineMutation(ta, v, s, e, indent + body, Math.max(s + indent.length, pos - oldNumLen)); + } else if (isBullet) { + // Convert bullet → numbered + applyLineMutation(ta, v, s, e, indent + '1. ' + body, s + indent.length + 3); + } else { + // Add numbered + applyLineMutation(ta, v, s, e, indent + '1. ' + body, pos + 3); + } + }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Keyboard handler: Tab/Shift+Tab + Enter ───────────────────────────────── const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key !== 'Enter') return; const ta = textareaRef.current; if (!ta) return; - const start = ta.selectionStart; - const val = ta.value; - const lineStart = val.lastIndexOf('\n', start - 1) + 1; - const lineContent = val.substring(lineStart, start); + const pos = ta.selectionStart; + const v = ta.value; + const info = blLineAt(v, pos); - if (lineContent === '• ') { - // Empty bullet: remove it and exit list + // Tab → indent (+2 spaces at line start) + // Shift+Tab → outdent (-2 spaces from line start) + if (e.key === 'Tab') { e.preventDefault(); - const newVal = val.substring(0, lineStart) + val.substring(start); - onChange(newVal); + if (e.shiftKey) { + if (info.text.startsWith(' ')) { + onChange(v.substring(0, info.s) + info.text.substring(2) + v.substring(info.e)); + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = Math.max(info.s, pos - 2); + }); + } + } else { + onChange(v.substring(0, info.s) + ' ' + v.substring(info.s)); + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = pos + 2; + }); + } + return; + } + + if (e.key !== 'Enter') return; + + const isEmptyItem = (info.isBullet || info.isNum) && info.body.trim() === ''; + + if (isEmptyItem) { + // Empty list item → exit list, keep indentation only + e.preventDefault(); + onChange(v.substring(0, info.s) + info.indent + v.substring(info.e)); requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = lineStart; + ta.selectionStart = ta.selectionEnd = info.s + info.indent.length; }); - } else if (lineContent.startsWith('• ')) { - // Continue bullet list on new line + } else if (info.isBullet) { + // Continue bullet on next line (preserving indentation) e.preventDefault(); - const insert = '\n• '; - const newVal = - val.substring(0, start) + insert + val.substring(ta.selectionEnd); - onChange(newVal); + const ins = '\n' + info.indent + '• '; + onChange(v.substring(0, pos) + ins + v.substring(ta.selectionEnd)); requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = start + insert.length; + ta.selectionStart = ta.selectionEnd = pos + ins.length; + }); + } else if (info.isNum) { + // Continue numbered list with incremented number + e.preventDefault(); + const ins = '\n' + info.indent + String(info.num + 1) + '. '; + onChange(v.substring(0, pos) + ins + v.substring(ta.selectionEnd)); + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = pos + ins.length; }); } }, @@ -122,16 +205,26 @@ function BulletEditor({ type="button" className={styles.bulletBtn} onClick={toggleBullet} - title="Aufzählungspunkt ein/aus (☰)" + title="Aufzählungsliste ein/aus (• Punkt)" > ☰ Liste + + + Tab = einrücken · ⇧Tab = ausrücken