feat(frontend): bullet-editor – nummerierte Liste + Tab/Shift+Tab Einrueckung

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 20:40:50 +01:00
parent a4013d4356
commit 4f141b94e5
3 changed files with 173 additions and 44 deletions

View file

@ -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 ### Aenderungen 2026-03-13 (13): Experten-Profil Bullet-Editor fuer Aufgaben + Popup-Backdrop deaktiviert
#### Frontend #### Frontend

View file

@ -479,6 +479,24 @@
box-shadow: none; 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 { .checkboxRow {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -34,7 +34,36 @@ const INDUSTRIES = [
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i); 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({ function BulletEditor({
value, value,
onChange, onChange,
@ -50,65 +79,119 @@ function BulletEditor({
}) { }) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(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 toggleBullet = useCallback(() => {
const ta = textareaRef.current; const ta = textareaRef.current;
if (!ta) return; if (!ta) return;
const start = ta.selectionStart; const pos = ta.selectionStart;
const val = ta.value; const v = ta.value;
const lineStart = val.lastIndexOf('\n', start - 1) + 1; const { s, e, indent, isBullet, isNum, num, body } = blLineAt(v, pos);
const lineEnd = val.indexOf('\n', start);
const line = val.substring(lineStart, lineEnd === -1 ? val.length : lineEnd);
let newVal: string; if (isBullet) {
let newCursor: number; // Remove bullet
applyLineMutation(ta, v, s, e, indent + body, Math.max(s + indent.length, pos - 2));
if (line.startsWith('• ')) { } else if (isNum) {
// Remove bullet from current line // Convert numbered → bullet
newVal = const oldNumLen = String(num).length + 2; // "N. "
val.substring(0, lineStart) + applyLineMutation(ta, v, s, e, indent + '• ' + body, Math.max(s + indent.length + 2, pos - oldNumLen + 2));
line.substring(2) +
val.substring(lineEnd === -1 ? val.length : lineEnd);
newCursor = Math.max(lineStart, start - 2);
} else { } else {
// Add bullet to current line // Add bullet
newVal = val.substring(0, lineStart) + '• ' + val.substring(lineStart); applyLineMutation(ta, v, s, e, indent + '• ' + body, pos + 2);
newCursor = start + 2;
} }
}, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps
onChange(newVal); // ── Toggle numbered list ────────────────────────────────────────────────────
requestAnimationFrame(() => { const toggleNumbered = useCallback(() => {
ta.selectionStart = ta.selectionEnd = newCursor; const ta = textareaRef.current;
ta.focus(); if (!ta) return;
}); const pos = ta.selectionStart;
}, [onChange]); 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( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter') return;
const ta = textareaRef.current; const ta = textareaRef.current;
if (!ta) return; if (!ta) return;
const start = ta.selectionStart; const pos = ta.selectionStart;
const val = ta.value; const v = ta.value;
const lineStart = val.lastIndexOf('\n', start - 1) + 1; const info = blLineAt(v, pos);
const lineContent = val.substring(lineStart, start);
if (lineContent === '• ') { // Tab → indent (+2 spaces at line start)
// Empty bullet: remove it and exit list // Shift+Tab → outdent (-2 spaces from line start)
if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const newVal = val.substring(0, lineStart) + val.substring(start); if (e.shiftKey) {
onChange(newVal); 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(() => { requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = lineStart; ta.selectionStart = ta.selectionEnd = info.s + info.indent.length;
}); });
} else if (lineContent.startsWith('• ')) { } else if (info.isBullet) {
// Continue bullet list on new line // Continue bullet on next line (preserving indentation)
e.preventDefault(); e.preventDefault();
const insert = '\n• '; const ins = '\n' + info.indent + '• ';
const newVal = onChange(v.substring(0, pos) + ins + v.substring(ta.selectionEnd));
val.substring(0, start) + insert + val.substring(ta.selectionEnd);
onChange(newVal);
requestAnimationFrame(() => { 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" type="button"
className={styles.bulletBtn} className={styles.bulletBtn}
onClick={toggleBullet} onClick={toggleBullet}
title="Aufzählungspunkt ein/aus (☰)" title="Aufzählungsliste ein/aus (• Punkt)"
> >
Liste Liste
</button> </button>
<button
type="button"
className={styles.bulletBtn}
onClick={toggleNumbered}
title="Nummerierte Liste ein/aus (1. 2. 3.)"
>
1. Liste
</button>
<span className={styles.bulletToolbarSep} />
<span className={styles.bulletToolbarHint}>Tab = einrücken · Tab = ausrücken</span>
</div> </div>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className={styles.bulletEditorTextarea} className={styles.bulletEditorTextarea}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(ev) => onChange(ev.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
maxLength={maxLength} maxLength={maxLength}