mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
a4013d4356
commit
4f141b94e5
3 changed files with 173 additions and 44 deletions
18
Summarize.md
18
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
|
### Aenderungen 2026-03-13 (13): Experten-Profil – Bullet-Editor fuer Aufgaben + Popup-Backdrop deaktiviert
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue