feat(career): builder UX overhaul — DnD, rich text, per-item editing, richer preview
Phase 4.5 (priorities 1, 2, 5, 6). - Content tab now reads GET /api/cv/outline: each entry-based section expands to its entries with per-item hide, title/subtitle override, and rich-text bullet editing (RichTextField markdown toolbar). - Native HTML5 drag-and-drop reorder for sections AND entries (useDragReorder, zero deps), with drop-line + dim animations; arrow buttons remain for keyboard. - Preview: zoom presets (+/- , slider, Fit), measured page count with page navigation and dashed page-break indicators, an "updating…" chip, 300ms debounce. - Save indicator now distinguishes Unsaved / Saving / Saved / failed (aria-live). - Loading skeletons, better empty states, ATS-friendly theme badge, relative times in History; a11y: ARIA labels, keyboard-selectable theme cards, focus rings. - Pure helpers moveItem/wrapSelection extracted + unit-tested; tsc + build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import React, { useRef } from "react";
|
||||
|
||||
import { Box, IconButton, TextField, Tooltip } from "@mui/material";
|
||||
import FormatBoldIcon from "@mui/icons-material/FormatBold";
|
||||
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
|
||||
import FormatUnderlinedIcon from "@mui/icons-material/FormatUnderlined";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
|
||||
import { wrapSelection } from "../cvBuilder";
|
||||
|
||||
// Lightweight rich text: a plain textarea plus a markdown toolbar. Storage stays plain text
|
||||
// (**bold**, *italic*, __underline__, [text](url)); the server renderer converts the same whitelist
|
||||
// to safe HTML. No RTE dependency, no HTML sanitisation surface. Bullet lists are the field itself —
|
||||
// one line per bullet — so there is no separate list button.
|
||||
export default function RichTextField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
minRows = 2,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
minRows?: number;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const apply = (before: string, after: string, placeholderText: string) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const { text, selStart, selEnd } = wrapSelection(value, el.selectionStart, el.selectionEnd, before, after, placeholderText);
|
||||
onChange(text);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(selStart, selEnd);
|
||||
});
|
||||
};
|
||||
|
||||
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
|
||||
<Tooltip title={title}>
|
||||
<IconButton size="small" aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
|
||||
{icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", gap: 0.25, mb: 0.25 }}>
|
||||
{btn("Bold", <FormatBoldIcon fontSize="inherit" />, "**", "**", "bold text")}
|
||||
{btn("Italic", <FormatItalicIcon fontSize="inherit" />, "*", "*", "italic text")}
|
||||
{btn("Underline", <FormatUnderlinedIcon fontSize="inherit" />, "__", "__", "underlined")}
|
||||
{btn("Link", <LinkIcon fontSize="inherit" />, "[", "](https://)", "link text")}
|
||||
</Box>
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
multiline
|
||||
minRows={minRows}
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user