mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:36:38 +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
|
||||
|
||||
#### Frontend
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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 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<HTMLTextAreaElement>) => {
|
||||
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
|
||||
</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>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={styles.bulletEditorTextarea}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue